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