Compare commits

...

11 Commits

Author SHA1 Message Date
MSWS
f9e3d2d324 perf: Refactor KarmaStorage for thread-safety and reliability
- Replace `Dictionary<IPlayer, int>` with `ConcurrentDictionary<string, int>` in `KarmaStorage.cs` for thread-safe operations.
- Introduce caching logic with semaphore for serialized writes in `KarmaStorage.cs`.
- Add periodic data flushing mechanism to the database with improved error handling in `KarmaStorage.cs`.
- Seal `KarmaStorage` class to prevent inheritance and ensure configuration loading precedes database operations.
- Improve error handling with logging and ensure reliable database connections in `KarmaStorage.cs`.
- Enhance thread safety and performance in `FlushAsync` and `Write` methods, improving idempotency and preventing race conditions.
- Increase robustness with null checks, additional validations, and modular code separation in `KarmaStorage.cs`.
2025-10-12 17:24:19 -07:00
Isaac
9ce90cccaa Additional Shop Items, Synchronous Events (#111) 2025-10-12 14:05:14 -07:00
Isaac
551f6a09ef Update TTT/CS2/Command/PlayerPingShopAlias.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-12 13:59:45 -07:00
MSWS
41db8f9444 Additional awaits 2025-10-12 13:54:56 -07:00
MSWS
361bbb0a49 Wait for async event completion 2025-10-12 13:52:17 -07:00
MSWS
228ea40cec feat: Add item sorting and player ping features +semver:minor
```
- Introduce `IItemSorter` interface across multiple components to enhance item sorting capabilities in shop commands.
- Enhance `ListCommand` with caching mechanisms, improved sorting logic, and item formatting adjustments for better performance and usability.
- Implement `PlayerPingShopAlias` for enhanced player interaction, including command listeners and shop command processing tied to player actions.
- Set default price for Silent AWP in `SilentAWPConfig` to standardize item pricing.
- Conduct significant cleanup and optimization in `PoisonShotsListener` to improve gameplay experience and reduce unnecessary debug messages.
```
2025-10-12 13:22:31 -07:00
MSWS
44f7283145 feat: Refactor PlayerDamagedEvent for enhanced accuracy
```
- Increase delay time in KarmaListenerTests to ensure proper karma update processing.
- Change `Event` class from abstract to concrete, and modify `Id` property implementation.
- Adjust CS2GameConfig timing settings for more balanced gameplay.
- Enhance PoisonShotsListener functionality with player health parameters and item removal.
- Make SimpleLogger methods virtual to improve subclass flexibility.
- Implement new logging capabilities in CS2Logger with Serilog integration.
- Enhance GiveItemCommand with new event handling for item purchases.
- Update DeagleTests for accurate simulation of weapon damage.
- Modify PlayerDamagedEvent structure for more precise damage calculations.
- Improve DamageStation logic to align with new damage handling.
- Refactor DamageCanceler for better code organization and cross-platform support.
```
2025-10-12 12:28:08 -07:00
MSWS
1a52daad7c Fix async event handler 2025-10-12 11:56:20 -07:00
MSWS
7d0d32998e feat: Refactor karma configs and add new settings
- Change database connection string constant name for consistency in `CS2KarmaConfig.cs`
- Extend description of minimum karma for clarification of its impact
- Refine descriptions of player actions related to karma for improved clarity
- Rename karma-related constants to generic terms for simplicity
- Introduce configurable warning window for low karma to prevent repeat warnings
- Add configurable karma delta values for in-game actions
- Update Load method to include new karma-related configurations
2025-10-11 20:53:49 -07:00
MSWS
3cda83932e feat: Refactor karma system for configurability
```
- Add detailed XML documentation comments to `KarmaConfig.cs` to improve code understanding and maintainability.
- Remove the default value of `MinKarma` in `KarmaConfig.cs`, making it a mandatory setting.
- Introduce new properties in `KarmaConfig.cs` for handling different karma scenarios in player interactions.
- Add a dependency on `TTT.API.Storage` in `KarmaListener.cs` for loading configurations.
- Replace hardcoded karma values in `KarmaListener.cs` with configurable options, enhancing flexibility and adaptability.
```
2025-10-11 20:50:24 -07:00
MSWS
7ea57d0a9b Reformat & Cleanup 2025-10-11 20:46:16 -07:00
32 changed files with 538 additions and 228 deletions

View File

@@ -1,5 +1,5 @@
namespace TTT.API.Events;
public abstract class Event {
public abstract string Id { get; }
public class Event {
public virtual string Id => GetType().Name.ToLowerInvariant();
}

View File

@@ -1,4 +1,6 @@
using CounterStrikeSharp.API;
using Serilog;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Loggers;
@@ -12,4 +14,8 @@ public class CS2Logger(IServiceProvider provider) : SimpleLogger(provider) {
public override void PrintLogs(IOnlinePlayer? player) {
Server.NextWorldUpdate(() => base.PrintLogs(player));
}
public override void LogAction(IAction action) {
Server.NextWorldUpdate(() => base.LogAction(action));
}
}

View File

@@ -36,6 +36,7 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<IAliveSpoofer, CS2AliveSpoofer>();
collection.AddModBehavior<IIconManager, RoleIconsHandler>();
collection.AddModBehavior<NameDisplayer>();
collection.AddModBehavior<PlayerPingShopAlias>();
// Configs
collection.AddModBehavior<IStorage<TTTConfig>, CS2GameConfig>();

View File

@@ -18,11 +18,11 @@ public class CS2CommandManager(IServiceProvider provider)
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private BasePlugin? plugin;
private readonly IMessenger messenger = provider
.GetRequiredService<IMessenger>();
private BasePlugin? plugin;
public void Start(BasePlugin? basePlugin, bool hotReload) {
plugin = basePlugin;
base.Start();

View File

@@ -0,0 +1,53 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Commands;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API;
using TTT.API.Command;
using TTT.API.Player;
namespace TTT.CS2.Command;
public class PlayerPingShopAlias(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IItemSorter itemSorter =
provider.GetRequiredService<IItemSorter>();
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
plugin?.AddCommandListener("player_ping", onPlayerPing, HookMode.Post);
for (var i = 0; i < 10; i++) {
var index = i; // Capture variable
plugin?.AddCommand($"css_{index}", "",
(player, _) => { onButton(player, index); });
}
}
private HookResult onPlayerPing(CCSPlayerController? player,
CommandInfo commandInfo) {
if (player == null) return HookResult.Continue;
var gamePlayer = converter.GetPlayer(player) as IOnlinePlayer;
var cmdInfo =
new CS2CommandInfo(provider, gamePlayer, 0, "css_shop", "list");
cmdInfo.CallingContext = CommandCallingContext.Chat;
provider.GetRequiredService<ICommandManager>().ProcessCommand(cmdInfo);
return HookResult.Continue;
}
private void onButton(CCSPlayerController? player, int index) {
if (player == null) return;
if (converter.GetPlayer(player) is not IOnlinePlayer gamePlayer) return;
var lastUpdated = itemSorter.GetLastUpdate(gamePlayer);
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;
provider.GetRequiredService<ICommandManager>().ProcessCommand(cmdInfo);
}
}

View File

@@ -0,0 +1,21 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class CreditsCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
public void Start() { }
public string Id => "credits";
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (executor == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
shop.AddBalance(executor, 1000);
info.ReplySync("You have been given 1000 credits!");
return Task.FromResult(CommandResult.SUCCESS);
}
}

View File

@@ -1,7 +1,9 @@
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Events;
using TTT.API.Command;
using TTT.API.Events;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
@@ -44,7 +46,10 @@ public class GiveItemCommand(IServiceProvider provider) : ICommand {
target = result;
}
item.OnPurchase(target);
var purchaseEv = new PlayerPurchaseItemEvent(target, item);
provider.GetRequiredService<IEventBus>().Dispatch(purchaseEv);
if (purchaseEv.IsCanceled) return;
shop.GiveItem(target, item);
info.ReplySync($"Gave item '{item.Name}' to {target.Name}.");
});

View File

@@ -25,6 +25,7 @@ public class TestCommand(IServiceProvider provider) : ICommand, IPluginModule {
subCommands.Add("showicons", new ShowIconsCommand(provider));
subCommands.Add("sethealth", new SetHealthCommand());
subCommands.Add("emitsound", new EmitSoundCommand(provider));
subCommands.Add("credits", new CreditsCommand(provider));
}
public Task<CommandResult>

View File

@@ -11,7 +11,7 @@ namespace TTT.CS2.Configs;
public class CS2GameConfig : IStorage<TTTConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_ROUND_COUNTDOWN = new(
"css_ttt_round_countdown", "Time to wait before starting a round", 10,
"css_ttt_round_countdown", "Time to wait before starting a round", 15,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(1, 60));
public static readonly FakeConVar<int> CV_MINIMUM_PLAYERS = new(
@@ -80,7 +80,7 @@ public class CS2GameConfig : IStorage<TTTConfig>, IPluginModule {
new ItemValidator(allowMultiple: true));
public static readonly FakeConVar<int> CV_TIME_BETWEEN_ROUNDS = new(
"css_ttt_time_between_rounds", "Time to wait between rounds in seconds", 5,
"css_ttt_time_between_rounds", "Time to wait between rounds in seconds", 1,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(1, 60));
public void Dispose() { }

View File

@@ -1,40 +1,80 @@
using CounterStrikeSharp.API;
using TTT.API.Storage;
namespace TTT.CS2.Configs;
using System;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Cvars.Validators;
using TTT.API;
using TTT.API.Storage;
using TTT.Karma;
namespace TTT.CS2.Configs;
using Karma;
public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<string> CV_DB_STRING = new(
"css_ttt_karma_dbstring", "Database connection string for Karma storage",
"css_ttt_karma_db_string", "Database connection string for Karma storage",
"Data Source=karma.db");
public static readonly FakeConVar<int> CV_MIN_KARMA = new("css_ttt_karma_min",
"Minimum possible Karma value", 0, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(0, 1000));
"Minimum possible Karma value; falling below executes the low-karma command",
0, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<int> CV_DEFAULT_KARMA = new(
"css_ttt_karma_default", "Default Karma value for new players", 50,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
"css_ttt_karma_default", "Default Karma assigned to new or reset players",
50, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<string> CV_LOW_KARMA_COMMAND = new(
"css_ttt_karma_low_command",
"Command executed when a player falls below the Karma threshold (use {0} for player name)",
"css_ban #{0} 4320 Your karma is too low!");
"Command executed when a player's karma falls below the minimum (use {0} for player slot)",
"karmaban {0} Bad Player!");
public static readonly FakeConVar<int> CV_KARMA_TIMEOUT_THRESHOLD = new(
public static readonly FakeConVar<int> CV_TIMEOUT_THRESHOLD = new(
"css_ttt_karma_timeout_threshold",
"Minimum Karma to avoid punishment or timeout effects", 20,
"Minimum Karma before timing a player out for KarmaRoundTimeout rounds", 20,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<int> CV_KARMA_ROUND_TIMEOUT = new(
"css_ttt_karma_round_timeout", "Number of rounds a Karma penalty persists",
public static readonly FakeConVar<int> CV_ROUND_TIMEOUT = new(
"css_ttt_karma_round_timeout",
"Number of rounds a player is timed out for after falling below threshold",
4, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 100));
public static readonly FakeConVar<int> CV_WARNING_WINDOW_HOURS = new(
"css_ttt_karma_warning_window_hours",
"Time window (in hours) preventing repeat warnings for low karma", 24,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(1, 168));
// 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,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_DETECTIVE = new(
"css_ttt_karma_traitor_on_detective",
"Karma gained when Traitor kills a Detective", 1, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_INNO_VICTIM = new(
"css_ttt_karma_inno_on_inno_victim",
"Karma gained or lost when Innocent kills another Innocent who was a victim",
-1, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_INNO = new(
"css_ttt_karma_inno_on_inno",
"Karma lost when Innocent kills another Innocent", -4,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_TRAITOR = new(
"css_ttt_karma_traitor_on_traitor",
"Karma lost when Traitor kills another Traitor", -5, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_DETECTIVE = new(
"css_ttt_karma_inno_on_detective",
"Karma lost when Innocent kills a Detective", -6, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public void Dispose() { }
public void Start() { }
@@ -50,8 +90,15 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
MinKarma = CV_MIN_KARMA.Value,
DefaultKarma = CV_DEFAULT_KARMA.Value,
CommandUponLowKarma = CV_LOW_KARMA_COMMAND.Value,
KarmaTimeoutThreshold = CV_KARMA_TIMEOUT_THRESHOLD.Value,
KarmaRoundTimeout = CV_KARMA_ROUND_TIMEOUT.Value
KarmaTimeoutThreshold = CV_TIMEOUT_THRESHOLD.Value,
KarmaRoundTimeout = CV_ROUND_TIMEOUT.Value,
KarmaWarningWindow = TimeSpan.FromHours(CV_WARNING_WINDOW_HOURS.Value),
INNO_ON_TRAITOR = CV_INNO_ON_TRAITOR.Value,
TRAITOR_ON_DETECTIVE = CV_TRAITOR_ON_DETECTIVE.Value,
INNO_ON_INNO_VICTIM = CV_INNO_ON_INNO_VICTIM.Value,
INNO_ON_INNO = CV_INNO_ON_INNO.Value,
TRAITOR_ON_TRAITOR = CV_TRAITOR_ON_TRAITOR.Value,
INNO_ON_DETECTIVE = CV_INNO_ON_DETECTIVE.Value
};
return Task.FromResult<KarmaConfig?>(cfg);

View File

@@ -1,4 +1,5 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
using Microsoft.Extensions.DependencyInjection;
@@ -33,7 +34,6 @@ public class DamageCanceler(IServiceProvider provider) : IPluginModule {
var damagedEvent = new PlayerDamagedEvent(converter, hook);
bus.Dispatch(damagedEvent);
if (damagedEvent.IsCanceled) return HookResult.Handled;
var info = hook.GetParam<CTakeDamageInfo>(1);

View File

@@ -1,7 +1,6 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;

View File

@@ -66,7 +66,6 @@ public class PoisonShotsListener(IServiceProvider provider)
if (ev.Attacker == null) return;
if (!poisonShots.TryGetValue(ev.Attacker, out var shot) || shot <= 0)
return;
Messenger.DebugAnnounce("weapon: " + ev.Weapon);
if (ev.Weapon == null || !Tag.GUNS.Contains(ev.Weapon)) return;
Messenger.Message(ev.Attacker,
Locale[PoisonShotMsgs.SHOP_ITEM_POISON_HIT(ev.Player)]);
@@ -105,7 +104,7 @@ public class PoisonShotsListener(IServiceProvider provider)
if (!online.IsAlive) return false;
var dmgEvent = new PlayerDamagedEvent(online,
effect.Shooter as IOnlinePlayer,
effect.Shooter as IOnlinePlayer, online.Health,
online.Health - config.PoisonConfig.DamagePerTick) {
Weapon = $"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]"
};

View File

@@ -1,9 +1,6 @@
using System.Numerics;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.UserMessages;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
@@ -27,6 +24,18 @@ public static class SilentAWPServiceCollection {
public class SilentAWPItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider), IPluginModule {
private readonly SilentAWPConfig config =
provider.GetService<IStorage<SilentAWPConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new SilentAWPConfig();
private readonly IPlayerConverter<CCSPlayerController> playerConverter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IDictionary<IOnlinePlayer, int> silentShots =
new Dictionary<IOnlinePlayer, int>();
public override string Name => Locale[SilentAWPMsgs.SHOP_ITEM_SILENT_AWP];
public override string Description
@@ -34,28 +43,16 @@ public class SilentAWPItem(IServiceProvider provider)
public override ShopItemConfig Config => config;
private readonly SilentAWPConfig config =
provider.GetService<IStorage<SilentAWPConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new SilentAWPConfig();
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.HookUserMessage(452, onWeaponSound);
}
public override void OnPurchase(IOnlinePlayer player) {
silentShots[player] = config.CurrentAmmo ?? 0 + config.ReserveAmmo ?? 0;
Inventory.GiveWeapon(player, config);
}
private readonly IDictionary<IOnlinePlayer, int> silentShots =
new Dictionary<IOnlinePlayer, int>();
private readonly IPlayerConverter<CCSPlayerController> playerConverter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.HookUserMessage(452, onWeaponSound);
}
private HookResult onWeaponSound(UserMessage msg) {
var defIndex = msg.ReadUInt("item_def_index");

View File

@@ -73,9 +73,7 @@ public class DamageStation(IServiceProvider provider)
(int)Math.Floor(_Config.HealthIncrements * healthScale);
var dmgEvent = new PlayerDamagedEvent(player,
info.Owner as IOnlinePlayer, player.Health + damageAmount) {
Weapon = $"[{Name}]"
};
info.Owner as IOnlinePlayer, damageAmount) { Weapon = $"[{Name}]" };
bus.Dispatch(dmgEvent);

View File

@@ -2,8 +2,8 @@
public static class TextCompass {
/// <summary>
/// Builds a compass line with at most one character for each of N, E, S, W.
/// 0° = North, 90° = East, angles increase clockwise.
/// Builds a compass line with at most one character for each of N, E, S, W.
/// 0° = North, 90° = East, angles increase clockwise.
/// </summary>
/// <param name="fov">Field of view in degrees [0..360].</param>
/// <param name="width">Output width in characters.</param>
@@ -18,10 +18,10 @@ public static class TextCompass {
direction = Normalize(direction);
var buf = new char[width];
for (int i = 0; i < width; i++) buf[i] = filler;
for (var i = 0; i < width; i++) buf[i] = filler;
float start = direction - fov / 2f; // left edge of view
float degPerChar = fov / width;
var start = direction - fov / 2f; // left edge of view
var degPerChar = fov / width;
PlaceIfVisible('N', 0f);
PlaceIfVisible('E', 90f);
@@ -32,11 +32,11 @@ public static class TextCompass {
return new string(buf);
void PlaceIfVisible(char c, float cardinalAngle) {
float delta = ForwardDelta(start, cardinalAngle); // [0..360)
if (delta < 0f || delta >= fov) return; // outside view
var delta = ForwardDelta(start, cardinalAngle); // [0..360)
if (delta < 0f || delta >= fov) return; // outside view
// Map degrees to nearest character cell
int idx = (int)MathF.Round(delta / degPerChar);
var idx = (int)MathF.Round(delta / degPerChar);
if (idx < 0) idx = 0;
if (idx >= width) idx = width - 1;
@@ -46,15 +46,15 @@ public static class TextCompass {
return;
}
int maxRadius = Math.Max(idx, width - 1 - idx);
for (int r = 1; r <= maxRadius; r++) {
int left = idx - r;
var maxRadius = Math.Max(idx, width - 1 - idx);
for (var r = 1; r <= maxRadius; r++) {
var left = idx - r;
if (left >= 0 && buf[left] == filler) {
buf[left] = c;
return;
}
int right = idx + r;
var right = idx + r;
if (right < width && buf[right] == filler) {
buf[right] = c;
return;
@@ -73,9 +73,9 @@ public static class TextCompass {
// Delta moving forward from start to target, wrapped to [0..360)
private static float ForwardDelta(float start, float target) {
float s = Normalize(start);
float t = Normalize(target);
float d = t - s;
var s = Normalize(start);
var t = Normalize(target);
var d = t - s;
return d < 0 ? d + 360f : d;
}
}

View File

@@ -6,7 +6,11 @@ using TTT.API.Player;
namespace TTT.Game.Events.Player;
public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
int hpLeft) : PlayerEvent(player), ICancelableEvent {
int originalHp, int hpLeft) : PlayerEvent(player), ICancelableEvent {
public PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
int damageDealt) : this(player, attacker, player.Health - damageDealt,
player.Health) { }
public PlayerDamagedEvent(IPlayerConverter<CCSPlayerController> converter,
EventPlayerHurt ev) : this(
converter.GetPlayer(ev.Userid!) as IOnlinePlayer
@@ -14,22 +18,14 @@ public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
ev.Attacker == null ?
null :
converter.GetPlayer(ev.Attacker) as IOnlinePlayer,
ev.Health + ev.DmgHealth) {
ev.Health + ev.DmgHealth, ev.Health) {
ArmorDamage = ev.DmgArmor;
ArmorRemaining = ev.Armor;
Weapon = ev.Weapon;
}
public PlayerDamagedEvent(IPlayerConverter<CCSPlayerController> converter,
EventPlayerFalldamage ev) : this(
converter.GetPlayer(ev.Userid!) as IOnlinePlayer
?? throw new InvalidOperationException(), null,
ev.Userid!.Health + (int)ev.Damage) {
ArmorRemaining = ev.Userid.PawnArmor;
}
public PlayerDamagedEvent(IPlayerConverter<CCSPlayerController> converter,
DynamicHook hook) : this(null!, null, 0) {
DynamicHook hook) : this(null!, null, 0, 0) {
var playerPawn = hook.GetParam<CCSPlayerPawn>(0);
var info = hook.GetParam<CTakeDamageInfo>(1);
@@ -46,8 +42,8 @@ public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
Attacker = attacker == null || !attacker.IsValid ?
null :
converter.GetPlayer(attacker) as IOnlinePlayer;
// HpLeft = player.Health - DmgDealt;
HpLeft = (int)(player.Pawn.Value!.Health - info.Damage);
OriginalHp = player.Pawn.Value!.Health;
HpLeft = (int)(OriginalHp - info.Damage);
}
public override string Id => "basegame.event.player.damaged";
@@ -55,9 +51,10 @@ public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
public int ArmorDamage { get; private set; }
public int ArmorRemaining { get; set; }
public int DmgDealt => player.Health - HpLeft;
public int DmgDealt => OriginalHp - HpLeft;
public int HpLeft { get; set; } = hpLeft;
public int OriginalHp { get; } = originalHp;
public string? Weapon { get; init; }
public bool IsCanceled { get; set; }

View File

@@ -21,7 +21,7 @@ public class SimpleLogger(IServiceProvider provider) : IActionLogger {
private DateTime? epoch;
public void LogAction(IAction action) {
public virtual void LogAction(IAction action) {
#if DEBUG
msg.Value.Debug(
$"Logging action: {action.GetType().Name} at {scheduler.Now}");

View File

@@ -5,15 +5,52 @@ namespace TTT.Karma;
public record KarmaConfig {
public string DbString { get; init; } = "Data Source=karma.db";
public int MinKarma { get; init; } = 0;
/// <summary>
/// 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.
/// </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.
/// </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;
/// </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.
/// </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.
/// </summary>
public TimeSpan KarmaWarningWindow { get; init; } = TimeSpan.FromDays(1);
public int MaxKarma(IPlayer? player) { return 100; }
public int INNO_ON_TRAITOR { get; init; } = 2;
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;
}

View File

@@ -1,10 +1,10 @@
using System.Reactive.Concurrency;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
@@ -13,13 +13,6 @@ using TTT.Game.Roles;
namespace TTT.Karma;
public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private static readonly int INNO_ON_TRAITOR = 2;
private static readonly int TRAITOR_ON_DETECTIVE = 1;
private static readonly int INNO_ON_INNO_VICTIM = -1;
private static readonly int INNO_ON_INNO = -4;
private static readonly int TRAITOR_ON_TRAITOR = -5;
private static readonly int INNO_ON_DETECTIVE = -6;
private readonly Dictionary<string, int> badKills = new();
private readonly IGameManager games =
@@ -33,6 +26,12 @@ 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();
[EventHandler]
[UsedImplicitly]
public void OnRoundStart(GameStateUpdateEvent ev) { badKills.Clear(); }
@@ -64,19 +63,20 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
case InnocentRole when killerRole is TraitorRole:
return;
case InnocentRole:
victimKarmaDelta = INNO_ON_INNO_VICTIM;
killerKarmaDelta = INNO_ON_INNO;
victimKarmaDelta = config.INNO_ON_INNO_VICTIM;
killerKarmaDelta = config.INNO_ON_INNO;
break;
case TraitorRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_TRAITOR :
INNO_ON_TRAITOR;
config.TRAITOR_ON_TRAITOR :
config.INNO_ON_TRAITOR;
break;
case DetectiveRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_DETECTIVE :
INNO_ON_DETECTIVE;
if (killerRole is DetectiveRole) victimKarmaDelta = INNO_ON_INNO_VICTIM;
config.TRAITOR_ON_DETECTIVE :
config.INNO_ON_DETECTIVE;
if (killerRole is DetectiveRole)
victimKarmaDelta = config.INNO_ON_INNO_VICTIM;
break;
}

View File

@@ -1,6 +1,8 @@
using System.Data;
using System.Collections.Concurrent;
using System.Data;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using System.Reactive.Threading.Tasks;
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
@@ -11,91 +13,157 @@ using TTT.Karma.Events;
namespace TTT.Karma;
public class KarmaStorage(IServiceProvider provider) : IKarmaService {
private static readonly bool enableCache = true;
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
public sealed class KarmaStorage(IServiceProvider provider) : IKarmaService {
private readonly IEventBus _bus = provider.GetRequiredService<IEventBus>();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()?.Load().Result
?? new KarmaConfig();
private readonly IScheduler _scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IDictionary<IPlayer, int> karmaCache =
new Dictionary<IPlayer, int>();
private readonly IStorage<KarmaConfig>? _configStorage =
provider.GetService<IStorage<KarmaConfig>>();
private IDbConnection? connection;
// Cache keyed by stable player id to avoid relying on IPlayer equality
private readonly ConcurrentDictionary<string, int> _karmaCache = new();
public void Start() {
connection = new SqliteConnection(config.DbString);
connection.Open();
private KarmaConfig _config = new();
private IDbConnection? _connection;
private IDisposable? _flushSubscription;
private readonly SemaphoreSlim _flushGate = new(1, 1);
Task.Run(async () => {
if (connection is not { State: ConnectionState.Open })
throw new InvalidOperationException(
"Storage connection is not initialized.");
await connection.ExecuteAsync("CREATE TABLE IF NOT EXISTS PlayerKarma ("
+ "PlayerId TEXT PRIMARY KEY, " + "Karma INTEGER NOT NULL)");
});
var scheduler = provider.GetRequiredService<IScheduler>();
Observable.Interval(TimeSpan.FromSeconds(30), scheduler)
.Subscribe(_ => Task.Run(async () => await updateKarmas()));
}
public async Task<int> Load(IPlayer key) {
if (enableCache) {
karmaCache.TryGetValue(key, out var cachedKarma);
if (cachedKarma != 0) return cachedKarma;
}
if (connection is not { State: ConnectionState.Open })
throw new InvalidOperationException(
"Storage connection is not initialized.");
return await connection.QuerySingleAsync<int>(
$"SELECT COALESCE((SELECT Karma FROM PlayerKarma WHERE PlayerId = @PlayerId), {config.DefaultKarma})",
new { PlayerId = key.Id });
}
public void Dispose() { connection?.Dispose(); }
// 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 async Task Write(IPlayer key, int newData) {
if (newData > config.MaxKarma(key))
throw new ArgumentOutOfRangeException(nameof(newData),
$"Karma must be less than {config.MaxKarma(key)} for player {key.Id}.");
if (!karmaCache.TryGetValue(key, out var oldKarma)) {
oldKarma = await Load(key);
karmaCache[key] = oldKarma;
public void Start() {
// Load configuration first
if (_configStorage is not null) {
// Synchronously wait here since IKarmaService.Start is sync
_config = _configStorage.Load().GetAwaiter().GetResult()
?? new KarmaConfig();
}
if (oldKarma == newData) return;
// Open a dedicated connection used only by this service
_connection = new SqliteConnection(_config.DbString);
_connection.Open();
var karmaUpdateEvent = new KarmaUpdateEvent(key, oldKarma, newData);
bus.Dispatch(karmaUpdateEvent);
if (karmaUpdateEvent.IsCanceled) return;
// Ensure schema before any reads or writes
_connection.Execute(@"CREATE TABLE IF NOT EXISTS PlayerKarma (
PlayerId TEXT PRIMARY KEY,
Karma INTEGER NOT NULL
)");
karmaCache[key] = newData;
if (!enableCache) await updateKarmas();
// Periodic flush with proper error handling and serialization
_flushSubscription = Observable
.Interval(TimeSpan.FromSeconds(30), _scheduler)
.SelectMany(_ => FlushAsync().ToObservable())
.Subscribe(_ => { }, // no-op on success
ex => {
// Replace with your logger if available
System.Diagnostics.Trace.TraceError($"Karma flush failed: {ex}");
});
}
private async Task updateKarmas() {
if (connection is not { State: ConnectionState.Open })
public async Task<int> Load(IPlayer player) {
if (player is null) throw new ArgumentNullException(nameof(player));
var key = player.Id;
if (EnableCache && _karmaCache.TryGetValue(key, out var cached))
return cached;
var conn = EnsureConnection();
// Parameterize the default value to keep SQL static
var sql = @"
SELECT COALESCE(
(SELECT Karma FROM PlayerKarma WHERE PlayerId = @PlayerId),
@DefaultKarma
)";
var karma = await conn.QuerySingleAsync<int>(sql,
new { PlayerId = key, DefaultKarma = _config.DefaultKarma });
if (EnableCache) _karmaCache[key] = karma;
return karma;
}
public async Task Write(IPlayer player, int newValue) {
if (player is null) throw new ArgumentNullException(nameof(player));
var key = player.Id;
var max = _config.MaxKarma(player);
if (newValue > max)
throw new ArgumentOutOfRangeException(nameof(newValue),
$"Karma must be less than {max} for player {key}.");
int 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.");
throw;
}
if (evt.IsCanceled) return;
_karmaCache[key] = newValue;
if (!EnableCache) await FlushAsync();
}
private async Task FlushAsync() {
var conn = EnsureConnection();
// Fast path if there is nothing to flush
if (_karmaCache.IsEmpty) return;
await _flushGate.WaitAsync().ConfigureAwait(false);
try {
// Snapshot to avoid long lock on dictionary and to provide a stable view
var snapshot = _karmaCache.ToArray();
if (snapshot.Length == 0) return;
using var tx = conn.BeginTransaction();
const string upsert = @"
INSERT INTO PlayerKarma (PlayerId, Karma)
VALUES (@PlayerId, @Karma)
ON CONFLICT(PlayerId) DO UPDATE SET Karma = excluded.Karma
";
foreach (var (playerId, karma) in snapshot) {
await conn.ExecuteAsync(upsert,
new { PlayerId = playerId, Karma = karma }, tx);
}
tx.Commit();
} finally { _flushGate.Release(); }
}
private IDbConnection EnsureConnection() {
if (_connection is not { State: ConnectionState.Open })
throw new InvalidOperationException(
"Storage connection is not initialized.");
return _connection;
}
var tasks = new List<Task>();
foreach (var (player, karma) in karmaCache)
tasks.Add(connection.ExecuteAsync(
"INSERT INTO PlayerKarma (PlayerId, Karma) VALUES (@PlayerId, @Karma) "
+ "ON CONFLICT(PlayerId) DO UPDATE SET Karma = @Karma",
new { PlayerId = player.Id, Karma = karma }));
await Task.WhenAll(tasks);
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

@@ -16,6 +16,8 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly IItemSorter? sorter = provider.GetService<IItemSorter>();
public void Dispose() { }
public string Id => "buy";
public void Start() { }
@@ -41,7 +43,7 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
}
var query = string.Join(" ", info.Args.Skip(1));
var item = searchItem(query);
var item = searchItem(executor, query);
if (item == null) {
info.ReplySync(locale[ShopMsgs.SHOP_ITEM_NOT_FOUND(query)]);
@@ -54,7 +56,13 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
CommandResult.ERROR);
}
private IShopItem? searchItem(string query) {
private IShopItem? searchItem(IOnlinePlayer? player, string query) {
if (sorter != null && int.TryParse(query, out var id)) {
var items = sorter.GetSortedItems(player);
if (id >= 0 && id < items.Count) return items[id];
return null;
}
var item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));

View File

@@ -1,4 +1,5 @@
using CounterStrikeSharp.API.Modules.Utils;
using System.Runtime.CompilerServices;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
@@ -7,10 +8,16 @@ using TTT.API.Player;
namespace TTT.Shop.Commands;
public class ListCommand(IServiceProvider provider) : ICommand {
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 IDictionary<IOnlinePlayer, DateTime> lastUpdate =
new Dictionary<IOnlinePlayer, DateTime>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
@@ -21,19 +28,37 @@ public class ListCommand(IServiceProvider provider) : ICommand {
public async Task<CommandResult> Execute(IOnlinePlayer? executor,
ICommandInfo info) {
var items = new List<IShopItem>(shop.Items).Where(item
=> executor == null
|| games.ActiveGame is not { State: State.IN_PROGRESS }
|| item.CanPurchase(executor) != PurchaseResult.WRONG_ROLE)
.ToList();
var items = calculateSortedItems(executor);
if (executor != null) cache[executor] = items;
items = new List<IShopItem>(items);
items.Reverse();
var balance = executor == null ? int.MaxValue : await shop.Load(executor);
foreach (var (index, item) in items.Select((value, i) => (i, value))) {
var canPurchase = executor == null
|| item.CanPurchase(executor) == PurchaseResult.SUCCESS;
canPurchase = canPurchase && item.Config.Price <= balance;
info.ReplySync(formatItem(item, items.Count - index, canPurchase));
}
return CommandResult.SUCCESS;
}
private List<IShopItem> calculateSortedItems(IOnlinePlayer? player) {
var items = new List<IShopItem>(shop.Items).Where(item
=> player == null
|| games.ActiveGame is not { State: State.IN_PROGRESS }
|| item.CanPurchase(player) != PurchaseResult.WRONG_ROLE)
.ToList();
items.Sort((a, b) => {
var aPrice = a.Config.Price;
var bPrice = b.Config.Price;
var aCanBuy = executor != null
&& a.CanPurchase(executor) == PurchaseResult.SUCCESS;
var bCanBuy = executor != null
&& b.CanPurchase(executor) == PurchaseResult.SUCCESS;
var aCanBuy = player != null
&& a.CanPurchase(player) == PurchaseResult.SUCCESS;
var bCanBuy = player != null
&& b.CanPurchase(player) == PurchaseResult.SUCCESS;
if (aCanBuy && !bCanBuy) return -1;
if (!aCanBuy && bCanBuy) return 1;
@@ -41,29 +66,40 @@ public class ListCommand(IServiceProvider provider) : ICommand {
return string.Compare(a.Name, b.Name, StringComparison.Ordinal);
});
var balance = info.CallingPlayer == null ?
int.MaxValue :
await shop.Load(info.CallingPlayer);
foreach (var item in items)
info.ReplySync(formatItem(item,
item.Config.Price <= balance
&& item.CanPurchase(info.CallingPlayer ?? executor!)
== PurchaseResult.SUCCESS));
return CommandResult.SUCCESS;
if (player != null) lastUpdate[player] = DateTime.Now;
return items;
}
private string formatPrefix(IShopItem item, bool canBuy = true) {
private string formatPrefix(IShopItem item, int index, bool canBuy) {
if (!canBuy)
return
$" {ChatColors.Grey}- [{ChatColors.DarkRed}{item.Config.Price}{ChatColors.Grey}] {ChatColors.Red}{item.Name}";
if (index > 9) {
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
}
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
$" {ChatColors.Blue}/{index} {ChatColors.Default}| [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
}
private string formatItem(IShopItem item, bool canBuy) {
private string formatItem(IShopItem item, int index, bool canBuy) {
return
$" {formatPrefix(item, canBuy)} {ChatColors.Grey} | {item.Description}";
$" {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

@@ -1,5 +1,6 @@
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Player;
using TTT.Game;
@@ -7,28 +8,38 @@ using TTT.Locale;
namespace TTT.Shop.Commands;
public class ShopCommand(IServiceProvider provider) : ICommand {
public class ShopCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
private readonly Dictionary<string, ICommand> subcommands = new() {
["list"] = new ListCommand(provider),
["buy"] = new BuyCommand(provider),
["balance"] = new BalanceCommand(provider),
["bal"] = new BalanceCommand(provider)
};
private readonly ListCommand listCmd = new(provider);
private Dictionary<string, ICommand>? subcommands;
public void Dispose() { }
public string Id => "shop";
public string[] Usage => ["list", "buy [item]", "balance"];
public void Start() { }
public void Start() {
subcommands = new Dictionary<string, ICommand> {
["list"] = listCmd,
["buy"] = new BuyCommand(provider),
["balance"] = new BalanceCommand(provider),
["bal"] = new BalanceCommand(provider)
};
}
public bool MustBeOnMainThread => true;
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
HashSet<string> sent = [];
if (subcommands == null) {
info.ReplySync(
$"{locale[GameMsgs.PREFIX]}{ChatColors.Red}No subcommands available.");
return Task.FromResult(CommandResult.ERROR);
}
if (info.ArgCount == 1) {
foreach (var (_, cmd) in subcommands) {
if (!sent.Add(cmd.Id)) continue;
@@ -52,4 +63,13 @@ public class ShopCommand(IServiceProvider provider) : ICommand {
return command.Execute(executor, info.Skip());
return Task.FromResult(CommandResult.ERROR);
}
public List<IShopItem> GetSortedItems(IOnlinePlayer? player,
bool refresh = false) {
return listCmd.GetSortedItems(player, refresh);
}
public DateTime? GetLastUpdate(IOnlinePlayer? player) {
return listCmd.GetLastUpdate(player);
}
}

View File

@@ -16,31 +16,34 @@ public class PlayerKillListener(IServiceProvider provider)
[UsedImplicitly]
[EventHandler]
public async Task OnKill(PlayerDeathEvent ev) {
public void OnKill(PlayerDeathEvent ev) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
if (ev.Killer == null) return;
var victimBal = await shop.Load(ev.Victim);
shop.AddBalance(ev.Killer, victimBal / 6, "Killed " + ev.Victim.Name);
Task.Run(async () => {
var victimBal = await shop.Load(ev.Victim);
shop.AddBalance(ev.Killer, victimBal / 6, "Killed " + ev.Victim.Name);
});
}
[UsedImplicitly]
[EventHandler(IgnoreCanceled = true)]
public async Task OnIdentify(BodyIdentifyEvent ev) {
public void OnIdentify(BodyIdentifyEvent ev) {
if (ev.Identifier == null) return;
var victimBal = await shop.Load(ev.Body.OfPlayer);
shop.AddBalance(ev.Identifier, victimBal / 4,
"Identified " + ev.Body.OfPlayer.Name);
Task.Run(async () => {
var victimBal = await shop.Load(ev.Body.OfPlayer);
shop.AddBalance(ev.Identifier, victimBal / 4,
"Identified " + ev.Body.OfPlayer.Name);
if (ev.Body.Killer is not IOnlinePlayer killer) return;
if (ev.Body.Killer is not IOnlinePlayer killer) return;
if (!isGoodKill(ev.Body.Killer, ev.Body.OfPlayer)) {
var killerBal = await shop.Load(killer);
shop.AddBalance(killer, -killerBal / 4 - victimBal / 2, "Bad Kill");
return;
}
if (!isGoodKill(ev.Body.Killer, ev.Body.OfPlayer)) {
var killerBal = await shop.Load(killer);
shop.AddBalance(killer, -killerBal / 4 - victimBal / 2, "Bad Kill");
return;
}
shop.AddBalance(killer, victimBal / 4, "Good Kill");
shop.AddBalance(killer, victimBal / 4, "Good Kill");
});
}
private bool isGoodKill(IPlayer attacker, IPlayer victim) {

View File

@@ -33,7 +33,7 @@ public static class ShopServiceCollection {
collection.AddModBehavior<PeriodicRewarder>();
collection.AddModBehavior<TaseRewarder>();
collection.AddModBehavior<ShopCommand>();
collection.AddModBehavior<IItemSorter, ShopCommand>();
collection.AddModBehavior<BuyCommand>();
collection.AddModBehavior<BalanceCommand>();

View File

@@ -28,7 +28,7 @@ public abstract class BaseItem(IServiceProvider provider) : IShopItem {
protected readonly IRoleAssigner Roles =
provider.GetRequiredService<IRoleAssigner>();
protected readonly IShop Shop = provider.GetRequiredService<IShop>();
public virtual void Dispose() { }

View File

@@ -3,9 +3,9 @@
namespace ShopAPI.Configs.Traitor;
public record SilentAWPConfig : ShopItemConfig, IWeapon {
public override int Price { get; init; }
public override int Price { get; init; } = 90;
public int WeaponIndex { get; } = 9;
public string WeaponId { get; } = "weapon_awp";
public int? ReserveAmmo { get; } = 0;
public int? CurrentAmmo { get; } = 2;
public int WeaponIndex { get; } = 9;
}

View File

@@ -0,0 +1,8 @@
using TTT.API.Player;
namespace ShopAPI;
public interface IItemSorter {
List<IShopItem> GetSortedItems(IOnlinePlayer? player, bool refresh = false);
DateTime? GetLastUpdate(IOnlinePlayer? player);
}

View File

@@ -1,4 +1,3 @@
using System.Reactive.Linq;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
@@ -89,7 +88,7 @@ public class KarmaListenerTests {
bus.Dispatch(deathEvent);
game.EndGame();
await Task.Delay(TimeSpan.FromMilliseconds(10),
await Task.Delay(TimeSpan.FromMilliseconds(20),
TestContext.Current
.CancellationToken); // Wait for the karma update to process
@@ -131,6 +130,10 @@ public class KarmaListenerTests {
game.EndGame();
await Task.Delay(TimeSpan.FromMilliseconds(20),
TestContext.Current
.CancellationToken); // Wait for the karma update to process
var killerKarma = await karma.Load(attacker);
Assert.Equal(20, killerKarma);
}

View File

@@ -57,6 +57,9 @@ public class BalanceClearTest(IServiceProvider provider) {
var game = games.CreateGame();
game?.Start();
await Task.Delay(TimeSpan.FromMilliseconds(10),
TestContext.Current.CancellationToken);
var newBalance = await shop.Load(player);
var expected = 100;

View File

@@ -38,7 +38,7 @@ public class DeagleTests {
shop.GiveItem(testPlayer, item);
var playerDmgEvent =
new PlayerDamagedEvent(victim, testPlayer, 99) { Weapon = item.WeaponId };
new PlayerDamagedEvent(victim, testPlayer, 1) { Weapon = item.WeaponId };
bus.Dispatch(playerDmgEvent);
Assert.Equal(0, victim.Health);
@@ -51,11 +51,11 @@ public class DeagleTests {
shop.GiveItem(testPlayer, item);
var playerDmgEvent =
new PlayerDamagedEvent(victim, testPlayer, 99) { Weapon = item.WeaponId };
new PlayerDamagedEvent(victim, testPlayer, 1) { Weapon = item.WeaponId };
bus.Dispatch(playerDmgEvent);
var secondDmgEvent =
new PlayerDamagedEvent(survivor, testPlayer, 99) {
new PlayerDamagedEvent(survivor, testPlayer, 1) {
Weapon = item.WeaponId
};
bus.Dispatch(secondDmgEvent);