Compare commits

...

112 Commits

Author SHA1 Message Date
MSWS
2d572e19b0 Improve feedback on module reload command 2025-10-20 18:46:24 -07:00
MSWS
e4938502f4 feat: Introduce AFK detection and reward enhancements +semver:minor
Implement AFK Management and Enhance Reward and Purchase Systems

- **TTTConfig.cs**: Add `CheckAFKTimespan` configuration to manage player inactivity during game rounds.
- **HealthshotConfig.cs**: Introduce `MaxPurchases` property to limit healthshot item usage per player.
- **Command/Test/TestCommand.cs**: Implement "reload" sub-command with permission checks for restricted execution.
- **CS2ServiceCollection.cs**: Integrate `AfkTimerListener` for handling inactive players and remove conditional compilation for `TestCommand`.
- **Listeners/AfkTimerListener.cs**: Develop an AFK detection system, moving idle players to spectator mode and issuing warnings.

**Additional updates:**

- **ReloadModule.cs**: Implement class to handle reloading of modules with user feedback and error handling.
- **CS2/lang/CS2Msgs.cs**: Add message templates for AFK warnings and notifications.
- **RoundTimerListener.cs**: Streamline TTTConfig access and remove redundant scheduler handling.
- **TeamChangeHandler.cs**: Enhance team change logic with new dependencies and player checks.
- **ShopConfig.cs**: Rework reward distribution system, introducing flexible reward ranges and removing the old fixed interval configuration.
- **HealthshotItem.cs**: Implement purchase tracking and finalize configurations for purchase limits.
- **PeriodicRewarder.cs**: Split reward and update timers, integrate player position tracking, and enhance reward calculation logic based on player movement.
- **GameHandlers/LateSpawnListener.cs**: Add game state checks to improve player respawn logic during specific states.
2025-10-20 18:44:22 -07:00
MSWS
e59b2538ee Dont duplicate death events, buff poison shots 2025-10-20 17:18:53 -07:00
MSWS
7454e5e3f3 feat: Enhance CamoConfig and update role logic +semver:minor
- Increase the price of camo configuration in `CamoConfig.cs` from 55 to 75
- Add `CS2CamoConfig` behavior to `CS2ServiceCollection.cs` for extended configuration options
- Update logic in `PlayerKillListener.cs` to enhance role-based kill classification by checking differing roles
- Introduce `CS2CamoConfig.cs` with configuration variables for camo items and player visibility
- Adjust starting credits in `CS2ShopConfig.cs` for Innocents, Traitors, and Detectives
- Reduce interval reward amount for credits in `ShopConfig.cs` from 8 to 5
2025-10-20 17:09:43 -07:00
MSWS
4ce453dccd Buff gloves 2025-10-19 22:14:00 -07:00
MSWS
31f1403b9b Bump one shot cost 2025-10-19 22:13:28 -07:00
MSWS
d12cfa5eab Reduce credits given 2025-10-19 22:09:51 -07:00
MSWS
9022416053 refactor: Refactor config init to use expression-bodied properties
Refactor configuration initialization for improved code readability and maintainability

- Update `PoisonSmokeListener.cs` to use a property for `PoisonSmokeConfig` initialization, adding conditional access and null-coalescing logic.
- Adjust `KarmaConfig.cs` to reduce karma gain values, affecting end-of-round and winning scenarios.
- Refactor `HealthshotItem.cs`, using an expression-bodied property for `config` to enhance code clarity.
- Enhance `ArmorItem.cs` with lazy loading for `ArmorConfig` by transitioning `config` to a property using an expression-bodied member.
- Modify `PeriodicRewarder.cs` to initialize `ShopConfig` using a property, ensuring fallback configuration with unchanged core logic.

Other file changes focus on transitioning configuration retrieval to properties, promoting lazy loading and streamlined expressions across items and listeners, thereby refining consistency and readability throughout the codebase.
2025-10-19 21:51:09 -07:00
MSWS
6524772d4f Remove player on disconnect 2025-10-19 16:11:13 -07:00
MSWS
bd8125b7a0 Prevent traitor chat metagming 2025-10-19 15:51:09 -07:00
MSWS
695d34c10c Revert "Refresh AliveSpoofer per map"
This reverts commit 9d3ecbe7fb.
2025-10-19 15:35:00 -07:00
MSWS
9d3ecbe7fb Refresh AliveSpoofer per map 2025-10-18 01:16:51 -07:00
MSWS
85dac3622a Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-10-17 23:20:28 -07:00
MSWS
9e4c29e3f7 Bump taser cost 2025-10-17 23:20:22 -07:00
Isaac
453ba14126 Update TTT/CS2/Items/PoisonShots/PoisonShotsListener.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-17 22:02:16 -07:00
Isaac
91750a1067 Update TTT/CS2/Items/Station/DamageStation.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-17 22:00:44 -07:00
Isaac
dd6b8c00fe Update TTT/CS2/Utils/DamageDealingHelper.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-17 22:00:38 -07:00
MSWS
d9ad08aa27 Improve alive checks 2025-10-17 21:55:36 -07:00
MSWS
35191f23e1 refactor: Refactor data structures for kill tracking +semver:patch
- Change `killedWithStation` data structure to `Dictionary` for enhanced player interaction tracking in `DamageStation.cs`
- Update `PoisonShotsListener.cs` to use `Dictionary` for poison kill tracking and adjust related logic
- Specify priority levels for event handlers in `GlovesListener.cs` to optimize execution order
2025-10-17 21:45:25 -07:00
MSWS
ad29de1bc5 Revert 2025-10-17 21:33:19 -07:00
MSWS
0a0416bff0 Try using native damage dealing method 2025-10-17 20:28:33 -07:00
MSWS
62c96123d1 Remove verbose debug module: 2025-10-17 19:18:51 -07:00
MSWS
274716267f Add null checks to body spawner 2025-10-16 16:24:41 -07:00
MSWS
c20842575b Merge branch 'dev' 2025-10-16 16:01:00 -07:00
MSWS
cf8169a10e Disable TeamChangeHandler for now 2025-10-16 15:15:05 -07:00
Isaac
3dcc3a7de5 Item Rebalancing, Karma Updates, New Compass, Cluster Grenade | Bug Fixes (#125)
This PR implements a comprehensive set of game balancing changes, bug fixes, and new features for a Trouble in Terrorist Town (TTT) game mode in Counter-Strike 2.

Key Changes:

    Shop item pricing rebalance: Adjusted prices across multiple traitor and detective items to improve game economy balance
    New cluster grenade item: Added a new traitor shop item that splits into multiple grenades on detonation
    Compass system refactor: Split the single compass into two separate items (player compass and body compass) with a shared abstract base class
    Karma system improvements: Updated karma calculation values and added proper storage/disposal patterns
    Bug fixes: Fixed damage application, ragdoll spawning, and team change handling issues
2025-10-16 13:38:34 -07:00
MSWS
65bcafca79 Extra extra delay 2025-10-16 13:33:12 -07:00
MSWS
6cac535e94 Additional unit testing adjustments 2025-10-16 13:24:06 -07:00
Isaac
ab3dfbda45 Update TTT/CS2/Items/PoisonShots/PoisonShotsListener.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-16 13:22:02 -07:00
Isaac
324a19c457 Update TTT/CS2/GameHandlers/BodySpawner.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-16 13:21:34 -07:00
Isaac
fda4c72da5 Update TTT/CS2/Items/PoisonShots/PoisonShotsListener.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-16 13:21:14 -07:00
Isaac
b0a1959a2e Merge branch 'main' into dev 2025-10-16 13:19:28 -07:00
MSWS
8a18b1df9c Fix failing tests 2025-10-16 13:12:49 -07:00
MSWS
c233258efc feat: Overhaul sound events, fix duplicate body issues +semver:minor
Refactor and Enhance Gameplay Mechanics

- Remove unnecessary type checks and modify logic for handling victim HP in `OneHitKnifeListener`.
- Add configurable properties in `HealthStationConfig` for improved control over healing behavior.
- Enhance `BodySpawner` with optional parameters and improve code readability for ragdoll management.
- Simplify array initialization and item sorting logic in `BuyCommand`.
- Refactor health-related variables and update sound effects in `HealthStation` to standardize behavior.
- Introduce `DealPoisonDamage` method and utility imports in `PlayerExtensions` for improved poison damage handling.
- Implement tracking and handling of poison-related events in `PoisonShotsListener` and `PoisonSmokeListener`.
- Update `DamageStation` with tracking mechanisms and improved health adjustment logic.
- Enhance item search logic in `GiveItemCommand` and introduce `DebugMessage` management in `CS2ServiceCollection`.
- Revise communication consistency in `lang/en.yml` and add `DebugMessage` class for handling debug scenarios.
- Streamline damage handling and weapon verification in `DeagleDamageListener`.
2025-10-16 13:02:22 -07:00
MSWS
e13497af76 refactor: Update item prices, make buy menu print to chat
- Increase the price of the Poison Smoke item in `PoisonSmokeConfig.cs` from 30 to 35 and ensure a newline at the end of the file.
- Reduce the price of the gloves in `GlovesConfig.cs` from 65 to 50.
- Refactor `BuyMenuHandler.cs` to improve command management and handling robustness.
- Reduce the default price of armor in `ArmorConfig.cs` from 80 to 60.
- Adjust prices in various Traitor and Detective configurations, including `C4Config.cs`, `PoisonShotsConfig.cs`, and `StickersConfig.cs` for better game balance.
2025-10-16 11:32:57 -07:00
MSWS
e8ccd2dbf8 Update velocity configs 2025-10-16 11:19:47 -07:00
MSWS
c0e95a2254 feat: Add cluster grenade feature +semver:minor (resolves #124)
```
- Add section for `CHEGrenadeProjectile_CreateFunc` in gamedata for new grenade functionality
- Update BuyMenuHandler to recognize "weapon_hegrenade" alias for purchase process
- Enhance ClusterGrenadeListener to implement multi-projectile detonation logic and remove unused fields
- Introduce `GrenadeDataHelper` class for handling grenade projectile creation
- Extend ClusterGrenadeConfig with `UpForce` and `ThrowForce` properties for customization
```
2025-10-16 11:16:23 -07:00
MSWS
5a9fd9da1a feat: Add Cluster Grenade item and adjust item prices +semver:minor
- Increase the price of the One-Shot Deagle item in CS2OneShotDeagleConfig.cs from 100 to 110.
- Reduce item prices in CamoConfig.cs and Detective/DnaScannerConfig.cs, adjusting the game's economy and item valuation.
- Introduce the Cluster Grenade item in ClusterGrenadeItem.cs and implement its related services.
- Create ClusterGrenadeListener.cs to handle events and dependencies for cluster grenades.
- Add the Cluster Grenade item description in en.yml.
- Reduce the price of HealthStationConfig.cs to improve affordability and balance.
- Update BodyPaintConfig.cs to allow for increased usage flexibility.
- Add new configuration for the traitor shop's Cluster Grenade in Traitor/ClusterGrenadeConfig.cs.
- Add the Cluster Grenade to the shop services and adjust service ordering in ShopServiceCollection.cs.
2025-10-16 10:25:55 -07:00
MSWS
fb562563de Sort items before searching 2025-10-16 10:10:57 -07:00
MSWS
161480c1f1 Fix locale usage (resolves #124) 2025-10-16 10:08:23 -07:00
MSWS
cfdffbdb47 feat: Refactor compass items into a modular system +semver:minor
Implement New Compass Item System and Remove Legacy CompassItem

- Remove `CompassItem.cs`, eliminating the legacy radar-like compass functionality for traitors and any associated gameplay mechanics.
- Introduce `InnoCompassItem` class, providing role-specific target identification and implementing extension methods for service collection integration.
- Add `AbstractCompassItem` class as a base for compass-related items, including periodic updates, abstract methods for target logic, and a visual compass display.
- Split and rename `AddCompassServices` to `AddInnoCompassServices` and `AddBodyCompassServices` in `ShopServiceCollection.cs` for better service management.
- Update `CompassMsgs.cs` by refining message identifiers and adding new instances to support expanded in-game functionalities.
- Enhance `StationItem` object spawning logic for more accurate placement based on player orientation.
- Amend `en.yml` to include "Body Compass" item details and adjust existing labels for clarity and consistency.
- Implement `BodyCompassItem` class, extending abstract compass functionality for detective roles, with methods for ownership checks and target retrieval.
2025-10-16 10:01:40 -07:00
MSWS
70e4127ccf Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-10-16 09:28:05 -07:00
MSWS
5acc57d96e Update to latest CS# 2025-10-16 08:57:26 -07:00
Isaac
a10b83ec4d Update FUNDING 2025-10-15 01:15:45 -07:00
Isaac
0a10cd22ab Update funding information for GitHub Sponsors
Signed-off-by: Isaac <git@msws.xyz>
2025-10-15 01:14:20 -07:00
MSWS
7838e335e4 fix: Avoid failing main pipeline if release already exists 2025-10-15 01:11:52 -07:00
MSWS
3a472bb0bf Reformat & Cleanup 2025-10-15 01:04:03 -07:00
MSWS
1a7943a58e Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-10-15 00:58:29 -07:00
MSWS
b385daf157 Add more M4 shortcuts and AWP shortcut 2025-10-15 00:58:02 -07:00
MSWS
31a1069550 feat: Suppress game stats at all times (resolves #122)
```
- Add JetBrains.Annotations to PlayerStatsTracker and annotate methods with [UsedImplicitly] to enhance code quality and maintainability.
- Reorder logic in CombatHandler's `OnPlayerDeath_Pre` to ensure necessary operations are executed before checking game state.
- Move `info.DontBroadcast` setting in CombatHandler to occur only when game is in progress.
- Modify operation sequence in `OnPlayerDeath_Pre` to spoof player status before dispatching death event.
- Clarify comment in `OnPlayerHurt` method in CombatHandler for better understanding on non-Windows platforms.
```
2025-10-14 18:26:13 -07:00
MSWS
38ef183072 Update logs unit test 2025-10-14 16:41:43 -07:00
MSWS
2c03129e86 Update README 2025-10-14 14:05:54 -07:00
MSWS
6f169ef850 Adjust credit dispersement for good/bad kills and iding bodies 2025-10-14 12:34:58 -07:00
MSWS
6f924a82b0 Fix station spawn positioning (resolves #106) 2025-10-14 11:50:58 -07:00
MSWS
06ae0250d0 Additional tweaks to karma balance 2025-10-14 11:50:03 -07:00
MSWS
bd475edd54 Localize no logs shown msg 2025-10-14 11:43:12 -07:00
MSWS
092a676f97 feat: Implement player muting when dead +semver:minor (resolves #121)
- Introduce `PlayerMuter` class in `GameHandlers` for muting dead players and send appropriate messages
- Add `PlayerMuter` behavior to `CS2ServiceCollection` and organize mod behaviors
- Remove unnecessary debug print and simplify logic in `SilentAWPItem`'s `onWeaponSound` method
- Add reminder message in `en.yml` for dead players indicating they cannot be heard
- Add `DEAD_MUTE_REMINDER` message in `CS2Msgs.cs` to notify muted dead players
2025-10-14 11:41:41 -07:00
MSWS
cebf48a9e6 refactor: Refactor dict to use IDs, fix silent awp (#105)
- Change dictionary key types from `IOnlinePlayer` to `string` in `ListCommand` for consistency, using `executor.Id` as the key.
- Update method calls in `ListCommand` to align with new dictionary key types.
- Update `silentShots` dictionary in `SilentAWPItem` to use player IDs (`string`) instead of `IOnlinePlayer` objects.
- Modify `OnPurchase` method in `SilentAWPItem` to handle weapon management asynchronously.
- Add server logging for debug messages in `SilentAWPItem`.
2025-10-14 11:26:05 -07:00
MSWS
303b6de39c Working MAUL integration 2025-10-14 11:05:11 -07:00
MSWS
9f5e96ce33 Add MAUL compatability 2025-10-14 10:45:23 -07:00
MSWS
83e90deb44 refactor: Rename ChatHandler to TraitorChatHandler +semver:patch
- Update the traitor chat format label to pluralize "TRAITOR" to "TRAITORS" in `en.yml`
- Replace `ChatHandler` with `TraitorChatHandler` in `CS2ServiceCollection.cs` to enhance focus on traitor-specific chat functionality
- Rename `ChatHandler` to `TraitorChatHandler` and update to manage traitor roles in `ChatHandler.cs`
- Ensure message processing occurs only if the game is in progress or finished in `ChatHandler.cs`
- Modify command message handling in `ChatHandler.cs` to strip backslashes from messages
2025-10-14 10:01:25 -07:00
Isaac
658eecef02 feat: Add traitor chat (resolves #112, #114) (#120) 2025-10-14 09:04:42 -07:00
MSWS
c90af8dfcf feat: Implement traitor chat message formatting +semver:minor
- Add new message format function for traitor chat in CS2Msgs.cs
- Update ChatHandler.cs with new API modules and role-checking logic
- Modify onSay method in ChatHandler.cs to support traitor message formatting
- Add new chat format specification for traitors in en.yml
2025-10-14 09:01:03 -07:00
MSWS
6cd1788992 Start workon traitor chat 2025-10-14 08:42:25 -07:00
MSWS
1288ccbd7b Refactor & Reformat, fix Spectators preventing specific roles 2025-10-14 08:33:56 -07:00
MSWS
2596289f58 Fix unit tests 2025-10-14 07:18:37 -07:00
Isaac
59eea4bc6d Simplify perm checks, grammar update +semver:patch (#119) 2025-10-13 22:46:37 -07:00
MSWS
d13403d7a7 Simplify perm checking 2025-10-13 22:44:31 -07:00
MSWS
d4fa621a03 Fix color formatting 2025-10-13 22:16:59 -07:00
Isaac
cf6b42344f Balance Changes, Respawn On Countdown Start (#118)
- Buffed poison shots (3 -> 5 bullets)
- Nerfed healthshot price (25 -> 30 credits)
- Added role indicator in shop list
- Reduced Karma harshness
2025-10-13 21:42:52 -07:00
MSWS
969c872e48 Force logs to be run on main thread 2025-10-13 21:12:59 -07:00
MSWS
b2f19b7ac3 feat: Enhance log viewing with messaging and visibility logic +semver:minor
```
Enhance logging and visibility management in game commands and role handling

- Update `LogsCommand.cs` to integrate messaging and localization with `IMessenger` and `IMsgLocalizer`, and conditionally adjust player visibility using `IIconManager`.
- Add new localized message in `GameMsgs.cs` for player's log viewing activity, using player's name.
- Overload `SetVisiblePlayers` in `IIconManager.cs` to increase flexibility by accepting an `IOnlinePlayer` object.
- Implement `SetVisiblePlayers` in `RoleIconsHandler.cs` to improve player visibility and role management, and integrate rules for specific roles like `DetectiveRole` and `TraitorRole`.
- Modify `en.yml` to include a log message for when a player views logs alive.
```
2025-10-13 21:11:50 -07:00
MSWS
742e407bcf Add shop list footer 2025-10-13 20:55:17 -07:00
MSWS
f8c8e528e2 Buff poison shot amo (resolves #115) 2025-10-13 20:16:21 -07:00
MSWS
24bd3d5f40 Clear recipients of awp sounds 2025-10-13 19:51:49 -07:00
MSWS
f1ff53893f Add karma grants per round 2025-10-13 19:47:15 -07:00
MSWS
54c89e96c0 fix: Respawn players when game actually starts
```
- Improve player respawn logic in RoundTimerListener for better team handling and secure timer disposal.
- Enhance initial setup and end game logic in RoundBasedGame with robust role assignment, game state checks, and localization support.
- Increase default Healthshot item price in HealthshotConfig.
```
2025-10-13 19:19:42 -07:00
MSWS
46514a6016 Change comparator 2025-10-12 19:05:13 -07:00
MSWS
73a8f6a9f5 Restrict logs command behind permission 2025-10-12 18:16:52 -07:00
MSWS
f0c239f08e build: Change DI lifetimes for TExtension and ITerrorModule
- Change dependency injection lifetime for services implementing `TExtension` from scoped to singleton in `ServiceCollectionExtensions.cs`
- Register `ITerrorModule` as a transient service in `ServiceCollectionExtensions.cs`
2025-10-12 18:08:25 -07:00
MSWS
bafad884d9 Dont modify karma if killer is the victim 2025-10-12 17:49:30 -07:00
MSWS
1d7e2f7466 Update compass more frequently 2025-10-12 17:42:15 -07:00
MSWS
f9e3d2d324 perf: Refactor KarmaStorage for thread-safety and reliability
- Replace `Dictionary<IPlayer, int>` with `ConcurrentDictionary<string, int>` in `KarmaStorage.cs` for thread-safe operations.
- Introduce caching logic with semaphore for serialized writes in `KarmaStorage.cs`.
- Add periodic data flushing mechanism to the database with improved error handling in `KarmaStorage.cs`.
- Seal `KarmaStorage` class to prevent inheritance and ensure configuration loading precedes database operations.
- Improve error handling with logging and ensure reliable database connections in `KarmaStorage.cs`.
- Enhance thread safety and performance in `FlushAsync` and `Write` methods, improving idempotency and preventing race conditions.
- Increase robustness with null checks, additional validations, and modular code separation in `KarmaStorage.cs`.
2025-10-12 17:24:19 -07:00
Isaac
9ce90cccaa Additional Shop Items, Synchronous Events (#111) 2025-10-12 14:05:14 -07:00
Isaac
551f6a09ef Update TTT/CS2/Command/PlayerPingShopAlias.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-12 13:59:45 -07:00
MSWS
41db8f9444 Additional awaits 2025-10-12 13:54:56 -07:00
MSWS
361bbb0a49 Wait for async event completion 2025-10-12 13:52:17 -07:00
MSWS
228ea40cec feat: Add item sorting and player ping features +semver:minor
```
- Introduce `IItemSorter` interface across multiple components to enhance item sorting capabilities in shop commands.
- Enhance `ListCommand` with caching mechanisms, improved sorting logic, and item formatting adjustments for better performance and usability.
- Implement `PlayerPingShopAlias` for enhanced player interaction, including command listeners and shop command processing tied to player actions.
- Set default price for Silent AWP in `SilentAWPConfig` to standardize item pricing.
- Conduct significant cleanup and optimization in `PoisonShotsListener` to improve gameplay experience and reduce unnecessary debug messages.
```
2025-10-12 13:22:31 -07:00
MSWS
44f7283145 feat: Refactor PlayerDamagedEvent for enhanced accuracy
```
- Increase delay time in KarmaListenerTests to ensure proper karma update processing.
- Change `Event` class from abstract to concrete, and modify `Id` property implementation.
- Adjust CS2GameConfig timing settings for more balanced gameplay.
- Enhance PoisonShotsListener functionality with player health parameters and item removal.
- Make SimpleLogger methods virtual to improve subclass flexibility.
- Implement new logging capabilities in CS2Logger with Serilog integration.
- Enhance GiveItemCommand with new event handling for item purchases.
- Update DeagleTests for accurate simulation of weapon damage.
- Modify PlayerDamagedEvent structure for more precise damage calculations.
- Improve DamageStation logic to align with new damage handling.
- Refactor DamageCanceler for better code organization and cross-platform support.
```
2025-10-12 12:28:08 -07:00
MSWS
1a52daad7c Fix async event handler 2025-10-12 11:56:20 -07:00
MSWS
7d0d32998e feat: Refactor karma configs and add new settings
- Change database connection string constant name for consistency in `CS2KarmaConfig.cs`
- Extend description of minimum karma for clarification of its impact
- Refine descriptions of player actions related to karma for improved clarity
- Rename karma-related constants to generic terms for simplicity
- Introduce configurable warning window for low karma to prevent repeat warnings
- Add configurable karma delta values for in-game actions
- Update Load method to include new karma-related configurations
2025-10-11 20:53:49 -07:00
MSWS
3cda83932e feat: Refactor karma system for configurability
```
- Add detailed XML documentation comments to `KarmaConfig.cs` to improve code understanding and maintainability.
- Remove the default value of `MinKarma` in `KarmaConfig.cs`, making it a mandatory setting.
- Introduce new properties in `KarmaConfig.cs` for handling different karma scenarios in player interactions.
- Add a dependency on `TTT.API.Storage` in `KarmaListener.cs` for loading configurations.
- Replace hardcoded karma values in `KarmaListener.cs` with configurable options, enhancing flexibility and adaptability.
```
2025-10-11 20:50:24 -07:00
MSWS
7ea57d0a9b Reformat & Cleanup 2025-10-11 20:46:16 -07:00
MSWS
839be785f0 refactor: Refactor shop item removal to use generics
- Update `DeagleDamageListener.cs` to enhance type safety and address edge cases related to item removal and friendly fire logic.
- Improve the purchase validation logic in `Stickers.cs` using a type-specific item check.
- Refactor `Shop.cs` to use generic type parameters in item removal methods, enhancing type safety.
- Simplify `IShop.cs` by removing default implementations and focusing on type-based item checks.
- Enhance overall code clarity and maintainability with type-specific method improvements.
2025-10-11 20:42:40 -07:00
MSWS
8f0a273f79 refactor: Enhance EventBus validation and optimize role assign logic
```
- Introduce validation to `EventBus` listener methods to enforce `void` return type and enhance exception messaging for parameter constraints.
- Refactor `RoleAssignCreditor` to simplify execution path by making `OnRoleAssign` synchronous and handling asynchronous operations with `Task.Run`.
```
2025-10-11 20:23:06 -07:00
MSWS
cb6cb442b1 refactor: Refactor event dispatching to be synchronous +semver:minor
- Remove asynchronous calls and convert to synchronous dispatch in multiple files, improving performance and reducing complexity.
- Refactor `RoundBasedGame.cs` to enhance game state management, implement team victory determination, and ensure resource disposal.
- Update `IEventBus.cs` and `EventBus.cs` to change the dispatch method to synchronous operation, altering method return types.
- Modify karma-related tests and storage in `KarmaListenerTests.cs`, `KarmaStorage.cs`, and `MemoryKarmaStorage.cs` to reflect synchronization changes, ensuring correct behavior.
- Refactor `EventModifiedMessenger.cs` to improve message handling by switching to synchronous calls.
- Implement new karma penalty logic in `KarmaListener.cs` for certain actions, adjusting the handling and calculations of karma.
2025-10-11 20:01:40 -07:00
Isaac
cb2a5a8720 feat: Compass Item (resolves #80) (#108) 2025-10-09 18:55:36 -07:00
MSWS
10be465d33 Resolve merge / build issue 2025-10-09 18:17:10 -07:00
Isaac
a0720376d4 Merge branch 'dev' into feat/shop-compass
Signed-off-by: Isaac <git@msws.xyz>
2025-10-09 18:15:58 -07:00
Isaac
f5cb87d92c feat: Add Silent AWP item +semver:minor (resolves #105) (#110)
- Implement Silent AWP item functionality with
`SilentAWPServiceCollection` and `SilentAWPItem` class for `TraitorRole`
- Add Silent AWP shop item and related localized texts in `en.yml`
- Define message constants for Silent AWP in `SilentAWPMsgs.cs` for
internationalization
- Modify `GiveItemCommand.cs` to incorporate `OnPurchase` logic for item
purchases
- Manage player validity in `CS2AliveSpoofer.cs` by removing players
with null handles
- Enhance player detail replies in `IndexCommand.cs`
- Introduce messaging functionality in `BaseItem.cs` and use via
`Messenger` field
- Add Silent AWP service integration in `ShopServiceCollection.cs` and
`Traitor` config in `SilentAWPConfig.cs`
2025-10-09 18:14:57 -07:00
MSWS
bd6c15aca7 feat: Add Silent AWP item and related services +semver:minor
- Implement Silent AWP item functionality with `SilentAWPServiceCollection` and `SilentAWPItem` class for `TraitorRole`
- Add Silent AWP shop item and related localized texts in `en.yml`
- Define message constants for Silent AWP in `SilentAWPMsgs.cs` for internationalization
- Modify `GiveItemCommand.cs` to incorporate `OnPurchase` logic for item purchases
- Manage player validity in `CS2AliveSpoofer.cs` by removing players with null handles
- Enhance player detail replies in `IndexCommand.cs`
- Introduce messaging functionality in `BaseItem.cs` and use via `Messenger` field
- Add Silent AWP service integration in `ShopServiceCollection.cs` and `Traitor` config in `SilentAWPConfig.cs`
2025-10-09 18:07:05 -07:00
MSWS
7e5e34c500 Merge branch 'feat/shop-compass' of github.com:MSWS/TTT into feat/shop-compass 2025-10-09 15:04:21 -07:00
MSWS
8a886a158c Replace center with HTML specific call 2025-10-09 15:04:15 -07:00
MSWS
fc61682669 Merge branch 'dev' into feat/shop-compass 2025-10-08 21:02:54 -07:00
MSWS
d6e4655674 Update licenses 2025-10-08 21:02:40 -07:00
MSWS
c53a584113 Update licenses 2025-10-08 21:02:26 -07:00
MSWS
c56387d6e4 feat: Enhance compass configuration and logic
- Add new configuration fields in CompassConfig.cs to customize compass FOV and length
- Implement maximum range check and refactor angle calculations in CompassItem.cs
- Update distance descriptions in CompassItem.cs for thematic clarity
- Enhance code readability and maintainability in CompassItem.cs through refactoring
2025-10-08 20:53:30 -07:00
MSWS
1c8d1a5dd5 feat: Implement advanced compass and detection features +semver:minor
- Enhance `CompassItem` in `CompassItem.cs` with refined enemy detection, directional compass, and improved documentation.
- Add `TextCompass` utility class in `TextCompass.cs` featuring static method for compass line generation with direction normalization and cardinal placements.
2025-10-08 20:23:14 -07:00
MSWS
340dae1b16 Optimize role/team-based checks 2025-10-08 18:55:16 -07:00
MSWS
eff58ab2f1 Reformat & Cleanup 2025-10-07 20:54:29 -07:00
MSWS
acababeaf5 feat: Add compass itme +semver:minor (resolves #80)
```
Introduce Compass Item for Traitor Role

- Add a new configuration file `CompassConfig.cs` for the traitor-exclusive compass with default settings, including a price of 70 and a maximum range of 10,000.
- Integrate the `Compass` service into the shop by updating `ShopServiceCollection.cs` to support the new item.
- Extend `BaseItem.cs` with additional dependencies for game management, enhancing item functionality.
- Update localization in `en.yml` to include the "Player Compass" item, ensuring descriptions and structure accommodate the new addition.
- Create `CompassMsgs.cs` to manage compass-related messages with `SHOP_ITEM_COMPASS` and `SHOP_ITEM_COMPASS_DESC` for localization.
- Enhance vector handling in `VectorExtensions.cs` by adding nullability checks and support for nullable objects.
- Implement `CompassItem.cs` to support traitor-specific gameplay features like real-time radar updates and enemy tracking.
```
2025-10-07 20:46:32 -07:00
MSWS
f40b8ebef0 test: Fix BalanceClear unit tests relying on karma 2025-10-07 10:10:52 -07:00
138 changed files with 2544 additions and 506 deletions

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: [msws] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: msws # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -33,7 +33,31 @@ jobs:
id: gitversion
uses: gittools/actions/gitversion/execute@v4
# Early exit guard: if tag already exists, mark and skip all following steps
- name: Check if tag exists
id: tag_exists
run: |
set -euo pipefail
git fetch --tags --force
TAG="${{ steps.gitversion.outputs.fullSemVer }}"
if git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists locally."
elif git ls-remote --tags origin "refs/tags/${TAG}" | grep -q "refs/tags/${TAG}$"; then
echo "exists=true" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} already exists on origin."
else
echo "exists=false" >> "$GITHUB_OUTPUT"
echo "Tag ${TAG} does not exist. Continuing."
fi
# Short-circuit info step for logs
- name: Tag exists, nothing to do
if: steps.tag_exists.outputs.exists == 'true'
run: echo "Release already exists for tag ${{ steps.gitversion.outputs.fullSemVer }}. Exiting successfully."
- name: Build Locale
if: steps.tag_exists.outputs.exists != 'true'
run: |
mkdir -p build/TTT/lang
dotnet restore Locale/Locale.csproj
@@ -41,22 +65,26 @@ jobs:
cp lang/*.json build/TTT/lang
- name: Copy Gamedata
if: steps.tag_exists.outputs.exists != 'true'
run: |
mkdir -p build/TTT/gamedata
cp -r TTT/CS2/gamedata/* build/TTT/gamedata
- name: Publish Plugin
if: steps.tag_exists.outputs.exists != 'true'
run: |
dotnet restore TTT/Plugin/Plugin.csproj
dotnet publish TTT/Plugin/Plugin.csproj --no-restore -c Release -o build/TTT
- name: Zip Artifacts
if: steps.tag_exists.outputs.exists != 'true'
run: |
cd build/TTT
zip -r TTT-${{ steps.gitversion.outputs.fullSemVer }}.zip *
# 2. Get latest tag
- name: Get latest tag
if: steps.tag_exists.outputs.exists != 'true'
id: latest_tag
run: |
if git describe --tags --abbrev=0 >/dev/null 2>&1; then
@@ -66,58 +94,59 @@ jobs:
fi
- name: Create and push new tag
if: steps.gitversion.outputs.fullSemVer != steps.latest_tag.outputs.tag
if: steps.tag_exists.outputs.exists != 'true' && steps.gitversion.outputs.fullSemVer != steps.latest_tag.outputs.tag
run: |
set -euo pipefail
TAG="${{ steps.gitversion.outputs.fullSemVer }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git tag ${{ steps.gitversion.outputs.fullSemVer }}
git push origin ${{ steps.gitversion.outputs.fullSemVer }}
if ! git rev-parse -q --verify "refs/tags/${TAG}" >/dev/null; then
git tag "${TAG}"
fi
if git ls-remote --tags origin "refs/tags/${TAG}" | grep -q "refs/tags/${TAG}$"; then
echo "Tag ${TAG} already on origin. Skipping push."
else
git push origin "${TAG}"
fi
- name: Determine previous relevant tag
if: steps.tag_exists.outputs.exists != 'true'
id: prev_tag
run: |
set -euo pipefail
branch="${GITHUB_REF_NAME}"
# Use HEAD^ to skip the tag we just created. If no parent, fall back to HEAD.
if git rev-parse --verify -q HEAD^ >/dev/null; then
base_rev="HEAD^"
else
base_rev="HEAD"
fi
# Match stable tags on main and prerelease tags on non-main
if [[ "$branch" == "main" ]]; then
pattern='[0-9]*.[0-9]*.[0-9]*'
else
pattern='[0-9]*.[0-9]*.[0-9]*-*'
fi
# Nearest tag reachable on this lineage, not just "second most recent by date"
prev=$(git describe --tags --abbrev=0 --match "$pattern" --tags "$base_rev" 2>/dev/null || true)
echo "tag=${prev:-0.0.0}" >> "$GITHUB_OUTPUT"
- name: Generate changelog
if: steps.tag_exists.outputs.exists != 'true'
run: |
set -euo pipefail
prev="${{ steps.prev_tag.outputs.tag }}"
curr="${{ steps.gitversion.outputs.fullSemVer }}"
# Choose what you want in the raw feed: %s = subject only, %B = full message
GIT_LOG_FORMAT='%B'
if [[ "$prev" == "0.0.0" ]]; then
# First release: whole history to this tag, first-parent to reflect mains narrative
git log --no-merges --format="${GIT_LOG_FORMAT}" --reverse "$curr" > CHANGELOG.md
else
# Strict range between the previous reachable tag and the new tag on this lineage
git log --no-merges --format="${GIT_LOG_FORMAT}" --reverse "$prev..$curr" > CHANGELOG.md
fi
# Fallback in case nothing was captured
if [[ ! -s CHANGELOG.md ]]; then
echo "No commits found between $prev and $curr on first-parent. Using full messages without first-parent filter." >&2
if [[ "$prev" == "0.0.0" ]]; then
@@ -131,7 +160,7 @@ jobs:
- name: Rewrite changelog with OpenAI
id: ai_changelog
if: success()
if: steps.tag_exists.outputs.exists != 'true'
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: ${{ env.OPENAI_MODEL }}
@@ -140,25 +169,19 @@ jobs:
run: |
set -euo pipefail
# Ensure we have a changelog to work with
if [[ ! -s CHANGELOG.md ]]; then
echo "CHANGELOG.md is empty. Skipping AI rewrite."
echo "skipped=true" >> $GITHUB_OUTPUT
exit 0
fi
# Trim the input to a safe size for token limits
head -c "${MAX_CHANGELOG_CHARS}" CHANGELOG.md > CHANGELOG_RAW.md
# 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 intended for reading by the public, grouping by features, features, and other pertinent groups where appropriate. Do not include a group if it is unnecessary. Remove internal ticket IDs and commit hashes unless essential. Merge duplicates. Use imperative, past tense voice with proper prose. 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
# Call the API
# Basic retry on transient failures
for i in 1 2 3; do
HTTP_CODE=$(curl -sS -w "%{http_code}" -o ai_response.json \
https://api.openai.com/v1/responses \
@@ -175,14 +198,12 @@ jobs:
exit 0
fi
# Prefer output_text if present. Fallback to first text item. :contentReference[oaicite:1]{index=1}
if jq -e '.output_text' ai_response.json >/dev/null; then
jq -r '.output_text' ai_response.json > CHANGELOG.md
else
jq -r '.output[0].content[] | select(.type=="output_text") | .text' ai_response.json | sed '/^[[:space:]]*$/d' > CHANGELOG.md
fi
# If the rewrite somehow produced an empty file, keep the raw one
if [[ ! -s CHANGELOG.md ]]; then
echo "AI returned empty content. Restoring raw changelog."
mv CHANGELOG_RAW.md CHANGELOG.md
@@ -195,6 +216,7 @@ jobs:
cat CHANGELOG.md
- name: Create GitHub release
if: steps.tag_exists.outputs.exists != 'true'
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.gitversion.outputs.fullSemVer }}
@@ -204,9 +226,8 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# 7. Cleanup old pre-releases
- name: Delete old pre-releases
if: github.ref_name != 'main'
if: steps.tag_exists.outputs.exists != 'true' && github.ref_name != 'main'
run: |
gh release list --limit 100 --json name,isPrerelease \
--jq '.[] | select(.isPrerelease) | .name' | tail -n +11 | \

View File

@@ -4,12 +4,14 @@
| CounterStrikeSharp.API | 1.0.340 | Expression | GPL-3.0-only | https://licenses.nuget.org/GPL-3.0-only | | Roflmuffin | http://docs.cssharp.dev/ |
| Dapper | 2.1.66 | Expression | Apache-2.0 | https://licenses.nuget.org/Apache-2.0 | 2019 Stack Exchange, Inc. | Sam Saffron,Marc Gravell,Nick Craver | https://github.com/DapperLib/Dapper |
| JetBrains.Annotations | 2025.2.0 | Expression | MIT | https://licenses.nuget.org/MIT | Copyright (c) 2016-2025 JetBrains s.r.o. | JetBrains | https://www.jetbrains.com/help/resharper/Code_Analysis__Code_Annotations.html |
| Microsoft.Data.Sqlite | 9.0.9 | Expression | MIT | https://licenses.nuget.org/MIT | © Microsoft Corporation. All rights reserved. | Microsoft | https://docs.microsoft.com/dotnet/standard/data/sqlite/ |
| Microsoft.Extensions.DependencyInjection.Abstractions | 9.0.7 | Expression | MIT | https://licenses.nuget.org/MIT | © Microsoft Corporation. All rights reserved. | Microsoft | https://dot.net/ |
| Microsoft.Extensions.Localization.Abstractions | 8.0.3 | Expression | MIT | https://licenses.nuget.org/MIT | © Microsoft Corporation. All rights reserved. | Microsoft | https://asp.net/ |
| Microsoft.NET.Test.Sdk | 17.14.1 | Expression | MIT | https://licenses.nuget.org/MIT | © Microsoft Corporation. All rights reserved. | Microsoft | https://github.com/microsoft/vstest |
| Microsoft.Reactive.Testing | 6.0.1 | Expression | MIT | https://licenses.nuget.org/MIT | Copyright (c) .NET Foundation and Contributors. | .NET Foundation and Contributors | https://github.com/dotnet/reactive |
| Microsoft.Testing.Extensions.CodeCoverage | 17.14.2 | Unknown | | https://aka.ms/deprecateLicenseUrl | © Microsoft Corporation. All rights reserved. | Microsoft | https://github.com/microsoft/codecoverage |
| MySqlConnector | 2.4.0 | Expression | MIT | https://licenses.nuget.org/MIT | Copyright 20162024 Bradley Grainger | Bradley Grainger | https://mysqlconnector.net/ |
| SQLite | 3.13.0 | Unknown | | | Public Domain | SQLite Development Team | |
| System.Reactive | 6.0.1 | Expression | MIT | https://licenses.nuget.org/MIT | Copyright (c) .NET Foundation and Contributors. | .NET Foundation and Contributors | https://github.com/dotnet/reactive |
| System.Text.Json | 8.0.5 | Expression | MIT | https://licenses.nuget.org/MIT | © Microsoft Corporation. All rights reserved. | Microsoft | https://dot.net/ |
| Xunit.DependencyInjection | 10.6.0 | Expression | MIT | https://licenses.nuget.org/MIT | Copyright © 2019 | Wei Peng | https://github.com/pengweiqhca/Xunit.DependencyInjection/tree/main/src/Xunit.DependencyInjection |

View File

@@ -14,8 +14,8 @@ survive while eliminating the traitors among them.
- [X] Traitors
- [X] Detectives
- [X] Innocents
- [ ] Shop
- [ ] Karma
- [X] Shop
- [X] Karma
- [ ] Statistics
## Versioning

View File

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

View File

@@ -5,5 +5,5 @@ public interface IEventBus {
void UnregisterListener(IListener listener);
Task Dispatch(Event ev);
void Dispatch(Event ev);
}

View File

@@ -24,7 +24,7 @@ public static class ServiceCollectionExtensions {
collection.AddTransient<ICommand>(provider
=> (provider.GetRequiredService<TExtension>() as ICommand)!);
collection.AddScoped<TExtension>();
collection.AddSingleton<TExtension>();
collection.AddTransient<ITerrorModule, TExtension>(provider
=> provider.GetRequiredService<TExtension>());

View File

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

View File

@@ -9,9 +9,4 @@ public interface IGameManager : IDisposable {
}
IGame? CreateGame();
[Obsolete("This method is ambiguous, check the game state directly.")]
bool IsGameActive() {
return ActiveGame is not null && ActiveGame.IsInProgress();
}
}

View File

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

View File

@@ -15,6 +15,12 @@
<ProjectReference Include="..\ShopAPI\ShopAPI.csproj"/>
</ItemGroup>
<ItemGroup>
<Reference Include="MAULActainShared.dll">
<HintPath>./ThirdParties/Binaries/MAULActainShared.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<Folder Include="RayTrace\"/>
</ItemGroup>

View File

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

View File

@@ -36,6 +36,7 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<IAliveSpoofer, CS2AliveSpoofer>();
collection.AddModBehavior<IIconManager, RoleIconsHandler>();
collection.AddModBehavior<NameDisplayer>();
collection.AddModBehavior<PlayerPingShopAlias>();
// Configs
collection.AddModBehavior<IStorage<TTTConfig>, CS2GameConfig>();
@@ -48,6 +49,7 @@ public static class CS2ServiceCollection {
collection
.AddModBehavior<IStorage<PoisonSmokeConfig>, CS2PoisonSmokeConfig>();
collection.AddModBehavior<IStorage<KarmaConfig>, CS2KarmaConfig>();
collection.AddModBehavior<IStorage<CamoConfig>, CS2CamoConfig>();
// TTT - CS2 Specific optionals
collection.AddScoped<ITextSpawner, TextSpawner>();
@@ -63,12 +65,15 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<MapZoneRemover>();
collection.AddModBehavior<BuyMenuHandler>();
collection.AddModBehavior<TeamChangeHandler>();
collection.AddModBehavior<TraitorChatHandler>();
collection.AddModBehavior<PlayerMuter>();
// Damage Cancelers
collection.AddModBehavior<OutOfRoundCanceler>();
collection.AddModBehavior<TaserListenCanceler>();
// Listeners
collection.AddModBehavior<AfkTimerListener>();
collection.AddModBehavior<BodyPickupListener>();
collection.AddModBehavior<IBodyTracker, BodyTracker>();
collection.AddModBehavior<LateSpawnListener>();
@@ -79,9 +84,7 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<KarmaSyncer>();
// Commands
#if DEBUG
collection.AddModBehavior<TestCommand>();
#endif
collection.AddScoped<IGameManager, CS2GameManager>();
collection.AddScoped<IInventoryManager, CS2InventoryManager>();

View File

@@ -4,6 +4,7 @@ using CounterStrikeSharp.API.Modules.Commands;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Command;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.Game;
using TTT.Game.Commands;
@@ -17,6 +18,9 @@ public class CS2CommandManager(IServiceProvider provider)
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IMessenger messenger = provider
.GetRequiredService<IMessenger>();
private BasePlugin? plugin;
public void Start(BasePlugin? basePlugin, bool hotReload) {
@@ -41,7 +45,9 @@ public class CS2CommandManager(IServiceProvider provider)
null :
converter.GetPlayer(executor) as IOnlinePlayer;
if (cmdMap.TryGetValue(info.GetArg(0), out var command))
messenger.Debug($"Received command: {cs2Info.Args[0]} from {wrapper?.Id}");
if (cmdMap.TryGetValue(cs2Info.Args[0], out var command))
if (command.MustBeOnMainThread) {
processCommandSync(cs2Info, wrapper);
return;

View File

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

View File

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

View File

@@ -7,11 +7,11 @@ 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 string Id => "emitsound";
public void Dispose() { }
public void Start() { }

View File

@@ -1,7 +1,9 @@
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Events;
using TTT.API.Command;
using TTT.API.Events;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
@@ -44,6 +46,10 @@ public class GiveItemCommand(IServiceProvider provider) : ICommand {
target = result;
}
var purchaseEv = new PlayerPurchaseItemEvent(target, item);
provider.GetRequiredService<IEventBus>().Dispatch(purchaseEv);
if (purchaseEv.IsCanceled) return;
shop.GiveItem(target, item);
info.ReplySync($"Gave item '{item.Name}' to {target.Name}.");
});
@@ -52,7 +58,8 @@ public class GiveItemCommand(IServiceProvider provider) : ICommand {
private IShopItem? searchItem(string query) {
var item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
=> it.Name.Replace(" ", "")
.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;

View File

@@ -16,7 +16,8 @@ public class IndexCommand : ICommand {
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers())
info.ReplySync($"{player.PlayerName} - {player.Slot}");
info.ReplySync(
$"{player.PlayerName} - {player.Slot} {player.Index} {player.DraftIndex} {player.PawnCharacterDefIndex}");
});
return Task.FromResult(CommandResult.SUCCESS);

View File

@@ -0,0 +1,58 @@
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Command;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class ReloadModule(IServiceProvider provider) : ICommand, IPluginModule {
public void Dispose() { }
public void Start() { }
private BasePlugin? plugin;
public string Id => "reload";
public void Start(BasePlugin? plugin) {
if (plugin == null) return;
this.plugin = plugin;
}
public string[] Usage => ["<module>"];
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (info.ArgCount != 2) return Task.FromResult(CommandResult.INVALID_ARGS);
var moduleName = info.Args[1];
var modules = provider.GetServices<ITerrorModule>();
var module = modules.FirstOrDefault(m
=> m.Id.Equals(moduleName, StringComparison.OrdinalIgnoreCase));
if (module == null) {
info.ReplySync($"Module '{moduleName}' not found.");
return Task.FromResult(CommandResult.INVALID_ARGS);
}
info.ReplySync("Reloading module '{moduleName}'...");
module.Dispose();
info.ReplySync("Starting module '{moduleName}'...");
module.Start();
info.ReplySync("Module '{moduleName}' reloaded successfully.");
if (plugin == null) {
info.ReplySync("Plugin context not found; skipping hotload steps.");
return Task.FromResult(CommandResult.SUCCESS);
}
if (module is not IPluginModule pluginModule)
return Task.FromResult(CommandResult.SUCCESS);
info.ReplySync("Hotloading plugin module '{moduleName}'...");
pluginModule.Start(plugin, true);
info.ReplySync("Plugin module '{moduleName}' hotloaded successfully.");
return Task.FromResult(CommandResult.SUCCESS);
}
}

View File

@@ -0,0 +1,41 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class SpecCommand(IServiceProvider provider) : ICommand {
public void Dispose() { }
public void Start() { }
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
var target = executor;
if (info.ArgCount == 2) {
var finder = provider.GetRequiredService<IPlayerFinder>();
var result = finder.GetPlayerByName(info.Args[1]);
if (result == null) {
info.ReplySync($"Player '{info.Args[1]}' not found.");
return Task.FromResult(CommandResult.ERROR);
}
target = result;
} else if (target == null) {
return Task.FromResult(CommandResult.PLAYER_ONLY);
}
var converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
Server.NextWorldUpdate(() => {
var player = converter.GetPlayer(target);
player?.ChangeTeam(CsTeam.Spectator);
info.ReplySync($"{target.Name} has been moved to Spectators.");
});
return Task.FromResult(CommandResult.SUCCESS);
}
}

View File

@@ -25,12 +25,18 @@ public class TestCommand(IServiceProvider provider) : ICommand, IPluginModule {
subCommands.Add("showicons", new ShowIconsCommand(provider));
subCommands.Add("sethealth", new SetHealthCommand());
subCommands.Add("emitsound", new EmitSoundCommand(provider));
subCommands.Add("credits", new CreditsCommand(provider));
subCommands.Add("spec", new SpecCommand(provider));
subCommands.Add("reload", new ReloadModule(provider));
}
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (executor == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
if (executor.Id != "76561198333588297")
return Task.FromResult(CommandResult.NO_PERMISSION);
if (info.ArgCount == 1) {
foreach (var c in subCommands.Values)
info.ReplySync(

View File

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

View File

@@ -10,31 +10,78 @@ 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",
"css_ttt_karma_db_string", "Database connection string for Karma storage",
"Data Source=karma.db");
public static readonly FakeConVar<int> CV_MIN_KARMA = new("css_ttt_karma_min",
"Minimum possible Karma value", 0, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(0, 1000));
"Minimum possible Karma value; falling below executes the low-karma command",
0, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<int> CV_DEFAULT_KARMA = new(
"css_ttt_karma_default", "Default Karma value for new players", 50,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
"css_ttt_karma_default", "Default Karma assigned to new or reset players",
50, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<string> CV_LOW_KARMA_COMMAND = new(
"css_ttt_karma_low_command",
"Command executed when a player falls below the Karma threshold (use {0} for player name)",
"css_ban #{0} 4320 Your karma is too low!");
"Command executed when a player's karma falls below the minimum (use {0} for player slot)",
"css_ban #{0} 2880 Low Karma");
public static readonly FakeConVar<int> CV_KARMA_TIMEOUT_THRESHOLD = new(
public static readonly FakeConVar<int> CV_TIMEOUT_THRESHOLD = new(
"css_ttt_karma_timeout_threshold",
"Minimum Karma to avoid punishment or timeout effects", 20,
"Minimum Karma before timing a player out for KarmaRoundTimeout rounds", 20,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<int> CV_KARMA_ROUND_TIMEOUT = new(
"css_ttt_karma_round_timeout", "Number of rounds a Karma penalty persists",
public static readonly FakeConVar<int> CV_ROUND_TIMEOUT = new(
"css_ttt_karma_round_timeout",
"Number of rounds a player is timed out for after falling below threshold",
4, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 100));
public static readonly FakeConVar<int> CV_WARNING_WINDOW_HOURS = new(
"css_ttt_karma_warning_window_hours",
"Time window (in hours) preventing repeat warnings for low karma", 24,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(1, 168));
// Karma deltas
public static readonly FakeConVar<int> CV_INNO_ON_TRAITOR = new(
"css_ttt_karma_inno_on_traitor",
"Karma gained when Innocent kills a Traitor", 4, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_DETECTIVE = new(
"css_ttt_karma_traitor_on_detective",
"Karma gained when Traitor kills a Detective", 1, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_INNO_VICTIM = new(
"css_ttt_karma_inno_on_inno_victim",
"Karma gained or lost when Innocent kills another Innocent who was a victim",
-1, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_INNO = new(
"css_ttt_karma_inno_on_inno",
"Karma lost when Innocent kills another Innocent", -5,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_TRAITOR = new(
"css_ttt_karma_traitor_on_traitor",
"Karma lost when Traitor kills another Traitor", -6, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_DETECTIVE = new(
"css_ttt_karma_inno_on_detective",
"Karma lost when Innocent kills a Detective", -8, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND = new(
"css_ttt_karma_per_round",
"Amount of karma a player will gain at the end of each round", 2,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND_WIN = new(
"css_ttt_karma_per_round_win",
"Amount of karma a player will gain at the end of each round if their team won",
4, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 50));
public void Dispose() { }
public void Start() { }
@@ -50,8 +97,17 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
MinKarma = CV_MIN_KARMA.Value,
DefaultKarma = CV_DEFAULT_KARMA.Value,
CommandUponLowKarma = CV_LOW_KARMA_COMMAND.Value,
KarmaTimeoutThreshold = CV_KARMA_TIMEOUT_THRESHOLD.Value,
KarmaRoundTimeout = CV_KARMA_ROUND_TIMEOUT.Value
KarmaTimeoutThreshold = CV_TIMEOUT_THRESHOLD.Value,
KarmaRoundTimeout = CV_ROUND_TIMEOUT.Value,
KarmaWarningWindow = TimeSpan.FromHours(CV_WARNING_WINDOW_HOURS.Value),
KarmaPerRound = CV_KARMA_PER_ROUND.Value,
KarmaPerRoundWin = CV_KARMA_PER_ROUND_WIN.Value,
INNO_ON_TRAITOR = CV_INNO_ON_TRAITOR.Value,
TRAITOR_ON_DETECTIVE = CV_TRAITOR_ON_DETECTIVE.Value,
INNO_ON_INNO_VICTIM = CV_INNO_ON_INNO_VICTIM.Value,
INNO_ON_INNO = CV_INNO_ON_INNO.Value,
TRAITOR_ON_TRAITOR = CV_TRAITOR_ON_TRAITOR.Value,
INNO_ON_DETECTIVE = CV_INNO_ON_DETECTIVE.Value
};
return Task.FromResult<KarmaConfig?>(cfg);

View File

@@ -10,15 +10,15 @@ namespace TTT.CS2.Configs;
public class CS2ShopConfig : IStorage<ShopConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_STARTING_INNOCENT_CREDITS = new(
"css_ttt_shop_start_innocent", "Starting credits for Innocents", 100,
"css_ttt_shop_start_innocent", "Starting credits for Innocents", 80,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<int> CV_STARTING_TRAITOR_CREDITS = new(
"css_ttt_shop_start_traitor", "Starting credits for Traitors", 120,
"css_ttt_shop_start_traitor", "Starting credits for Traitors", 100,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<int> CV_STARTING_DETECTIVE_CREDITS = new(
"css_ttt_shop_start_detective", "Starting credits for Detectives", 150,
"css_ttt_shop_start_detective", "Starting credits for Detectives", 120,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<int> CV_INNO_V_INNO = new(

View File

@@ -0,0 +1,37 @@
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;
namespace TTT.CS2.Configs.ShopItems;
public class CS2CamoConfig : IStorage<CamoConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_camo_price", "Price of the Camo item", 75,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<float> CV_CAMO_VISIBILITY = new(
"css_ttt_shop_camo_visibility",
"Player visibility multiplier while camouflaged (0 = invisible, 1 = fully visible)",
0.4f, ConVarFlags.FCVAR_NONE, new RangeValidator<float>(0f, 1f));
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
ArgumentNullException.ThrowIfNull(plugin, nameof(plugin));
plugin.RegisterFakeConVars(this);
}
public Task<CamoConfig?> Load() {
var cfg = new CamoConfig {
Price = CV_PRICE.Value, CamoVisibility = CV_CAMO_VISIBILITY.Value
};
return Task.FromResult<CamoConfig?>(cfg);
}
}

View File

@@ -11,7 +11,7 @@ namespace TTT.CS2.Configs.ShopItems;
public class CS2M4A1Config : IStorage<M4A1Config>, IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_m4a1_price", "Price of the M4A1 item", 90,
"css_ttt_shop_m4a1_price", "Price of the M4A1 item", 75,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<string> CV_CLEAR_SLOTS = new(

View File

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

View File

@@ -2,6 +2,7 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.UserMessages;
using CounterStrikeSharp.API.Modules.Utils;
namespace TTT.CS2.Extensions;
@@ -73,14 +74,15 @@ public static class PlayerExtensions {
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;
hasHelmet = new CCSPlayer_ItemServices(pawn.ItemServices.Handle)
.HasHelmet;
return (pawn.ArmorValue, hasHelmet);
}
@@ -106,4 +108,19 @@ public static class PlayerExtensions {
color.R | color.G << 8 | color.B << 16 | color.A << 24);
fadeMsg.Send(player);
}
public static void DealPoisonDamage(this CCSPlayerController player,
int damage) {
if (player.Pawn.Value == null) return;
player.AddHealth(-damage);
player.PlayerPawn.Value?.EmitSound("Player.DamageBody.Onlooker",
OTHERS(player.Slot), 0.2f, 1);
player.PlayerPawn.Value?.EmitSound("Player.DamageBody.Victim",
SELF(player.Slot), 0.2f, 1);
}
private static RecipientFilter SELF(int slot) => new(slot);
private static RecipientFilter OTHERS(int slot)
=> new(ulong.MaxValue & ~(1ul << slot));
}

View File

@@ -9,7 +9,7 @@ public static class VectorExtensions {
return MathF.Sqrt(vec.DistanceSquared(other));
}
public static float DistanceSquared(this Vector vec, Vector other) {
public static float DistanceSquared(this Vector? vec, Vector other) {
return (vec.X - other.X) * (vec.X - other.X)
+ (vec.Y - other.Y) * (vec.Y - other.Y)
+ (vec.Z - other.Z) * (vec.Z - other.Z);

View File

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

View File

@@ -1,9 +1,12 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Command;
using TTT.API.Player;
using TTT.CS2.Command;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
@@ -16,9 +19,6 @@ public class BuyMenuHandler(IServiceProvider provider) : IPluginModule {
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" },
@@ -27,10 +27,16 @@ public class BuyMenuHandler(IServiceProvider provider) : IPluginModule {
{ "weapon_smokegrenade", "Poison Smoke" },
{ "weapon_m4a1_silencer", "M4A1" },
{ "weapon_usp_silencer", "M4A1" },
{ "weapon_sg556", "M4A1" },
{ "weapon_mp5sd", "M4A1" },
{ "weapon_decoy", "healthshot" }
{ "weapon_decoy", "healthshot" },
{ "weapon_awp", "AWP" },
{ "weapon_hegrenade", "Cluster" }
};
public void Dispose() { }
public void Start() { }
[UsedImplicitly]
[GameEventHandler(HookMode.Pre)]
public HookResult OnPurchase(EventItemPurchase ev, GameEventInfo info) {
@@ -44,8 +50,16 @@ public class BuyMenuHandler(IServiceProvider provider) : IPluginModule {
inventory.RemoveWeapon(player, new BaseWeapon(ev.Weapon));
if (shopAliases.TryGetValue(ev.Weapon, out var alias))
ev.Userid.ExecuteClientCommandFromServer("css_buy " + alias);
if (!shopAliases.TryGetValue(ev.Weapon, out var alias))
return HookResult.Continue;
var commandManager = provider.GetRequiredService<ICommandManager>();
var newInfo = new CS2CommandInfo(provider, player, 0, "css_shop", "buy",
alias);
newInfo.CallingContext = CommandCallingContext.Chat;
commandManager.ProcessCommand(newInfo);
return HookResult.Handled;
}
}

View File

@@ -36,19 +36,19 @@ public class CombatHandler(IServiceProvider provider) : IPluginModule {
[UsedImplicitly]
[GameEventHandler(HookMode.Pre)]
public HookResult OnPlayerDeath_Pre(EventPlayerDeath ev, GameEventInfo info) {
if (games.ActiveGame is not { State: State.IN_PROGRESS })
return HookResult.Continue;
var player = ev.Userid;
if (player == null) return HookResult.Continue;
var deathEvent = new PlayerDeathEvent(converter, ev);
Server.NextWorldUpdateAsync(() => bus.Dispatch(deathEvent));
info.DontBroadcast = true;
hideAndTrackStats(ev, player);
if (games.ActiveGame is not { State: State.IN_PROGRESS })
return HookResult.Continue;
if (ev.Attacker != null) ev.FireEventToClient(ev.Attacker);
info.DontBroadcast = true;
spoofer.SpoofAlive(player);
Server.NextWorldUpdateAsync(() => bus.Dispatch(deathEvent));
return HookResult.Continue;
}
@@ -74,7 +74,6 @@ public class CombatHandler(IServiceProvider provider) : IPluginModule {
ev.Attacker.ActionTrackingServices.NumRoundKills--;
Utilities.SetStateChanged(ev.Attacker, "CCSPlayerController",
"m_pActionTrackingServices");
ev.FireEventToClient(ev.Attacker);
}
var assisterStats = ev.Assister?.ActionTrackingServices?.MatchStats;

View File

@@ -33,7 +33,6 @@ public class DamageCanceler(IServiceProvider provider) : IPluginModule {
var damagedEvent = new PlayerDamagedEvent(converter, hook);
bus.Dispatch(damagedEvent);
if (damagedEvent.IsCanceled) return HookResult.Handled;
var info = hook.GetParam<CTakeDamageInfo>(1);

View File

@@ -1,19 +1,24 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.Extensions;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
namespace TTT.CS2.Listeners;
namespace TTT.CS2.GameHandlers;
public class LateSpawnListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
[UsedImplicitly]
[EventHandler]
public void OnJoin(PlayerJoinEvent ev) {
if (Games.ActiveGame is { State: State.IN_PROGRESS }) return;
@@ -24,4 +29,17 @@ public class LateSpawnListener(IServiceProvider provider)
player.Respawn();
});
}
[UsedImplicitly]
[EventHandler]
public void GameState(GameStateUpdateEvent ev) {
if (ev.NewState is State.FINISHED or State.WAITING) return;
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers()
.Where(p => p.GetHealth() <= 0 && p.Team != CsTeam.Spectator
&& p.Team != CsTeam.None))
player.Respawn();
});
}
}

View File

@@ -9,6 +9,8 @@ namespace TTT.CS2.GameHandlers;
public class MapZoneRemover : IPluginModule {
private BasePlugin? plugin;
private bool zonesRemoved;
public void Dispose() {
plugin?.RemoveListener<CounterStrikeSharp.API.Core.Listeners.OnMapStart>(
onMapStart);
@@ -16,10 +18,8 @@ public class MapZoneRemover : IPluginModule {
public void Start() { }
private bool zonesRemoved = false;
public void Start(BasePlugin? pluginParent) {
if (pluginParent != null) this.plugin = pluginParent;
if (pluginParent != null) plugin = pluginParent;
plugin?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.OnMapStart>(
onMapStart);
}

View File

@@ -0,0 +1,55 @@
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.Messages;
using TTT.API.Player;
using TTT.CS2.lang;
using TTT.Locale;
namespace TTT.CS2.GameHandlers;
public class PlayerMuter(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
plugin
?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.OnClientVoice>(
onVoice);
}
private void onVoice(int playerSlot) {
var player = Utilities.GetPlayerFromSlot(playerSlot);
if (player == null) return;
if (player.Pawn.Value is { Health: > 0 }) return;
if ((player.VoiceFlags & VoiceFlags.Muted) != VoiceFlags.Muted) {
var apiPlayer = converter.GetPlayer(player);
messenger.Message(apiPlayer, locale[CS2Msgs.DEAD_MUTE_REMINDER]);
}
player.VoiceFlags |= VoiceFlags.Muted;
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnSpawn(EventPlayerSpawn ev, GameEventInfo _) {
var player = ev.Userid;
if (player == null) return HookResult.Continue;
player.VoiceFlags &= ~VoiceFlags.Muted;
return HookResult.Continue;
}
}

View File

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

View File

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

View File

@@ -1,17 +1,33 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.API;
using TTT.CS2.Extensions;
using TTT.Game;
using TTT.Game.Events.Player;
namespace TTT.CS2.GameHandlers;
public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
public void Dispose() { }
public void Start() { }
@@ -23,28 +39,50 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
CommandInfo commandInfo) {
CsTeam requestedTeam;
if (int.TryParse(commandInfo.GetArg(1), out var teamIndex)) {
if (player == null) return HookResult.Continue;
if (int.TryParse(commandInfo.GetArg(1), out var teamIndex))
requestedTeam = (CsTeam)teamIndex;
} else {
else
requestedTeam = commandInfo.GetArg(1).ToLower() switch {
"ct" or "counterterrorist" or "counter" => CsTeam.CounterTerrorist,
"t" or "terrorist" => CsTeam.Terrorist,
"s" or "spec" or "spectator" or "spectators" => CsTeam.Spectator,
_ => CsTeam.None
};
}
if (games.ActiveGame is not { State: State.IN_PROGRESS }) {
if (player != null && player.LifeState != (int)LifeState_t.LIFE_ALIVE)
Server.NextWorldUpdate(player.Respawn);
if (player.GetHealth() <= 0) Server.NextWorldUpdate(player.Respawn);
return HookResult.Continue;
}
if (requestedTeam is CsTeam.CounterTerrorist or CsTeam.Terrorist) {
if (player != null && player.Team is CsTeam.Spectator or CsTeam.None)
if (requestedTeam is CsTeam.CounterTerrorist or CsTeam.Terrorist)
if (player.Team is CsTeam.Spectator or CsTeam.None)
return HookResult.Continue;
}
var apiPlayer = converter.GetPlayer(player);
// If the player is dead and already identified, let them move to spec
if (bodies.Bodies.Keys.Any(b
=> b.OfPlayer.Id == apiPlayer.Id && b.IsIdentified))
return HookResult.Continue;
return HookResult.Handled;
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnChangeTeam(EventPlayerTeam ev, GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
var team = (CsTeam)ev.Team;
if (team is not (CsTeam.Spectator or CsTeam.None))
return HookResult.Continue;
var apiPlayer = converter.GetPlayer(ev.Userid);
Server.NextWorldUpdate(() => {
var playerDeath = new PlayerDeathEvent(apiPlayer);
bus.Dispatch(playerDeath);
});
return HookResult.Continue;
}
}

View File

@@ -0,0 +1,90 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Utils;
using MAULActainShared.plugin;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.lang;
using TTT.CS2.ThirdParties.eGO;
using TTT.Game.Roles;
using TTT.Locale;
namespace TTT.CS2.GameHandlers;
public class TraitorChatHandler(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IGameManager game =
provider.GetRequiredService<IGameManager>();
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private IActain? maulService;
public void Start(BasePlugin? plugin) {
try {
maulService ??= EgoApi.MAUL.Get();
if (maulService != null) {
maulService.getChatShareService().OnChatShare += OnChatShare;
return;
}
plugin?.AddCommandListener("say_team", onSay);
} catch (KeyNotFoundException) {
plugin?.AddCommandListener("say_team", onSay);
}
}
public void Dispose() {
if (maulService != null)
maulService.getChatShareService().OnChatShare -= OnChatShare;
}
public void Start() { }
private void OnChatShare(CCSPlayerController? player, CommandInfo info,
ref bool canceled) {
if (player == null) return;
if (!info.GetArg(0).Equals("say_team", StringComparison.OrdinalIgnoreCase))
return;
if (player.Team == CsTeam.CounterTerrorist) return;
var result = onSay(player, info);
canceled = true;
if (result == HookResult.Handled) return;
player?.ExecuteClientCommandFromServer("say " + info.ArgString);
}
private HookResult onSay(CCSPlayerController? player,
CommandInfo commandInfo) {
if (player == null
|| game.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED }
|| converter.GetPlayer(player) is not IOnlinePlayer apiPlayer
|| !roles.GetRoles(apiPlayer).Any(r => r is TraitorRole))
return HookResult.Continue;
var teammates = game.ActiveGame?.Players.Where(p
=> roles.GetRoles(p).Any(r => r is TraitorRole))
.ToList();
if (teammates == null) return HookResult.Continue;
var msg = commandInfo.ArgString;
if (msg.StartsWith('"') && msg.EndsWith('"') && msg.Length >= 2)
msg = msg[1..^1];
var formatted = locale[CS2Msgs.TRAITOR_CHAT_FORMAT(apiPlayer, msg)];
foreach (var mate in teammates) messenger.Message(mate, formatted);
return HookResult.Handled;
}
}

View File

@@ -16,11 +16,11 @@ public static class ArmorItemServicesCollection {
}
public class ArmorItem(IServiceProvider provider) : BaseItem(provider) {
private readonly ArmorConfig config = provider
.GetService<IStorage<ArmorConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ArmorConfig();
private ArmorConfig config
=> Provider.GetService<IStorage<ArmorConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ArmorConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();

View File

@@ -17,11 +17,11 @@ public static class BodyPaintServicesCollection {
public class BodyPaintItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly BodyPaintConfig config = provider
.GetService<IStorage<BodyPaintConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new BodyPaintConfig();
private BodyPaintConfig config
=> Provider.GetService<IStorage<BodyPaintConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new BodyPaintConfig();
public override string Name => Locale[BodyPaintMsgs.SHOP_ITEM_BODY_PAINT];

View File

@@ -0,0 +1,38 @@
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.ClusterGrenade;
public static class ClusterGrenadeServiceCollection {
public static void AddClusterGrenade(this IServiceCollection services) {
services.AddModBehavior<ClusterGrenadeItem>();
services.AddModBehavior<ClusterGrenadeListener>();
}
}
public class ClusterGrenadeItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private ClusterGrenadeConfig config
=> Provider.GetService<IStorage<ClusterGrenadeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ClusterGrenadeConfig();
public override string Name
=> Locale[ClusterGrenadeMsgs.SHOP_ITEM_CLUSTER_GRENADE];
public override string Description
=> Locale[ClusterGrenadeMsgs.SHOP_ITEM_CLUSTER_GRENADE_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) {
Inventory.GiveWeapon(player, config);
}
}

View File

@@ -0,0 +1,65 @@
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Memory;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
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;
namespace TTT.CS2.Items.ClusterGrenade;
public class ClusterGrenadeListener(IServiceProvider provider) : IPluginModule {
private ClusterGrenadeConfig config
=> provider.GetService<IStorage<ClusterGrenadeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ClusterGrenadeConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[UsedImplicitly]
[GameEventHandler]
public HookResult OnHeGrenade(EventHegrenadeDetonate 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<ClusterGrenadeItem>(player)) return HookResult.Continue;
shop.RemoveItem<ClusterGrenadeItem>(player);
for (var i = 0; i < config.GrenadeCount; i++) {
var entity =
Utilities.GetEntityFromIndex<CHEGrenadeProjectile>(ev.Entityid);
if (entity == null || entity.AbsOrigin == null) continue;
// Throw grenade in circular pattern
var angle = new Vector(
(float)(Math.Cos(2 * Math.PI / config.GrenadeCount * i)
* config.ThrowForce),
(float)(Math.Sin(2 * Math.PI / config.GrenadeCount * i)
* config.ThrowForce), config.UpForce);
if (ev.Userid.Pawn.Value == null) continue;
GrenadeDataHelper.CreateGrenade(entity.AbsOrigin, QAngle.Zero, angle,
Vector.Zero, ev.Userid.Pawn.Value.Handle, ev.Userid.Team);
}
return HookResult.Continue;
}
public void Dispose() { }
public void Start() { }
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.CS2.Items.ClusterGrenade;
public class ClusterGrenadeMsgs {
public static IMsg SHOP_ITEM_CLUSTER_GRENADE
=> MsgFactory.Create(nameof(SHOP_ITEM_CLUSTER_GRENADE));
public static IMsg SHOP_ITEM_CLUSTER_GRENADE_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_CLUSTER_GRENADE_DESC));
}

View File

@@ -0,0 +1,152 @@
using System.Linq;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Events;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.CS2.Utils;
using TTT.Game.Events.Game;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Compass;
/// <summary>
/// Base compass that renders a heading toward the nearest target returned by GetTargets.
/// Child classes decide which targets to expose and who owns the item.
/// </summary>
public abstract class AbstractCompassItem<TRole> : RoleRestrictedItem<TRole>,
IListener, IPluginModule where TRole : class, IRole {
protected readonly CompassConfig config;
protected readonly IPlayerConverter<CCSPlayerController> Converter;
protected readonly ISet<IPlayer> Owners = new HashSet<IPlayer>();
protected AbstractCompassItem(IServiceProvider provider) : base(provider) {
config = provider.GetService<IStorage<CompassConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new CompassConfig();
Converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
}
public override ShopItemConfig Config => config;
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.AddTimer(0.1f, Tick, TimerFlags.REPEAT);
}
[UsedImplicitly]
[EventHandler]
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState == State.FINISHED) Owners.Clear();
}
/// <summary>
/// Return world positions to point at for this player.
/// </summary>
protected abstract IList<Vector> GetTargets(IOnlinePlayer requester);
/// <summary>
/// Whether this player currently owns/has this compass effect.
/// </summary>
protected abstract bool OwnsItem(IOnlinePlayer player);
public override void OnPurchase(IOnlinePlayer player) { Owners.Add(player); }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return OwnsItem(player) ?
PurchaseResult.ALREADY_OWNED :
base.CanPurchase(player);
}
private void Tick() {
if (Games.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED })
return;
foreach (var player in Owners.OfType<IOnlinePlayer>()) {
var gamePlayer = Converter.GetPlayer(player);
if (gamePlayer == null) continue;
ShowCompass(gamePlayer, player);
}
}
private void ShowCompass(CCSPlayerController viewer, IOnlinePlayer online) {
if (Games.ActiveGame?.Players == null) return;
if (viewer.PlayerPawn.Value == null) return;
var src = viewer.Pawn.Value?.AbsOrigin.Clone();
if (src == null) return;
var targets = GetTargets(online).ToList();
if (targets.Count == 0) return;
var (nearest, distance) = GetNearestVector(src, targets);
if (nearest == null || distance > config.MaxRange) return;
var normalizedYaw = AdjustGameAngle(viewer.PlayerPawn.Value.EyeAngles.Y);
var diff = (nearest - src).Normalized();
var targetYaw = MathF.Atan2(diff.Y, diff.X) * 180f / MathF.PI;
targetYaw = AdjustGameAngle(targetYaw);
var compass = GenerateCompass(normalizedYaw, targetYaw);
compass = "<font color=\"#777777\">" + compass;
foreach (var c in "NESW".ToCharArray())
compass = compass.Replace(c.ToString(),
$"</font><font color=\"#FFFF00\">{c}</font><font color=\"#777777\">");
compass = compass.Replace("X",
"</font><font color=\"#FF0000\">X</font><font color=\"#777777\">");
compass += "</font>";
viewer.PrintToCenterHtml($"{compass} {GetDistanceDescription(distance)}");
}
private static float AdjustGameAngle(float angle) {
return 360 - (angle + 360) % 360 + 90;
}
private string GenerateCompass(float pointing, float target) {
return TextCompass.GenerateCompass(config.CompassFOV, config.CompassLength,
pointing, targetDir: target);
}
private static string GetDistanceDescription(float distance) {
return distance switch {
> 2000 => "AWP Distance",
> 1500 => "Scout Distance",
> 1000 => "Rifle Distance",
> 500 => "Pistol",
> 250 => "Nearby",
_ => "Knife Range"
};
}
private static (Vector?, float) GetNearestVector(in Vector src,
IList<Vector> targets) {
var minDistSq = float.MaxValue;
Vector? nearest = null;
foreach (var v in targets) {
var d2 = v.Clone().DistanceSquared(src);
if (d2 >= minDistSq) continue;
minDistSq = d2;
nearest = v;
}
return (nearest, MathF.Sqrt(minDistSq));
}
}

View File

@@ -0,0 +1,53 @@
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.API;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Compass;
public static class BodyCompassItemExtensions {
public static void
AddBodyCompassServices(this IServiceCollection collection) {
collection.AddModBehavior<BodyCompassItem>();
}
}
public class BodyCompassItem(IServiceProvider provider)
: AbstractCompassItem<DetectiveRole>(provider) {
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
public override string Name => Locale[CompassMsgs.SHOP_ITEM_COMPASS_BODY];
public override string Description
=> Locale[CompassMsgs.SHOP_ITEM_COMPASS_BODY_DESC];
/// <summary>
/// For innocents: point to nearest traitor.
/// For traitors: point to nearest non-traitor (ally list in original code).
/// Returns target world positions as vectors.
/// </summary>
protected override IList<Vector> GetTargets(IOnlinePlayer requester) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED })
return Array.Empty<Vector>();
List<Vector> vectors = [];
foreach (var (apiBody, body) in bodies.Bodies) {
if (apiBody.IsIdentified) continue;
var origin = body.AbsOrigin.Clone();
if (origin == null) continue;
vectors.Add(origin);
}
return vectors;
}
override protected bool OwnsItem(IOnlinePlayer player) {
return Shop.HasItem<BodyCompassItem>(player);
}
}

View File

@@ -0,0 +1,17 @@
using TTT.Locale;
namespace TTT.CS2.Items.Compass;
public class CompassMsgs {
public static IMsg SHOP_ITEM_COMPASS_PLAYER
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_PLAYER));
public static IMsg SHOP_ITEM_COMPASS_PLAYER_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_PLAYER_DESC));
public static IMsg SHOP_ITEM_COMPASS_BODY
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_BODY));
public static IMsg SHOP_ITEM_COMPASS_BODY_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_BODY_DESC));
}

View File

@@ -0,0 +1,62 @@
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Compass;
public static class InnoCompassItemExtensions {
public static void
AddInnoCompassServices(this IServiceCollection collection) {
collection.AddModBehavior<InnoCompassItem>();
}
}
public class InnoCompassItem(IServiceProvider provider)
: AbstractCompassItem<TraitorRole>(provider) {
public override string Name => Locale[CompassMsgs.SHOP_ITEM_COMPASS_PLAYER];
public override string Description
=> Locale[CompassMsgs.SHOP_ITEM_COMPASS_PLAYER_DESC];
/// <summary>
/// For innocents: point to nearest traitor.
/// For traitors: point to nearest non-traitor (ally list in original code).
/// Returns target world positions as vectors.
/// </summary>
protected override IList<Vector> GetTargets(IOnlinePlayer requester) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED })
return Array.Empty<Vector>();
var all = Games.ActiveGame.Players.OfType<IOnlinePlayer>()
.Where(p => p.IsAlive)
.ToList();
// Split by traitor role
var traitors = all.Where(p => Roles.GetRoles(p).Any(r => r is TraitorRole))
.ToList();
var allies = all.Where(p => !Roles.GetRoles(p).Any(r => r is TraitorRole))
.ToList();
var enemies = Roles.GetRoles(requester).Any(r => r is TraitorRole) ?
allies :
traitors;
// Convert to game controllers then to positions
var vectors = new List<Vector>(enemies.Count);
foreach (var enemy in enemies) {
var controller = Converter.GetPlayer(enemy);
var pos = controller?.Pawn.Value?.AbsOrigin.Clone();
if (pos != null) vectors.Add(pos);
}
return vectors;
}
override protected bool OwnsItem(IOnlinePlayer player) {
return Shop.HasItem<InnoCompassItem>(player);
}
}

View File

@@ -28,11 +28,11 @@ public class DnaListener(IServiceProvider provider) : BaseListener(provider) {
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
private readonly DnaScannerConfig config = provider
.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
private DnaScannerConfig config
=> Provider.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
private readonly Dictionary<string, DateTime> lastMessages = new();
private readonly IShop shop = provider.GetRequiredService<IShop>();

View File

@@ -18,11 +18,11 @@ public static class DnaScannerServiceCollection {
public class DnaScanner(IServiceProvider provider)
: RoleRestrictedItem<DetectiveRole>(provider) {
private readonly DnaScannerConfig config = provider
.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
private DnaScannerConfig config
=> Provider.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
public override string Name => Locale[DnaMsgs.SHOP_ITEM_DNA];
public override string Description => Locale[DnaMsgs.SHOP_ITEM_DNA_DESC];
@@ -31,7 +31,7 @@ public class DnaScanner(IServiceProvider provider)
public override void OnPurchase(IOnlinePlayer player) { }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
if (Shop.HasItem(player, this)) return PurchaseResult.ALREADY_OWNED;
if (Shop.HasItem<DnaScanner>(player)) return PurchaseResult.ALREADY_OWNED;
return base.CanPurchase(player);
}
}

View File

@@ -18,11 +18,11 @@ public static class OneHitKnifeServiceCollection {
public class OneHitKnife(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly OneHitKnifeConfig config = provider
.GetService<IStorage<OneHitKnifeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
private OneHitKnifeConfig config
=> Provider.GetService<IStorage<OneHitKnifeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
public override string Name
=> Locale[OneHitKnifeMsgs.SHOP_ITEM_ONE_HIT_KNIFE];

View File

@@ -13,14 +13,14 @@ 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>>()
private OneHitKnifeConfig config
=> Provider.GetService<IStorage<OneHitKnifeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[UsedImplicitly]
[EventHandler]
public void OnDamage(PlayerDamagedEvent ev) {
@@ -32,13 +32,12 @@ public class OneHitKnifeListener(IServiceProvider provider)
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;
ev.HpLeft = -100;
}
}

View File

@@ -18,11 +18,11 @@ public static class PoisonShotServiceCollection {
public class PoisonShotsItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly PoisonShotsConfig config = provider
.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonShotsConfig();
private PoisonShotsConfig config
=> Provider.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonShotsConfig();
public override string Name => Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS];

View File

@@ -13,6 +13,7 @@ using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Events.Body;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
@@ -21,6 +22,8 @@ namespace TTT.CS2.Items.PoisonShots;
public class PoisonShotsListener(IServiceProvider provider)
: BaseListener(provider), IPluginModule {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly PoisonShotsConfig config =
provider.GetService<IStorage<PoisonShotsConfig>>()
?.Load()
@@ -32,8 +35,6 @@ public class PoisonShotsListener(IServiceProvider provider)
private readonly Dictionary<IPlayer, int> poisonShots = new();
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly List<IDisposable> poisonTimers = [];
private readonly IScheduler scheduler =
@@ -41,6 +42,8 @@ public class PoisonShotsListener(IServiceProvider provider)
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly Dictionary<string, IPlayer> killedWithPoison = new();
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonTimers) timer.Dispose();
@@ -66,7 +69,6 @@ public class PoisonShotsListener(IServiceProvider provider)
if (ev.Attacker == null) return;
if (!poisonShots.TryGetValue(ev.Attacker, out var shot) || shot <= 0)
return;
Messenger.DebugAnnounce("weapon: " + ev.Weapon);
if (ev.Weapon == null || !Tag.GUNS.Contains(ev.Weapon)) return;
Messenger.Message(ev.Attacker,
Locale[PoisonShotMsgs.SHOP_ITEM_POISON_HIT(ev.Player)]);
@@ -81,6 +83,7 @@ public class PoisonShotsListener(IServiceProvider provider)
foreach (var timer in poisonTimers) timer.Dispose();
poisonTimers.Clear();
poisonShots.Clear();
killedWithPoison.Clear();
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
@@ -105,7 +108,7 @@ public class PoisonShotsListener(IServiceProvider provider)
if (!online.IsAlive) return false;
var dmgEvent = new PlayerDamagedEvent(online,
effect.Shooter as IOnlinePlayer,
effect.Shooter as IOnlinePlayer, online.Health,
online.Health - config.PoisonConfig.DamagePerTick) {
Weapon = $"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]"
};
@@ -115,19 +118,20 @@ public class PoisonShotsListener(IServiceProvider provider)
if (dmgEvent.IsCanceled) return true;
if (online.Health - config.PoisonConfig.DamagePerTick <= 0) {
killedWithPoison[online.Id] = effect.Shooter;
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);
if (gamePlayer != null)
gamePlayer.DealPoisonDamage(config.PoisonConfig.DamagePerTick);
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
@@ -158,4 +162,15 @@ public class PoisonShotsListener(IServiceProvider provider)
public int Ticks { get; set; }
public int DamageGiven { get; set; }
}
[UsedImplicitly]
[EventHandler]
public void OnRagdollSpawn(BodyCreateEvent ev) {
if (!killedWithPoison.TryGetValue(ev.Body.OfPlayer.Id, out var shooter))
return;
if (ev.Body.Killer != null && ev.Body.Killer.Id != ev.Body.OfPlayer.Id)
return;
ev.Body.Killer = shooter as IOnlinePlayer;
}
}

View File

@@ -18,8 +18,8 @@ public static class PoisonSmokeServiceCollection {
public class PoisonSmokeItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private readonly PoisonSmokeConfig config =
provider.GetService<IStorage<PoisonSmokeConfig>>()
private PoisonSmokeConfig config
=> Provider.GetService<IStorage<PoisonSmokeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonSmokeConfig();

View File

@@ -9,17 +9,24 @@ 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.Role;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Events.Body;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.CS2.Items.PoisonSmoke;
public class PoisonSmokeListener(IServiceProvider provider) : IPluginModule {
private readonly PoisonSmokeConfig config =
provider.GetService<IStorage<PoisonSmokeConfig>>()
public class PoisonSmokeListener(IServiceProvider provider)
: BaseListener(provider), IPluginModule {
private PoisonSmokeConfig config
=> Provider.GetService<IStorage<PoisonSmokeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new PoisonSmokeConfig();
@@ -27,26 +34,20 @@ public class PoisonSmokeListener(IServiceProvider provider) : IPluginModule {
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() {
private readonly ISet<string> killedWithPoison = new HashSet<string>();
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonSmokes) timer.Dispose();
poisonSmokes.Clear();
killedWithPoison.Clear();
}
public void Start() { }
[UsedImplicitly]
[GameEventHandler]
@@ -62,17 +63,18 @@ public class PoisonSmokeListener(IServiceProvider provider) : IPluginModule {
var projectile =
Utilities.GetEntityFromIndex<CSmokeGrenadeProjectile>(ev.Entityid);
if (projectile == null || !projectile.IsValid) return HookResult.Continue;
startPoisonEffect(projectile);
startPoisonEffect(projectile, player);
return HookResult.Continue;
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
private void startPoisonEffect(CSmokeGrenadeProjectile projectile) {
private void startPoisonEffect(CSmokeGrenadeProjectile projectile,
IOnlinePlayer thrower) {
IDisposable? timer = null;
var effect = new PoisonEffect(projectile);
var effect = new PoisonEffect(projectile, thrower);
timer = scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
timer = Scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
=> {
Server.NextWorldUpdate(() => {
if (tickPoisonEffect(effect) || timer == null) return;
@@ -88,31 +90,67 @@ public class PoisonSmokeListener(IServiceProvider provider) : IPluginModule {
if (!effect.Projectile.IsValid) return false;
effect.Ticks++;
var players = finder.GetOnline()
.Where(player => player.IsAlive && roleAssigner.GetRoles(player)
var players = Finder.GetOnline()
.Where(player => player.IsAlive && Roles.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()!));
var gamePlayers = players.Select(p => (p, converter.GetPlayer(p)))
.Where(p => p.Item2 != null && p.Item2.Pawn.Value != null
&& p.Item2.Pawn.Value.IsValid)
.Select(p => (p!, p.Item2?.Pawn.Value?.AbsOrigin.Clone()!));
gamePlayers = gamePlayers.Where(t
=> t.Item2.Distance(effect.Origin) <= config.SmokeRadius);
foreach (var player in gamePlayers.Select(p => p.Item1)) {
foreach (var (apiPlayer, gamePlayer) in gamePlayers.Select(p => p.Item1)) {
if (effect.DamageGiven >= config.PoisonConfig.TotalDamage) continue;
player.AddHealth(-config.PoisonConfig.DamagePerTick);
player.ExecuteClientCommand("play " + config.PoisonConfig.PoisonSound);
if (gamePlayer.GetHealth() - config.PoisonConfig.DamagePerTick <= 0) {
killedWithPoison.Add(apiPlayer.Id);
var playerDeathEvent = new PlayerDeathEvent(apiPlayer)
.WithKiller(effect.Attacker as IOnlinePlayer)
.WithWeapon("[Poison Smoke]");
Bus.Dispatch(playerDeathEvent);
gamePlayer.SetHealth(0);
continue;
}
var dmgEvent = new PlayerDamagedEvent(apiPlayer,
effect.Attacker as IOnlinePlayer, config.PoisonConfig.DamagePerTick) {
Weapon = "[Poison Smoke]"
};
Bus.Dispatch(dmgEvent);
gamePlayer.DealPoisonDamage(config.PoisonConfig.DamagePerTick);
effect.DamageGiven += config.PoisonConfig.DamagePerTick;
}
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
private class PoisonEffect(CSmokeGrenadeProjectile projectile) {
private class PoisonEffect(CSmokeGrenadeProjectile projectile,
IOnlinePlayer attacker) {
public int Ticks { get; set; }
public int DamageGiven { get; set; }
public Vector Origin { get; } = projectile.AbsOrigin.Clone()!;
public CSmokeGrenadeProjectile Projectile { get; } = projectile;
public IPlayer Attacker { get; } = attacker;
}
[UsedImplicitly]
[EventHandler]
public void OnGameEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
killedWithPoison.Clear();
}
[UsedImplicitly]
[EventHandler]
public void OnRagdollSpawn(BodyCreateEvent ev) {
if (!killedWithPoison.Contains(ev.Body.OfPlayer.Id)) return;
if (ev.Body.Killer == null || ev.Body.Killer.Id == ev.Body.OfPlayer.Id)
ev.IsCanceled = true;
}
}

View File

@@ -0,0 +1,104 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.UserMessages;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
using TTT.Game.Roles;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
namespace TTT.CS2.Items.SilentAWP;
public static class SilentAWPServiceCollection {
public static void AddSilentAWPServices(this IServiceCollection services) {
services.AddModBehavior<SilentAWPItem>();
}
}
public class SilentAWPItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider), IPluginModule {
private SilentAWPConfig config
=> Provider.GetService<IStorage<SilentAWPConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new SilentAWPConfig();
private readonly IPlayerConverter<CCSPlayerController> playerConverter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IDictionary<string, int> silentShots =
new Dictionary<string, int>();
public override string Name => Locale[SilentAWPMsgs.SHOP_ITEM_SILENT_AWP];
public override string Description
=> Locale[SilentAWPMsgs.SHOP_ITEM_SILENT_AWP_DESC];
public override ShopItemConfig Config => config;
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.HookUserMessage(452, onWeaponSound);
}
public override void OnPurchase(IOnlinePlayer player) {
silentShots[player.Id] = config.CurrentAmmo ?? 0 + config.ReserveAmmo ?? 0;
Task.Run(async () => {
await Inventory.RemoveWeaponInSlot(player, 0);
await Inventory.GiveWeapon(player, config);
});
}
private HookResult onWeaponSound(UserMessage msg) {
var defIndex = msg.ReadUInt("item_def_index");
if (config.WeaponIndex != defIndex) return HookResult.Continue;
var splits = msg.DebugString.Split("\n");
if (splits.Length < 5) return HookResult.Continue;
var angleLines = msg.DebugString.Split("\n")[1..4]
.Select(s => s.Trim())
.ToList();
if (!angleLines[0].Contains('x') || !angleLines[1].Contains('y')
|| !angleLines[2].Contains('z'))
return HookResult.Continue;
var x = float.Parse(angleLines[0].Split(' ')[1]);
var y = float.Parse(angleLines[1].Split(' ')[1]);
var z = float.Parse(angleLines[2].Split(' ')[1]);
var vec = new Vector(x, y, z);
var player = findPlayerByCoord(vec);
if (player == null) return HookResult.Continue;
if (playerConverter.GetPlayer(player) is not IOnlinePlayer apiPlayer)
return HookResult.Continue;
if (!silentShots.TryGetValue(apiPlayer.Id, out var shots) || shots <= 0)
return HookResult.Continue;
silentShots[apiPlayer.Id] = shots - 1;
if (silentShots[apiPlayer.Id] == 0) {
silentShots.Remove(apiPlayer.Id);
Shop.RemoveItem<SilentAWPItem>(apiPlayer);
}
msg.Recipients.Clear();
return HookResult.Handled;
}
private CCSPlayerController? findPlayerByCoord(Vector vec) {
foreach (var pl in Utilities.GetPlayers()) {
var origin = pl.GetEyePosition();
if (origin == null) continue;
var dist = vec.DistanceSquared(origin);
if (dist < 1) return pl;
}
return null;
}
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.CS2.Items.SilentAWP;
public class SilentAWPMsgs {
public static IMsg SHOP_ITEM_SILENT_AWP
=> MsgFactory.Create(nameof(SHOP_ITEM_SILENT_AWP));
public static IMsg SHOP_ITEM_SILENT_AWP_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_SILENT_AWP_DESC));
}

View File

@@ -1,12 +1,18 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs.Traitor;
using TTT.API.Events;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.CS2.Utils;
using TTT.Game.Events.Body;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Roles;
@@ -23,7 +29,9 @@ public class DamageStation(IServiceProvider provider)
provider.GetService<IStorage<DamageStationConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DamageStationConfig()) {
.GetResult() ?? new DamageStationConfig()), IListener {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
@@ -33,13 +41,14 @@ public class DamageStation(IServiceProvider 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];
private Dictionary<string, StationInfo> killedWithStation =
new Dictionary<string, StationInfo>();
override protected void onInterval() {
var players = finder.GetOnline();
var toRemove = new List<CPhysicsPropMultiplayer>();
@@ -73,28 +82,46 @@ public class DamageStation(IServiceProvider provider)
(int)Math.Floor(_Config.HealthIncrements * healthScale);
var dmgEvent = new PlayerDamagedEvent(player,
info.Owner as IOnlinePlayer, player.Health + damageAmount) {
Weapon = $"[{Name}]"
};
info.Owner as IOnlinePlayer, damageAmount) { Weapon = $"[{Name}]" };
bus.Dispatch(dmgEvent);
damageAmount = -dmgEvent.DmgDealt;
player.Health += damageAmount;
info.HealthGiven += damageAmount;
if (player.Health + damageAmount <= 0) {
killedWithStation[player.Id] = info;
var playerDeath = new PlayerDeathEvent(player)
.WithKiller(info.Owner as IOnlinePlayer)
.WithWeapon($"[{Name}]");
bus.Dispatch(playerDeath);
}
gamePlayer.ExecuteClientCommand("play " + _Config.UseSound);
player.Health += damageAmount;
info.HealthGiven += damageAmount;
gamePlayer.EmitSound("Player.DamageFall", null, 0.2f);
}
}
foreach (var prop in toRemove) props.Remove(prop);
}
[UsedImplicitly]
[EventHandler]
public void OnGameEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
killedWithStation.Clear();
}
[UsedImplicitly]
[EventHandler]
public void OnRagdollSpawn(BodyCreateEvent ev) {
if (!killedWithStation.TryGetValue(ev.Body.OfPlayer.Id,
out var stationInfo))
return;
if (ev.Body.Killer != null && ev.Body.Killer.Id != ev.Body.OfPlayer.Id)
return;
ev.Body.Killer = stationInfo.Owner as IOnlinePlayer;
}
}

View File

@@ -49,13 +49,14 @@ public class HealthStation(IServiceProvider provider)
foreach (var (player, dist) in playerDists) {
var maxHp = player.Pawn.Value?.MaxHealth ?? 100;
var healthScale = 1.0 - dist / _Config.MaxRange;
var healAmount =
var maxHealAmo =
(int)Math.Ceiling(_Config.HealthIncrements * healthScale);
var newHealth = Math.Min(player.GetHealth() + healAmount, maxHp);
var newHealth = Math.Min(player.GetHealth() + maxHealAmo, maxHp);
var healthGiven = newHealth - player.GetHealth();
player.SetHealth(newHealth);
info.HealthGiven += healAmount;
info.HealthGiven += healthGiven;
player.ExecuteClientCommand("play " + _Config.UseSound);
if (healthGiven > 0) player.EmitSound("HealthShot.Pickup", null, 0.1f);
}
}

View File

@@ -11,18 +11,20 @@ using TTT.API;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
namespace TTT.CS2.Items.Station;
public abstract class StationItem<T>(IServiceProvider provider,
StationConfig config)
: RoleRestrictedItem<T>(provider), IPluginModule where T : IRole {
private readonly long PROP_SIZE_SQUARED = 500;
protected readonly StationConfig _Config = config;
protected readonly IPlayerConverter<CCSPlayerController> Converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly long PROP_SIZE_SQUARED = 500;
protected readonly Dictionary<CPhysicsPropMultiplayer, StationInfo> props =
new();
@@ -126,12 +128,15 @@ public abstract class StationItem<T>(IServiceProvider provider,
if (gamePlayer == null || !gamePlayer.Pawn.IsValid
|| gamePlayer.Pawn.Value == null)
return;
var spawnPos = gamePlayer.Pawn.Value.AbsOrigin.Clone();
if (spawnPos != null && gamePlayer.PlayerPawn.Value != null) {
var forward = gamePlayer.PlayerPawn.Value.EyeAngles.ToForward();
forward.Z = 0;
spawnPos += forward.Normalized() * 8;
}
var spawnPos = gamePlayer.GetEyePosition();
var forward = gamePlayer.Pawn.Value.AbsRotation;
if (spawnPos == null) return;
if (forward == null) forward = new QAngle(0, 0, 0);
spawnPos += forward.ToForward() * 50;
prop.Teleport(spawnPos);
});

View File

@@ -0,0 +1,67 @@
using System.Drawing;
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.CS2.lang;
using TTT.CS2.Utils;
using TTT.Game;
using TTT.Game.Events.Game;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.CS2.Listeners;
public class AfkTimerListener(IServiceProvider provider)
: BaseListener(provider) {
private TTTConfig config
=> provider.GetRequiredService<IStorage<TTTConfig>>()
.Load()
.GetAwaiter()
.GetResult() ?? new TTTConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private IDisposable? specTimer, specWarnTimer;
[UsedImplicitly]
[EventHandler(IgnoreCanceled = true)]
public void OnRoundStart(GameStateUpdateEvent ev) {
if (ev.NewState != State.IN_PROGRESS) return;
specWarnTimer?.Dispose();
specWarnTimer = Scheduler.Schedule(config.RoundCfg.CheckAFKTimespan / 2, ()
=> {
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers()
.Where(p
=> p.PlayerPawn.Value != null
&& !p.PlayerPawn.Value.HasMovedSinceSpawn)) {
var apiPlayer = converter.GetPlayer(player);
var timetill = config.RoundCfg.CheckAFKTimespan / 2;
Messenger.Message(apiPlayer, Locale[CS2Msgs.AFK_WARNING(timetill)]);
}
});
});
specTimer?.Dispose();
specTimer = Scheduler.Schedule(config.RoundCfg.CheckAFKTimespan, () => {
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers()
.Where(p
=> p.PlayerPawn.Value != null
&& !p.PlayerPawn.Value.HasMovedSinceSpawn)) {
player.ChangeTeam(CsTeam.Spectator);
}
});
});
}
}

View File

@@ -15,21 +15,22 @@ using TTT.Karma.lang;
namespace TTT.CS2.Listeners;
public class KarmaBanner(IServiceProvider provider) : BaseListener(provider) {
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
private 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();
private readonly IKarmaService karma =
provider.GetRequiredService<IKarmaService>();
private readonly Dictionary<IPlayer, DateTime> lastWarned = new();
[UsedImplicitly]
[EventHandler(Priority = Priority.MONITOR, IgnoreCanceled = true)]
public void OnKarmaUpdate(KarmaUpdateEvent ev) {

View File

@@ -1,5 +1,6 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
@@ -24,6 +25,7 @@ public class PlayerStatsTracker(IServiceProvider provider) : IListener {
public void Dispose() { }
[UsedImplicitly]
[EventHandler(Priority = Priority.MONITOR)]
public void OnIdentify(BodyIdentifyEvent ev) {
var gamePlayer = converter.GetPlayer(ev.Body.OfPlayer);
@@ -40,6 +42,7 @@ public class PlayerStatsTracker(IServiceProvider provider) : IListener {
// Needs to be higher so we detect the kill before the game ends
// in the case that this is the last player
[UsedImplicitly]
[EventHandler(Priority = Priority.HIGH)]
public void OnKill(PlayerDeathEvent ev) {
var killer = ev.Killer == null ? null : converter.GetPlayer(ev.Killer);
@@ -59,6 +62,7 @@ public class PlayerStatsTracker(IServiceProvider provider) : IListener {
}
}
[UsedImplicitly]
[EventHandler]
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState == State.IN_PROGRESS) {

View File

@@ -22,18 +22,15 @@ namespace TTT.CS2.Listeners;
public class RoundTimerListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly TTTConfig config = provider
.GetRequiredService<IStorage<TTTConfig>>()
.Load()
.GetAwaiter()
.GetResult() ?? new TTTConfig();
private TTTConfig config
=> Provider.GetRequiredService<IStorage<TTTConfig>>()
.Load()
.GetAwaiter()
.GetResult() ?? new TTTConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IScheduler scheduler = provider
.GetRequiredService<IScheduler>();
private IDisposable? endTimer;
[UsedImplicitly]
@@ -45,7 +42,9 @@ public class RoundTimerListener(IServiceProvider provider)
.TotalSeconds);
Server.ExecuteCommand("mp_ignore_round_win_conditions 1");
foreach (var player in Utilities.GetPlayers()
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE))
.Where(p => p.GetHealth() <= 0 && p is {
Team: CsTeam.CounterTerrorist or CsTeam.Terrorist
}))
player.Respawn();
foreach (var player in Utilities.GetPlayers())
@@ -55,6 +54,15 @@ public class RoundTimerListener(IServiceProvider provider)
return;
}
if (ev.NewState == State.IN_PROGRESS)
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers()
.Where(p => p.GetHealth() <= 0 && p is {
Team: CsTeam.CounterTerrorist or CsTeam.Terrorist
}))
player.Respawn();
});
if (ev.NewState == State.FINISHED) endTimer?.Dispose();
if (ev.NewState != State.IN_PROGRESS) return;
var duration = config.RoundCfg.RoundDuration(ev.Game.Players.Count);
@@ -62,7 +70,7 @@ public class RoundTimerListener(IServiceProvider provider)
=> RoundUtil.SetTimeRemaining((int)duration.TotalSeconds));
endTimer?.Dispose();
endTimer = scheduler.Schedule(duration,
endTimer = Scheduler.Schedule(duration,
() => {
Server.NextWorldUpdate(()
=> ev.Game.EndGame(EndReason.TIMEOUT(new InnocentRole(Provider))));

View File

@@ -1,5 +1,7 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using JetBrains.Annotations;
using TTT.API;
using TTT.CS2.API;
@@ -52,8 +54,16 @@ public class CS2AliveSpoofer : IAliveSpoofer, IPluginModule {
onTick);
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnDisconnect(EventPlayerDisconnect ev) {
if (ev.Userid == null) return HookResult.Continue;
_fakeAlivePlayers.Remove(ev.Userid);
return HookResult.Continue;
}
private void onTick() {
_fakeAlivePlayers.RemoveWhere(p => !p.IsValid);
_fakeAlivePlayers.RemoveWhere(p => !p.IsValid || p.Handle == IntPtr.Zero);
foreach (var player in _fakeAlivePlayers) {
player.PawnIsAlive = true;
Utilities.SetStateChanged(player, "CCSPlayerController",

View File

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

Binary file not shown.

View File

@@ -0,0 +1,9 @@
using CounterStrikeSharp.API.Core.Capabilities;
using MAULActainShared.plugin;
namespace TTT.CS2.ThirdParties.eGO;
public class EgoApi {
public static PluginCapability<IActain> MAUL { get; } =
new("maulactain:core");
}

View File

@@ -0,0 +1,87 @@
using System.Net;
using System.Numerics;
using System.Runtime.InteropServices;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
namespace TTT.CS2.Utils;
public class DamageDealingHelper {
public static void DealDamage(CCSPlayerController target,
CCSPlayerController? attacker, int damage, string source,
DamageTypes_t type = DamageTypes_t.DMG_BLAST_SURFACE) {
if (target.Pawn.Value == null) return;
var infoSize = Schema.GetClassSize("CTakeDamageInfo");
var infoPtr = Marshal.AllocHGlobal(infoSize);
for (var i = 0; i < infoSize; i++) Marshal.WriteByte(infoPtr, i, 0);
var damageInfo = new CTakeDamageInfo(infoPtr);
Schema.SetSchemaValue(damageInfo.Handle, "CTakeDamageInfo", "m_hInflictor",
attacker != null ? attacker.Pawn.Raw : 0);
Schema.SetSchemaValue(damageInfo.Handle, "CTakeDamageInfo", "m_hAttacker",
attacker != null ? attacker.EntityHandle.Raw : 0);
damageInfo.Damage = damage;
damageInfo.BitsDamageType = type;
if (target.Pawn.Value?.AbsOrigin != null)
Schema.SetSchemaValue(damageInfo.Handle, "CTakeDamageInfo",
"m_vecDamagePosition",
target.Pawn.Value != null ?
target.Pawn.Value.AbsOrigin.Handle :
Vector.Zero.Handle);
Schema.SetSchemaValue(damageInfo.Handle, "CTakeDamageInfo",
"m_vecDamageForce", Vector.Zero.Handle);
var damageResultSize = Schema.GetClassSize("CTakeDamageResult");
var damageResultPtr = Marshal.AllocHGlobal(damageResultSize);
for (var i = 0; i < damageResultSize; i++)
Marshal.WriteByte(damageResultPtr, i, 0);
var damageResult = new CTakeDamageResult(damageResultPtr);
Schema.SetSchemaValue(damageResult.Handle, "CTakeDamageResult",
"m_pOriginatingInfo", damageInfo.Handle);
damageResult.HealthLost = damage;
damageResult.DamageDealt = damage;
damageResult.TotalledHealthLost = damage;
damageResult.TotalledDamageDealt = damage;
damageResult.WasDamageSuppressed = false;
if (target.EntityHandle.Value != null)
VirtualFunctions.CBaseEntity_TakeDamageOldFunc.Invoke(
target.EntityHandle.Value, damageInfo, damageResult);
Marshal.FreeHGlobal(infoPtr);
Marshal.FreeHGlobal(damageResultPtr);
}
}
[StructLayout(LayoutKind.Explicit)]
public struct CAttackerInfo {
[FieldOffset(0x0)]
public bool NeedInit;
[FieldOffset(0x1)]
public bool IsPawn;
[FieldOffset(0x2)]
public bool IsWorld;
[FieldOffset(0x4)]
public UInt32 AttackerPawn;
[FieldOffset(0x8)]
public ushort AttackerUserId;
[FieldOffset(0x0C)]
public int TeamChecked;
[FieldOffset(0x10)]
public int TeamNum;
}

View File

@@ -0,0 +1,32 @@
using System.Runtime.InteropServices;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
using CounterStrikeSharp.API.Modules.Utils;
using TTT.CS2.RayTrace.Class;
using Address = TTT.CS2.Utils.Address;
namespace TTT.CS2.Items.ClusterGrenade;
public class GrenadeDataHelper {
private static readonly CHEGrenadeProjectile_CreateDelegate
CHEGrenadeProjectile_CreateFunc;
static GrenadeDataHelper() {
var heGrenadeSignature = NativeAPI.FindSignature(Addresses.ServerPath,
GameData.GetSignature("CHEGrenadeProjectile_CreateFunc"));
CHEGrenadeProjectile_CreateFunc =
Marshal
.GetDelegateForFunctionPointer<CHEGrenadeProjectile_CreateDelegate>(
heGrenadeSignature);
}
private delegate int CHEGrenadeProjectile_CreateDelegate(IntPtr position,
IntPtr angle, IntPtr velocity, IntPtr velocityAngle, IntPtr thrower,
int weaponId, byte team);
public static int CreateGrenade(Vector position, QAngle angle,
Vector velocity, Vector velocityAngle, IntPtr thrower, CsTeam team) {
return CHEGrenadeProjectile_CreateFunc(position.Handle, angle.Handle,
velocity.Handle, velocityAngle.Handle, thrower, 44, (byte)team);
}
}

View File

@@ -0,0 +1,81 @@
namespace TTT.CS2.Utils;
public static class TextCompass {
/// <summary>
/// Builds a compass line with at most one character for each of N, E, S, W.
/// 0° = North, 90° = East, angles increase clockwise.
/// </summary>
/// <param name="fov">Field of view in degrees [0..360].</param>
/// <param name="width">Output width in characters.</param>
/// <param name="direction">Facing direction in degrees.</param>
/// <param name="filler">Filler character for empty slots.</param>
/// <param name="targetDir"></param>
public static string GenerateCompass(float fov, int width, float direction,
char filler = '·', float? targetDir = null) {
if (width <= 0) return string.Empty;
fov = Math.Clamp(fov, 0.001f, 360f);
direction = Normalize(direction);
var buf = new char[width];
for (var i = 0; i < width; i++) buf[i] = filler;
var start = direction - fov / 2f; // left edge of view
var degPerChar = fov / width;
PlaceIfVisible('N', 0f);
PlaceIfVisible('E', 90f);
PlaceIfVisible('S', 180f);
PlaceIfVisible('W', 270f);
if (targetDir.HasValue) PlaceIfVisible('X', targetDir.Value);
return new string(buf);
void PlaceIfVisible(char c, float cardinalAngle) {
var delta = ForwardDelta(start, cardinalAngle); // [0..360)
if (delta < 0f || delta >= fov) return; // outside view
// Map degrees to nearest character cell
var idx = (int)MathF.Round(delta / degPerChar);
if (idx < 0) idx = 0;
if (idx >= width) idx = width - 1;
// Nudge left/right to avoid collisions when possible
if (buf[idx] == filler) {
buf[idx] = c;
return;
}
var maxRadius = Math.Max(idx, width - 1 - idx);
for (var r = 1; r <= maxRadius; r++) {
var left = idx - r;
if (left >= 0 && buf[left] == filler) {
buf[left] = c;
return;
}
var right = idx + r;
if (right < width && buf[right] == filler) {
buf[right] = c;
return;
}
}
// If no space, overwrite the original cell as a last resort
buf[idx] = c;
}
}
private static float Normalize(float angle) {
angle %= 360f;
return angle < 0 ? angle + 360f : angle;
}
// Delta moving forward from start to target, wrapped to [0..360)
private static float ForwardDelta(float start, float target) {
var s = Normalize(start);
var t = Normalize(target);
var d = t - s;
return d < 0 ? d + 360f : d;
}
}

View File

@@ -26,5 +26,12 @@
"windows": "48 89 5C 24 ? 48 89 4C 24 ? 55 57",
"linux": "55 48 89 E5 41 57 49 89 CF 41 56 49 89 F6 41 55 4D 89 C5 41 54 49 89 D4 53 4C 89 CB"
}
},
"CHEGrenadeProjectile_CreateFunc": {
"signatures": {
"library": "server",
"windows": "48 89 5C 24 08 48 89 6C 24 10 48 89 74 24 18 57 48 83 EC 40 48 8B 6C 24 70",
"linux": "55 4C 89 C1 48 89 E5 41 57 49 89 D7"
}
}
}

View File

@@ -9,9 +9,22 @@ public static class CS2Msgs {
public static IMsg ROLE_SPECTATOR
=> MsgFactory.Create(nameof(ROLE_SPECTATOR));
public static IMsg DEAD_MUTE_REMINDER
=> MsgFactory.Create(nameof(DEAD_MUTE_REMINDER));
public static IMsg TASER_SCANNED(IPlayer scannedPlayer, IRole role) {
var rolePrefix = GameMsgs.GetRolePrefix(role);
return MsgFactory.Create(nameof(TASER_SCANNED),
rolePrefix + scannedPlayer.Name, role.Name);
}
public static IMsg AFK_WARNING(TimeSpan span) {
return MsgFactory.Create(nameof(AFK_WARNING), span.TotalSeconds);
}
public static IMsg AFK_MOVED => MsgFactory.Create(nameof(AFK_MOVED));
public static IMsg TRAITOR_CHAT_FORMAT(IOnlinePlayer player, string msg) {
return MsgFactory.Create(nameof(TRAITOR_CHAT_FORMAT), player.Name, msg);
}
}

View File

@@ -1,6 +1,11 @@
ROLE_SPECTATOR: "Spectator"
ROLE_SPECTATOR: "Spectator"
TRAITOR_CHAT_FORMAT: "{darkred}[TRAITORS] {red}{0}: {default}{1}"
TASER_SCANNED: "%PREFIX%You scanned {0}{grey}, they are %an% {1}{grey}!"
DNA_PREFIX: "{darkblue}D{blue}N{lightblue}A{grey} | {grey}"
AFK_WARNING: "%PREFIX%You will be moved to Spectator mode in {0} second%s% for being AFK."
AFK_MOVED: "%PREFIX%You have been moved to Spectator mode for being AFK."
DEAD_MUTE_REMINDER: "%PREFIX%You are dead and cannot be heard."
SHOP_ITEM_DNA: "DNA Scanner"
SHOP_ITEM_DNA_DESC: "Scan bodies to reveal the person who killed them."
@@ -32,4 +37,16 @@ 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."
SHOP_ITEM_ONE_HIT_KNIFE_DESC: "Your next knife hit will be a guaranteed kill."
SHOP_ITEM_COMPASS_PLAYER: "Player Compass"
SHOP_ITEM_COMPASS_PLAYER_DESC: "Reveals the direction that the nearest non-Traitor is in."
SHOP_ITEM_COMPASS_BODY: "Body Compass"
SHOP_ITEM_COMPASS_BODY_DESC: "Reveals the direction that the nearest unidentified body is in."
SHOP_ITEM_SILENT_AWP: "Silent AWP"
SHOP_ITEM_SILENT_AWP_DESC: "Receive a silenced AWP with limited ammo."
SHOP_ITEM_CLUSTER_GRENADE: "Cluster Grenade"
SHOP_ITEM_CLUSTER_GRENADE_DESC: "A grenade that splits into multiple smaller grenades."

View File

@@ -1,6 +1,6 @@
<Project>
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.340"/>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.342"/>
<PackageReference Include="System.Reactive" Version="6.0.1"/>
</ItemGroup>

View File

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

View File

@@ -27,29 +27,24 @@ public class EventBus(IServiceProvider provider) : IEventBus, ITerrorModule {
}
}
public Task Dispatch(Event ev) {
public void Dispatch(Event ev) {
var type = ev.GetType();
handlers.TryGetValue(type, out var list);
if (list == null || list.Count == 0) return Task.CompletedTask;
if (list == null || list.Count == 0) return;
ICancelableEvent? cancelable = null;
if (ev is ICancelableEvent) cancelable = (ICancelableEvent)ev;
List<Task> tasks = [];
foreach (var (listener, method) in list) {
if (cancelable is { IsCanceled: true } && method
.GetCustomAttribute<EventHandlerAttribute>()
?.IgnoreCanceled == true)
continue;
var result = method.Invoke(listener, [ev]);
if (result is Task task) tasks.Add(task);
method.Invoke(listener, [ev]);
}
return Task.WhenAll(tasks);
}
public void Dispose() { handlers.Clear(); }
@@ -85,6 +80,11 @@ public class EventBus(IServiceProvider provider) : IEventBus, ITerrorModule {
var attr = method.GetCustomAttribute<EventHandlerAttribute>();
if (attr == null) return;
if (method.ReturnType != typeof(void))
throw new InvalidOperationException(
$"Method {method.Name} in {listener.GetType().Name} "
+ "must have void return type.");
var parameters = method.GetParameters();
if (parameters.Length != 1
|| !typeof(Event).IsAssignableFrom(parameters[0].ParameterType))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -38,6 +38,7 @@ public record TTTConfig {
public record RoundConfig {
public TimeSpan CountDownDuration { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan TimeBetweenRounds { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan CheckAFKTimespan { get; init; } = TimeSpan.FromSeconds(60);
public int MinimumPlayers { get; init; } = 2;
public virtual TimeSpan RoundDuration(int players) {

View File

@@ -27,6 +27,12 @@ public static class GameMsgs {
public static IMsg GAME_LOGS_FOOTER
=> MsgFactory.Create(nameof(GAME_LOGS_FOOTER));
public static IMsg GAME_LOGS_NONE
=> MsgFactory.Create(nameof(GAME_LOGS_NONE));
public static IMsg LOGS_VIEWED_INFO
=> MsgFactory.Create(nameof(LOGS_VIEWED_INFO));
public static IMsg ROLE_REVEAL_DEATH(IRole killerRole) {
return MsgFactory.Create(nameof(ROLE_REVEAL_DEATH),
GetRolePrefix(killerRole) + killerRole.Name);
@@ -79,6 +85,10 @@ public static class GameMsgs {
#endregion
public static IMsg LOGS_VIEWED_ALIVE(IPlayer player) {
return MsgFactory.Create(nameof(LOGS_VIEWED_ALIVE), player.Name);
}
#region GENERIC
public static IMsg GENERIC_UNKNOWN(string command) {

View File

@@ -21,4 +21,7 @@ GAME_STATE_ENDED_OTHER: "%PREFIX%{blue}GAME! {default}{0}{grey}."
NOT_ENOUGH_PLAYERS: "%PREFIX%{red}Game was canceled due to having fewer than {yellow}{0}{red} player%s%."
BODY_IDENTIFIED: "%PREFIX%{default}{0}{grey} identified the body of {blue}{1}{grey}, they were %an% {2}{grey}!"
GAME_LOGS_HEADER: "---------- Game Logs ----------"
GAME_LOGS_FOOTER: "-------------------------------"
GAME_LOGS_FOOTER: "-------------------------------"
GAME_LOGS_NONE: "%PREFIX%There is no game active."
LOGS_VIEWED_ALIVE: "%PREFIX%{red}{0}{grey} viewed the logs while alive."
LOGS_VIEWED_INFO: "%PREFIX%Logs printed to console. All players' roles have been shown."

View File

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

View File

@@ -4,6 +4,7 @@ using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
@@ -12,25 +13,26 @@ using TTT.Game.Roles;
namespace TTT.Karma;
public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private static readonly int INNO_ON_TRAITOR = 2;
private static readonly int TRAITOR_ON_DETECTIVE = 1;
private static readonly int INNO_ON_INNO_VICTIM = -1;
private static readonly int INNO_ON_INNO = -4;
private static readonly int TRAITOR_ON_TRAITOR = -5;
private static readonly int INNO_ON_DETECTIVE = -6;
private readonly Dictionary<string, int> badKills = new();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IKarmaService karma =
provider.GetRequiredService<IKarmaService>();
private readonly Dictionary<IPlayer, int> queuedKarmaUpdates = new();
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private readonly Dictionary<IPlayer, int> queuedKarmaUpdates = new();
public bool GiveKarmaOnRoundEnd = true;
[EventHandler]
[UsedImplicitly]
@@ -45,6 +47,7 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
var killer = ev.Killer;
if (killer == null) return;
if (victim.Id == killer.Id) return;
var victimRole = roles.GetRoles(victim).First();
var killerRole = roles.GetRoles(killer).First();
@@ -63,18 +66,20 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
case InnocentRole when killerRole is TraitorRole:
return;
case InnocentRole:
victimKarmaDelta = INNO_ON_INNO_VICTIM;
killerKarmaDelta = INNO_ON_INNO;
victimKarmaDelta = config.INNO_ON_INNO_VICTIM;
killerKarmaDelta = config.INNO_ON_INNO;
break;
case TraitorRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_TRAITOR :
INNO_ON_TRAITOR;
config.TRAITOR_ON_TRAITOR :
config.INNO_ON_TRAITOR;
break;
case DetectiveRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_DETECTIVE :
INNO_ON_DETECTIVE;
config.TRAITOR_ON_DETECTIVE :
config.INNO_ON_DETECTIVE;
if (killerRole is DetectiveRole)
victimKarmaDelta = config.INNO_ON_INNO_VICTIM;
break;
}
@@ -88,18 +93,27 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
[UsedImplicitly]
[EventHandler]
public Task OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return Task.CompletedTask;
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
var tasks = new List<Task>();
foreach (var (player, karmaDelta) in queuedKarmaUpdates) {
tasks.Add(Task.Run(async () => {
var winner = ev.Game.WinningRole;
if (GiveKarmaOnRoundEnd)
foreach (var player in ev.Game.Players)
if (Roles.GetRoles(player).Any(r => r.GetType() == winner?.GetType()))
queuedKarmaUpdates[player] =
queuedKarmaUpdates.GetValueOrDefault(player, 0)
+ config.KarmaPerRoundWin;
else
queuedKarmaUpdates[player] =
queuedKarmaUpdates.GetValueOrDefault(player, 0)
+ config.KarmaPerRound;
foreach (var (player, karmaDelta) in queuedKarmaUpdates)
Task.Run(async () => {
var newKarma = await karma.Load(player) + karmaDelta;
await karma.Write(player, newKarma);
}));
}
});
queuedKarmaUpdates.Clear();
return Task.WhenAll(tasks);
}
}

View File

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

View File

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

View File

@@ -16,6 +16,8 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly IItemSorter? sorter = provider.GetService<IItemSorter>();
public void Dispose() { }
public string Id => "buy";
public void Start() { }
@@ -41,7 +43,7 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
}
var query = string.Join(" ", info.Args.Skip(1));
var item = searchItem(query);
var item = searchItem(executor, query);
if (item == null) {
info.ReplySync(locale[ShopMsgs.SHOP_ITEM_NOT_FOUND(query)]);
@@ -54,20 +56,47 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
CommandResult.ERROR);
}
private IShopItem? searchItem(string query) {
var item = shop.Items.FirstOrDefault(it
private IShopItem? searchItem(IOnlinePlayer? player, string query) {
if (sorter != null && int.TryParse(query, out var id)) {
var items = sorter.GetSortedItems(player);
if (id >= 0 && id < items.Count) return items[id];
return null;
}
var searchSet = sortItems(player);
var item = searchSet.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
item = searchSet.FirstOrDefault(it
=> it.Name.Contains(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
item = searchSet.FirstOrDefault(it
=> it.Description.Contains(query, StringComparison.OrdinalIgnoreCase));
return item;
}
private List<IShopItem> sortItems(IOnlinePlayer? player) {
var items = new List<IShopItem>(shop.Items).ToList();
items.Sort((a, b) => {
var aPrice = a.Config.Price;
var bPrice = b.Config.Price;
var aCanBuy = player != null
&& a.CanPurchase(player) == PurchaseResult.SUCCESS;
var bCanBuy = player != null
&& b.CanPurchase(player) == PurchaseResult.SUCCESS;
if (aCanBuy && !bCanBuy) return -1;
if (!aCanBuy && bCanBuy) return 1;
if (aPrice != bPrice) return aPrice.CompareTo(bPrice);
return string.Compare(a.Name, b.Name, StringComparison.Ordinal);
});
return items;
}
}

View File

@@ -4,13 +4,27 @@ using ShopAPI;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop.Commands;
public class ListCommand(IServiceProvider provider) : ICommand {
public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly IDictionary<string, List<IShopItem>> cache =
new Dictionary<string, List<IShopItem>>();
private readonly IGameManager games = provider
.GetRequiredService<IGameManager>();
private readonly IDictionary<string, DateTime> lastUpdate =
new Dictionary<string, DateTime>();
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
private readonly IRoleAssigner roles = provider
.GetRequiredService<IRoleAssigner>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
@@ -21,19 +35,58 @@ public class ListCommand(IServiceProvider provider) : ICommand {
public async Task<CommandResult> Execute(IOnlinePlayer? executor,
ICommandInfo info) {
var items = new List<IShopItem>(shop.Items).Where(item
=> executor == null
|| games.ActiveGame is not { State: State.IN_PROGRESS }
|| item.CanPurchase(executor) != PurchaseResult.WRONG_ROLE)
.ToList();
var items = calculateSortedItems(executor);
if (executor != null) cache[executor.Id] = items;
items = new List<IShopItem>(items);
items.Reverse();
var balance = executor == null ? int.MaxValue : await shop.Load(executor);
foreach (var (index, item) in items.Select((value, i) => (i, value))) {
var canPurchase = executor == null
|| item.CanPurchase(executor) == PurchaseResult.SUCCESS;
canPurchase = canPurchase && item.Config.Price <= balance;
info.ReplySync(formatItem(item, items.Count - index, canPurchase));
}
if (games.ActiveGame is not { State: State.IN_PROGRESS }
|| executor == null)
return CommandResult.SUCCESS;
var role = roles.GetRoles(executor).FirstOrDefault();
if (role == null) return CommandResult.SUCCESS;
info.ReplySync(locale[ShopMsgs.SHOP_LIST_FOOTER(role, balance)]);
return CommandResult.SUCCESS;
}
public List<IShopItem> GetSortedItems(IOnlinePlayer? player,
bool refresh = false) {
if (player == null) return calculateSortedItems(null);
if (refresh || !cache.ContainsKey(player.Id))
cache[player.Id] = calculateSortedItems(player);
return cache[player.Id];
}
public DateTime? GetLastUpdate(IOnlinePlayer? player) {
if (player == null) return null;
lastUpdate.TryGetValue(player.Id, out var time);
return time;
}
private List<IShopItem> calculateSortedItems(IOnlinePlayer? player) {
var items = new List<IShopItem>(shop.Items).Where(item
=> player == null
|| games.ActiveGame is not { State: State.IN_PROGRESS }
|| item.CanPurchase(player) != PurchaseResult.WRONG_ROLE)
.ToList();
items.Sort((a, b) => {
var aPrice = a.Config.Price;
var bPrice = b.Config.Price;
var aCanBuy = executor != null
&& a.CanPurchase(executor) == PurchaseResult.SUCCESS;
var bCanBuy = executor != null
&& b.CanPurchase(executor) == PurchaseResult.SUCCESS;
var aCanBuy = player != null
&& a.CanPurchase(player) == PurchaseResult.SUCCESS;
var bCanBuy = player != null
&& b.CanPurchase(player) == PurchaseResult.SUCCESS;
if (aCanBuy && !bCanBuy) return -1;
if (!aCanBuy && bCanBuy) return 1;
@@ -41,29 +94,25 @@ public class ListCommand(IServiceProvider provider) : ICommand {
return string.Compare(a.Name, b.Name, StringComparison.Ordinal);
});
var balance = info.CallingPlayer == null ?
int.MaxValue :
await shop.Load(info.CallingPlayer);
foreach (var item in items)
info.ReplySync(formatItem(item,
item.Config.Price <= balance
&& item.CanPurchase(info.CallingPlayer ?? executor!)
== PurchaseResult.SUCCESS));
return CommandResult.SUCCESS;
if (player != null) lastUpdate[player.Id] = DateTime.Now;
return items;
}
private string formatPrefix(IShopItem item, bool canBuy = true) {
private string formatPrefix(IShopItem item, int index, bool canBuy) {
if (!canBuy)
return
$" {ChatColors.Grey}- [{ChatColors.DarkRed}{item.Config.Price}{ChatColors.Grey}] {ChatColors.Red}{item.Name}";
if (index > 9)
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
$" {ChatColors.Blue}/{index} {ChatColors.Default}| [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
}
private string formatItem(IShopItem item, bool canBuy) {
private string formatItem(IShopItem item, int index, bool canBuy) {
return
$" {formatPrefix(item, canBuy)} {ChatColors.Grey} | {item.Description}";
$" {formatPrefix(item, index, canBuy)} {ChatColors.Grey} | {item.Description}";
}
}

View File

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

View File

@@ -18,11 +18,11 @@ public static class StickerExtensions {
public class Stickers(IServiceProvider provider)
: RoleRestrictedItem<DetectiveRole>(provider) {
private readonly StickersConfig config = provider
.GetService<IStorage<StickersConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new StickersConfig();
private StickersConfig config
=> Provider.GetService<IStorage<StickersConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new StickersConfig();
public override string Name => Locale[StickerMsgs.SHOP_ITEM_STICKERS];
@@ -34,7 +34,7 @@ public class Stickers(IServiceProvider provider)
public override void OnPurchase(IOnlinePlayer player) { }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
if (Shop.HasItem(player, this)) return PurchaseResult.ALREADY_OWNED;
if (Shop.HasItem<Stickers>(player)) return PurchaseResult.ALREADY_OWNED;
return base.CanPurchase(player);
}
}

View File

@@ -1,9 +1,13 @@
using Microsoft.Extensions.DependencyInjection;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API.Events;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Events.Game;
using TTT.Game.Roles;
namespace TTT.Shop.Items.Healthshot;
@@ -14,9 +18,10 @@ public static class HealthshotServiceCollection {
}
}
public class HealthshotItem(IServiceProvider provider) : BaseItem(provider) {
private readonly HealthshotConfig config =
provider.GetService<IStorage<HealthshotConfig>>()
public class HealthshotItem(IServiceProvider provider)
: BaseItem(provider), IListener {
private HealthshotConfig config
=> Provider.GetService<IStorage<HealthshotConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new HealthshotConfig();
@@ -28,11 +33,27 @@ public class HealthshotItem(IServiceProvider provider) : BaseItem(provider) {
public override ShopItemConfig Config => config;
private readonly Dictionary<string, int> purchaseCounts = new();
public override void OnPurchase(IOnlinePlayer player) {
Inventory.GiveWeapon(player, new BaseWeapon(config.Weapon));
purchaseCounts.TryGetValue(player.Id, out var purchases);
purchaseCounts[player.Id] = purchases + 1;
}
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return PurchaseResult.SUCCESS;
if (purchaseCounts.TryGetValue(player.Id, out var purchases))
return PurchaseResult.SUCCESS;
return purchases < config.MaxPurchases ?
PurchaseResult.SUCCESS :
PurchaseResult.ALREADY_OWNED;
}
[UsedImplicitly]
[EventHandler]
public void OnGameState(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
purchaseCounts.Clear();
}
}

View File

@@ -31,21 +31,18 @@ public class DeagleDamageListener(IServiceProvider provider)
if (attacker == null) return;
var deagleItem = shop.GetOwnedItems(attacker)
.FirstOrDefault(s => s is OneShotDeagleItem);
if (deagleItem == null) return;
if (!shop.HasItem<OneShotDeagleItem>(attacker)) return;
if (ev.Weapon != config.Weapon)
// CS2 specifically causes the weapon to be "weapon_deagle" even if
// the player is holding a revolver, so we need to check for that as well
if (ev.Weapon is not "weapon_deagle"
|| !config.Weapon.Equals("weapon_revolver"))
if (ev.Weapon != "weapon_deagle" || config.Weapon != "weapon_revolver")
return;
var attackerRole = Roles.GetRoles(attacker);
var victimRole = Roles.GetRoles(victim);
shop.RemoveItem(attacker, deagleItem);
shop.RemoveItem<OneShotDeagleItem>(attacker);
var attackerIsTraitor = attackerRole.Any(r => r is TraitorRole);
var victimIsTraitor = victimRole.Any(r => r is TraitorRole);
if (attackerIsTraitor == victimIsTraitor) {
@@ -57,7 +54,6 @@ public class DeagleDamageListener(IServiceProvider provider)
}
}
if (victim is not IOnlinePlayer onlineVictim) return;
onlineVictim.Health = 0;
ev.HpLeft = -100;
}
}

View File

@@ -17,11 +17,11 @@ public static class DeagleServiceCollection {
public class OneShotDeagleItem(IServiceProvider provider)
: BaseItem(provider), IWeapon {
private readonly OneShotDeagleConfig deagleConfigStorage = provider
.GetService<IStorage<OneShotDeagleConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneShotDeagleConfig();
private OneShotDeagleConfig deagleConfigStorage
=> Provider.GetService<IStorage<OneShotDeagleConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneShotDeagleConfig();
public override string Name => Locale[DeagleMsgs.SHOP_ITEM_DEAGLE];

View File

@@ -24,7 +24,7 @@ public class GlovesListener(IServiceProvider provider)
private readonly Dictionary<IPlayer, int> uses = new();
[UsedImplicitly]
[EventHandler]
[EventHandler(Priority = Priority.LOW)]
public void BodyCreate(BodyCreateEvent ev) {
if (ev.Body.Killer == null || !useGloves(ev.Body.Killer)) return;
if (ev.Body.Killer is not IOnlinePlayer online) return;

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