diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index c327a78..83d690d 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -34,6 +34,19 @@ updates:
- "major"
patterns:
- "*"
+ # These are pinned as version ranges inside a TargetFramework-conditional
+ # ItemGroup in Directory.Packages.props (parallel net10.0-stable/
+ # net11.0-preview lines - see the comment there). Dependabot can't
+ # reliably resolve/update a conditional range, so it's ignored here and
+ # bumped by hand instead.
+ ignore:
+ - dependency-name: "Microsoft.EntityFrameworkCore.Relational"
+ - dependency-name: "Microsoft.EntityFrameworkCore.Sqlite"
+ - dependency-name: "Microsoft.Extensions.Identity.Core"
+ - dependency-name: "Microsoft.Extensions.DependencyInjection"
+ - dependency-name: "Microsoft.Extensions.Hosting"
+ - dependency-name: "Microsoft.Extensions.Configuration"
+ - dependency-name: "EntityFrameworkCore.DynamoDb"
- package-ecosystem: "dotnet-sdk"
directory: "/"
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0aec333..fefa37a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -9,17 +9,6 @@
Microsoft.Data.Sqlite.Core 10.0.9 pulls in - flagged NU1903 high
severity (GHSA-2m69-gcr7-jv3q). -->
-
-
-
-
+
+
+
+ [10.0.10, 11.0.0)
+ [11.0.0-preview.6.26359.118, 12.0.0-a)
+
-
-
-
-
+
+
+
+
-
-
+
+
+
+
+
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SharpMud.Ruleset.Basic/BasicBehaviorMappingContributor.cs b/src/SharpMud.Ruleset.Basic/BasicBehaviorMappingContributor.cs
new file mode 100644
index 0000000..2b392c5
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/BasicBehaviorMappingContributor.cs
@@ -0,0 +1,23 @@
+using Microsoft.EntityFrameworkCore;
+using SharpMud.Persistence;
+
+namespace SharpMud.Ruleset.Basic;
+
+// Registers this package's own EF Core mapping for BasicStatsBehavior -
+// scoped to this assembly's own IEntityTypeConfiguration<> types, same
+// pattern as RpgBehaviorMappingContributor/ClassicBehaviorMappingContributor.
+// Without this, a Basic world/player carrying BasicStatsBehavior hits the
+// same unmapped TPH discriminator subtype problem CombatantBehavior would
+// have without RpgBehaviorMappingContributor.
+///
+/// This package's - registers 's EF Core mapping. Registered automatically
+/// by AddSharpMudBasicRuleset(...); a consumer doesn't need to
+/// register it themselves.
+///
+public sealed class BasicBehaviorMappingContributor : IBehaviorMappingContributor
+{
+ /// Applies every IEntityTypeConfiguration<T> in this assembly - currently just 's.
+ public void ConfigureBehaviors(ModelBuilder modelBuilder) =>
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(BasicBehaviorMappingContributor).Assembly);
+}
diff --git a/src/SharpMud.Ruleset.Basic/BasicCombatOutcomeHandler.cs b/src/SharpMud.Ruleset.Basic/BasicCombatOutcomeHandler.cs
new file mode 100644
index 0000000..2c97ca7
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/BasicCombatOutcomeHandler.cs
@@ -0,0 +1,70 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic;
+
+///
+/// Basic's - awards on a win, applies a flat XP-loss/
+/// HP-halving death penalty on a loss (same shape as Classic's, deliberately
+/// simple), and always respawns at the world's starting room. Basic has no
+/// "hub room" concept of its own -
+/// already is one.
+///
+public sealed class BasicCombatOutcomeHandler : ICombatOutcomeHandler
+{
+ private readonly WorldContext _worldContext;
+
+ /// Creates the handler against the shared (for the respawn destination).
+ public BasicCombatOutcomeHandler(WorldContext worldContext)
+ {
+ _worldContext = worldContext;
+ }
+
+ ///
+ public async Task OnVictoryAsync(Thing victor, Thing defeated, CancellationToken ct)
+ {
+ var stats = victor.FindBehavior();
+ var combatant = defeated.FindBehavior();
+ if (stats is null || combatant is null)
+ return;
+
+ stats.Experience += combatant.ExperienceReward;
+
+ var session = victor.FindBehavior()?.Session;
+ if (session is not null)
+ await session.WriteLineAsync($"You gain {combatant.ExperienceReward} experience.", ct);
+ }
+
+ ///
+ public async Task OnDefeatAsync(Thing defeated, Thing victor, CancellationToken ct)
+ {
+ // Respawn HP fraction is an open item (docs/combat.md); 50% is a
+ // placeholder, matching Classic's. BasicStatsBehavior carries no HP
+ // of its own (just Level/Experience) - CombatantBehavior.CurrentHitPoints
+ // is the only combat HP a Basic character has, and CombatManager
+ // already reset it to full before calling this method, so halving
+ // it here is what makes the penalty real rather than a no-op.
+ var combatant = defeated.FindBehavior();
+ if (combatant is not null)
+ combatant.CurrentHitPoints = Math.Max(1, combatant.MaxHitPoints / 2);
+
+ var stats = defeated.FindBehavior();
+ if (stats is not null)
+ {
+ // XP-loss death penalty (docs/combat.md decision) - exact
+ // percentage is still an open item; 10% is a placeholder,
+ // matching Classic's.
+ long xpLoss = (long)(stats.Experience * 0.10);
+ stats.Experience = Math.Max(0, stats.Experience - xpLoss);
+
+ var session = defeated.FindBehavior()?.Session;
+ if (session is not null)
+ await session.WriteLineAsync($"You lose {xpLoss} experience and awaken back in the clearing.", ct);
+ }
+
+ return _worldContext.StartingRoom;
+ }
+}
diff --git a/src/SharpMud.Ruleset.Basic/BasicPlayerFactory.cs b/src/SharpMud.Ruleset.Basic/BasicPlayerFactory.cs
new file mode 100644
index 0000000..bdddf78
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/BasicPlayerFactory.cs
@@ -0,0 +1,50 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic;
+
+///
+/// The Basic ruleset's - without this, / (which constructor-inject
+/// ) can't create a fresh CLI/Telnet player at
+/// all, and the quick-start fails at first login.
+///
+public sealed class BasicPlayerFactory : IPlayerFactory
+{
+ private readonly BasicRulesetOptions _options;
+
+ /// Creates the factory against the configured (starting HP/AC/damage).
+ public BasicPlayerFactory(BasicRulesetOptions options)
+ {
+ _options = options;
+ }
+
+ ///
+ /// Creates a player with , , , and
+ /// (seeded from ), adds it to , and registers it with .
+ ///
+ 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 });
+ player.Behaviors.Add(new EquippedBehavior());
+ player.Behaviors.Add(new BasicStatsBehavior());
+ player.Behaviors.Add(new CombatantBehavior
+ {
+ MaxHitPoints = _options.StartingHitPoints,
+ CurrentHitPoints = _options.StartingHitPoints,
+ ArmorClass = _options.StartingArmorClass,
+ DamageMin = _options.StartingDamageMin,
+ DamageMax = _options.StartingDamageMax,
+ });
+
+ startingRoom.Add(player);
+ world.Register(player);
+ return player;
+ }
+}
diff --git a/src/SharpMud.Ruleset.Basic/BasicRulesetOptions.cs b/src/SharpMud.Ruleset.Basic/BasicRulesetOptions.cs
new file mode 100644
index 0000000..8595f28
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/BasicRulesetOptions.cs
@@ -0,0 +1,37 @@
+namespace SharpMud.Ruleset.Basic;
+
+///
+/// Tunable starting numbers for a fresh player character, configured via
+/// AddSharpMudBasicRuleset(...)'s callback - not IOptions<T>/
+/// appsettings.json-bound, same shape as Engine's
+/// GameLoopOptions. The default world's NPC keeps its own fixed
+/// stats regardless (small, deliberately simple content, not a tunable).
+///
+public sealed class BasicRulesetOptions
+{
+ /// A fresh character's starting (and max) hit points. Must be at least 1 - see .
+ public int StartingHitPoints { get; set; } = 20;
+
+ /// A fresh character's starting armor class.
+ public int StartingArmorClass { get; set; } = 10;
+
+ /// A fresh character's minimum damage per hit. Must be at least 1 - see .
+ public int StartingDamageMin { get; set; } = 1;
+
+ /// A fresh character's maximum damage per hit. Must be at least - see .
+ public int StartingDamageMax { get; set; } = 4;
+
+ ///
+ /// Fails fast at composition-root time on a combat-breaking configuration
+ /// (non-positive starting HP/damage, or a damage range with no valid
+ /// rolls) - called by AddSharpMudBasicRuleset(...) right after its
+ /// configureOptions callback runs, so a bad value surfaces at
+ /// startup instead of the first time a fight actually happens.
+ ///
+ public void Validate()
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(StartingHitPoints, 1, nameof(StartingHitPoints));
+ ArgumentOutOfRangeException.ThrowIfLessThan(StartingDamageMin, 1, nameof(StartingDamageMin));
+ ArgumentOutOfRangeException.ThrowIfLessThan(StartingDamageMax, StartingDamageMin, nameof(StartingDamageMax));
+ }
+}
diff --git a/src/SharpMud.Ruleset.Basic/BasicStatsBehavior.cs b/src/SharpMud.Ruleset.Basic/BasicStatsBehavior.cs
new file mode 100644
index 0000000..30c3fb9
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/BasicStatsBehavior.cs
@@ -0,0 +1,22 @@
+using SharpMud.Engine.Core;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic;
+
+// Deliberately minimal - plain numeric attributes only, no Race/CharacterClass
+// (that's Classic-flavored content, not what Basic promises). Attached
+// alongside SharpMud.Engine's PlayerBehavior to make a Thing a character;
+// SharpMud.Ruleset.Rpg's CombatantBehavior handles the actual combat numbers.
+///
+/// Basic's minimal character-progression behavior - just level and
+/// experience, no race/class/attributes. Combat HP lives on , not here.
+///
+public sealed class BasicStatsBehavior : Behavior
+{
+ /// The character's level. Not currently used to modify combat numbers - see docs/character.md.
+ public int Level { get; set; } = 1;
+
+ /// Accumulated experience, adjusted by on combat wins/losses.
+ public long Experience { get; set; }
+}
diff --git a/src/SharpMud.Ruleset.Basic/BasicWorldBuilder.cs b/src/SharpMud.Ruleset.Basic/BasicWorldBuilder.cs
new file mode 100644
index 0000000..81532ca
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/BasicWorldBuilder.cs
@@ -0,0 +1,87 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic;
+
+///
+/// The default small world a fresh SharpMud.Ruleset.Basic consumer
+/// gets for free - two rooms and one fightable NPC, enough to walk around
+/// and issue attack/flee against something without writing any
+/// world content of their own. A real game still wants its own ; this is the quick-start default.
+///
+public sealed class BasicWorldBuilder : IWorldBuilder
+{
+ // Fixed, not ThingId.New() - so a fresh boot can ask the repository
+ // "does this already exist?" instead of always rebuilding. See
+ // docs/persistence.md.
+ /// The fixed id of the default world's root area - stable across restarts so a persisted world can be found again.
+ public static readonly ThingId AreaId = new(Guid.Parse("00000000-0000-0000-0000-000000000002"));
+
+ ///
+ public ThingId RootId => AreaId;
+
+ /// Builds the default two-room world (a Clearing and an Old Watchtower) with one fightable NPC (a wild boar) in the watchtower.
+ public (World World, Thing StartingRoom) Build()
+ {
+ var world = new World();
+
+ var area = new Thing { Id = AreaId, Name = "The Basic World" };
+ area.Behaviors.Add(new AreaBehavior());
+ world.Register(area);
+
+ var clearing = CreateRoom(world, area, "Clearing",
+ "A quiet clearing ringed by tall grass. A worn path leads north.");
+ var watchtower = CreateRoom(world, area, "Old Watchtower",
+ "A crumbling stone watchtower, long abandoned. Something rustles nearby.");
+
+ Connect(world, clearing, watchtower, Direction.North);
+
+ var boar = new Thing { Id = ThingId.New(), Name = "wild boar" };
+ boar.Behaviors.Add(new NpcBehavior());
+ boar.Behaviors.Add(new CombatantBehavior
+ {
+ MaxHitPoints = 8,
+ CurrentHitPoints = 8,
+ ArmorClass = 8,
+ DamageMin = 1,
+ DamageMax = 3,
+ ExperienceReward = 10,
+ });
+ watchtower.Add(boar);
+ world.Register(boar);
+
+ return (world, clearing);
+ }
+
+ ///
+ public Thing FindStartingRoom(Thing root) =>
+ root.Children.FirstOrDefault(c => c.HasBehavior() && c.Name == "Clearing")
+ ?? root.Children.First(c => c.HasBehavior());
+
+ private static Thing CreateRoom(World world, Thing area, string name, string description)
+ {
+ var room = new Thing { Id = ThingId.New(), Name = name, Description = description };
+ room.Behaviors.Add(new RoomBehavior());
+ area.Add(room);
+ world.Register(room);
+ return room;
+ }
+
+ // Two exit Things per connection - one per direction (docs/engine-vs-ruleset.md
+ // Decisions), each a child of the room it exits from.
+ private static void Connect(World world, Thing a, Thing b, Direction direction)
+ {
+ var aToB = new Thing { Id = ThingId.New(), Name = direction.ToDisplayString() };
+ aToB.Behaviors.Add(new ExitBehavior { Direction = direction, Destination = b });
+ a.Add(aToB);
+ world.Register(aToB);
+
+ var bToA = new Thing { Id = ThingId.New(), Name = direction.Opposite().ToDisplayString() };
+ bToA.Behaviors.Add(new ExitBehavior { Direction = direction.Opposite(), Destination = a });
+ b.Add(bToA);
+ world.Register(bToA);
+ }
+}
diff --git a/src/SharpMud.Ruleset.Basic/Configurations/BasicStatsBehaviorConfiguration.cs b/src/SharpMud.Ruleset.Basic/Configurations/BasicStatsBehaviorConfiguration.cs
new file mode 100644
index 0000000..b52ace9
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/Configurations/BasicStatsBehaviorConfiguration.cs
@@ -0,0 +1,15 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace SharpMud.Ruleset.Basic.Configurations;
+
+// internal: discovered via ApplyConfigurationsFromAssembly's reflection scan
+// (BasicBehaviorMappingContributor), never referenced by name - not part of
+// this package's public contract.
+internal sealed class BasicStatsBehaviorConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ // All plain int/long properties - default mapping.
+ }
+}
diff --git a/src/SharpMud.Ruleset.Basic/ServiceCollectionExtensions.cs b/src/SharpMud.Ruleset.Basic/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..3659770
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/ServiceCollectionExtensions.cs
@@ -0,0 +1,43 @@
+using Microsoft.Extensions.DependencyInjection;
+using SharpMud.Engine.Commands;
+using SharpMud.Hosting;
+using SharpMud.Persistence;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic;
+
+/// DI registration entry point for this package.
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers everything a consumer needs for a runnable, playable basic
+ /// game on top of SharpMud.Ruleset.Rpg's combat scaffolding -
+ /// this package's stats behavior mapping, default world, player
+ /// factory, and combat-outcome handler. Combined with Engine/
+ /// Hosting/a persistence provider/a transport adapter, this is
+ /// the actual "dotnet add package, a few lines in
+ /// Program.cs, run a basic game" quick-start
+ /// (docs/adr/0008-ruleset-scaffolding-tier.md).
+ ///
+ /// The consumer's .
+ /// Tunes the starting numbers for a fresh player character - see . Validated immediately after this callback runs; an invalid combination throws at startup rather than at first combat.
+ /// Forwarded to AddSharpMudRpgRuleset(...) - a consumer's own commands, registered alongside kill/attack/flee.
+ public static IServiceCollection AddSharpMudBasicRuleset(
+ this IServiceCollection services,
+ Action? configureOptions = null,
+ Action? registerConsumerCommands = null)
+ {
+ var options = new BasicRulesetOptions();
+ configureOptions?.Invoke(options);
+ options.Validate();
+ services.AddSingleton(options);
+
+ services.AddSingleton();
+ services.AddSharpMudWorld();
+ services.AddSharpMudPlayerFactory();
+
+ services.AddSharpMudRpgRuleset(registerConsumerCommands);
+
+ return services;
+ }
+}
diff --git a/src/SharpMud.Ruleset.Basic/SharpMud.Ruleset.Basic.csproj b/src/SharpMud.Ruleset.Basic/SharpMud.Ruleset.Basic.csproj
new file mode 100644
index 0000000..54a8eec
--- /dev/null
+++ b/src/SharpMud.Ruleset.Basic/SharpMud.Ruleset.Basic.csproj
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+ net10.0;net11.0
+ enable
+ enable
+ Minimal, deliberately simple concrete ruleset built on SharpMud.Ruleset.Rpg - a plain numeric stat block, a small default world with a fightable NPC, and AddSharpMudBasicRuleset(...) for a true "dotnet add package, few lines in Program.cs, run a basic game" quick-start.
+
+
+
diff --git a/src/SharpMud.Ruleset.Rpg/AttackCommand.cs b/src/SharpMud.Ruleset.Rpg/AttackCommand.cs
new file mode 100644
index 0000000..fda109d
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/AttackCommand.cs
@@ -0,0 +1,85 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Commands;
+
+namespace SharpMud.Ruleset.Rpg;
+
+///
+/// The kill/attack command - starts a combat encounter against
+/// an NPC in the current room. Registered by
+/// AddSharpMudRpgRuleset(...), not meant to be constructed directly
+/// by a consumer.
+///
+public sealed class AttackCommand : ICommand
+{
+ private readonly ICombatManager _combatManager;
+
+ /// Creates the command against the shared .
+ public AttackCommand(ICombatManager combatManager)
+ {
+ _combatManager = combatManager;
+ }
+
+ /// The canonical verb, kill.
+ public string Verb => "kill";
+
+ /// Aliases for - just attack.
+ public IReadOnlyList Aliases { get; } = ["attack"];
+
+ ///
+ /// Guards (not already fighting, actor can fight, a matching NPC target
+ /// exists in the room), then atomically starts the encounter via - which is also the guard
+ /// against a target someone else is already fighting, checked and
+ /// inserted as one operation so two concurrent attackers targeting the
+ /// same NPC can't both succeed. Resolution happens on the next game
+ /// tick, not synchronously here.
+ ///
+ public async Task ExecuteAsync(CommandContext ctx, CancellationToken ct)
+ {
+ if (await CommandGuards.RequireArgsAsync(ctx, "Kill what?", ct))
+ return;
+
+ if (_combatManager.IsInCombat(ctx.Actor.Id))
+ {
+ await ctx.Session.WriteLineAsync("You are already fighting!", ct);
+ return;
+ }
+
+ // Every built-in IPlayerFactory attaches CombatantBehavior to a
+ // fresh player, but nothing enforces that for a consumer's own
+ // IPlayerFactory - without this guard, a player missing it would
+ // start an encounter here successfully and only fail later, at tick
+ // time, on CombatResolver's attacker.FindBehavior()!.
+ if (!ctx.Actor.HasBehavior())
+ {
+ await ctx.Session.WriteLineAsync("You have no way to fight.", ct);
+ return;
+ }
+
+ var targetName = string.Join(' ', ctx.Args);
+ var target = ObjectMatcher.FindMatch(
+ ctx.CurrentRoom.Children.Where(c => c.HasBehavior() && c.HasBehavior()),
+ targetName,
+ n => n.Name);
+
+ if (target is null)
+ {
+ await ctx.Session.WriteLineAsync("You don't see that here.", ct);
+ return;
+ }
+
+ // TryStartEncounter is the actual guard against a target someone
+ // else is already fighting - checked and inserted atomically, so
+ // two concurrent attackers targeting the same NPC (two different
+ // players' session-loop tasks racing each other) can't both
+ // succeed. The IsInCombat check above already ruled out "this same
+ // actor is already fighting" as the reason for a false return here.
+ if (!_combatManager.TryStartEncounter(ctx.Actor, target))
+ {
+ await ctx.Session.WriteLineAsync($"Someone else is already fighting {target.Name}!", ct);
+ return;
+ }
+
+ await ctx.Session.WriteLineAsync($"You attack {target.Name}!", ct);
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/CombatManager.cs b/src/SharpMud.Ruleset.Rpg/CombatManager.cs
new file mode 100644
index 0000000..e728b31
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/CombatManager.cs
@@ -0,0 +1,218 @@
+using System.Diagnostics.CodeAnalysis;
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Commands.Builtin;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+using SharpMud.Engine.Ticking;
+
+namespace SharpMud.Ruleset.Rpg;
+
+// Registered once with IGameLoop and resolves every active encounter each
+// tick, but "each tick" is not the only time _encounters is touched -
+// AttackCommand/FleeCommand call into this manager from whichever session's
+// SessionLoop.RunAsync happens to be running a command at that moment, and
+// each connection runs its own independently-scheduled task (see
+// TelnetTransportBackgroundService.HandleConnectionAsync). So _encounters is
+// genuinely accessed from multiple threads, not just serialized through the
+// tick loop - every access goes through _lock (System.Threading.Lock, per
+// this repo's own concurrency guidance for a real, unavoidable critical
+// section) rather than assuming single-threaded access.
+//
+// Combat-outcome side effects (XP awards, death penalties, respawn
+// destination) are delegated to ICombatOutcomeHandler rather than touching a
+// concrete ruleset's stats behavior or a hard-coded room directly - this
+// package has zero reference to any concrete ruleset's types (see
+// docs/adr/0008-ruleset-scaffolding-tier.md's Decision Outcome).
+///
+/// Tracks active combat encounters and resolves them once per game tick -
+/// the concrete implementation of , registered
+/// by AddSharpMudRpgRuleset(...) as both
+/// and off the same instance. Public (rather than
+/// internal) specifically so a consumer can drive it directly against a
+/// custom in their own tests, without
+/// going through DI.
+///
+public sealed class CombatManager : ICombatManager, ITickable
+{
+ private readonly ICombatResolver _resolver;
+ private readonly ICombatOutcomeHandler _outcomeHandler;
+ private readonly Lock _lock = new();
+ private readonly Dictionary _encounters = [];
+
+ /// Creates the manager against a resolver and a ruleset's outcome handler.
+ public CombatManager(ICombatResolver resolver, ICombatOutcomeHandler outcomeHandler)
+ {
+ _resolver = resolver;
+ _outcomeHandler = outcomeHandler;
+ }
+
+ ///
+ public bool IsInCombat(ThingId thingId)
+ {
+ lock (_lock)
+ return _encounters.ContainsKey(thingId);
+ }
+
+ ///
+ public bool IsDefenderEngaged(ThingId defenderId)
+ {
+ lock (_lock)
+ return _encounters.Values.Any(e => e.Defender.Id == defenderId);
+ }
+
+ ///
+ public bool TryStartEncounter(Thing attacker, Thing defender)
+ {
+ lock (_lock)
+ {
+ if (_encounters.ContainsKey(attacker.Id))
+ return false;
+
+ if (_encounters.Values.Any(e => e.Defender.Id == defender.Id))
+ return false;
+
+ _encounters[attacker.Id] = new CombatEncounter { Attacker = attacker, Defender = defender };
+ return true;
+ }
+ }
+
+ ///
+ public void EndEncounter(ThingId thingId)
+ {
+ lock (_lock)
+ _encounters.Remove(thingId);
+ }
+
+ ///
+ public bool TryGetEncounter(ThingId thingId, [MaybeNullWhen(false)] out CombatEncounter encounter)
+ {
+ lock (_lock)
+ return _encounters.TryGetValue(thingId, out encounter);
+ }
+
+ /// Resolves one round for every active encounter - see the class remarks above for the full sequence.
+ public async Task OnTickAsync(TickContext ctx, CancellationToken ct)
+ {
+ ThingId[] activeEncounterIds;
+ lock (_lock)
+ activeEncounterIds = [.. _encounters.Keys];
+
+ foreach (var thingId in activeEncounterIds)
+ {
+ CombatEncounter? encounter;
+ lock (_lock)
+ {
+ if (!_encounters.TryGetValue(thingId, out encounter))
+ continue;
+ }
+
+ // Only AttackCommand calls TryStartEncounter, and only with a
+ // player Thing as the attacker - encounter.Attacker always has a
+ // PlayerBehavior.
+ var attackerBehavior = encounter.Attacker.FindBehavior()!;
+ if (attackerBehavior.ConnectionState == ConnectionState.Linkdead)
+ {
+ // Attacker disconnected mid-fight (ADR-0004). Freeze the
+ // encounter rather than ending it immediately - it resumes
+ // automatically once LoginFlow reconnects them (ConnectionState
+ // flips back to Playing). Only actually abandon it once the
+ // same grace window LoginFlow/LinkdeadSweeper use has elapsed.
+ // Linkdead always sets LinkdeadSinceUtc (PlayerBehavior.EnterLinkdead).
+ if (ctx.Timestamp - attackerBehavior.LinkdeadSinceUtc!.Value >= ReconnectPolicy.GraceWindow)
+ EndEncounter(thingId);
+
+ continue;
+ }
+
+ // Re-verify this is still the live encounter for thingId,
+ // immediately before resolving a round against it. Nothing
+ // above this point holds _lock across an await, so a concurrent
+ // FleeCommand (EndEncounter) + AttackCommand (TryStartEncounter,
+ // against a *different* defender) pair could otherwise replace
+ // _encounters[thingId] between the fetch above and here - this
+ // tick would then resolve a round against the stale, already-
+ // replaced encounter and could wrongly EndEncounter the new one
+ // afterward. Skip and pick up the real current encounter next
+ // tick instead.
+ lock (_lock)
+ {
+ if (!_encounters.TryGetValue(thingId, out var currentEncounter) || !ReferenceEquals(currentEncounter, encounter))
+ continue;
+ }
+
+ // Not Linkdead (checked above), so Session is the live, connected session.
+ var session = attackerBehavior.Session!;
+
+ var attackResult = _resolver.ResolveRound(encounter.Attacker, encounter.Defender);
+ await session.WriteLineAsync(
+ attackResult.Hit
+ ? $"You hit {encounter.Defender.Name} for {attackResult.Damage} damage."
+ : $"You miss {encounter.Defender.Name}.",
+ ct);
+
+ if (attackResult.DefenderDefeated)
+ {
+ await HandleDefenderDefeatedAsync(encounter, session, ct);
+ continue;
+ }
+
+ // Classic mutual combat: the defender strikes back the same round.
+ var counterResult = _resolver.ResolveRound(encounter.Defender, encounter.Attacker);
+ await session.WriteLineAsync(
+ counterResult.Hit
+ ? $"{encounter.Defender.Name} hits you for {counterResult.Damage} damage."
+ : $"{encounter.Defender.Name} misses you.",
+ ct);
+
+ if (counterResult.DefenderDefeated)
+ await HandleAttackerDefeatedAsync(encounter, session, ct);
+ }
+ }
+
+ private async Task HandleDefenderDefeatedAsync(CombatEncounter encounter, ISession session, CancellationToken ct)
+ {
+ await session.WriteLineAsync($"You have slain {encounter.Defender.Name}!", ct);
+
+ await _outcomeHandler.OnVictoryAsync(encounter.Attacker, encounter.Defender, ct);
+
+ encounter.Defender.Parent?.Remove(encounter.Defender);
+ EndEncounter(encounter.Attacker.Id);
+ }
+
+ private async Task HandleAttackerDefeatedAsync(CombatEncounter encounter, ISession session, CancellationToken ct)
+ {
+ var attacker = encounter.Attacker;
+
+ // Real, pre-existing bug fixed here: CombatResolver reads/writes
+ // damage against CombatantBehavior.CurrentHitPoints, not any
+ // ruleset-specific stats behavior. A respawn that only reset the
+ // latter left CombatantBehavior.CurrentHitPoints at/below 0, so the
+ // very next hit instantly re-triggered "defeated" regardless of the
+ // roll. This reset is generic (CombatantBehavior is this package's
+ // own type) so it happens here, as a safe full-HP baseline, before
+ // the ruleset-specific outcome handler runs - NOT as the final word.
+ // A ruleset that wants a real death penalty (e.g. respawn at half
+ // HP, not full) mutates CombatantBehavior.CurrentHitPoints itself
+ // inside OnDefeatAsync below, which runs after this and therefore
+ // wins - see ClassicCombatOutcomeHandler/BasicCombatOutcomeHandler.
+ // Without that override, "no penalty, full-HP respawn" is the
+ // correct default for a ruleset that doesn't want one.
+ var combatant = attacker.FindBehavior()!;
+ combatant.CurrentHitPoints = combatant.MaxHitPoints;
+
+ // Generic defeat message first, then the ruleset-specific outcome
+ // handler (which may itself write its own messages, e.g. an XP-loss
+ // line) - same ordering as HandleDefenderDefeatedAsync above, and
+ // matches the message order from before this extraction.
+ await session.WriteLineAsync($"{encounter.Defender.Name} has slain you!", ct);
+
+ var destination = await _outcomeHandler.OnDefeatAsync(attacker, encounter.Defender, ct);
+
+ EndEncounter(attacker.Id);
+
+ attacker.Parent?.Remove(attacker);
+ destination.Add(attacker);
+
+ await LookCommand.SendRoomDescriptionAsync(attacker, destination, ct);
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/CombatResolver.cs b/src/SharpMud.Ruleset.Rpg/CombatResolver.cs
new file mode 100644
index 0000000..352c68c
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/CombatResolver.cs
@@ -0,0 +1,47 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+// 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 -
+// this is currently an unmodified d20 roll against the defender's AC.
+///
+/// The default - a Diku/Circle-style
+/// unmodified d20-vs-armor-class roll, with damage rolled from the
+/// attacker's . Public (rather
+/// than internal) so a consumer can construct it directly in their own
+/// tests, or explicitly replace the DI
+/// registration with their own combat math.
+///
+public sealed class CombatResolver : ICombatResolver
+{
+ private readonly IDiceRoller _dice;
+ private readonly IRandomSource _random;
+
+ /// Creates the resolver against a dice roller and a raw randomness source (for the damage roll - see remarks on ).
+ public CombatResolver(IDiceRoller dice, IRandomSource random)
+ {
+ _dice = dice;
+ _random = random;
+ }
+
+ ///
+ public CombatRoundResult ResolveRound(Thing attacker, Thing defender)
+ {
+ var attackerCombatant = attacker.FindBehavior()!;
+ var defenderCombatant = defender.FindBehavior()!;
+
+ var toHitRoll = _dice.Roll(1, 20);
+ if (toHitRoll < defenderCombatant.ArmorClass)
+ return new CombatRoundResult(false, 0, false);
+
+ // Damage range isn't dice notation (arbitrary min/max, not 1-based
+ // per-die), so this stays a direct IRandomSource roll rather than
+ // going through IDiceRoller.
+ var (min, max) = attackerCombatant.DamageRange;
+ var damage = _random.Next(min, max);
+ defenderCombatant.CurrentHitPoints -= damage;
+
+ return new CombatRoundResult(true, damage, defenderCombatant.CurrentHitPoints <= 0);
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/CombatantBehavior.cs b/src/SharpMud.Ruleset.Rpg/CombatantBehavior.cs
new file mode 100644
index 0000000..88b7b68
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/CombatantBehavior.cs
@@ -0,0 +1,33 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+///
+/// HP/armor-class/damage-range/XP-reward - a any
+/// can carry (a hostile plant, a turret - anything the
+/// ruleset wants to be able to fight), independent of any stats behavior so
+/// an NPC doesn't need a full character sheet just to throw a punch.
+///
+public sealed class CombatantBehavior : Behavior
+{
+ /// Hit points at full health.
+ public int MaxHitPoints { get; set; }
+
+ /// Current hit points - the value actually reads/writes during combat.
+ public int CurrentHitPoints { get; set; }
+
+ /// Armor class - the to-hit roll must meet or exceed this to land a hit.
+ public int ArmorClass { get; set; }
+
+ /// Minimum damage rolled on a hit - see .
+ public int DamageMin { get; set; }
+
+ /// Maximum damage rolled on a hit - see .
+ public int DamageMax { get; set; }
+
+ /// XP awarded to the victor when this combatant is defeated - see .
+ public int ExperienceReward { get; set; }
+
+ /// The (, ) pair, as read by .
+ public (int Min, int Max) DamageRange => (DamageMin, DamageMax);
+}
diff --git a/src/SharpMud.Ruleset.Rpg/Configurations/CombatantBehaviorConfiguration.cs b/src/SharpMud.Ruleset.Rpg/Configurations/CombatantBehaviorConfiguration.cs
new file mode 100644
index 0000000..c392f43
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/Configurations/CombatantBehaviorConfiguration.cs
@@ -0,0 +1,15 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace SharpMud.Ruleset.Rpg.Configurations;
+
+// internal: discovered via ApplyConfigurationsFromAssembly's reflection scan
+// (RpgBehaviorMappingContributor), never referenced by name - not part of
+// this package's public contract.
+internal sealed class CombatantBehaviorConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ // All plain int properties - default mapping.
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/DiceRoller.cs b/src/SharpMud.Ruleset.Rpg/DiceRoller.cs
new file mode 100644
index 0000000..519d831
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/DiceRoller.cs
@@ -0,0 +1,34 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+///
+/// The default - sums diceCount
+/// independent rolls of over [1,
+/// sides], plus a flat modifier. Public (rather than internal) so a
+/// consumer can construct it directly in their own tests.
+///
+public sealed class DiceRoller : IDiceRoller
+{
+ private readonly IRandomSource _random;
+
+ /// Creates the roller against the engine's randomness source.
+ public DiceRoller(IRandomSource random)
+ {
+ _random = random;
+ }
+
+ ///
+ /// or is less than 1.
+ public int Roll(int diceCount, int sides, int modifier = 0)
+ {
+ ArgumentOutOfRangeException.ThrowIfLessThan(diceCount, 1);
+ ArgumentOutOfRangeException.ThrowIfLessThan(sides, 1);
+
+ var total = modifier;
+ for (var i = 0; i < diceCount; i++)
+ total += _random.Next(1, sides);
+
+ return total;
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/FleeCommand.cs b/src/SharpMud.Ruleset.Rpg/FleeCommand.cs
new file mode 100644
index 0000000..9c33d02
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/FleeCommand.cs
@@ -0,0 +1,114 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Commands;
+using SharpMud.Engine.Commands.Builtin;
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+///
+/// The flee command - attempts to escape an active combat encounter
+/// through a random exit. Registered by AddSharpMudRpgRuleset(...),
+/// not meant to be constructed directly by a consumer.
+///
+public sealed class FleeCommand : ICommand
+{
+ private readonly ICombatManager _combatManager;
+ private readonly IDiceRoller _dice;
+ private readonly IRandomSource _random;
+
+ /// Creates the command against the shared and dice/randomness sources.
+ public FleeCommand(ICombatManager combatManager, IDiceRoller dice, IRandomSource random)
+ {
+ _combatManager = combatManager;
+ _dice = dice;
+ _random = random;
+ }
+
+ /// The canonical verb, flee. No aliases.
+ public string Verb => "flee";
+
+ /// No aliases for .
+ public IReadOnlyList Aliases { get; } = [];
+
+ ///
+ /// Guards (an active encounter exists, the current room has an exit),
+ /// rolls a flat success chance, and on success publishes the same request MoveCommand does (so a locked
+ /// exit can still veto) before ending the encounter and moving the
+ /// actor through the chosen exit.
+ ///
+ public async Task ExecuteAsync(CommandContext ctx, CancellationToken ct)
+ {
+ if (!_combatManager.TryGetEncounter(ctx.Actor.Id, out _))
+ {
+ await ctx.Session.WriteLineAsync("You aren't fighting anything.", ct);
+ return;
+ }
+
+ var exits = ctx.CurrentRoom.Children.Select(c => c.FindBehavior()).OfType().ToList();
+ if (exits.Count == 0)
+ {
+ await ctx.Session.WriteLineAsync("There's nowhere to flee to!", ct);
+ return;
+ }
+
+ // Success chance: DEX-influenced per docs/combat.md, but the exact
+ // formula is still an open item there. Flat 60% until it's defined.
+ var success = _dice.Roll(1, 100) <= 60;
+ if (!success)
+ {
+ await ctx.Session.WriteLineAsync("You fail to escape!", ct);
+ return;
+ }
+
+ var exit = exits[_random.Next(0, exits.Count - 1)];
+
+ // Same request/cancellation path MoveCommand uses - without this,
+ // a locked exit blocks a normal move but not a flee through the
+ // exact same exit.
+ var exitThing = exit.Parent!;
+ var request = new UseExitEvent { ActiveThing = ctx.Actor, Exit = exitThing };
+ exitThing.Events.PublishRequest(request, EventScope.SelfOnly);
+ if (request.IsCanceled)
+ {
+ await ctx.Session.WriteLineAsync(request.CancelReason ?? "You can't escape that way!", ct);
+ return;
+ }
+
+ var destination = exit.Destination;
+
+ // Remove can itself publish a cancellable RemoveChildEvent and
+ // return false (e.g. a future "can't leave this room" behavior) -
+ // same check MoveCommand does, and for the same reason: proceeding
+ // to Add into destination after a failed Remove would leave the
+ // actor added to the new room without ever having left the old
+ // one. EndEncounter/messaging only happen once the move is real.
+ if (!ctx.CurrentRoom.Remove(ctx.Actor))
+ {
+ await ctx.Session.WriteLineAsync("You can't escape that way!", ct);
+ return;
+ }
+
+ // Add can itself publish a cancellable AddChildEvent and return
+ // false (e.g. a future "room is full" behavior). Unlike Remove
+ // above, there's no early-return option here - the actor is already
+ // detached from ctx.CurrentRoom - so a failed Add is rolled back by
+ // re-adding to the original room, keeping the encounter (and the
+ // actor) exactly where they were rather than leaving them parentless
+ // after an announced-but-not-actually-completed flee.
+ if (!destination.Add(ctx.Actor))
+ {
+ ctx.CurrentRoom.Add(ctx.Actor);
+ await ctx.Session.WriteLineAsync("You can't escape that way!", ct);
+ return;
+ }
+
+ _combatManager.EndEncounter(ctx.Actor.Id);
+ await ctx.Session.WriteLineAsync($"You flee {exit.Direction.ToDisplayString()}!", ct);
+
+ await RoomBroadcast.ToOccupantsAsync(
+ ctx.CurrentRoom, ctx.Actor, $"{ctx.Actor.Name} flees {exit.Direction.ToDisplayString()}.", ct);
+
+ await LookCommand.SendRoomDescriptionAsync(ctx.Actor, destination, ct);
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/ICombatManager.cs b/src/SharpMud.Ruleset.Rpg/ICombatManager.cs
new file mode 100644
index 0000000..4f88be1
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/ICombatManager.cs
@@ -0,0 +1,59 @@
+using System.Diagnostics.CodeAnalysis;
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+/// An active combat encounter - the attacking and the it's fighting.
+public sealed class CombatEncounter
+{
+ /// The Thing that initiated the encounter (always the player, per the v1 scope note below).
+ public required Thing Attacker { get; init; }
+
+ /// The Thing being attacked.
+ public required Thing Defender { get; init; }
+}
+
+// v1 scope is player-vs-NPC only - no PvP verb/aggression rules exist yet,
+// so an encounter is always keyed by the attacking Thing.
+///
+/// Tracks and resolves active combat encounters. Implemented by ; a consumer typically only interacts with this
+/// interface (e.g. a custom command checking ),
+/// resolved from DI rather than constructed directly.
+///
+public interface ICombatManager
+{
+ /// Whether the given Thing is currently the attacker in an active encounter.
+ bool IsInCombat(ThingId thingId);
+
+ ///
+ /// Whether the given Thing is currently the defender in any active
+ /// encounter - since encounters are keyed by attacker only, a second
+ /// attacker targeting an already-engaged defender would otherwise let
+ /// both encounters independently resolve, remove, and award victory for
+ /// the same kill. This is a point-in-time status query only - the actual
+ /// enforcement against that race is ,
+ /// which checks and inserts atomically; don't call this separately and
+ /// then call expecting the combination
+ /// to be race-free, since another attacker can start an encounter
+ /// between the two calls.
+ ///
+ bool IsDefenderEngaged(ThingId defenderId);
+
+ ///
+ /// Atomically starts the encounter keyed by ,
+ /// unless is already fighting or is already engaged by a different attacker - the
+ /// check and the insert happen under the same lock, so two concurrent
+ /// callers (e.g. two players' independent session-loop tasks targeting
+ /// the same NPC at nearly the same time) can't both succeed against the
+ /// same defender. Returns whether the encounter was actually started.
+ ///
+ bool TryStartEncounter(Thing attacker, Thing defender);
+
+ /// Ends the encounter keyed by the given Thing, if one exists.
+ void EndEncounter(ThingId thingId);
+
+ /// Attempts to get the active encounter keyed by the given Thing.
+ bool TryGetEncounter(ThingId thingId, [MaybeNullWhen(false)] out CombatEncounter encounter);
+}
diff --git a/src/SharpMud.Ruleset.Rpg/ICombatOutcomeHandler.cs b/src/SharpMud.Ruleset.Rpg/ICombatOutcomeHandler.cs
new file mode 100644
index 0000000..9904172
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/ICombatOutcomeHandler.cs
@@ -0,0 +1,38 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+///
+/// A ruleset's hook into combat outcomes - owns
+/// generic encounter bookkeeping (round resolution, freezing on linkdead,
+/// resetting ) but has no
+/// concept of a ruleset's own stats/leveling behavior or where a defeated
+/// character should respawn. Implemented once per ruleset (e.g. Classic
+/// touches its StatsBehavior's XP; a ruleset with no leveling concept
+/// at all can no-op the reward side) and registered via
+/// AddSharpMudRpgRuleset<TCombatOutcomeHandler>(...). See
+/// docs/adr/0008-ruleset-scaffolding-tier.md's Decision Outcome for why this
+/// mechanism replaces CombatManager's prior direct StatsBehavior
+/// touches and hard-coded respawn room.
+///
+public interface ICombatOutcomeHandler
+{
+ /// Called when defeats - the hook for awarding XP/rewards.
+ Task OnVictoryAsync(Thing victor, Thing defeated, CancellationToken ct);
+
+ ///
+ /// Called when loses the encounter to
+ /// - the hook for a death penalty (XP loss,
+ /// stats-specific HP reset) and for deciding the respawn destination.
+ /// already reset 's
+ /// to full as a safe baseline before this is called - a "no penalty"
+ /// ruleset can rely on that and do nothing here, but a ruleset that
+ /// wants a real HP penalty (e.g. respawn at half HP) must mutate itself inside this
+ /// method, since it's the value actually
+ /// reads/writes in combat - see ClassicCombatOutcomeHandler/
+ /// BasicCombatOutcomeHandler for worked examples.
+ ///
+ Task OnDefeatAsync(Thing defeated, Thing victor, CancellationToken ct);
+}
diff --git a/src/SharpMud.Ruleset.Rpg/ICombatResolver.cs b/src/SharpMud.Ruleset.Rpg/ICombatResolver.cs
new file mode 100644
index 0000000..10023e6
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/ICombatResolver.cs
@@ -0,0 +1,21 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg;
+
+/// The outcome of one call.
+/// Whether the attack landed.
+/// Damage applied - always 0 when is .
+/// Whether this round dropped the defender's to zero or below.
+public sealed record CombatRoundResult(bool Hit, int Damage, bool DefenderDefeated);
+
+/// Resolves a single round of combat between two -carrying Things.
+public interface ICombatResolver
+{
+ ///
+ /// Resolves and applies one round: rolls to hit, and on a hit rolls and
+ /// applies damage directly to 's . Both Things must already
+ /// carry .
+ ///
+ CombatRoundResult ResolveRound(Thing attacker, Thing defender);
+}
diff --git a/src/SharpMud.Ruleset.Rpg/IDiceRoller.cs b/src/SharpMud.Ruleset.Rpg/IDiceRoller.cs
new file mode 100644
index 0000000..0ff0085
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/IDiceRoller.cs
@@ -0,0 +1,14 @@
+namespace SharpMud.Ruleset.Rpg;
+
+///
+/// "N dice of M sides plus a modifier" over the engine's - a generic RPG mechanic, not
+/// bare randomness (so it doesn't belong in Engine) and not tied to any one
+/// ruleset's specific formulas (so it doesn't belong in a concrete leaf
+/// package either). See docs/adr/0008-ruleset-scaffolding-tier.md.
+///
+public interface IDiceRoller
+{
+ /// Rolls dice of sides each and adds .
+ int Roll(int diceCount, int sides, int modifier = 0);
+}
diff --git a/src/SharpMud.Ruleset.Rpg/RpgBehaviorMappingContributor.cs b/src/SharpMud.Ruleset.Rpg/RpgBehaviorMappingContributor.cs
new file mode 100644
index 0000000..34a75e2
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/RpgBehaviorMappingContributor.cs
@@ -0,0 +1,23 @@
+using Microsoft.EntityFrameworkCore;
+using SharpMud.Persistence;
+
+namespace SharpMud.Ruleset.Rpg;
+
+// Registers this package's own EF Core mapping for CombatantBehavior -
+// Persistence never references SharpMud.Ruleset.Rpg directly, per
+// docs/persistence.md. Scoped to this assembly's own
+// IEntityTypeConfiguration<> types, same pattern as
+// ClassicBehaviorMappingContributor - a consumer's own contributor scans
+// its own assembly, never this one's.
+///
+/// This package's - registers 's EF Core mapping. Registered automatically by
+/// AddSharpMudRpgRuleset(...); a consumer doesn't need to register it
+/// themselves.
+///
+public sealed class RpgBehaviorMappingContributor : IBehaviorMappingContributor
+{
+ /// Applies every IEntityTypeConfiguration<T> in this assembly - currently just 's.
+ public void ConfigureBehaviors(ModelBuilder modelBuilder) =>
+ modelBuilder.ApplyConfigurationsFromAssembly(typeof(RpgBehaviorMappingContributor).Assembly);
+}
diff --git a/src/SharpMud.Ruleset.Rpg/ServiceCollectionExtensions.cs b/src/SharpMud.Ruleset.Rpg/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000..aa5146d
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/ServiceCollectionExtensions.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.DependencyInjection;
+using SharpMud.Engine.Commands;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Ticking;
+using SharpMud.Hosting;
+using SharpMud.Persistence;
+
+namespace SharpMud.Ruleset.Rpg;
+
+/// DI registration entry point for this package.
+public static class ServiceCollectionExtensions
+{
+ ///
+ /// Registers this package's combat scaffolding - , (as both
+ /// itself and , off the same instance), the dice
+ /// service, this package's ,
+ /// and the kill/attack/flee commands - reproducing
+ /// what every consumer previously had to hand-wire in their own
+ /// Program.cs. is
+ /// the consumer's own implementation
+ /// (XP awards, death penalty, respawn destination).
+ ///
+ /// The consumer's .
+ ///
+ /// Optional callback for the consumer's own commands, invoked after this
+ /// package's own commands are registered. This package calls the
+ /// underlying Hosting.AddSharpMudRuleset(...) exactly once
+ /// internally - a consumer must not call it again themselves, since
+ /// DI's last-registration-wins resolution for
+ /// would silently drop whichever call came first (see
+ /// docs/adr/0008-ruleset-scaffolding-tier.md's Decision Outcome).
+ ///
+ public static IServiceCollection AddSharpMudRpgRuleset(
+ this IServiceCollection services,
+ Action? registerConsumerCommands = null)
+ where TCombatOutcomeHandler : class, ICombatOutcomeHandler
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ 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.
+ services.AddSingleton(sp =>
+ new CombatManager(sp.GetRequiredService(), sp.GetRequiredService()));
+ services.AddSingleton(sp => (ITickable)sp.GetRequiredService());
+
+ services.AddSharpMudRuleset((sp, registry) =>
+ {
+ registry.Register(new AttackCommand(sp.GetRequiredService()));
+ registry.Register(new FleeCommand(
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ sp.GetRequiredService()));
+
+ registerConsumerCommands?.Invoke(sp, registry);
+ });
+
+ return services;
+ }
+}
diff --git a/src/SharpMud.Ruleset.Rpg/SharpMud.Ruleset.Rpg.csproj b/src/SharpMud.Ruleset.Rpg/SharpMud.Ruleset.Rpg.csproj
new file mode 100644
index 0000000..21edae2
--- /dev/null
+++ b/src/SharpMud.Ruleset.Rpg/SharpMud.Ruleset.Rpg.csproj
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
+
+ net10.0;net11.0
+ enable
+ enable
+ Reusable RPG scaffolding between SharpMud.Engine and a concrete ruleset - CombatantBehavior, combat resolution/encounter tracking, attack/flee commands, and a dice-rolling abstraction over IRandomSource.
+
+
+
diff --git a/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj b/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj
index eb990ec..2bf8194 100644
--- a/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj
+++ b/tests/SharpMud.Persistence.Tests/SharpMud.Persistence.Tests.csproj
@@ -42,6 +42,7 @@
+
diff --git a/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs b/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs
index 6d715f5..2bec471 100644
--- a/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs
+++ b/tests/SharpMud.Persistence.Tests/TestKit/TestDbContextFactory.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using SharpMud.Ruleset.Rpg;
using SharpMud.Samples.Classic;
namespace SharpMud.Persistence.Tests.TestKit;
@@ -16,7 +17,7 @@ public GameDbContext CreateDbContext()
var options = new DbContextOptionsBuilder()
.UseSqlite($"Data Source={_dbPath}")
.Options;
- var context = new GameDbContext(options, [new ClassicBehaviorMappingContributor()]);
+ var context = new GameDbContext(options, [new ClassicBehaviorMappingContributor(), new RpgBehaviorMappingContributor()]);
context.Database.EnsureCreated();
return context;
}
diff --git a/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs b/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs
index 60e0ec6..23c47cc 100644
--- a/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs
+++ b/tests/SharpMud.Persistence.Tests/ThingRepositoryTests.cs
@@ -1,6 +1,7 @@
using SharpMud.Engine.Behaviors;
using SharpMud.Engine.Core;
using SharpMud.Persistence.Tests.TestKit;
+using SharpMud.Ruleset.Rpg;
using SharpMud.Samples.Classic;
namespace SharpMud.Persistence.Tests;
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/BasicCombatOutcomeHandlerTests.cs b/tests/SharpMud.Ruleset.Basic.Tests/BasicCombatOutcomeHandlerTests.cs
new file mode 100644
index 0000000..4d9d5cf
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/BasicCombatOutcomeHandlerTests.cs
@@ -0,0 +1,59 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+using SharpMud.Hosting;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic.Tests;
+
+public sealed class BasicCombatOutcomeHandlerTests
+{
+ [Fact]
+ public async Task OnVictoryAsync_AwardsExperienceFromCombatantReward()
+ {
+ var session = Substitute.For();
+ var victor = new Thing { Id = ThingId.New(), Name = "Hero" };
+ victor.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ victor.Behaviors.Add(new BasicStatsBehavior { Experience = 0 });
+
+ var defeated = new Thing { Id = ThingId.New(), Name = "wild boar" };
+ defeated.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10 });
+
+ var worldContext = new WorldContext();
+ var sut = new BasicCombatOutcomeHandler(worldContext);
+
+ await sut.OnVictoryAsync(victor, defeated, TestContext.Current.CancellationToken);
+
+ victor.FindBehavior()!.Experience.Should().Be(10);
+ }
+
+ [Fact]
+ public async Task OnDefeatAsync_AppliesXpLossAndHalvesCombatHitPoints_AndReturnsStartingRoom()
+ {
+ var session = Substitute.For();
+ var defeated = new Thing { Id = ThingId.New(), Name = "Hero" };
+ defeated.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ defeated.Behaviors.Add(new BasicStatsBehavior { Experience = 100 });
+ defeated.Behaviors.Add(new CombatantBehavior { MaxHitPoints = 20, CurrentHitPoints = 20 });
+
+ var victor = new Thing { Id = ThingId.New(), Name = "wild boar" };
+
+ var startingRoom = new Thing { Id = ThingId.New(), Name = "Clearing" };
+ startingRoom.Behaviors.Add(new RoomBehavior());
+ var worldContext = new WorldContext();
+ worldContext.Initialize(new World(), startingRoom, startingRoom);
+
+ var sut = new BasicCombatOutcomeHandler(worldContext);
+
+ var destination = await sut.OnDefeatAsync(defeated, victor, TestContext.Current.CancellationToken);
+
+ destination.Should().Be(startingRoom);
+ defeated.FindBehavior()!.Experience.Should().Be(90);
+
+ // The value CombatResolver actually reads/writes during combat -
+ // BasicStatsBehavior carries no HP field of its own, so this is the
+ // only assertion that would catch the death penalty regressing to
+ // a no-op (full-HP respawn).
+ defeated.FindBehavior()!.CurrentHitPoints.Should().Be(10);
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/BasicPlayerFactoryTests.cs b/tests/SharpMud.Ruleset.Basic.Tests/BasicPlayerFactoryTests.cs
new file mode 100644
index 0000000..12678a8
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/BasicPlayerFactoryTests.cs
@@ -0,0 +1,33 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic.Tests;
+
+public sealed class BasicPlayerFactoryTests
+{
+ [Fact]
+ public void CreatePlayer_ReturnsThingWithPlayerAndBasicStatsAndCombatantBehaviors()
+ {
+ var options = new BasicRulesetOptions { StartingHitPoints = 15, StartingArmorClass = 9, StartingDamageMin = 2, StartingDamageMax = 5 };
+ var sut = new BasicPlayerFactory(options);
+ var world = new World();
+ var startingRoom = new Thing { Id = ThingId.New(), Name = "Clearing" };
+ startingRoom.Behaviors.Add(new RoomBehavior());
+
+ var player = sut.CreatePlayer(world, "Adventurer", "hash", startingRoom);
+
+ player.HasBehavior().Should().BeTrue();
+ player.HasBehavior().Should().BeTrue();
+
+ var combatant = player.FindBehavior();
+ combatant.Should().NotBeNull();
+ combatant!.MaxHitPoints.Should().Be(15);
+ combatant.ArmorClass.Should().Be(9);
+ combatant.DamageMin.Should().Be(2);
+ combatant.DamageMax.Should().Be(5);
+
+ player.Parent.Should().Be(startingRoom);
+ world.GetThing(player.Id).Should().Be(player);
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/BasicRulesetOptionsTests.cs b/tests/SharpMud.Ruleset.Basic.Tests/BasicRulesetOptionsTests.cs
new file mode 100644
index 0000000..5e1923b
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/BasicRulesetOptionsTests.cs
@@ -0,0 +1,48 @@
+namespace SharpMud.Ruleset.Basic.Tests;
+
+public sealed class BasicRulesetOptionsTests
+{
+ [Fact]
+ public void Validate_DoesNotThrow_ForDefaultOptions()
+ {
+ var sut = new BasicRulesetOptions();
+
+ var act = sut.Validate;
+
+ act.Should().NotThrow();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Validate_Throws_WhenStartingHitPointsIsNotPositive(int hitPoints)
+ {
+ var sut = new BasicRulesetOptions { StartingHitPoints = hitPoints };
+
+ var act = sut.Validate;
+
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-1)]
+ public void Validate_Throws_WhenStartingDamageMinIsNotPositive(int damageMin)
+ {
+ var sut = new BasicRulesetOptions { StartingDamageMin = damageMin };
+
+ var act = sut.Validate;
+
+ act.Should().Throw();
+ }
+
+ [Fact]
+ public void Validate_Throws_WhenStartingDamageMaxIsLessThanStartingDamageMin()
+ {
+ var sut = new BasicRulesetOptions { StartingDamageMin = 5, StartingDamageMax = 4 };
+
+ var act = sut.Validate;
+
+ act.Should().Throw();
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/BasicWorldBuilderTests.cs b/tests/SharpMud.Ruleset.Basic.Tests/BasicWorldBuilderTests.cs
new file mode 100644
index 0000000..b7c855b
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/BasicWorldBuilderTests.cs
@@ -0,0 +1,31 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic.Tests;
+
+public sealed class BasicWorldBuilderTests
+{
+ [Fact]
+ public void Build_ReturnsWorldWithAtLeastOneFightableNpc()
+ {
+ var sut = new BasicWorldBuilder();
+
+ var (world, startingRoom) = sut.Build();
+
+ var fightableNpcs = world.AllWithBehavior().Where(t => t.HasBehavior());
+ fightableNpcs.Should().NotBeEmpty("a fresh character must be able to walk around and fight something");
+ startingRoom.HasBehavior().Should().BeTrue();
+ }
+
+ [Fact]
+ public void FindStartingRoom_ReturnsTheClearing_AfterReload()
+ {
+ var sut = new BasicWorldBuilder();
+ var (_, startingRoom) = sut.Build();
+ var area = startingRoom.Parent!;
+
+ var found = sut.FindStartingRoom(area);
+
+ found.Should().Be(startingRoom);
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/PersistenceRoundTripTests.cs b/tests/SharpMud.Ruleset.Basic.Tests/PersistenceRoundTripTests.cs
new file mode 100644
index 0000000..7f05c8b
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/PersistenceRoundTripTests.cs
@@ -0,0 +1,47 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Persistence;
+using SharpMud.Ruleset.Basic.Tests.TestKit;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic.Tests;
+
+public sealed class PersistenceRoundTripTests : IDisposable
+{
+ private readonly TestDbContextFactory _factory = new();
+ private readonly ThingRepository _sut;
+
+ public PersistenceRoundTripTests()
+ {
+ _sut = new ThingRepository(_factory);
+ }
+
+ public void Dispose() => _factory.Dispose();
+
+ // Without BasicBehaviorMappingContributor actually being registered and
+ // discovered, a Basic world/player carrying BasicStatsBehavior hits an
+ // unmapped TPH discriminator subtype - only a real save/load round trip
+ // catches this, not a unit test against the behavior alone.
+ [Fact]
+ public async Task SaveTreeAsync_ThenLoadTreeAsync_RoundTripsPlayerWithBasicStatsAndCombatant()
+ {
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash" });
+ player.Behaviors.Add(new BasicStatsBehavior { Level = 2, Experience = 150 });
+ player.Behaviors.Add(new CombatantBehavior { MaxHitPoints = 20, CurrentHitPoints = 12, ArmorClass = 10 });
+
+ await _sut.SaveTreeAsync(player, TestContext.Current.CancellationToken);
+
+ var loaded = await _sut.LoadTreeAsync(player.Id, TestContext.Current.CancellationToken);
+
+ loaded.Should().NotBeNull();
+ var stats = loaded!.FindBehavior();
+ stats.Should().NotBeNull();
+ stats!.Level.Should().Be(2);
+ stats.Experience.Should().Be(150);
+
+ var combatant = loaded.FindBehavior();
+ combatant.Should().NotBeNull();
+ combatant!.CurrentHitPoints.Should().Be(12);
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/ServiceCollectionExtensionsTests.cs b/tests/SharpMud.Ruleset.Basic.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..f333ab2
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.DependencyInjection;
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Hosting;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic.Tests;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ // Without a real IPlayerFactory registration, LoginFlow/PlayerLogin
+ // (which constructor-inject IPlayerFactory) can't create a fresh
+ // CLI/Telnet player at all - the quick-start would fail at first login,
+ // not just "nothing to see".
+ [Fact]
+ public void AddSharpMudBasicRuleset_RegistersPlayerFactory_ThatProducesAPlayableCharacter()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(Substitute.For());
+
+ services.AddSharpMudBasicRuleset();
+
+ var provider = services.BuildServiceProvider();
+ var factory = provider.GetRequiredService();
+
+ var world = new World();
+ var startingRoom = new Thing { Id = ThingId.New(), Name = "Clearing" };
+ startingRoom.Behaviors.Add(new RoomBehavior());
+
+ var player = factory.CreatePlayer(world, "Adventurer", "hash", startingRoom);
+
+ player.HasBehavior().Should().BeTrue();
+ player.HasBehavior().Should().BeTrue();
+ player.HasBehavior().Should().BeTrue();
+ }
+
+ [Fact]
+ public void AddSharpMudBasicRuleset_AppliesConfigureOptionsCallback()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(Substitute.For());
+
+ services.AddSharpMudBasicRuleset(options => options.StartingHitPoints = 42);
+
+ var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService();
+
+ options.StartingHitPoints.Should().Be(42);
+ }
+
+ // Without this, an invalid StartingHitPoints/StartingDamageMin/Max only
+ // surfaces the first time a fight actually happens - IRandomSource.Next(min, max)
+ // throwing mid-combat, not a clear failure at startup.
+ [Fact]
+ public void AddSharpMudBasicRuleset_ThrowsAtCompositionRootTime_WhenOptionsAreInvalid()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(Substitute.For());
+
+ var act = () => services.AddSharpMudBasicRuleset(options => options.StartingHitPoints = 0);
+
+ act.Should().Throw();
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/SharpMud.Ruleset.Basic.Tests.csproj b/tests/SharpMud.Ruleset.Basic.Tests/SharpMud.Ruleset.Basic.Tests.csproj
new file mode 100644
index 0000000..b9b9642
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/SharpMud.Ruleset.Basic.Tests.csproj
@@ -0,0 +1,48 @@
+
+
+
+ enable
+ enable
+ Exe
+ SharpMud.Ruleset.Basic.Tests
+ net11.0
+ false
+ true
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/TestKit/Attributes/BasicAutoDataAttribute.cs b/tests/SharpMud.Ruleset.Basic.Tests/TestKit/Attributes/BasicAutoDataAttribute.cs
new file mode 100644
index 0000000..a598059
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/TestKit/Attributes/BasicAutoDataAttribute.cs
@@ -0,0 +1,12 @@
+using AutoFixture;
+using AutoFixture.Xunit3;
+
+namespace SharpMud.Ruleset.Basic.Tests.TestKit.Attributes;
+
+public sealed class BasicAutoDataAttribute() : AutoDataAttribute(CreateFixture)
+{
+ internal static IFixture CreateFixture() => BaseFixtureFactory.CreateFixture();
+}
+
+public sealed class InlineBasicAutoDataAttribute(params object[] values)
+ : InlineAutoDataAttribute(BasicAutoDataAttribute.CreateFixture, values);
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/TestKit/BaseFixtureFactory.cs b/tests/SharpMud.Ruleset.Basic.Tests/TestKit/BaseFixtureFactory.cs
new file mode 100644
index 0000000..278c6a0
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/TestKit/BaseFixtureFactory.cs
@@ -0,0 +1,22 @@
+using AutoFixture;
+using AutoFixture.AutoNSubstitute;
+
+namespace SharpMud.Ruleset.Basic.Tests.TestKit;
+
+public static class BaseFixtureFactory
+{
+ public static IFixture CreateFixture(Action? customizeAction = null)
+ {
+ var fixture = new Fixture();
+
+ fixture.Behaviors.OfType().ToList()
+ .ForEach(b => fixture.Behaviors.Remove(b));
+ fixture.Behaviors.Add(new OmitOnRecursionBehavior());
+
+ fixture.Customize(new AutoNSubstituteCustomization { ConfigureMembers = true });
+
+ customizeAction?.Invoke(fixture);
+
+ return fixture;
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/TestKit/TestDbContextFactory.cs b/tests/SharpMud.Ruleset.Basic.Tests/TestKit/TestDbContextFactory.cs
new file mode 100644
index 0000000..70cfc5e
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/TestKit/TestDbContextFactory.cs
@@ -0,0 +1,31 @@
+using Microsoft.EntityFrameworkCore;
+using SharpMud.Persistence;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Ruleset.Basic.Tests.TestKit;
+
+// A temp-file SQLite DB, not in-memory - matches
+// SharpMud.Persistence.Tests.TestKit.TestDbContextFactory's reasoning.
+public sealed class TestDbContextFactory : IDbContextFactory, IDisposable
+{
+ private readonly string _dbPath = Path.Combine(Path.GetTempPath(), $"sharpmud-basic-test-{Guid.NewGuid()}.db");
+
+ public GameDbContext CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={_dbPath}")
+ .Options;
+ var context = new GameDbContext(options, [new BasicBehaviorMappingContributor(), new RpgBehaviorMappingContributor()]);
+ context.Database.EnsureCreated();
+ return context;
+ }
+
+ public Task CreateDbContextAsync(CancellationToken ct = default) =>
+ Task.FromResult(CreateDbContext());
+
+ public void Dispose()
+ {
+ if (File.Exists(_dbPath))
+ File.Delete(_dbPath);
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Basic.Tests/xunit.runner.json b/tests/SharpMud.Ruleset.Basic.Tests/xunit.runner.json
new file mode 100644
index 0000000..86c7ea0
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Basic.Tests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/Combat/CombatManagerTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/Combat/CombatManagerTests.cs
new file mode 100644
index 0000000..1f1308c
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/Combat/CombatManagerTests.cs
@@ -0,0 +1,227 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+using SharpMud.Engine.Ticking;
+
+namespace SharpMud.Ruleset.Rpg.Tests.Combat;
+
+public sealed class CombatManagerTests
+{
+ [Fact]
+ public void IsDefenderEngaged_ReturnsTrue_WhenAnotherAttackerAlreadyTargetsTheSameDefender()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+
+ var attackerOne = new Thing { Id = ThingId.New(), Name = "Hero One" };
+ var attackerTwo = new Thing { Id = ThingId.New(), Name = "Hero Two" };
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+ sut.TryStartEncounter(attackerOne, npc);
+
+ sut.IsDefenderEngaged(npc.Id).Should().BeTrue();
+ sut.IsDefenderEngaged(attackerTwo.Id).Should().BeFalse();
+ }
+
+ [Fact]
+ public void TryStartEncounter_ReturnsFalse_WhenDefenderIsAlreadyEngagedByAnotherAttacker()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+
+ var attackerOne = new Thing { Id = ThingId.New(), Name = "Hero One" };
+ var attackerTwo = new Thing { Id = ThingId.New(), Name = "Hero Two" };
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+
+ sut.TryStartEncounter(attackerOne, npc).Should().BeTrue();
+ sut.TryStartEncounter(attackerTwo, npc).Should().BeFalse();
+ }
+
+ // Regression coverage for the actual concurrency bug (two players'
+ // independent session-loop tasks both targeting the same NPC at nearly
+ // the same time) - not just the sequential "second call fails" case
+ // above, which wouldn't have caught the original TOCTOU race between a
+ // separate IsDefenderEngaged check and StartEncounter.
+ [Fact]
+ public async Task TryStartEncounter_AllowsExactlyOneCaller_WhenManyAttackersRaceTheSameDefender()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ var sut = new CombatManager(resolver, outcomeHandler);
+
+ var attackers = Enumerable.Range(0, 32)
+ .Select(i => new Thing { Id = ThingId.New(), Name = $"Hero {i}" })
+ .ToArray();
+
+ var results = await Task.WhenAll(attackers.Select(attacker =>
+ Task.Run(() => sut.TryStartEncounter(attacker, npc))));
+
+ results.Count(succeeded => succeeded).Should().Be(1);
+ }
+
+ [Fact]
+ public async Task OnTickAsync_NotifiesOutcomeHandlerAndEndsEncounter_WhenPlayerDefeatsNpc()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+ var session = Substitute.For();
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 0 });
+ room.Add(npc);
+
+ resolver.ResolveRound(player, npc).Returns(new CombatRoundResult(true, 6, true));
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+ sut.TryStartEncounter(player, npc);
+
+ await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
+
+ await outcomeHandler.Received(1).OnVictoryAsync(player, npc, TestContext.Current.CancellationToken);
+ room.Children.Should().NotContain(npc);
+ resolver.DidNotReceive().ResolveRound(npc, player);
+ sut.IsInCombat(player.Id).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task OnTickAsync_ResetsCombatantHitPointsAndRespawnsAtHandlerDestination_WhenNpcDefeatsPlayer()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+ var session = Substitute.For();
+ var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ player.Behaviors.Add(new CombatantBehavior { MaxHitPoints = 20, CurrentHitPoints = -5 });
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 6 });
+ room.Add(npc);
+
+ resolver.ResolveRound(player, npc).Returns(new CombatRoundResult(false, 0, false));
+ resolver.ResolveRound(npc, player).Returns(new CombatRoundResult(true, 999, true));
+ outcomeHandler.OnDefeatAsync(player, npc, TestContext.Current.CancellationToken).Returns(hubRoom);
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+ sut.TryStartEncounter(player, npc);
+
+ await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
+
+ // Regression coverage for the pre-existing bug: a respawned
+ // character's CombatantBehavior.CurrentHitPoints must actually
+ // reset, not stay at/below 0 and instantly re-trigger "defeated" on
+ // the next hit.
+ player.FindBehavior()!.CurrentHitPoints.Should().Be(20);
+ player.Parent.Should().Be(hubRoom);
+ sut.IsInCombat(player.Id).Should().BeFalse();
+ }
+
+ [Fact]
+ public async Task OnTickAsync_SendsDefeatMessageBeforeInvokingOutcomeHandler_WhenNpcDefeatsPlayer()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+ var session = Substitute.For();
+ var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
+ var callOrder = new List();
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ player.Behaviors.Add(new CombatantBehavior { MaxHitPoints = 20, CurrentHitPoints = 20 });
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior { CurrentHitPoints = 6 });
+ room.Add(npc);
+
+ resolver.ResolveRound(player, npc).Returns(new CombatRoundResult(false, 0, false));
+ resolver.ResolveRound(npc, player).Returns(new CombatRoundResult(true, 999, true));
+ session.When(s => s.WriteLineAsync("cave rat has slain you!", Arg.Any()))
+ .Do(_ => callOrder.Add("defeat-message"));
+ outcomeHandler.OnDefeatAsync(player, npc, TestContext.Current.CancellationToken)
+ .Returns(hubRoom)
+ .AndDoes(_ => callOrder.Add("outcome-handler"));
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+ sut.TryStartEncounter(player, npc);
+
+ await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
+
+ // Matches the message order from before the ADR-0008 extraction -
+ // the generic "has slain you!" message must still arrive before any
+ // ruleset-specific outcome-handler messaging (e.g. an XP-loss line).
+ callOrder.Should().Equal("defeat-message", "outcome-handler");
+ }
+
+ [Fact]
+ public async Task OnTickAsync_FreezesEncounter_WhenAttackerLinkdeadWithinGraceWindow()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+ var session = Substitute.For();
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ var playerBehavior = new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session };
+ playerBehavior.EnterLinkdead(DateTimeOffset.UtcNow);
+ player.Behaviors.Add(playerBehavior);
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 6 });
+ room.Add(npc);
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+ sut.TryStartEncounter(player, npc);
+
+ await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
+
+ sut.IsInCombat(player.Id).Should().BeTrue();
+ resolver.DidNotReceiveWithAnyArgs().ResolveRound(default!, default!);
+ await session.DidNotReceiveWithAnyArgs().WriteLineAsync(default!, TestContext.Current.CancellationToken);
+ }
+
+ [Fact]
+ public async Task OnTickAsync_AbandonsEncounter_WhenAttackerLinkdeadPastGraceWindow()
+ {
+ var resolver = Substitute.For();
+ var outcomeHandler = Substitute.For();
+ var session = Substitute.For();
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ var playerBehavior = new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session };
+ playerBehavior.EnterLinkdead(DateTimeOffset.UtcNow - ReconnectPolicy.GraceWindow - TimeSpan.FromSeconds(1));
+ player.Behaviors.Add(playerBehavior);
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 6 });
+ room.Add(npc);
+
+ var sut = new CombatManager(resolver, outcomeHandler);
+ sut.TryStartEncounter(player, npc);
+
+ await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
+
+ sut.IsInCombat(player.Id).Should().BeFalse();
+ }
+}
diff --git a/tests/SharpMud.Samples.Classic.Tests/Combat/CombatResolverTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/Combat/CombatResolverTests.cs
similarity index 83%
rename from tests/SharpMud.Samples.Classic.Tests/Combat/CombatResolverTests.cs
rename to tests/SharpMud.Ruleset.Rpg.Tests/Combat/CombatResolverTests.cs
index 0922ff8..e2e4b5a 100644
--- a/tests/SharpMud.Samples.Classic.Tests/Combat/CombatResolverTests.cs
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/Combat/CombatResolverTests.cs
@@ -1,20 +1,21 @@
using SharpMud.Engine.Core;
-namespace SharpMud.Samples.Classic.Tests.Combat;
+namespace SharpMud.Ruleset.Rpg.Tests.Combat;
public sealed class CombatResolverTests
{
[Fact]
public void ResolveRound_AppliesDamageAndReportsHit_WhenToHitRollMeetsArmorClass()
{
+ var dice = Substitute.For();
var random = Substitute.For();
- random.Next(1, 20).Returns(10);
+ dice.Roll(1, 20).Returns(10);
random.Next(2, 5).Returns(3);
var attacker = MakeCombatant("Attacker", damageMin: 2, damageMax: 5);
var defender = MakeCombatant("Defender", armorClass: 10, hitPoints: 10);
- var sut = new CombatResolver(random);
+ var sut = new CombatResolver(dice, random);
var result = sut.ResolveRound(attacker, defender);
@@ -27,13 +28,14 @@ public void ResolveRound_AppliesDamageAndReportsHit_WhenToHitRollMeetsArmorClass
[Fact]
public void ResolveRound_ReportsMissAndAppliesNoDamage_WhenToHitRollIsBelowArmorClass()
{
+ var dice = Substitute.For();
var random = Substitute.For();
- random.Next(1, 20).Returns(5);
+ dice.Roll(1, 20).Returns(5);
var attacker = MakeCombatant("Attacker");
var defender = MakeCombatant("Defender", armorClass: 10, hitPoints: 10);
- var sut = new CombatResolver(random);
+ var sut = new CombatResolver(dice, random);
var result = sut.ResolveRound(attacker, defender);
@@ -45,14 +47,15 @@ public void ResolveRound_ReportsMissAndAppliesNoDamage_WhenToHitRollIsBelowArmor
[Fact]
public void ResolveRound_ReportsDefenderDefeated_WhenDamageDropsHitPointsToZeroOrBelow()
{
+ var dice = Substitute.For();
var random = Substitute.For();
- random.Next(1, 20).Returns(20);
+ dice.Roll(1, 20).Returns(20);
random.Next(1, 4).Returns(4);
var attacker = MakeCombatant("Attacker", damageMin: 1, damageMax: 4);
var defender = MakeCombatant("Defender", armorClass: 10, hitPoints: 3);
- var sut = new CombatResolver(random);
+ var sut = new CombatResolver(dice, random);
var result = sut.ResolveRound(attacker, defender);
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/Combat/DiceRollerTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/Combat/DiceRollerTests.cs
new file mode 100644
index 0000000..6b0ce4b
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/Combat/DiceRollerTests.cs
@@ -0,0 +1,58 @@
+using SharpMud.Engine.Core;
+
+namespace SharpMud.Ruleset.Rpg.Tests.Combat;
+
+public sealed class DiceRollerTests
+{
+ [Fact]
+ public void Roll_SumsEachDieResultPlusModifier()
+ {
+ var random = Substitute.For();
+ random.Next(1, 6).Returns(3, 5, 2);
+
+ var sut = new DiceRoller(random);
+
+ var result = sut.Roll(3, 6, modifier: 4);
+
+ result.Should().Be(3 + 5 + 2 + 4);
+ }
+
+ [Fact]
+ public void Roll_DefaultsModifierToZero()
+ {
+ var random = Substitute.For();
+ random.Next(1, 20).Returns(15);
+
+ var sut = new DiceRoller(random);
+
+ var result = sut.Roll(1, 20);
+
+ result.Should().Be(15);
+ }
+
+ [Theory]
+ [InlineData(0, 6)]
+ [InlineData(-1, 6)]
+ public void Roll_Throws_WhenDiceCountIsLessThanOne(int diceCount, int sides)
+ {
+ var random = Substitute.For();
+ var sut = new DiceRoller(random);
+
+ var act = () => sut.Roll(diceCount, sides);
+
+ act.Should().Throw();
+ }
+
+ [Theory]
+ [InlineData(1, 0)]
+ [InlineData(1, -1)]
+ public void Roll_Throws_WhenSidesIsLessThanOne(int diceCount, int sides)
+ {
+ var random = Substitute.For();
+ var sut = new DiceRoller(random);
+
+ var act = () => sut.Roll(diceCount, sides);
+
+ act.Should().Throw();
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/Commands/AttackCommandTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/Commands/AttackCommandTests.cs
new file mode 100644
index 0000000..49a46c3
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/Commands/AttackCommandTests.cs
@@ -0,0 +1,128 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Commands;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+
+namespace SharpMud.Ruleset.Rpg.Tests.Commands;
+
+public sealed class AttackCommandTests
+{
+ [Fact]
+ public async Task ExecuteAsync_StartsEncounter_WhenTargetExists()
+ {
+ var combatManager = Substitute.For();
+ var session = Substitute.For();
+ combatManager.TryStartEncounter(Arg.Any(), Arg.Any()).Returns(true);
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new CombatantBehavior());
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior());
+ room.Add(npc);
+
+ var sut = new AttackCommand(combatManager);
+ var ctx = new CommandContext(player, room, ["cave", "rat"], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.Received(1).TryStartEncounter(player, npc);
+ await session.Received(1).WriteLineAsync("You attack cave rat!", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SendsNotHereMessage_WhenNoMatchingCombatantInRoom()
+ {
+ var combatManager = Substitute.For();
+ var session = Substitute.For();
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new CombatantBehavior());
+ room.Add(player);
+
+ var sut = new AttackCommand(combatManager);
+ var ctx = new CommandContext(player, room, ["dragon"], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().TryStartEncounter(default!, default!);
+ await session.Received(1).WriteLineAsync("You don't see that here.", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SendsCannotFightMessage_WhenActorHasNoCombatantBehavior()
+ {
+ var combatManager = Substitute.For();
+ var session = Substitute.For();
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior());
+ room.Add(npc);
+
+ var sut = new AttackCommand(combatManager);
+ var ctx = new CommandContext(player, room, ["cave", "rat"], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().TryStartEncounter(default!, default!);
+ await session.Received(1).WriteLineAsync("You have no way to fight.", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SendsAlreadyEngagedMessage_WhenTryStartEncounterFails()
+ {
+ var combatManager = Substitute.For();
+ var session = Substitute.For();
+ combatManager.TryStartEncounter(Arg.Any(), Arg.Any()).Returns(false);
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new CombatantBehavior());
+ room.Add(player);
+
+ var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ npc.Behaviors.Add(new NpcBehavior());
+ npc.Behaviors.Add(new CombatantBehavior());
+ room.Add(npc);
+
+ var sut = new AttackCommand(combatManager);
+ var ctx = new CommandContext(player, room, ["cave", "rat"], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ // TryStartEncounter is called (and is the actual source of truth for
+ // this failure) rather than skipped - distinguishes this from the
+ // guard-clause tests above, which never even attempt to start.
+ combatManager.Received(1).TryStartEncounter(player, npc);
+ await session.Received(1).WriteLineAsync("Someone else is already fighting cave rat!", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SendsAlreadyFightingMessage_WhenActorAlreadyInCombat()
+ {
+ var combatManager = Substitute.For();
+ var session = Substitute.For();
+ combatManager.IsInCombat(Arg.Any()).Returns(true);
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ room.Add(player);
+
+ var sut = new AttackCommand(combatManager);
+ var ctx = new CommandContext(player, room, ["cave", "rat"], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().TryStartEncounter(default!, default!);
+ await session.Received(1).WriteLineAsync("You are already fighting!", Arg.Any());
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/Commands/FleeCommandTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/Commands/FleeCommandTests.cs
new file mode 100644
index 0000000..b28ea24
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/Commands/FleeCommandTests.cs
@@ -0,0 +1,212 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Commands;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+
+namespace SharpMud.Ruleset.Rpg.Tests.Commands;
+
+public sealed class FleeCommandTests
+{
+ [Fact]
+ public async Task ExecuteAsync_MovesActorAndEndsEncounter_WhenRollSucceeds()
+ {
+ var combatManager = Substitute.For();
+ var dice = Substitute.For();
+ var random = Substitute.For();
+ var session = Substitute.For();
+ var encounter = new CombatEncounter { Attacker = new Thing { Id = ThingId.New(), Name = "x" }, Defender = new Thing { Id = ThingId.New(), Name = "y" } };
+ combatManager.TryGetEncounter(Arg.Any(), out Arg.Any())
+ .Returns(x => { x[1] = encounter; return true; });
+ dice.Roll(1, 100).Returns(1);
+ random.Next(0, 0).Returns(0);
+
+ var origin = new Thing { Id = ThingId.New(), Name = "Origin" };
+ var destination = new Thing { Id = ThingId.New(), Name = "Destination", Description = "A quiet place." };
+ var exit = new Thing { Id = ThingId.New(), Name = "north" };
+ exit.Behaviors.Add(new ExitBehavior { Direction = Direction.North, Destination = destination });
+ origin.Add(exit);
+
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ origin.Add(player);
+
+ var sut = new FleeCommand(combatManager, dice, random);
+ var ctx = new CommandContext(player, origin, [], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.Received(1).EndEncounter(player.Id);
+ player.Parent.Should().Be(destination);
+ origin.Children.Should().NotContain(player);
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_DoesNotMoveOrEndEncounter_WhenChosenExitIsLocked()
+ {
+ var combatManager = Substitute.For();
+ var dice = Substitute.For();
+ var random = Substitute.For();
+ var session = Substitute.For();
+ var encounter = new CombatEncounter { Attacker = new Thing { Id = ThingId.New(), Name = "x" }, Defender = new Thing { Id = ThingId.New(), Name = "y" } };
+ combatManager.TryGetEncounter(Arg.Any(), out Arg.Any())
+ .Returns(x => { x[1] = encounter; return true; });
+ dice.Roll(1, 100).Returns(1);
+ random.Next(0, 0).Returns(0);
+
+ var origin = new Thing { Id = ThingId.New(), Name = "Origin" };
+ var destination = new Thing { Id = ThingId.New(), Name = "Destination" };
+ var exit = new Thing { Id = ThingId.New(), Name = "north" };
+ exit.Behaviors.Add(new ExitBehavior { Direction = Direction.North, Destination = destination });
+ exit.Behaviors.Add(new LockableBehavior { IsLocked = true });
+ origin.Add(exit);
+
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ origin.Add(player);
+
+ var sut = new FleeCommand(combatManager, dice, random);
+ var ctx = new CommandContext(player, origin, [], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().EndEncounter(default!);
+ player.Parent.Should().Be(origin);
+ await session.Received(1).WriteLineAsync("The door is locked.", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_DoesNotMoveOrEndEncounter_WhenLeavingTheRoomIsVetoed()
+ {
+ var combatManager = Substitute.For();
+ var dice = Substitute.For();
+ var random = Substitute.For();
+ var session = Substitute.For();
+ var encounter = new CombatEncounter { Attacker = new Thing { Id = ThingId.New(), Name = "x" }, Defender = new Thing { Id = ThingId.New(), Name = "y" } };
+ combatManager.TryGetEncounter(Arg.Any(), out Arg.Any())
+ .Returns(x => { x[1] = encounter; return true; });
+ dice.Roll(1, 100).Returns(1);
+ random.Next(0, 0).Returns(0);
+
+ var origin = new Thing { Id = ThingId.New(), Name = "Origin" };
+ var destination = new Thing { Id = ThingId.New(), Name = "Destination" };
+ var exit = new Thing { Id = ThingId.New(), Name = "north" };
+ exit.Behaviors.Add(new ExitBehavior { Direction = Direction.North, Destination = destination });
+ origin.Add(exit);
+
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ origin.Add(player);
+
+ // Registered after Add so it only vetoes the flee's own Remove, not
+ // Thing.Add's own AddChildEvent publish on the same origin.Events.
+ origin.Events.SubscribeRequest((_, evt) =>
+ {
+ if (evt is RemoveChildEvent)
+ evt.Cancel("You are rooted to the spot.");
+ });
+
+ var sut = new FleeCommand(combatManager, dice, random);
+ var ctx = new CommandContext(player, origin, [], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().EndEncounter(default!);
+ player.Parent.Should().Be(origin);
+ destination.Children.Should().NotContain(player);
+ await session.Received(1).WriteLineAsync("You can't escape that way!", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_RollsBackAndDoesNotEndEncounter_WhenEnteringDestinationIsVetoed()
+ {
+ var combatManager = Substitute.For();
+ var dice = Substitute.For();
+ var random = Substitute.For();
+ var session = Substitute.For();
+ var encounter = new CombatEncounter { Attacker = new Thing { Id = ThingId.New(), Name = "x" }, Defender = new Thing { Id = ThingId.New(), Name = "y" } };
+ combatManager.TryGetEncounter(Arg.Any(), out Arg.Any())
+ .Returns(x => { x[1] = encounter; return true; });
+ dice.Roll(1, 100).Returns(1);
+ random.Next(0, 0).Returns(0);
+
+ var origin = new Thing { Id = ThingId.New(), Name = "Origin" };
+ var destination = new Thing { Id = ThingId.New(), Name = "Destination" };
+ destination.Events.SubscribeRequest((_, evt) =>
+ {
+ if (evt is AddChildEvent)
+ evt.Cancel("The room is full.");
+ });
+ var exit = new Thing { Id = ThingId.New(), Name = "north" };
+ exit.Behaviors.Add(new ExitBehavior { Direction = Direction.North, Destination = destination });
+ origin.Add(exit);
+
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ origin.Add(player);
+
+ var sut = new FleeCommand(combatManager, dice, random);
+ var ctx = new CommandContext(player, origin, [], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().EndEncounter(default!);
+ player.Parent.Should().Be(origin, "a vetoed Add must roll back the earlier Remove, not leave the actor parentless");
+ origin.Children.Should().Contain(player);
+ destination.Children.Should().NotContain(player);
+ await session.Received(1).WriteLineAsync("You can't escape that way!", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SendsFailureMessage_WhenRollFails()
+ {
+ var combatManager = Substitute.For();
+ var dice = Substitute.For();
+ var random = Substitute.For();
+ var session = Substitute.For();
+ var encounter = new CombatEncounter { Attacker = new Thing { Id = ThingId.New(), Name = "x" }, Defender = new Thing { Id = ThingId.New(), Name = "y" } };
+ combatManager.TryGetEncounter(Arg.Any(), out Arg.Any())
+ .Returns(x => { x[1] = encounter; return true; });
+ dice.Roll(1, 100).Returns(100);
+
+ var origin = new Thing { Id = ThingId.New(), Name = "Origin" };
+ var destination = new Thing { Id = ThingId.New(), Name = "Destination" };
+ var exit = new Thing { Id = ThingId.New(), Name = "north" };
+ exit.Behaviors.Add(new ExitBehavior { Direction = Direction.North, Destination = destination });
+ origin.Add(exit);
+
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ origin.Add(player);
+
+ var sut = new FleeCommand(combatManager, dice, random);
+ var ctx = new CommandContext(player, origin, [], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ combatManager.DidNotReceiveWithAnyArgs().EndEncounter(default!);
+ player.Parent.Should().Be(origin);
+ await session.Received(1).WriteLineAsync("You fail to escape!", Arg.Any());
+ }
+
+ [Fact]
+ public async Task ExecuteAsync_SendsNotFightingMessage_WhenNoActiveEncounter()
+ {
+ var combatManager = Substitute.For();
+ var dice = Substitute.For();
+ var random = Substitute.For();
+ var session = Substitute.For();
+ combatManager.TryGetEncounter(Arg.Any(), out Arg.Any()).Returns(false);
+
+ var room = new Thing { Id = ThingId.New(), Name = "Room" };
+ var player = new Thing { Id = ThingId.New(), Name = "Hero" };
+ player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ room.Add(player);
+
+ var sut = new FleeCommand(combatManager, dice, random);
+ var ctx = new CommandContext(player, room, [], new World(), session);
+
+ await sut.ExecuteAsync(ctx, TestContext.Current.CancellationToken);
+
+ await session.Received(1).WriteLineAsync("You aren't fighting anything.", Arg.Any());
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs b/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs
new file mode 100644
index 0000000..3f8cc35
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/ServiceCollectionExtensionsTests.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.DependencyInjection;
+using SharpMud.Engine.Commands;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Ticking;
+
+namespace SharpMud.Ruleset.Rpg.Tests;
+
+public sealed class ServiceCollectionExtensionsTests
+{
+ // Proves builtin commands, this package's kill/attack/flee, and a
+ // consumer's own registered command all end up in the same resolved
+ // ICommandRegistry - the seam most likely to silently regress (one
+ // registration source clobbering another) per
+ // docs/adr/0008-ruleset-scaffolding-tier.md.
+ [Fact]
+ public void AddSharpMudRpgRuleset_ComposesBuiltinRpgAndConsumerCommands_IntoOneRegistry()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(Substitute.For());
+
+ services.AddSharpMudRpgRuleset((_, registry) =>
+ registry.Register(new FakeConsumerCommand()));
+
+ var provider = services.BuildServiceProvider();
+ var registry = provider.GetRequiredService();
+
+ registry.TryResolve("look", out _).Should().BeTrue("built-in commands must still be registered");
+ registry.TryResolve("kill", out var killCommand).Should().BeTrue();
+ registry.TryResolve("attack", out var attackAliasCommand).Should().BeTrue();
+ killCommand.Should().BeSameAs(attackAliasCommand, "attack is kill's alias, not a separate command");
+ registry.TryResolve("flee", out _).Should().BeTrue();
+ registry.TryResolve("dance", out _).Should().BeTrue("a consumer's own command must not be clobbered");
+ }
+
+ [Fact]
+ public void AddSharpMudRpgRuleset_RegistersCombatManagerAsBothItselfAndTickable_OffTheSameInstance()
+ {
+ var services = new ServiceCollection();
+ services.AddSingleton(Substitute.For());
+
+ services.AddSharpMudRpgRuleset();
+
+ var provider = services.BuildServiceProvider();
+ var combatManager = provider.GetRequiredService();
+ var tickable = provider.GetRequiredService();
+
+ tickable.Should().BeSameAs(combatManager);
+ }
+
+ private sealed class FakeCombatOutcomeHandler : ICombatOutcomeHandler
+ {
+ public Task OnVictoryAsync(Thing victor, Thing defeated, CancellationToken ct) => Task.CompletedTask;
+
+ public Task OnDefeatAsync(Thing defeated, Thing victor, CancellationToken ct) => Task.FromResult(defeated);
+ }
+
+ private sealed class FakeConsumerCommand : ICommand
+ {
+ public string Verb => "dance";
+ public IReadOnlyList Aliases { get; } = [];
+
+ public Task ExecuteAsync(CommandContext ctx, CancellationToken ct) => Task.CompletedTask;
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/SharpMud.Ruleset.Rpg.Tests.csproj b/tests/SharpMud.Ruleset.Rpg.Tests/SharpMud.Ruleset.Rpg.Tests.csproj
new file mode 100644
index 0000000..ad35a61
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/SharpMud.Ruleset.Rpg.Tests.csproj
@@ -0,0 +1,46 @@
+
+
+
+ enable
+ enable
+ Exe
+ SharpMud.Ruleset.Rpg.Tests
+ net11.0
+ false
+ true
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/TestKit/Attributes/RpgAutoDataAttribute.cs b/tests/SharpMud.Ruleset.Rpg.Tests/TestKit/Attributes/RpgAutoDataAttribute.cs
new file mode 100644
index 0000000..747aa80
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/TestKit/Attributes/RpgAutoDataAttribute.cs
@@ -0,0 +1,12 @@
+using AutoFixture;
+using AutoFixture.Xunit3;
+
+namespace SharpMud.Ruleset.Rpg.Tests.TestKit.Attributes;
+
+public sealed class RpgAutoDataAttribute() : AutoDataAttribute(CreateFixture)
+{
+ internal static IFixture CreateFixture() => BaseFixtureFactory.CreateFixture();
+}
+
+public sealed class InlineRpgAutoDataAttribute(params object[] values)
+ : InlineAutoDataAttribute(RpgAutoDataAttribute.CreateFixture, values);
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/TestKit/BaseFixtureFactory.cs b/tests/SharpMud.Ruleset.Rpg.Tests/TestKit/BaseFixtureFactory.cs
new file mode 100644
index 0000000..d7bd71f
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/TestKit/BaseFixtureFactory.cs
@@ -0,0 +1,22 @@
+using AutoFixture;
+using AutoFixture.AutoNSubstitute;
+
+namespace SharpMud.Ruleset.Rpg.Tests.TestKit;
+
+public static class BaseFixtureFactory
+{
+ public static IFixture CreateFixture(Action? customizeAction = null)
+ {
+ var fixture = new Fixture();
+
+ fixture.Behaviors.OfType().ToList()
+ .ForEach(b => fixture.Behaviors.Remove(b));
+ fixture.Behaviors.Add(new OmitOnRecursionBehavior());
+
+ fixture.Customize(new AutoNSubstituteCustomization { ConfigureMembers = true });
+
+ customizeAction?.Invoke(fixture);
+
+ return fixture;
+ }
+}
diff --git a/tests/SharpMud.Ruleset.Rpg.Tests/xunit.runner.json b/tests/SharpMud.Ruleset.Rpg.Tests/xunit.runner.json
new file mode 100644
index 0000000..86c7ea0
--- /dev/null
+++ b/tests/SharpMud.Ruleset.Rpg.Tests/xunit.runner.json
@@ -0,0 +1,3 @@
+{
+ "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json"
+}
diff --git a/tests/SharpMud.Samples.Classic.Tests/ClassicCombatOutcomeHandlerTests.cs b/tests/SharpMud.Samples.Classic.Tests/ClassicCombatOutcomeHandlerTests.cs
new file mode 100644
index 0000000..a03e7c5
--- /dev/null
+++ b/tests/SharpMud.Samples.Classic.Tests/ClassicCombatOutcomeHandlerTests.cs
@@ -0,0 +1,60 @@
+using SharpMud.Engine.Behaviors;
+using SharpMud.Engine.Core;
+using SharpMud.Engine.Sessions;
+using SharpMud.Hosting;
+using SharpMud.Ruleset.Rpg;
+
+namespace SharpMud.Samples.Classic.Tests;
+
+public sealed class ClassicCombatOutcomeHandlerTests
+{
+ [Fact]
+ public async Task OnVictoryAsync_AwardsExperienceFromCombatantReward()
+ {
+ var session = Substitute.For();
+ var victor = new Thing { Id = ThingId.New(), Name = "Hero" };
+ victor.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ victor.Behaviors.Add(new StatsBehavior { Experience = 0 });
+
+ var defeated = new Thing { Id = ThingId.New(), Name = "cave rat" };
+ defeated.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10 });
+
+ var worldContext = new WorldContext();
+ var sut = new ClassicCombatOutcomeHandler(worldContext);
+
+ await sut.OnVictoryAsync(victor, defeated, TestContext.Current.CancellationToken);
+
+ victor.FindBehavior()!.Experience.Should().Be(10);
+ }
+
+ [Fact]
+ public async Task OnDefeatAsync_AppliesXpLossAndHitPointHalving_AndReturnsHubRoom()
+ {
+ var session = Substitute.For();
+ var defeated = new Thing { Id = ThingId.New(), Name = "Hero" };
+ defeated.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
+ defeated.Behaviors.Add(new StatsBehavior { Experience = 100, MaxHitPoints = 20 });
+ defeated.Behaviors.Add(new CombatantBehavior { MaxHitPoints = 20, CurrentHitPoints = 20 });
+
+ var victor = new Thing { Id = ThingId.New(), Name = "cave rat" };
+
+ var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
+ hubRoom.Behaviors.Add(new RoomBehavior());
+ var worldContext = new WorldContext();
+ worldContext.Initialize(new World(), hubRoom, hubRoom);
+
+ var sut = new ClassicCombatOutcomeHandler(worldContext);
+
+ var destination = await sut.OnDefeatAsync(defeated, victor, TestContext.Current.CancellationToken);
+
+ destination.Should().Be(hubRoom);
+ defeated.FindBehavior()!.Experience.Should().Be(90);
+ defeated.FindBehavior()!.CurrentHitPoints.Should().Be(10);
+
+ // The value CombatResolver actually reads/writes during combat -
+ // the whole point of the ADR-0008-extraction bug fix. Asserting
+ // only StatsBehavior's copy (above) would still pass even if the
+ // CombatantBehavior halving were deleted.
+ defeated.FindBehavior()!.CurrentHitPoints.Should().Be(10);
+ }
+}
diff --git a/tests/SharpMud.Samples.Classic.Tests/Combat/CombatManagerTests.cs b/tests/SharpMud.Samples.Classic.Tests/Combat/CombatManagerTests.cs
deleted file mode 100644
index 6394a54..0000000
--- a/tests/SharpMud.Samples.Classic.Tests/Combat/CombatManagerTests.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using SharpMud.Engine.Behaviors;
-using SharpMud.Engine.Core;
-using SharpMud.Engine.Sessions;
-using SharpMud.Engine.Ticking;
-
-namespace SharpMud.Samples.Classic.Tests.Combat;
-
-public sealed class CombatManagerTests
-{
- [Fact]
- public async Task OnTickAsync_AwardsXpAndEndsEncounter_WhenPlayerDefeatsNpc()
- {
- var resolver = Substitute.For();
- var session = Substitute.For();
- var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
-
- var room = new Thing { Id = ThingId.New(), Name = "Room" };
- var player = new Thing { Id = ThingId.New(), Name = "Hero" };
- player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
- player.Behaviors.Add(new StatsBehavior());
- room.Add(player);
-
- var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
- npc.Behaviors.Add(new NpcBehavior());
- npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 0 });
- room.Add(npc);
-
- resolver.ResolveRound(player, npc).Returns(new CombatRoundResult(true, 6, true));
-
- var sut = new CombatManager(resolver, hubRoom);
- sut.StartEncounter(player, npc);
-
- await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
-
- player.FindBehavior()!.Experience.Should().Be(10);
- room.Children.Should().NotContain(npc);
- resolver.DidNotReceive().ResolveRound(npc, player);
- sut.IsInCombat(player.Id).Should().BeFalse();
- }
-
- [Fact]
- public async Task OnTickAsync_RespawnsPlayerWithXpLoss_WhenNpcDefeatsPlayer()
- {
- var resolver = Substitute.For();
- var session = Substitute.For();
- var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
-
- var room = new Thing { Id = ThingId.New(), Name = "Room" };
- var player = new Thing { Id = ThingId.New(), Name = "Hero" };
- player.Behaviors.Add(new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session });
- player.Behaviors.Add(new StatsBehavior { Experience = 100, MaxHitPoints = 20 });
- room.Add(player);
-
- var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
- npc.Behaviors.Add(new NpcBehavior());
- npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 6 });
- room.Add(npc);
-
- resolver.ResolveRound(player, npc).Returns(new CombatRoundResult(false, 0, false));
- resolver.ResolveRound(npc, player).Returns(new CombatRoundResult(true, 999, true));
-
- var sut = new CombatManager(resolver, hubRoom);
- sut.StartEncounter(player, npc);
-
- await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
-
- player.FindBehavior()!.Experience.Should().Be(90);
- player.FindBehavior()!.CurrentHitPoints.Should().Be(10);
- player.Parent.Should().Be(hubRoom);
- sut.IsInCombat(player.Id).Should().BeFalse();
- }
-
- [Fact]
- public async Task OnTickAsync_FreezesEncounter_WhenAttackerLinkdeadWithinGraceWindow()
- {
- var resolver = Substitute.For();
- var session = Substitute.For();
- var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
-
- var room = new Thing { Id = ThingId.New(), Name = "Room" };
- var player = new Thing { Id = ThingId.New(), Name = "Hero" };
- var playerBehavior = new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session };
- playerBehavior.EnterLinkdead(DateTimeOffset.UtcNow);
- player.Behaviors.Add(playerBehavior);
- room.Add(player);
-
- var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
- npc.Behaviors.Add(new NpcBehavior());
- npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 6 });
- room.Add(npc);
-
- var sut = new CombatManager(resolver, hubRoom);
- sut.StartEncounter(player, npc);
-
- await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
-
- sut.IsInCombat(player.Id).Should().BeTrue();
- resolver.DidNotReceiveWithAnyArgs().ResolveRound(default!, default!);
- await session.DidNotReceiveWithAnyArgs().WriteLineAsync(default!, TestContext.Current.CancellationToken);
- }
-
- [Fact]
- public async Task OnTickAsync_AbandonsEncounter_WhenAttackerLinkdeadPastGraceWindow()
- {
- var resolver = Substitute.For();
- var session = Substitute.For();
- var hubRoom = new Thing { Id = ThingId.New(), Name = "Hub" };
-
- var room = new Thing { Id = ThingId.New(), Name = "Room" };
- var player = new Thing { Id = ThingId.New(), Name = "Hero" };
- var playerBehavior = new PlayerBehavior { Username = "TestUser", PasswordHash = "test-hash", Session = session };
- playerBehavior.EnterLinkdead(DateTimeOffset.UtcNow - ReconnectPolicy.GraceWindow - TimeSpan.FromSeconds(1));
- player.Behaviors.Add(playerBehavior);
- room.Add(player);
-
- var npc = new Thing { Id = ThingId.New(), Name = "cave rat" };
- npc.Behaviors.Add(new NpcBehavior());
- npc.Behaviors.Add(new CombatantBehavior { ExperienceReward = 10, CurrentHitPoints = 6 });
- room.Add(npc);
-
- var sut = new CombatManager(resolver, hubRoom);
- sut.StartEncounter(player, npc);
-
- await sut.OnTickAsync(new TickContext(DateTimeOffset.UtcNow), TestContext.Current.CancellationToken);
-
- sut.IsInCombat(player.Id).Should().BeFalse();
- }
-}