Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -68,5 +68,6 @@
<PackageVersion Include="TwoFactorAuth.Net" Version="1.4.0" />
<PackageVersion Include="WolverineFx" Version="6.31.0" />
<PackageVersion Include="WolverineFx.RuntimeCompilation" Version="6.31.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Scripting" Version="5.6.0" />
</ItemGroup>
</Project>
49 changes: 49 additions & 0 deletions NosCore.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
47 changes: 47 additions & 0 deletions instances/timespace-01.csx
Original file line number Diff line number Diff line change
@@ -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();
59 changes: 59 additions & 0 deletions src/NosCore.Instances.Abstractions/IInstanceRun.cs
Original file line number Diff line number Diff line change
@@ -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) { }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
54 changes: 54 additions & 0 deletions src/NosCore.Instances.Scripting/CsxInstanceScriptLoader.cs
Original file line number Diff line number Diff line change
@@ -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<string, Task<IInstanceScript>> _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<IInstanceScript> LoadAsync(string name) =>
_scripts.GetOrAdd(name, CompileAsync);

public string[] Available() => Directory.Exists(folder)
? Directory.GetFiles(folder, "*.csx")
: [];

private async Task<IInstanceScript> 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<IInstanceScript>(
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.");
}
}
}
11 changes: 11 additions & 0 deletions src/NosCore.Instances.Scripting/NosCore.Instances.Scripting.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Scripting" />
<ProjectReference Include="..\NosCore.Instances.Abstractions\NosCore.Instances.Abstractions.csproj" />
</ItemGroup>
</Project>
126 changes: 126 additions & 0 deletions test/NosCore.Instances.Tests/CsxInstanceScriptTests.cs
Original file line number Diff line number Diff line change
@@ -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<string> 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<InvalidOperationException>(
() => 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");
}
}
}
Loading
Loading