Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/Core/Modules/AuthBan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ private void Command_kick(ReadOnlySpan<char> commandName, ReadOnlySpan<char> par
{
if (!target.TryGetPlayerTarget(out Player? targetPlayer))
{
_chat.SendMessage(player, "This comand only operates when targeting a specific player.");
_chat.SendMessage(player, "This command only operates when targeting a specific player.");
return;
}

Expand Down
36 changes: 36 additions & 0 deletions src/Matchmaking/Interfaces/ICaptainsMatchStatsBehavior.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
using SS.Core;

namespace SS.Matchmaking.Interfaces
{
/// <summary>
/// Behavior interface for extending <see cref="Modules.CaptainsMatch"/> with statistics tracking and database persistence.
/// </summary>
public interface ICaptainsMatchStatsBehavior : IComponentInterface
{
/// <summary>
/// Called when a captains match begins (both teams have readied up and the match is starting).
/// </summary>
/// <param name="arena">The arena the match is in.</param>
/// <param name="freq1">The frequency of the first team.</param>
/// <param name="team1">Players on the first team.</param>
/// <param name="freq2">The frequency of the second team.</param>
/// <param name="team2">Players on the second team.</param>
void MatchStarted(Arena arena, short freq1, IEnumerable<Player> team1, short freq2, IEnumerable<Player> team2);

/// <summary>
/// Called when a player is killed during an active match.
/// </summary>
/// <param name="arena">The arena the kill took place in.</param>
/// <param name="killer">The player who made the kill.</param>
/// <param name="killed">The player who was killed.</param>
void PlayerKilled(Arena arena, Player killer, Player killed);

/// <summary>
/// Called when a match ends. The implementation should save stats to the database.
/// </summary>
/// <param name="arena">The arena the match was in.</param>
/// <param name="winnerFreq">The frequency of the winning team.</param>
/// <param name="loserFreq">The frequency of the losing team.</param>
Task MatchEndedAsync(Arena arena, short winnerFreq, short loserFreq);
}
}
2,198 changes: 2,198 additions & 0 deletions src/Matchmaking/Modules/CaptainsMatch.cs

Large diffs are not rendered by default.

255 changes: 255 additions & 0 deletions src/Matchmaking/Modules/CaptainsMatchStats.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
using Microsoft.IO;
using SS.Core;
using SS.Core.ComponentInterfaces;
using SS.Matchmaking.Interfaces;
using System.Text.Json;

namespace SS.Matchmaking.Modules
{
/// <summary>
/// Module that tracks statistics for captains matches and saves results to the database.
/// Requires <see cref="PostgreSqlGameStats"/> to be loaded for database persistence.
/// </summary>
[ModuleInfo($"""
Tracks statistics for captains matches and saves results to the database.
For use with the {nameof(CaptainsMatch)} module.
Configure {nameof(CaptainsMatch)}.conf with: [CaptainsMatch] GameTypeId = <id>
""")]
public sealed class CaptainsMatchStats : IModule, ICaptainsMatchStatsBehavior
{
private static readonly RecyclableMemoryStreamManager s_memoryStreamManager = new();

private readonly IChat _chat;
private readonly IConfigManager _configManager;
private readonly ILogManager _logManager;

// optional
private IGameStatsRepository? _gameStatsRepository;

private InterfaceRegistrationToken<ICaptainsMatchStatsBehavior>? _iCaptainsMatchStatsBehaviorToken;

private string? _zoneServerName;

/// <summary>Key: arena</summary>
private readonly Dictionary<Arena, MatchContext> _activeMatches = [];

public CaptainsMatchStats(
IChat chat,
IConfigManager configManager,
ILogManager logManager)
{
_chat = chat ?? throw new ArgumentNullException(nameof(chat));
_configManager = configManager ?? throw new ArgumentNullException(nameof(configManager));
_logManager = logManager ?? throw new ArgumentNullException(nameof(logManager));
}

#region Module members

bool IModule.Load(IComponentBroker broker)
{
_gameStatsRepository = broker.GetInterface<IGameStatsRepository>();

if (_gameStatsRepository is not null)
{
_zoneServerName = _configManager.GetStr(_configManager.Global, "Billing", "ServerName");

if (string.IsNullOrWhiteSpace(_zoneServerName))
{
_logManager.LogM(LogLevel.Error, nameof(CaptainsMatchStats), "Missing setting, global.conf: Billing.ServerName");
broker.ReleaseInterface(ref _gameStatsRepository);
return false;
}
}

_iCaptainsMatchStatsBehaviorToken = broker.RegisterInterface<ICaptainsMatchStatsBehavior>(this);
return true;
}

bool IModule.Unload(IComponentBroker broker)
{
if (broker.UnregisterInterface(ref _iCaptainsMatchStatsBehaviorToken) != 0)
return false;

if (_gameStatsRepository is not null)
broker.ReleaseInterface(ref _gameStatsRepository);

return true;
}

#endregion

#region ICaptainsMatchStatsBehavior

[ConfigHelp<long>("CaptainsMatch", "GameTypeId", ConfigScope.Arena,
Description = "The game type ID in the stats database that corresponds to this captains match configuration. Required for database persistence.")]
void ICaptainsMatchStatsBehavior.MatchStarted(Arena arena, short freq1, IEnumerable<Player> team1, short freq2, IEnumerable<Player> team2)
{
var context = new MatchContext
{
StartTimestamp = DateTime.UtcNow,
Team1 = new TeamInfo { Freq = freq1 },
Team2 = new TeamInfo { Freq = freq2 },
};

foreach (Player p in team1)
{
context.Team1.PlayerNames.Add(p.Name!);
context.PlayerStats[p.Name!] = new PlayerStats();
}

foreach (Player p in team2)
{
context.Team2.PlayerNames.Add(p.Name!);
context.PlayerStats[p.Name!] = new PlayerStats();
}

_activeMatches[arena] = context;
}

void ICaptainsMatchStatsBehavior.PlayerKilled(Arena arena, Player killer, Player killed)
{
if (!_activeMatches.TryGetValue(arena, out MatchContext? context))
return;

if (context.PlayerStats.TryGetValue(killer.Name!, out PlayerStats? killerStats))
killerStats.Kills++;

if (context.PlayerStats.TryGetValue(killed.Name!, out PlayerStats? killedStats))
killedStats.Deaths++;
}

async Task ICaptainsMatchStatsBehavior.MatchEndedAsync(Arena arena, short winnerFreq, short loserFreq)
{
if (!_activeMatches.Remove(arena, out MatchContext? context))
return;

context.EndTimestamp = DateTime.UtcNow;

if (_gameStatsRepository is null)
return;

long? gameTypeId = ReadGameTypeId(arena);
if (gameTypeId is null)
{
_logManager.LogA(LogLevel.Warn, nameof(CaptainsMatchStats), arena,
"CaptainsMatch.GameTypeId is not configured; match result not saved to database.");
return;
}

try
{
using MemoryStream jsonStream = s_memoryStreamManager.GetStream();
WriteMatchJson(jsonStream, arena, context, winnerFreq, gameTypeId.Value);
jsonStream.Position = 0;

long? gameId = await _gameStatsRepository.SaveGameAsync(jsonStream);

if (gameId is not null)
{
_logManager.LogA(LogLevel.Info, nameof(CaptainsMatchStats), arena, $"Saved captains match to database as game ID {gameId.Value}.");
_chat.SendArenaMessage(arena, $"Match stats saved (game ID {gameId.Value}).");
}
else
{
_logManager.LogA(LogLevel.Warn, nameof(CaptainsMatchStats), arena, "Failed to save captains match to database.");
}
}
catch (Exception ex)
{
_logManager.LogA(LogLevel.Error, nameof(CaptainsMatchStats), arena, $"Exception saving captains match stats: {ex.Message}");
}
}

#endregion

#region Helpers

private long? ReadGameTypeId(Arena arena)
{
if (arena.Cfg is null)
return null;

int raw = _configManager.GetInt(arena.Cfg, "CaptainsMatch", "GameTypeId", -1);
return raw < 0 ? null : (long)raw;
}

private void WriteMatchJson(Stream stream, Arena arena, MatchContext context, short winnerFreq, long gameTypeId)
{
using Utf8JsonWriter writer = new(stream, new JsonWriterOptions { SkipValidation = false });

writer.WriteStartObject();

writer.WriteNumber("game_type_id"u8, gameTypeId);
writer.WriteString("zone_server_name"u8, _zoneServerName);
writer.WriteString("arena"u8, arena.Name);
writer.WriteString("start_timestamp"u8, context.StartTimestamp);
writer.WriteString("end_timestamp"u8, context.EndTimestamp!.Value);

writer.WriteStartArray("team_stats"u8);

WriteTeam(writer, context.Team1!, isWinner: context.Team1!.Freq == winnerFreq, context);
WriteTeam(writer, context.Team2!, isWinner: context.Team2!.Freq == winnerFreq, context);

writer.WriteEndArray(); // team_stats

writer.WriteEndObject();
writer.Flush();
}

private static void WriteTeam(Utf8JsonWriter writer, TeamInfo team, bool isWinner, MatchContext context)
{
writer.WriteStartObject();
writer.WriteNumber("freq"u8, team.Freq);
writer.WriteBoolean("is_winner"u8, isWinner);

writer.WriteStartArray("player_slots"u8);

foreach (string playerName in team.PlayerNames)
{
writer.WriteStartObject(); // slot
writer.WriteStartArray("player_stats"u8);

writer.WriteStartObject(); // player stat entry
writer.WriteString("player"u8, playerName);

context.PlayerStats.TryGetValue(playerName, out PlayerStats? stats);
writer.WriteNumber("kills"u8, stats?.Kills ?? 0);
writer.WriteNumber("deaths"u8, stats?.Deaths ?? 0);

writer.WriteEndObject(); // player stat entry
writer.WriteEndArray(); // player_stats
writer.WriteEndObject(); // slot
}

writer.WriteEndArray(); // player_slots
writer.WriteEndObject(); // team
}

#endregion

#region Data

private sealed class TeamInfo
{
public short Freq;
public readonly List<string> PlayerNames = [];
}

private sealed class PlayerStats
{
public int Kills;
public int Deaths;
}

private sealed class MatchContext
{
public DateTime StartTimestamp;
public DateTime? EndTimestamp;
public TeamInfo? Team1;
public TeamInfo? Team2;
public readonly Dictionary<string, PlayerStats> PlayerStats = new(StringComparer.OrdinalIgnoreCase);
}

#endregion
}
}
50 changes: 50 additions & 0 deletions src/SubspaceServer/Zone/arenas/4v4caps/arena.conf
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
; This arena is a test case for the SS.Matchmaking.Modules.TeamVersusMatch and SS.Matchmaking.Modules.TeamVersusStats matchmaking modules.
; See conf/TeamVersusMatch.conf for the global matchmaking configuration.

; Start with the shared, standard settings as a baseline.
#include conf/svs-league/svs-league.conf

[ General ]
Map = teamversus.lvl
LevelFiles = match.lvz

[ Modules ]
; Use the SS.Core.Modules.Scoring.KillPoints module so that players are awarded points for kills.
; Use the SS.Matchmaking.Modules.TeamVersusStats module to print stats for each match.
AttachModules = \
SS.Core.Modules.Scoring.KillPoints \
SS.Matchmaking.Modules.MatchFocus \
SS.Matchmaking.Modules.MatchLvz \
SS.Matchmaking.Modules.TeamVersusStats

[ Misc ]
SeeEnergy = Team
SpecSeeEnergy = All
GreetMessage = 4v4caps: Type ?captain to form a team, ?join <name> to join one, ?challenge <captain> to challenge, ?accept to accept a challenge, ?ready to start.

[ Team ]
InitialSpec = 1

[CaptainsMatch]
ArenaBaseName = 4v4caps
PlayersPerTeam = 4
LivesPerPlayer = 3
TimeLimit = 00:30:00
OverTimeLimit = 00:05:00
WinConditionDelay = 00:00:02
TimeLimitWinBy = 2
MaxLagOuts = 3
OpenSkillModel = PlackettLuce
OpenSkillModelJson =
OpenSkillSigmaDecayPerDay = 0.0347031963470319634703196347032
OpenSkillUseScoresWhenPossible = false
; Freq pairs
Freq1 = 100
Freq2 = 200
Freq3 = 300
Freq4 = 400
; Start locations: tile coordinates (x,y) to warp each freq's players to at match start.
Freq100StartLocation = 340,350
Freq200StartLocation = 710,360
Freq300StartLocation = 340,350
Freq400StartLocation = 710,360
3 changes: 3 additions & 0 deletions src/SubspaceServer/Zone/conf/Modules.config
Original file line number Diff line number Diff line change
Expand Up @@ -190,8 +190,10 @@ For plug-in modules (e.g. custom modules that you build):
- OneVersusOneStats: Stats for 1v1 (dueling) matches. Use this with the OneVersusOneMatch module.
- TeamVersusMatch: Functionality for team matches. Configure with: TeamVersusMatch.conf
- TeamVersusStats: Stats for team matches. Use this with the TeamVersusMatch module.
- CaptainsMatchStats: Stats for captains matches. Use this with the CaptainsMatch module.
-->
<!--<module type="SS.Matchmaking.Modules.PostgreSqlGameStats" path="bin/modules/Matchmaking/SS.Matchmaking.dll"/>-->
<!--<module type="SS.Matchmaking.Modules.CaptainsMatchStats" path="bin/modules/Matchmaking/SS.Matchmaking.dll"/>-->
<!--<module type="SS.Matchmaking.Modules.LeagueAuthorization" path="bin/modules/Matchmaking/SS.Matchmaking.dll" />-->
<module type="SS.Matchmaking.Modules.PlayerGroups" path="bin/modules/Matchmaking/SS.Matchmaking.dll"/>
<module type="SS.Matchmaking.Modules.MatchmakingQueues" path="bin/modules/Matchmaking/SS.Matchmaking.dll"/>
Expand All @@ -201,6 +203,7 @@ For plug-in modules (e.g. custom modules that you build):
<module type="SS.Matchmaking.Modules.OneVersusOneStats" path="bin/modules/Matchmaking/SS.Matchmaking.dll"/>
<module type="SS.Matchmaking.Modules.TeamVersusStats" path="bin/modules/Matchmaking/SS.Matchmaking.dll" />
<module type="SS.Matchmaking.Modules.TeamVersusMatch" path="bin/modules/Matchmaking/SS.Matchmaking.dll" />
<module type="SS.Matchmaking.Modules.CaptainsMatch" path="bin/modules/Matchmaking/SS.Matchmaking.dll" />
<module type="SS.Matchmaking.Modules.PlayerStatboxPreference" path="bin/modules/Matchmaking/SS.Matchmaking.dll" />
<module type="SS.Matchmaking.Modules.MatchLvz" path="bin/modules/Matchmaking/SS.Matchmaking.dll" />

Expand Down
2 changes: 1 addition & 1 deletion src/SubspaceServer/Zone/conf/global.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
;; Names of arenas to permanently keep running.
;; These arenas will be created when the server is started
;; and show up on the arena list, even if no players are in them.
PermanentArenas = 2v2pub 3v3pub 4v4pub duel jackpot king pb rabbit running speed turf warzone
PermanentArenas = 2v2pub 3v3pub 4v4pub 4v4caps duel jackpot king pb rabbit running speed turf warzone


;; The syntax for these is:
Expand Down
12 changes: 12 additions & 0 deletions src/SubspaceServer/Zone/conf/groupdef.dir/default
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,15 @@ cmd_schedule
cmd_results
cmd_leaguepermit
privcmd_leaguepermit

; Matchmaking - Captains
cmd_captain
cmd_cap
cmd_challenge
cmd_accept
cmd_refuse
cmd_ready
cmd_rdy
cmd_join
cmd_kick
cmd_disband