Compare commits

...

49 Commits

Author SHA1 Message Date
MSWS
f1521fc499 feat: Implement purchase handling and enhance karma update +semver:minor
- Update `KarmaStorage.cs` to increase karma update frequency and integrate Counter Strike features.
- Enhance `CS2ServiceCollection.cs` with new `BuyListener` behavior for handling purchase activities.
- Add `GetArmor` method in `PlayerExtensions.cs` to retrieve player armor and helmet status.
- Implement `BuyListener.cs` to manage player purchases, logging, and inventory adjustments.
2025-10-04 21:57:04 -07:00
MSWS
986f30c34f Remove all buyzones 2025-10-04 20:35:33 -07:00
MSWS
be6d79dcea test: Refactor damage events and enhance debug logging
- Simplify `PlayerDamagedEvent` instantiation by removing an unused parameter in `DeagleTests.cs` and `PlayerActionsTest.cs`.
- Update `HpLeft` calculation in `PlayerDamagedEvent.cs` to use a new formula for determining remaining health, improving damage calculation accuracy.
- Enhance debugging in `PoisonShotsListener.cs` by adding debug logs and null checks for more reliable damage event tracking.
- Refactor tests in `DeagleTests.cs` to verify unique item kill behavior after the first use.
2025-10-04 20:14:26 -07:00
MSWS
87d55aadd2 refactor: Refactor event handling and improve localization. +semver:minor
- Simplify `PlayerDamagedEvent` constructor and update health calculations for clarity and consistency.
- Add context to damage events in `DamageStation.cs` by including weapon property and streamline damage calculation logic.
- Enhance `PoisonShotsListener.cs` with localization support for weapon names, improve poison effect handling, and cleanup code for better maintainability.
2025-10-04 19:53:21 -07:00
MSWS
9efd37063b refactor: Refactor damage logic and event handling
- Refactor `OneHitKnifeListener` to set victim's health directly to zero for simplified one-hit kill logic.
- Add `PlayerDamagedEvent` in `DamageStation` to allow external interference in damage calculation and consider event modifications.
- Streamline `PlayerDamagedEvent` class by refactoring damage calculation logic and removing unnecessary properties and fields.
- Update `PoisonShotsListener` to include dependency on `IEventBus`, enhance poison effects, and dispatch relevant events such as `PlayerDamagedEvent` and `PlayerDeathEvent`.
- Remove `DamageEventTest` class, indicating a refactor or change in damage event management/testing strategy.
2025-10-04 19:04:24 -07:00
MSWS
3381722e7e feat: Enhance event handling and add player tracking features
```
- Update DamageStation.cs to enhance event handling and player interaction:
  - Integrate `IEventBus` for event dispatching.
  - Improve `onInterval` to provide health boosts, handle player deaths, and ensure sound commands are issued post-death event.
- Modify RoundBasedGame.cs to increase class extensibility:
  - Update variable access levels and rename to maintain consistency.
- Adjust OneHitKnife.cs to change service registration approach and extend functionality.
- Enhance DamageCanceler.cs with OS-specific cleanup logic and streamlined damage handling.
- Update OneHitKnifeListener.cs for improved damage calculation and to incorporate JetBrains annotations.
- Simplify station tracking and ownership in StationItem.cs and StationInfo.cs.
- Introduce EmitSoundCommand.cs to expand command module functionality.
```
2025-10-04 18:50:47 -07:00
MSWS
36f9b7a600 Address unit test issues 2025-10-04 09:17:50 -07:00
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
e71d255409 feat: Add poison shots +semver:minor (resolves #75)
```
- Add new configuration for poison shots in `Traitor/PoisonShotsConfig.cs` to manage properties like price, damage, and total shots.
- Implement `PoisonShotsListener` in the game to handle item usage, apply poison effects, and ensure resource management.
- Create `PoisonShotsItem` class to introduce a new item restricted to the Traitor role, integrating it with shop functionalities.
- Update `lang/en.yml` to include a new shop item description for "Poison Shots".
- Define message constants for poison shots in `PoisonShotMsgs.cs` to facilitate localization and UI display.
```
2025-10-03 00:39:42 -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
122 changed files with 1907 additions and 419 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

@@ -10,6 +10,7 @@ using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.API;
using TTT.CS2.Command;
using TTT.CS2.Command.BuySupport;
using TTT.CS2.Command.Test;
using TTT.CS2.Configs;
using TTT.CS2.Configs.ShopItems;
@@ -21,6 +22,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 +36,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 +45,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 +59,10 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<DamageCanceler>();
collection.AddModBehavior<PlayerConnectionsHandler>();
collection.AddModBehavior<PropMover>();
// collection.AddModBehavior<RoundEnd_GameEndHandler>();
collection.AddModBehavior<RoundStart_GameStartHandler>();
collection.AddModBehavior<BombPlantSuppressor>();
collection.AddModBehavior<MapZoneRemover>();
collection.AddModBehavior<BuyListener>();
// Damage Cancelers
collection.AddModBehavior<OutOfRoundCanceler>();
@@ -66,6 +75,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

@@ -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.Player;
using TTT.CS2.Extensions;
using TTT.CS2.Utils;
using TTT.Game.Roles;
namespace TTT.CS2.Command.BuySupport;
public class BuyListener(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IInventoryManager inventory =
provider.GetRequiredService<IInventoryManager>();
public void Dispose() { }
public void Start() { }
private readonly Dictionary<string, string> shopAliases = new() {
{ "item_assaultsuit", "Armor" },
{ "item_kevlar", "Armor" },
{ "weapon_taser", "Taser" },
{ "weapon_deagle", "Revolver" },
{ "weapon_smokegrenade", "Poison Smoke" },
{ "weapon_m4a1_silencer", "M4A1" },
{ "weapon_usp_silencer", "M4A1" },
{ "weapon_mp5sd", "M4A1" },
{ "weapon_decoy", "healthshot" }
};
[UsedImplicitly]
[GameEventHandler(HookMode.Pre)]
public HookResult OnPurchase(EventItemPurchase ev, GameEventInfo info) {
if (ev.Userid == null) return HookResult.Continue;
Server.PrintToChatAll($"Purchase: {ev.Weapon} (loadout: {ev.Loadout})");
if (converter.GetPlayer(ev.Userid) is not IOnlinePlayer player)
return HookResult.Continue;
if (ev.Weapon is "item_assaultsuit" or "item_kevlar") {
var user = ev.Userid;
Server.NextWorldUpdate(() => user.SetArmor(0));
}
inventory.RemoveWeapon(player, new BaseWeapon(ev.Weapon));
if (shopAliases.TryGetValue(ev.Weapon, out var alias))
ev.Userid.ExecuteClientCommandFromServer("css_buy " + alias);
return HookResult.Handled;
}
}

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

@@ -0,0 +1,33 @@
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 EmitSoundCommand(IServiceProvider provider) : ICommand {
public string Id => "emitsound";
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public void Dispose() { }
public void Start() { }
public string[] Usage => ["[sound]"];
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (executor == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
var gamePlayer = converter.GetPlayer(executor);
if (gamePlayer == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
if (info.Args.Length < 2) return Task.FromResult(CommandResult.PRINT_USAGE);
Server.NextWorldUpdate(() => {
gamePlayer.EmitSound(info.Args[1]);
info.ReplySync("Emitted sound " + info.Args[1]);
});
return Task.FromResult(CommandResult.SUCCESS);
}
}

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,9 +21,10 @@ 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());
subCommands.Add("emitsound", new EmitSoundCommand(provider));
}
public Task<CommandResult>

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,29 @@ 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 (int, bool) GetArmor(this CCSPlayerController player) {
if (!player.IsValid) return (0, false);
var pawn = player.PlayerPawn.Value;
if (pawn == null || !pawn.IsValid) return (0, false);
var hasHelmet = false;
if (pawn.ItemServices != null)
hasHelmet = new CCSPlayer_ItemServices(pawn.ItemServices.Handle).HasHelmet;
return (pawn.ArmorValue, hasHelmet);
}
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

@@ -6,12 +6,24 @@ using TTT.API.Role;
using TTT.CS2.Roles;
using TTT.CS2.Utils;
using TTT.Game;
using TTT.Game.Events.Game;
using TTT.Game.lang;
using TTT.Game.Roles;
namespace TTT.CS2.Game;
public class CS2Game(IServiceProvider provider) : RoundBasedGame(provider) {
public override State State {
set {
var ev = new GameStateUpdateEvent(this, value);
Bus.Dispatch(ev);
if (ev.IsCanceled) return;
state = value;
}
get => state;
}
public override IActionLogger Logger { get; } = new CS2Logger(provider);
public override IList<IRole> Roles { get; } = [

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 CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
using Microsoft.Extensions.DependencyInjection;
@@ -15,7 +16,11 @@ public class DamageCanceler(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public void Dispose() { }
public void Dispose() {
if (OperatingSystem.IsWindows()) return;
VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Unhook(onTakeDamage,
HookMode.Pre);
}
public void Start() { }
@@ -32,11 +37,8 @@ public class DamageCanceler(IServiceProvider provider) : IPluginModule {
if (damagedEvent.IsCanceled) return HookResult.Handled;
if (!damagedEvent.HpModified
|| damagedEvent.Player is not IOnlinePlayer onlinePlayer)
return HookResult.Continue;
onlinePlayer.Health = damagedEvent.HpLeft;
return HookResult.Handled;
var info = hook.GetParam<CTakeDamageInfo>(1);
info.Damage = damagedEvent.DmgDealt;
return HookResult.Continue;
}
}

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,40 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using JetBrains.Annotations;
using TTT.API;
namespace TTT.CS2.GameHandlers;
public class MapZoneRemover : IPluginModule {
private BasePlugin? plugin;
public void Dispose() {
plugin?.RemoveListener<CounterStrikeSharp.API.Core.Listeners.OnMapStart>(
onMapStart);
}
public void Start() { }
private bool zonesRemoved = false;
public void Start(BasePlugin? pluginParent) {
if (pluginParent != null) this.plugin = pluginParent;
plugin?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.OnMapStart>(
onMapStart);
}
private void onMapStart(string mapName) { zonesRemoved = false; }
[UsedImplicitly]
[GameEventHandler]
public HookResult OnRoundStart(EventRoundStart _, GameEventInfo _2) {
if (zonesRemoved) return HookResult.Continue;
var buyzones =
Utilities.FindAllEntitiesByDesignerName<CBuyZone>("func_buyzone");
foreach (var zone in buyzones) zone.Remove();
zonesRemoved = true;
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,41 @@
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.OneHitKnife;
public static class OneHitKnifeServiceCollection {
public static void AddOneHitKnifeService(this IServiceCollection services) {
services.AddModBehavior<OneHitKnife>();
services.AddModBehavior<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,44 @@
using JetBrains.Annotations;
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();
[UsedImplicitly]
[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);
ev.HpLeft = 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

@@ -0,0 +1,19 @@
using TTT.API.Player;
using TTT.Locale;
namespace TTT.CS2.Items.PoisonShots;
public class PoisonShotMsgs {
public static IMsg SHOP_ITEM_POISON_SHOTS
=> MsgFactory.Create(nameof(SHOP_ITEM_POISON_SHOTS));
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

@@ -0,0 +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 => config;
public override void OnPurchase(IOnlinePlayer player) { }
}

View File

@@ -0,0 +1,161 @@
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 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;
using TTT.Game.Listeners;
namespace TTT.CS2.Items.PoisonShots;
public class PoisonShotsListener(IServiceProvider provider)
: BaseListener(provider), IPluginModule {
private readonly PoisonShotsConfig config =
provider.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonShotsConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly Dictionary<IPlayer, int> poisonShots = new();
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly List<IDisposable> poisonTimers = [];
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonTimers) timer.Dispose();
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnFire(EventWeaponFire ev, GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
if (!Tag.GUNS.Contains(ev.Weapon)) return HookResult.Continue;
if (converter.GetPlayer(ev.Userid) is not IOnlinePlayer player)
return HookResult.Continue;
var remainingShots = usePoisonShot(player);
if (remainingShots == 0)
Messenger.Message(player, Locale[PoisonShotMsgs.SHOP_ITEM_POISON_OUT]);
return HookResult.Continue;
}
[UsedImplicitly]
[EventHandler]
public void OnDamage(PlayerDamagedEvent ev) {
if (ev.Attacker == null) return;
if (!poisonShots.TryGetValue(ev.Attacker, out var shot) || shot <= 0)
return;
Messenger.DebugAnnounce("weapon: " + ev.Weapon);
if (ev.Weapon == null || !Tag.GUNS.Contains(ev.Weapon)) return;
Messenger.Message(ev.Attacker,
Locale[PoisonShotMsgs.SHOP_ITEM_POISON_HIT(ev.Player)]);
addPoisonEffect(ev.Player, ev.Attacker);
}
[UsedImplicitly]
[EventHandler]
public void OnGameEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
foreach (var timer in poisonTimers) timer.Dispose();
poisonTimers.Clear();
poisonShots.Clear();
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
private void addPoisonEffect(IPlayer player, IPlayer shooter) {
IDisposable? timer = null;
var effect = new PoisonEffect(player, shooter);
timer = scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
=> {
Server.NextWorldUpdate(() => {
if (tickPoison(effect) || timer == null) return;
timer.Dispose();
poisonTimers.Remove(timer);
});
});
poisonTimers.Add(timer);
}
private bool tickPoison(PoisonEffect effect) {
if (effect.Player is not IOnlinePlayer online) return false;
if (!online.IsAlive) return false;
var dmgEvent = new PlayerDamagedEvent(online,
effect.Shooter as IOnlinePlayer,
online.Health - config.PoisonConfig.DamagePerTick) {
Weapon = $"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]"
};
bus.Dispatch(dmgEvent);
if (dmgEvent.IsCanceled) return true;
if (online.Health - config.PoisonConfig.DamagePerTick <= 0) {
var deathEvent = new PlayerDeathEvent(online)
.WithKiller(effect.Shooter as IOnlinePlayer)
.WithWeapon($"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]");
bus.Dispatch(deathEvent);
}
online.Health -= config.PoisonConfig.DamagePerTick;
effect.Ticks++;
effect.DamageGiven += config.PoisonConfig.DamagePerTick;
var gamePlayer = converter.GetPlayer(online);
gamePlayer?.ColorScreen(config.PoisonColor, 0.2f, 0.3f);
gamePlayer?.ExecuteClientCommand("play " + config.PoisonConfig.PoisonSound);
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
/// <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, IPlayer shooter) {
public IPlayer Player { get; } = player;
public IPlayer Shooter { get; } = shooter;
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,12 +1,14 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API.Events;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Events.Player;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Station;
@@ -17,11 +19,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>>();
@@ -31,16 +34,20 @@ public class DamageStation(IServiceProvider provider) : StationItem(provider,
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
public override string Name => Locale[StationMsgs.SHOP_ITEM_STATION_HURT];
public override string Description
=> 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;
}
@@ -65,11 +72,30 @@ public class DamageStation(IServiceProvider provider) : StationItem(provider,
var healthScale = 1.0 - dist / _Config.MaxRange;
var damageAmount =
(int)Math.Floor(_Config.HealthIncrements * healthScale);
var dmgEvent = new PlayerDamagedEvent(player,
info.Owner as IOnlinePlayer, player.Health + damageAmount) {
Weapon = $"[{Name}]"
};
bus.Dispatch(dmgEvent);
damageAmount = -dmgEvent.DmgDealt;
player.Health += damageAmount;
info.HealthGiven += damageAmount;
if (player.Health + damageAmount <= 0) {
var playerDeath = new PlayerDeathEvent(player)
.WithKiller(info.Owner as IOnlinePlayer)
.WithWeapon($"[{Name}]");
bus.Dispatch(playerDeath);
}
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

@@ -1,9 +1,12 @@
using CounterStrikeSharp.API.Core;
using TTT.API.Player;
namespace TTT.CS2.Items.Station;
public class StationInfo(CPhysicsPropMultiplayer prop, int health) {
public class StationInfo(CPhysicsPropMultiplayer prop, int health,
IPlayer owner) {
public readonly CPhysicsPropMultiplayer Prop = prop;
public int Health = health;
public int HealthGiven = 0;
public IPlayer Owner = owner;
}

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 =
@@ -117,7 +117,7 @@ public abstract class StationItem(IServiceProvider provider,
if (prop == null) return;
props[prop] = new StationInfo(prop, _Config.StationHealth);
props[prop] = new StationInfo(prop, _Config.StationHealth, player);
prop.SetModel("models/props/cs_office/microwave.vmdl");
prop.DispatchSpawn();

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

@@ -20,8 +20,6 @@ public class CS2PermManager(IPlayerConverter<CCSPlayerController> converter)
public bool InGroups(IPlayer player, params string[] groups) {
if (groups.Length == 0) return true;
Server.PrintToChatAll("Checking groups for player: " + player.Id);
Console.WriteLine("Checking groups for player: " + player.Id);
var gamePlayer = converter.GetPlayer(player);
var adminData = AdminManager.GetPlayerAdminData(gamePlayer);

View File

@@ -1,6 +1,7 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using TTT.API.Player;
using TTT.Game.Events.Player;
namespace TTT.CS2.Player;
@@ -10,7 +11,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 +52,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 +113,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

@@ -18,4 +18,18 @@ SHOP_ITEM_CAMO_DESC: "Disguise yourself and make yourself harder to see."
SHOP_ITEM_BODY_PAINT: "Body Paint"
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_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_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

@@ -6,17 +6,15 @@ using TTT.API.Player;
namespace TTT.Game.Events.Player;
public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
int dmgDealt, int hpLeft) : PlayerEvent(player), ICancelableEvent {
private int _hpLeft = hpLeft;
int hpLeft) : PlayerEvent(player), ICancelableEvent {
public PlayerDamagedEvent(IPlayerConverter<CCSPlayerController> converter,
EventPlayerHurt ev) : this(
converter.GetPlayer(ev.Userid!) as IOnlinePlayer
?? throw new InvalidOperationException(),
ev.Attacker == null ?
null :
converter.GetPlayer(ev.Attacker) as IOnlinePlayer, ev.DmgHealth,
ev.Health) {
converter.GetPlayer(ev.Attacker) as IOnlinePlayer,
ev.Health + ev.DmgHealth) {
ArmorDamage = ev.DmgArmor;
ArmorRemaining = ev.Armor;
Weapon = ev.Weapon;
@@ -25,13 +23,13 @@ public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
public PlayerDamagedEvent(IPlayerConverter<CCSPlayerController> converter,
EventPlayerFalldamage ev) : this(
converter.GetPlayer(ev.Userid!) as IOnlinePlayer
?? throw new InvalidOperationException(), null, (int)ev.Damage,
ev.Userid!.Health) {
?? throw new InvalidOperationException(), null,
ev.Userid!.Health + (int)ev.Damage) {
ArmorRemaining = ev.Userid.PawnArmor;
}
public PlayerDamagedEvent(IPlayerConverter<CCSPlayerController> converter,
DynamicHook hook) : this(null!, null, 0, 0) {
DynamicHook hook) : this(null!, null, 0) {
var playerPawn = hook.GetParam<CCSPlayerPawn>(0);
var info = hook.GetParam<CTakeDamageInfo>(1);
@@ -48,37 +46,18 @@ public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
Attacker = attacker == null || !attacker.IsValid ?
null :
converter.GetPlayer(attacker) as IOnlinePlayer;
DmgDealt = (int)info.Damage;
_hpLeft = player.Health - DmgDealt;
// HpLeft = player.Health - DmgDealt;
HpLeft = (int)(player.Pawn.Value!.Health - info.Damage);
}
public bool HpModified { get; private set; }
public override string Id => "basegame.event.player.damaged";
public IOnlinePlayer? Attacker { get; private set; } = attacker;
public int ArmorDamage { get; private set; }
public int ArmorRemaining { get; set; }
public int DmgDealt { get; } = dmgDealt;
public int DmgDealt => player.Health - HpLeft;
public int HpLeft {
get => _hpLeft;
set {
if (value == _hpLeft) return;
switch (value) {
case < 0:
throw new ArgumentOutOfRangeException(nameof(value),
"HpLeft must be greater than 0.");
case 0:
throw new ArgumentException(
"Cannot override HP if player is already dead; cancel the event instead.");
default:
HpModified = _hpLeft != value;
_hpLeft = value;
break;
}
}
}
public int HpLeft { get; set; } = hpLeft;
public string? Weapon { get; init; }
public bool IsCanceled { get; set; }

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

@@ -18,7 +18,7 @@ public class PlayerActionsLogger(IServiceProvider provider)
[UsedImplicitly]
[EventHandler]
[EventHandler(Priority = Priority.MONITOR, IgnoreCanceled = true)]
public void OnPlayerDamage(PlayerDamagedEvent ev) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
Games.ActiveGame.Logger.LogAction(new DamagedAction(Provider, ev));

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,15 +22,12 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
.GetAwaiter()
.GetResult() ?? new TTTConfig();
private readonly IInventoryManager inventory =
provider.GetRequiredService<IInventoryManager>();
protected readonly IMsgLocalizer Locale =
provider.GetRequiredService<IMsgLocalizer>();
private readonly List<IPlayer> players = [];
private State state = State.WAITING;
protected State state = State.WAITING;
public virtual IList<IRole> Roles { get; } = [
new InnocentRole(provider), new TraitorRole(provider),
@@ -49,10 +46,10 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
public IRoleAssigner RoleAssigner { get; init; } = provider
.GetRequiredService<IRoleAssigner>();
public State State {
public virtual State State {
set {
var ev = new GameStateUpdateEvent(this, value);
bus.Dispatch(ev);
Bus.Dispatch(ev).GetAwaiter().GetResult();
if (ev.IsCanceled) return;
state = value;
}
@@ -172,24 +169,23 @@ 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)]);
}
#region classDeps
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
protected readonly IEventBus Bus = provider.GetRequiredService<IEventBus>();
protected readonly IScheduler Scheduler =
provider.GetRequiredService<IScheduler>();

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

@@ -1,9 +1,10 @@
using System.Data;
using System.Reactive.Concurrency;
using System.Reactive.Linq;
using CounterStrikeSharp.API;
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 +13,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 +26,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());
Observable.Interval(TimeSpan.FromSeconds(30), scheduler)
.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 +77,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 +94,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;
}

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