Compare commits

...

42 Commits

Author SHA1 Message Date
MSWS
70e4127ccf Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-10-16 09:28:05 -07:00
MSWS
5acc57d96e Update to latest CS# 2025-10-16 08:57:26 -07:00
MSWS
7838e335e4 fix: Avoid failing main pipeline if release already exists 2025-10-15 01:11:52 -07:00
MSWS
3a472bb0bf Reformat & Cleanup 2025-10-15 01:04:03 -07:00
MSWS
1a7943a58e Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-10-15 00:58:29 -07:00
MSWS
b385daf157 Add more M4 shortcuts and AWP shortcut 2025-10-15 00:58:02 -07:00
MSWS
31a1069550 feat: Suppress game stats at all times (resolves #122)
```
- Add JetBrains.Annotations to PlayerStatsTracker and annotate methods with [UsedImplicitly] to enhance code quality and maintainability.
- Reorder logic in CombatHandler's `OnPlayerDeath_Pre` to ensure necessary operations are executed before checking game state.
- Move `info.DontBroadcast` setting in CombatHandler to occur only when game is in progress.
- Modify operation sequence in `OnPlayerDeath_Pre` to spoof player status before dispatching death event.
- Clarify comment in `OnPlayerHurt` method in CombatHandler for better understanding on non-Windows platforms.
```
2025-10-14 18:26:13 -07:00
MSWS
38ef183072 Update logs unit test 2025-10-14 16:41:43 -07:00
MSWS
2c03129e86 Update README 2025-10-14 14:05:54 -07:00
MSWS
6f169ef850 Adjust credit dispersement for good/bad kills and iding bodies 2025-10-14 12:34:58 -07:00
MSWS
6f924a82b0 Fix station spawn positioning (resolves #106) 2025-10-14 11:50:58 -07:00
MSWS
06ae0250d0 Additional tweaks to karma balance 2025-10-14 11:50:03 -07:00
MSWS
bd475edd54 Localize no logs shown msg 2025-10-14 11:43:12 -07:00
MSWS
092a676f97 feat: Implement player muting when dead +semver:minor (resolves #121)
- Introduce `PlayerMuter` class in `GameHandlers` for muting dead players and send appropriate messages
- Add `PlayerMuter` behavior to `CS2ServiceCollection` and organize mod behaviors
- Remove unnecessary debug print and simplify logic in `SilentAWPItem`'s `onWeaponSound` method
- Add reminder message in `en.yml` for dead players indicating they cannot be heard
- Add `DEAD_MUTE_REMINDER` message in `CS2Msgs.cs` to notify muted dead players
2025-10-14 11:41:41 -07:00
MSWS
cebf48a9e6 refactor: Refactor dict to use IDs, fix silent awp (#105)
- Change dictionary key types from `IOnlinePlayer` to `string` in `ListCommand` for consistency, using `executor.Id` as the key.
- Update method calls in `ListCommand` to align with new dictionary key types.
- Update `silentShots` dictionary in `SilentAWPItem` to use player IDs (`string`) instead of `IOnlinePlayer` objects.
- Modify `OnPurchase` method in `SilentAWPItem` to handle weapon management asynchronously.
- Add server logging for debug messages in `SilentAWPItem`.
2025-10-14 11:26:05 -07:00
MSWS
303b6de39c Working MAUL integration 2025-10-14 11:05:11 -07:00
MSWS
9f5e96ce33 Add MAUL compatability 2025-10-14 10:45:23 -07:00
MSWS
83e90deb44 refactor: Rename ChatHandler to TraitorChatHandler +semver:patch
- Update the traitor chat format label to pluralize "TRAITOR" to "TRAITORS" in `en.yml`
- Replace `ChatHandler` with `TraitorChatHandler` in `CS2ServiceCollection.cs` to enhance focus on traitor-specific chat functionality
- Rename `ChatHandler` to `TraitorChatHandler` and update to manage traitor roles in `ChatHandler.cs`
- Ensure message processing occurs only if the game is in progress or finished in `ChatHandler.cs`
- Modify command message handling in `ChatHandler.cs` to strip backslashes from messages
2025-10-14 10:01:25 -07:00
Isaac
658eecef02 feat: Add traitor chat (resolves #112, #114) (#120) 2025-10-14 09:04:42 -07:00
MSWS
c90af8dfcf feat: Implement traitor chat message formatting +semver:minor
- Add new message format function for traitor chat in CS2Msgs.cs
- Update ChatHandler.cs with new API modules and role-checking logic
- Modify onSay method in ChatHandler.cs to support traitor message formatting
- Add new chat format specification for traitors in en.yml
2025-10-14 09:01:03 -07:00
MSWS
6cd1788992 Start workon traitor chat 2025-10-14 08:42:25 -07:00
MSWS
1288ccbd7b Refactor & Reformat, fix Spectators preventing specific roles 2025-10-14 08:33:56 -07:00
MSWS
2596289f58 Fix unit tests 2025-10-14 07:18:37 -07:00
MSWS
d13403d7a7 Simplify perm checking 2025-10-13 22:44:31 -07:00
MSWS
d4fa621a03 Fix color formatting 2025-10-13 22:16:59 -07:00
MSWS
969c872e48 Force logs to be run on main thread 2025-10-13 21:12:59 -07:00
MSWS
b2f19b7ac3 feat: Enhance log viewing with messaging and visibility logic +semver:minor
```
Enhance logging and visibility management in game commands and role handling

- Update `LogsCommand.cs` to integrate messaging and localization with `IMessenger` and `IMsgLocalizer`, and conditionally adjust player visibility using `IIconManager`.
- Add new localized message in `GameMsgs.cs` for player's log viewing activity, using player's name.
- Overload `SetVisiblePlayers` in `IIconManager.cs` to increase flexibility by accepting an `IOnlinePlayer` object.
- Implement `SetVisiblePlayers` in `RoleIconsHandler.cs` to improve player visibility and role management, and integrate rules for specific roles like `DetectiveRole` and `TraitorRole`.
- Modify `en.yml` to include a log message for when a player views logs alive.
```
2025-10-13 21:11:50 -07:00
MSWS
742e407bcf Add shop list footer 2025-10-13 20:55:17 -07:00
MSWS
f8c8e528e2 Buff poison shot amo (resolves #115) 2025-10-13 20:16:21 -07:00
MSWS
24bd3d5f40 Clear recipients of awp sounds 2025-10-13 19:51:49 -07:00
MSWS
f1ff53893f Add karma grants per round 2025-10-13 19:47:15 -07:00
MSWS
54c89e96c0 fix: Respawn players when game actually starts
```
- Improve player respawn logic in RoundTimerListener for better team handling and secure timer disposal.
- Enhance initial setup and end game logic in RoundBasedGame with robust role assignment, game state checks, and localization support.
- Increase default Healthshot item price in HealthshotConfig.
```
2025-10-13 19:19:42 -07:00
MSWS
46514a6016 Change comparator 2025-10-12 19:05:13 -07:00
MSWS
73a8f6a9f5 Restrict logs command behind permission 2025-10-12 18:16:52 -07:00
MSWS
f0c239f08e build: Change DI lifetimes for TExtension and ITerrorModule
- Change dependency injection lifetime for services implementing `TExtension` from scoped to singleton in `ServiceCollectionExtensions.cs`
- Register `ITerrorModule` as a transient service in `ServiceCollectionExtensions.cs`
2025-10-12 18:08:25 -07:00
MSWS
bafad884d9 Dont modify karma if killer is the victim 2025-10-12 17:49:30 -07:00
MSWS
1d7e2f7466 Update compass more frequently 2025-10-12 17:42:15 -07:00
MSWS
f9e3d2d324 perf: Refactor KarmaStorage for thread-safety and reliability
- Replace `Dictionary<IPlayer, int>` with `ConcurrentDictionary<string, int>` in `KarmaStorage.cs` for thread-safe operations.
- Introduce caching logic with semaphore for serialized writes in `KarmaStorage.cs`.
- Add periodic data flushing mechanism to the database with improved error handling in `KarmaStorage.cs`.
- Seal `KarmaStorage` class to prevent inheritance and ensure configuration loading precedes database operations.
- Improve error handling with logging and ensure reliable database connections in `KarmaStorage.cs`.
- Enhance thread safety and performance in `FlushAsync` and `Write` methods, improving idempotency and preventing race conditions.
- Increase robustness with null checks, additional validations, and modular code separation in `KarmaStorage.cs`.
2025-10-12 17:24:19 -07:00
Isaac
9ce90cccaa Additional Shop Items, Synchronous Events (#111) 2025-10-12 14:05:14 -07:00
Isaac
551f6a09ef Update TTT/CS2/Command/PlayerPingShopAlias.cs
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Isaac <git@msws.xyz>
2025-10-12 13:59:45 -07:00
MSWS
41db8f9444 Additional awaits 2025-10-12 13:54:56 -07:00
MSWS
361bbb0a49 Wait for async event completion 2025-10-12 13:52:17 -07:00
49 changed files with 647 additions and 222 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
using CounterStrikeSharp.API;
using Serilog;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Loggers;

View File

@@ -64,6 +64,8 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<MapZoneRemover>();
collection.AddModBehavior<BuyMenuHandler>();
collection.AddModBehavior<TeamChangeHandler>();
collection.AddModBehavior<TraitorChatHandler>();
collection.AddModBehavior<PlayerMuter>();
// Damage Cancelers
collection.AddModBehavior<OutOfRoundCanceler>();

View File

@@ -44,7 +44,9 @@ public class PlayerPingShopAlias(IServiceProvider provider) : IPluginModule {
if (converter.GetPlayer(player) is not IOnlinePlayer gamePlayer) return;
var lastUpdated = itemSorter.GetLastUpdate(gamePlayer);
if (DateTime.Now - lastUpdated > TimeSpan.FromSeconds(20)) return;
if (lastUpdated == null
|| DateTime.Now - lastUpdated > TimeSpan.FromSeconds(20))
return;
var cmdInfo = new CS2CommandInfo(provider, gamePlayer, 0, "css_shop", "buy",
(index - 1).ToString());
cmdInfo.CallingContext = CommandCallingContext.Chat;

View File

@@ -1,15 +1,12 @@
using TTT.API.Storage;
namespace TTT.CS2.Configs;
using System;
using System.Threading.Tasks;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Cvars.Validators;
using TTT.API;
using Karma;
using TTT.API.Storage;
using TTT.Karma;
namespace TTT.CS2.Configs;
public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<string> CV_DB_STRING = new(
@@ -27,7 +24,7 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<string> CV_LOW_KARMA_COMMAND = new(
"css_ttt_karma_low_command",
"Command executed when a player's karma falls below the minimum (use {0} for player slot)",
"karmaban {0} Bad Player!");
"css_ban #{0} 2880 Low Karma");
public static readonly FakeConVar<int> CV_TIMEOUT_THRESHOLD = new(
"css_ttt_karma_timeout_threshold",
@@ -47,7 +44,7 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
// 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,
"Karma gained when Innocent kills a Traitor", 4, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_DETECTIVE = new(
@@ -62,19 +59,29 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_INNO_ON_INNO = new(
"css_ttt_karma_inno_on_inno",
"Karma lost when Innocent kills another Innocent", -4,
"Karma lost when Innocent kills another Innocent", -5,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_TRAITOR = new(
"css_ttt_karma_traitor_on_traitor",
"Karma lost when Traitor kills another Traitor", -5, ConVarFlags.FCVAR_NONE,
"Karma lost when Traitor kills another Traitor", -6, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_DETECTIVE = new(
"css_ttt_karma_inno_on_detective",
"Karma lost when Innocent kills a Detective", -6, ConVarFlags.FCVAR_NONE,
"Karma lost when Innocent kills a Detective", -8, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND = new(
"css_ttt_karma_per_round",
"Amount of karma a player will gain at the end of each round", 2,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND_WIN = new(
"css_ttt_karma_per_round_win",
"Amount of karma a player will gain at the end of each round if their team won",
4, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 50));
public void Dispose() { }
public void Start() { }
@@ -93,6 +100,8 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
KarmaTimeoutThreshold = CV_TIMEOUT_THRESHOLD.Value,
KarmaRoundTimeout = CV_ROUND_TIMEOUT.Value,
KarmaWarningWindow = TimeSpan.FromHours(CV_WARNING_WINDOW_HOURS.Value),
KarmaPerRound = CV_KARMA_PER_ROUND.Value,
KarmaPerRoundWin = CV_KARMA_PER_ROUND_WIN.Value,
INNO_ON_TRAITOR = CV_INNO_ON_TRAITOR.Value,
TRAITOR_ON_DETECTIVE = CV_TRAITOR_ON_DETECTIVE.Value,
INNO_ON_INNO_VICTIM = CV_INNO_ON_INNO_VICTIM.Value,

View File

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

View File

@@ -6,17 +6,18 @@ using CounterStrikeSharp.API.Modules.Entities.Constants;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.Extensions;
using TTT.Game.Events.Body;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.CS2.GameHandlers;
public class BodySpawner(IServiceProvider provider) : IPluginModule {
public class BodySpawner(IServiceProvider provider) : BaseListener(provider) {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerConverter<CCSPlayerController> converter =
@@ -25,31 +26,37 @@ public class BodySpawner(IServiceProvider provider) : IPluginModule {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
public void Dispose() { }
public void Start() { }
[UsedImplicitly]
[EventHandler]
public void OnLeave(PlayerLeaveEvent ev) {
if (games.ActiveGame is not { State: State.IN_PROGRESS }) return;
spawnRagdoll(ev.Player);
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnDeath(EventPlayerDeath ev, GameEventInfo _) {
if (games.ActiveGame is not { State: State.IN_PROGRESS })
return HookResult.Continue;
var player = ev.Userid;
if (player == null || !player.IsValid) return HookResult.Continue;
[EventHandler]
public void OnDeath(PlayerDeathEvent ev) {
if (games.ActiveGame is not { State: State.IN_PROGRESS }) return;
spawnRagdoll(ev.Player, ev.Killer, ev.Weapon);
}
private void spawnRagdoll(IPlayer apiPlayer, IOnlinePlayer? killer = null,
string? weapon = null) {
var player = converter.GetPlayer(apiPlayer);
if (player == null || !player.IsValid) return;
player.SetColor(Color.FromArgb(0, 255, 255, 255));
var ragdollBody = makeGameRagdoll(player);
var body = new CS2Body(ragdollBody, converter.GetPlayer(player));
if (ev.Attacker != null && ev.Attacker.IsValid)
body.WithKiller(converter.GetPlayer(ev.Attacker));
if (killer != null) body.WithKiller(killer);
body.WithWeapon(new BaseWeapon(ev.Weapon));
body.WithWeapon(new BaseWeapon(weapon ?? "unknown"));
var bodyCreatedEvent = new BodyCreateEvent(body);
bus.Dispatch(bodyCreatedEvent);
if (bodyCreatedEvent.IsCanceled) ragdollBody.AcceptInput("Kill");
return HookResult.Continue;
}
[UsedImplicitly]

View File

@@ -24,8 +24,10 @@ public class BuyMenuHandler(IServiceProvider provider) : IPluginModule {
{ "weapon_smokegrenade", "Poison Smoke" },
{ "weapon_m4a1_silencer", "M4A1" },
{ "weapon_usp_silencer", "M4A1" },
{ "weapon_sg556", "M4A1" },
{ "weapon_mp5sd", "M4A1" },
{ "weapon_decoy", "healthshot" }
{ "weapon_decoy", "healthshot" },
{ "weapon_awp", "AWP" }
};
public void Dispose() { }

View File

@@ -36,19 +36,18 @@ public class CombatHandler(IServiceProvider provider) : IPluginModule {
[UsedImplicitly]
[GameEventHandler(HookMode.Pre)]
public HookResult OnPlayerDeath_Pre(EventPlayerDeath ev, GameEventInfo info) {
if (games.ActiveGame is not { State: State.IN_PROGRESS })
return HookResult.Continue;
var player = ev.Userid;
if (player == null) return HookResult.Continue;
var deathEvent = new PlayerDeathEvent(converter, ev);
Server.NextWorldUpdateAsync(() => bus.Dispatch(deathEvent));
info.DontBroadcast = true;
hideAndTrackStats(ev, player);
if (games.ActiveGame is not { State: State.IN_PROGRESS })
return HookResult.Continue;
info.DontBroadcast = true;
spoofer.SpoofAlive(player);
Server.NextWorldUpdateAsync(() => bus.Dispatch(deathEvent));
return HookResult.Continue;
}

View File

@@ -1,5 +1,4 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Memory;
using CounterStrikeSharp.API.Modules.Memory.DynamicFunctions;
using Microsoft.Extensions.DependencyInjection;

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,24 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Commands;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Events.Player;
namespace TTT.CS2.GameHandlers;
public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
@@ -45,4 +55,20 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
return HookResult.Handled;
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnChangeTeam(EventPlayerTeam ev, GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
var team = (CsTeam)ev.Team;
if (team is not (CsTeam.Spectator or CsTeam.None))
return HookResult.Continue;
var apiPlayer = converter.GetPlayer(ev.Userid);
Server.NextWorldUpdate(() => {
var playerDeath = new PlayerDeathEvent(apiPlayer);
bus.Dispatch(playerDeath);
});
return HookResult.Continue;
}
}

View File

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

View File

@@ -42,7 +42,7 @@ public class CompassItem(IServiceProvider provider)
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.AddTimer(0.5f, tick, TimerFlags.REPEAT);
plugin?.AddTimer(0.1f, tick, TimerFlags.REPEAT);
}
public override void OnPurchase(IOnlinePlayer player) { }

View File

@@ -33,8 +33,8 @@ public class SilentAWPItem(IServiceProvider provider)
private readonly IPlayerConverter<CCSPlayerController> playerConverter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IDictionary<IOnlinePlayer, int> silentShots =
new Dictionary<IOnlinePlayer, int>();
private readonly IDictionary<string, int> silentShots =
new Dictionary<string, int>();
public override string Name => Locale[SilentAWPMsgs.SHOP_ITEM_SILENT_AWP];
@@ -49,8 +49,11 @@ public class SilentAWPItem(IServiceProvider provider)
}
public override void OnPurchase(IOnlinePlayer player) {
silentShots[player] = config.CurrentAmmo ?? 0 + config.ReserveAmmo ?? 0;
Inventory.GiveWeapon(player, config);
silentShots[player.Id] = config.CurrentAmmo ?? 0 + config.ReserveAmmo ?? 0;
Task.Run(async () => {
await Inventory.RemoveWeaponInSlot(player, 0);
await Inventory.GiveWeapon(player, config);
});
}
private HookResult onWeaponSound(UserMessage msg) {
@@ -75,15 +78,16 @@ public class SilentAWPItem(IServiceProvider provider)
if (playerConverter.GetPlayer(player) is not IOnlinePlayer apiPlayer)
return HookResult.Continue;
if (!silentShots.TryGetValue(apiPlayer, out var shots) || shots <= 0)
if (!silentShots.TryGetValue(apiPlayer.Id, out var shots) || shots <= 0)
return HookResult.Continue;
silentShots[apiPlayer] = shots - 1;
if (silentShots[apiPlayer] == 0) {
silentShots.Remove(apiPlayer);
silentShots[apiPlayer.Id] = shots - 1;
if (silentShots[apiPlayer.Id] == 0) {
silentShots.Remove(apiPlayer.Id);
Shop.RemoveItem<SilentAWPItem>(apiPlayer);
}
msg.Recipients.Clear();
return HookResult.Handled;
}

View File

@@ -11,6 +11,7 @@ using TTT.API;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
namespace TTT.CS2.Items.Station;
@@ -127,14 +128,8 @@ public abstract class StationItem<T>(IServiceProvider provider,
if (gamePlayer == null || !gamePlayer.Pawn.IsValid
|| gamePlayer.Pawn.Value == null)
return;
var spawnPos = gamePlayer.Pawn.Value.AbsOrigin.Clone();
if (spawnPos != null && gamePlayer.PlayerPawn.Value != null) {
var forward = gamePlayer.PlayerPawn.Value.EyeAngles.ToForward();
forward.Z = 0;
spawnPos += forward.Normalized() * 8;
}
prop.Teleport(spawnPos);
prop.Teleport(gamePlayer.GetEyePosition());
});
}

View File

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

View File

@@ -45,7 +45,9 @@ public class RoundTimerListener(IServiceProvider provider)
.TotalSeconds);
Server.ExecuteCommand("mp_ignore_round_win_conditions 1");
foreach (var player in Utilities.GetPlayers()
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE))
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE && p is {
Team: CsTeam.CounterTerrorist or CsTeam.Terrorist
}))
player.Respawn();
foreach (var player in Utilities.GetPlayers())
@@ -55,6 +57,15 @@ public class RoundTimerListener(IServiceProvider provider)
return;
}
if (ev.NewState == State.IN_PROGRESS)
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers()
.Where(p => p.LifeState != (int)LifeState_t.LIFE_ALIVE && p is {
Team: CsTeam.CounterTerrorist or CsTeam.Terrorist
}))
player.Respawn();
});
if (ev.NewState == State.FINISHED) endTimer?.Dispose();
if (ev.NewState != State.IN_PROGRESS) return;
var duration = config.RoundCfg.RoundDuration(ev.Game.Players.Count);

View File

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

Binary file not shown.

View File

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

View File

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

View File

@@ -1,7 +1,10 @@
ROLE_SPECTATOR: "Spectator"
TRAITOR_CHAT_FORMAT: "{darkred}[TRAITORS] {red}{0}: {default}{1}"
TASER_SCANNED: "%PREFIX%You scanned {0}{grey}, they are %an% {1}{grey}!"
DNA_PREFIX: "{darkblue}D{blue}N{lightblue}A{grey} | {grey}"
DEAD_MUTE_REMINDER: "%PREFIX%You are dead and cannot be heard."
SHOP_ITEM_DNA: "DNA Scanner"
SHOP_ITEM_DNA_DESC: "Scan bodies to reveal the person who killed them."
SHOP_ITEM_DNA_SCANNED: "%DNA_PREFIX%You scanned {0}{1}'%s% {grey}body, their killer was {red}{2}{grey}."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,51 +6,58 @@ public record KarmaConfig {
public string DbString { get; init; } = "Data Source=karma.db";
/// <summary>
/// The minimum amount of karma a player can have.
/// If a player's karma falls below this value, the CommandUponLowKarma
/// will be executed.
/// 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.
/// 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.
/// 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!";
/// <summary>
/// The minimum threshold that a player's karma must reach
/// before timing them out for KarmaRoundTimeout rounds;
/// 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.
/// 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.
/// 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; }
/// <summary>
/// Amount of karma a player will gain at the end of each round.
/// </summary>
public int KarmaPerRound { get; init; } = 3;
public int INNO_ON_TRAITOR { get; init; } = 2;
public int KarmaPerRoundWin { get; init; } = 5;
public int INNO_ON_TRAITOR { get; init; } = 5;
public int TRAITOR_ON_DETECTIVE { get; init; } = 1;
public int INNO_ON_INNO_VICTIM { get; init; } = -1;
public int INNO_ON_INNO { get; init; } = -4;
public int TRAITOR_ON_TRAITOR { get; init; } = -5;
public int INNO_ON_DETECTIVE { get; init; } = -6;
public int MaxKarma(IPlayer? player) { return 100; }
}

View File

@@ -15,6 +15,12 @@ namespace TTT.Karma;
public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private readonly Dictionary<string, int> badKills = new();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
@@ -26,11 +32,7 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
private readonly IRoleAssigner roles =
provider.GetRequiredService<IRoleAssigner>();
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
public bool GiveKarmaOnRoundEnd = true;
[EventHandler]
[UsedImplicitly]
@@ -45,6 +47,7 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
var killer = ev.Killer;
if (killer == null) return;
if (victim.Id == killer.Id) return;
var victimRole = roles.GetRoles(victim).First();
var killerRole = roles.GetRoles(killer).First();
@@ -93,6 +96,18 @@ public class KarmaListener(IServiceProvider provider) : BaseListener(provider) {
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
var winner = ev.Game.WinningRole;
if (GiveKarmaOnRoundEnd)
foreach (var player in ev.Game.Players)
if (Roles.GetRoles(player).Any(r => r.GetType() == winner?.GetType()))
queuedKarmaUpdates[player] =
queuedKarmaUpdates.GetValueOrDefault(player, 0)
+ config.KarmaPerRoundWin;
else
queuedKarmaUpdates[player] =
queuedKarmaUpdates.GetValueOrDefault(player, 0)
+ config.KarmaPerRound;
foreach (var (player, karmaDelta) in queuedKarmaUpdates)
Task.Run(async () => {
var newKarma = await karma.Load(player) + karmaDelta;

View File

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

View File

@@ -1,22 +1,29 @@
using System.Runtime.CompilerServices;
using CounterStrikeSharp.API.Modules.Utils;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop.Commands;
public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly IDictionary<string, List<IShopItem>> cache =
new Dictionary<string, List<IShopItem>>();
private readonly IGameManager games = provider
.GetRequiredService<IGameManager>();
private readonly IDictionary<IOnlinePlayer, List<IShopItem>> cache =
new Dictionary<IOnlinePlayer, List<IShopItem>>();
private readonly IDictionary<string, DateTime> lastUpdate =
new Dictionary<string, DateTime>();
private readonly IDictionary<IOnlinePlayer, DateTime> lastUpdate =
new Dictionary<IOnlinePlayer, DateTime>();
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
private readonly IRoleAssigner roles = provider
.GetRequiredService<IRoleAssigner>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
@@ -30,7 +37,7 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
ICommandInfo info) {
var items = calculateSortedItems(executor);
if (executor != null) cache[executor] = items;
if (executor != null) cache[executor.Id] = items;
items = new List<IShopItem>(items);
items.Reverse();
@@ -43,9 +50,30 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
info.ReplySync(formatItem(item, items.Count - index, canPurchase));
}
if (games.ActiveGame is not { State: State.IN_PROGRESS }
|| executor == null)
return CommandResult.SUCCESS;
var role = roles.GetRoles(executor).FirstOrDefault();
if (role == null) return CommandResult.SUCCESS;
info.ReplySync(locale[ShopMsgs.SHOP_LIST_FOOTER(role, balance)]);
return CommandResult.SUCCESS;
}
public List<IShopItem> GetSortedItems(IOnlinePlayer? player,
bool refresh = false) {
if (player == null) return calculateSortedItems(null);
if (refresh || !cache.ContainsKey(player.Id))
cache[player.Id] = calculateSortedItems(player);
return cache[player.Id];
}
public DateTime? GetLastUpdate(IOnlinePlayer? player) {
if (player == null) return null;
lastUpdate.TryGetValue(player.Id, out var time);
return time;
}
private List<IShopItem> calculateSortedItems(IOnlinePlayer? player) {
var items = new List<IShopItem>(shop.Items).Where(item
=> player == null
@@ -66,7 +94,7 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
return string.Compare(a.Name, b.Name, StringComparison.Ordinal);
});
if (player != null) lastUpdate[player] = DateTime.Now;
if (player != null) lastUpdate[player.Id] = DateTime.Now;
return items;
}
@@ -75,10 +103,9 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
return
$" {ChatColors.Grey}- [{ChatColors.DarkRed}{item.Config.Price}{ChatColors.Grey}] {ChatColors.Red}{item.Name}";
if (index > 9) {
if (index > 9)
return
$" {ChatColors.Default}- [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
}
return
$" {ChatColors.Blue}/{index} {ChatColors.Default}| [{ChatColors.Yellow}{item.Config.Price}{ChatColors.Default}] {ChatColors.Green}{item.Name}";
@@ -88,18 +115,4 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
return
$" {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

@@ -9,11 +9,11 @@ using TTT.Locale;
namespace TTT.Shop.Commands;
public class ShopCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly ListCommand listCmd = new(provider);
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
private readonly ListCommand listCmd = new(provider);
private Dictionary<string, ICommand>? subcommands;
public void Dispose() { }

View File

@@ -21,7 +21,7 @@ public class PlayerKillListener(IServiceProvider provider)
if (ev.Killer == null) return;
Task.Run(async () => {
var victimBal = await shop.Load(ev.Victim);
shop.AddBalance(ev.Killer, victimBal / 6, "Killed " + ev.Victim.Name);
shop.AddBalance(ev.Killer, victimBal / 2, "Killed " + ev.Victim.Name);
});
}
@@ -38,7 +38,7 @@ public class PlayerKillListener(IServiceProvider provider)
if (!isGoodKill(ev.Body.Killer, ev.Body.OfPlayer)) {
var killerBal = await shop.Load(killer);
shop.AddBalance(killer, -killerBal / 4 - victimBal / 2, "Bad Kill");
shop.AddBalance(killer, -killerBal / 3 - victimBal / 2, "Bad Kill");
return;
}

View File

@@ -1,5 +1,6 @@
using CounterStrikeSharp.API.Modules.Utils;
using ShopAPI;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop;
@@ -48,4 +49,8 @@ public static class ShopMsgs {
public static IMsg COMMAND_BALANCE(int bal) {
return MsgFactory.Create(nameof(COMMAND_BALANCE), bal);
}
public static IMsg SHOP_LIST_FOOTER(IRole role, int bal) {
return MsgFactory.Create(nameof(SHOP_LIST_FOOTER), role.Name, bal);
}
}

View File

@@ -32,6 +32,7 @@ SHOP_INSUFFICIENT_BALANCE: "%SHOP_PREFIX%You cannot afford {white}{0}{grey}, it
SHOP_CANNOT_PURCHASE: "%SHOP_PREFIX%You cannot purchase this item."
SHOP_CANNOT_PURCHASE_WITH_REASON: "%SHOP_PREFIX%You cannot purchase this item: {red}{0}{grey}."
SHOP_PURCHASED: "%SHOP_PREFIX%You purchased {white}{0}{grey}."
SHOP_LIST_FOOTER: "%SHOP_PREFIX%You are %an% {0}{grey}, you have {yellow}{1}{grey} %CREDITS_NAME%%s%."
CREDITS_NAME: "credit"
CREDITS_GIVEN: "%SHOP_PREFIX%{0}{1} %CREDITS_NAME%%s%"

View File

@@ -4,7 +4,7 @@ namespace ShopAPI.Configs.Traitor;
public record PoisonShotsConfig : ShopItemConfig {
public override int Price { get; init; } = 65;
public int TotalShots { get; init; } = 3;
public int TotalShots { get; init; } = 5;
public Color PoisonColor { get; init; } = Color.FromArgb(128, Color.Purple);
public PoisonConfig PoisonConfig { get; init; } = new();
}

View File

@@ -3,6 +3,6 @@
namespace ShopAPI;
public record HealthshotConfig : ShopItemConfig {
public override int Price { get; init; } = 25;
public override int Price { get; init; } = 30;
public string Weapon { get; init; } = "weapon_healthshot";
}

View File

@@ -21,7 +21,7 @@ public class LogsTest(IServiceProvider provider) : CommandTest(provider,
var result = await Commands.ProcessCommand(info);
Assert.Equal(CommandResult.ERROR, result);
Assert.Single(player.Messages);
Assert.Contains("No active game", player.Messages.First());
Assert.Contains(locale[GameMsgs.GAME_LOGS_NONE], player.Messages);
}
[Fact]

View File

@@ -35,7 +35,10 @@ public class KarmaListenerTests {
new DetectiveRole(provider)
};
bus.RegisterListener(new KarmaListener(provider));
var listener = new KarmaListener(provider);
listener.GiveKarmaOnRoundEnd = false;
bus.RegisterListener(listener);
}
[Fact]
@@ -59,13 +62,13 @@ public class KarmaListenerTests {
[Theory]
[InlineData(RoleEnum.Innocent, RoleEnum.Innocent, 46, 49)]
[InlineData(RoleEnum.Innocent, RoleEnum.Traitor, 52, 50)]
[InlineData(RoleEnum.Innocent, RoleEnum.Traitor, 55, 50)]
[InlineData(RoleEnum.Innocent, RoleEnum.Detective, 44, 50)]
[InlineData(RoleEnum.Traitor, RoleEnum.Innocent, 50, 50)]
[InlineData(RoleEnum.Traitor, RoleEnum.Traitor, 45, 50)]
[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.Traitor, 55, 50)]
[InlineData(RoleEnum.Detective, RoleEnum.Detective, 44, 49)]
public async Task OnKill_AffectsKarma(RoleEnum attackerRole,
RoleEnum victimRole, int expAttackerKarma, int expVictimKarma) {
@@ -130,6 +133,10 @@ public class KarmaListenerTests {
game.EndGame();
await Task.Delay(TimeSpan.FromMilliseconds(20),
TestContext.Current
.CancellationToken); // Wait for the karma update to process
var killerKarma = await karma.Load(attacker);
Assert.Equal(20, killerKarma);
}

View File

@@ -57,6 +57,9 @@ public class BalanceClearTest(IServiceProvider provider) {
var game = games.CreateGame();
game?.Start();
await Task.Delay(TimeSpan.FromMilliseconds(10),
TestContext.Current.CancellationToken);
var newBalance = await shop.Load(player);
var expected = 100;