diff --git a/SubathonManager.Server/WebServer.Overlays.cs b/SubathonManager.Server/WebServer.Overlays.cs
index ee6df5eb..371662bc 100644
--- a/SubathonManager.Server/WebServer.Overlays.cs
+++ b/SubathonManager.Server/WebServer.Overlays.cs
@@ -57,17 +57,19 @@ internal async Task HandleWidgetRequest(IHttpContext ctx) {
if (Path.GetExtension(filePath).Equals(".html", StringComparison.OrdinalIgnoreCase)) {
string html = WidgetFiles.Current.ReadAllText(filePath) ?? string.Empty;
+ string clientScript = GetWebsocketInjectionScript();
+
var cssOverrides = new StringBuilder();
- cssOverrides.AppendLine(GetWebsocketInjectionScript());
cssOverrides.AppendLine("");
+
if (html.Contains("", StringComparison.OrdinalIgnoreCase))
- html = html.Replace("", cssOverrides + "\n",
+ html = html.Replace("", clientScript + "\n" + cssOverrides + "\n",
StringComparison.OrdinalIgnoreCase);
else
- html += cssOverrides;
+ html = clientScript + "\n" + html + cssOverrides;
var jsOverrides = new StringBuilder();
jsOverrides.AppendLine("\n
- ";
- return script;
- }
-}
\ No newline at end of file
+ string route = routeId ?? string.Empty;
+ bool isOverlay = !string.IsNullOrWhiteSpace(route);
+
+ return ClientScriptTemplate
+ .Replace("__WS_URL__", $"ws://localhost:{Port}/ws")
+ .Replace("__ROUTE_ID__", route)
+ .Replace("__EMPTY_GUID__", Guid.Empty.ToString())
+ .Replace("__IS_OVERLAY__", isOverlay ? "true" : "false");
+ }
+
+ private const string ClientScriptTemplate = """
+
+ """;
+}
diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs
index 37170a56..15ba8f83 100644
--- a/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs
+++ b/SubathonManager.Tests/ServerUnitTests/WebServerTests.cs
@@ -491,6 +491,56 @@ await File.WriteAllTextAsync(tempHtml, "
"
AppServices.Provider = null!;
}
+ [Fact]
+ public async Task HandleWidgetRequestAsync_Puts_Client_Script_Before_A_Fragment_Widgets_Script() {
+ WebServer server = CreateServer();
+ SetupServices();
+ var route = new Route { Name = "TestRoute" };
+
+ string tempHtml = Path.Combine(Path.GetTempPath(), Guid.NewGuid() + ".html");
+ await File.WriteAllTextAsync(tempHtml,
+ "\n",
+ TestContext.Current.CancellationToken);
+
+ var widget = new Widget("Widget1", tempHtml) {
+ Route = route,
+ RouteId = route.Id,
+ JsVariables = new List {
+ new() { Name = "testVar", Value = "42", Type = WidgetVariableType.Int, WidgetId = Guid.NewGuid() }
+ }
+ };
+ route.Widgets.Add(widget);
+
+ await using AppDbContext db = await server._factory.CreateDbContextAsync(TestContext.Current.CancellationToken);
+ db.Routes.Add(route);
+ db.Widgets.Add(widget);
+ await db.SaveChangesAsync(TestContext.Current.CancellationToken);
+
+ var ctx = new MockHttpContext {
+ Method = "GET",
+ Path = $"/widget/{widget.Id}"
+ };
+
+ await server.HandleWidgetRequest(ctx);
+
+ Assert.Equal(200, ctx.StatusCode);
+ string body = ctx.GetResponseText();
+
+ int clientAt = body.IndexOf("data-subathon-client", StringComparison.Ordinal);
+ int varsAt = body.IndexOf("const testVar = 42", StringComparison.Ordinal);
+ int widgetAt = body.IndexOf("window.__probe", StringComparison.Ordinal);
+
+ Assert.True(clientAt >= 0, "client script was not injected");
+ Assert.True(varsAt >= 0, "widget variables were not injected");
+ Assert.True(widgetAt >= 0, "widget markup was not served");
+
+ Assert.True(clientAt < widgetAt, "client script must come before the widget's own script");
+ Assert.True(varsAt < widgetAt, "widget variables must come before the widget's own script");
+
+ File.Delete(tempHtml);
+ AppServices.Provider = null!;
+ }
+
[Fact]
public async Task HandleWidgetRequest_Returns_404_For_Invalid_Widget() {
WebServer server = CreateServer();
diff --git a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs
index c3e2b8bf..66b46a1d 100644
--- a/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs
+++ b/SubathonManager.Tests/ServerUnitTests/WebServerWebSocketTests.cs
@@ -215,6 +215,74 @@ public void Generate_Injection_Script() {
AppServices.Provider = null!;
}
+ [Fact]
+ public void Injection_Script_Widget_Mode_Is_Not_An_Overlay_Host() {
+ SetupServices();
+ WebServer server = CreateServer();
+ string script = server.GetWebsocketInjectionScript();
+
+ Assert.Contains("var IS_OVERLAY = false;", script);
+ Assert.Contains("var ROUTE_ID = '';", script);
+ Assert.DoesNotContain("__IS_OVERLAY__", script);
+ Assert.DoesNotContain("__ROUTE_ID__", script);
+ Assert.DoesNotContain("__WS_URL__", script);
+ Assert.DoesNotContain("__EMPTY_GUID__", script);
+
+ AppServices.Provider = null!;
+ }
+
+ [Fact]
+ public void Injection_Script_Overlay_Mode_Carries_Route_Id() {
+ SetupServices();
+ WebServer server = CreateServer();
+ var routeId = Guid.NewGuid();
+ string script = server.GetWebsocketInjectionScript(routeId.ToString());
+
+ Assert.Contains("var IS_OVERLAY = true;", script);
+ Assert.Contains($"var ROUTE_ID = '{routeId}';", script);
+ Assert.Contains($"var EMPTY_GUID = '{Guid.Empty}';", script);
+ Assert.Contains("ws_type: 'Overlay'", script);
+ Assert.Contains("ws_type: 'Widget'", script);
+
+ AppServices.Provider = null!;
+ }
+
+ [Fact]
+ public void Injection_Script_Still_Dispatches_Every_Legacy_Global() {
+ SetupServices();
+ WebServer server = CreateServer();
+ string script = server.GetWebsocketInjectionScript();
+ foreach (string handler in new[] {
+ "handleSubathonUpdate", "handleSubathonEvent", "handlePromptUpdate",
+ "handleGoalsUpdate", "handleGoalCompleted", "handleValueConfig",
+ "handleTotalsUpdate", "handleSubscriptionTotalsUpdate", "handleWheelSpinResult",
+ "handleWheelData", "handleWheelSpinStart", "handleWheelSpinStatus",
+ "handleVarsUpdate", "handleSubathonDisconnect"
+ })
+ Assert.Contains(handler, script);
+
+ AppServices.Provider = null!;
+ }
+
+ [Fact]
+ public void Injection_Script_Replays_Cached_State_To_Late_Legacy_Handlers() {
+ SetupServices();
+ WebServer server = CreateServer();
+ string script = server.GetWebsocketInjectionScript();
+
+ Assert.Contains("DOMContentLoaded", script);
+ Assert.Contains("replayStateToLegacy", script);
+ Assert.Contains("legacyServed", script);
+
+ foreach (string stateType in new[] {
+ "subathon_timer", "subathon_totals", "subscription_totals",
+ "goals_list", "value_config", "prompt_update", "wheel_data"
+ })
+ Assert.Contains(stateType + ":", script);
+
+ AppServices.Provider = null!;
+ }
+
[Fact]
public async Task Non_WebSocket_Request_Is_Rejected_As_WebSocket() {
var ctx = new MockHttpContext {
diff --git a/SubathonManager.Tests/SubathonManager.Tests.csproj b/SubathonManager.Tests/SubathonManager.Tests.csproj
index 197287af..6a4c302f 100644
--- a/SubathonManager.Tests/SubathonManager.Tests.csproj
+++ b/SubathonManager.Tests/SubathonManager.Tests.csproj
@@ -12,6 +12,8 @@
+
+
diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Event-Reader_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Event-Reader_1.0.0.smw
new file mode 100644
index 00000000..78c768de
Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Event-Reader_1.0.0.smw differ
diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Goal-Viewer_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Goal-Viewer_1.0.0.smw
new file mode 100644
index 00000000..333060b4
Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Goal-Viewer_1.0.0.smw differ
diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Prompt_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Prompt_1.0.0.smw
new file mode 100644
index 00000000..606537df
Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Prompt_1.0.0.smw differ
diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Timer_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Timer_1.0.0.smw
new file mode 100644
index 00000000..e3ae65b9
Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Timer_1.0.0.smw differ
diff --git a/presets/cyberpunk/WolfwithSword_Cyberpunk_Wheel-Spinner_1.0.0.smw b/presets/cyberpunk/WolfwithSword_Cyberpunk_Wheel-Spinner_1.0.0.smw
new file mode 100644
index 00000000..4c584c1f
Binary files /dev/null and b/presets/cyberpunk/WolfwithSword_Cyberpunk_Wheel-Spinner_1.0.0.smw differ
diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Event-Scroll_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Event-Scroll_1.0.0.smw
new file mode 100644
index 00000000..25cf39b9
Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Event-Scroll_1.0.0.smw differ
diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Goals-Mana-Bar_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Goals-Mana-Bar_1.0.0.smw
new file mode 100644
index 00000000..f96af145
Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Goals-Mana-Bar_1.0.0.smw differ
diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Timer_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Timer_1.0.0.smw
new file mode 100644
index 00000000..a3260189
Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Timer_1.0.0.smw differ
diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw
new file mode 100644
index 00000000..414aceab
Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Magic-Wheel_1.0.0.smw differ
diff --git a/presets/magicrune/WolfwithSword_Magic-Rune_Prompt-Scroll_1.0.0.smw b/presets/magicrune/WolfwithSword_Magic-Rune_Prompt-Scroll_1.0.0.smw
new file mode 100644
index 00000000..4d37d25e
Binary files /dev/null and b/presets/magicrune/WolfwithSword_Magic-Rune_Prompt-Scroll_1.0.0.smw differ