Start work on messaging

This commit is contained in:
MSWS
2025-07-28 09:31:51 -07:00
parent 08adb86f65
commit c47851f4c5
32 changed files with 284 additions and 64 deletions

View File

@@ -3,8 +3,19 @@ using TTT.Api.Player;
namespace TTT.Api;
public interface IGame {
/// <summary>
/// The list of players in the game.
/// Spectators are not included in this list.
/// </summary>
ICollection<IPlayer> Players { get; }
DateTime StartedAt { get; }
DateTime? StartedAt { get; }
DateTime? FinishedAt { get; }
SortedDictionary<DateTime, ISet<IAction>> Actions { get; }
/// <summary>
/// Attempts to start a game.
/// Depending on implementation, this may start a countdown or immediately start the game.
/// </summary>
void Start();
}

View File

@@ -1,6 +1,6 @@
using TTT.Api.Player;
namespace TTT.Api;
namespace TTT.Api.Messages;
public interface IOnlineMessenger : IMessenger {
Task<bool> IMessenger.Message(IPlayer player, string message) {
@@ -12,4 +12,13 @@ public interface IOnlineMessenger : IMessenger {
}
Task<bool> Message(IOnlinePlayer player, string message);
async Task<bool> MessageAll(IPlayerFinder finder, string message) {
var tasks = finder.GetAllPlayers()
.Select(onlinePlayer => Message(onlinePlayer, message))
.ToList();
var results = await Task.WhenAll(tasks);
return results.All(r => r);
}
}

View File

@@ -1,8 +1,8 @@
namespace TTT.Api.Player;
public interface IPlayerFinder {
internal void addPlayer(IOnlinePlayer player);
internal void removePlayer(IOnlinePlayer player);
internal protected void addPlayer(IOnlinePlayer player);
internal protected void removePlayer(IOnlinePlayer player);
ISet<IOnlinePlayer> GetAllPlayers();

View File

@@ -0,0 +1,5 @@
namespace TTT.CS2.Messages;
public enum Channel {
CHAT, CONSOLE, HUD
}

View File

@@ -0,0 +1,12 @@
using CounterStrikeSharp.API.Core;
using TTT.Api.Events;
namespace TTT.CS2.Messages;
public class ChatMessenger(IEventBus bus) : GameMessenger(bus) {
override protected Task<bool> SendMessage(CCSPlayerController gamePlayer,
string message) {
gamePlayer.PrintToChat(message);
return Task.FromResult(true);
}
}

View File

@@ -0,0 +1,28 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using TTT.Api.Events;
using TTT.Api.Messages;
using TTT.Api.Player;
using TTT.Game.Events.Player;
namespace TTT.CS2.Messages;
public abstract class GameMessenger(IEventBus bus) : IOnlineMessenger {
public Task<bool> Message(IOnlinePlayer player, string message) {
if (!ulong.TryParse(player.Id, out var steamId))
return Task.FromResult(false);
var gamePlayer = Utilities.GetPlayerFromSteamId(steamId);
if (gamePlayer == null || !gamePlayer.IsValid || gamePlayer.IsBot)
return Task.FromResult(false);
var messageEvent = new PlayerMessageEvent(player, message);
bus.Dispatch(messageEvent);
if (messageEvent.IsCanceled) return Task.FromResult(false);
return SendMessage(gamePlayer, messageEvent.Message);
}
abstract protected Task<bool> SendMessage(CCSPlayerController gamePlayer,
string message);
}

View File

@@ -9,7 +9,7 @@ public class AttackAction(IPlayer attacker, IPlayer? target, string weapon,
public int Damage { get; } = damage;
public IPlayer Player { get; } = attacker;
public IPlayer? Other { get; } = target;
public string Id => "core.action.attack";
public string Id => "basegame.action.attack";
public string Verb => "attacked";
public string Details => $"for {Damage} damage with {Weapon}";
}

View File

@@ -6,7 +6,7 @@ namespace TTT.Game.Actions;
public class DeathAction(IPlayer victim, IPlayer? killer) : IAction {
public IPlayer Player { get; } = victim;
public IPlayer? Other { get; } = killer;
public string Id { get; } = "core.action.death";
public string Id { get; } = "basegame.action.death";
public string Verb { get; } = killer is null ? "died" : "was killed by";
public string Details { get; } = string.Empty;

View File

@@ -1,7 +0,0 @@
using TTT.Api;
namespace TTT.Game.Events.Game;
public class GameStartEvent(IGame game) : GameEvent(game) {
public override string Id => "core.event.game.start";
}

View File

@@ -0,0 +1,11 @@
using TTT.Api;
using TTT.Api.Events;
namespace TTT.Game.Events.Game;
public class GameStateUpdateEvent(IGame game, RoundBasedGame.State newState)
: GameEvent(game), ICancelableEvent {
public override string Id => "basegame.event.game.update";
public bool IsCanceled { get; set; } = false;
public RoundBasedGame.State NewState { get; } = newState;
}

View File

@@ -7,7 +7,7 @@ namespace TTT.Game.Events.Player;
public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
int dmgDealt, int hpLeft) : PlayerEvent(player), ICancelableEvent {
public override string Id => "core.event.player.damaged";
public override string Id => "basegame.event.player.damaged";
public bool IsCanceled { get; set; } = false;
public IOnlinePlayer? Attacker { get; private set; } = attacker;

View File

@@ -5,7 +5,7 @@ using TTT.Api.Player;
namespace TTT.Game.Events.Player;
public class PlayerDeathEvent(IPlayer player) : PlayerEvent(player) {
public override string Id => "core.event.player.death";
public override string Id => "basegame.event.player.death";
public IPlayer? Assister { get; private set; } = null;
public IPlayer? Killer { get; private set; } = null;

View File

@@ -8,5 +8,5 @@ namespace TTT.Game.Events.Player;
/// </summary>
/// <param name="player"></param>
public class PlayerJoinEvent(IPlayer player) : PlayerEvent(player) {
public override string Id => "core.event.player.join";
public override string Id => "basegame.event.player.join";
}

View File

@@ -7,5 +7,5 @@ namespace TTT.Game.Events.Player;
/// A game is not necessarily in progress when this event is fired.
/// </summary>
public class PlayerLeaveEvent(IPlayer player) : PlayerEvent(player) {
public override string Id => "core.event.player.leave";
public override string Id => "basegame.event.player.leave";
}

View File

@@ -0,0 +1,11 @@
using TTT.Api.Events;
using TTT.Api.Player;
namespace TTT.Game.Events.Player;
public class PlayerMessageEvent(IPlayer player, string message)
: PlayerEvent(player), ICancelableEvent {
public override string Id => "basegame.event.player.message";
public bool IsCanceled { get; set; } = false;
public string Message { get; set; } = message;
}

View File

@@ -3,6 +3,6 @@ using TTT.Api.Events;
namespace TTT.Game.Events.Player;
public class PlayerRoleAssignEvent : Event, ICancelableEvent {
public override string Id => "core.event.player.roleassign";
public override string Id => "basegame.event.player.roleassign";
public bool IsCanceled { get; set; } = false;
}

View File

@@ -4,13 +4,10 @@ using TTT.Api.Player;
namespace TTT.Game.Roles;
public class DetectiveRole : IRole {
public const string ID = "core.role.detective";
public string Id => ID;
public string Name => "Detective";
public Color Color => Color.DodgerBlue;
public IPlayer? FindPlayerToAssign(ISet<IOnlinePlayer> players) {
return players.FirstOrDefault(p => p.Roles.Count == 0);
}
public class DetectiveRole(float targetRatio = 1f / 8f)
: RatioBasedRole(targetRatio) {
public const string ID = "basegame.role.detective";
public override string Id => ID;
public override string Name => "Detective";
public override Color Color => Color.DodgerBlue;
}

View File

@@ -5,7 +5,7 @@ using TTT.Api.Player;
namespace TTT.Game.Roles;
public class InnocentRole : IRole {
public const string ID = "core.role.innocent";
public const string ID = "basegame.role.innocent";
public string Id => ID;
public string Name => "Innocent";
public Color Color => Color.LimeGreen;

View File

@@ -0,0 +1,18 @@
using System.Drawing;
using TTT.Api;
using TTT.Api.Player;
namespace TTT.Game.Roles;
public abstract class RatioBasedRole(float targetRatio) : IRole {
public abstract string Id { get; }
public abstract string Name { get; }
public abstract Color Color { get; }
public IPlayer? FindPlayerToAssign(ISet<IOnlinePlayer> players) {
var currentCount = players.Count(p => p.Roles.Any(r => r.Id == Id));
var ratio = currentCount / (float)players.Count;
if (ratio >= targetRatio) return null;
return players.First(p => p.Roles.Count == 0);
}
}

View File

@@ -5,7 +5,7 @@ using TTT.Api.Player;
namespace TTT.Game.Roles;
public class SpectatorRole : IRole {
public string Id => "core.role.spectator";
public string Id => "basegame.role.spectator";
public string Name => "Spectator";
public Color Color => Color.Gray;

View File

@@ -4,16 +4,10 @@ using TTT.Api.Player;
namespace TTT.Game.Roles;
public class TraitorRole(float targetRatio = 1f / 5f) : IRole {
public const string ID = "core.role.traitor";
public string Id => ID;
public string Name => "Traitor";
public Color Color => Color.Red;
public IPlayer? FindPlayerToAssign(ISet<IOnlinePlayer> players) {
var traitorCount = players.Count(p => p.Roles.Any(r => r.Id == ID));
var ratio = traitorCount / (float)players.Count;
if (ratio >= targetRatio) return null;
return players.First(p => p.Roles.Count == 0);
}
public class TraitorRole(float targetRatio = 1f / 5f)
: RatioBasedRole(targetRatio) {
public const string ID = "basegame.role.traitor";
public override string Id => ID;
public override string Name => "Traitor";
public override Color Color => Color.Red;
}

View File

@@ -0,0 +1,53 @@
using TTT.Api;
using TTT.Api.Events;
using TTT.Api.Player;
using TTT.Game.Events.Game;
namespace TTT.Game;
public class RoundBasedGame(IEventBus bus) : IGame {
public ICollection<IPlayer> Players { get; } = new List<IPlayer>();
public DateTime? StartedAt { get; } = null;
public DateTime? FinishedAt { get; } = null;
public SortedDictionary<DateTime, ISet<IAction>> Actions { get; } = new();
public void Start() {
}
public enum State {
/// <summary>
/// Waiting for players to join.
/// </summary>
WAITING,
/// <summary>
/// Waiting for the countdown to finish before starting the game.
/// </summary>
COUNTDOWN,
/// <summary>
/// Currently playing the game.
/// </summary>
IN_PROGRESS,
/// <summary>
/// Game has finished.
/// </summary>
FINISHED
}
private State currentState = State.WAITING;
public State CurrentState {
set {
var ev = new GameStateUpdateEvent(this, value);
bus.Dispatch(ev);
if (ev.IsCanceled) return;
currentState = value;
}
get => currentState;
}
}

View File

@@ -11,29 +11,29 @@ namespace GitVersion
public const string PreReleaseLabelWithDash = "";
// PreReleaseNumber is null and omitted
public const int WeightedPreReleaseNumber = 60000;
public const int BuildMetaData = 3;
public const string BuildMetaDataPadded = "0003";
public const string FullBuildMetaData = "3.Branch.main.Sha.c935acb0a88e645f9cfc9eefed037078b9c35179";
public const int BuildMetaData = 6;
public const string BuildMetaDataPadded = "0006";
public const string FullBuildMetaData = "6.Branch.main.Sha.08adb86f65b1b9385eb073addd2096dba435e687";
public const string MajorMinorPatch = "0.1.0";
public const string SemVer = "0.1.0";
public const string LegacySemVer = "0.1.0";
public const string LegacySemVerPadded = "0.1.0";
public const string AssemblySemVer = "0.1.0.0";
public const string AssemblySemFileVer = "0.1.0.0";
public const string FullSemVer = "0.1.0+3";
public const string InformationalVersion = "0.1.0+3.Branch.main.Sha.c935acb0a88e645f9cfc9eefed037078b9c35179";
public const string FullSemVer = "0.1.0+6";
public const string InformationalVersion = "0.1.0+6.Branch.main.Sha.08adb86f65b1b9385eb073addd2096dba435e687";
public const string BranchName = "main";
public const string EscapedBranchName = "main";
public const string Sha = "c935acb0a88e645f9cfc9eefed037078b9c35179";
public const string ShortSha = "c935acb";
public const string Sha = "08adb86f65b1b9385eb073addd2096dba435e687";
public const string ShortSha = "08adb86";
public const string NuGetVersionV2 = "0.1.0";
public const string NuGetVersion = "0.1.0";
public const string NuGetPreReleaseTagV2 = "";
public const string NuGetPreReleaseTag = "";
public const string VersionSourceSha = "dabc2a6913b5b73fa972ef21c1c480615eecff36";
public const int CommitsSinceVersionSource = 3;
public const string CommitsSinceVersionSourcePadded = "0003";
public const int UncommittedChanges = 1;
public const int CommitsSinceVersionSource = 6;
public const string CommitsSinceVersionSourcePadded = "0006";
public const int UncommittedChanges = 30;
public const string CommitDate = "2025-07-28";
}
}

View File

@@ -8,28 +8,28 @@
"PreReleaseLabelWithDash": "",
"PreReleaseNumber": null,
"WeightedPreReleaseNumber": 60000,
"BuildMetaData": 3,
"BuildMetaDataPadded": "0003",
"FullBuildMetaData": "3.Branch.main.Sha.c935acb0a88e645f9cfc9eefed037078b9c35179",
"BuildMetaData": 6,
"BuildMetaDataPadded": "0006",
"FullBuildMetaData": "6.Branch.main.Sha.08adb86f65b1b9385eb073addd2096dba435e687",
"MajorMinorPatch": "0.1.0",
"SemVer": "0.1.0",
"LegacySemVer": "0.1.0",
"LegacySemVerPadded": "0.1.0",
"AssemblySemVer": "0.1.0.0",
"AssemblySemFileVer": "0.1.0.0",
"FullSemVer": "0.1.0+3",
"InformationalVersion": "0.1.0+3.Branch.main.Sha.c935acb0a88e645f9cfc9eefed037078b9c35179",
"FullSemVer": "0.1.0+6",
"InformationalVersion": "0.1.0+6.Branch.main.Sha.08adb86f65b1b9385eb073addd2096dba435e687",
"BranchName": "main",
"EscapedBranchName": "main",
"Sha": "c935acb0a88e645f9cfc9eefed037078b9c35179",
"ShortSha": "c935acb",
"Sha": "08adb86f65b1b9385eb073addd2096dba435e687",
"ShortSha": "08adb86",
"NuGetVersionV2": "0.1.0",
"NuGetVersion": "0.1.0",
"NuGetPreReleaseTagV2": "",
"NuGetPreReleaseTag": "",
"VersionSourceSha": "dabc2a6913b5b73fa972ef21c1c480615eecff36",
"CommitsSinceVersionSource": 3,
"CommitsSinceVersionSourcePadded": "0003",
"UncommittedChanges": 1,
"CommitsSinceVersionSource": 6,
"CommitsSinceVersionSourcePadded": "0006",
"UncommittedChanges": 30,
"CommitDate": "2025-07-28"
}

View File

@@ -0,0 +1,20 @@
using TTT.Api;
using TTT.Api.Events;
using TTT.Api.Player;
using TTT.Game.Events.Player;
namespace TTT.Test.Fakes;
public class FakeMessenger(IEventBus bus) : IMessenger {
public Task<bool> Message(IPlayer player, string message) {
if (player is not TestPlayer testPlayer)
throw new ArgumentException("Player must be a TestPlayer",
nameof(player));
var messageEvent = new PlayerMessageEvent(testPlayer, message);
bus.Dispatch(messageEvent);
if (messageEvent.IsCanceled) return Task.FromResult(false);
testPlayer.Messages.Add(messageEvent.Message);
return Task.FromResult(true);
}
}

View File

@@ -1,13 +1,21 @@
using TTT.Api.Events;
using TTT.Api.Player;
using TTT.Game.Events.Player;
namespace TTT.Test.Fakes;
public class FakePlayerFinder : IPlayerFinder {
public class FakePlayerFinder(IEventBus bus) : IPlayerFinder {
private readonly HashSet<IOnlinePlayer> players = [];
public void addPlayer(IOnlinePlayer player) => players.Add(player);
public void addPlayer(IOnlinePlayer player) {
players.Add(player);
bus.Dispatch(new PlayerJoinEvent(player));
}
public void removePlayer(IOnlinePlayer player) => players.Remove(player);
public void removePlayer(IOnlinePlayer player) {
players.Remove(player);
bus.Dispatch(new PlayerLeaveEvent(player));
}
public ISet<IOnlinePlayer> GetAllPlayers() => players;
}

View File

@@ -0,0 +1,34 @@
using TTT.Api;
using TTT.Api.Events;
using TTT.Game.Events.Player;
using TTT.Test.Fakes;
using Xunit;
namespace TTT.Test.Messages;
public class JoinMessageTest(IEventBus bus, IMessenger msg,
FakePlayerFinder finder) {
[Fact]
public void TestJoinMessage() {
// Arrange
var listener = new JoinMessageListener(bus, msg);
var player = TestPlayer.Random();
bus.RegisterListener(listener);
// Act
finder.addPlayer(player);
// Assert
Assert.Single(player.Messages);
Assert.Equal("Hello, World!", player.Messages[0]);
}
private class JoinMessageListener(IEventBus bus, IMessenger msg) : IListener {
public void Dispose() { bus.UnregisterListener(this); }
[EventHandler]
public void OnJoin(PlayerJoinEvent ev) {
msg.Message(ev.Player, "Hello, World!");
}
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.Api;
using TTT.Api.Events;
using TTT.Api.Player;
using TTT.Game;
@@ -11,5 +12,7 @@ public class Startup {
public void ConfigureServices(IServiceCollection services) {
services.AddScoped<IEventBus, EventBus>();
services.AddScoped<IPlayerFinder, FakePlayerFinder>();
services.AddScoped<FakePlayerFinder>();
services.AddScoped<IMessenger, FakeMessenger>();
}
}

View File

@@ -27,4 +27,8 @@
<PackageReference Include="xunit.v3" Version="3.0.0" />
</ItemGroup>
<ItemGroup>
<Folder Include="Game\Roles\" />
</ItemGroup>
</Project>

View File

@@ -7,4 +7,13 @@ public class TestPlayer(string id, string name) : IOnlinePlayer {
public string Id { get; } = id;
public string Name { get; } = name;
public ICollection<IRole> Roles { get; } = (List<IRole>) [];
public List<string> Messages { get; } = [];
public TestPlayer() : this("314159", "Test Player") { }
public static TestPlayer Random() {
return new TestPlayer(new Random().NextInt64().ToString(),
"Test Player " + Guid.NewGuid());
}
}