Skip to content

Commit 90e92d1

Browse files
SS13 "Classic Servers" Support (#71)
Co-authored-by: DEATHB4DEFEAT <[email protected]>
1 parent 8c558af commit 90e92d1

14 files changed

+561
-2
lines changed

SS14.Launcher.sln.DotSettings

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=OS/@EntryIndexedValue">OS</s:String>
44
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=VM/@EntryIndexedValue">VM</s:String>
55
<s:Boolean x:Key="/Default/UserDictionary/Words/=Avalonia/@EntryIndexedValue">True</s:Boolean>
6+
<s:Boolean x:Key="/Default/UserDictionary/Words/=byond/@EntryIndexedValue">True</s:Boolean>
67
<s:Boolean x:Key="/Default/UserDictionary/Words/=prerelease/@EntryIndexedValue">True</s:Boolean>
78
<s:Boolean x:Key="/Default/UserDictionary/Words/=ROWID/@EntryIndexedValue">True</s:Boolean>
89
<s:Boolean x:Key="/Default/UserDictionary/Words/=zeroblob/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

SS14.Launcher/Assets/Locale/en-US/text.ftl

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,10 @@ region-short-south-america-west = SA West
234234
## Strings for the "servers" tab
235235

236236
tab-servers-title = Servers
237+
tab-servers-byond-title = BYOND Servers
238+
tab-servers-byond-error-msg = BYOND not installed or found
239+
tab-servers-byond-error-desc = To connect to BYOND servers, please install BYOND from https://www.byond.com/download/ and ensure it is set as the default program for handling byond:// links.
240+
tab-servers-byond-error-link-text = Download BYOND
237241
tab-servers-refresh = Refresh
238242
filters = Filters ({ $filteredServers } / { $totalServers })
239243
tab-servers-search-watermark = Search For Servers…
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using System.Collections.ObjectModel;
4+
using System.IO;
5+
using System.Net.Http;
6+
using System.Threading;
7+
using System.Threading.Tasks;
8+
using Serilog;
9+
using Splat;
10+
using SS14.Launcher.Utility;
11+
12+
namespace SS14.Launcher.Models.ServerStatus;
13+
14+
public sealed class ClassicServerListCache
15+
{
16+
private readonly HttpClient _http;
17+
private readonly ObservableCollection<ClassicServerStatusData> _allServers = new();
18+
19+
public ReadOnlyObservableCollection<ClassicServerStatusData> AllServers { get; }
20+
21+
public ClassicServerListCache()
22+
{
23+
_http = Locator.Current.GetRequiredService<HttpClient>();
24+
AllServers = new ReadOnlyObservableCollection<ClassicServerStatusData>(_allServers);
25+
}
26+
27+
public async Task Refresh()
28+
{
29+
try
30+
{
31+
var response = await _http.GetStringAsync("http://www.byond.com/games/exadv1/spacestation13?format=text");
32+
var servers = ParseByondResponse(response);
33+
34+
Avalonia.Threading.Dispatcher.UIThread.Post(() =>
35+
{
36+
_allServers.Clear();
37+
foreach (var server in servers)
38+
{
39+
_allServers.Add(server);
40+
}
41+
});
42+
}
43+
catch (Exception e)
44+
{
45+
Log.Error(e, "Failed to fetch Classic SS13 server list.");
46+
}
47+
}
48+
49+
private List<ClassicServerStatusData> ParseByondResponse(string response)
50+
{
51+
var list = new List<ClassicServerStatusData>();
52+
using var reader = new StringReader(response);
53+
54+
string? line;
55+
string? currentName = null;
56+
string? currentUrl = null;
57+
string? currentStatus = null;
58+
int currentPlayers = 0;
59+
60+
// Simple state machine to parse the text format
61+
// The format uses 'world/ID' blocks for servers.
62+
63+
bool inServerBlock = false;
64+
65+
while ((line = reader.ReadLine()) != null)
66+
{
67+
var trimmed = line.Trim();
68+
if (string.IsNullOrWhiteSpace(trimmed)) continue;
69+
70+
if (trimmed.StartsWith("world/"))
71+
{
72+
// If we were parsing a server, save it
73+
if (inServerBlock && currentUrl != null)
74+
{
75+
// Name might be missing, try to extract from status or use URL
76+
var name = currentName ?? ExtractNameFromStatus(currentStatus) ?? "Unknown Server";
77+
var roundTime = ExtractRoundTimeFromStatus(currentStatus);
78+
list.Add(new ClassicServerStatusData(name, currentUrl, currentPlayers, CleanStatus(currentStatus, name) ?? "", roundTime ?? "In-Lobby"));
79+
}
80+
81+
// Reset for new server
82+
inServerBlock = true;
83+
currentName = null;
84+
currentUrl = null;
85+
currentStatus = null;
86+
currentPlayers = 0;
87+
}
88+
else if (inServerBlock)
89+
{
90+
if (trimmed.StartsWith("name ="))
91+
{
92+
currentName = ParseStringValue(trimmed);
93+
}
94+
else if (trimmed.StartsWith("url ="))
95+
{
96+
currentUrl = ParseStringValue(trimmed);
97+
}
98+
else if (trimmed.StartsWith("status ="))
99+
{
100+
currentStatus = ParseStringValue(trimmed);
101+
}
102+
else if (trimmed.StartsWith("players = list("))
103+
{
104+
// "players = list("Bob","Alice")"
105+
// Just count the commas + 1, correcting for empty list "list()"
106+
var content = trimmed.Substring("players = list(".Length);
107+
if (content.EndsWith(")"))
108+
{
109+
content = content.Substring(0, content.Length - 1);
110+
if (string.IsNullOrWhiteSpace(content))
111+
{
112+
currentPlayers = 0;
113+
}
114+
else
115+
{
116+
// A simple Count(',') + 1 is risky if names contain commas, but usually they are quoted.
117+
// However, parsing full CSV is safer but 'Splitting by ",' might be enough?
118+
// Let's iterate and count quoted segments.
119+
// Or simpler: Splitting by ',' is mostly fine for SS13 ckeys.
120+
currentPlayers = content.Split(',').Length;
121+
}
122+
}
123+
}
124+
else if (trimmed.StartsWith("players ="))
125+
{
126+
// Fallback for simple number if ever used
127+
var parts = trimmed.Split('=');
128+
if (parts.Length > 1 && int.TryParse(parts[1].Trim(), out var p))
129+
{
130+
currentPlayers = p;
131+
}
132+
}
133+
}
134+
}
135+
136+
// Add the last one if exists
137+
if (inServerBlock && currentUrl != null)
138+
{
139+
var name = currentName ?? ExtractNameFromStatus(currentStatus) ?? "Unknown Server";
140+
var roundTime = ExtractRoundTimeFromStatus(currentStatus);
141+
list.Add(new ClassicServerStatusData(name, currentUrl, currentPlayers, CleanStatus(currentStatus, name) ?? "", roundTime ?? "In-Lobby"));
142+
}
143+
144+
return list;
145+
}
146+
147+
private string? ExtractRoundTimeFromStatus(string? status)
148+
{
149+
if (string.IsNullOrEmpty(status)) return null;
150+
151+
// Try to match "Round time: <b>00:07</b>" or similar
152+
var match = System.Text.RegularExpressions.Regex.Match(status, @"Round\s+time:\s+(?:<b>)?(\d{1,2}:\d{2})(?:</b>)?", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
153+
if (match.Success)
154+
{
155+
return match.Groups[1].Value;
156+
}
157+
return null;
158+
}
159+
160+
private string? ExtractNameFromStatus(string? status)
161+
{
162+
if (string.IsNullOrEmpty(status)) return null;
163+
// Usually starts with <b>Name</b>
164+
var match = System.Text.RegularExpressions.Regex.Match(status, @"<b>(.*?)</b>");
165+
if (match.Success)
166+
{
167+
var raw = match.Groups[1].Value;
168+
// Remove nested tags if any
169+
var clean = System.Text.RegularExpressions.Regex.Replace(raw, "<.*?>", String.Empty);
170+
return System.Net.WebUtility.HtmlDecode(clean);
171+
}
172+
return null;
173+
}
174+
175+
private string? CleanStatus(string? status, string? nameToRemove)
176+
{
177+
if (string.IsNullOrEmpty(status)) return null;
178+
179+
var s = status.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n");
180+
// Remove tags
181+
s = System.Text.RegularExpressions.Regex.Replace(s, "<.*?>", String.Empty);
182+
183+
// Decode HTML
184+
s = System.Net.WebUtility.HtmlDecode(s);
185+
186+
if (nameToRemove != null && s.StartsWith(nameToRemove))
187+
{
188+
s = s.Substring(nameToRemove.Length);
189+
}
190+
191+
// Clean artifacts
192+
char[] trims = { ' ', '\t', '\n', '\r', ']', ')', '-', '—', ':' };
193+
s = s.TrimStart(trims).Trim();
194+
195+
// Reduce multiple newlines
196+
s = System.Text.RegularExpressions.Regex.Replace(s, @"\n\s+", "\n");
197+
s = System.Text.RegularExpressions.Regex.Replace(s, @"\n{3,}", "\n\n");
198+
199+
return s;
200+
}
201+
202+
private string ParseStringValue(string line)
203+
{
204+
// format: key = "value"
205+
var idx = line.IndexOf('"');
206+
if (idx == -1) return string.Empty;
207+
var lastIdx = line.LastIndexOf('"');
208+
if (lastIdx <= idx) return string.Empty;
209+
210+
// Extract content inside quotes
211+
var inner = line.Substring(idx + 1, lastIdx - idx - 1);
212+
213+
// Unescape BYOND/C string escapes
214+
// \" -> "
215+
// \n -> newline
216+
// \\ -> \
217+
// The most critical one is \n showing up as literal \n in UI.
218+
219+
// Simple manual unescape for common sequences
220+
return inner.Replace("\\\"", "\"")
221+
.Replace("\\n", "\n")
222+
.Replace("\\\\", "\\")
223+
.Replace("\\t", "\t");
224+
}
225+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
namespace SS14.Launcher.Models.ServerStatus;
2+
3+
public class ClassicServerStatusData(string name, string address, int playerCount, string status, string roundTime)
4+
{
5+
public string Name { get; } = name;
6+
public string Address { get; } = address;
7+
public int PlayerCount { get; } = playerCount;
8+
public string Status { get; } = status;
9+
public string RoundTime { get; } = roundTime;
10+
}

SS14.Launcher/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ private static AppBuilder BuildAvaloniaApp(DataManager cfg)
234234
locator.RegisterConstant(authApi);
235235
locator.RegisterConstant(hubApi);
236236
locator.RegisterConstant(new ServerListCache());
237+
locator.RegisterConstant(new ClassicServerListCache());
237238
locator.RegisterConstant(loginManager);
238239
locator.RegisterConstant(overrideAssets);
239240
locator.RegisterConstant(launcherInfo);
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
using System;
2+
using System.Diagnostics;
3+
using ReactiveUI;
4+
using Serilog;
5+
using Splat;
6+
using SS14.Launcher.Localization;
7+
using SS14.Launcher.Models;
8+
using SS14.Launcher.Models.ServerStatus;
9+
using SS14.Launcher.Utility;
10+
11+
namespace SS14.Launcher.ViewModels.MainWindowTabs;
12+
13+
public class ClassicServerEntryViewModel : ViewModelBase
14+
{
15+
private readonly MainWindowViewModel _mainWindow;
16+
private readonly ClassicServerStatusData _server;
17+
18+
public string Name => _server.Name;
19+
public string Address => _server.Address;
20+
public string PlayerCount => _server.PlayerCount.ToString();
21+
public string Status => _server.Status;
22+
public string RoundTime => _server.RoundTime;
23+
24+
private bool _isExpanded;
25+
26+
public bool IsExpanded
27+
{
28+
get => _isExpanded;
29+
set => this.RaiseAndSetIfChanged(ref _isExpanded, value);
30+
}
31+
32+
public ReactiveCommand<System.Reactive.Unit, System.Reactive.Unit> ConnectCommand { get; }
33+
34+
public ClassicServerEntryViewModel(MainWindowViewModel mainWindow, ClassicServerStatusData server)
35+
{
36+
_mainWindow = mainWindow;
37+
_server = server;
38+
39+
ConnectCommand = ReactiveCommand.Create(Connect);
40+
}
41+
42+
private void Connect()
43+
{
44+
if (IsByondInstalled())
45+
Helpers.OpenUri(new Uri(Address));
46+
else
47+
{
48+
Log.Information("User attempted to connect to BYOND server but BYOND is not installed.");
49+
// Set the MainWindowViewModel's CustomInfo to show the BYOND not installed message
50+
// I didn't wanna make another dialog, reuse the generic thing :)
51+
_mainWindow.CustomInfo = new LauncherInfoManager.CustomInfo()
52+
{
53+
Message = LocalizationManager.Instance.GetString("tab-servers-byond-error-msg"),
54+
Description = LocalizationManager.Instance.GetString("tab-servers-byond-error-desc"),
55+
LinkText = LocalizationManager.Instance.GetString("tab-servers-byond-error-link-text"),
56+
Link = "https://www.byond.com/download/",
57+
};
58+
}
59+
}
60+
61+
private bool IsByondInstalled()
62+
{
63+
#if WINDOWS
64+
using var key = Registry.CurrentUser.OpenSubKey(@"Software\Dantom\BYOND");
65+
return key != null;
66+
#elif LINUX
67+
// Ask xdg-mime if BYOND is registered
68+
var process = new Process
69+
{
70+
StartInfo = new ProcessStartInfo
71+
{
72+
FileName = "xdg-mime",
73+
Arguments = "query default x-scheme-handler/byond",
74+
RedirectStandardOutput = true,
75+
UseShellExecute = false,
76+
CreateNoWindow = true,
77+
},
78+
};
79+
process.Start();
80+
var output = process.StandardOutput.ReadToEnd();
81+
process.WaitForExit();
82+
83+
return !string.IsNullOrWhiteSpace(output);
84+
#elif MACOS
85+
return true; // No idea, they might have it, might not
86+
#endif
87+
}
88+
}

0 commit comments

Comments
 (0)