Compare commits

...

40 Commits

Author SHA1 Message Date
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
MSWS
9a005c209a Cleanup & Reformat 2025-10-07 10:06:34 -07:00
MSWS
36914d01a5 refactor: Move BuyMenu handler into GameHandlers 2025-10-07 09:59:26 -07:00
MSWS
62e48e6c73 update(traitor): Buff hurt stations 2025-10-07 09:55:43 -07:00
MSWS
82a4c47dfb Merge branch 'main' into dev 2025-10-07 09:48:53 -07:00
MSWS
31f02ec7f0 ci: Cleanup release.yml 2025-10-07 09:48:48 -07:00
MSWS
36be427f56 Crash fixes for TeamChangeHandler 2025-10-05 17:17:53 -07:00
MSWS
8709d633b1 Fix one shot revolver improperly handling detective case 2025-10-05 00:36:33 -07:00
MSWS
1c63b3fbcc feat: Implement karma, team changes, and taser rewards
```
- Update PlayerKillListener to modify balance adjustments and simplify kill log messages.
- Add TeamChangeHandler to CS2ServiceCollection for managing team changes.
- Introduce TaseRewarder to ShopServiceCollection, enabling taser damage event handling.
- Make RoleAssignCreditor asynchronous and integrate karma-based credit adjustments.
- Update SpectatorRole assignment logic with new IRoleAssigner dependency.
- Make player parameter nullable in KarmaConfig's MaxKarma method.
- Refactor BuyListener to remove server chat notifications and streamline armor setting.
```
2025-10-04 22:53:11 -07:00
MSWS
f1521fc499 feat: Implement purchase handling and enhance karma update +semver:minor
- Update `KarmaStorage.cs` to increase karma update frequency and integrate Counter Strike features.
- Enhance `CS2ServiceCollection.cs` with new `BuyListener` behavior for handling purchase activities.
- Add `GetArmor` method in `PlayerExtensions.cs` to retrieve player armor and helmet status.
- Implement `BuyListener.cs` to manage player purchases, logging, and inventory adjustments.
2025-10-04 21:57:04 -07:00
MSWS
986f30c34f Remove all buyzones 2025-10-04 20:35:33 -07:00
MSWS
be6d79dcea test: Refactor damage events and enhance debug logging
- Simplify `PlayerDamagedEvent` instantiation by removing an unused parameter in `DeagleTests.cs` and `PlayerActionsTest.cs`.
- Update `HpLeft` calculation in `PlayerDamagedEvent.cs` to use a new formula for determining remaining health, improving damage calculation accuracy.
- Enhance debugging in `PoisonShotsListener.cs` by adding debug logs and null checks for more reliable damage event tracking.
- Refactor tests in `DeagleTests.cs` to verify unique item kill behavior after the first use.
2025-10-04 20:14:26 -07:00
MSWS
87d55aadd2 refactor: Refactor event handling and improve localization. +semver:minor
- Simplify `PlayerDamagedEvent` constructor and update health calculations for clarity and consistency.
- Add context to damage events in `DamageStation.cs` by including weapon property and streamline damage calculation logic.
- Enhance `PoisonShotsListener.cs` with localization support for weapon names, improve poison effect handling, and cleanup code for better maintainability.
2025-10-04 19:53:21 -07:00
MSWS
9efd37063b refactor: Refactor damage logic and event handling
- Refactor `OneHitKnifeListener` to set victim's health directly to zero for simplified one-hit kill logic.
- Add `PlayerDamagedEvent` in `DamageStation` to allow external interference in damage calculation and consider event modifications.
- Streamline `PlayerDamagedEvent` class by refactoring damage calculation logic and removing unnecessary properties and fields.
- Update `PoisonShotsListener` to include dependency on `IEventBus`, enhance poison effects, and dispatch relevant events such as `PlayerDamagedEvent` and `PlayerDeathEvent`.
- Remove `DamageEventTest` class, indicating a refactor or change in damage event management/testing strategy.
2025-10-04 19:04:24 -07:00
Isaac
5565c72b5b Add Karma and One-Hit Knife Item (#103) 2025-10-04 09:53:53 -07:00
Isaac
05daee24a4 Shop and gameplay expansion: new items, localization, periodic rewards, command refactor (#94)
**Summary**

This PR delivers a substantial TTT update that expands the shop, refines
gameplay flow, and improves player feedback. It adds multiple new items
with CS2-specific behavior, introduces a periodic reward system,
implements configurable sounds and full localization, and cleans up
internal command and event handling.

**Scope**

* 56 commits
* 117 files changed
* +2,518 −301

**Why**

* Broaden traitor and detective toolkits to increase strategic variety.
* Make messaging consistent and translatable.
* Reduce friction in command handling and event wiring.
* Close a set of outstanding issues.

**Highlights**

New items and features

* C4 item with CS2 configuration (resolves #79).
* M4A1 item and storage support (resolves #71).
* Traitor gloves item (resolves #81).
* Camouflage item with async fix.
* Poison shots with enhanced effects and feedback (resolves #75).
* Poison smoke (resolves #74).
* Armor item (resolves #67).
* Taser item (resolves #69).
* Healthshot and role reveal (resolves #98).
* Body Paint item and service registration with default config.
* Periodic reward system for players (resolves #97).
* Credits granted for kills and identifications with tuned penalties.

Player experience

* Configurable sound support.
* Full localization for commands and messages.
* Increased HUD font size and adjusted text positioning.
* Shop prefix and HUD tweaks. Skull hidden in HUD.

Core refactors

* Command execution support on main thread. Command structure
consolidated and clarified.
* Round timer and C4 event handling refactored. Old C4 listener logic
removed.
* Event namespaces and item handling reorganized.
* Name formatting utilities updated.

Stability and fixes

* Shop events now reliably fire.
* Role assignment logging corrected.
* Credits no longer overridden by balance clearer.
* Various cleanup, reformatting, and unused line removals.

Administrative

* Licenses updated for new dependencies and content.
* Multiple merges and resyncs with main to keep branch current.

**Configuration changes**

* New item configs: C4, M4A1, Gloves, Camouflage, Poison Shots, Poison
Smoke, Armor, Taser, Healthshot, Body Paint.
* Added localization keys for new messages, commands, and out-of-ammo
lines.
* Sound configuration is now user configurable.
* Station role behavior updated to support Poison Smoke changes.

**API and plugin touchpoints**

* Storage and plugin modules extended for M4A1Config.
* PlayerKillListener added and registered.
* BodyPaint service registered and applied by default where configured.

**Testing**

* Unit and runtime checks for:

  * Shop purchase flows for each new item.
  * Command execution on main thread and permission checks.
  * Credits earn and clear at round transitions.
  * Poison effects timing, damage application, particles, and feedback.
  * HUD text size and positions across common resolutions.
  * Localization fallback behavior for missing keys.
* Manual CS2 tests for C4 behavior and item equips.
* Verified shop events fire for buy, equip, and use.
2025-10-04 05:50:51 -07:00
89 changed files with 1184 additions and 303 deletions

View File

@@ -13,10 +13,8 @@ jobs:
auto-release:
runs-on: ubuntu-latest
env:
# Tweak these if you want a different model or style
OPENAI_MODEL: gpt-4o-mini
OPENAI_TEMPERATURE: "0.2"
# Safety: cap how many characters we feed to the model
OPENAI_TEMPERATURE: "0.3"
MAX_CHANGELOG_CHARS: "50000"
steps:
@@ -67,7 +65,6 @@ jobs:
echo "tag=0.0.0" >> $GITHUB_OUTPUT
fi
# 3. Tag if new version
- name: Create and push new tag
if: steps.gitversion.outputs.fullSemVer != steps.latest_tag.outputs.tag
run: |
@@ -76,7 +73,6 @@ jobs:
git tag ${{ steps.gitversion.outputs.fullSemVer }}
git push origin ${{ steps.gitversion.outputs.fullSemVer }}
# 4. Determine previous relevant tag (lineage-aware)
- name: Determine previous relevant tag
id: prev_tag
run: |
@@ -103,7 +99,6 @@ jobs:
echo "tag=${prev:-0.0.0}" >> "$GITHUB_OUTPUT"
# 5. Generate changelog using local git (no compare API)
- name: Generate changelog
run: |
set -euo pipefail
@@ -134,7 +129,6 @@ jobs:
cat CHANGELOG.md
# 5b. Rewrite changelog with OpenAI
- name: Rewrite changelog with OpenAI
id: ai_changelog
if: success()
@@ -158,7 +152,7 @@ jobs:
# Build the JSON body. We feed system guidance and the raw changelog
# See OpenAI Responses API docs for the schema and output_text helper. :contentReference[oaicite:0]{index=0}
jq -Rs --arg sys "You are an expert release-notes writer. Given a list of changes in various formats (e.g: commits, merges, etc.), write release notes intended for reading by the public, grouping by features, features, and other pertinent groups where appropriate. Do not include a group if it is not necessary / populated. Remove internal ticket IDs and commit hashes unless essential. Merge duplicates. Use imperative, past tense voice voice. Output valid Markdown only." \
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
@@ -200,7 +194,6 @@ jobs:
echo "Rewritten changelog:"
cat CHANGELOG.md
# 6. Create release using the (possibly rewritten) changelog
- name: Create GitHub release
uses: softprops/action-gh-release@v2
with:

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

@@ -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

@@ -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

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

View File

@@ -36,6 +36,7 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<IAliveSpoofer, CS2AliveSpoofer>();
collection.AddModBehavior<IIconManager, RoleIconsHandler>();
collection.AddModBehavior<NameDisplayer>();
collection.AddModBehavior<PlayerPingShopAlias>();
// Configs
collection.AddModBehavior<IStorage<TTTConfig>, CS2GameConfig>();
@@ -60,6 +61,9 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<PropMover>();
collection.AddModBehavior<RoundStart_GameStartHandler>();
collection.AddModBehavior<BombPlantSuppressor>();
collection.AddModBehavior<MapZoneRemover>();
collection.AddModBehavior<BuyMenuHandler>();
collection.AddModBehavior<TeamChangeHandler>();
// Damage Cancelers
collection.AddModBehavior<OutOfRoundCanceler>();

View File

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

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

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

View File

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

View File

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

View File

@@ -74,6 +74,17 @@ 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;
return (pawn.ArmorValue, hasHelmet);
}
public static void ColorScreen(this CCSPlayerController player, Color color,
float hold = 0.1f, float fade = 0.2f, FadeFlags flags = FadeFlags.FADE_IN,
bool withPurge = true) {

View File

@@ -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

@@ -7,7 +7,6 @@ using TTT.CS2.Roles;
using TTT.CS2.Utils;
using TTT.Game;
using TTT.Game.Events.Game;
using TTT.Game.lang;
using TTT.Game.Roles;
namespace TTT.CS2.Game;

View File

@@ -0,0 +1,51 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Player;
using TTT.CS2.Extensions;
using TTT.Game.Roles;
namespace TTT.CS2.GameHandlers;
public class BuyMenuHandler(IServiceProvider provider) : IPluginModule {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IInventoryManager inventory =
provider.GetRequiredService<IInventoryManager>();
private readonly Dictionary<string, string> shopAliases = new() {
{ "item_assaultsuit", "Armor" },
{ "item_kevlar", "Armor" },
{ "weapon_taser", "Taser" },
{ "weapon_deagle", "Revolver" },
{ "weapon_smokegrenade", "Poison Smoke" },
{ "weapon_m4a1_silencer", "M4A1" },
{ "weapon_usp_silencer", "M4A1" },
{ "weapon_mp5sd", "M4A1" },
{ "weapon_decoy", "healthshot" }
};
public void Dispose() { }
public void Start() { }
[UsedImplicitly]
[GameEventHandler(HookMode.Pre)]
public HookResult OnPurchase(EventItemPurchase ev, GameEventInfo info) {
if (ev.Userid == null) return HookResult.Continue;
if (converter.GetPlayer(ev.Userid) is not IOnlinePlayer player)
return HookResult.Continue;
if (ev.Weapon is "item_assaultsuit" or "item_kevlar") {
var user = ev.Userid;
user.SetArmor(0);
}
inventory.RemoveWeapon(player, new BaseWeapon(ev.Weapon));
if (shopAliases.TryGetValue(ev.Weapon, out var alias))
ev.Userid.ExecuteClientCommandFromServer("css_buy " + alias);
return HookResult.Handled;
}
}

View File

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

View File

@@ -0,0 +1,40 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using JetBrains.Annotations;
using TTT.API;
namespace TTT.CS2.GameHandlers;
public class MapZoneRemover : IPluginModule {
private BasePlugin? plugin;
private bool zonesRemoved;
public void Dispose() {
plugin?.RemoveListener<CounterStrikeSharp.API.Core.Listeners.OnMapStart>(
onMapStart);
}
public void Start() { }
public void Start(BasePlugin? pluginParent) {
if (pluginParent != null) plugin = pluginParent;
plugin?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.OnMapStart>(
onMapStart);
}
private void onMapStart(string mapName) { zonesRemoved = false; }
[UsedImplicitly]
[GameEventHandler]
public HookResult OnRoundStart(EventRoundStart _, GameEventInfo _2) {
if (zonesRemoved) return HookResult.Continue;
var buyzones =
Utilities.FindAllEntitiesByDesignerName<CBuyZone>("func_buyzone");
foreach (var zone in buyzones) zone.Remove();
zonesRemoved = true;
return HookResult.Continue;
}
}

View File

@@ -0,0 +1,48 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Game;
namespace TTT.CS2.GameHandlers;
public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
public void Dispose() { }
public void Start() { }
public void Start(BasePlugin? plugin) {
plugin?.AddCommandListener("jointeam", onJoinTeam);
}
private HookResult onJoinTeam(CCSPlayerController? player,
CommandInfo commandInfo) {
CsTeam requestedTeam;
if (int.TryParse(commandInfo.GetArg(1), out var teamIndex))
requestedTeam = (CsTeam)teamIndex;
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);
return HookResult.Continue;
}
if (requestedTeam is CsTeam.CounterTerrorist or CsTeam.Terrorist)
if (player != null && player.Team is CsTeam.Spectator or CsTeam.None)
return HookResult.Continue;
return HookResult.Handled;
}
}

View File

@@ -0,0 +1,162 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Timers;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.CS2.Utils;
using TTT.Game.Roles;
namespace TTT.CS2.Items.Compass;
public static class CompassServiceCollection {
public static void AddCompassServices(this IServiceCollection collection) {
collection.AddModBehavior<CompassItem>();
}
}
public class CompassItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider), IPluginModule {
private readonly CompassConfig config =
provider.GetService<IStorage<CompassConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new CompassConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public override string Name => Locale[CompassMsgs.SHOP_ITEM_COMPASS];
public override string Description
=> Locale[CompassMsgs.SHOP_ITEM_COMPASS_DESC];
public override ShopItemConfig Config => config;
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.AddTimer(0.5f, tick, TimerFlags.REPEAT);
}
public override void OnPurchase(IOnlinePlayer player) { }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return Shop.HasItem<CompassItem>(player) ?
PurchaseResult.ALREADY_OWNED :
base.CanPurchase(player);
}
private void tick() {
if (Games.ActiveGame is not { State: State.IN_PROGRESS or State.FINISHED })
return;
var traitors = Games.ActiveGame.Players.OfType<IOnlinePlayer>()
.Where(p => p.IsAlive)
.Where(p => Roles.GetRoles(p).Any(r => r is TraitorRole))
.ToList();
var allies = Games.ActiveGame.Players.OfType<IOnlinePlayer>()
.Where(p => p.IsAlive)
.Where(p => !Roles.GetRoles(p).Any(r => r is TraitorRole))
.ToList();
foreach (var gamePlayer in Utilities.GetPlayers()) {
var player = converter.GetPlayer(gamePlayer);
if (player is not IOnlinePlayer online) continue;
if (!Shop.HasItem<CompassItem>(online)) continue;
showRadarTo(gamePlayer, online, traitors, allies);
}
}
private void showRadarTo(CCSPlayerController player, IOnlinePlayer online,
IList<IOnlinePlayer> traitors, List<IOnlinePlayer> allies) {
if (Games.ActiveGame?.Players == null) return;
if (player.PlayerPawn.Value == null) return;
var enemies = getEnemies(online, traitors, allies);
if (enemies.Count == 0) return;
var gameEnemies = enemies.Select(e => converter.GetPlayer(e))
.Where(e => e != null)
.Select(e => e!)
.ToList();
if (gameEnemies.Count == 0) return;
var (nearestPlayer, distance) =
getNearest(player, gameEnemies) ?? (null, 0);
if (nearestPlayer == null || distance > config.MaxRange) return;
var src = player.Pawn.Value?.AbsOrigin.Clone();
var dst = nearestPlayer.Pawn.Value?.AbsOrigin.Clone();
if (src == null || dst == null) return;
var normalizedYaw = adjustGameAngle(player.PlayerPawn.Value.EyeAngles.Y);
var diff = (dst - 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>";
player.PrintToCenterHtml($"{compass} {getDistanceDescription(distance)}");
}
private 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 string getDistanceDescription(float distance) {
return distance switch {
> 2000 => "AWP Distance",
> 1500 => "Scout Distance",
> 1000 => "Rifle Distance",
> 500 => "Pistol",
> 250 => "Nearby",
_ => "Knife Range"
};
}
private IList<IOnlinePlayer> getEnemies(IOnlinePlayer online,
IList<IOnlinePlayer> traitors, IList<IOnlinePlayer> allies) {
return Roles.GetRoles(online).Any(r => r is TraitorRole) ?
allies :
traitors;
}
private (CCSPlayerController?, float)? getNearest(CCSPlayerController source,
List<CCSPlayerController> others) {
if (others.Count == 0) return null;
var minDist = float.MaxValue;
var minPlayer = others[0];
var src = source.Pawn.Value?.AbsOrigin.Clone();
if (src == null) return null;
foreach (var player in others) {
if (player.Pawn.Value == null) continue;
var dist = player.Pawn.Value.AbsOrigin.Clone().DistanceSquared(src);
if (dist >= minDist) continue;
minDist = dist;
minPlayer = player;
}
return (minPlayer, MathF.Sqrt(minDist));
}
}

View File

@@ -0,0 +1,11 @@
using TTT.Locale;
namespace TTT.CS2.Items.Compass;
public class CompassMsgs {
public static IMsg SHOP_ITEM_COMPASS
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS));
public static IMsg SHOP_ITEM_COMPASS_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_DESC));
}

View File

@@ -1,6 +1,6 @@
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game.lang;
using TTT.Game;
using TTT.Locale;
namespace TTT.CS2.Items.DNA;

View File

@@ -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

@@ -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>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[UsedImplicitly]
[EventHandler]
public void OnDamage(PlayerDamagedEvent ev) {
@@ -39,6 +39,6 @@ public class OneHitKnifeListener(IServiceProvider provider)
if (friendly && !config.FriendlyFire) return;
shop.RemoveItem<OneHitKnife>(attacker);
ev.DmgDealt = onlineVictim.Health;
ev.HpLeft = 0;
}
}

View File

@@ -21,6 +21,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()
@@ -64,9 +66,10 @@ public class PoisonShotsListener(IServiceProvider provider)
if (ev.Attacker == null) return;
if (!poisonShots.TryGetValue(ev.Attacker, out var shot) || shot <= 0)
return;
if (ev.Weapon == null || !Tag.GUNS.Contains(ev.Weapon)) return;
Messenger.Message(ev.Attacker,
Locale[PoisonShotMsgs.SHOP_ITEM_POISON_HIT(ev.Player)]);
addPoisonEffect(ev.Player);
addPoisonEffect(ev.Player, ev.Attacker);
}
[UsedImplicitly]
@@ -80,10 +83,10 @@ public class PoisonShotsListener(IServiceProvider provider)
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
private void addPoisonEffect(IPlayer player) {
private void addPoisonEffect(IPlayer player, IPlayer shooter) {
IDisposable? timer = null;
var effect = new PoisonEffect(player);
var effect = new PoisonEffect(player, shooter);
timer = scheduler.SchedulePeriodic(config.PoisonConfig.TimeBetweenDamage, ()
=> {
Server.NextWorldUpdate(() => {
@@ -99,6 +102,24 @@ public class PoisonShotsListener(IServiceProvider provider)
private bool tickPoison(PoisonEffect effect) {
if (effect.Player is not IOnlinePlayer online) return false;
if (!online.IsAlive) return false;
var dmgEvent = new PlayerDamagedEvent(online,
effect.Shooter as IOnlinePlayer, online.Health,
online.Health - config.PoisonConfig.DamagePerTick) {
Weapon = $"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]"
};
bus.Dispatch(dmgEvent);
if (dmgEvent.IsCanceled) return true;
if (online.Health - config.PoisonConfig.DamagePerTick <= 0) {
var deathEvent = new PlayerDeathEvent(online)
.WithKiller(effect.Shooter as IOnlinePlayer)
.WithWeapon($"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]");
bus.Dispatch(deathEvent);
}
online.Health -= config.PoisonConfig.DamagePerTick;
effect.Ticks++;
effect.DamageGiven += config.PoisonConfig.DamagePerTick;
@@ -130,8 +151,9 @@ public class PoisonShotsListener(IServiceProvider provider)
return 0;
}
private class PoisonEffect(IPlayer player) {
private class PoisonEffect(IPlayer player, IPlayer shooter) {
public IPlayer Player { get; } = player;
public IPlayer Shooter { get; } = shooter;
public int Ticks { get; set; }
public int DamageGiven { get; set; }
}

View File

@@ -0,0 +1,100 @@
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 readonly SilentAWPConfig config =
provider.GetService<IStorage<SilentAWPConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new SilentAWPConfig();
private readonly IPlayerConverter<CCSPlayerController> playerConverter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IDictionary<IOnlinePlayer, int> silentShots =
new Dictionary<IOnlinePlayer, int>();
public override string Name => Locale[SilentAWPMsgs.SHOP_ITEM_SILENT_AWP];
public override string Description
=> 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] = config.CurrentAmmo ?? 0 + config.ReserveAmmo ?? 0;
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, out var shots) || shots <= 0)
return HookResult.Continue;
silentShots[apiPlayer] = shots - 1;
if (silentShots[apiPlayer] == 0) {
silentShots.Remove(apiPlayer);
Shop.RemoveItem<SilentAWPItem>(apiPlayer);
}
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,5 +1,4 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs.Traitor;
using TTT.API.Events;
@@ -25,6 +24,8 @@ public class DamageStation(IServiceProvider provider)
?.Load()
.GetAwaiter()
.GetResult() ?? new DamageStationConfig()) {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
@@ -34,8 +35,6 @@ 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
@@ -72,13 +71,21 @@ public class DamageStation(IServiceProvider provider)
var healthScale = 1.0 - dist / _Config.MaxRange;
var damageAmount =
(int)Math.Floor(_Config.HealthIncrements * healthScale);
var dmgEvent = new PlayerDamagedEvent(player,
info.Owner as IOnlinePlayer, damageAmount) { Weapon = $"[{Name}]" };
bus.Dispatch(dmgEvent);
damageAmount = -dmgEvent.DmgDealt;
player.Health += damageAmount;
info.HealthGiven += damageAmount;
if (player.Health + damageAmount <= 0) {
var playerDeath =
new PlayerDeathEvent(player).WithKiller(
info.Owner as IOnlinePlayer);
var playerDeath = new PlayerDeathEvent(player)
.WithKiller(info.Owner as IOnlinePlayer)
.WithWeapon($"[{Name}]");
bus.Dispatch(playerDeath);
}

View File

@@ -17,12 +17,13 @@ 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();

View File

@@ -8,8 +8,8 @@ using TTT.API.Player;
using TTT.CS2.API;
using TTT.CS2.Events;
using TTT.CS2.Extensions;
using TTT.Game;
using TTT.Game.Events.Body;
using TTT.Game.lang;
using TTT.Game.Listeners;
using TTT.Game.Roles;

View File

@@ -21,15 +21,16 @@ public class KarmaBanner(IServiceProvider provider) : BaseListener(provider) {
.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

@@ -53,7 +53,7 @@ public class CS2AliveSpoofer : IAliveSpoofer, IPluginModule {
}
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,4 +1,3 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Admin;
using CounterStrikeSharp.API.Modules.Entities;

View File

@@ -1,7 +1,6 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using TTT.API.Player;
using TTT.Game.Events.Player;
namespace TTT.CS2.Player;

View File

@@ -13,6 +13,9 @@ public class SpectatorRole(IServiceProvider provider) : IRole {
private readonly IPlayerConverter<CCSPlayerController> playerConverter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
public string Id => "basegame.role.spectator";
public string Name
@@ -22,7 +25,8 @@ public class SpectatorRole(IServiceProvider provider) : IRole {
public IOnlinePlayer? FindPlayerToAssign(ISet<IOnlinePlayer> players) {
return players.FirstOrDefault(p
=> playerConverter.GetPlayer(p) is { Team: CsTeam.Spectator });
=> roles.GetRoles(p).Count == 0
&& playerConverter.GetPlayer(p) is { Team: CsTeam.Spectator });
}
public void OnAssign(IOnlinePlayer player) {

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

@@ -1,6 +1,6 @@
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game.lang;
using TTT.Game;
using TTT.Locale;
namespace TTT.CS2.lang;

View File

@@ -1,4 +1,4 @@
ROLE_SPECTATOR: "Spectator"
ROLE_SPECTATOR: "Spectator"
TASER_SCANNED: "%PREFIX%You scanned {0}{grey}, they are %an% {1}{grey}!"
DNA_PREFIX: "{darkblue}D{blue}N{lightblue}A{grey} | {grey}"
@@ -32,4 +32,10 @@ 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 Compass"
SHOP_ITEM_COMPASS_DESC: "Reveals the direction that the nearest non-Traitor is in."
SHOP_ITEM_SILENT_AWP: "Silent AWP"
SHOP_ITEM_SILENT_AWP_DESC: "Receive a silenced AWP with limited ammo."

View File

@@ -2,7 +2,6 @@ using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Command;
using TTT.API.Player;
using TTT.Game.lang;
using TTT.Locale;
namespace TTT.Game.Commands;

View File

@@ -4,7 +4,6 @@ using TTT.API;
using TTT.API.Command;
using TTT.API.Events;
using TTT.API.Player;
using TTT.Game.lang;
using TTT.Locale;
namespace TTT.Game.Commands;

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

View File

@@ -1,7 +1,6 @@
using JetBrains.Annotations;
using TTT.API.Events;
using TTT.Game.Events.Player;
using TTT.Game.lang;
namespace TTT.Game.Listeners;

View File

@@ -3,7 +3,6 @@ using JetBrains.Annotations;
using TTT.API.Events;
using TTT.API.Game;
using TTT.Game.Events.Game;
using TTT.Game.lang;
using TTT.Game.Roles;
namespace TTT.Game.Listeners;

View File

@@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.Game.lang;
using TTT.Locale;
namespace TTT.Game.Loggers;
@@ -22,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

@@ -4,7 +4,6 @@ using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.Game.lang;
using TTT.Locale;
namespace TTT.Game.Roles;

View File

@@ -1,6 +1,5 @@
using System.Drawing;
using TTT.API.Player;
using TTT.Game.lang;
namespace TTT.Game.Roles;

View File

@@ -1,7 +1,6 @@
using System.Drawing;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Player;
using TTT.Game.lang;
using TTT.Locale;
namespace TTT.Game.Roles;

View File

@@ -1,6 +1,5 @@
using System.Drawing;
using TTT.API.Player;
using TTT.Game.lang;
namespace TTT.Game.Roles;

View File

@@ -8,7 +8,6 @@ using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.Game.Events.Game;
using TTT.Game.lang;
using TTT.Game.Loggers;
using TTT.Game.Roles;
using TTT.Locale;
@@ -49,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;
}

View File

@@ -4,7 +4,7 @@ using TTT.API.Role;
using TTT.Game.Roles;
using TTT.Locale;
namespace TTT.Game.lang;
namespace TTT.Game;
public static class GameMsgs {
public static IMsg PREFIX => MsgFactory.Create(nameof(PREFIX));

View File

@@ -5,15 +5,52 @@ namespace TTT.Karma;
public record KarmaConfig {
public string DbString { get; init; } = "Data Source=karma.db";
public int MinKarma { get; init; } = 0;
/// <summary>
/// The minimum amount of karma a player can have.
/// If a player's karma falls below this value, the CommandUponLowKarma
/// will be executed.
/// </summary>
public int MinKarma { get; init; }
/// <summary>
/// The default amount of karma a player starts with.
/// Once a player falls below MinKarma, their karma will
/// also be reset to this value.
/// </summary>
public int DefaultKarma { get; init; } = 50;
/// <summary>
/// The command to execute when a player's karma falls below MinKarma.
/// The first argument will be the player's slot.
/// </summary>
public string CommandUponLowKarma { get; init; } = "karmaban {0} Bad Player!";
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);
public int MaxKarma(IPlayer? player) { return 100; }
public int INNO_ON_TRAITOR { get; init; } = 2;
public int TRAITOR_ON_DETECTIVE { get; init; } = 1;
public int INNO_ON_INNO_VICTIM { get; init; } = -1;
public int INNO_ON_INNO { get; init; } = -4;
public int TRAITOR_ON_TRAITOR { get; init; } = -5;
public int INNO_ON_DETECTIVE { get; init; } = -6;
}

View File

@@ -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,13 +13,6 @@ using TTT.Game.Roles;
namespace TTT.Karma;
public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private static readonly int INNO_ON_TRAITOR = 2;
private static readonly int TRAITOR_ON_DETECTIVE = 1;
private static readonly int INNO_ON_INNO_VICTIM = -1;
private static readonly int INNO_ON_INNO = -4;
private static readonly int TRAITOR_ON_TRAITOR = -5;
private static readonly int INNO_ON_DETECTIVE = -6;
private readonly Dictionary<string, int> badKills = new();
private readonly IGameManager games =
@@ -27,10 +21,16 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
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();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
[EventHandler]
[UsedImplicitly]
@@ -63,18 +63,20 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
case InnocentRole when killerRole is TraitorRole:
return;
case InnocentRole:
victimKarmaDelta = INNO_ON_INNO_VICTIM;
killerKarmaDelta = INNO_ON_INNO;
victimKarmaDelta = config.INNO_ON_INNO_VICTIM;
killerKarmaDelta = config.INNO_ON_INNO;
break;
case TraitorRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_TRAITOR :
INNO_ON_TRAITOR;
config.TRAITOR_ON_TRAITOR :
config.INNO_ON_TRAITOR;
break;
case DetectiveRole:
killerKarmaDelta = killerRole is TraitorRole ?
TRAITOR_ON_DETECTIVE :
INNO_ON_DETECTIVE;
config.TRAITOR_ON_DETECTIVE :
config.INNO_ON_DETECTIVE;
if (killerRole is DetectiveRole)
victimKarmaDelta = config.INNO_ON_INNO_VICTIM;
break;
}
@@ -88,18 +90,15 @@ 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 () => {
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

@@ -39,7 +39,7 @@ public class KarmaStorage(IServiceProvider provider) : IKarmaService {
var scheduler = provider.GetRequiredService<IScheduler>();
Observable.Interval(TimeSpan.FromMinutes(5), scheduler)
Observable.Interval(TimeSpan.FromSeconds(30), scheduler)
.Subscribe(_ => Task.Run(async () => await updateKarmas()));
}
@@ -76,7 +76,7 @@ public class KarmaStorage(IServiceProvider provider) : IKarmaService {
if (oldKarma == newData) return;
var karmaUpdateEvent = new KarmaUpdateEvent(key, oldKarma, newData);
await bus.Dispatch(karmaUpdateEvent);
bus.Dispatch(karmaUpdateEvent);
if (karmaUpdateEvent.IsCanceled) return;
karmaCache[key] = newData;

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,7 +56,13 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
CommandResult.ERROR);
}
private IShopItem? searchItem(string query) {
private IShopItem? searchItem(IOnlinePlayer? player, string query) {
if (sorter != null && int.TryParse(query, out var id)) {
var items = sorter.GetSortedItems(player);
if (id >= 0 && id < items.Count) return items[id];
return null;
}
var item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));

View File

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

View File

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

View File

@@ -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

@@ -8,6 +8,7 @@ using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.Shop.Items;
@@ -44,8 +45,10 @@ public class DeagleDamageListener(IServiceProvider provider)
var attackerRole = Roles.GetRoles(attacker);
var victimRole = Roles.GetRoles(victim);
shop.RemoveItem(attacker, deagleItem);
if (attackerRole.Intersect(victimRole).Any()) {
shop.RemoveItem<OneShotDeagleItem>(attacker);
var attackerIsTraitor = attackerRole.Any(r => r is TraitorRole);
var victimIsTraitor = victimRole.Any(r => r is TraitorRole);
if (attackerIsTraitor == victimIsTraitor) {
if (config.KillShooterOnFF) attacker.Health = 0;
Messenger.Message(attacker, Locale[DeagleMsgs.SHOP_ITEM_DEAGLE_HIT_FF]);
if (!config.DoesFriendlyFire) {

View File

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

View File

@@ -7,6 +7,7 @@ using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Karma;
namespace TTT.Shop.Listeners;
@@ -16,6 +17,15 @@ public class RoleAssignCreditor(IServiceProvider provider)
provider.GetService<IStorage<ShopConfig>>()?.Load().GetAwaiter().GetResult()
?? new ShopConfig(provider);
private readonly KarmaConfig karmaConfig =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
private readonly IKarmaService? karmaService =
provider.GetService<IKarmaService>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[UsedImplicitly]
@@ -23,6 +33,27 @@ public class RoleAssignCreditor(IServiceProvider provider)
public void OnRoleAssign(PlayerRoleAssignEvent ev) {
var toGive = config.StartingCreditsForRole(ev.Role);
if (ev.Player is not IOnlinePlayer online) return;
shop.AddBalance(online, toGive, "Round Start", false);
if (karmaService == null) {
shop.AddBalance(online, toGive, "Round Start", false);
return;
}
Task.Run(async () => {
var karma = await karmaService.Load(ev.Player);
var percent = (karma + karmaConfig.MinKarma)
/ (float)karmaConfig.MaxKarma(ev.Player);
var givenScale = toGive * getKarmaScale(percent);
toGive = (int)Math.Ceiling(givenScale);
shop.AddBalance(online, toGive, "Round Start", false);
});
}
private float getKarmaScale(float percent) {
if (percent >= 0.9) return 1.1f;
if (percent >= 0.8f) return 1;
if (percent >= 0.5) return 0.8f;
if (percent >= 0.3) return 0.5f;
return 0.25f;
}
}

View File

@@ -0,0 +1,29 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
namespace TTT.Shop.Listeners;
public class TaseRewarder(IServiceProvider provider) : BaseListener(provider) {
private readonly IShop shop = provider.GetRequiredService<IShop>();
[UsedImplicitly]
[EventHandler]
public void OnHurt(PlayerDamagedEvent ev) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
if (ev.Weapon == null) return;
if (!ev.Weapon.Contains("taser", StringComparison.OrdinalIgnoreCase))
return;
ev.IsCanceled = true;
var attacker = ev.Attacker;
if (attacker == null) return;
shop.AddBalance(attacker, 30, "Successful Tase");
}
}

View File

@@ -20,14 +20,14 @@ public class PeriodicRewarder(IServiceProvider provider) : ITerrorModule {
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private IDisposable? timer;
public void Dispose() { timer?.Dispose(); }

View File

@@ -105,8 +105,10 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
return items.GetValueOrDefault(player.Id, []);
}
public void RemoveItem(IOnlinePlayer player, IShopItem item) {
public void RemoveItem<T>(IOnlinePlayer player) where T : IShopItem {
if (!items.TryGetValue(player.Id, out var itemList)) return;
var item = itemList.FirstOrDefault(i => i is T);
if (item == null) return;
itemList.Remove(item);
}

View File

@@ -2,8 +2,8 @@
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xml:space="preserve">
<s:Boolean
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=items_005Coneshotdeagle/@EntryIndexedValue">True</s:Boolean>
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=items_005Coneshotdeagle/@EntryIndexedValue">True</s:Boolean>
<s:Boolean
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=lang/@EntryIndexedValue">True</s:Boolean>
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=lang/@EntryIndexedValue">True</s:Boolean>
<s:Boolean
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=shop/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
x:Key="/Default/CodeInspection/NamespaceProvider/NamespaceFoldersToSkip/=shop/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

View File

@@ -4,10 +4,12 @@ using TTT.API.Extensions;
using TTT.CS2.Items.Armor;
using TTT.CS2.Items.BodyPaint;
using TTT.CS2.Items.Camouflage;
using TTT.CS2.Items.Compass;
using TTT.CS2.Items.DNA;
using TTT.CS2.Items.OneHitKnife;
using TTT.CS2.Items.PoisonShots;
using TTT.CS2.Items.PoisonSmoke;
using TTT.CS2.Items.SilentAWP;
using TTT.CS2.Items.Station;
using TTT.Shop.Commands;
using TTT.Shop.Items;
@@ -29,8 +31,9 @@ public static class ShopServiceCollection {
collection.AddModBehavior<RoleAssignCreditor>();
collection.AddModBehavior<PlayerKillListener>();
collection.AddModBehavior<PeriodicRewarder>();
collection.AddModBehavior<TaseRewarder>();
collection.AddModBehavior<ShopCommand>();
collection.AddModBehavior<IItemSorter, ShopCommand>();
collection.AddModBehavior<BuyCommand>();
collection.AddModBehavior<BalanceCommand>();
@@ -38,6 +41,7 @@ public static class ShopServiceCollection {
collection.AddBodyPaintServices();
collection.AddC4Services();
collection.AddCamoServices();
collection.AddCompassServices();
collection.AddDamageStation();
collection.AddDeagleServices();
collection.AddDnaScannerServices();
@@ -48,6 +52,7 @@ public static class ShopServiceCollection {
collection.AddOneHitKnifeService();
collection.AddPoisonShots();
collection.AddPoisonSmoke();
collection.AddSilentAWPServices();
collection.AddStickerServices();
collection.AddTaserItem();
}

View File

@@ -1,5 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Locale;
@@ -7,12 +9,21 @@ using TTT.Locale;
namespace ShopAPI;
public abstract class BaseItem(IServiceProvider provider) : IShopItem {
protected readonly IPlayerFinder Finder =
provider.GetRequiredService<IPlayerFinder>();
protected readonly IGameManager Games =
provider.GetRequiredService<IGameManager>();
protected readonly IInventoryManager Inventory =
provider.GetRequiredService<IInventoryManager>();
protected readonly IMsgLocalizer Locale =
provider.GetRequiredService<IMsgLocalizer>();
protected readonly IMessenger Messenger =
provider.GetRequiredService<IMessenger>();
protected readonly IServiceProvider Provider = provider;
protected readonly IRoleAssigner Roles =

View File

@@ -3,5 +3,5 @@
public record DnaScannerConfig : ShopItemConfig {
public override int Price { get; init; } = 120;
public int MaxSamples { get; init; } = 0;
public TimeSpan DecayTime { get; init; } = TimeSpan.FromSeconds(10);
public TimeSpan DecayTime { get; init; } = TimeSpan.FromMinutes(2);
}

View File

@@ -0,0 +1,8 @@
namespace ShopAPI.Configs.Traitor;
public record CompassConfig : ShopItemConfig {
public override int Price { get; init; } = 70;
public float MaxRange { get; init; } = 10000;
public float CompassFOV { get; init; } = 120;
public int CompassLength { get; init; } = 64;
}

View File

@@ -3,7 +3,7 @@
namespace ShopAPI.Configs.Traitor;
public record DamageStationConfig : StationConfig {
public override int HealthIncrements { get; init; } = -20;
public override int HealthIncrements { get; init; } = -25;
public override int TotalHealthGiven { get; init; } = -3000;
public override string UseSound { get; init; } = "sounds/buttons/blip2";

View File

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

View File

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

View File

@@ -21,18 +21,9 @@ public interface IShop : IKeyedStorage<IPlayer, int>,
void GiveItem(IOnlinePlayer player, IShopItem item);
IList<IShopItem> GetOwnedItems(IOnlinePlayer player);
bool HasItem(IOnlinePlayer player, IShopItem item) {
return GetOwnedItems(player).Any(i => i.Id == item.Id);
}
bool HasItem<T>(IOnlinePlayer player) where T : IShopItem {
return GetOwnedItems(player).Any(i => i is T);
}
void RemoveItem(IOnlinePlayer player, IShopItem item);
void RemoveItem<T>(IOnlinePlayer player) where T : IShopItem {
var owned = GetOwnedItems(player).FirstOrDefault(i => i is T);
if (owned != null) RemoveItem(player, owned);
}
void RemoveItem<T>(IOnlinePlayer player) where T : IShopItem;
}

View File

@@ -17,7 +17,7 @@ public class MemoryKarmaStorage(IEventBus bus)
public override async Task Write(IPlayer key, int value) {
var old = await Load(key);
var karmaEvent = new KarmaUpdateEvent(key, old, value);
await bus.Dispatch(karmaEvent);
bus.Dispatch(karmaEvent);
if (karmaEvent.IsCanceled) return;

View File

@@ -2,8 +2,8 @@ using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game;
using TTT.Game.Commands;
using TTT.Game.lang;
using TTT.Locale;
using Xunit;

View File

@@ -1,41 +0,0 @@
using TTT.Game.Events.Player;
using Xunit;
namespace TTT.Test.Game.Event;
public class DamageEventTest {
[Theory]
[InlineData(0)]
[InlineData(-1)]
public void HpLeft_Set_FailsIfInvalid(int hp) {
var ev = new PlayerDamagedEvent(TestPlayer.Random(), null, 0, 50);
Assert.ThrowsAny<ArgumentException>(() => ev.HpLeft = hp);
}
[Fact]
public void HpLeft_Set_DoesNothingIfSame() {
var ev = new PlayerDamagedEvent(TestPlayer.Random(), null, 0, 0);
ev.HpLeft = 0;
}
[Fact]
public void HpLeft_Set_MarksDirty() {
var ev = new PlayerDamagedEvent(TestPlayer.Random(), null, 0, 50);
ev.HpLeft = 49;
Assert.True(ev.HpModified);
}
[Fact]
public void HpLeft_SetViaBody_MarksDirty() {
var ev =
new PlayerDamagedEvent(TestPlayer.Random(), null, 0, 50) { HpLeft = 49 };
Assert.True(ev.HpModified);
}
[Fact]
public void HpLeft_SetWithSame_DoesNotMarkDirty() {
var ev = new PlayerDamagedEvent(TestPlayer.Random(), null, 0, 50);
ev.HpLeft = 50;
Assert.False(ev.HpModified);
}
}

View File

@@ -32,7 +32,6 @@ public class GameRestartingTest(IServiceProvider provider)
game = Games.ActiveGame;
Assert.NotNull(game);
Assert.True(Games.IsGameActive());
Assert.Equal(State.COUNTDOWN, game.State);
}

View File

@@ -26,7 +26,7 @@ public class PlayerActionsTest(IServiceProvider provider) : GameTest(provider) {
var (alice, bob, game) = CreateActiveGame();
var ev = new PlayerDamagedEvent(alice, bob, 10, 90);
var ev = new PlayerDamagedEvent(alice, bob, 90);
Bus.Dispatch(ev);
Assert.Contains(game.Logger.GetActions().Select(p => p.Item2),

View File

@@ -48,7 +48,7 @@ public class KarmaListenerTests {
var deathEvent = new PlayerDeathEvent(victim);
deathEvent.WithKiller(attacker);
await bus.Dispatch(deathEvent);
bus.Dispatch(deathEvent);
var victimKarma = await karma.Load(victim);
var attackerKarma = await karma.Load(attacker);
@@ -66,7 +66,7 @@ public class KarmaListenerTests {
[InlineData(RoleEnum.Traitor, RoleEnum.Detective, 51, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Innocent, 46, 49)]
[InlineData(RoleEnum.Detective, RoleEnum.Traitor, 52, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Detective, 44, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Detective, 44, 49)]
public async Task OnKill_AffectsKarma(RoleEnum attackerRole,
RoleEnum victimRole, int expAttackerKarma, int expVictimKarma) {
var victim = TestPlayer.Random();
@@ -85,9 +85,13 @@ public class KarmaListenerTests {
var deathEvent = new PlayerDeathEvent(victim);
deathEvent.WithKiller(attacker);
await bus.Dispatch(deathEvent);
bus.Dispatch(deathEvent);
game.EndGame();
await Task.Delay(TimeSpan.FromMilliseconds(20),
TestContext.Current
.CancellationToken); // Wait for the karma update to process
var victimKarma = await karma.Load(victim);
var attackerKarma = await karma.Load(attacker);
@@ -120,12 +124,12 @@ public class KarmaListenerTests {
var deathEvent3 = new PlayerDeathEvent(victim3);
deathEvent3.WithKiller(attacker);
await bus.Dispatch(deathEvent1); // First kill => 50 - (4*1) = 46
await bus.Dispatch(deathEvent2); // Second kill => 46 - (4*2) = 38
await bus.Dispatch(
deathEvent3); // Third kill (detective) => 38 - (6*3) = 20
bus.Dispatch(deathEvent1); // First kill => 50 - (4*1) = 46
bus.Dispatch(deathEvent2); // Second kill => 46 - (4*2) = 38
bus.Dispatch(deathEvent3); // Third kill (detective) => 38 - (6*3) = 20
game.EndGame();
var killerKarma = await karma.Load(attacker);
Assert.Equal(20, killerKarma);
}
@@ -155,9 +159,10 @@ public class KarmaListenerTests {
var deathEvent3 = new PlayerDeathEvent(victim3);
deathEvent3.WithKiller(attacker);
await bus.Dispatch(deathEvent1); // First kill => 50 + 2 = 52
await bus.Dispatch(deathEvent2); // Second kill => 52 + 2 = 54
await bus.Dispatch(deathEvent3); // Third kill (inno) => 54 - 4 = 50
bus.Dispatch(deathEvent1); // First kill => 50 + 2 = 52
bus.Dispatch(deathEvent2); // Second kill => 52 + 2 = 54
bus.Dispatch(deathEvent3); // Third kill (inno) => 54 - 4 = 50
var killerKarma = await karma.Load(attacker);
Assert.Equal(50, killerKarma);
}

View File

@@ -3,7 +3,7 @@ using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game.lang;
using TTT.Game;
using TTT.Locale;
using Xunit;

View File

@@ -5,6 +5,7 @@ using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game.Roles;
using TTT.Karma;
using TTT.Shop.Listeners;
using Xunit;
@@ -50,6 +51,9 @@ public class BalanceClearTest(IServiceProvider provider) {
finder.AddPlayer(player);
finder.AddPlayer(TestPlayer.Random());
var karmaService = provider.GetService<IKarmaService>();
if (karmaService != null) await karmaService.Write(player, 80);
var game = games.CreateGame();
game?.Start();

View File

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

View File

@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="/Content/Site.css" />
<title>&#39;MIT&#39; reference</title>
</head>
<body>
<div id="main-content">
<h1>MIT License</h1>
<h2>SPDX identifier</h2>
<div id="license-expression">MIT</div>
<h2>License text</h2>
<div class="optional-license-text">
<p>MIT License</p>
</div>
<div class="replaceable-license-text">
<p>Copyright (c) &lt;year&gt; &lt;copyright holders&gt;
</p>
</div>
<p>Permission is hereby granted, free of charge, to any person obtaining a copy of <var class="replaceable-license-text"> this software and
associated documentation files</var> (the &quot;Software&quot;), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:</p>
<p>The above copyright notice and this permission notice
<var class="optional-license-text"> (including the next paragraph)</var>
shall be included in all copies or substantial
portions of the Software.</p>
<p>THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
NO EVENT SHALL <var class="replaceable-license-text"> THE AUTHORS OR COPYRIGHT HOLDERS</var> BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>
<h2>SPDX web page</h2>
<ul>
<li><a href="https://spdx.org/licenses/MIT.html">https://spdx.org/licenses/MIT.html</a></li>
</ul>
<h2>Notice</h2>
<p>This license content is provided by the <a href="https://spdx.dev/">SPDX project</a>. For more information about <b>licenses.nuget.org</b>, see <a href="https://aka.ms/licenses.nuget.org">our documentation</a>.
<p><i>Data pulled from <a href="https://github.com/spdx/license-list-data">spdx/license-list-data</a> on November 6, 2024.</i></p>
</div>
</body>
</html>