diff --git a/Directory.Packages.props b/Directory.Packages.props index 0cc9a4e8f..81b2f93a6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -68,5 +68,6 @@ + diff --git a/NosCore.sln b/NosCore.sln index 90f225acd..7ee8f2864 100644 --- a/NosCore.sln +++ b/NosCore.sln @@ -51,6 +51,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.EcsGenerator", "too EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.DiGenerator", "tools\NosCore.DiGenerator\NosCore.DiGenerator.csproj", "{E4AD945D-BF33-40FB-8F3A-3F706850ED76}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.Instances.Abstractions", "src\NosCore.Instances.Abstractions\NosCore.Instances.Abstractions.csproj", "{8147B13A-C792-45DC-B986-15BACCA0947C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.Instances.Scripting", "src\NosCore.Instances.Scripting\NosCore.Instances.Scripting.csproj", "{2A00759C-15BE-4C76-95D6-546D6D5E5F7F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{0C88DD14-F956-CE84-757C-A364CCF449FC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NosCore.Instances.Tests", "test\NosCore.Instances.Tests\NosCore.Instances.Tests.csproj", "{DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -301,6 +311,42 @@ Global {E4AD945D-BF33-40FB-8F3A-3F706850ED76}.Release|x64.Build.0 = Release|Any CPU {E4AD945D-BF33-40FB-8F3A-3F706850ED76}.Release|x86.ActiveCfg = Release|Any CPU {E4AD945D-BF33-40FB-8F3A-3F706850ED76}.Release|x86.Build.0 = Release|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Debug|x64.ActiveCfg = Debug|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Debug|x64.Build.0 = Debug|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Debug|x86.ActiveCfg = Debug|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Debug|x86.Build.0 = Debug|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Release|Any CPU.Build.0 = Release|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Release|x64.ActiveCfg = Release|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Release|x64.Build.0 = Release|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Release|x86.ActiveCfg = Release|Any CPU + {8147B13A-C792-45DC-B986-15BACCA0947C}.Release|x86.Build.0 = Release|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Debug|x64.ActiveCfg = Debug|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Debug|x64.Build.0 = Debug|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Debug|x86.ActiveCfg = Debug|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Debug|x86.Build.0 = Debug|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Release|Any CPU.Build.0 = Release|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Release|x64.ActiveCfg = Release|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Release|x64.Build.0 = Release|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Release|x86.ActiveCfg = Release|Any CPU + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F}.Release|x86.Build.0 = Release|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Debug|x64.ActiveCfg = Debug|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Debug|x64.Build.0 = Debug|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Debug|x86.ActiveCfg = Debug|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Debug|x86.Build.0 = Debug|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Release|Any CPU.Build.0 = Release|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Release|x64.ActiveCfg = Release|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Release|x64.Build.0 = Release|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Release|x86.ActiveCfg = Release|Any CPU + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -326,6 +372,9 @@ Global {4E68313F-69F8-4125-B87E-665D6F7DFD5D} = {EFA0F8A7-CBF2-4542-82A9-931BB0111FED} {78731E48-C6CA-4614-B617-65B55A85CAE0} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {E4AD945D-BF33-40FB-8F3A-3F706850ED76} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {8147B13A-C792-45DC-B986-15BACCA0947C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {2A00759C-15BE-4C76-95D6-546D6D5E5F7F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {DEB691A6-EE42-4FB0-BD00-9A68D62FC8B7} = {0C88DD14-F956-CE84-757C-A364CCF449FC} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4B673C11-52EF-455B-BFC6-8CFEBC340972} diff --git a/instances/timespace-01.csx b/instances/timespace-01.csx new file mode 100644 index 000000000..b41b4698b --- /dev/null +++ b/instances/timespace-01.csx @@ -0,0 +1,47 @@ +// Time-space 1. Dropped in here; nothing is rebuilt. +using NosCore.Instances.Abstractions; + +public class TimeSpace01 : InstanceScript +{ + public override void OnFirstEnter(IInstanceRun run) + { + run.StartClock(TimeSpan.FromMinutes(20)); + run.Message("Clear each room to open the way."); + + run.Summon(0, + new MonsterSpawn(58, new Spot(24, 12)), + new MonsterSpawn(58, new Spot(28, 14)), + new MonsterSpawn(59, new Spot(26, 18))); + run.SetMonsterLocker(0, 3); + run.SpawnPortal(0, 1, new Spot(30, 20)); + } + + public override void OnRoomCleared(IInstanceRun run, int room) + { + if (room == 0) + { + run.UnlockPortal(0, 1); + run.Effect(0, 5000); + return; + } + + if (room == 1) + { + run.Summon(1, new MonsterSpawn(200, new Spot(20, 20), IsBoss: true)); + run.SetMonsterLocker(1, 1); + } + } + + public override void OnMonsterKilled(IInstanceRun run, short vNum) + { + if (vNum == 200) + { + run.Message("The way out is open."); + run.Succeed(); + } + } + + public override void OnTimeout(IInstanceRun run) => run.Fail(); +} + +return new TimeSpace01(); diff --git a/src/NosCore.Instances.Abstractions/IInstanceRun.cs b/src/NosCore.Instances.Abstractions/IInstanceRun.cs new file mode 100644 index 000000000..512e1c472 --- /dev/null +++ b/src/NosCore.Instances.Abstractions/IInstanceRun.cs @@ -0,0 +1,59 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using System; +using System.Collections.Generic; + +namespace NosCore.Instances.Abstractions +{ + public readonly record struct Spot(short X, short Y); + + public readonly record struct MonsterSpawn(short VNum, Spot At, bool IsBoss = false); + + // The verbs a script may use. Everything a script can do to a running instance goes + // through here, so the engine behind it - csx today - stays swappable. + public interface IInstanceRun + { + int CurrentRoom { get; } + TimeSpan Elapsed { get; } + + void Summon(int room, params MonsterSpawn[] monsters); + void SummonNpc(int room, short vNum, Spot at); + void SpawnPortal(int fromRoom, int toRoom, Spot at, bool locked = true); + void UnlockPortal(int fromRoom, int toRoom); + + // A locker holds the room shut until the monsters it names are dead. + void SetMonsterLocker(int room, short count); + void SetButtonLocker(int room, short count); + + void StartClock(TimeSpan limit); + void StopClock(); + + void Message(string text); + void Effect(int room, short effectId); + + void Succeed(); + void Fail(); + } + + public interface IInstanceScript + { + void OnFirstEnter(IInstanceRun run); + void OnMonsterKilled(IInstanceRun run, short vNum); + void OnRoomCleared(IInstanceRun run, int room); + void OnTimeout(IInstanceRun run); + } + + // Scripts subclass this so they only override the triggers they use, the way real + // instance definitions omit whatever they do not have. + public abstract class InstanceScript : IInstanceScript + { + public virtual void OnFirstEnter(IInstanceRun run) { } + public virtual void OnMonsterKilled(IInstanceRun run, short vNum) { } + public virtual void OnRoomCleared(IInstanceRun run, int room) { } + public virtual void OnTimeout(IInstanceRun run) { } + } +} diff --git a/src/NosCore.Instances.Abstractions/NosCore.Instances.Abstractions.csproj b/src/NosCore.Instances.Abstractions/NosCore.Instances.Abstractions.csproj new file mode 100644 index 000000000..e5195a2a5 --- /dev/null +++ b/src/NosCore.Instances.Abstractions/NosCore.Instances.Abstractions.csproj @@ -0,0 +1,7 @@ + + + net10.0 + latest + enable + + diff --git a/src/NosCore.Instances.Scripting/CsxInstanceScriptLoader.cs b/src/NosCore.Instances.Scripting/CsxInstanceScriptLoader.cs new file mode 100644 index 000000000..376c93711 --- /dev/null +++ b/src/NosCore.Instances.Scripting/CsxInstanceScriptLoader.cs @@ -0,0 +1,54 @@ +// __ _ __ __ ___ __ ___ ___ +// | \| |/__\ /' _/ / _//__\| _ \ __| +// | | ' | \/ |`._`.| \_| \/ | v / _| +// |_|\__|\__/ |___/ \__/\__/|_|_\___| +// + +using Microsoft.CodeAnalysis.CSharp.Scripting; +using Microsoft.CodeAnalysis.Scripting; +using NosCore.Instances.Abstractions; +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Threading.Tasks; + +namespace NosCore.Instances.Scripting +{ + public class CsxInstanceScriptLoader(string folder) + { + private readonly ConcurrentDictionary> _scripts = new(); + + // Roslyn costs ~1.3s to warm up and ~85ms a script after that, so compiling every + // instance at boot would be about eleven seconds for no benefit. Compiled on first + // entry instead, then kept. + public Task LoadAsync(string name) => + _scripts.GetOrAdd(name, CompileAsync); + + public string[] Available() => Directory.Exists(folder) + ? Directory.GetFiles(folder, "*.csx") + : []; + + private async Task CompileAsync(string name) + { + var path = Path.Combine(folder, name + ".csx"); + var options = ScriptOptions.Default + .WithReferences(typeof(IInstanceRun).Assembly) + .WithImports("System", "NosCore.Instances.Abstractions"); + + var script = CSharpScript.Create( + await File.ReadAllTextAsync(path).ConfigureAwait(false), options); + + // Diagnostics surface here, at load, rather than as a door onto an empty room. + var diagnostics = script.Compile(); + if (!diagnostics.IsEmpty) + { + throw new InvalidOperationException( + $"{name}.csx did not compile:{Environment.NewLine}{string.Join(Environment.NewLine, diagnostics)}"); + } + + var result = await script.RunAsync().ConfigureAwait(false); + return result.ReturnValue + ?? throw new InvalidOperationException($"{name}.csx returned no script instance."); + } + } +} diff --git a/src/NosCore.Instances.Scripting/NosCore.Instances.Scripting.csproj b/src/NosCore.Instances.Scripting/NosCore.Instances.Scripting.csproj new file mode 100644 index 000000000..f08f83433 --- /dev/null +++ b/src/NosCore.Instances.Scripting/NosCore.Instances.Scripting.csproj @@ -0,0 +1,11 @@ + + + net10.0 + latest + enable + + + + + + diff --git a/test/NosCore.Instances.Tests/CsxInstanceScriptTests.cs b/test/NosCore.Instances.Tests/CsxInstanceScriptTests.cs new file mode 100644 index 000000000..70dd9a58e --- /dev/null +++ b/test/NosCore.Instances.Tests/CsxInstanceScriptTests.cs @@ -0,0 +1,126 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using NosCore.Instances.Abstractions; +using NosCore.Instances.Scripting; +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; + +namespace NosCore.Instances.Tests +{ + // A script is only as good as what it can be held to. This drives the dropped-in file + // against a recording run, so instance content is testable the way the rest is. + public class RecordingRun : IInstanceRun + { + public List Calls { get; } = []; + public int CurrentRoom { get; set; } + public TimeSpan Elapsed { get; set; } + + public void Summon(int room, params MonsterSpawn[] monsters) => + Calls.Add($"summon({room},{string.Join(",", Array.ConvertAll(monsters, m => m.VNum + (m.IsBoss ? "!" : "")))})"); + public void SummonNpc(int room, short vNum, Spot at) => Calls.Add($"npc({room},{vNum})"); + public void SpawnPortal(int fromRoom, int toRoom, Spot at, bool locked = true) => + Calls.Add($"portal({fromRoom}->{toRoom},{(locked ? "locked" : "open")})"); + public void UnlockPortal(int fromRoom, int toRoom) => Calls.Add($"unlock({fromRoom}->{toRoom})"); + public void SetMonsterLocker(int room, short count) => Calls.Add($"monsterLocker({room},{count})"); + public void SetButtonLocker(int room, short count) => Calls.Add($"buttonLocker({room},{count})"); + public void StartClock(TimeSpan limit) => Calls.Add($"clock({limit.TotalMinutes}m)"); + public void StopClock() => Calls.Add("stopClock"); + public void Message(string text) => Calls.Add($"message({text})"); + public void Effect(int room, short effectId) => Calls.Add($"effect({room},{effectId})"); + public void Succeed() => Calls.Add("succeed"); + public void Fail() => Calls.Add("fail"); + } + + [TestClass] + public class CsxInstanceScriptTests + { + private static CsxInstanceScriptLoader Loader() => + new(Path.Combine(Directory.GetCurrentDirectory(), "instances")); + + [TestMethod] + public async Task ADroppedInScriptOpensTheInstance() + { + var script = await Loader().LoadAsync("timespace-01"); + var run = new RecordingRun(); + + script.OnFirstEnter(run); + + CollectionAssert.AreEqual(new[] + { + "clock(20m)", + "message(Clear each room to open the way.)", + "summon(0,58,58,59)", + "monsterLocker(0,3)", + "portal(0->1,locked)" + }, run.Calls); + } + + [TestMethod] + public async Task ClearingTheFirstRoomOpensTheWay() + { + var script = await Loader().LoadAsync("timespace-01"); + var run = new RecordingRun(); + + script.OnRoomCleared(run, 0); + + CollectionAssert.AreEqual(new[] { "unlock(0->1)", "effect(0,5000)" }, run.Calls); + } + + [TestMethod] + public async Task KillingTheBossEndsTheInstance() + { + var script = await Loader().LoadAsync("timespace-01"); + var run = new RecordingRun(); + + script.OnRoomCleared(run, 1); + script.OnMonsterKilled(run, 200); + + CollectionAssert.AreEqual(new[] + { + "summon(1,200!)", "monsterLocker(1,1)", "message(The way out is open.)", "succeed" + }, run.Calls); + } + + [TestMethod] + public async Task TheClockRunningOutFailsTheRun() + { + var script = await Loader().LoadAsync("timespace-01"); + var run = new RecordingRun(); + + script.OnTimeout(run); + + CollectionAssert.AreEqual(new[] { "fail" }, run.Calls); + } + + [TestMethod] + public async Task ABrokenScriptSaysSoAtLoad() + { + var dir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + Directory.CreateDirectory(dir); + await File.WriteAllTextAsync(Path.Combine(dir, "broken.csx"), "this is not C#;"); + + var ex = await Assert.ThrowsExactlyAsync( + () => new CsxInstanceScriptLoader(dir).LoadAsync("broken")); + + StringAssert.Contains(ex.Message, "did not compile"); + } + + [TestMethod] + public async Task CompilingIsPaidOnceThenCached() + { + var loader = Loader(); + var cold = Stopwatch.StartNew(); + await loader.LoadAsync("timespace-01"); + cold.Stop(); + + var warm = Stopwatch.StartNew(); + await loader.LoadAsync("timespace-01"); + warm.Stop(); + + Console.WriteLine($"COLD {cold.ElapsedMilliseconds}ms WARM {warm.ElapsedMilliseconds}ms"); + Assert.IsTrue(warm.ElapsedMilliseconds <= 5, "a second entry must not recompile"); + } + } +} diff --git a/test/NosCore.Instances.Tests/NosCore.Instances.Tests.csproj b/test/NosCore.Instances.Tests/NosCore.Instances.Tests.csproj new file mode 100644 index 000000000..a8297d49b --- /dev/null +++ b/test/NosCore.Instances.Tests/NosCore.Instances.Tests.csproj @@ -0,0 +1,18 @@ + + + net10.0 + latest + enable + false + + + + + + + + + + + + diff --git a/test/NosCore.Instances.Tests/instances/timespace-01.csx b/test/NosCore.Instances.Tests/instances/timespace-01.csx new file mode 100644 index 000000000..b41b4698b --- /dev/null +++ b/test/NosCore.Instances.Tests/instances/timespace-01.csx @@ -0,0 +1,47 @@ +// Time-space 1. Dropped in here; nothing is rebuilt. +using NosCore.Instances.Abstractions; + +public class TimeSpace01 : InstanceScript +{ + public override void OnFirstEnter(IInstanceRun run) + { + run.StartClock(TimeSpan.FromMinutes(20)); + run.Message("Clear each room to open the way."); + + run.Summon(0, + new MonsterSpawn(58, new Spot(24, 12)), + new MonsterSpawn(58, new Spot(28, 14)), + new MonsterSpawn(59, new Spot(26, 18))); + run.SetMonsterLocker(0, 3); + run.SpawnPortal(0, 1, new Spot(30, 20)); + } + + public override void OnRoomCleared(IInstanceRun run, int room) + { + if (room == 0) + { + run.UnlockPortal(0, 1); + run.Effect(0, 5000); + return; + } + + if (room == 1) + { + run.Summon(1, new MonsterSpawn(200, new Spot(20, 20), IsBoss: true)); + run.SetMonsterLocker(1, 1); + } + } + + public override void OnMonsterKilled(IInstanceRun run, short vNum) + { + if (vNum == 200) + { + run.Message("The way out is open."); + run.Succeed(); + } + } + + public override void OnTimeout(IInstanceRun run) => run.Fail(); +} + +return new TimeSpace01();