diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 165668d..e722da4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -70,6 +70,7 @@ jobs: # legacy releases are bare v0.0.N; matching them keeps the counter from resetting version_tag_regex: '^(?:css-)?v0\.0\.(\d+)$' channel: ${{ needs.changes.outputs.channel }} + run_tests: true secrets: inherit swiftly: diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..9edee11 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,26 @@ +name: Tests + +on: + pull_request: + push: + branches: + - "main" + - "beta" + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + plugin: [counterstrikesharp, swiftly] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + - name: Test + run: dotnet test apps/${{ matrix.plugin }}/test/FiveStack.Tests.csproj -c Release diff --git a/FiveStack.sln b/FiveStack.sln index b9d979e..fa1598c 100644 --- a/FiveStack.sln +++ b/FiveStack.sln @@ -21,6 +21,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{DBE142A0-2 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiveStack.Tests", "apps\swiftly\test\FiveStack.Tests.csproj", "{AFA38A0E-6497-4822-9FD7-43C96FAE658C}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{F7607F84-A084-CCDF-99B5-B00F006F6845}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiveStack.Tests", "apps\counterstrikesharp\test\FiveStack.Tests.csproj", "{8A2C004D-559D-48A1-B6C5-DCDF09E5B194}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -67,6 +71,18 @@ Global {AFA38A0E-6497-4822-9FD7-43C96FAE658C}.Release|x64.Build.0 = Release|Any CPU {AFA38A0E-6497-4822-9FD7-43C96FAE658C}.Release|x86.ActiveCfg = Release|Any CPU {AFA38A0E-6497-4822-9FD7-43C96FAE658C}.Release|x86.Build.0 = Release|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Debug|x64.ActiveCfg = Debug|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Debug|x64.Build.0 = Debug|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Debug|x86.ActiveCfg = Debug|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Debug|x86.Build.0 = Debug|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Release|Any CPU.Build.0 = Release|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Release|x64.ActiveCfg = Release|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Release|x64.Build.0 = Release|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Release|x86.ActiveCfg = Release|Any CPU + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -80,5 +96,7 @@ Global {F8A6B88F-3606-414B-AC30-63F9FD2AE5B7} = {0CC2E62D-FAFA-6EC9-F250-DE74EC537044} {DBE142A0-22E2-B003-5EB2-D7DEEB405156} = {3AE66ABB-E1E0-F8CE-025B-3DE40F5A8E41} {AFA38A0E-6497-4822-9FD7-43C96FAE658C} = {DBE142A0-22E2-B003-5EB2-D7DEEB405156} + {F7607F84-A084-CCDF-99B5-B00F006F6845} = {7D0EF178-B40A-8254-92EC-BBDDDD1B716A} + {8A2C004D-559D-48A1-B6C5-DCDF09E5B194} = {F7607F84-A084-CCDF-99B5-B00F006F6845} EndGlobalSection EndGlobal diff --git a/apps/counterstrikesharp/scripts/dev.sh b/apps/counterstrikesharp/scripts/dev.sh index d464d16..671eb13 100755 --- a/apps/counterstrikesharp/scripts/dev.sh +++ b/apps/counterstrikesharp/scripts/dev.sh @@ -23,8 +23,12 @@ trap kill_dotnet_watch EXIT directory_to_watch="/opt/5stack/apps/counterstrikesharp/src/bin/Debug/net10.0" +# css and sw dev builds share the dev volume; each uses its own subfolder +DEV_DIR="/opt/dev/css" +mkdir -p "$DEV_DIR" + while true; do rm -f "$directory_to_watch/CounterStrikeSharp.API.dll" inotifywait -r -e modify,create,delete,move "$directory_to_watch" - cp -r "$directory_to_watch"/* "/opt/dev" + cp -r "$directory_to_watch"/* "$DEV_DIR" done diff --git a/apps/counterstrikesharp/scripts/setup.sh b/apps/counterstrikesharp/scripts/setup.sh index 0e44bd8..f62c2d5 100755 --- a/apps/counterstrikesharp/scripts/setup.sh +++ b/apps/counterstrikesharp/scripts/setup.sh @@ -110,7 +110,9 @@ create_symlinks "$BASE_SERVER_DIR" "$INSTANCE_SERVER_DIR" if $INSTALL_5STACK_PLUGIN = true ; then echo "---Install 5Stack---" if [ "${DEV_SWAPPED}" == "1" ]; then - ln -s "/opt/dev" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/FiveStack" + # css and sw dev builds share the dev volume; each uses its own subfolder + mkdir -p "/opt/dev/css" + ln -s "/opt/dev/css" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/FiveStack" else ln -s "/opt/mod" "${INSTANCE_SERVER_DIR}/game/csgo/addons/counterstrikesharp/plugins/FiveStack" fi diff --git a/apps/counterstrikesharp/src/FiveStack.Events/PlayerDamage.cs b/apps/counterstrikesharp/src/FiveStack.Events/PlayerDamage.cs index 1781825..71c0e27 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/PlayerDamage.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/PlayerDamage.cs @@ -7,6 +7,10 @@ namespace FiveStack; public partial class FiveStackPlugin { + // Victim health before their next hit, per round (cleared on round start). + // Lets us report exact applied damage instead of raw overkill damage. + private readonly Dictionary _victimHealth = new(); + [GameEventHandler] public HookResult OnPlayerDamage(EventPlayerHurt @event, GameEventInfo info) { @@ -36,17 +40,13 @@ public HookResult OnPlayerDamage(EventPlayerHurt @event, GameEventInfo info) } var attackerLocation = attacker?.PlayerPawn.Value?.AbsOrigin; - var attackedLocation = attacked?.PlayerPawn?.Value?.AbsOrigin; - - var damageDealt = @event.DmgHealth; + var attackedLocation = attacked.PlayerPawn.Value?.AbsOrigin; - if (attacked != null) - { - if (attacked.PlayerPawn.Value.Health < 0) - { - damageDealt = damageDealt + attacked.PlayerPawn.Value.Health; - } - } + int priorHealth = _victimHealth.TryGetValue(attacked.SteamID, out int tracked) + ? tracked + : 100; + var damageDealt = Math.Min(@event.DmgHealth, priorHealth); + _victimHealth[attacked.SteamID] = @event.Health; _matchEvents.PublishGameEvent( "damage", @@ -73,12 +73,9 @@ public HookResult OnPlayerDamage(EventPlayerHurt @event, GameEventInfo info) { "hitgroup", $"{DamageUtility.HitGroupToString(@event.Hitgroup)}" }, { "health", @event.Health }, { "armor", @event.Armor }, - { "attacked_steam_id", attacked != null ? attacked.SteamID.ToString() : "" }, - { - "attacked_team", - attacked != null ? $"{TeamUtility.TeamNumToString(attacked.TeamNum)}" : "" - }, - { "attacked_location", $"{attacked?.PlayerPawn.Value.LastPlaceName}" }, + { "attacked_steam_id", attacked.SteamID.ToString() }, + { "attacked_team", $"{TeamUtility.TeamNumToString(attacked.TeamNum)}" }, + { "attacked_location", $"{attacked.PlayerPawn.Value?.LastPlaceName}" }, { "attacked_location_coordinates", attackedLocation != null diff --git a/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs b/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs index c0ac31b..69e6fd0 100644 --- a/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs +++ b/apps/counterstrikesharp/src/FiveStack.Events/RoundStart.cs @@ -10,6 +10,8 @@ public partial class FiveStackPlugin [GameEventHandler] public HookResult OnRoundStart(EventRoundStart @event, GameEventInfo info) { + _victimHealth.Clear(); + MatchManager? matchManager = _matchService.GetCurrentMatch(); if (matchManager == null) { diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameDemos.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameDemos.cs index bfcf367..fb5e0f5 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameDemos.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameDemos.cs @@ -171,6 +171,7 @@ public async Task UploadDemo(string filePath) return; } + // dedicated client: the S3 PUT needs an unbounded timeout using var httpClient = new HttpClient(); httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; @@ -207,12 +208,16 @@ public async Task UploadDemo(string filePath) size = fileInfo.Length, }; - using var notifyClient = new HttpClient(); - notifyClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", apiPassword); - var notifyResponse = await notifyClient.PostAsJsonAsync( - notifyEndpoint, - notifyRequest + var notifyHttpRequest = new HttpRequestMessage(HttpMethod.Post, notifyEndpoint) + { + Content = JsonContent.Create(notifyRequest), + }; + notifyHttpRequest.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + apiPassword + ); + var notifyResponse = await HttpClientProvider.Client.SendAsync( + notifyHttpRequest ); _logger.LogInformation( @@ -259,19 +264,19 @@ public async Task UploadDemo(string filePath) string endpoint = $"{_environmentService.GetDemosUrl()}/demos/{match.id}/pre-signed"; - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - apiPassword - ); - var requestBody = new { demo = Path.GetFileName(filePath), mapId = _matchService.GetCurrentMatch()?.GetMatchData()?.current_match_map_id, }; - var response = await httpClient.PostAsJsonAsync(endpoint, requestBody); + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonContent.Create(requestBody), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiPassword); + + var response = await HttpClientProvider.Client.SendAsync(request); _logger.LogInformation( $"presigned url response {(int)response.StatusCode} {response.StatusCode} (match {match.id} map {match.current_match_map_id} demo {Path.GetFileName(filePath)})" diff --git a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs index d1c735e..860ae86 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/GameServer.cs @@ -115,32 +115,31 @@ public void Ping(string pluginVersion) endpoint += $"&steamID={serverSteamID}"; } - using (HttpClient httpClient = new HttpClient()) + try { - httpClient.Timeout = TimeSpan.FromSeconds(5); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", apiPassword ); - - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - HttpResponseMessage response = await httpClient.GetAsync(endpoint, cts.Token); - response.EnsureSuccessStatusCode(); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogWarning("Ping request timed out after 5 seconds"); - } - catch (HttpRequestException ex) - { - _logger.LogCritical($"Unable to ping: {ex.Message}"); - } - catch (Exception ex) - { - _logger.LogCritical($"Unexpected error during ping: {ex.Message}"); - } + HttpResponseMessage response = await HttpClientProvider.Client.SendAsync( + request, + cts.Token + ); + response.EnsureSuccessStatusCode(); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Ping request timed out after 5 seconds"); + } + catch (HttpRequestException ex) + { + _logger.LogCritical($"Unable to ping: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogCritical($"Unexpected error during ping: {ex.Message}"); } }); } diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs index 87aacc3..cf966b6 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchEvents.cs @@ -1,6 +1,8 @@ +using System.Collections.Concurrent; using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Threading.Channels; using CounterStrikeSharp.API.Modules.Utils; using FiveStack.Entities; using FiveStack.Enums; @@ -15,7 +17,7 @@ public class MatchEvents private ClientWebSocket? _webSocket; private CancellationTokenSource? _connectionCts; private bool _manualDisconnect = false; - private Dictionary< + private ConcurrentDictionary< Guid, (EventData> Event, DateTime Timestamp) > _pendingMessages = new(); @@ -23,6 +25,21 @@ private Dictionary< private const int RETRY_INTERVAL_MS = 5000; private const int MESSAGE_RETRY_THRESHOLD_SECONDS = 10; + // ClientWebSocket forbids concurrent SendAsync. + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + + // Single-consumer queue: keeps serialization off the game thread while + // preserving event order. + private readonly Channel<( + Guid MatchId, + Guid MapId, + EventData> Payload + )> _publishQueue = Channel.CreateUnbounded<( + Guid, + Guid, + EventData> + )>(new UnboundedChannelOptions { SingleReader = true }); + private readonly ILogger _logger; private readonly MatchService _matchService; private readonly EnvironmentService _environmentService; @@ -43,9 +60,25 @@ GameServer gameServer _retryTimer = new System.Timers.Timer(RETRY_INTERVAL_MS); _retryTimer.Elapsed += async (sender, e) => await RetryPendingMessages(); + _ = Task.Run(ProcessPublishQueue); _ = ConnectAndMonitor(); } + private async Task ProcessPublishQueue() + { + await foreach (var (matchId, mapId, payload) in _publishQueue.Reader.ReadAllAsync()) + { + try + { + await Publish(matchId, mapId, payload); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed publishing game event"); + } + } + } + public class EventData { public string @event { get; set; } = ""; @@ -102,7 +135,7 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) ); } - public async void PublishGameEvent(string Event, Dictionary Data) + public void PublishGameEvent(string Event, Dictionary Data) { if (_environmentService.IsOfflineMode()) { @@ -121,14 +154,12 @@ public async void PublishGameEvent(string Event, Dictionary Data return; } - await Publish( - matchId, - mapId, - new EventData> - { - data = new Dictionary { { "event", Event }, { "data", Data } }, - } - ); + var payload = new EventData> + { + data = new Dictionary { { "event", Event }, { "data", Data } }, + }; + + _publishQueue.Writer.TryWrite((matchId, mapId, payload)); } private async Task ConnectAndMonitor() @@ -186,7 +217,7 @@ private async Task MonitorConnection() try { var messageId = Guid.Parse(messageIdStr.Trim('"')); - _pendingMessages.Remove(messageId); + _pendingMessages.TryRemove(messageId, out _); } catch (Exception ex) { @@ -298,6 +329,7 @@ public async Task Disconnect() _isMonitoring = false; _retryTimer.Stop(); _retryTimer?.Dispose(); + _publishQueue.Writer.TryComplete(); if (_webSocket != null && _webSocket.State == WebSocketState.Open) { @@ -332,12 +364,20 @@ private async Task RetryPendingMessages() if (_webSocket?.State == WebSocketState.Open) { - await _webSocket.SendAsync( - new ArraySegment(buffer), - WebSocketMessageType.Text, - true, - _connectionCts?.Token ?? CancellationToken.None - ); + await _sendLock.WaitAsync(_connectionCts?.Token ?? CancellationToken.None); + try + { + await _webSocket.SendAsync( + new ArraySegment(buffer), + WebSocketMessageType.Text, + true, + _connectionCts?.Token ?? CancellationToken.None + ); + } + finally + { + _sendLock.Release(); + } _pendingMessages[message.Key] = (message.Value.Event, currentTime); } @@ -351,37 +391,49 @@ await _webSocket.SendAsync( private async Task Publish(Guid matchId, Guid mapId, EventData data) { - if (_webSocket == null || _webSocket.State == WebSocketState.Closed) - { - _logger.LogWarning($"Trying to publish but not connected"); - return; - } - data.mapId = mapId; data.matchId = matchId; data.messageId = Guid.NewGuid(); + // Queue before the connectivity check so events sent while disconnected + // go out via the retry timer once reconnected. if (data is EventData> typedData) { _pendingMessages[data.messageId] = (typedData, DateTime.UtcNow); if (_pendingMessages.Count > 1000) { - var oldest = _pendingMessages.OrderBy(p => p.Value.Timestamp).First(); - _pendingMessages.Remove(oldest.Key); + var oldest = _pendingMessages.MinBy(p => p.Value.Timestamp); + _pendingMessages.TryRemove(oldest.Key, out _); } } + if (_webSocket == null || _webSocket.State != WebSocketState.Open) + { + _logger.LogWarning( + $"Cannot publish game event - websocket not connected; queued for retry" + ); + return; + } + try { var message = JsonSerializer.Serialize(new { @event = "events", data }); var buffer = Encoding.UTF8.GetBytes(message); - await _webSocket!.SendAsync( - new ArraySegment(buffer), - WebSocketMessageType.Text, - true, - _connectionCts?.Token ?? CancellationToken.None - ); + await _sendLock.WaitAsync(_connectionCts?.Token ?? CancellationToken.None); + try + { + await _webSocket!.SendAsync( + new ArraySegment(buffer), + WebSocketMessageType.Text, + true, + _connectionCts?.Token ?? CancellationToken.None + ); + } + finally + { + _sendLock.Release(); + } } catch (Exception error) { diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs index 3b5d65b..03fe232 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchManager.cs @@ -727,7 +727,7 @@ public int GetExpectedPlayerCount() private void StartWarmup() { - ConVar.Find("sv_disable_teamselect_menu")?.SetValue(0); + ConVar.Find("sv_disable_teamselect_menu")?.SetValue(false); _gameServer.SendCommands(["exec 5stack.warmup.cfg"]); knifeSystem.Reset(); @@ -752,7 +752,7 @@ private void StartWarmup() private void StartKnife() { - ConVar.Find("sv_disable_teamselect_menu")?.SetValue(1); + ConVar.Find("sv_disable_teamselect_menu")?.SetValue(true); if (_matchData == null || IsKnife()) { @@ -775,7 +775,7 @@ private void StartLive() _logger.LogInformation("Starting Live Match"); - ConVar.Find("sv_disable_teamselect_menu")?.SetValue(1); + ConVar.Find("sv_disable_teamselect_menu")?.SetValue(true); ConVar.Find("mp_backup_round_auto")?.SetValue(1); diff --git a/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs b/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs index 6960a16..7776232 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/MatchService.cs @@ -1,6 +1,7 @@ using System.Text.Json; using CounterStrikeSharp.API; using FiveStack.Entities; +using FiveStack.Utilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -47,19 +48,14 @@ var file in new[] _logger.LogInformation($"Downloading Config: {file}"); string url = $"https://raw.githubusercontent.com/5stackgg/game-server/main/shared/cfg/{file}"; - using (var client = new HttpClient()) + var response = await HttpClientProvider.Client.GetAsync(url); + if (!response.IsSuccessStatusCode) { - var response = await client.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - _logger.LogError( - $"Failed to download config {file}: {response.StatusCode}" - ); - continue; - } - var content = await response.Content.ReadAsStringAsync(); - File.WriteAllText(Path.Join(directoryPath, file), content); + _logger.LogError($"Failed to download config {file}: {response.StatusCode}"); + continue; } + var content = await response.Content.ReadAsStringAsync(); + File.WriteAllText(Path.Join(directoryPath, file), content); } } } @@ -102,17 +98,19 @@ public async void GetMatchFromApi() try { - using (var httpClient = new HttpClient()) { string matchUri = $"{_environmentService.GetApiUrl()}/matches/current-match/{serverId}"; _logger.LogInformation($"Fetching Match Info for server : {serverId}"); - httpClient.DefaultRequestHeaders.Authorization = + var request = new HttpRequestMessage(HttpMethod.Get, matchUri); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiPassword); - string? response = await httpClient.GetStringAsync(matchUri); + var httpResponse = await HttpClientProvider.Client.SendAsync(request); + httpResponse.EnsureSuccessStatusCode(); + string? response = await httpResponse.Content.ReadAsStringAsync(); Server.NextFrame(() => { diff --git a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs index 4202b5a..d6069ea 100644 --- a/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs +++ b/apps/counterstrikesharp/src/FiveStack.Services/ReadySystem.cs @@ -77,7 +77,8 @@ public void UpdatePlayerStatus() continue; } - if (player.Clan != "" && player.Clan != "[ready]" && player.Clan != "[not ready]") + // ready tags end in " |"; leave real "[..]" clan tags alone + if (player.Clan != "" && !player.Clan.EndsWith(" |")) { continue; } diff --git a/apps/counterstrikesharp/test/FiveStack.Tests.csproj b/apps/counterstrikesharp/test/FiveStack.Tests.csproj new file mode 100644 index 0000000..b325b71 --- /dev/null +++ b/apps/counterstrikesharp/test/FiveStack.Tests.csproj @@ -0,0 +1,14 @@ + + + false + bin/ + + + + + + + + + + diff --git a/apps/counterstrikesharp/test/MatchUtilityTests.cs b/apps/counterstrikesharp/test/MatchUtilityTests.cs new file mode 100644 index 0000000..f61e63c --- /dev/null +++ b/apps/counterstrikesharp/test/MatchUtilityTests.cs @@ -0,0 +1,101 @@ +using FiveStack.Entities; +using FiveStack.Enums; +using FiveStack.Utilities; +using Xunit; + +public class MatchUtilityTests +{ + private static MatchData BuildMatch() + { + return new MatchData + { + id = Guid.Parse("11111111-1111-1111-1111-111111111111"), + current_match_map_id = Guid.Parse("22222222-2222-2222-2222-222222222222"), + lineup_1 = new MatchLineUp + { + lineup_players = new List + { + new MatchMember { steam_id = "76561198000000001", name = "Real" }, + }, + }, + lineup_2 = new MatchLineUp + { + lineup_players = new List + { + new MatchMember { steam_id = null, placeholder_name = "AceBot" }, + }, + }, + }; + } + + [Fact] + public void GetMemberFromLineup_MatchesBySteamId() + { + MatchMember? member = MatchUtility.GetMemberFromLineup( + BuildMatch(), + "76561198000000001", + "ignored" + ); + Assert.NotNull(member); + Assert.Equal("Real", member!.name); + } + + [Fact] + public void GetMemberFromLineup_MatchesPlaceholderByNamePrefix() + { + MatchMember? member = MatchUtility.GetMemberFromLineup(BuildMatch(), "9999", "Ace"); + Assert.NotNull(member); + Assert.Equal("AceBot", member!.placeholder_name); + } + + [Fact] + public void GetMemberFromLineup_ReturnsNullWhenNoMatch() + { + Assert.Null(MatchUtility.GetMemberFromLineup(BuildMatch(), "9999", "Nobody")); + } + + [Fact] + public void HasPlaceholderMembers_TrueWhenAnyNullSteamId() + { + Assert.True(MatchUtility.HasPlaceholderMembers(BuildMatch())); + } + + [Fact] + public void HasPlaceholderMembers_FalseWhenAllHaveSteamId() + { + MatchData match = BuildMatch(); + match.lineup_2.lineup_players[0].steam_id = "76561198000000002"; + Assert.False(MatchUtility.HasPlaceholderMembers(match)); + } + + [Fact] + public void GetSafeMatchPrefix_StripsDashesAndJoinsIds() + { + string prefix = MatchUtility.GetSafeMatchPrefix(BuildMatch()); + Assert.DoesNotContain("-", prefix); + Assert.Equal("11111111111111111111111111111111_22222222222222222222222222222222", prefix); + } + + [Theory] + [InlineData("Warmup", eMapStatus.Warmup)] + [InlineData("Knife", eMapStatus.Knife)] + [InlineData("Live", eMapStatus.Live)] + [InlineData("Overtime", eMapStatus.Overtime)] + [InlineData("Paused", eMapStatus.Paused)] + [InlineData("WaitingForTV", eMapStatus.WaitingForTV)] + [InlineData("UploadingDemo", eMapStatus.UploadingDemo)] + [InlineData("Finished", eMapStatus.Finished)] + [InlineData("Surrendered", eMapStatus.Surrendered)] + [InlineData("Scheduled", eMapStatus.Scheduled)] + [InlineData("Unknown", eMapStatus.Unknown)] + public void MapStatus_MapsApiStrings(string input, eMapStatus expected) + { + Assert.Equal(expected, MatchUtility.MapStatusStringToEnum(input)); + } + + [Fact] + public void MapStatus_UnknownStringThrows() + { + Assert.Throws(() => MatchUtility.MapStatusStringToEnum("Bogus")); + } +} diff --git a/apps/counterstrikesharp/test/TeamUtilityTests.cs b/apps/counterstrikesharp/test/TeamUtilityTests.cs new file mode 100644 index 0000000..143a930 --- /dev/null +++ b/apps/counterstrikesharp/test/TeamUtilityTests.cs @@ -0,0 +1,131 @@ +using CounterStrikeSharp.API.Modules.Utils; +using FiveStack.Entities; +using FiveStack.Utilities; +using Xunit; + +public class TeamUtilityTests +{ + [Theory] + [InlineData(1, "Spectator")] + [InlineData(2, "TERRORIST")] + [InlineData(3, "CT")] + [InlineData(0, "None")] + [InlineData(99, "None")] + public void TeamNumToString_MapsTeamNumbers(int teamNum, string expected) + { + Assert.Equal(expected, TeamUtility.TeamNumToString(teamNum)); + } + + [Theory] + [InlineData(1, CsTeam.Spectator)] + [InlineData(2, CsTeam.Terrorist)] + [InlineData(3, CsTeam.CounterTerrorist)] + [InlineData(0, CsTeam.None)] + [InlineData(99, CsTeam.None)] + public void TeamNumToCSTeam_MapsTeamNumbers(int teamNum, CsTeam expected) + { + Assert.Equal(expected, TeamUtility.TeamNumToCSTeam(teamNum)); + } + + [Theory] + [InlineData("Spectator", CsTeam.Spectator)] + [InlineData("TERRORIST", CsTeam.Terrorist)] + [InlineData("CT", CsTeam.CounterTerrorist)] + [InlineData("", CsTeam.None)] + [InlineData("garbage", CsTeam.None)] + public void TeamStringToCsTeam_MapsApiStrings(string team, CsTeam expected) + { + Assert.Equal(expected, TeamUtility.TeamStringToCsTeam(team)); + } + + [Theory] + [InlineData(CsTeam.Spectator)] + [InlineData(CsTeam.Terrorist)] + [InlineData(CsTeam.CounterTerrorist)] + public void TeamStringRoundTrips(CsTeam team) + { + Assert.Equal(team, TeamUtility.TeamStringToCsTeam(TeamUtility.CSTeamToString(team))); + } +} + +public class GetLineupSideTests +{ + private static readonly Guid Lineup1 = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid Lineup2 = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + private static (MatchData match, MatchMap map) BuildMatch( + int mr = 12, + string lineup1Side = "CT", + string lineup2Side = "TERRORIST" + ) + { + var match = new MatchData + { + lineup_1_id = Lineup1, + lineup_2_id = Lineup2, + options = new MatchOptions { mr = mr }, + }; + var map = new MatchMap { lineup_1_side = lineup1Side, lineup_2_side = lineup2Side }; + return (match, map); + } + + [Theory] + [InlineData(0, CsTeam.CounterTerrorist)] + [InlineData(11, CsTeam.CounterTerrorist)] + [InlineData(12, CsTeam.Terrorist)] + [InlineData(23, CsTeam.Terrorist)] + public void RegularTime_Lineup1_SwitchesAtHalf(int round, CsTeam expected) + { + var (match, map) = BuildMatch(); + Assert.Equal(expected, TeamUtility.GetLineupSide(match, map, Lineup1, round)); + } + + [Theory] + [InlineData(0, CsTeam.Terrorist)] + [InlineData(11, CsTeam.Terrorist)] + [InlineData(12, CsTeam.CounterTerrorist)] + [InlineData(23, CsTeam.CounterTerrorist)] + public void RegularTime_Lineup2_SwitchesAtHalf(int round, CsTeam expected) + { + var (match, map) = BuildMatch(); + Assert.Equal(expected, TeamUtility.GetLineupSide(match, map, Lineup2, round)); + } + + [Fact] + public void ShortMatch_SwitchesAtItsOwnHalf() + { + var (match, map) = BuildMatch(mr: 8); + Assert.Equal( + CsTeam.CounterTerrorist, + TeamUtility.GetLineupSide(match, map, Lineup1, 7) + ); + Assert.Equal(CsTeam.Terrorist, TeamUtility.GetLineupSide(match, map, Lineup1, 8)); + } + + [Fact] + public void ZeroMr_ReturnsNone() + { + var (match, map) = BuildMatch(mr: 0); + Assert.Equal(CsTeam.None, TeamUtility.GetLineupSide(match, map, Lineup1, 0)); + } + + [Theory] + [InlineData("")] + [InlineData("Spectator")] + public void InvalidStartingSide_ReturnsNone(string side) + { + var (match, map) = BuildMatch(lineup1Side: side); + Assert.Equal(CsTeam.None, TeamUtility.GetLineupSide(match, map, Lineup1, 0)); + } + + [Fact] + public void UnknownLineupId_IsTreatedAsLineup2() + { + var (match, map) = BuildMatch(); + Guid unknown = Guid.Parse("33333333-3333-3333-3333-333333333333"); + Assert.Equal( + TeamUtility.GetLineupSide(match, map, Lineup2, 0), + TeamUtility.GetLineupSide(match, map, unknown, 0) + ); + } +} diff --git a/apps/swiftly/scripts/dev.sh b/apps/swiftly/scripts/dev.sh index 425e41f..c4b6a6a 100755 --- a/apps/swiftly/scripts/dev.sh +++ b/apps/swiftly/scripts/dev.sh @@ -14,7 +14,17 @@ log() { echo "[dev.sh $(date '+%H:%M:%S')] $*"; } PROJECT="/opt/5stack/apps/swiftly/src/FiveStack.csproj" BUILD_OUTPUT="/opt/5stack/apps/swiftly/src/build/net10.0" -DEV_DIR="/opt/dev" + +# Write straight into the shared plugin-dir mount when the pod provides it +# (the game server's AutoHotReload watches that directory); otherwise the sw +# subfolder of the dev volume (css and sw dev builds share the volume). +PLUGIN_MOUNT="/opt/instance/game/csgo/addons/swiftlys2/plugins/FiveStack" +if mountpoint -q "$PLUGIN_MOUNT" 2>/dev/null; then + DEV_DIR="$PLUGIN_MOUNT" +else + DEV_DIR="/opt/dev/sw" + mkdir -p "$DEV_DIR" +fi log "starting dev hot-reload" log " project: $PROJECT" diff --git a/apps/swiftly/scripts/setup.sh b/apps/swiftly/scripts/setup.sh index dd33920..c164fbb 100755 --- a/apps/swiftly/scripts/setup.sh +++ b/apps/swiftly/scripts/setup.sh @@ -126,20 +126,24 @@ if $INSTALL_5STACK_PLUGIN = true ; then echo "---Install 5Stack---" FIVESTACK_PLUGIN_DIR="${INSTANCE_SERVER_DIR}/game/csgo/addons/swiftlys2/plugins/FiveStack" if [ "${DEV_SWAPPED}" == "1" ]; then - # Bind-mount /opt/dev rather than symlink it: SwiftlyS2's AutoHotReload - # FileSystemWatcher (inotify) does not follow symlinked plugin dirs, so a - # symlink here silently disables hot reload. Fall back to a symlink if the - # container can't mount (then use "sw plugins reload FiveStack" manually). + # AutoHotReload's recursive FileSystemWatcher does not follow symlinked plugin + # dirs, so hot reload needs the dev volume mounted straight onto this path + # (see the dev deployment). Without the mount, fall back to a symlink. mkdir -p "$FIVESTACK_PLUGIN_DIR" - if mount --bind "/opt/dev" "$FIVESTACK_PLUGIN_DIR" 2>/dev/null; then - echo "---5Stack dev: bind-mounted /opt/dev (hot reload enabled)---" + if mountpoint -q "$FIVESTACK_PLUGIN_DIR"; then + echo "---5Stack dev: plugin dir is a mounted volume (hot reload enabled)---" else + # css and sw dev builds share the dev volume; each uses its own subfolder rmdir "$FIVESTACK_PLUGIN_DIR" 2>/dev/null - ln -s "/opt/dev" "$FIVESTACK_PLUGIN_DIR" - echo "---5Stack dev: mount unavailable, symlinked /opt/dev (hot reload OFF; use 'sw plugins reload FiveStack')---" + mkdir -p "/opt/dev/sw" + ln -s "/opt/dev/sw" "$FIVESTACK_PLUGIN_DIR" + echo "---5Stack dev: symlinked /opt/dev/sw (hot reload OFF; use 'sw plugins reload FiveStack')---" fi - else + elif [ ! -e "$FIVESTACK_PLUGIN_DIR" ]; then ln -s "/opt/mod" "$FIVESTACK_PLUGIN_DIR" + else + # dir pre-exists (dev volume mount without DEV_SWAPPED); don't plant /opt/mod inside it + echo "---5Stack: plugin dir already present, skipping /opt/mod symlink---" fi fi diff --git a/apps/swiftly/src/FiveStack.Events/PlayerDamage.cs b/apps/swiftly/src/FiveStack.Events/PlayerDamage.cs index e195bde..5b0ebd4 100644 --- a/apps/swiftly/src/FiveStack.Events/PlayerDamage.cs +++ b/apps/swiftly/src/FiveStack.Events/PlayerDamage.cs @@ -10,6 +10,10 @@ namespace FiveStack; public partial class FiveStackPlugin { + // Victim health before their next hit, per round (cleared on round start). + // Lets us report exact applied damage instead of raw overkill damage. + private readonly Dictionary _victimHealth = new(); + #pragma warning disable CS0618 [GameEventHandler(HookMode.Post)] public HookResult OnPlayerDamage(EventPlayerHurt @event) @@ -47,12 +51,11 @@ public HookResult OnPlayerDamage(EventPlayerHurt @event) var attackerLocation = attackerPawn?.AbsOrigin; var attackedLocation = attackedPawn.AbsOrigin; - int damageDealt = @event.DmgHealth; - - if (attackedPawn.Health < 0) - { - damageDealt = damageDealt + attackedPawn.Health; - } + int priorHealth = _victimHealth.TryGetValue(attacked.SteamID, out int tracked) + ? tracked + : 100; + int damageDealt = Math.Min(@event.DmgHealth, priorHealth); + _victimHealth[attacked.SteamID] = @event.Health; _matchEvents.PublishGameEvent( "damage", diff --git a/apps/swiftly/src/FiveStack.Events/RoundStart.cs b/apps/swiftly/src/FiveStack.Events/RoundStart.cs index c07bd23..6cc0021 100644 --- a/apps/swiftly/src/FiveStack.Events/RoundStart.cs +++ b/apps/swiftly/src/FiveStack.Events/RoundStart.cs @@ -11,6 +11,8 @@ public partial class FiveStackPlugin [GameEventHandler(HookMode.Post)] public HookResult OnRoundStart(EventRoundStart @event) { + _victimHealth.Clear(); + MatchManager? matchManager = _matchService.GetCurrentMatch(); if (matchManager == null) { diff --git a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs index a7d9174..8336864 100644 --- a/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs +++ b/apps/swiftly/src/FiveStack.Services/GameBackUpRounds.cs @@ -513,38 +513,56 @@ public void RestoreRound(int round) backupRoundFileName ); - File.WriteAllText(backupRoundFilePath, backupRound.backup_file); - _resetRound = round; _logger.LogInformation($"Loading backup round file {backupRoundFileName}"); - _core.Scheduler.NextTick(() => + string backupFileContents = backupRound.backup_file; + + _ = Task.Run(() => { - _gameServer.SendCommands( - [$"mp_backup_restore_load_file {backupRoundFileName}"] - ); - _matchService.GetCurrentMatch()?.PauseMatch(); + try + { + File.WriteAllText(backupRoundFilePath, backupFileContents); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed writing restore backup round file {File}", + backupRoundFileName + ); + _core.Scheduler.NextTick(() => _resetRound = null); + return; + } - TimerUtility.AddTimer( - 5, - () => - { - _resetRound = null; + _core.Scheduler.NextTick(() => + { + _gameServer.SendCommands( + [$"mp_backup_restore_load_file {backupRoundFileName}"] + ); + _matchService.GetCurrentMatch()?.PauseMatch(); - _logger.LogInformation($"Sending Message for Round {round}"); - - _gameServer.Message( - MessageType.Alert, - _localizer[ - "backup.round_restored", - ChatColors.Red, - round, - CommandUtility.PublicChatTrigger - ] - ); - } - ); + TimerUtility.AddTimer( + 5, + () => + { + _resetRound = null; + + _logger.LogInformation($"Sending Message for Round {round}"); + + _gameServer.Message( + MessageType.Alert, + _localizer[ + "backup.round_restored", + ChatColors.Red, + round, + CommandUtility.PublicChatTrigger + ] + ); + } + ); + }); }); } diff --git a/apps/swiftly/src/FiveStack.Services/GameDemos.cs b/apps/swiftly/src/FiveStack.Services/GameDemos.cs index ead16b8..e32d700 100644 --- a/apps/swiftly/src/FiveStack.Services/GameDemos.cs +++ b/apps/swiftly/src/FiveStack.Services/GameDemos.cs @@ -173,6 +173,7 @@ public async Task UploadDemo(string filePath) return; } + // dedicated client: the S3 PUT needs an unbounded timeout using var httpClient = new HttpClient(); httpClient.Timeout = System.Threading.Timeout.InfiniteTimeSpan; @@ -209,12 +210,16 @@ public async Task UploadDemo(string filePath) size = fileInfo.Length, }; - using var notifyClient = new HttpClient(); - notifyClient.DefaultRequestHeaders.Authorization = - new AuthenticationHeaderValue("Bearer", apiPassword); - var notifyResponse = await notifyClient.PostAsJsonAsync( - notifyEndpoint, - notifyRequest + var notifyHttpRequest = new HttpRequestMessage(HttpMethod.Post, notifyEndpoint) + { + Content = JsonContent.Create(notifyRequest), + }; + notifyHttpRequest.Headers.Authorization = new AuthenticationHeaderValue( + "Bearer", + apiPassword + ); + var notifyResponse = await HttpClientProvider.Client.SendAsync( + notifyHttpRequest ); _logger.LogInformation( @@ -261,19 +266,19 @@ public async Task UploadDemo(string filePath) string endpoint = $"{_environmentService.GetDemosUrl()}/demos/{match.id}/pre-signed"; - using var httpClient = new HttpClient(); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( - "Bearer", - apiPassword - ); - var requestBody = new { demo = Path.GetFileName(filePath), mapId = _matchService.GetCurrentMatch()?.GetMatchData()?.current_match_map_id, }; - var response = await httpClient.PostAsJsonAsync(endpoint, requestBody); + var request = new HttpRequestMessage(HttpMethod.Post, endpoint) + { + Content = JsonContent.Create(requestBody), + }; + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiPassword); + + var response = await HttpClientProvider.Client.SendAsync(request); _logger.LogInformation( $"presigned url response {(int)response.StatusCode} {response.StatusCode} (match {match.id} map {match.current_match_map_id} demo {Path.GetFileName(filePath)})" diff --git a/apps/swiftly/src/FiveStack.Services/GameServer.cs b/apps/swiftly/src/FiveStack.Services/GameServer.cs index 77bde88..c808478 100644 --- a/apps/swiftly/src/FiveStack.Services/GameServer.cs +++ b/apps/swiftly/src/FiveStack.Services/GameServer.cs @@ -141,35 +141,31 @@ public void Ping(string pluginVersion) _ = Task.Run(async () => { - using (HttpClient httpClient = new HttpClient()) + try { - httpClient.Timeout = TimeSpan.FromSeconds(5); - httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var request = new HttpRequestMessage(HttpMethod.Get, endpoint); + request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", apiPassword ); - - try - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); - HttpResponseMessage response = await httpClient.GetAsync( - endpoint, - cts.Token - ); - response.EnsureSuccessStatusCode(); - } - catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) - { - _logger.LogWarning("Ping request timed out after 5 seconds"); - } - catch (HttpRequestException ex) - { - _logger.LogCritical($"Unable to ping: {ex.Message}"); - } - catch (Exception ex) - { - _logger.LogCritical($"Unexpected error during ping: {ex.Message}"); - } + HttpResponseMessage response = await HttpClientProvider.Client.SendAsync( + request, + cts.Token + ); + response.EnsureSuccessStatusCode(); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Ping request timed out after 5 seconds"); + } + catch (HttpRequestException ex) + { + _logger.LogCritical($"Unable to ping: {ex.Message}"); + } + catch (Exception ex) + { + _logger.LogCritical($"Unexpected error during ping: {ex.Message}"); } }); }); diff --git a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs index 9a34949..e280e16 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchEvents.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchEvents.cs @@ -2,6 +2,7 @@ using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Threading.Channels; using SwiftlyS2.Shared.Players; using FiveStack.Entities; using FiveStack.Enums; @@ -24,6 +25,21 @@ private ConcurrentDictionary< private const int RETRY_INTERVAL_MS = 5000; private const int MESSAGE_RETRY_THRESHOLD_SECONDS = 10; + // ClientWebSocket forbids concurrent SendAsync. + private readonly SemaphoreSlim _sendLock = new SemaphoreSlim(1, 1); + + // Single-consumer queue: keeps serialization off the game thread while + // preserving event order. + private readonly Channel<( + Guid MatchId, + Guid MapId, + EventData> Payload + )> _publishQueue = Channel.CreateUnbounded<( + Guid, + Guid, + EventData> + )>(new UnboundedChannelOptions { SingleReader = true }); + private readonly ILogger _logger; private readonly MatchService _matchService; private readonly EnvironmentService _environmentService; @@ -44,9 +60,25 @@ GameServer gameServer _retryTimer = new System.Timers.Timer(RETRY_INTERVAL_MS); _retryTimer.Elapsed += async (sender, e) => await RetryPendingMessages(); + _ = Task.Run(ProcessPublishQueue); _ = ConnectAndMonitor(); } + private async Task ProcessPublishQueue() + { + await foreach (var (matchId, mapId, payload) in _publishQueue.Reader.ReadAllAsync()) + { + try + { + await Publish(matchId, mapId, payload); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed publishing game event"); + } + } + } + public class EventData { public string @event { get; set; } = ""; @@ -109,7 +141,7 @@ public void PublishMapStatus(eMapStatus status, Guid? winningLineupId) ); } - public async void PublishGameEvent(string Event, Dictionary Data) + public void PublishGameEvent(string Event, Dictionary Data) { if (_environmentService.IsOfflineMode()) { @@ -131,14 +163,12 @@ public async void PublishGameEvent(string Event, Dictionary Data return; } - await Publish( - matchId, - mapId, - new EventData> - { - data = new Dictionary { { "event", Event }, { "data", Data } }, - } - ); + var payload = new EventData> + { + data = new Dictionary { { "event", Event }, { "data", Data } }, + }; + + _publishQueue.Writer.TryWrite((matchId, mapId, payload)); } private async Task ConnectAndMonitor() @@ -308,6 +338,7 @@ public async Task Disconnect() _isMonitoring = false; _retryTimer.Stop(); _retryTimer?.Dispose(); + _publishQueue.Writer.TryComplete(); if (_webSocket != null && _webSocket.State == WebSocketState.Open) { @@ -349,12 +380,20 @@ private async Task RetryPendingMessages() (currentTime - message.Value.Timestamp).TotalSeconds ); - await _webSocket.SendAsync( - new ArraySegment(buffer), - WebSocketMessageType.Text, - true, - _connectionCts?.Token ?? CancellationToken.None - ); + await _sendLock.WaitAsync(_connectionCts?.Token ?? CancellationToken.None); + try + { + await _webSocket.SendAsync( + new ArraySegment(buffer), + WebSocketMessageType.Text, + true, + _connectionCts?.Token ?? CancellationToken.None + ); + } + finally + { + _sendLock.Release(); + } _pendingMessages[message.Key] = (message.Value.Event, currentTime); } @@ -415,7 +454,7 @@ private async Task Publish(Guid matchId, Guid mapId, EventData data) if (_pendingMessages.Count > 1000) { - var oldest = _pendingMessages.OrderBy(p => p.Value.Timestamp).First(); + var oldest = _pendingMessages.MinBy(p => p.Value.Timestamp); _pendingMessages.TryRemove(oldest.Key, out _); } } @@ -429,12 +468,20 @@ private async Task Publish(Guid matchId, Guid mapId, EventData data) { var message = JsonSerializer.Serialize(new { @event = "events", data }); var buffer = Encoding.UTF8.GetBytes(message); - await _webSocket!.SendAsync( - new ArraySegment(buffer), - WebSocketMessageType.Text, - true, - _connectionCts?.Token ?? CancellationToken.None - ); + await _sendLock.WaitAsync(_connectionCts?.Token ?? CancellationToken.None); + try + { + await _webSocket!.SendAsync( + new ArraySegment(buffer), + WebSocketMessageType.Text, + true, + _connectionCts?.Token ?? CancellationToken.None + ); + } + finally + { + _sendLock.Release(); + } _logger.LogDebug( "Published game event '{Event}' (messageId={MessageId})", eventName, diff --git a/apps/swiftly/src/FiveStack.Services/MatchManager.cs b/apps/swiftly/src/FiveStack.Services/MatchManager.cs index 802b273..81ded72 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchManager.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchManager.cs @@ -721,7 +721,7 @@ public int GetExpectedPlayerCount() private void StartWarmup() { - SetConVar("sv_disable_teamselect_menu", 0); + SetConVar("sv_disable_teamselect_menu", false); _gameServer.SendCommands(["exec 5stack.warmup.cfg"]); knifeSystem.Reset(); @@ -746,7 +746,7 @@ private void StartWarmup() private void StartKnife() { - SetConVar("sv_disable_teamselect_menu", 1); + SetConVar("sv_disable_teamselect_menu", true); if (_matchData == null || IsKnife()) { @@ -769,7 +769,7 @@ private void StartLive() _logger.LogInformation("Starting Live Match"); - SetConVar("sv_disable_teamselect_menu", 1); + SetConVar("sv_disable_teamselect_menu", true); // mp_backup_round_auto is enabled in _backUpManagement.Setup(). diff --git a/apps/swiftly/src/FiveStack.Services/MatchService.cs b/apps/swiftly/src/FiveStack.Services/MatchService.cs index ab2d939..560934f 100644 --- a/apps/swiftly/src/FiveStack.Services/MatchService.cs +++ b/apps/swiftly/src/FiveStack.Services/MatchService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using FiveStack.Entities; +using FiveStack.Utilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using SwiftlyS2.Shared; @@ -47,19 +48,14 @@ var file in new[] _logger.LogInformation($"Downloading Config: {file}"); string url = $"https://raw.githubusercontent.com/5stackgg/game-server/main/shared/cfg/{file}"; - using (var client = new HttpClient()) + var response = await HttpClientProvider.Client.GetAsync(url); + if (!response.IsSuccessStatusCode) { - var response = await client.GetAsync(url); - if (!response.IsSuccessStatusCode) - { - _logger.LogError( - $"Failed to download config {file}: {response.StatusCode}" - ); - continue; - } - var content = await response.Content.ReadAsStringAsync(); - File.WriteAllText(Path.Join(directoryPath, file), content); + _logger.LogError($"Failed to download config {file}: {response.StatusCode}"); + continue; } + var content = await response.Content.ReadAsStringAsync(); + File.WriteAllText(Path.Join(directoryPath, file), content); } } } @@ -102,17 +98,19 @@ public async void GetMatchFromApi() try { - using (var httpClient = new HttpClient()) { string matchUri = $"{_environmentService.GetApiUrl()}/matches/current-match/{serverId}"; _logger.LogInformation($"Fetching Match Info for server : {serverId}"); - httpClient.DefaultRequestHeaders.Authorization = + var request = new HttpRequestMessage(HttpMethod.Get, matchUri); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiPassword); - string? response = await httpClient.GetStringAsync(matchUri); + var httpResponse = await HttpClientProvider.Client.SendAsync(request); + httpResponse.EnsureSuccessStatusCode(); + string? response = await httpResponse.Content.ReadAsStringAsync(); _core.Scheduler.NextTick(() => { diff --git a/apps/swiftly/src/FiveStack.Services/RankSystem.cs b/apps/swiftly/src/FiveStack.Services/RankSystem.cs index e8a887d..755e2d7 100644 --- a/apps/swiftly/src/FiveStack.Services/RankSystem.cs +++ b/apps/swiftly/src/FiveStack.Services/RankSystem.cs @@ -35,17 +35,21 @@ public RankSystem(ISwiftlyCore core, ILogger logger, MatchService ma public void Start() { - _core.Event.OnTick += OnTick; - _revealAllTimer = _core.Scheduler.RepeatBySeconds(RevealAllInterval, SendRevealAll); + _revealAllTimer = _core.Scheduler.RepeatBySeconds(RevealAllInterval, OnRevealInterval); } public void Stop() { - _core.Event.OnTick -= OnTick; _revealAllTimer?.Cancel(); _revealAllTimer = null; } + private void OnRevealInterval() + { + ApplyAllRanks(); + SendRevealAll(); + } + public void OnMatchSetup(MatchData matchData) { _lastNotified.Clear(); @@ -65,6 +69,7 @@ public void OnMatchSetup(MatchData matchData) $"Elo rank display enabled for match {matchData.id} (mode={matchData.options.type}, {_eloBySteamId.Count} player(s) with elo)" ); + ApplyAllRanks(); SendRevealAll(); } @@ -108,7 +113,7 @@ public void SendRevealAll() } } - private void OnTick() + private void ApplyAllRanks() { if (_eloBySteamId == null) { @@ -146,15 +151,15 @@ private void OnTick() } catch (InvalidOperationException ex) { - _logger.LogError(ex, "RankSystem.OnTick: invalid state during iteration"); + _logger.LogError(ex, "RankSystem.ApplyAllRanks: invalid state during iteration"); } catch (NullReferenceException ex) { - _logger.LogError(ex, "RankSystem.OnTick: unexpected null from external API"); + _logger.LogError(ex, "RankSystem.ApplyAllRanks: unexpected null from external API"); } catch (ArgumentException ex) { - _logger.LogError(ex, "RankSystem.OnTick: bad argument from upstream data"); + _logger.LogError(ex, "RankSystem.ApplyAllRanks: bad argument from upstream data"); } } diff --git a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs index dc52dca..dd1c75c 100644 --- a/apps/swiftly/src/FiveStack.Services/ReadySystem.cs +++ b/apps/swiftly/src/FiveStack.Services/ReadySystem.cs @@ -75,8 +75,7 @@ public void UpdatePlayerStatus() if ( player.Controller.Clan != "" - && player.Controller.Clan != "[ready]" - && player.Controller.Clan != "[not ready]" + && !player.Controller.Clan.EndsWith(" |") ) { continue; diff --git a/k8s/dev-game-server.yaml b/k8s/dev-game-server.yaml new file mode 100644 index 0000000..5bc1217 --- /dev/null +++ b/k8s/dev-game-server.yaml @@ -0,0 +1,101 @@ +# Local-dev only (not managed by the panel). CSS hot reload works through the +# plugins/FiveStack -> /opt/dev/css symlink created by setup.sh, so the dev +# volume mounts at /opt/dev. DEV_SWAPPED is injected by codepier during +# hot-swap sessions. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dev-game-server + namespace: 5stack +spec: + replicas: 1 + selector: + matchLabels: + app: dev-game-server + template: + metadata: + labels: + app: dev-game-server + spec: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + dnsConfig: + options: + - name: ndots + value: '1' + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: 5stack-dev-server + operator: In + values: + - 'true' + containers: + - name: dev-game-server + image: ghcr.io/5stackgg/game-server-css:beta + imagePullPolicy: Always + ports: + - containerPort: 27015 + protocol: TCP + - containerPort: 27015 + protocol: UDP + - containerPort: 27020 + protocol: TCP + - containerPort: 27020 + protocol: UDP + envFrom: + - secretRef: + name: dev-server-secrets + env: + - name: DEV_SERVER + value: 'true' + - name: SERVER_PORT + value: '27015' + - name: TV_PORT + value: '27020' + - name: EXTRA_GAME_PARAMS + value: '-maxplayers 13 +map de_overpass' + - name: ALLOW_BOTS + value: 'true' + - name: STEAM_RELAY + value: 'true' + volumeMounts: + - name: steamcmd + mountPath: /serverdata/steamcmd + - name: serverfiles + mountPath: /serverdata/serverfiles + - name: serverfiles-csgo + mountPath: /serverdata/serverfiles-csgo + - name: demos + mountPath: /opt/demos + - name: custom-plugins + mountPath: /opt/custom-plugins + - name: dev + mountPath: /opt/dev + volumes: + - name: steamcmd + hostPath: + path: /opt/5stack/steamcmd + type: DirectoryOrCreate + - name: serverfiles + hostPath: + path: /opt/5stack/serverfiles + type: DirectoryOrCreate + - name: serverfiles-csgo + hostPath: + path: /opt/5stack/serverfiles-csgo + type: DirectoryOrCreate + - name: demos + hostPath: + path: /opt/5stack/demos + type: DirectoryOrCreate + - name: custom-plugins + hostPath: + path: /opt/5stack/custom-plugins + type: DirectoryOrCreate + - name: dev + hostPath: + path: /opt/5stack/dev + type: DirectoryOrCreate diff --git a/k8s/dev-swiftly-game-server.yaml b/k8s/dev-swiftly-game-server.yaml new file mode 100644 index 0000000..644c955 --- /dev/null +++ b/k8s/dev-swiftly-game-server.yaml @@ -0,0 +1,98 @@ +# Local-dev only (not managed by the panel). The dev volume's sw/ subfolder is +# mounted directly onto the plugin dir so SwiftlyS2 AutoHotReload's watcher sees +# builds (symlinks don't work with its recursive FileSystemWatcher). +# DEV_SWAPPED is injected by codepier during hot-swap sessions. +apiVersion: apps/v1 +kind: Deployment +metadata: + name: dev-swiftly-game-server + namespace: 5stack +spec: + replicas: 1 + selector: + matchLabels: + app: dev-swiftly-game-server + template: + metadata: + labels: + app: dev-swiftly-game-server + spec: + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet + affinity: + nodeAffinity: + requiredDuringSchedulingIgnoredDuringExecution: + nodeSelectorTerms: + - matchExpressions: + - key: 5stack-dev-server + operator: In + values: + - 'true' + containers: + - name: dev-swiftly-game-server + image: ghcr.io/5stackgg/game-server-sw:beta + imagePullPolicy: Always + ports: + - containerPort: 27016 + protocol: TCP + - containerPort: 27016 + protocol: UDP + - containerPort: 27021 + protocol: TCP + - containerPort: 27021 + protocol: UDP + envFrom: + - secretRef: + name: dev-swiftly-server-secrets + env: + - name: DEV_SERVER + value: 'true' + - name: SERVER_PORT + value: '27016' + - name: TV_PORT + value: '27021' + - name: EXTRA_GAME_PARAMS + value: '-maxplayers 13 +map de_overpass' + - name: ALLOW_BOTS + value: 'true' + - name: STEAM_RELAY + value: 'true' + volumeMounts: + - name: steamcmd + mountPath: /serverdata/steamcmd + - name: serverfiles + mountPath: /serverdata/serverfiles + - name: serverfiles-csgo + mountPath: /serverdata/serverfiles-csgo + - name: demos + mountPath: /opt/demos + - name: custom-plugins + mountPath: /opt/custom-plugins + - name: dev + mountPath: /opt/instance/game/csgo/addons/swiftlys2/plugins/FiveStack + subPath: sw + volumes: + - name: steamcmd + hostPath: + path: /opt/5stack/steamcmd + type: DirectoryOrCreate + - name: serverfiles + hostPath: + path: /opt/5stack/serverfiles + type: DirectoryOrCreate + - name: serverfiles-csgo + hostPath: + path: /opt/5stack/serverfiles-csgo + type: DirectoryOrCreate + - name: demos + hostPath: + path: /opt/5stack/demos + type: DirectoryOrCreate + - name: custom-plugins + hostPath: + path: /opt/5stack/custom-plugins + type: DirectoryOrCreate + - name: dev + hostPath: + path: /opt/5stack/dev + type: DirectoryOrCreate diff --git a/shared/dotnet/FiveStack.Utilities/HttpClientProvider.cs b/shared/dotnet/FiveStack.Utilities/HttpClientProvider.cs new file mode 100644 index 0000000..e78938e --- /dev/null +++ b/shared/dotnet/FiveStack.Utilities/HttpClientProvider.cs @@ -0,0 +1,11 @@ +using System.Net.Http; + +namespace FiveStack.Utilities +{ + public static class HttpClientProvider + { + public static readonly HttpClient Client = new HttpClient( + new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) } + ); + } +}