Compare commits

...

41 Commits

Author SHA1 Message Date
MSWS
41c7a788d3 refactor: Refactor codebase for cleanup and async improvements
```
Refactor and optimize multiple components

- Remove obsolete attributes and unused dependencies in `IEventBus.cs`, `KarmaListener.cs`, `KarmaSyncer.cs`, and `ICommandManager.cs` to clean up code and improve maintainability.
- Enhance asynchronous event handling in `EventModifiedMessenger.cs` by integrating `await` for dispatch operations, improving consistency in message processing.
- Refine `CombatHandler.cs` logic with improved null-checking and better statistics handling, enhancing robustness during player events.
- Enable caching by default in `KarmaStorage.cs`, and update karmas asynchronously to boost performance.
- Simplify `Game/RoundBasedGame.cs` by removing unnecessary dependencies and improving state management and role assignment logic, enhancing game flow control.
```
2025-10-04 08:56:58 -07:00
MSWS
5d37e5d1ec feat: Add One-Hit Knife to Traitor shop +semver:minor (resolves #99)
- Add new shop item "One-Hit Knife" with description in `en.yml`
- Introduce `OneHitKnifeMsgs` class for handling localization messages related to "One-Hit Knife" item
- Implement `OneHitKnifeListener` class to handle game events for the "One-Hit Knife" item functionality
- Create `OneHitKnife` class, extending the item functionality for the `TraitorRole` with service integration
- Update `ShopServiceCollection.cs` to include "One-Hit Knife" in the item collection and shop services
- Add `OneHitKnifeConfig.cs` to define configuration settings including price and friendly fire options for the "One Hit Knife" feature
2025-10-04 08:40:19 -07:00
MSWS
741f2b8586 feat: Implement karma system enhancement features (resolves #47)
Implement enhanced Karma system functionalities and refactorings

- Update `KarmaConfig.cs` to make properties non-virtual and add immutability with "init" accessors, and introduce new configuration properties to support additional karma functionalities.
- Add `KarmaSyncer` behavior to `CS2ServiceCollection.cs` for managing Karma-related synchronization tasks.
- Enhance `SpectatorRole.cs` with an `OnAssign` method to facilitate role assignments and automatically convert players to spectators when needed.
- Filter player assignments in `RoundBasedGame.cs` to include only specific roles and refine non-traitor calculations to only consider assigned players.
- Introduce a comprehensive `KarmaSyncer` class in `Listeners/KarmaSyncer.cs` for managing Karma synchronization, including event handling for Karma updates and safe operations checks.
- Refactor `CS2KarmaConfig.cs` by simplifying structure, adding descriptive updates, and altering command usages related to Karma configurations.
- Remove `KarmaSyncer.cs` from `GameHandlers` to accommodate code restructuring and potential modality changes for player data management.
- Refactor and expand methods in `KarmaMsgs.cs` to adopt simpler expression-bodied syntax and create structured warning messages.
- Introduce dependency checks and reward temporal conditions in `PeriodicRewarder.cs` to ensure balanced issuance aligns with game progression.
- Enhance `KarmaBanner.cs` with new dependencies, tracking mechanisms, and advanced event handling for role assignments and karma-related notifications.
- Improve `KarmaListener.cs` by changing class inheritance, adding debugging capabilities, and switching to batch processing for Karma updates.
- Extend language file `en.yml` with additional warning messages targeting users with critically low Karma levels.
2025-10-04 08:27:21 -07:00
Isaac
e08cad21b3 Add remaining karma services / implementations (#102) 2025-10-04 07:09:34 -07:00
MSWS
ff4c8e76ff Reformat & Cleanup 2025-10-04 07:05:31 -07:00
MSWS
e8bb8564ad feat: Introduce Karma feature enhancements and .NET 8 update
```
- Update TTT/Plugin/Plugin.csproj to target .NET 8.0 and add Microsoft.Data.Sqlite reference
- Enhance TTT/CS2/CS2ServiceCollection.cs with new KarmaBanner mod behavior
- Add TTT/Karma/lang/KarmaMsgs.cs for Karma language messages handling
- Introduce configuration command in TTT/CS2/Configs/CS2KarmaConfig.cs for low karma actions
- Add old karma value property in TTT/Karma/Events/KarmaUpdateEvent.cs
- Implement KarmaBanner class in TTT/CS2/Listeners/KarmaBanner.cs for monitoring karma updates
- Develop new KarmaCommand class in TTT/Karma/KarmaCommand.cs for retrieving karma values
- Enhance TTT/Karma/KarmaStorage.cs with cache support and better SQL handling
- Introduce NameDisplayer OS check in TTT/CS2/GameHandlers/NameDisplayer.cs
- Add CommandUponLowKarma configuration in TTT/Karma/KarmaConfig.cs
- Register KarmaCommand in TTT/Karma/KarmaServiceCollection.cs
- Create English language file TTT/Karma/lang/en.yml for Karma messages
```
2025-10-04 07:00:08 -07:00
MSWS
58cb208c1d feat: Add Karma configuration and async event handling +semver:minor
```
Implement Karma Configuration and Improve Event Handling

- Add `IStorage<KarmaConfig>` service registration in `CS2ServiceCollection`, enhancing support for karma configurations.
- Make `KarmaUpdateEvent` dispatch asynchronous in `KarmaStorage`, and improve error checking for database connections.
- Introduce `CS2KarmaConfig`, a new configuration class with settings for managing karma, including connection strings and value limits.
- Convert karma properties and methods to virtual in `KarmaConfig`, allowing for potential overrides in derived classes.
```
2025-10-04 06:03:34 -07:00
MSWS
a546a8b22e feat: Migrate to SQLite and cleanup KarmaSyncer logic +semver:minor
- Update `Karma.csproj` to include Microsoft.Data.Sqlite and SQLite packages.
- Refactor `KarmaSyncer.cs` by removing the unused `IPlayerFinder` initialization, adding the `UsedImplicitly` attribute, and streamlining player validation logic.
- Change database backend in `KarmaStorage.cs` from MySQL to SQLite.
- Define a default value for the `DbString` property in `KarmaConfig.cs`.
- Adjust service configuration in `KarmaServiceCollection.cs` by modifying `IKarmaService` registration and introducing `KarmaListener`.
2025-10-04 05:58:49 -07:00
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
MSWS
c223f3994b feat: Add Taser item +semver:minor (resolves #69)
```
- Integrate Taser functionality into the Shop module by updating the `ShopServiceCollection.cs` to include the Taser item in the shop services.
- Add a new `TaserConfig` file to define default configurations for the taser item in `TTT/ShopAPI/Configs`.
- Update `en.yml` to include localization for the new Taser item with its description.
- Introduce `CS2TaserConfig` for advanced configuration management of taser items within the CS2 module, and integrate it into `CS2ServiceCollection.cs`.
- Implement the `TaserItem` class in `TTT/Shop/Items/Taser` along with the supporting `TaserMsgs` class for handling messages related to the taser in the shop.
```
2025-10-03 21:47:59 -07:00
MSWS
8d0b4878f1 feat: Add armor item (resolves #67)
```plaintext
Add comprehensive armor functionalities and improvements

- Introduce `ArmorConfig` class to configure shop items with default armor and helmet settings.
- Create `ArmorMsgs` class to handle message creation for armor-related items in the game.
- Enhance `PlayerExtensions` with new `SetArmor` method and improve `SetColor` handling and state management.
- Register armor services within `ShopServiceCollection` to integrate armor features into the shop framework.
- Refactor `RoundTimerListener` for improved readability and consistency in round-ending logic.
- Include "Armor with Helmet" in the language file with its description for better item localization.
- Develop `ArmorItem` class to manage armor items, incorporating configuration, purchase logic, and localization.
```
2025-10-03 21:38:37 -07:00
MSWS
b1155a18ba feat: Enhance poison shot effects and player feedback +semver:minor
- Enhance `PoisonShotsListener.cs` by adding utility functions, refining weapon event handling, and improving player feedback with configurable effects and sounds.
- Update `PoisonShotMsgs.cs` to include personalized messages for poison hit events by leveraging the player's name.
- Refactor `GiveItemCommand.cs` for improved targeting and async item delivery, adding clearer command usage instructions.
- Add a new localization message in `en.yml` for poison shot hit feedback.
- Modify `PoisonShotsConfig.cs` to adjust damage interval and introduce configurable sound and color for poison effects.
2025-10-03 21:22:21 -07:00
MSWS
c134289990 Add locale for running out of shots 2025-10-03 21:10:16 -07:00
MSWS
d8323f4c64 fix: Body Paint and Poison Items
- Remove event handler for item purchases in `BodyPaintListener` and enhance logic for body identification and use tracking
- Integrate `IPluginModule` interface in `PoisonShotsListener` and refactor poison shot handling to improve inventory management and dependency injection
- Update `PoisonShotsItem` to use specific configuration and storage mechanisms
- Register PoisonShots service in `ShopServiceCollection` to expand shop functionalities
2025-10-03 21:06:15 -07:00
MSWS
a1f5c27660 Register poision shots, fix m4a1 internal weapon 2025-10-03 20:34:54 -07:00
MSWS
8ed094b8fc Remerge main 2025-10-03 00:44:11 -07:00
MSWS
07fc47803d Update prompt again 2025-10-03 00:42:47 -07:00
MSWS
c7b5460b35 Update CONTRIBUTING.md 2025-10-03 00:03:08 -07:00
MSWS
afe44097c3 Change to AGPL License, update README 2025-10-02 23:58:13 -07:00
MSWS
63c4e9b7d8 Update prompt again 2025-10-01 21:49:59 -07:00
MSWS
e317e9418e Include subsequent merge info when generating release notes 2025-10-01 21:46:48 -07:00
Isaac
e39b19930c Additional Shop Item Implementations 2025-10-01 20:40:16 -07:00
109 changed files with 1548 additions and 344 deletions

View File

@@ -158,7 +158,7 @@ jobs:
# Build the JSON body. We feed system guidance and the raw changelog
# See OpenAI Responses API docs for the schema and output_text helper. :contentReference[oaicite:0]{index=0}
jq -Rs --arg sys "You are an expert release-notes writer. Given a list of changes in various formats (e.g: commits, merges, etc.), write Release notes, grouping by features, features, and other pertinent groups where appropriate. Do not include a group if it is not necessary / populated. Remove internal ticket IDs and commit hashes unless essential. Merge duplicates. Use imperative, past tense voice voice. Output valid Markdown only." \
jq -Rs --arg sys "You are an expert release-notes writer. Given a list of changes in various formats (e.g: commits, merges, etc.), write release notes intended for reading by the public, grouping by features, features, and other pertinent groups where appropriate. Do not include a group if it is not necessary / populated. Remove internal ticket IDs and commit hashes unless essential. Merge duplicates. Use imperative, past tense voice voice. Output valid Markdown only." \
--arg temp "${OPENAI_TEMPERATURE}" \
--arg model "${OPENAI_MODEL}" \
'{model:$model, temperature: ($temp|tonumber), input:[{role:"system", content:$sys},{role:"user", content:.}]}' CHANGELOG_RAW.md > request.json

View File

@@ -43,7 +43,7 @@ public partial class StringLocalizer : IMsgLocalizer {
private LocalizedString getString(string name, params object[] arguments) {
// Get the localized value
string value;
try { value = localizer[name].Value; } catch (NullReferenceException e) {
try { value = localizer[name].Value; } catch (NullReferenceException) {
return new LocalizedString(name, name, true);
}

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

@@ -12,7 +12,6 @@ public interface ICommandManager {
/// Registers a command with the manager.
/// </summary>
/// <param name="command">True if the command was successfully registered.</param>
[Obsolete("Registration is done via the ServiceProvider now.")]
bool RegisterCommand(ICommand command);
/// <summary>

View File

@@ -1,7 +1,6 @@
namespace TTT.API.Events;
public interface IEventBus {
[Obsolete("Registration should be done via the ServiceProvider")]
void RegisterListener(IListener listener);
void UnregisterListener(IListener listener);

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

@@ -21,6 +21,7 @@ using TTT.CS2.lang;
using TTT.CS2.Listeners;
using TTT.CS2.Player;
using TTT.Game;
using TTT.Karma;
using TTT.Locale;
namespace TTT.CS2;
@@ -34,6 +35,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>();
@@ -42,6 +44,10 @@ public static class CS2ServiceCollection {
.AddModBehavior<IStorage<OneShotDeagleConfig>, CS2OneShotDeagleConfig>();
collection.AddModBehavior<IStorage<C4Config>, CS2C4Config>();
collection.AddModBehavior<IStorage<M4A1Config>, CS2M4A1Config>();
collection.AddModBehavior<IStorage<TaserConfig>, CS2TaserConfig>();
collection
.AddModBehavior<IStorage<PoisonSmokeConfig>, CS2PoisonSmokeConfig>();
collection.AddModBehavior<IStorage<KarmaConfig>, CS2KarmaConfig>();
// TTT - CS2 Specific optionals
collection.AddScoped<ITextSpawner, TextSpawner>();
@@ -52,8 +58,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>();
@@ -66,6 +72,8 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<PlayerStatsTracker>();
collection.AddModBehavior<RoundTimerListener>();
collection.AddModBehavior<ScreenColorApplier>();
collection.AddModBehavior<KarmaBanner>();
collection.AddModBehavior<KarmaSyncer>();
// Commands
#if DEBUG

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

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Player;
@@ -6,12 +7,16 @@ using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class GiveItemCommand(IServiceProvider provider) : ICommand {
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
public void Start() { }
public string Id => "giveitem";
public string[] Usage => ["[item] <player>"];
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
@@ -19,15 +24,29 @@ public class GiveItemCommand(IServiceProvider provider) : ICommand {
if (info.ArgCount == 1) return Task.FromResult(CommandResult.PRINT_USAGE);
var query = string.Join(" ", info.Args.Skip(1));
var query = info.Args[1];
var item = searchItem(query);
if (item == null) {
info.ReplySync($"Item '{query}' not found.");
return Task.FromResult(CommandResult.ERROR);
}
shop.GiveItem(executor, item);
info.ReplySync($"Gave item '{item.Name}' to {executor.Name}.");
var target = executor;
Server.NextWorldUpdateAsync(() => {
if (info.ArgCount == 3) {
var result = finder.GetPlayerByName(info.Args[2]);
if (result == null) {
info.ReplySync($"Player '{info.Args[2]}' not found.");
return;
}
target = result;
}
shop.GiveItem(target, item);
info.ReplySync($"Gave item '{item.Name}' to {target.Name}.");
});
return Task.FromResult(CommandResult.SUCCESS);
}

View File

@@ -1,15 +1,10 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class IndexCommand(IServiceProvider provider) : ICommand {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public class IndexCommand : ICommand {
public string Id => "index";
public void Dispose() { }

View File

@@ -21,7 +21,7 @@ public class TestCommand(IServiceProvider provider) : ICommand, IPluginModule {
subCommands.Add("state", new StateCommand(provider));
subCommands.Add("screencolor", new ScreenColorCommand(provider));
subCommands.Add("giveitem", new GiveItemCommand(provider));
subCommands.Add("index", new IndexCommand(provider));
subCommands.Add("index", new IndexCommand());
subCommands.Add("showicons", new ShowIconsCommand(provider));
subCommands.Add("sethealth", new SetHealthCommand());
}

View File

@@ -0,0 +1,59 @@
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;
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",
"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));
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));
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!");
public static readonly FakeConVar<int> CV_KARMA_TIMEOUT_THRESHOLD = new(
"css_ttt_karma_timeout_threshold",
"Minimum Karma to avoid punishment or timeout effects", 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",
4, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 100));
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
ArgumentNullException.ThrowIfNull(plugin, nameof(plugin));
plugin.RegisterFakeConVars(this);
}
public Task<KarmaConfig?> Load() {
var cfg = new KarmaConfig {
DbString = CV_DB_STRING.Value,
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
};
return Task.FromResult<KarmaConfig?>(cfg);
}
}

View File

@@ -0,0 +1,38 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Cvars.Validators;
using ShopAPI.Configs;
using TTT.API;
using TTT.API.Storage;
using TTT.CS2.Validators;
namespace TTT.CS2.Configs;
public class CS2TaserConfig : IStorage<TaserConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_taser_price", "Price of the Taser item", 120,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<string> CV_WEAPON = new(
"css_ttt_shop_taser_weapon", "Weapon entity name used for the Taser",
"weapon_taser", ConVarFlags.FCVAR_NONE,
new ItemValidator(allowMultiple: false));
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
ArgumentNullException.ThrowIfNull(plugin, nameof(plugin));
plugin.RegisterFakeConVars(this);
}
public Task<TaserConfig?> Load() {
var cfg = new TaserConfig {
Price = CV_PRICE.Value, Weapon = CV_WEAPON.Value
};
return Task.FromResult<TaserConfig?>(cfg);
}
}

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

@@ -21,7 +21,7 @@ public class CS2M4A1Config : IStorage<M4A1Config>, IPluginModule {
public static readonly FakeConVar<string> CV_WEAPONS = new(
"css_ttt_shop_m4a1_weapons",
"Weapons granted with this item (comma-separated names)",
"weapon_m4a1,weapon_usp_silencer", ConVarFlags.FCVAR_NONE,
"weapon_m4a1_silencer,weapon_usp_silencer", ConVarFlags.FCVAR_NONE,
new ItemValidator(allowMultiple: true));
public void Dispose() { }

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

@@ -61,6 +61,19 @@ public static class PlayerExtensions {
pawn.SetColor(color);
}
public static void SetArmor(this CCSPlayerController player, int armor,
bool withHelmet = false) {
if (!player.IsValid) return;
var pawn = player.PlayerPawn.Value;
if (pawn == null || !pawn.IsValid) return;
pawn.ArmorValue = armor;
if (withHelmet)
if (pawn.ItemServices != null)
new CCSPlayer_ItemServices(pawn.ItemServices.Handle).HasHelmet = true;
Utilities.SetStateChanged(pawn, "CCSPlayerPawn", "m_ArmorValue");
}
public static void ColorScreen(this CCSPlayerController player, Color color,
float hold = 0.1f, float fade = 0.2f, FadeFlags flags = FadeFlags.FADE_IN,
bool withPurge = true) {

View File

@@ -0,0 +1,17 @@
using CounterStrikeSharp.API.Core;
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,11 +62,16 @@ public class CombatHandler(IServiceProvider provider) : IPluginModule {
}
var killerStats = ev.Attacker?.ActionTrackingServices?.MatchStats;
if (killerStats == null) return;
killerStats.Kills -= 1;
killerStats.Damage -= ev.DmgHealth;
if (ev.Attacker != null) {
Utilities.SetStateChanged(ev.Attacker,
"CCSPlayerController_ActionTrackingServices",
"m_pActionTrackingServices");
if (killerStats == null) return;
killerStats.Kills -= 1;
killerStats.Damage -= ev.DmgHealth;
if (ev.Attacker.ActionTrackingServices != null)
ev.Attacker.ActionTrackingServices.NumRoundKills--;
Utilities.SetStateChanged(ev.Attacker, "CCSPlayerController",
"m_pActionTrackingServices");
ev.FireEventToClient(ev.Attacker);

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

@@ -1,47 +0,0 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Player;
using TTT.Karma;
namespace TTT.CS2.GameHandlers;
public class KarmaSyncer(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IKarmaService? karma = provider.GetService<IKarmaService>();
private readonly IPlayerFinder players =
provider.GetRequiredService<IPlayerFinder>();
public void Dispose() { }
public string Id => nameof(KarmaSyncer);
public string Version => GitVersionInformation.FullSemVer;
public void Start() { }
[GameEventHandler]
public HookResult OnRoundStart(EventRoundStart _, GameEventInfo _1) {
if (karma == null) return HookResult.Continue;
foreach (var p in Utilities.GetPlayers()) {
if (!p.IsValid || p.IsBot) continue;
var apiPlayer = converter.GetPlayer(p);
Task.Run(async () => {
var pk = await karma.Load(apiPlayer);
await Server.NextFrameAsync(() => {
p.Score = pk;
Utilities.SetStateChanged(p, "CCSPlayerController",
"m_pActionTrackingServices");
});
});
}
return HookResult.Continue;
}
}

View File

@@ -0,0 +1,35 @@
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) {
if (OperatingSystem.IsWindows()) return;
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

@@ -27,8 +27,6 @@ public class RoleIconsHandler(IServiceProvider provider)
private static readonly string T_MODEL =
"characters/models/tm_phoenix/tm_phoenix.vmdl";
// private readonly IDictionary<int, IEnumerable<CPointWorldText>> icons =
// new Dictionary<int, IEnumerable<CPointWorldText>>();
private readonly IEnumerable<CPointWorldText>?[] icons =
new IEnumerable<CPointWorldText>[64];
@@ -79,8 +77,10 @@ public class RoleIconsHandler(IServiceProvider provider)
plugin
?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.CheckTransmit>(
onTransmit);
if (hotReload) OnRoundEnd(null!, null!);
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnRoundEnd(EventRoundStart _, GameEventInfo _1) {
foreach (var text in Utilities
@@ -93,7 +93,7 @@ public class RoleIconsHandler(IServiceProvider provider)
[UsedImplicitly]
[EventHandler(IgnoreCanceled = true)]
public void OnRoundStart(GameStateUpdateEvent ev) {
if (ev.NewState != State.IN_PROGRESS) return;
if (ev.NewState != State.FINISHED) return;
for (var i = 0; i < icons.Length; i++) removeIcon(i);
ClearAllVisibility();
traitorsThisRound.Clear();
@@ -163,6 +163,7 @@ public class RoleIconsHandler(IServiceProvider provider)
icons[player.Slot] = roleIcon;
}
[UsedImplicitly]
[EventHandler(Priority = Priority.MONITOR)]
public void OnDeath(PlayerDeathEvent ev) {
var gamePlayer = players.GetPlayer(ev.Victim);

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

@@ -0,0 +1,42 @@
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
namespace TTT.CS2.Items.Armor;
public static class ArmorItemServicesCollection {
public static void AddArmorServices(this IServiceCollection collection) {
collection.AddModBehavior<ArmorItem>();
}
}
public class ArmorItem(IServiceProvider provider) : BaseItem(provider) {
private readonly ArmorConfig config = provider
.GetService<IStorage<ArmorConfig>>()
?.Load()
.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;
public override void OnPurchase(IOnlinePlayer player) {
var gamePlayer = converter.GetPlayer(player);
if (gamePlayer == null) return;
gamePlayer.SetArmor(config.Armor, config.Helmet);
}
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return PurchaseResult.SUCCESS;
}
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.CS2.Items.Armor;
public class ArmorMsgs {
public static IMsg SHOP_ITEM_ARMOR
=> MsgFactory.Create(nameof(SHOP_ITEM_ARMOR));
public static IMsg SHOP_ITEM_ARMOR_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_ARMOR_DESC));
}

View File

@@ -5,7 +5,6 @@ using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
using TTT.Shop.Items.Traitor.BodyPaint;
namespace TTT.CS2.Items.BodyPaint;

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;
@@ -10,35 +9,26 @@ using TTT.CS2.API;
using TTT.CS2.Extensions;
using TTT.Game.Events.Body;
using TTT.Game.Listeners;
using TTT.Shop.Items.Traitor.BodyPaint;
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]
public void OnPurchase(PlayerPurchaseItemEvent ev) {
if (ev.Item is not BodyPaintItem) return;
if (ev.Player is not IOnlinePlayer online) return;
uses.TryAdd(online, 0);
uses[online] += config.MaxUses;
}
[UsedImplicitly]
[EventHandler]
[EventHandler(Priority = Priority.HIGH)]
public void BodyIdentify(BodyIdentifyEvent ev) {
if (!bodies.Bodies.TryGetValue(ev.Body, out var body)) return;
if (ev.Identifier == null || !usePaint(ev.Identifier)) return;
@@ -48,13 +38,17 @@ public class BodyPaintListener(IServiceProvider provider)
private bool usePaint(IPlayer player) {
if (player is not IOnlinePlayer online) return false;
if (!uses.TryGetValue(player, out var useCount)) return false;
if (!uses.ContainsKey(player)) {
if (!shop.HasItem<BodyPaintItem>(online)) return false;
uses[player] = config.MaxUses;
}
if (useCount <= 0) return false;
uses[player] = useCount - 1;
if (uses[player] <= 0) return false;
uses[player]--;
if (uses[player] > 0) return true;
shop.RemoveItem<BodyPaintItem>(online);
Messenger.Message(online, Locale[BodyPaintMsgs.SHOP_ITEM_BODY_PAINT_OUT]);
uses.Remove(player);
return true;
}
}

View File

@@ -1,6 +1,6 @@
using TTT.Locale;
namespace TTT.Shop.Items.Traitor.BodyPaint;
namespace TTT.CS2.Items.BodyPaint;
public class BodyPaintMsgs {
public static IMsg SHOP_ITEM_BODY_PAINT
@@ -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

@@ -0,0 +1,40 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
namespace TTT.CS2.Items.OneHitKnife;
public static class OneHitKnifeServiceCollection {
public static void AddOneHitKnifeService(this IServiceCollection services) {
services.AddSingleton<OneHitKnife>();
services.AddSingleton<OneHitKnifeListener>();
}
}
public class OneHitKnife(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly OneHitKnifeConfig config = provider
.GetService<IStorage<OneHitKnifeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
public override string Name
=> Locale[OneHitKnifeMsgs.SHOP_ITEM_ONE_HIT_KNIFE];
public override string Description
=> Locale[OneHitKnifeMsgs.SHOP_ITEM_ONE_HIT_KNIFE_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) { }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
if (Shop.HasItem<OneHitKnife>(player)) return PurchaseResult.ALREADY_OWNED;
return base.CanPurchase(player);
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs.Traitor;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
namespace TTT.CS2.Items.OneHitKnife;
public class OneHitKnifeListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly OneHitKnifeConfig config =
provider.GetService<IStorage<OneHitKnifeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
[EventHandler]
public void OnDamage(PlayerDamagedEvent ev) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
if (ev.Weapon == null || !Tag.KNIVES.Contains(ev.Weapon)) return;
var attacker = ev.Attacker;
var victim = ev.Player;
if (attacker == null) return;
if (!shop.HasItem<OneHitKnife>(attacker)) return;
if (victim is not IOnlinePlayer onlineVictim) return;
var friendly = Roles.GetRoles(attacker)
.Any(r => Roles.GetRoles(victim).Contains(r));
if (friendly && !config.FriendlyFire) return;
shop.RemoveItem<OneHitKnife>(attacker);
onlineVictim.Health = 0;
}
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.CS2.Items.OneHitKnife;
public class OneHitKnifeMsgs {
public static IMsg SHOP_ITEM_ONE_HIT_KNIFE
=> MsgFactory.Create(nameof(SHOP_ITEM_ONE_HIT_KNIFE));
public static IMsg SHOP_ITEM_ONE_HIT_KNIFE_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_ONE_HIT_KNIFE_DESC));
}

View File

@@ -1,3 +1,4 @@
using TTT.API.Player;
using TTT.Locale;
namespace TTT.CS2.Items.PoisonShots;
@@ -8,4 +9,11 @@ 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_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,18 +1,35 @@
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.PoisonShots;
public static class PoisonShotServiceCollection {
public static void AddPoisonShots(this IServiceCollection services) {
services.AddModBehavior<PoisonShotsItem>();
services.AddModBehavior<PoisonShotsListener>();
}
}
public class PoisonShotsItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly PoisonShotsConfig config = provider
.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonShotsConfig();
public override string Name => Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS];
public override string Description
=> Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS_DESC];
public override ShopItemConfig Config { get; }
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) { }
}

View File

@@ -1,15 +1,17 @@
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 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;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
@@ -18,36 +20,41 @@ using TTT.Game.Listeners;
namespace TTT.CS2.Items.PoisonShots;
public class PoisonShotsListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly Dictionary<IPlayer, int> poisonShots = new();
: BaseListener(provider), IPluginModule {
private readonly PoisonShotsConfig config =
provider.GetRequiredService<PoisonShotsConfig>();
provider.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonShotsConfig();
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 = [];
[UsedImplicitly]
[EventHandler]
public void OnPurchase(PlayerPurchaseItemEvent ev) {
if (ev.Item is not PoisonShotsItem) return;
poisonShots.TryAdd(ev.Player, 0);
poisonShots[ev.Player] += config.TotalShots;
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 _) {
if (ev.Userid == null) return HookResult.Continue;
var player = converter.GetPlayer(ev.Userid);
if (!poisonShots.TryGetValue(player, out var shot) || shot <= 0)
if (!Tag.GUNS.Contains(ev.Weapon)) return HookResult.Continue;
if (converter.GetPlayer(ev.Userid) is not IOnlinePlayer player)
return HookResult.Continue;
Server.NextWorldUpdate(() => poisonShots[player]--);
var remainingShots = usePoisonShot(player);
if (remainingShots == 0)
Messenger.Message(player, Locale[PoisonShotMsgs.SHOP_ITEM_POISON_OUT]);
return HookResult.Continue;
}
@@ -57,7 +64,8 @@ public class PoisonShotsListener(IServiceProvider provider)
if (ev.Attacker == null) return;
if (!poisonShots.TryGetValue(ev.Attacker, out var shot) || shot <= 0)
return;
poisonShots[ev.Attacker]--;
Messenger.Message(ev.Attacker,
Locale[PoisonShotMsgs.SHOP_ITEM_POISON_HIT(ev.Player)]);
addPoisonEffect(ev.Player);
}
@@ -68,15 +76,21 @@ 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
if (!tickPoison(effect)) timer?.Dispose();
timer = scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
=> {
Server.NextWorldUpdate(() => {
if (tickPoison(effect) || timer == null) return;
timer.Dispose();
poisonTimers.Remove(timer);
});
});
poisonTimers.Add(timer);
@@ -85,23 +99,39 @@ 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(Color.Purple, 0.2f, 0.3f);
gamePlayer?.ColorScreen(config.PoisonColor, 0.2f, 0.3f);
gamePlayer?.ExecuteClientCommand("play " + config.PoisonConfig.PoisonSound);
return effect.DamageGiven < config.TotalDamage;
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonTimers) timer.Dispose();
/// <summary>
/// Uses a poison shot for the player. Returns the remaining shots, -1 if none
/// are available.
/// </summary>
/// <param name="player"></param>
/// <returns></returns>
private int usePoisonShot(IOnlinePlayer player) {
if (!poisonShots.TryGetValue(player, out var shot) || shot <= 0) {
if (!shop.HasItem<PoisonShotsItem>(player)) return -1;
poisonShots[player] = config.TotalShots;
}
poisonShots[player]--;
if (poisonShots[player] > 0) return poisonShots[player];
poisonShots.Remove(player);
shop.RemoveItem<PoisonShotsItem>(player);
return 0;
}
private class PoisonEffect(IPlayer player) {
public IPlayer Player { get; init; } = 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,118 @@
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 TTT.API;
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 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() { }
[UsedImplicitly]
[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,15 +9,15 @@ 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 {
private static readonly long PROP_SIZE_SQUARED = 500;
: RoleRestrictedItem<T>(provider), IPluginModule where T : IRole {
private readonly long PROP_SIZE_SQUARED = 500;
protected readonly StationConfig _Config = config;
protected readonly IPlayerConverter<CCSPlayerController> Converter =

View File

@@ -0,0 +1,74 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Roles;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Karma;
using TTT.Karma.Events;
using TTT.Karma.lang;
namespace TTT.CS2.Listeners;
public class KarmaBanner(IServiceProvider provider) : BaseListener(provider) {
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
private readonly IKarmaService karma =
provider.GetRequiredService<IKarmaService>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly Dictionary<IPlayer, DateTime> lastWarned = new();
private readonly Dictionary<IPlayer, int> cooldownRounds = new();
[UsedImplicitly]
[EventHandler(Priority = Priority.MONITOR, IgnoreCanceled = true)]
public void OnKarmaUpdate(KarmaUpdateEvent ev) {
if (ev.Karma < config.MinKarma) {
issueKarmaBan(ev.Player);
return;
}
if (ev.Karma >= config.KarmaTimeoutThreshold) return;
var timeSinceLastWarn = DateTime.UtcNow
- lastWarned.GetValueOrDefault(ev.Player, DateTime.MinValue);
if (timeSinceLastWarn <= config.KarmaWarningWindow) return;
issueKarmaWarning(ev.Player);
}
[UsedImplicitly]
[EventHandler(Priority = Priority.HIGH)]
public void OnRoleAssign(PlayerRoleAssignEvent ev) {
if (!cooldownRounds.TryGetValue(ev.Player, out var rounds) || rounds <= 0)
return;
Messenger.Message(ev.Player, Locale[KarmaMsgs.KARMA_WARNING(rounds)]);
cooldownRounds[ev.Player]--;
if (cooldownRounds[ev.Player] <= 0) cooldownRounds.Remove(ev.Player);
ev.Role = new SpectatorRole(Provider);
}
private void issueKarmaBan(IPlayer player) {
Server.NextWorldUpdate(() => {
var userId = converter.GetPlayer(player);
if (userId == null) return;
Server.ExecuteCommand(string.Format(config.CommandUponLowKarma,
userId.UserId));
Task.Run(async () => await karma.Write(player, config.DefaultKarma));
});
}
private void issueKarmaWarning(IPlayer player) {
cooldownRounds[player] = config.KarmaRoundTimeout;
lastWarned[player] = DateTime.UtcNow;
}
}

View File

@@ -0,0 +1,54 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Events;
using TTT.API.Player;
using TTT.Game.Listeners;
using TTT.Karma;
using TTT.Karma.Events;
namespace TTT.CS2.Listeners;
public class KarmaSyncer(IServiceProvider provider)
: BaseListener(provider), IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IKarmaService? karma = provider.GetService<IKarmaService>();
[UsedImplicitly]
[EventHandler]
public void OnKarmaUpdate(KarmaUpdateEvent ev) {
if (karma == null) return;
Server.NextWorldUpdate(() => {
var player = converter.GetPlayer(ev.Player);
if (player == null) return;
player.Score = ev.Karma;
Utilities.SetStateChanged(player, "CCSPlayerController", "m_iScore");
});
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnJoin(EventPlayerConnectFull ev, GameEventInfo _) {
if (ev.Userid == null || karma == null) return HookResult.Continue;
var player = converter.GetPlayer(ev.Userid);
var user = ev.Userid;
Task.Run(async () => {
var karmaValue = await karma.Load(player);
await Server.NextWorldUpdateAsync(() => {
if (!user.IsValid) return;
user.Score = karmaValue;
Utilities.SetStateChanged(user, "CCSPlayerController", "m_iScore");
});
});
return HookResult.Continue;
}
}

View File

@@ -3,7 +3,6 @@ using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.Game.Events.Body;
using TTT.Game.Events.Game;
@@ -18,9 +17,6 @@ public class PlayerStatsTracker(IServiceProvider provider) : IListener {
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
private readonly ISet<int> revealedDeaths = new HashSet<int>();
private readonly IDictionary<int, (int, int)> roundKillsAndAssists =

View File

@@ -58,19 +58,15 @@ public class RoundTimerListener(IServiceProvider provider)
if (ev.NewState == State.FINISHED) endTimer?.Dispose();
if (ev.NewState != State.IN_PROGRESS) return;
var duration = config.RoundCfg.RoundDuration(ev.Game.Players.Count);
Messenger.DebugAnnounce("Total duration: {0} for {1} player", duration,
ev.Game.Players.Count);
Server.NextWorldUpdate(() => {
RoundUtil.SetTimeRemaining((int)duration.TotalSeconds);
});
Server.NextWorldUpdate(()
=> RoundUtil.SetTimeRemaining((int)duration.TotalSeconds));
endTimer?.Dispose();
endTimer = scheduler.Schedule(duration, () => {
Server.NextWorldUpdate(() => {
Messenger.DebugAnnounce("Time is up!");
ev.Game.EndGame(EndReason.TIMEOUT(new InnocentRole(provider)));
endTimer = scheduler.Schedule(duration,
() => {
Server.NextWorldUpdate(()
=> ev.Game.EndGame(EndReason.TIMEOUT(new InnocentRole(Provider))));
});
});
}
[UsedImplicitly]

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

@@ -24,4 +24,11 @@ public class SpectatorRole(IServiceProvider provider) : IRole {
return players.FirstOrDefault(p
=> playerConverter.GetPlayer(p) is { Team: CsTeam.Spectator });
}
public void OnAssign(IOnlinePlayer player) {
var csPlayer = playerConverter.GetPlayer(player);
if (csPlayer is null) return;
csPlayer.CommitSuicide(false, true);
csPlayer.ChangeTeam(CsTeam.Spectator);
}
}

View File

@@ -21,4 +21,15 @@ SHOP_ITEM_BODY_PAINT_DESC: "Paint bodies to make them appear identified."
SHOP_ITEM_BODY_PAINT_OUT: "%PREFIX% You ran out of body paint."
SHOP_ITEM_POISON_SHOTS: "Poison Shots"
SHOP_ITEM_POISON_SHOTS_DESC: "Your bullets are coated in a mildly poisonous substance."
SHOP_ITEM_POISON_SHOTS_DESC: "Your bullets are coated in a mildly poisonous substance."
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."
SHOP_ITEM_ONE_HIT_KNIFE: "One-Hit Knife"
SHOP_ITEM_ONE_HIT_KNIFE_DESC: "Your next knife hit will be a guaranteed kill."

View File

@@ -15,7 +15,9 @@ public class IdentifyBodyAction(IRoleAssigner roles, BodyIdentifyEvent ev)
#endregion
public IPlayer Player { get; } = ev.Identifier;
public IPlayer Player { get; } =
ev.Identifier ?? throw new InvalidOperationException();
public IPlayer? Other { get; } = ev.Body.OfPlayer;
public IRole? PlayerRole { get; } =

View File

@@ -9,7 +9,6 @@ public class EventBus(IServiceProvider provider) : IEventBus, ITerrorModule {
private readonly Dictionary<Type, List<(object listener, MethodInfo method)>>
handlers = new();
[Obsolete("Registering listeners is deprecated, use DI instead.")]
public void RegisterListener(IListener listener) {
var dirtyTypes = new HashSet<Type>();
appendListener(listener, dirtyTypes);

View File

@@ -73,7 +73,7 @@ public abstract class EventModifiedMessenger(IServiceProvider provider)
PlayerMessageEvent ev) {
if (player == null) return await SendMessage(null, msg);
Bus.Dispatch(ev);
await Bus.Dispatch(ev);
if (ev.IsCanceled) return false;
return await SendMessage(player, ev.Message, ev.Args);

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

@@ -1,8 +1,6 @@
using JetBrains.Annotations;
using TTT.API.Events;
using TTT.API.Role;
using TTT.Game.Events.Player;
using TTT.Game.Roles;
namespace TTT.Game.Listeners;
@@ -19,38 +17,4 @@ public class PlayerCausesEndListener(IServiceProvider provider)
public void OnLeave(PlayerLeaveEvent ev) {
Games.ActiveGame?.CheckEndConditions();
}
private bool getWinningTeam(out IRole? winningTeam) {
var game = Games.ActiveGame;
winningTeam = null;
if (game is null) return false;
var traitorRole =
game.Roles.First(r => r.GetType().IsAssignableTo(typeof(TraitorRole)));
var innocentRole =
game.Roles.First(r => r.GetType().IsAssignableTo(typeof(InnocentRole)));
var detectiveRole = game.Roles.First(r
=> r.GetType().IsAssignableTo(typeof(DetectiveRole)));
var traitorsAlive = game.GetAlive(typeof(TraitorRole)).Count;
var nonTraitorsAlive = game.GetAlive().Count - traitorsAlive;
var detectivesAlive = game.GetAlive(typeof(DetectiveRole)).Count;
switch (traitorsAlive) {
case 0 when nonTraitorsAlive == 0:
winningTeam = null;
return true;
case > 0 when nonTraitorsAlive == 0:
winningTeam = traitorRole;
return true;
case 0 when nonTraitorsAlive > 0:
winningTeam = nonTraitorsAlive == detectivesAlive ?
detectiveRole :
innocentRole;
return true;
default:
winningTeam = null;
return false;
}
}
}

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

@@ -22,9 +22,6 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
.GetAwaiter()
.GetResult() ?? new TTTConfig();
private readonly IInventoryManager inventory =
provider.GetRequiredService<IInventoryManager>();
protected readonly IMsgLocalizer Locale =
provider.GetRequiredService<IMsgLocalizer>();
@@ -172,17 +169,16 @@ 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);
players.AddRange(online.Where(p
=> RoleAssigner.GetRoles(p)
.Any(r => r is TraitorRole or DetectiveRole or InnocentRole)));
State = State.IN_PROGRESS;
var traitors = ((IGame)this).GetAlive(typeof(TraitorRole)).Count;
var nonTraitors = online.Count - traitors;
var nonTraitors = players.Count - traitors;
Messenger?.MessageAll(Locale[
GameMsgs.GAME_STATE_STARTED(traitors, nonTraitors)]);
}

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

@@ -7,6 +7,7 @@ namespace TTT.Karma.Events;
public class KarmaUpdateEvent(IPlayer player, int oldKarma, int newKarma)
: PlayerEvent(player), ICancelableEvent {
public override string Id => "karma.update";
public int OldKarma { get; set; } = oldKarma;
public int Karma { get; set; } = newKarma;
public bool IsCanceled { get; set; }
}

View File

@@ -13,7 +13,9 @@
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.66"/>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.9"/>
<PackageReference Include="MySqlConnector" Version="2.4.0"/>
<PackageReference Include="SQLite" Version="3.13.0"/>
</ItemGroup>
</Project>

30
TTT/Karma/KarmaCommand.cs Normal file
View File

@@ -0,0 +1,30 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
using TTT.Karma.lang;
using TTT.Locale;
namespace TTT.Karma;
public class KarmaCommand(IServiceProvider provider) : ICommand {
private readonly IKarmaService karma =
provider.GetRequiredService<IKarmaService>();
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
public void Dispose() { }
public void Start() { }
public string Id => "karma";
public async Task<CommandResult> Execute(IOnlinePlayer? executor,
ICommandInfo info) {
if (executor == null) return CommandResult.PLAYER_ONLY;
var value = await karma.Load(executor);
info.ReplySync(locale[KarmaMsgs.KARMA_COMMAND(value)]);
return CommandResult.SUCCESS;
}
}

View File

@@ -3,9 +3,17 @@ using TTT.API.Player;
namespace TTT.Karma;
public record KarmaConfig {
public string DbString { get; init; }
public string DbString { get; init; } = "Data Source=karma.db";
public int MinKarma { get; init; } = 0;
public int DefaultKarma { get; init; } = 50;
public string CommandUponLowKarma { get; init; } = "karmaban {0} Bad Player!";
public int MinKarma => 0;
public int DefaultKarma => 50;
public int MaxKarma(IPlayer player) { return 100; }
public int KarmaTimeoutThreshold { get; init; } = 20;
public int KarmaRoundTimeout { get; init; } = 4;
public TimeSpan KarmaWarningWindow { get; init; } = TimeSpan.FromDays(1);
}

View File

@@ -2,14 +2,16 @@ 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.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.Karma;
public class KarmaListener(IServiceProvider provider) : IListener {
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;
@@ -28,7 +30,7 @@ public class KarmaListener(IServiceProvider provider) : IListener {
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
public void Dispose() { }
private readonly Dictionary<IPlayer, int> queuedKarmaUpdates = new();
[EventHandler]
[UsedImplicitly]
@@ -36,14 +38,13 @@ public class KarmaListener(IServiceProvider provider) : IListener {
[EventHandler]
[UsedImplicitly]
public Task OnKill(PlayerDeathEvent ev) {
if (games.ActiveGame is not { State: State.IN_PROGRESS })
return Task.CompletedTask;
public void OnKill(PlayerDeathEvent ev) {
if (games.ActiveGame is not { State: State.IN_PROGRESS }) return;
var victim = ev.Victim;
var killer = ev.Killer;
if (killer == null) return Task.CompletedTask;
if (killer == null) return;
var victimRole = roles.GetRoles(victim).First();
var killerRole = roles.GetRoles(killer).First();
@@ -58,30 +59,47 @@ public class KarmaListener(IServiceProvider provider) : IListener {
attackerKarmaMultiplier = badKills[killer.Id];
}
if (victimRole is InnocentRole) {
if (killerRole is TraitorRole) return Task.CompletedTask;
victimKarmaDelta = INNO_ON_INNO_VICTIM;
killerKarmaDelta = INNO_ON_INNO;
switch (victimRole) {
case InnocentRole when killerRole is TraitorRole:
return;
case InnocentRole:
victimKarmaDelta = INNO_ON_INNO_VICTIM;
killerKarmaDelta = INNO_ON_INNO;
break;
case TraitorRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_TRAITOR :
INNO_ON_TRAITOR;
break;
case DetectiveRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_DETECTIVE :
INNO_ON_DETECTIVE;
break;
}
if (victimRole is TraitorRole)
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_TRAITOR :
INNO_ON_TRAITOR;
if (victimRole is DetectiveRole)
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_DETECTIVE :
INNO_ON_DETECTIVE;
killerKarmaDelta *= attackerKarmaMultiplier;
return Task.Run(async () => {
var newKillerKarma = await karma.Load(killer) + killerKarmaDelta;
var newVictimKarma = await karma.Load(victim) + victimKarmaDelta;
queuedKarmaUpdates[killer] = queuedKarmaUpdates.GetValueOrDefault(killer, 0)
+ killerKarmaDelta;
queuedKarmaUpdates[victim] = queuedKarmaUpdates.GetValueOrDefault(victim, 0)
+ victimKarmaDelta;
}
await karma.Write(killer, newKillerKarma);
await karma.Write(victim, newVictimKarma);
});
[UsedImplicitly]
[EventHandler]
public Task OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return Task.CompletedTask;
var tasks = new List<Task>();
foreach (var (player, karmaDelta) in queuedKarmaUpdates) {
tasks.Add(Task.Run(async () => {
var newKarma = await karma.Load(player) + karmaDelta;
await karma.Write(player, newKarma);
}));
}
queuedKarmaUpdates.Clear();
return Task.WhenAll(tasks);
}
}

View File

@@ -1,9 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Extensions;
namespace TTT.Karma;
public static class KarmaServiceCollection {
public static void AddKarmaService(this IServiceCollection collection) {
collection.AddScoped<IKarmaService, KarmaStorage>();
collection.AddModBehavior<IKarmaService, KarmaStorage>();
collection.AddModBehavior<KarmaListener>();
collection.AddModBehavior<KarmaCommand>();
}
}

View File

@@ -2,8 +2,8 @@
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using Dapper;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.DependencyInjection;
using MySqlConnector;
using TTT.API.Events;
using TTT.API.Player;
using TTT.API.Storage;
@@ -12,6 +12,7 @@ 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>();
private readonly KarmaConfig config =
@@ -24,31 +25,48 @@ public class KarmaStorage(IServiceProvider provider) : IKarmaService {
private IDbConnection? connection;
public void Start() {
connection = new MySqlConnection(config.DbString);
connection = new SqliteConnection(config.DbString);
connection.Open();
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.FromMinutes(5), scheduler)
.Subscribe(_ => updateKarmas());
.Subscribe(_ => Task.Run(async () => await updateKarmas()));
}
public Task<int> Load(IPlayer key) {
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 connection.QuerySingleOrDefaultAsync<int>(
$"SELECT IFNULL(Karma, {config.DefaultKarma}) FROM PlayerKarma WHERE PlayerId = @PlayerId",
return await connection.QuerySingleAsync<int>(
$"SELECT COALESCE((SELECT Karma FROM PlayerKarma WHERE PlayerId = @PlayerId), {config.DefaultKarma})",
new { PlayerId = key.Id });
}
public void Dispose() { }
public void Dispose() { connection?.Dispose(); }
public string Id => nameof(KarmaStorage);
public string Version => GitVersionInformation.FullSemVer;
public async Task Write(IPlayer key, int newData) {
if (newData < config.MinKarma || newData > config.MaxKarma(key))
if (newData > config.MaxKarma(key))
throw new ArgumentOutOfRangeException(nameof(newData),
$"Karma must be between {config.MinKarma} and {config.MaxKarma(key)} for player {key.Id}.");
$"Karma must be less than {config.MaxKarma(key)} for player {key.Id}.");
if (!karmaCache.TryGetValue(key, out var oldKarma)) {
oldKarma = await Load(key);
@@ -58,10 +76,12 @@ public class KarmaStorage(IServiceProvider provider) : IKarmaService {
if (oldKarma == newData) return;
var karmaUpdateEvent = new KarmaUpdateEvent(key, oldKarma, newData);
bus.Dispatch(karmaUpdateEvent);
await bus.Dispatch(karmaUpdateEvent);
if (karmaUpdateEvent.IsCanceled) return;
karmaCache[key] = newData;
if (!enableCache) await updateKarmas();
}
private async Task updateKarmas() {
@@ -73,7 +93,7 @@ public class KarmaStorage(IServiceProvider provider) : IKarmaService {
foreach (var (player, karma) in karmaCache)
tasks.Add(connection.ExecuteAsync(
"INSERT INTO PlayerKarma (PlayerId, Karma) VALUES (@PlayerId, @Karma) "
+ "ON DUPLICATE KEY UPDATE Karma = @Karma",
+ "ON CONFLICT(PlayerId) DO UPDATE SET Karma = @Karma",
new { PlayerId = player.Id, Karma = karma }));
await Task.WhenAll(tasks);

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.Karma.lang;
public class KarmaMsgs {
public static IMsg KARMA_COMMAND(int karma)
=> MsgFactory.Create(nameof(KARMA_COMMAND), karma);
public static IMsg KARMA_WARNING(int rounds)
=> MsgFactory.Create(nameof(KARMA_WARNING), rounds);
}

2
TTT/Karma/lang/en.yml Normal file
View File

@@ -0,0 +1,2 @@
KARMA_COMMAND: "%PREFIX%You have {yellow}{0}{grey} karma."
KARMA_WARNING: "%PREFIX%You have {red}very low{grey} karma, and have been forced to sit out for {yellow}{0} {grey}round%s%. Please make sure you read our rules!"

View File

@@ -14,6 +14,16 @@
<ProjectReference Include="..\Shop\Shop.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.9"/>
</ItemGroup>
<PropertyGroup>
<!-- Ensure all NuGet deps are copied to the publish folder -->
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
<PropertyGroup>
<PublishBaseDirectory>$(MSBuildThisFileDirectory)/../../build</PublishBaseDirectory>
<PublishDir>$(PublishBaseDirectory)/TTT</PublishDir>

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,13 +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();
private readonly IIconManager? icons = provider.GetService<IIconManager>();
.GetResult() ?? new StickersConfig();
public override string Name => Locale[StickerMsgs.SHOP_ITEM_STICKERS];

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

@@ -0,0 +1,37 @@
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.Taser;
public static class TaserServiceCollection {
public static void AddTaserItem(this IServiceCollection collection) {
collection.AddModBehavior<TaserItem>();
}
}
public class TaserItem(IServiceProvider provider) : BaseItem(provider) {
private readonly TaserConfig config =
provider.GetService<IStorage<TaserConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new TaserConfig();
public override string Name => Locale[TaserMsgs.SHOP_ITEM_TASER];
public override string Description => Locale[TaserMsgs.SHOP_ITEM_TASER_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) {
// Remove in case they already have it, to allow refresh of recharging taser
Inventory.RemoveWeapon(player, config.Weapon);
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.Taser;
public class TaserMsgs {
public static IMsg SHOP_ITEM_TASER
=> MsgFactory.Create(nameof(SHOP_ITEM_TASER));
public static IMsg SHOP_ITEM_TASER_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_TASER_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,47 @@
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API;
using TTT.API.Game;
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 readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private IDisposable? timer;
public void Dispose() { timer?.Dispose(); }
public void Start() {
timer = scheduler.SchedulePeriodic(config.CreditRewardInterval,
issueRewards);
}
private void issueRewards() {
if (games.ActiveGame is not { State: State.IN_PROGRESS }) return;
Server.NextWorldUpdate(() => {
foreach (var player in finder.GetOnline().Where(p => p.IsAlive))
shop.AddBalance(player, config.IntervalRewardAmount, "Alive");
});
}
}

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

@@ -1,14 +1,20 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Extensions;
using TTT.CS2.Items.Armor;
using TTT.CS2.Items.BodyPaint;
using TTT.CS2.Items.Camouflage;
using TTT.CS2.Items.DNA;
using TTT.CS2.Items.OneHitKnife;
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;
using TTT.Shop.Items.Traitor.Gloves;
using TTT.Shop.Listeners;
@@ -21,11 +27,14 @@ public static class ShopServiceCollection {
collection.AddModBehavior<RoundShopClearer>();
collection.AddModBehavior<RoleAssignCreditor>();
collection.AddModBehavior<PlayerKillListener>();
collection.AddModBehavior<PeriodicRewarder>();
collection.AddModBehavior<ShopCommand>();
collection.AddModBehavior<BuyCommand>();
collection.AddModBehavior<BalanceCommand>();
collection.AddArmorServices();
collection.AddBodyPaintServices();
collection.AddC4Services();
collection.AddCamoServices();
@@ -34,7 +43,12 @@ public static class ShopServiceCollection {
collection.AddDnaScannerServices();
collection.AddGlovesServices();
collection.AddHealthStation();
collection.AddHealthshot();
collection.AddM4A1Services();
collection.AddOneHitKnifeService();
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,15 +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_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_TASER: "Taser"
SHOP_ITEM_TASER_DESC: "A taser that allows you to identify the roles of players you hit."
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

@@ -0,0 +1,7 @@
namespace ShopAPI.Configs;
public record ArmorConfig : ShopItemConfig {
public override int Price { get; init; } = 80;
public int Armor { get; init; } = 100;
public bool Helmet { get; init; } = true;
}

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

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

Some files were not shown because too many files have changed in this diff Show More