Compare commits

...

20 Commits

Author SHA1 Message Date
MSWS
84230fd231 Improve prop drag line appeareance (resolves #96) 2025-10-04 05:40:10 -07:00
MSWS
fdfc0cc3bd Update phrasing in role reveals 2025-10-04 05:31:01 -07:00
MSWS
7fc0f21fa4 fix: Poison shots carrying over rounds (resolves #101)
```
Enhance poison effect management and resource disposal

- Improve resource management in `PoisonSmokeListener.cs` by ensuring proper disposal of poison smoke timers, clearing the timer list upon disposal, and ensuring termination of damage effects according to configuration settings. Remove unnecessary debug output for cleaner logs.
- Refactor `PoisonShotsListener.cs` to reset poison shot data and improve disposal logic. Enhance poison shot usage management and streamline game mechanics related to poison damage and effects, ensuring a more efficient game experience.
```
2025-10-04 05:28:56 -07:00
MSWS
ed7ad35b85 Address concurrent modification issue 2025-10-04 05:25:55 -07:00
MSWS
e6009dd75a Price tweaks 2025-10-04 05:22:10 -07:00
MSWS
b295fc45a2 Suppress bomb planting notification 2025-10-04 05:02:38 -07:00
MSWS
385f87ad12 Reformat & Cleanup 2025-10-04 03:47:57 -07:00
MSWS
6aedbeb3fb Adjust shop prefix 2025-10-04 03:38:40 -07:00
MSWS
2d078e4dfa Adjust periodic reward 2025-10-04 03:36:54 -07:00
MSWS
ff3dd9563e feat: Add periodic reward system +semver:minor (resolves #97)
```
- Modify `DamageStation.cs` to check `_Config.TotalHealthGiven` before health comparison and improve `onInterval` method for better clarity and robustness.
- Enhance `ShopServiceCollection.cs` by adding `PeriodicRewarder`, introducing periodic rewards functionality.
- Update `StationConfig.cs` by setting `TotalHealthGiven` to 0 and increasing `StationHealth` from 100 to 1000, enhancing station durability.
- Implement `PeriodicRewarder.cs`, a new class for issuing periodic rewards to players using dependency injection and a timed approach.
- Introduce new properties `CreditRewardInterval` and `IntervalRewardAmount` in `ShopConfig.cs` to define frequency and amount of periodic rewards, supporting a new credit accumulation strategy.
```
2025-10-04 03:36:11 -07:00
MSWS
8126dfea21 Hide skull in HUD 2025-10-04 03:20:56 -07:00
MSWS
e158bbbd77 feat: Add Healthshot and role-reveal +semver:minor (resolves #98)
Enhance Game Messaging and Item Functionality

- Update `GameMsgs.cs` to add new messages for role reveal on death and traitor reveal, ensuring consistency with message factory use.
- Add "Health Shot" item to `en.yml`, enhancing shop content with healing capabilities.
- Refine `RoundBasedGame.cs` to improve game state transitions, event handling, and winning team determination.
- Create `HealthshotConfig.cs` for configuring "Health Shot" item specifics, including pricing and weapon identifiers.
- Modify `BuyCommand.cs` to implement main thread execution and enhance item search logic for partial matches.
- Introduce `TraitorBuddyInformer` class to inform traitors of their allies during rounds.
- Expand `OutOfRoundCanceler.cs` logic to allow damage events in finished game states.
- Add `PlayerDeathInformer` class for revealing player killers, enhancing game event transparency.
- Introduce and register `HealthshotItem` within the shop, defining purchase logic and localized messaging.
- Remove redundant debug information from `PoisonSmokeListener.cs`, optimizing poison effect logic.
2025-10-04 02:55:08 -07:00
MSWS
e382302911 Reformat & Cleanup 2025-10-04 00:45:57 -07:00
MSWS
75690ee64b feat: Add Poison Smoke feature and refactor station roles (resolves #74)
Introduce Poison Smoke Item and Enhance Role Configurations

- Create `CS2PoisonSmokeConfig.cs` to configure the new Poison Smoke item, defining parameters like price, damage, and sound effect.
- Update `CS2ServiceCollection.cs` to handle poison smoke configuration using new implementations.
- Add `PoisonSmokeMsgs.cs` to define messages for the Poison Smoke item, facilitating localization.
- Enhance `DamageStation.cs` with role management by excluding `TraitorRole` from damage and adding sound feedback.
- Implement `PoisonSmokeListener.cs` to manage poison smoke events with dependencies and scheduled effects.
- Refactor `StationItem.cs` to introduce a generic role parameter, increasing flexibility.
- Update `HealthStation.cs` to specify the detective role with the generic `StationItem`.
- Introduce new configuration files for the traitor's poison smoke in `Traitor/PoisonSmokeConfig.cs`, detailing item properties.
- Simplify `ListCommand.cs` by adjusting item formatting logic for consistency.
- Update `PoisonShotsListener.cs` to handle refined poison configurations.
- Create `PoisonSmokeItem.cs` to define and manage the Poison Smoke item with dependency injection and role restrictions.
2025-10-04 00:43:53 -07:00
MSWS
dadd7b31a1 feat: Increase font size and adjust text positioning
- Increase default font size in TextSetting.cs for improved visibility
- Adjust world units per pixel in TextSetting.cs for better text scaling
- Modify text hat positioning in TextSpawner.cs relative to player’s rotation
2025-10-03 23:50:17 -07:00
MSWS
697c7f5d6b refactor: Refactor commands and enhance item handling +semver:minor
- Enhance `BuyCommand` with usage guidance and unified query logic
- Improve `ListCommand` with async task handling, dependency injections, and enhanced filtering and sorting based on player roles and game state
- Introduce a shorthand alias for `ShopCommand` balance and add usage property for better command guidance
- Upgrade `RoleIconsHandler` event handling, visibility management, and hot-reload logic
- Implement standard pricing and new properties in Traitor and Detective configuration files; adjust color mapping for health display
- Improve player equality handling by implementing `IEquatable` in `CS2Player` and `IPlayer` classes
2025-10-03 23:38:21 -07:00
MSWS
559718621f feat: Implement main thread command execution support +semver:minor
- Integrate `CounterStrikeSharp.API` in `BuyCommand.cs` for enhanced shop functionality.
- Refactor health color calculation in `HealthStationConfig.cs` to increase intensity as health increases.
- Revamp `CS2CommandManager.cs` with improved synchronous command processing and robust exception handling.
- Add `MustBeOnMainThread` property in `ICommand.cs` to manage command execution context.
- Update `ShopCommand.cs` to refine execution flow and description formatting.
- Revise logical comment and color calculation in `DamageStationConfig.cs` for correctness.
- Enhance clarity in `PlayerKillListener.cs` by refining event handling messages.
2025-10-03 22:59:25 -07:00
MSWS
d84e581392 feat: Localize balance command messages +semver:minor
```
- Update `BalanceCommand.cs` to include message localization and update reply format
- Refactor `Shop.cs` logging methods and simplify item handling
- Enhance `ShopMsgs.cs` with message prefix logic and credit prefix determination
- Update `en.yml` with consistent shop prefix and add new balance display message
```
2025-10-03 22:29:49 -07:00
MSWS
640924d2a2 fix: Fixed credits being overriden by balance clearer
```
- Modify RoundShopClearer to reset balances and items only when the game state is "FINISHED"
- Add debug logging and correct logic in PlayerKillListener to improve event handling
- Comment out obsolete Roles property in TestPlayer
- Enhance debug logging and initialize item lists correctly in Shop
- Update BalanceClearTest with role-related imports and new test for role assignments
```
2025-10-03 22:09:56 -07:00
MSWS
d6da16e537 Register PlayerKillListener 2025-10-03 21:50:24 -07:00
70 changed files with 811 additions and 168 deletions

View File

@@ -8,6 +8,7 @@ public interface ICommand : ITerrorModule {
string[] RequiredFlags => [];
string[] RequiredGroups => [];
string[] Aliases => [Id];
bool MustBeOnMainThread => false;
Task<CommandResult> Execute(IOnlinePlayer? executor, ICommandInfo info);
}

View File

@@ -1,6 +1,6 @@
namespace TTT.API.Player;
public interface IPlayer {
public interface IPlayer : IEquatable<IPlayer> {
/// <summary>
/// The unique identifier for the player, should
/// be unique across all players at all times.
@@ -8,4 +8,9 @@ public interface IPlayer {
string Id { get; }
string Name { get; }
bool IEquatable<IPlayer>.Equals(IPlayer? other) {
if (other is null) return false;
return Id == other.Id;
}
}

View File

@@ -34,6 +34,7 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<ICommandManager, CS2CommandManager>();
collection.AddModBehavior<IAliveSpoofer, CS2AliveSpoofer>();
collection.AddModBehavior<IIconManager, RoleIconsHandler>();
collection.AddModBehavior<NameDisplayer>();
// Configs
collection.AddModBehavior<IStorage<TTTConfig>, CS2GameConfig>();
@@ -43,6 +44,8 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<IStorage<C4Config>, CS2C4Config>();
collection.AddModBehavior<IStorage<M4A1Config>, CS2M4A1Config>();
collection.AddModBehavior<IStorage<TaserConfig>, CS2TaserConfig>();
collection
.AddModBehavior<IStorage<PoisonSmokeConfig>, CS2PoisonSmokeConfig>();
// TTT - CS2 Specific optionals
collection.AddScoped<ITextSpawner, TextSpawner>();
@@ -53,8 +56,8 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<DamageCanceler>();
collection.AddModBehavior<PlayerConnectionsHandler>();
collection.AddModBehavior<PropMover>();
// collection.AddModBehavior<RoundEnd_GameEndHandler>();
collection.AddModBehavior<RoundStart_GameStartHandler>();
collection.AddModBehavior<BombPlantSuppressor>();
// Damage Cancelers
collection.AddModBehavior<OutOfRoundCanceler>();

View File

@@ -40,20 +40,44 @@ public class CS2CommandManager(IServiceProvider provider)
var wrapper = executor == null ?
null :
converter.GetPlayer(executor) as IOnlinePlayer;
Task.Run(async () => {
try {
Console.WriteLine($"Processing command: {cs2Info.CommandString}");
return await ProcessCommand(cs2Info);
} catch (Exception e) {
var msg = e.Message;
cs2Info.ReplySync(Localizer[GameMsgs.GENERIC_ERROR(msg)]);
await Server.NextWorldUpdateAsync(() => {
if (cmdMap.TryGetValue(info.GetArg(0), out var command))
if (command.MustBeOnMainThread) {
processCommandSync(cs2Info, wrapper);
return;
}
Task.Run(async () => await processCommandAsync(cs2Info, wrapper));
}
private async Task<CommandResult> processCommandAsync(CS2CommandInfo cs2Info,
IOnlinePlayer? wrapper) {
try {
Console.WriteLine($"Processing command: {cs2Info.CommandString}");
return await ProcessCommand(cs2Info);
} catch (Exception e) {
var msg = e.Message;
cs2Info.ReplySync(Localizer[GameMsgs.GENERIC_ERROR(msg)]);
await Server.NextWorldUpdateAsync(() => {
Console.WriteLine(
$"Encountered an error when processing command: \"{cs2Info.CommandString}\" by {wrapper?.Id}");
Console.WriteLine(e);
});
return CommandResult.ERROR;
}
}
private void processCommandSync(CS2CommandInfo cs2Info,
IOnlinePlayer? wrapper) {
try { _ = ProcessCommand(cs2Info); } catch (Exception e) {
var msg = e.Message;
cs2Info.ReplySync(Localizer[GameMsgs.GENERIC_ERROR(msg)]);
Server.NextWorldUpdateAsync(() => {
Console.WriteLine(
$"Encountered an error when processing command: \"{cs2Info.CommandString}\" by {wrapper?.Id}");
Console.WriteLine(e);
});
return CommandResult.ERROR;
}
});
})
.Wait();
}
}
}

View File

@@ -7,11 +7,11 @@ using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class GiveItemCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
public void Start() { }

View File

@@ -11,7 +11,7 @@ namespace TTT.CS2.Configs.ShopItems;
public class CS2C4Config : IStorage<C4Config>, IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new("css_ttt_shop_c4_price",
"Price of the C4 item", 140, ConVarFlags.FCVAR_NONE,
"Price of the C4 item", 130, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<string> CV_WEAPON = new(

View File

@@ -12,7 +12,7 @@ namespace TTT.CS2.Configs.ShopItems;
public class CS2OneShotDeagleConfig : IStorage<OneShotDeagleConfig>,
IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_onedeagle_price", "Price of the One-Shot Deagle item", 120,
"css_ttt_shop_onedeagle_price", "Price of the One-Shot Deagle item", 100,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<bool> CV_FRIENDLY_FIRE = new(

View File

@@ -0,0 +1,67 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Cvars.Validators;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Storage;
using TTT.CS2.Validators;
namespace TTT.CS2.Configs.ShopItems;
public class CS2PoisonSmokeConfig : IStorage<PoisonSmokeConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_poisonsmoke_price", "Price of the Poison Smoke item", 45,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<string> CV_WEAPON = new(
"css_ttt_shop_poisonsmoke_weapon",
"Weapon entity name used for the Poison Smoke item", "weapon_smokegrenade",
ConVarFlags.FCVAR_NONE, new ItemValidator(allowMultiple: false));
// Poison effect sub-config
public static readonly FakeConVar<int> CV_POISON_TICK_DAMAGE = new(
"css_ttt_shop_poisonsmoke_poison_damage_per_tick",
"Damage dealt per poison tick", 15, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(1, 100));
public static readonly FakeConVar<int> CV_POISON_TOTAL_DAMAGE = new(
"css_ttt_shop_poisonsmoke_poison_total_damage",
"Total damage dealt over the poison duration", 500, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(1, 1000));
public static readonly FakeConVar<int> CV_POISON_TICK_INTERVAL = new(
"css_ttt_shop_poisonsmoke_poison_tick_interval",
"Milliseconds between each poison damage tick", 500, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(100, 10000));
public static readonly FakeConVar<string> CV_POISON_SOUND = new(
"css_ttt_shop_poisonsmoke_poison_sound",
"Sound played when poison deals damage",
"sounds/player/player_damagebody_03");
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
ArgumentNullException.ThrowIfNull(plugin, nameof(plugin));
plugin.RegisterFakeConVars(this);
}
public Task<PoisonSmokeConfig?> Load() {
var poison = new PoisonConfig {
TimeBetweenDamage =
TimeSpan.FromMilliseconds(CV_POISON_TICK_INTERVAL.Value),
DamagePerTick = CV_POISON_TICK_DAMAGE.Value,
TotalDamage = CV_POISON_TOTAL_DAMAGE.Value,
PoisonSound = CV_POISON_SOUND.Value
};
var cfg = new PoisonSmokeConfig {
Price = CV_PRICE.Value, Weapon = CV_WEAPON.Value, PoisonConfig = poison
};
return Task.FromResult<PoisonSmokeConfig?>(cfg);
}
}

View File

@@ -67,10 +67,9 @@ public static class PlayerExtensions {
var pawn = player.PlayerPawn.Value;
if (pawn == null || !pawn.IsValid) return;
pawn.ArmorValue = armor;
if (withHelmet) {
if (withHelmet)
if (pawn.ItemServices != null)
new CCSPlayer_ItemServices(pawn.ItemServices.Handle).HasHelmet = true;
}
Utilities.SetStateChanged(pawn, "CCSPlayerPawn", "m_ArmorValue");
}

View File

@@ -0,0 +1,20 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using JetBrains.Annotations;
using TTT.API;
namespace TTT.CS2.GameHandlers;
public class BombPlantSuppressor : IPluginModule {
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
plugin?.HookUserMessage(322, um => {
um.Recipients.Clear();
return HookResult.Handled;
});
}
}

View File

@@ -62,6 +62,10 @@ public class CombatHandler(IServiceProvider provider) : IPluginModule {
}
var killerStats = ev.Attacker?.ActionTrackingServices?.MatchStats;
ev.Attacker.ActionTrackingServices.NumRoundKills--;
Utilities.SetStateChanged(ev.Attacker,
"CCSPlayerController_ActionTrackingServices",
"m_pActionTrackingServices");
if (killerStats == null) return;
killerStats.Kills -= 1;
killerStats.Damage -= ev.DmgHealth;

View File

@@ -1,4 +1,5 @@
using TTT.API.Events;
using JetBrains.Annotations;
using TTT.API.Events;
using TTT.API.Game;
using TTT.CS2.Utils;
using TTT.Game.Events.Player;
@@ -8,10 +9,11 @@ namespace TTT.CS2.GameHandlers.DamageCancelers;
public class OutOfRoundCanceler(IServiceProvider provider)
: BaseListener(provider) {
[UsedImplicitly]
[EventHandler]
public void OnHurt(PlayerDamagedEvent ev) {
if (RoundUtil.IsWarmup()) return;
if (Games.ActiveGame is not { State: State.IN_PROGRESS })
if (Games.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED })
ev.IsCanceled = true;
}
}

View File

@@ -0,0 +1,34 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using TTT.API;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
using TTT.CS2.RayTrace.Enum;
namespace TTT.CS2.GameHandlers;
public class NameDisplayer : IPluginModule {
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
plugin?.AddTimer(0.25f, showNames, TimerFlags.REPEAT);
}
private void showNames() {
foreach (var player in Utilities.GetPlayers()) {
if (player.GetHealth() <= 0) continue;
var target = player.GetGameTraceByEyePosition(TraceMask.MaskSolid,
Contents.NoDraw, player);
if (target == null) continue;
if (!target.Value.HitPlayer(out var hit)) continue;
if (hit == null) continue;
player.PrintToCenterAlert($"{hit.PlayerName}");
}
}
}

View File

@@ -187,12 +187,11 @@ public class PropMover(IServiceProvider provider) : IPluginModule {
targetVector.Z = Math.Max(targetVector.Z, playerOrigin.Z - 48);
if (ent.AbsOrigin == null) return;
var lerpedVector = ent.AbsOrigin.Lerp(targetVector, 0.3f);
if (info.Beam != null && info.Beam.IsValid) {
info.Beam.AcceptInput("Kill");
info.Beam = createBeam(playerOrigin.With(z: playerOrigin.Z - 16),
lerpedVector);
ent.AbsOrigin);
}
playersPressingE[player] = info;
@@ -201,9 +200,9 @@ public class PropMover(IServiceProvider provider) : IPluginModule {
private CEnvBeam? createBeam(Vector start, Vector end) {
var beam = Utilities.CreateEntityByName<CEnvBeam>("env_beam");
if (beam == null) return null;
beam.RenderMode = RenderMode_t.kRenderTransColor;
beam.Width = 0.5f;
beam.Render = Color.White;
beam.RenderMode = RenderMode_t.kRenderTransAlpha;
beam.Width = 2.0f;
beam.Render = Color.FromArgb(32, Color.White);
beam.EndPos.X = end.X;
beam.EndPos.Y = end.Y;
beam.EndPos.Z = end.Z;

View File

@@ -77,6 +77,7 @@ public class RoleIconsHandler(IServiceProvider provider)
plugin
?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.CheckTransmit>(
onTransmit);
if (hotReload) OnRoundEnd(null!, null!);
}
[UsedImplicitly]

View File

@@ -8,7 +8,7 @@ public class TextSetting {
public float depthOffset = 0.0f;
public bool enabled = true;
public string fontName = "Arial";
public float fontSize = 50;
public float fontSize = 64;
public bool fullbright = true;
public PointWorldTextJustifyHorizontal_t horizontal =
@@ -23,5 +23,5 @@ public class TextSetting {
public PointWorldTextJustifyVertical_t vertical =
PointWorldTextJustifyVertical_t.POINT_WORLD_TEXT_JUSTIFY_VERTICAL_CENTER;
public float worldUnitsPerPx = 0.4f;
public float worldUnitsPerPx = 0.5f;
}

View File

@@ -72,7 +72,7 @@ public class TextSpawner : ITextSpawner {
position.Add(new Vector(0, 0, 72));
rotation = new QAngle(rotation.X, rotation.Y + yRot, rotation.Z + 90);
position.Add(rotation.ToRight() * -10);
position.Add(rotation.ToRight() * 5);
var ent = CreateText(setting, position, rotation);
ent.AcceptInput("SetParent", player.Pawn.Value, null, "!activator");

View File

@@ -22,13 +22,13 @@ public class ArmorItem(IServiceProvider provider) : BaseItem(provider) {
.GetAwaiter()
.GetResult() ?? new ArmorConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public override string Name => Locale[ArmorMsgs.SHOP_ITEM_ARMOR];
public override string Description => Locale[ArmorMsgs.SHOP_ITEM_ARMOR_DESC];
public override ShopItemConfig Config => config;
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public override void OnPurchase(IOnlinePlayer player) {
var gamePlayer = converter.GetPlayer(player);
if (gamePlayer == null) return;

View File

@@ -2,7 +2,6 @@ using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Events;
using TTT.API.Events;
using TTT.API.Player;
using TTT.API.Storage;
@@ -16,17 +15,18 @@ namespace TTT.CS2.Items.BodyPaint;
public class BodyPaintListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
private readonly BodyPaintConfig config =
provider.GetService<IStorage<BodyPaintConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new BodyPaintConfig();
private readonly Dictionary<IPlayer, int> uses = new();
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
private readonly Dictionary<IPlayer, int> uses = new();
[UsedImplicitly]
[EventHandler(Priority = Priority.HIGH)]

View File

@@ -8,7 +8,7 @@ public class BodyPaintMsgs {
public static IMsg SHOP_ITEM_BODY_PAINT_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_BODY_PAINT_DESC));
public static IMsg SHOP_ITEM_BODY_PAINT_OUT
=> MsgFactory.Create(nameof(SHOP_ITEM_BODY_PAINT_OUT));
}

View File

@@ -1,7 +1,6 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Detective;
using TTT.API.Events;
using TTT.API.Game;

View File

@@ -10,9 +10,10 @@ public class PoisonShotMsgs {
public static IMsg SHOP_ITEM_POISON_SHOTS_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_POISON_SHOTS_DESC));
public static IMsg SHOP_ITEM_POISON_HIT(IPlayer player)
=> MsgFactory.Create(nameof(SHOP_ITEM_POISON_HIT), player.Name);
public static IMsg SHOP_ITEM_POISON_OUT
=> MsgFactory.Create(nameof(SHOP_ITEM_POISON_OUT));
public static IMsg SHOP_ITEM_POISON_HIT(IPlayer player) {
return MsgFactory.Create(nameof(SHOP_ITEM_POISON_HIT), player.Name);
}
}

View File

@@ -1,14 +1,12 @@
using System.Drawing;
using System.Diagnostics.CodeAnalysis;
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs.Traitor;
using ShopAPI.Events;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
@@ -23,8 +21,6 @@ namespace TTT.CS2.Items.PoisonShots;
public class PoisonShotsListener(IServiceProvider provider)
: BaseListener(provider), IPluginModule {
private readonly Dictionary<IPlayer, int> poisonShots = new();
private readonly PoisonShotsConfig config =
provider.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
@@ -34,13 +30,20 @@ public class PoisonShotsListener(IServiceProvider provider)
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
private readonly Dictionary<IPlayer, int> poisonShots = new();
private readonly List<IDisposable> poisonTimers = [];
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonTimers) timer.Dispose();
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnFire(EventWeaponFire ev, GameEventInfo _) {
@@ -73,16 +76,20 @@ public class PoisonShotsListener(IServiceProvider provider)
foreach (var timer in poisonTimers) timer.Dispose();
poisonTimers.Clear();
poisonShots.Clear();
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
private void addPoisonEffect(IPlayer player) {
IDisposable? timer = null;
var effect = new PoisonEffect(player);
timer = scheduler.SchedulePeriodic(config.TimeBetweenDamage, () => {
// ReSharper disable once AccessToModifiedClosure
timer = scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
=> {
Server.NextWorldUpdate(() => {
if (!tickPoison(effect)) timer?.Dispose();
if (tickPoison(effect) || timer == null) return;
timer.Dispose();
poisonTimers.Remove(timer);
});
});
@@ -92,31 +99,20 @@ public class PoisonShotsListener(IServiceProvider provider)
private bool tickPoison(PoisonEffect effect) {
if (effect.Player is not IOnlinePlayer online) return false;
if (!online.IsAlive) return false;
online.Health -= config.DamagePerTick;
online.Health -= config.PoisonConfig.DamagePerTick;
effect.Ticks++;
effect.DamageGiven += config.DamagePerTick;
effect.DamageGiven += config.PoisonConfig.DamagePerTick;
var gamePlayer = converter.GetPlayer(online);
gamePlayer?.ColorScreen(config.PoisonColor, 0.2f, 0.3f);
gamePlayer?.ExecuteClientCommand("play " + config.PoisonSound);
gamePlayer?.ExecuteClientCommand("play " + config.PoisonConfig.PoisonSound);
return effect.DamageGiven < config.TotalDamage;
}
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonTimers) timer.Dispose();
}
private class PoisonEffect(IPlayer player) {
public IPlayer Player { get; init; } = player;
public int Ticks { get; set; }
public int DamageGiven { get; set; }
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
/// <summary>
/// Uses a poison shot for the player. Returns the remaining shots, -1 if none
/// are available.
/// Uses a poison shot for the player. Returns the remaining shots, -1 if none
/// are available.
/// </summary>
/// <param name="player"></param>
/// <returns></returns>
@@ -133,4 +129,10 @@ public class PoisonShotsListener(IServiceProvider provider)
shop.RemoveItem<PoisonShotsItem>(player);
return 0;
}
private class PoisonEffect(IPlayer player) {
public IPlayer Player { get; } = player;
public int Ticks { get; set; }
public int DamageGiven { get; set; }
}
}

View File

@@ -0,0 +1,43 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
namespace TTT.CS2.Items.PoisonSmoke;
public static class PoisonSmokeServiceCollection {
public static void AddPoisonSmoke(this IServiceCollection services) {
services.AddModBehavior<PoisonSmokeItem>();
services.AddModBehavior<PoisonSmokeListener>();
}
}
public class PoisonSmokeItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly PoisonSmokeConfig config =
provider.GetService<IStorage<PoisonSmokeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonSmokeConfig();
public override string Name => Locale[PoisonSmokeMsgs.SHOP_ITEM_POISON_SMOKE];
public override string Description
=> Locale[PoisonSmokeMsgs.SHOP_ITEM_POISON_SMOKE_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) {
Inventory.GiveWeapon(player, new BaseWeapon(config.Weapon));
}
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return Shop.HasItem<PoisonSmokeItem>(player) ?
PurchaseResult.ALREADY_OWNED :
base.CanPurchase(player);
}
}

View File

@@ -0,0 +1,120 @@
using System.Diagnostics.CodeAnalysis;
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
namespace TTT.CS2.Items.PoisonSmoke;
public class PoisonSmokeListener(IServiceProvider provider) : IPluginModule {
private readonly PoisonSmokeConfig config =
provider.GetService<IStorage<PoisonSmokeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonSmokeConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
private readonly List<IDisposable> poisonSmokes = [];
private readonly IRoleAssigner roleAssigner =
provider.GetRequiredService<IRoleAssigner>();
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() {
foreach (var timer in poisonSmokes) timer.Dispose();
poisonSmokes.Clear();
}
public void Start() { }
[GameEventHandler]
public HookResult OnSmokeGrenade(EventSmokegrenadeDetonate ev,
GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
var player = converter.GetPlayer(ev.Userid) as IOnlinePlayer;
if (player == null) return HookResult.Continue;
if (!shop.HasItem<PoisonSmokeItem>(player)) return HookResult.Continue;
shop.RemoveItem<PoisonSmokeItem>(player);
var projectile =
Utilities.GetEntityFromIndex<CSmokeGrenadeProjectile>(ev.Entityid);
if (projectile == null || !projectile.IsValid) return HookResult.Continue;
startPoisonEffect(projectile);
return HookResult.Continue;
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
private void startPoisonEffect(CSmokeGrenadeProjectile projectile) {
IDisposable? timer = null;
var effect = new PoisonEffect(projectile);
timer = scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
=> {
Server.NextWorldUpdate(() => {
if (tickPoisonEffect(effect) || timer == null) return;
timer.Dispose();
poisonSmokes.Remove(timer);
});
});
poisonSmokes.Add(timer);
}
private bool tickPoisonEffect(PoisonEffect effect) {
if (!effect.Projectile.IsValid) return false;
effect.Ticks++;
var players = finder.GetOnline()
.Where(player => player.IsAlive && roleAssigner.GetRoles(player)
.Any(role => role is InnocentRole or DetectiveRole));
var gamePlayers = players.Select(p => converter.GetPlayer(p))
.Where(p => p != null && p.Pawn.Value != null && p.Pawn.Value.IsValid)
.Select(p => (p!, p?.Pawn.Value?.AbsOrigin.Clone()!));
gamePlayers = gamePlayers.Where(t
=> t.Item2.Distance(effect.Origin) <= config.SmokeRadius);
foreach (var player in gamePlayers.Select(p => p.Item1)) {
if (effect.DamageGiven >= config.PoisonConfig.TotalDamage) continue;
player.AddHealth(-config.PoisonConfig.DamagePerTick);
player.ExecuteClientCommand("play " + config.PoisonConfig.PoisonSound);
effect.DamageGiven += config.PoisonConfig.DamagePerTick;
}
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
private class PoisonEffect(CSmokeGrenadeProjectile projectile) {
public int Ticks { get; set; }
public int DamageGiven { get; set; }
public Vector Origin { get; } = projectile.AbsOrigin.Clone()!;
public CSmokeGrenadeProjectile Projectile { get; } = projectile;
}
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.CS2.Items.PoisonSmoke;
public class PoisonSmokeMsgs {
public static IMsg SHOP_ITEM_POISON_SMOKE
=> MsgFactory.Create(nameof(SHOP_ITEM_POISON_SMOKE));
public static IMsg SHOP_ITEM_POISON_SMOKE_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_POISON_SMOKE_DESC));
}

View File

@@ -1,6 +1,5 @@
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API.Extensions;
using TTT.API.Player;
@@ -17,11 +16,12 @@ public static class DamageStationCollection {
}
}
public class DamageStation(IServiceProvider provider) : StationItem(provider,
provider.GetService<IStorage<DamageStationConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DamageStationConfig()) {
public class DamageStation(IServiceProvider provider)
: StationItem<TraitorRole>(provider,
provider.GetService<IStorage<DamageStationConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DamageStationConfig()) {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
@@ -37,10 +37,12 @@ public class DamageStation(IServiceProvider provider) : StationItem(provider,
=> Locale[StationMsgs.SHOP_ITEM_STATION_HURT_DESC];
override protected void onInterval() {
var players = finder.GetOnline();
var players = finder.GetOnline();
var toRemove = new List<CPhysicsPropMultiplayer>();
foreach (var (prop, info) in props) {
if (Math.Abs(info.HealthGiven) > Math.Abs(_Config.TotalHealthGiven)) {
props.Remove(prop);
if (_Config.TotalHealthGiven != 0 && Math.Abs(info.HealthGiven)
> Math.Abs(_Config.TotalHealthGiven)) {
toRemove.Add(prop);
continue;
}
@@ -71,5 +73,7 @@ public class DamageStation(IServiceProvider provider) : StationItem(provider,
gamePlayer.ExecuteClientCommand("play " + _Config.UseSound);
}
}
foreach (var prop in toRemove) props.Remove(prop);
}
}

View File

@@ -1,10 +1,11 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using ShopAPI.Configs.Detective;
using TTT.API.Extensions;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Station;
@@ -14,21 +15,24 @@ public static class HealthStationCollection {
}
}
public class HealthStation(IServiceProvider provider) : StationItem(provider,
provider.GetService<IStorage<HealthStationConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new HealthStationConfig()) {
public class HealthStation(IServiceProvider provider)
: StationItem<DetectiveRole>(provider,
provider.GetService<IStorage<HealthStationConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new HealthStationConfig()) {
public override string Name => Locale[StationMsgs.SHOP_ITEM_STATION_HEALTH];
public override string Description
=> Locale[StationMsgs.SHOP_ITEM_STATION_HEALTH_DESC];
override protected void onInterval() {
var players = Utilities.GetPlayers();
var players = Utilities.GetPlayers();
var toRemove = new List<CPhysicsPropMultiplayer>();
foreach (var (prop, info) in props) {
if (Math.Abs(info.HealthGiven) > _Config.TotalHealthGiven) {
props.Remove(prop);
if (_Config.TotalHealthGiven != 0
&& Math.Abs(info.HealthGiven) > _Config.TotalHealthGiven) {
toRemove.Add(prop);
continue;
}
@@ -54,5 +58,7 @@ public class HealthStation(IServiceProvider provider) : StationItem(provider,
player.ExecuteClientCommand("play " + _Config.UseSound);
}
}
foreach (var prop in toRemove) props.Remove(prop);
}
}

View File

@@ -9,14 +9,14 @@ using ShopAPI;
using ShopAPI.Configs;
using TTT.API;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Station;
public abstract class StationItem(IServiceProvider provider,
public abstract class StationItem<T>(IServiceProvider provider,
StationConfig config)
: RoleRestrictedItem<DetectiveRole>(provider), IPluginModule {
: RoleRestrictedItem<T>(provider), IPluginModule where T : IRole {
private static readonly long PROP_SIZE_SQUARED = 500;
protected readonly StationConfig _Config = config;

View File

@@ -10,7 +10,7 @@ namespace TTT.CS2.Player;
/// Non-human Players (bots) will be tracked by their entity index.
/// Note that slot numbers are not guaranteed to be stable across server restarts.
/// </summary>
public class CS2Player : IOnlinePlayer {
public class CS2Player : IOnlinePlayer, IEquatable<CS2Player> {
private CCSPlayerController? cachePlayer;
protected CS2Player(string id, string name) {
@@ -51,6 +51,11 @@ public class CS2Player : IOnlinePlayer {
=> Math.Min(Utilities.GetPlayers().Select(p => p.PlayerName.Length).Max(),
24);
public bool Equals(CS2Player? other) {
if (other is null) return false;
return Id == other.Id;
}
public string Id { get; }
public string Name { get; }
@@ -107,6 +112,8 @@ public class CS2Player : IOnlinePlayer {
return player.SteamID.ToString();
}
public override int GetHashCode() { return Id.GetHashCode(); }
public override string ToString() { return createPaddedName(); }
// Goal: Pad the name to a fixed width for better alignment in logs

View File

@@ -25,5 +25,8 @@ SHOP_ITEM_POISON_SHOTS_DESC: "Your bullets are coated in a mildly poisonous subs
SHOP_ITEM_POISON_HIT: "%PREFIX% You hit {green}{0}{grey} with a {lightpurple}poison shot{grey}."
SHOP_ITEM_POISON_OUT: "%PREFIX% You are out of poison shots."
SHOP_ITEM_POISON_SMOKE: "Poison Smoke"
SHOP_ITEM_POISON_SMOKE_DESC: "Throw a grenade that releases poisonous gas."
SHOP_ITEM_ARMOR: "Armor with Helmet"
SHOP_ITEM_ARMOR_DESC: "Wear armor that reduces incoming damage."

View File

@@ -20,6 +20,8 @@ public static class GameServiceCollection {
collection.AddModBehavior<PlayerJoinStarting>();
collection.AddModBehavior<PlayerActionsLogger>();
collection.AddModBehavior<BodyIdentifyLogger>();
collection.AddModBehavior<PlayerDeathInformer>();
collection.AddModBehavior<TraitorBuddyInformer>();
// Commands
collection.AddModBehavior<TTTCommand>();

View File

@@ -0,0 +1,19 @@
using JetBrains.Annotations;
using TTT.API.Events;
using TTT.Game.Events.Player;
using TTT.Game.lang;
namespace TTT.Game.Listeners;
public class PlayerDeathInformer(IServiceProvider provider)
: BaseListener(provider) {
[UsedImplicitly]
[EventHandler]
public void OnDeath(PlayerDeathEvent ev) {
if (ev.Killer == null) return;
var killerRole = Roles.GetRoles(ev.Killer).FirstOrDefault();
if (killerRole == null) return;
Messenger.Message(ev.Victim,
Locale[GameMsgs.ROLE_REVEAL_DEATH(killerRole)]);
}
}

View File

@@ -0,0 +1,33 @@
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using TTT.API.Events;
using TTT.API.Game;
using TTT.Game.Events.Game;
using TTT.Game.lang;
using TTT.Game.Roles;
namespace TTT.Game.Listeners;
public class TraitorBuddyInformer(IServiceProvider provider)
: BaseListener(provider) {
[UsedImplicitly]
[EventHandler]
public void OnGameStatChange(GameStateUpdateEvent ev) {
if (ev.NewState != State.IN_PROGRESS) return;
var traitors = ev.Game.GetAlive(typeof(TraitorRole));
foreach (var traitor in traitors) {
var buddies = traitors.Where(x => x != traitor).ToList();
if (buddies.Count == 0) {
Messenger.Message(traitor, Locale[GameMsgs.ROLE_REVEAL_TRAITORS_NONE]);
} else {
Messenger.Message(traitor,
Locale[GameMsgs.ROLE_REVEAL_TRAITORS_HEADER]);
foreach (var buddy in buddies)
Messenger.Message(traitor,
$" {ChatColors.Grey}- {ChatColors.Red}{buddy.Name}");
}
}
}
}

View File

@@ -172,9 +172,6 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
return;
}
foreach (var player in online) inventory.RemoveAllWeapons(player);
StartedAt = DateTime.Now;
RoleAssigner.AssignRoles(online, Roles);
players.AddRange(online);

View File

@@ -32,7 +32,7 @@ public record TTTConfig {
public string[]? InnocentWeapons { get; init; } = ["knife", "pistol"];
public bool StripWeaponsPriorToEquipping { get; init; } = true;
public bool StripWeaponsPriorToEquipping { get; init; } = false;
}
public record RoundConfig {

View File

@@ -15,12 +15,23 @@ public static class GameMsgs {
public static IMsg ROLE_DETECTIVE
=> MsgFactory.Create(nameof(ROLE_DETECTIVE));
public static IMsg ROLE_REVEAL_TRAITORS_HEADER
=> MsgFactory.Create(nameof(ROLE_REVEAL_TRAITORS_HEADER));
public static IMsg ROLE_REVEAL_TRAITORS_NONE
=> MsgFactory.Create(nameof(ROLE_REVEAL_TRAITORS_NONE));
public static IMsg GAME_LOGS_HEADER
=> MsgFactory.Create(nameof(GAME_LOGS_HEADER));
public static IMsg GAME_LOGS_FOOTER
=> MsgFactory.Create(nameof(GAME_LOGS_FOOTER));
public static IMsg ROLE_REVEAL_DEATH(IRole killerRole) {
return MsgFactory.Create(nameof(ROLE_REVEAL_DEATH),
GetRolePrefix(killerRole) + killerRole.Name);
}
public static IMsg ROLE_ASSIGNED(IRole role) {
return MsgFactory.Create(nameof(ROLE_ASSIGNED), role.Name);
}

View File

@@ -3,6 +3,9 @@ ROLE_INNOCENT: "{green}Innocent"
ROLE_DETECTIVE: "{blue}Detective"
ROLE_TRAITOR: "{red}Traitor"
ROLE_ASSIGNED: "%PREFIX%You are %an% {0}{grey}!"
ROLE_REVEAL_DEATH: "%PREFIX%Your killer was %an% {0}{grey}!"
ROLE_REVEAL_TRAITORS_HEADER: "%PREFIX%Your {red}Traitor {grey}teammates are:"
ROLE_REVEAL_TRAITORS_NONE: "%PREFIX%You have no {red}Traitor {grey}teammates."
GENERIC_UNKNOWN: "%PREFIX%{red}Unknown Command: {darkred}{0}"
GENERIC_NO_PERMISSION: "%PREFIX%{red}You do not have permission to use this command."
GENERIC_NO_PERMISSION_NODE: "%PREFIX%{red}You are missing the {darkred}{0}{red} permission."

View File

@@ -2,11 +2,16 @@
using ShopAPI;
using TTT.API.Command;
using TTT.API.Player;
using TTT.Locale;
namespace TTT.Shop.Commands;
public class BalanceCommand(IServiceProvider provider) : ICommand {
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public string Id => "balance";
public string[] Aliases => [Id, "bal", "credits", "money"];
@@ -21,7 +26,7 @@ public class BalanceCommand(IServiceProvider provider) : ICommand {
}
var bal = await shop.Load(executor);
info.ReplySync($"You have {bal} credits.");
info.ReplySync(locale[ShopMsgs.COMMAND_BALANCE(bal)]);
return CommandResult.SUCCESS;
}
}

View File

@@ -20,6 +20,9 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
public string Id => "buy";
public void Start() { }
public string[] Aliases => [Id, "purchase", "b"];
public string[] Usage => ["[item]"];
public bool MustBeOnMainThread => true;
public Task<CommandResult> Execute(IOnlinePlayer? executor,
ICommandInfo info) {
@@ -58,7 +61,7 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
=> it.Name.Contains(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;

View File

@@ -2,14 +2,14 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Messages;
using TTT.API.Game;
using TTT.API.Player;
namespace TTT.Shop.Commands;
public class ListCommand(IServiceProvider provider) : ICommand {
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
private readonly IGameManager games = provider
.GetRequiredService<IGameManager>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
@@ -19,12 +19,51 @@ public class ListCommand(IServiceProvider provider) : ICommand {
public void Start() { }
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
foreach (var item in shop.Items)
messenger.Message(executor,
$"{ChatColors.Grey}- {ChatColors.White}{item.Name} {ChatColors.Grey}- {item.Description}");
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();
return Task.FromResult(CommandResult.SUCCESS);
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;
if (aCanBuy && !bCanBuy) return -1;
if (!aCanBuy && bCanBuy) return 1;
if (aPrice != bPrice) return aPrice.CompareTo(bPrice);
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;
}
private string formatPrefix(IShopItem item, bool canBuy = true) {
if (!canBuy)
return
$" {ChatColors.Grey}- [{ChatColors.DarkRed}{item.Config.Price}{ChatColors.Grey}] {ChatColors.Red}{item.Name}";
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
}
private string formatItem(IShopItem item, bool canBuy) {
return
$" {formatPrefix(item, canBuy)} {ChatColors.Grey} | {item.Description}";
}
}

View File

@@ -14,14 +14,18 @@ public class ShopCommand(IServiceProvider provider) : ICommand {
private readonly Dictionary<string, ICommand> subcommands = new() {
["list"] = new ListCommand(provider),
["buy"] = new BuyCommand(provider),
["balance"] = new BalanceCommand(provider)
["balance"] = new BalanceCommand(provider),
["bal"] = new BalanceCommand(provider)
};
public void Dispose() { }
public string Id => "shop";
public string[] Usage => ["list", "buy [item]", "balance"];
public void Start() { }
public bool MustBeOnMainThread => true;
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
HashSet<string> sent = [];

View File

@@ -18,11 +18,11 @@ public static class StickerExtensions {
public class Stickers(IServiceProvider provider)
: RoleRestrictedItem<DetectiveRole>(provider) {
private readonly StickerConfig config = provider
.GetService<IStorage<StickerConfig>>()
private readonly StickersConfig config = provider
.GetService<IStorage<StickersConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new StickerConfig();
.GetResult() ?? new StickersConfig();
private readonly IIconManager? icons = provider.GetService<IIconManager>();

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
namespace TTT.Shop.Items.Healthshot;
public static class HealthshotServiceCollection {
public static void AddHealthshot(this IServiceCollection services) {
services.AddModBehavior<HealthshotItem>();
}
}
public class HealthshotItem(IServiceProvider provider) : BaseItem(provider) {
private readonly HealthshotConfig config =
provider.GetService<IStorage<HealthshotConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new HealthshotConfig();
public override string Name => Locale[HealthshotMsgs.SHOP_ITEM_HEALTHSHOT];
public override string Description
=> Locale[HealthshotMsgs.SHOP_ITEM_HEALTHSHOT_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) {
Inventory.GiveWeapon(player, new BaseWeapon(config.Weapon));
}
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return PurchaseResult.SUCCESS;
}
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.Shop.Items.Healthshot;
public class HealthshotMsgs {
public static IMsg SHOP_ITEM_HEALTHSHOT
=> MsgFactory.Create(nameof(SHOP_ITEM_HEALTHSHOT));
public static IMsg SHOP_ITEM_HEALTHSHOT_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_HEALTHSHOT_DESC));
}

View File

@@ -17,7 +17,7 @@ public class PlayerKillListener(IServiceProvider provider)
[UsedImplicitly]
[EventHandler]
public async Task OnKill(PlayerDeathEvent ev) {
if (Games.ActiveGame is { State: State.IN_PROGRESS }) return;
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
if (ev.Killer == null) return;
var victimBal = await shop.Load(ev.Victim);
@@ -25,7 +25,7 @@ public class PlayerKillListener(IServiceProvider provider)
}
[UsedImplicitly]
[EventHandler]
[EventHandler(IgnoreCanceled = true)]
public async Task OnIdentify(BodyIdentifyEvent ev) {
if (ev.Identifier == null) return;
var victimBal = await shop.Load(ev.Body.OfPlayer);
@@ -37,12 +37,12 @@ public class PlayerKillListener(IServiceProvider provider)
if (!isGoodKill(ev.Body.Killer, ev.Body.OfPlayer)) {
var killerBal = await shop.Load(killer);
shop.AddBalance(killer, -killerBal / 4,
ev.Body.OfPlayer.Name + " kill invalidated");
ev.Body.OfPlayer.Name + " Bad Kill");
return;
}
shop.AddBalance(killer, victimBal / 4,
ev.Body.OfPlayer.Name + " kill validated");
ev.Body.OfPlayer.Name + " Good Kill");
}
private bool isGoodKill(IPlayer attacker, IPlayer victim) {

View File

@@ -13,12 +13,12 @@ public class RoundShopClearer(IServiceProvider provider) : IListener {
public void Dispose() { bus.UnregisterListener(this); }
[EventHandler(IgnoreCanceled = true, Priority = Priority.LOW)]
[EventHandler(IgnoreCanceled = true)]
[UsedImplicitly]
public void OnRoundStart(GameStateUpdateEvent ev) {
// Only clear balances if the round is in progress
// This is called only once, which means the round went from COUNTDOWN / WAITING -> IN_PROGRESS
if (ev.NewState != State.IN_PROGRESS) return;
if (ev.NewState != State.FINISHED) return;
shop.ClearBalances();
shop.ClearItems();
}

View File

@@ -0,0 +1,42 @@
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API;
using TTT.API.Player;
using TTT.API.Storage;
namespace TTT.Shop;
public class PeriodicRewarder(IServiceProvider provider) : ITerrorModule {
private readonly ShopConfig config = provider
.GetService<IStorage<ShopConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ShopConfig(provider);
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
private IDisposable? timer;
public void Dispose() { timer?.Dispose(); }
public void Start() {
timer = scheduler.SchedulePeriodic(config.CreditRewardInterval,
issueRewards);
}
private void issueRewards() {
Server.NextWorldUpdate(() => {
foreach (var player in finder.GetOnline().Where(p => p.IsAlive))
shop.AddBalance(player, config.IntervalRewardAmount, "Time Reward");
});
}
}

View File

@@ -68,6 +68,8 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
public void AddBalance(IOnlinePlayer player, int amount, string reason = "",
bool print = true) {
messenger?.Debug(
$"Adding {amount} to {player.Name} ({player.Id}) balance. Reason: {reason}");
if (amount == 0) return;
balances.TryAdd(player.Id, 0);

View File

@@ -6,10 +6,12 @@ using TTT.CS2.Items.BodyPaint;
using TTT.CS2.Items.Camouflage;
using TTT.CS2.Items.DNA;
using TTT.CS2.Items.PoisonShots;
using TTT.CS2.Items.PoisonSmoke;
using TTT.CS2.Items.Station;
using TTT.Shop.Commands;
using TTT.Shop.Items;
using TTT.Shop.Items.Detective.Stickers;
using TTT.Shop.Items.Healthshot;
using TTT.Shop.Items.M4A1;
using TTT.Shop.Items.Taser;
using TTT.Shop.Items.Traitor.C4;
@@ -24,6 +26,8 @@ public static class ShopServiceCollection {
collection.AddModBehavior<RoundShopClearer>();
collection.AddModBehavior<RoleAssignCreditor>();
collection.AddModBehavior<PlayerKillListener>();
collection.AddModBehavior<PeriodicRewarder>();
collection.AddModBehavior<ShopCommand>();
collection.AddModBehavior<BuyCommand>();
@@ -38,8 +42,10 @@ public static class ShopServiceCollection {
collection.AddDnaScannerServices();
collection.AddGlovesServices();
collection.AddHealthStation();
collection.AddHealthshot();
collection.AddM4A1Services();
collection.AddPoisonShots();
collection.AddPoisonSmoke();
collection.AddStickerServices();
collection.AddTaserItem();
}

View File

@@ -1,9 +1,12 @@
using CounterStrikeSharp.API.Modules.Utils;
using ShopAPI;
using TTT.Locale;
namespace TTT.Shop;
public static class ShopMsgs {
public static IMsg SHOP_PREFIX => MsgFactory.Create(nameof(SHOP_PREFIX));
public static IMsg SHOP_INACTIVE => MsgFactory.Create(nameof(SHOP_INACTIVE));
public static IMsg CREDITS_NAME => MsgFactory.Create(nameof(CREDITS_NAME));
@@ -20,15 +23,19 @@ public static class ShopMsgs {
}
public static IMsg CREDITS_GIVEN(int amo) {
return MsgFactory.Create(nameof(CREDITS_GIVEN), amo > 0 ? "+" : "-",
return MsgFactory.Create(nameof(CREDITS_GIVEN), getCreditPrefix(amo),
Math.Abs(amo));
}
public static IMsg CREDITS_GIVEN_REASON(int amo, string reason) {
return MsgFactory.Create(nameof(CREDITS_GIVEN_REASON), amo > 0 ? "+" : "-",
return MsgFactory.Create(nameof(CREDITS_GIVEN_REASON), getCreditPrefix(amo),
Math.Abs(amo), reason);
}
private static string getCreditPrefix(int diff) {
return diff > 0 ? ChatColors.Green + "+" : ChatColors.Red + "-";
}
public static IMsg SHOP_INSUFFICIENT_BALANCE(IShopItem item, int bal) {
return MsgFactory.Create(nameof(SHOP_INSUFFICIENT_BALANCE), item.Name,
item.Config.Price, bal);
@@ -37,4 +44,8 @@ public static class ShopMsgs {
public static IMsg SHOP_CANNOT_PURCHASE_WITH_REASON(string reason) {
return MsgFactory.Create(nameof(SHOP_CANNOT_PURCHASE_WITH_REASON), reason);
}
public static IMsg COMMAND_BALANCE(int bal) {
return MsgFactory.Create(nameof(COMMAND_BALANCE), bal);
}
}

View File

@@ -1,5 +1,6 @@
SHOP_INACTIVE: "%PREFIX%The shop is currently closed."
SHOP_ITEM_NOT_FOUND: "%PREFIX%Could not find an item named \"{default}{0}{grey}\"."
SHOP_PREFIX: "{green}SHOP {grey}| "
SHOP_INACTIVE: "%SHOP_PREFIX%The shop is currently closed."
SHOP_ITEM_NOT_FOUND: "%SHOP_PREFIX%Could not find an item named \"{default}{0}{grey}\"."
SHOP_ITEM_DEAGLE: "One-Hit Revolver"
SHOP_ITEM_DEAGLE_DESC: "A one-hit kill revolver with a single bullet. Aim carefully!"
@@ -7,7 +8,7 @@ SHOP_ITEM_DEAGLE_HIT_FF: "You hit a teammate!"
SHOP_ITEM_STICKERS: "Stickers"
SHOP_ITEM_STICKERS_DESC: "Reveal the roles of all players you taser to others."
SHOP_ITEM_STICKERS_HIT: "%PREFIX%You got stickered, your role is now visible to everyone."
SHOP_ITEM_STICKERS_HIT: "%SHOP_PREFIX%You got stickered, your role is now visible to everyone."
SHOP_ITEM_C4: "C4 Explosive"
SHOP_ITEM_C4_DESC: "A powerful explosive that blows up after a delay."
@@ -17,18 +18,23 @@ SHOP_ITEM_M4A1_DESC: "A fully automatic rifle with a silencer accompanied by a s
SHOP_ITEM_GLOVES: "Gloves"
SHOP_ITEM_GLOVES_DESC: "Lets you kill without DNA being left behind, or move bodies without identifying the body."
SHOP_ITEM_GLOVES_USED_BODY: "%PREFIX%You used your gloves to move a body without leaving DNA. ({yellow}{0}{grey}/{yellow}{1}{grey} use%s% left)."
SHOP_ITEM_GLOVES_USED_KILL: "%PREFIX%You used your gloves to kill without leaving DNA evidence. ({yellow}{0}{grey}/{yellow}{1}{grey} use%s% left)."
SHOP_ITEM_GLOVES_WORN_OUT: "%PREFIX%Your gloves worn out."
SHOP_ITEM_GLOVES_USED_BODY: "%SHOP_PREFIX%You used your gloves to move a body without leaving DNA. ({yellow}{0}{grey}/{yellow}{1}{grey} use%s% left)."
SHOP_ITEM_GLOVES_USED_KILL: "%SHOP_PREFIX%You used your gloves to kill without leaving DNA evidence. ({yellow}{0}{grey}/{yellow}{1}{grey} use%s% left)."
SHOP_ITEM_GLOVES_WORN_OUT: "%SHOP_PREFIX%Your gloves worn out."
SHOP_ITEM_TASER: "Taser"
SHOP_ITEM_TASER_DESC: "A taser that allows you to identify the roles of players you hit."
SHOP_INSUFFICIENT_BALANCE: "%PREFIX%You cannot afford {white}{0}{grey}, it costs {yellow}{1}{grey} credit%s%, and you have {yellow}{2}{grey}."
SHOP_CANNOT_PURCHASE: "%PREFIX%You cannot purchase this item."
SHOP_CANNOT_PURCHASE_WITH_REASON: "%PREFIX%You cannot purchase this item: {red}{0}{grey}."
SHOP_PURCHASED: "%PREFIX%You purchased {white}{0}{grey}."
SHOP_ITEM_HEALTHSHOT: "Healthshot"
SHOP_ITEM_HEALTHSHOT_DESC: "A healthshot that instantly heals you for 50 health."
SHOP_INSUFFICIENT_BALANCE: "%SHOP_PREFIX%You cannot afford {white}{0}{grey}, it costs {yellow}{1}{grey} %CREDITS_NAME%%s%, and you have {yellow}{2}{grey}."
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}."
CREDITS_NAME: "credit"
CREDITS_GIVEN: "%PREFIX%{0}{1} %CREDITS_NAME%%s%"
CREDITS_GIVEN_REASON: "%PREFIX%{0}{1} %CREDITS_NAME%%s% {grey}({white}{2}{grey})"
CREDITS_GIVEN: "%SHOP_PREFIX%{0}{1} %CREDITS_NAME%%s%"
CREDITS_GIVEN_REASON: "%SHOP_PREFIX%{0}{1} %CREDITS_NAME%%s% {grey}({white}{2}{grey})"
COMMAND_BALANCE: "%SHOP_PREFIX%You have {yellow}{0}{grey} %CREDITS_NAME%%s%."

View File

@@ -3,7 +3,7 @@ using System.Drawing;
namespace ShopAPI.Configs;
public record BodyPaintConfig : ShopItemConfig {
public override int Price { get; init; } = 60;
public override int Price { get; init; } = 40;
public int MaxUses { get; init; } = 1;
public Color ColorToApply { get; init; } = Color.GreenYellow;
}

View File

@@ -2,5 +2,5 @@ namespace ShopAPI.Configs;
public record CamoConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public float CamoVisibility { get; init; } = 0.5f;
public float CamoVisibility { get; init; } = 0.4f;
}

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs.Detective;
public record DnaScannerConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public override int Price { get; init; } = 120;
public int MaxSamples { get; init; } = 0;
public TimeSpan DecayTime { get; init; } = TimeSpan.FromSeconds(10);
}

View File

@@ -5,12 +5,14 @@ namespace ShopAPI.Configs.Detective;
public record HealthStationConfig : StationConfig {
public override string UseSound { get; init; } = "sounds/buttons/blip1";
public override int Price { get; init; } = 60;
public override Color GetColor(float health) {
// 100% health = white
// 10% health = green
var r = (int)(255 * (1 - health)); // goes from 255 → 0
var g = 255; // stays at 255
var b = (int)(255 * (1 - health)); // goes from 255 → 0
// 10% health = blue
var r = (int)(255 * health);
var g = (int)(255 * health);
var b = 255;
return Color.FromArgb(r, g, b);
}
}

View File

@@ -1,5 +0,0 @@
namespace ShopAPI.Configs.Detective;
public record StickerConfig : ShopItemConfig {
public override int Price { get; init; } = 70;
}

View File

@@ -0,0 +1,5 @@
namespace ShopAPI.Configs.Detective;
public record StickersConfig : ShopItemConfig {
public override int Price { get; init; } = 30;
}

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs;
public record M4A1Config : ShopItemConfig {
public override int Price { get; init; } = 90;
public override int Price { get; init; } = 85;
public int[] ClearSlots { get; init; } = [0, 1];
public string[] Weapons { get; init; } = ["m4a1", "usps"];
}

View File

@@ -27,9 +27,15 @@ public record ShopConfig(IRoleAssigner assigner) {
public int CreditsForDetectiveVInnoKill { get; init; } = -6;
public int CreditsForDetectiveVTraitorKill { get; init; } = 8;
public int CreditsForAnyKill { get; init; } = 2;
public float CreditMultiplierForAssisting { get; init; } = 0.5f;
public float CreditsMultiplierForNotAssisted { get; init; } = 1.5f;
public TimeSpan CreditRewardInterval { get; init; } =
TimeSpan.FromSeconds(30);
public int IntervalRewardAmount { get; init; } = 8;
public virtual int CreditsForKill(IOnlinePlayer attacker,
IOnlinePlayer victim) {
var attackerRole = assigner.GetRoles(attacker)

View File

@@ -1,13 +1,11 @@
using System.Drawing;
using ShopAPI.Configs;
namespace ShopAPI;
namespace ShopAPI.Configs;
public abstract record StationConfig : ShopItemConfig {
public override int Price { get; init; }
public virtual int HealthIncrements { get; init; } = 5;
public virtual int TotalHealthGiven { get; init; } = 200;
public virtual int StationHealth { get; init; } = 100;
public virtual int TotalHealthGiven { get; init; } = 0;
public virtual int StationHealth { get; init; } = 1000;
public virtual float MaxRange { get; init; } = 256;
public virtual TimeSpan HealthInterval { get; init; } =

View File

@@ -1,6 +1,6 @@
namespace ShopAPI.Configs;
public record TaserConfig : ShopItemConfig {
public override int Price { get; init; } = 120;
public override int Price { get; init; } = 100;
public string Weapon { get; init; } = "taser";
}

View File

@@ -3,17 +3,19 @@
namespace ShopAPI.Configs.Traitor;
public record DamageStationConfig : StationConfig {
public override int HealthIncrements { get; init; } = -15;
public override int TotalHealthGiven { get; init; } = -300;
public override int HealthIncrements { get; init; } = -20;
public override int TotalHealthGiven { get; init; } = -3000;
public override string UseSound { get; init; } = "sounds/buttons/blip2";
public override int Price { get; init; } = 65;
public override Color GetColor(float health) {
// 101% health = white
// 100% health = white
// 10% health = red
var r = 255; // stays at 255
var g = (int)(255 * (1 - health)); // goes from 255 → 0
var b = (int)(255 * (1 - health)); // goes from 255 → 0
var r = 255;
var g = (int)(255 * health);
var b = (int)(255 * health);
return Color.FromArgb(r, g, b);
}
}

View File

@@ -1,6 +1,6 @@
namespace ShopAPI.Configs.Traitor;
public record GlovesConfig : ShopItemConfig {
public override int Price { get; init; }
public override int Price { get; init; } = 65;
public int MaxUses { get; init; } = 3;
}

View File

@@ -0,0 +1,10 @@
namespace ShopAPI.Configs.Traitor;
public record PoisonConfig {
public TimeSpan TimeBetweenDamage { get; init; } = TimeSpan.FromSeconds(2.5);
public int DamagePerTick { get; init; } = 5;
public int TotalDamage { get; init; } = 60;
public string PoisonSound { get; init; } =
"sounds/player/player_damagebody_03";
}

View File

@@ -3,14 +3,8 @@ using System.Drawing;
namespace ShopAPI.Configs.Traitor;
public record PoisonShotsConfig : ShopItemConfig {
public override int Price { get; init; } = 30;
public TimeSpan TimeBetweenDamage { get; init; } = TimeSpan.FromSeconds(2);
public int DamagePerTick { get; init; } = 5;
public int TotalDamage { get; init; } = 50;
public override int Price { get; init; } = 65;
public int TotalShots { get; init; } = 3;
public string PoisonSound { get; init; } =
"sounds/player/player_damagebody_03";
public Color PoisonColor { get; init; } = Color.FromArgb(128, Color.Purple);
public PoisonConfig PoisonConfig { get; init; } = new();
}

View File

@@ -0,0 +1,12 @@
namespace ShopAPI.Configs.Traitor;
public record PoisonSmokeConfig : ShopItemConfig {
public override int Price { get; init; } = 30;
public string Weapon { get; init; } = "smoke";
public float SmokeRadius { get; init; } = 180;
public PoisonConfig PoisonConfig { get; init; } =
new() { DamagePerTick = 20, TotalDamage = 500 };
}

View File

@@ -0,0 +1,8 @@
using ShopAPI.Configs;
namespace ShopAPI;
public record HealthshotConfig : ShopItemConfig {
public override int Price { get; init; } = 25;
public string Weapon { get; init; } = "weapon_healthshot";
}

View File

@@ -3,6 +3,8 @@ using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game.Roles;
using TTT.Shop.Listeners;
using Xunit;
@@ -17,6 +19,9 @@ public class BalanceClearTest(IServiceProvider provider) {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[Fact]
@@ -30,9 +35,29 @@ public class BalanceClearTest(IServiceProvider provider) {
var game = games.CreateGame();
game?.Start();
game?.EndGame();
var newBalance = await shop.Load(player);
Assert.Equal(0, newBalance);
}
[Fact]
public async Task RoleAssignCreditor_ShouldNotBeOverriden_OnGameStart() {
bus.RegisterListener(new RoleAssignCreditor(provider));
bus.RegisterListener(new RoundShopClearer(provider));
var player = TestPlayer.Random();
finder.AddPlayer(player);
finder.AddPlayer(TestPlayer.Random());
var game = games.CreateGame();
game?.Start();
var newBalance = await shop.Load(player);
var expected = 100;
if (roles.GetRoles(player).Any(r => r is TraitorRole)) expected = 120;
Assert.Equal(expected, newBalance);
}
}

View File

@@ -1,14 +1,13 @@
using TTT.API.Player;
using TTT.API.Role;
namespace TTT.Test;
public class TestPlayer(string id, string name) : IOnlinePlayer {
public List<string> Messages { get; } = [];
[Obsolete(
"Roles are now managed via IRoleAssigner. Use IRoleAssigner.GetRoles(IPlayer) instead.")]
public ICollection<IRole> Roles { get; } = [];
// [Obsolete(
// "Roles are now managed via IRoleAssigner. Use IRoleAssigner.GetRoles(IPlayer) instead.")]
// public ICollection<IRole> Roles { get; } = [];
public string Id { get; } = id;
public string Name { get; } = name;