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(); - } -}