Compare commits

...

17 Commits

Author SHA1 Message Date
MSWS
83e90deb44 refactor: Rename ChatHandler to TraitorChatHandler +semver:patch
- Update the traitor chat format label to pluralize "TRAITOR" to "TRAITORS" in `en.yml`
- Replace `ChatHandler` with `TraitorChatHandler` in `CS2ServiceCollection.cs` to enhance focus on traitor-specific chat functionality
- Rename `ChatHandler` to `TraitorChatHandler` and update to manage traitor roles in `ChatHandler.cs`
- Ensure message processing occurs only if the game is in progress or finished in `ChatHandler.cs`
- Modify command message handling in `ChatHandler.cs` to strip backslashes from messages
2025-10-14 10:01:25 -07:00
Isaac
658eecef02 feat: Add traitor chat (resolves #112, #114) (#120) 2025-10-14 09:04:42 -07:00
MSWS
c90af8dfcf feat: Implement traitor chat message formatting +semver:minor
- Add new message format function for traitor chat in CS2Msgs.cs
- Update ChatHandler.cs with new API modules and role-checking logic
- Modify onSay method in ChatHandler.cs to support traitor message formatting
- Add new chat format specification for traitors in en.yml
2025-10-14 09:01:03 -07:00
MSWS
6cd1788992 Start workon traitor chat 2025-10-14 08:42:25 -07:00
MSWS
1288ccbd7b Refactor & Reformat, fix Spectators preventing specific roles 2025-10-14 08:33:56 -07:00
MSWS
2596289f58 Fix unit tests 2025-10-14 07:18:37 -07:00
MSWS
d13403d7a7 Simplify perm checking 2025-10-13 22:44:31 -07:00
MSWS
d4fa621a03 Fix color formatting 2025-10-13 22:16:59 -07:00
MSWS
969c872e48 Force logs to be run on main thread 2025-10-13 21:12:59 -07:00
MSWS
b2f19b7ac3 feat: Enhance log viewing with messaging and visibility logic +semver:minor
```
Enhance logging and visibility management in game commands and role handling

- Update `LogsCommand.cs` to integrate messaging and localization with `IMessenger` and `IMsgLocalizer`, and conditionally adjust player visibility using `IIconManager`.
- Add new localized message in `GameMsgs.cs` for player's log viewing activity, using player's name.
- Overload `SetVisiblePlayers` in `IIconManager.cs` to increase flexibility by accepting an `IOnlinePlayer` object.
- Implement `SetVisiblePlayers` in `RoleIconsHandler.cs` to improve player visibility and role management, and integrate rules for specific roles like `DetectiveRole` and `TraitorRole`.
- Modify `en.yml` to include a log message for when a player views logs alive.
```
2025-10-13 21:11:50 -07:00
MSWS
742e407bcf Add shop list footer 2025-10-13 20:55:17 -07:00
MSWS
f8c8e528e2 Buff poison shot amo (resolves #115) 2025-10-13 20:16:21 -07:00
MSWS
24bd3d5f40 Clear recipients of awp sounds 2025-10-13 19:51:49 -07:00
MSWS
f1ff53893f Add karma grants per round 2025-10-13 19:47:15 -07:00
MSWS
54c89e96c0 fix: Respawn players when game actually starts
```
- Improve player respawn logic in RoundTimerListener for better team handling and secure timer disposal.
- Enhance initial setup and end game logic in RoundBasedGame with robust role assignment, game state checks, and localization support.
- Increase default Healthshot item price in HealthshotConfig.
```
2025-10-13 19:19:42 -07:00
MSWS
46514a6016 Change comparator 2025-10-12 19:05:13 -07:00
MSWS
73a8f6a9f5 Restrict logs command behind permission 2025-10-12 18:16:52 -07:00
33 changed files with 308 additions and 115 deletions

View File

@@ -33,9 +33,6 @@ public interface IGame : IDisposable {
bool CheckEndConditions();
[Obsolete("This method is ambiguous, check the game state directly.")]
bool IsInProgress() { return State is State.COUNTDOWN or State.IN_PROGRESS; }
ISet<IOnlinePlayer> GetAlive() {
return Players.OfType<IOnlinePlayer>().Where(p => p.IsAlive).ToHashSet();
}

View File

@@ -11,6 +11,7 @@ public interface IIconManager {
void RevealToAll(int client);
void AddVisiblePlayer(int client, int player);
void RemoveVisiblePlayer(int client, int player);
void SetVisiblePlayers(IOnlinePlayer online, ulong playersBitmask);
void ClearAllVisibility();
}

View File

@@ -1,5 +1,4 @@
using CounterStrikeSharp.API;
using Serilog;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Loggers;

View File

@@ -64,6 +64,7 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<MapZoneRemover>();
collection.AddModBehavior<BuyMenuHandler>();
collection.AddModBehavior<TeamChangeHandler>();
collection.AddModBehavior<TraitorChatHandler>();
// Damage Cancelers
collection.AddModBehavior<OutOfRoundCanceler>();

View File

@@ -44,7 +44,9 @@ public class PlayerPingShopAlias(IServiceProvider provider) : IPluginModule {
if (converter.GetPlayer(player) is not IOnlinePlayer gamePlayer) return;
var lastUpdated = itemSorter.GetLastUpdate(gamePlayer);
if (lastUpdated == null || DateTime.Now - lastUpdated > TimeSpan.FromSeconds(20)) return;
if (lastUpdated == null
|| DateTime.Now - lastUpdated > TimeSpan.FromSeconds(20))
return;
var cmdInfo = new CS2CommandInfo(provider, gamePlayer, 0, "css_shop", "buy",
(index - 1).ToString());
cmdInfo.CallingContext = CommandCallingContext.Chat;

View File

@@ -1,15 +1,12 @@
using TTT.API.Storage;
namespace TTT.CS2.Configs;
using System;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Cvars.Validators;
using TTT.API;
using Karma;
using TTT.API.Storage;
using TTT.Karma;
namespace TTT.CS2.Configs;
public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<string> CV_DB_STRING = new(
@@ -27,7 +24,7 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<string> CV_LOW_KARMA_COMMAND = new(
"css_ttt_karma_low_command",
"Command executed when a player's karma falls below the minimum (use {0} for player slot)",
"karmaban {0} Bad Player!");
"css_ban #{0} 2880 Low Karma");
public static readonly FakeConVar<int> CV_TIMEOUT_THRESHOLD = new(
"css_ttt_karma_timeout_threshold",
@@ -47,7 +44,7 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
// Karma deltas
public static readonly FakeConVar<int> CV_INNO_ON_TRAITOR = new(
"css_ttt_karma_inno_on_traitor",
"Karma gained when Innocent kills a Traitor", 2, ConVarFlags.FCVAR_NONE,
"Karma gained when Innocent kills a Traitor", 5, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_DETECTIVE = new(

View File

@@ -1,7 +1,10 @@
using System.Reactive.Linq;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.Roles;
using TTT.CS2.Utils;
@@ -30,6 +33,9 @@ public class CS2Game(IServiceProvider provider) : RoundBasedGame(provider) {
new TraitorRole(provider), new DetectiveRole(provider)
];
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
override protected void StartRound() {
Server.NextWorldUpdate(() => {
base.StartRound();
@@ -70,4 +76,13 @@ public class CS2Game(IServiceProvider provider) : RoundBasedGame(provider) {
return timer;
}
override protected ISet<IOnlinePlayer> GetParticipants() {
var players = Utilities.GetPlayers()
.Where(p => p is { Team: CsTeam.Terrorist or CsTeam.CounterTerrorist });
return players.Select(p => converter.GetPlayer(p))
.OfType<IOnlinePlayer>()
.ToHashSet();
}
}

View File

@@ -1,5 +1,4 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
using Microsoft.Extensions.DependencyInjection;

View File

@@ -69,6 +69,12 @@ public class RoleIconsHandler(IServiceProvider provider)
visibilities[client] &= ~(1UL << player);
}
public void SetVisiblePlayers(IOnlinePlayer online, ulong playersBitmask) {
var gamePlayer = players.GetPlayer(online);
if (gamePlayer == null || !gamePlayer.IsValid) return;
SetVisiblePlayers(gamePlayer.Slot, playersBitmask);
}
public void ClearAllVisibility() {
Array.Clear(visibilities, 0, visibilities.Length);
}

View File

@@ -39,4 +39,18 @@ public class RoundStart_GameStartHandler(IServiceProvider provider)
game?.Start(config.RoundCfg.CountDownDuration);
return HookResult.Continue;
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnWarmupEnd(EventWarmupEnd ev, GameEventInfo _1) {
if (games.ActiveGame is { State: State.IN_PROGRESS or State.COUNTDOWN })
return HookResult.Continue;
var count = finder.GetOnline().Count;
if (count < config.RoundCfg.MinimumPlayers) return HookResult.Continue;
var game = games.CreateGame();
game?.Start(config.RoundCfg.CountDownDuration);
return HookResult.Continue;
}
}

View File

@@ -1,14 +1,24 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Events.Player;
namespace TTT.CS2.GameHandlers;
public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
@@ -45,4 +55,18 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
return HookResult.Handled;
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnChangeTeam(EventPlayerTeam ev, GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
if (ev.Userid.LifeState == (int)LifeState_t.LIFE_ALIVE)
return HookResult.Continue;
var apiPlayer = converter.GetPlayer(ev.Userid);
var playerDeath = new PlayerDeathEvent(apiPlayer);
bus.Dispatch(playerDeath);
return HookResult.Continue;
}
}

View File

@@ -0,0 +1,61 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.lang;
using TTT.Game.Listeners;
using TTT.Game.Roles;
using TTT.Locale;
namespace TTT.CS2.GameHandlers;
public class TraitorChatHandler(IServiceProvider provider) : IPluginModule {
private readonly IGameManager game =
provider.GetRequiredService<IGameManager>();
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
public void Start(BasePlugin? plugin) {
plugin?.AddCommandListener("say_team", onSay);
}
private HookResult onSay(CCSPlayerController? player,
CommandInfo commandInfo) {
if (player == null
|| game.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED }
|| converter.GetPlayer(player) is not IOnlinePlayer apiPlayer
|| !roles.GetRoles(apiPlayer).Any(r => r is TraitorRole))
return HookResult.Continue;
var teammates = game.ActiveGame?.Players.Where(p
=> roles.GetRoles(p).Any(r => r is TraitorRole))
.ToList();
if (teammates == null) return HookResult.Continue;
var msg = commandInfo.ArgString;
if (msg.StartsWith('\\') && msg.EndsWith('\\') && msg.Length >= 2)
msg = msg[1..^1];
var formatted = locale[CS2Msgs.TRAITOR_CHAT_FORMAT(apiPlayer, msg)];
foreach (var mate in teammates) messenger.Message(mate, formatted);
return HookResult.Stop;
}
public void Dispose() { }
public void Start() { }
}

View File

@@ -84,6 +84,7 @@ public class SilentAWPItem(IServiceProvider provider)
Shop.RemoveItem<SilentAWPItem>(apiPlayer);
}
msg.Recipients.Clear();
return HookResult.Handled;
}

View File

@@ -45,7 +45,9 @@ public class RoundTimerListener(IServiceProvider provider)
.TotalSeconds);
Server.ExecuteCommand("mp_ignore_round_win_conditions 1");
foreach (var player in Utilities.GetPlayers()
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE))
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE && p is {
Team: CsTeam.CounterTerrorist or CsTeam.Terrorist
}))
player.Respawn();
foreach (var player in Utilities.GetPlayers())
@@ -55,6 +57,15 @@ public class RoundTimerListener(IServiceProvider provider)
return;
}
if (ev.NewState == State.IN_PROGRESS)
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers()
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE && p is {
Team: CsTeam.CounterTerrorist or CsTeam.Terrorist
}))
player.Respawn();
});
if (ev.NewState == State.FINISHED) endTimer?.Dispose();
if (ev.NewState != State.IN_PROGRESS) return;
var duration = config.RoundCfg.RoundDuration(ev.Game.Players.Count);

View File

@@ -1,6 +1,5 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Entities;
using TTT.API.Player;
namespace TTT.CS2.Player;
@@ -9,12 +8,9 @@ public class CS2PermManager(IPlayerConverter<CCSPlayerController> converter)
: IPermissionManager {
public bool HasFlags(IPlayer player, params string[] flags) {
if (flags.Length == 0) return true;
Console.WriteLine("Checking flags for player: " + player.Id);
var gamePlayer = converter.GetPlayer(player);
if (gamePlayer == null) return false;
ulong.TryParse(player.Id, out var steamId);
return AdminManager.PlayerHasPermissions(new SteamID(steamId), flags);
return AdminManager.PlayerHasPermissions(gamePlayer, flags);
}
public bool InGroups(IPlayer player, params string[] groups) {

View File

@@ -14,4 +14,8 @@ public static class CS2Msgs {
return MsgFactory.Create(nameof(TASER_SCANNED),
rolePrefix + scannedPlayer.Name, role.Name);
}
public static IMsg TRAITOR_CHAT_FORMAT(IOnlinePlayer player, string msg) {
return MsgFactory.Create(nameof(TRAITOR_CHAT_FORMAT), player.Name, msg);
}
}

View File

@@ -1,4 +1,5 @@
ROLE_SPECTATOR: "Spectator"
TRAITOR_CHAT_FORMAT: "{darkred}[TRAITORS] {red}{0}: {default}{1}"
TASER_SCANNED: "%PREFIX%You scanned {0}{grey}, they are %an% {1}{grey}!"
DNA_PREFIX: "{darkblue}D{blue}N{lightblue}A{grey} | {grey}"

View File

@@ -1,7 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.Locale;
namespace TTT.Game.Commands;
@@ -9,13 +11,22 @@ public class LogsCommand(IServiceProvider provider) : ICommand {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IIconManager? icons = provider.GetService<IIconManager>();
private readonly IMsgLocalizer localizer =
provider.GetRequiredService<IMsgLocalizer>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
public void Dispose() { }
public string[] RequiredFlags => ["@ttt/admin"];
public bool MustBeOnMainThread => true;
public string Id => "logs";
public void Start() { }
// TODO: Restrict and verbalize usage
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (games.ActiveGame is not {
@@ -25,6 +36,12 @@ public class LogsCommand(IServiceProvider provider) : ICommand {
return Task.FromResult(CommandResult.ERROR);
}
if (executor is { IsAlive: true })
messenger.MessageAll(localizer[GameMsgs.LOGS_VIEWED_ALIVE(executor)]);
else if (icons != null && executor != null)
if (int.TryParse(executor.Id, out var slot))
icons.SetVisiblePlayers(slot, ulong.MaxValue);
games.ActiveGame.Logger.PrintLogs(executor);
return Task.FromResult(CommandResult.SUCCESS);
}

View File

@@ -49,7 +49,7 @@ public class RoleAssigner(IServiceProvider provider) : IRoleAssigner {
assignedRoles[player].Add(ev.Role);
ev.Role.OnAssign(player);
onlineMessenger?.BackgroundMsgAll(
onlineMessenger?.Debug(
$"{player.Name} was assigned the role of {role.Name}.");
return true;
}

View File

@@ -159,7 +159,7 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
}
virtual protected void StartRound() {
var online = finder.GetOnline();
var online = GetParticipants();
if (online.Count < config.RoundCfg.MinimumPlayers) {
Messenger?.MessageAll(
@@ -170,6 +170,7 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
StartedAt = DateTime.Now;
RoleAssigner.AssignRoles(online, Roles);
players.AddRange(online.Where(p
=> RoleAssigner.GetRoles(p)
.Any(r => r is TraitorRole or DetectiveRole or InnocentRole)));
@@ -182,6 +183,8 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
GameMsgs.GAME_STATE_STARTED(traitors, nonTraitors)]);
}
virtual protected ISet<IOnlinePlayer> GetParticipants() => finder.GetOnline();
#region classDeps
protected readonly IEventBus Bus = provider.GetRequiredService<IEventBus>();

View File

@@ -79,6 +79,10 @@ public static class GameMsgs {
#endregion
public static IMsg LOGS_VIEWED_ALIVE(IPlayer player) {
return MsgFactory.Create(nameof(LOGS_VIEWED_ALIVE), player.Name);
}
#region GENERIC
public static IMsg GENERIC_UNKNOWN(string command) {

View File

@@ -21,4 +21,5 @@ GAME_STATE_ENDED_OTHER: "%PREFIX%{blue}GAME! {default}{0}{grey}."
NOT_ENOUGH_PLAYERS: "%PREFIX%{red}Game was canceled due to having fewer than {yellow}{0}{red} player%s%."
BODY_IDENTIFIED: "%PREFIX%{default}{0}{grey} identified the body of {blue}{1}{grey}, they were %an% {2}{grey}!"
GAME_LOGS_HEADER: "---------- Game Logs ----------"
GAME_LOGS_FOOTER: "-------------------------------"
GAME_LOGS_FOOTER: "-------------------------------"
LOGS_VIEWED_ALIVE: "%PREFIX%{red}{0}{grey} viewed the logs while alive."

View File

@@ -6,51 +6,58 @@ public record KarmaConfig {
public string DbString { get; init; } = "Data Source=karma.db";
/// <summary>
/// The minimum amount of karma a player can have.
/// If a player's karma falls below this value, the CommandUponLowKarma
/// will be executed.
/// The minimum amount of karma a player can have.
/// If a player's karma falls below this value, the CommandUponLowKarma
/// will be executed.
/// </summary>
public int MinKarma { get; init; }
/// <summary>
/// The default amount of karma a player starts with.
/// Once a player falls below MinKarma, their karma will
/// also be reset to this value.
/// The default amount of karma a player starts with.
/// Once a player falls below MinKarma, their karma will
/// also be reset to this value.
/// </summary>
public int DefaultKarma { get; init; } = 50;
/// <summary>
/// The command to execute when a player's karma falls below MinKarma.
/// The first argument will be the player's slot.
/// The command to execute when a player's karma falls below MinKarma.
/// The first argument will be the player's slot.
/// </summary>
public string CommandUponLowKarma { get; init; } = "karmaban {0} Bad Player!";
/// <summary>
/// The minimum threshold that a player's karma must reach
/// before timing them out for KarmaRoundTimeout rounds;
/// The minimum threshold that a player's karma must reach
/// before timing them out for KarmaRoundTimeout rounds;
/// </summary>
public int KarmaTimeoutThreshold { get; init; } = 20;
/// <summary>
/// The number of rounds a player will be timed out for
/// if their karma falls below KarmaTimeoutThreshold.
/// The number of rounds a player will be timed out for
/// if their karma falls below KarmaTimeoutThreshold.
/// </summary>
public int KarmaRoundTimeout { get; init; } = 4;
/// <summary>
/// The time window in which a player will receive a warning
/// if their karma falls below KarmaWarningThreshold.
/// If the player has already received a warning within this time window,
/// no warning will be sent.
/// The time window in which a player will receive a warning
/// if their karma falls below KarmaWarningThreshold.
/// If the player has already received a warning within this time window,
/// no warning will be sent.
/// </summary>
public TimeSpan KarmaWarningWindow { get; init; } = TimeSpan.FromDays(1);
public int MaxKarma(IPlayer? player) { return 100; }
/// <summary>
/// Amount of karma a player will gain at the end of each round.
/// </summary>
public int KarmaPerRound { get; init; } = 3;
public int INNO_ON_TRAITOR { get; init; } = 2;
public int KarmaPerRoundWin { get; init; } = 5;
public int INNO_ON_TRAITOR { get; init; } = 5;
public int TRAITOR_ON_DETECTIVE { get; init; } = 1;
public int INNO_ON_INNO_VICTIM { get; init; } = -1;
public int INNO_ON_INNO { get; init; } = -4;
public int TRAITOR_ON_TRAITOR { get; init; } = -5;
public int INNO_ON_DETECTIVE { get; init; } = -6;
public int MaxKarma(IPlayer? player) { return 100; }
}

View File

@@ -15,6 +15,12 @@ namespace TTT.Karma;
public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private readonly Dictionary<string, int> badKills = new();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
@@ -26,11 +32,7 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
public bool GiveKarmaOnRoundEnd = true;
[EventHandler]
[UsedImplicitly]
@@ -45,7 +47,7 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
var killer = ev.Killer;
if (killer == null) return;
if (victim == killer) return;
if (victim.Id == killer.Id) return;
var victimRole = roles.GetRoles(victim).First();
var killerRole = roles.GetRoles(killer).First();
@@ -94,6 +96,18 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
var winner = ev.Game.WinningRole;
if (GiveKarmaOnRoundEnd)
foreach (var player in ev.Game.Players)
if (Roles.GetRoles(player).Any(r => r.GetType() == winner?.GetType()))
queuedKarmaUpdates[player] =
queuedKarmaUpdates.GetValueOrDefault(player, 0)
+ config.KarmaPerRoundWin;
else
queuedKarmaUpdates[player] =
queuedKarmaUpdates.GetValueOrDefault(player, 0)
+ config.KarmaPerRound;
foreach (var (player, karmaDelta) in queuedKarmaUpdates)
Task.Run(async () => {
var newKarma = await karma.Load(player) + karmaDelta;

View File

@@ -1,5 +1,6 @@
using System.Collections.Concurrent;
using System.Data;
using System.Diagnostics;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
@@ -14,35 +15,34 @@ using TTT.Karma.Events;
namespace TTT.Karma;
public sealed class KarmaStorage(IServiceProvider provider) : IKarmaService {
// Toggle immediate writes. If false, every Write triggers a flush
private const bool EnableCache = true;
private readonly IEventBus _bus = provider.GetRequiredService<IEventBus>();
private readonly IScheduler _scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IStorage<KarmaConfig>? _configStorage =
provider.GetService<IStorage<KarmaConfig>>();
private readonly SemaphoreSlim _flushGate = new(1, 1);
// Cache keyed by stable player id to avoid relying on IPlayer equality
private readonly ConcurrentDictionary<string, int> _karmaCache = new();
private readonly IScheduler _scheduler =
provider.GetRequiredService<IScheduler>();
private KarmaConfig _config = new();
private IDbConnection? _connection;
private IDisposable? _flushSubscription;
private readonly SemaphoreSlim _flushGate = new(1, 1);
// Toggle immediate writes. If false, every Write triggers a flush
private const bool EnableCache = true;
public string Id => nameof(KarmaStorage);
public string Version => GitVersionInformation.FullSemVer;
public void Start() {
// Load configuration first
if (_configStorage is not null) {
if (_configStorage is not null)
// Synchronously wait here since IKarmaService.Start is sync
_config = _configStorage.Load().GetAwaiter().GetResult()
?? new KarmaConfig();
}
// Open a dedicated connection used only by this service
_connection = new SqliteConnection(_config.DbString);
@@ -61,7 +61,7 @@ public sealed class KarmaStorage(IServiceProvider provider) : IKarmaService {
.Subscribe(_ => { }, // no-op on success
ex => {
// Replace with your logger if available
System.Diagnostics.Trace.TraceError($"Karma flush failed: {ex}");
Trace.TraceError($"Karma flush failed: {ex}");
});
}
@@ -81,7 +81,7 @@ SELECT COALESCE(
@DefaultKarma
)";
var karma = await conn.QuerySingleAsync<int>(sql,
new { PlayerId = key, DefaultKarma = _config.DefaultKarma });
new { PlayerId = key, _config.DefaultKarma });
if (EnableCache) _karmaCache[key] = karma;
return karma;
@@ -97,17 +97,15 @@ SELECT COALESCE(
$"Karma must be less than {max} for player {key}.");
int oldValue;
if (!_karmaCache.TryGetValue(key, out oldValue)) {
if (!_karmaCache.TryGetValue(key, out oldValue))
oldValue = await Load(player);
}
if (oldValue == newValue) return;
var evt = new KarmaUpdateEvent(player, oldValue, newValue);
try { _bus.Dispatch(evt); } catch {
// Replace with your logger if available
System.Diagnostics.Trace.TraceError(
"Exception during KarmaUpdateEvent dispatch.");
Trace.TraceError("Exception during KarmaUpdateEvent dispatch.");
throw;
}
@@ -118,6 +116,20 @@ SELECT COALESCE(
if (!EnableCache) await FlushAsync();
}
public void Dispose() {
try {
_flushSubscription?.Dispose();
// Best effort final flush
if (_connection is { State: ConnectionState.Open })
FlushAsync().GetAwaiter().GetResult();
} catch (Exception ex) {
Trace.TraceError($"Dispose flush failed: {ex}");
} finally {
_connection?.Dispose();
_flushGate.Dispose();
}
}
private async Task FlushAsync() {
var conn = EnsureConnection();
@@ -136,10 +148,9 @@ INSERT INTO PlayerKarma (PlayerId, Karma)
VALUES (@PlayerId, @Karma)
ON CONFLICT(PlayerId) DO UPDATE SET Karma = excluded.Karma
";
foreach (var (playerId, karma) in snapshot) {
foreach (var (playerId, karma) in snapshot)
await conn.ExecuteAsync(upsert,
new { PlayerId = playerId, Karma = karma }, tx);
}
tx.Commit();
} finally { _flushGate.Release(); }
@@ -151,19 +162,4 @@ ON CONFLICT(PlayerId) DO UPDATE SET Karma = excluded.Karma
"Storage connection is not initialized.");
return _connection;
}
public void Dispose() {
try {
_flushSubscription?.Dispose();
// Best effort final flush
if (_connection is { State: ConnectionState.Open }) {
FlushAsync().GetAwaiter().GetResult();
}
} catch (Exception ex) {
System.Diagnostics.Trace.TraceError($"Dispose flush failed: {ex}");
} finally {
_connection?.Dispose();
_flushGate.Dispose();
}
}
}

View File

@@ -1,23 +1,30 @@
using System.Runtime.CompilerServices;
using CounterStrikeSharp.API.Modules.Utils;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop.Commands;
public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly IGameManager games = provider
.GetRequiredService<IGameManager>();
private readonly IDictionary<IOnlinePlayer, List<IShopItem>> cache =
new Dictionary<IOnlinePlayer, List<IShopItem>>();
private readonly IGameManager games = provider
.GetRequiredService<IGameManager>();
private readonly IDictionary<IOnlinePlayer, DateTime> lastUpdate =
new Dictionary<IOnlinePlayer, DateTime>();
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
private readonly IRoleAssigner roles = provider
.GetRequiredService<IRoleAssigner>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
@@ -43,9 +50,30 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
info.ReplySync(formatItem(item, items.Count - index, canPurchase));
}
if (games.ActiveGame is not { State: State.IN_PROGRESS }
|| executor == null)
return CommandResult.SUCCESS;
var role = roles.GetRoles(executor).FirstOrDefault();
if (role == null) return CommandResult.SUCCESS;
info.ReplySync(locale[ShopMsgs.SHOP_LIST_FOOTER(role, balance)]);
return CommandResult.SUCCESS;
}
public List<IShopItem> GetSortedItems(IOnlinePlayer? player,
bool refresh = false) {
if (player == null) return calculateSortedItems(null);
if (refresh || !cache.ContainsKey(player))
cache[player] = calculateSortedItems(player);
return cache[player];
}
public DateTime? GetLastUpdate(IOnlinePlayer? player) {
if (player == null) return null;
lastUpdate.TryGetValue(player, out var time);
return time;
}
private List<IShopItem> calculateSortedItems(IOnlinePlayer? player) {
var items = new List<IShopItem>(shop.Items).Where(item
=> player == null
@@ -75,10 +103,9 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
return
$" {ChatColors.Grey}- [{ChatColors.DarkRed}{item.Config.Price}{ChatColors.Grey}] {ChatColors.Red}{item.Name}";
if (index > 9) {
if (index > 9)
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
}
return
$" {ChatColors.Blue}/{index} {ChatColors.Default}| [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
@@ -88,18 +115,4 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
return
$" {formatPrefix(item, index, canBuy)} {ChatColors.Grey} | {item.Description}";
}
public List<IShopItem> GetSortedItems(IOnlinePlayer? player,
bool refresh = false) {
if (player == null) return calculateSortedItems(null);
if (refresh || !cache.ContainsKey(player))
cache[player] = calculateSortedItems(player);
return cache[player];
}
public DateTime? GetLastUpdate(IOnlinePlayer? player) {
if (player == null) return null;
lastUpdate.TryGetValue(player, out var time);
return time;
}
}

View File

@@ -9,11 +9,11 @@ using TTT.Locale;
namespace TTT.Shop.Commands;
public class ShopCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly ListCommand listCmd = new(provider);
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
private readonly ListCommand listCmd = new(provider);
private Dictionary<string, ICommand>? subcommands;
public void Dispose() { }

View File

@@ -2,8 +2,8 @@
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xml:space="preserve">
<s:Boolean
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=items_005Coneshotdeagle/@EntryIndexedValue">True</s:Boolean>
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=items_005Coneshotdeagle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=lang/@EntryIndexedValue">True</s:Boolean>
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=lang/@EntryIndexedValue">True</s:Boolean>
<s:Boolean
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=shop/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=shop/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -1,5 +1,6 @@
using CounterStrikeSharp.API.Modules.Utils;
using ShopAPI;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop;
@@ -48,4 +49,8 @@ public static class ShopMsgs {
public static IMsg COMMAND_BALANCE(int bal) {
return MsgFactory.Create(nameof(COMMAND_BALANCE), bal);
}
public static IMsg SHOP_LIST_FOOTER(IRole role, int bal) {
return MsgFactory.Create(nameof(SHOP_LIST_FOOTER), role.Name, bal);
}
}

View File

@@ -32,6 +32,7 @@ SHOP_INSUFFICIENT_BALANCE: "%SHOP_PREFIX%You cannot afford {white}{0}{grey}, it
SHOP_CANNOT_PURCHASE: "%SHOP_PREFIX%You cannot purchase this item."
SHOP_CANNOT_PURCHASE_WITH_REASON: "%SHOP_PREFIX%You cannot purchase this item: {red}{0}{grey}."
SHOP_PURCHASED: "%SHOP_PREFIX%You purchased {white}{0}{grey}."
SHOP_LIST_FOOTER: "%SHOP_PREFIX%You are %an% {0}{grey}, you have {yellow}{1}{grey} %CREDITS_NAME%%s%."
CREDITS_NAME: "credit"
CREDITS_GIVEN: "%SHOP_PREFIX%{0}{1} %CREDITS_NAME%%s%"

View File

@@ -4,7 +4,7 @@ namespace ShopAPI.Configs.Traitor;
public record PoisonShotsConfig : ShopItemConfig {
public override int Price { get; init; } = 65;
public int TotalShots { get; init; } = 3;
public int TotalShots { get; init; } = 5;
public Color PoisonColor { get; init; } = Color.FromArgb(128, Color.Purple);
public PoisonConfig PoisonConfig { get; init; } = new();
}

View File

@@ -3,6 +3,6 @@
namespace ShopAPI;
public record HealthshotConfig : ShopItemConfig {
public override int Price { get; init; } = 25;
public override int Price { get; init; } = 30;
public string Weapon { get; init; } = "weapon_healthshot";
}

View File

@@ -35,7 +35,10 @@ public class KarmaListenerTests {
new DetectiveRole(provider)
};
bus.RegisterListener(new KarmaListener(provider));
var listener = new KarmaListener(provider);
listener.GiveKarmaOnRoundEnd = false;
bus.RegisterListener(listener);
}
[Fact]
@@ -59,13 +62,13 @@ public class KarmaListenerTests {
[Theory]
[InlineData(RoleEnum.Innocent, RoleEnum.Innocent, 46, 49)]
[InlineData(RoleEnum.Innocent, RoleEnum.Traitor, 52, 50)]
[InlineData(RoleEnum.Innocent, RoleEnum.Traitor, 55, 50)]
[InlineData(RoleEnum.Innocent, RoleEnum.Detective, 44, 50)]
[InlineData(RoleEnum.Traitor, RoleEnum.Innocent, 50, 50)]
[InlineData(RoleEnum.Traitor, RoleEnum.Traitor, 45, 50)]
[InlineData(RoleEnum.Traitor, RoleEnum.Detective, 51, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Innocent, 46, 49)]
[InlineData(RoleEnum.Detective, RoleEnum.Traitor, 52, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Traitor, 55, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Detective, 44, 49)]
public async Task OnKill_AffectsKarma(RoleEnum attackerRole,
RoleEnum victimRole, int expAttackerKarma, int expVictimKarma) {