Compare commits

...

15 Commits

Author SHA1 Message Date
MSWS
f1cce6c230 Fix windows-specific issues 2025-09-30 17:32:42 -07:00
MSWS
922f121009 refactor: Add IIconManager and Sticker features +semver:minor
```
- Rename property `Name` to `Id` across various commands and classes for consistency and clarity, affecting files like `Test/SetRoleCommand.cs`, `GameHandlers/KarmaSyncer.cs`, and `Command/Test/TestCommand.cs`.
- Add new interface `IIconManager` to manage player visibility with methods for handling up to 64 players using a bitmask in `API/Player/IIconManager.cs`.
- Introduce `ShowIconsCommand` and `IndexCommand` classes to enhance game command functionality, leveraging the new `IIconManager` for icon management.
- Implement a new shop item "Stickers" with associated classes `Stickers.cs`, `StickerListener.cs`, and `StickerMsgs.cs`, providing role-revealing capabilities for detective players.
- Refactor shop item and command structures to use a new `BaseItem` abstract class, enhancing code organization and inheritance patterns.
- Update logging in `Plugin/TTT.cs` to use `Id` instead of `Name` for module identification, standardizing log outputs.
- Adjust visibility and color duration settings in `Listeners/ScreenColorApplier.cs` for improved gameplay feedback.
- Refactor service registration and command handling to remove redundancies and improve icon manager integrations in files like `CS2ServiceCollection.cs` and `Command/Test/ScreenColorCommand.cs`.
```
2025-09-30 17:00:57 -07:00
MSWS
2a0924138f Tweak 1-shot weapon defaults 2025-09-30 13:03:30 -07:00
Isaac
a4dc781ee4 Feat/one shot revolver (resolves #66) (#90) 2025-09-30 11:15:24 -07:00
MSWS
935b430769 refactor: Enhance purchase messaging system with localization
- Add new localized messages for shop interactions in `TTT/Shop/lang/en.yml`
- Implement `PurchaseResultExtensions` in `TTT/Shop/Shop/PurchaseResult.cs` to translate purchase outcomes to user-friendly messages
- Streamline and optimize purchase process in `TTT/Shop/Commands/BuyCommand.cs`
- Localize error messages and improve test setup in `TTT/Test/Shop/Commands/BuyTest.cs`
- Update `TTT/Shop/Shop.cs` to use localized messages and enhance error handling logic
2025-09-30 11:13:25 -07:00
MSWS
9dd4414733 Tweak restrictions on deagle config 2025-09-30 10:59:04 -07:00
MSWS
dce4edd6a4 feat: Make friendly fire configurable
```
- Introduce a new configuration variable in `CS2OneShotDeagleConfig.cs` to determine if the shooter should be killed upon friendly fire, and update the `Load` method accordingly.
- Add a static message `SHOP_ITEM_DEAGLE_HIT_FF` in `DeagleMsgs.cs` for handling new Deagle functionality messages.
- Rename "One-Hit Deagle" to "One-Hit Revolver" in `en.yml` and update description and messages for consistency.
- Refactor friendly fire logic in `DeagleDamageListener.cs` by integrating nested conditions and simplifying weapon verification logic for damage events.
- Add `KillShooterOnFF` configuration option in `OneShotDeagle.cs` to manage shooter consequences on friendly fire.
```
2025-09-30 10:57:22 -07:00
MSWS
324711acb9 Working deagle impl +semver:minor 2025-09-30 10:34:47 -07:00
MSWS
85ae2c4210 Add reverse cache to CCPlayerConverter 2025-09-30 09:52:12 -07:00
Isaac
5ff27b37e5 Merge branch 'main' into dev 2025-09-28 01:29:38 -07:00
MSWS
721504f612 Debug out workflow +semver:patch 2025-09-28 01:19:57 -07:00
MSWS
86c24533b5 Test bumping +semver:patch 2025-09-28 01:15:39 -07:00
Isaac
eba49139c2 ci: Implement AI-driven changelog rewriting workflow +ratio (#59)
- Add environment variables and steps for OpenAI API usage in
`.github/workflows/release.yml`
- Retain raw changelog on AI rewrite failure and differentiate naming
- Introduce conditional logic for selective changelog rewriting
- Update GitHub release creation to utilize AI-rewritten changelog when
available
2025-09-28 01:05:24 -07:00
MSWS
d33550a5a4 ci: Enhance release workflow and changelog generation
- Add `fetch-tags: true` to actions/checkout in release workflow to ensure all tags are fetched during checkout.
- Improve tag determination process with lineage-aware strategy and refined pattern matching.
- Change changelog generation to use local git log for better control over commit messages.
- Enhance logic for finding commits for changelog, specifically handling first and subsequent releases.
- Improve error handling and retry mechanism for OpenAI API calls, and refine changelog rewrite logic with fallback strategies.
- Update comment styles for better clarity and organization.
2025-09-28 01:03:57 -07:00
MSWS
453ce77711 ci: Implement AI-driven changelog rewriting workflow +ratio
- Add environment variables and steps for OpenAI API usage in `.github/workflows/release.yml`
- Retain raw changelog on AI rewrite failure and differentiate naming
- Introduce conditional logic for selective changelog rewriting
- Update GitHub release creation to utilize AI-rewritten changelog when available
2025-09-28 00:58:21 -07:00
55 changed files with 754 additions and 241 deletions

View File

@@ -12,10 +12,18 @@ permissions:
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
MAX_CHANGELOG_CHARS: "50000"
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0
fetch-tags: true
# 1. Calculate version using GitVersion
- name: Install GitVersion
@@ -48,7 +56,7 @@ jobs:
run: |
cd build/TTT
zip -r TTT-${{ steps.gitversion.outputs.fullSemVer }}.zip *
# 2. Get latest tag
- name: Get latest tag
id: latest_tag
@@ -68,27 +76,131 @@ jobs:
git tag ${{ steps.gitversion.outputs.fullSemVer }}
git push origin ${{ steps.gitversion.outputs.fullSemVer }}
# 4. Determine previous tag for changelog
# 4. Determine previous relevant tag (lineage-aware)
- name: Determine previous relevant tag
id: prev_tag
run: |
set -euo pipefail
branch="${GITHUB_REF_NAME}"
if [[ "$branch" == "main" ]]; then
prev=$(git tag --sort=-creatordate | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sed -n 2p)
else
prev=$(git tag --sort=-creatordate | grep -E '^[0-9]+\.[0-9]+\.[0-9]+-' | sed -n 2p)
fi
echo "tag=${prev:-0.0.0}" >> $GITHUB_OUTPUT
# 5. Generate changelog
# 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"
# 5. Generate changelog using local git (no compare API)
- name: Generate changelog
run: |
gh api repos/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.tag }}...${{ steps.gitversion.outputs.fullSemVer }} \
--jq '.commits[].commit.message' > CHANGELOG.md
env:
GH_TOKEN: ${{ github.token }}
set -euo pipefail
# 6. Create release
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='%s'
if [[ "$prev" == "0.0.0" ]]; then
# First release: whole history to this tag, first-parent to reflect mains narrative
git log --first-parent --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 --first-parent --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
git log --no-merges --format="${GIT_LOG_FORMAT}" --reverse "$curr" > CHANGELOG.md
else
git log --no-merges --format="${GIT_LOG_FORMAT}" --reverse "$prev..$curr" > CHANGELOG.md
fi
fi
cat CHANGELOG.md
# 5b. Rewrite changelog with OpenAI
- name: Rewrite changelog with OpenAI
id: ai_changelog
if: success()
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_MODEL: ${{ env.OPENAI_MODEL }}
OPENAI_TEMPERATURE: ${{ env.OPENAI_TEMPERATURE }}
MAX_CHANGELOG_CHARS: ${{ env.MAX_CHANGELOG_CHARS }}
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 editor. Rewrite the text so it is grouped by Features, Fixes, Docs, and Chore. Use clear user-facing language. Remove internal ticket IDs and commit hashes unless essential. Merge duplicates. Use imperative voice. Output valid Markdown only. Include a short summary at the top." \
--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 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $OPENAI_API_KEY" \
--data-binary @request.json) && break || true
echo "Call attempt $i failed with HTTP $HTTP_CODE"
sleep $((i*i))
done
if [[ "${HTTP_CODE:-000}" -lt 200 || "${HTTP_CODE:-000}" -ge 300 ]]; then
echo "OpenAI API call failed with HTTP $HTTP_CODE. Keeping raw changelog."
echo "skipped=true" >> $GITHUB_OUTPUT
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
echo "skipped=true" >> $GITHUB_OUTPUT
exit 0
fi
echo "skipped=false" >> $GITHUB_OUTPUT
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

@@ -7,7 +7,7 @@ public interface ICommand : ITerrorModule {
string[] Usage => [];
string[] RequiredFlags => [];
string[] RequiredGroups => [];
string[] Aliases => [Name];
string[] Aliases => [Id];
Task<CommandResult> Execute(IOnlinePlayer? executor, ICommandInfo info);
}

View File

@@ -1,7 +1,7 @@
namespace TTT.API;
public interface ITerrorModule : IDisposable {
string Name => GetType().Name;
string Id => GetType().Name;
string Version => GitVersionInformation.FullSemVer;
void Start();

View File

@@ -0,0 +1,16 @@
namespace TTT.API.Player;
/// <summary>
/// Assumes a maximum of 64 players.
/// Each bit in the bitmask represents whether a player is visible to the client.
/// Bit 0 is unused, bit 1 represents player 1, bit 2 represents player 2, and so on.
/// </summary>
public interface IIconManager {
ulong GetVisiblePlayers(int client);
void SetVisiblePlayers(int client, ulong playersBitmask);
void RevealToAll(int client);
void AddVisiblePlayer(int client, int player);
void RemoveVisiblePlayer(int client, int player);
void ClearAllVisibility();
}

View File

@@ -6,24 +6,24 @@ public interface IInventoryManager {
/// </summary>
/// <param name="player">The player to give the weapon to.</param>
/// <param name="weapon"></param>
void GiveWeapon(IOnlinePlayer player, IWeapon weapon);
Task GiveWeapon(IOnlinePlayer player, IWeapon weapon);
/// <summary>
/// Removes a weapon from the player.
/// </summary>
/// <param name="player">The player to remove the weapon from.</param>
/// <param name="weaponId">The ID of the weapon to remove.</param>
void RemoveWeapon(IOnlinePlayer player, string weaponId);
Task RemoveWeapon(IOnlinePlayer player, string weaponId);
void RemoveWeapon(IOnlinePlayer player, IWeapon weapon) {
RemoveWeapon(player, weapon.WeaponId);
Task RemoveWeapon(IOnlinePlayer player, IWeapon weapon) {
return RemoveWeapon(player, weapon.WeaponId);
}
void RemoveWeaponInSlot(IOnlinePlayer player, int slot);
Task RemoveWeaponInSlot(IOnlinePlayer player, int slot);
/// <summary>
/// Removes all weapons from the player.
/// </summary>
/// <param name="player">The player to remove all weapons from.</param>
void RemoveAllWeapons(IOnlinePlayer player);
Task RemoveAllWeapons(IOnlinePlayer player);
}

View File

@@ -33,6 +33,7 @@ public static class CS2ServiceCollection {
CCPlayerConverter>();
collection.AddModBehavior<ICommandManager, CS2CommandManager>();
collection.AddModBehavior<IAliveSpoofer, CS2AliveSpoofer>();
collection.AddModBehavior<IIconManager, RoleIconsHandler>();
// Configs
collection.AddModBehavior<IStorage<TTTConfig>, CS2GameConfig>();
@@ -49,7 +50,6 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<DamageCanceler>();
collection.AddModBehavior<PlayerConnectionsHandler>();
collection.AddModBehavior<PropMover>();
collection.AddModBehavior<RoleIconsHandler>();
collection.AddModBehavior<RoundEnd_GameEndHandler>();
collection.AddModBehavior<RoundStart_GameStartHandler>();

View File

@@ -12,7 +12,7 @@ public class ForceAliveCommand(IServiceProvider provider) : ICommand {
public void Dispose() { }
public string Name => "forcealive";
public string Id => "forcealive";
public void Start() { }

View File

@@ -0,0 +1,50 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
using TTT.Shop;
namespace TTT.CS2.Command.Test;
public class GiveItemCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
public void Start() { }
public string Id => "giveitem";
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (executor == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
if (info.ArgCount == 1) return Task.FromResult(CommandResult.PRINT_USAGE);
var query = string.Join(" ", info.Args.Skip(1));
info.ReplySync($"Searching for item: {query}");
var item = searchItem(query);
if (item == null) {
info.ReplySync($"Item '{query}' not found.");
return Task.FromResult(CommandResult.ERROR);
}
shop.GiveItem(executor, item);
info.ReplySync($"Gave item '{item.Name}' to {executor.Name}.");
return Task.FromResult(CommandResult.SUCCESS);
}
private IShopItem? searchItem(string query) {
var item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
=> it.Name.Contains(query, StringComparison.OrdinalIgnoreCase));
return item;
}
}

View File

@@ -14,7 +14,7 @@ public class IdentifyAllCommand(IServiceProvider provider) : ICommand {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
public string Name => "identifyall";
public string Id => "identifyall";
public void Dispose() { }
public void Start() { }

View File

@@ -0,0 +1,29 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class IndexCommand(IServiceProvider provider) : ICommand {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public string Id => "index";
public void Dispose() { }
public void Start() { }
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (executor == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers())
info.ReplySync($"{player.PlayerName} - {player.Slot}");
});
return Task.FromResult(CommandResult.SUCCESS);
}
}

View File

@@ -12,7 +12,7 @@ public class ScreenColorCommand(IServiceProvider provider) : ICommand {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public string Name => "screencolor";
public string Id => "screencolor";
public void Dispose() { }
public void Start() { }

View File

@@ -17,7 +17,7 @@ public class SetRoleCommand(IServiceProvider provider) : ICommand {
public void Dispose() { }
public string Name => "setrole";
public string Id => "setrole";
public void Start() { }
public Task<CommandResult>

View File

@@ -0,0 +1,28 @@
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
using TTT.CS2.API;
namespace TTT.CS2.Command.Test;
public class ShowIconsCommand(IServiceProvider provider) : ICommand {
private readonly IIconManager icons =
provider.GetRequiredService<IIconManager>();
public string Id => "showicons";
public void Dispose() { }
public void Start() { }
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
Server.NextWorldUpdate(() => {
for (var i = 0; i < Server.MaxPlayers; i++)
icons.SetVisiblePlayers(i, ulong.MaxValue);
});
info.ReplySync("Set all icons visible");
return Task.FromResult(CommandResult.SUCCESS);
}
}

View File

@@ -9,7 +9,7 @@ public class StateCommand(IServiceProvider provider) : ICommand {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
public string Name => "state";
public string Id => "state";
public void Dispose() { }
public void Start() { }

View File

@@ -11,7 +11,7 @@ public class StopCommand(IServiceProvider provider) : ICommand {
provider.GetRequiredService<IGameManager>();
public void Dispose() { }
public string Name => "stop";
public string Id => "stop";
public void Start() { }

View File

@@ -11,7 +11,7 @@ public class TestCommand(IServiceProvider provider) : ICommand, IPluginModule {
public void Dispose() { }
public string Name => "test";
public string Id => "test";
public void Start() {
subCommands.Add("setrole", new SetRoleCommand(provider));
@@ -20,6 +20,9 @@ public class TestCommand(IServiceProvider provider) : ICommand, IPluginModule {
subCommands.Add("identifyall", new IdentifyAllCommand(provider));
subCommands.Add("state", new StateCommand(provider));
subCommands.Add("screencolor", new ScreenColorCommand(provider));
subCommands.Add("giveitem", new GiveItemCommand(provider));
subCommands.Add("index", new IndexCommand(provider));
subCommands.Add("showicons", new ShowIconsCommand(provider));
}
public Task<CommandResult>
@@ -29,7 +32,7 @@ public class TestCommand(IServiceProvider provider) : ICommand, IPluginModule {
if (info.ArgCount == 1) {
foreach (var c in subCommands.Values)
info.ReplySync(
$"- {c.Name} {c.Usage.FirstOrDefault()}: {c.Description ?? "No description provided."}");
$"- {c.Id} {c.Usage.FirstOrDefault()}: {c.Description ?? "No description provided."}");
return Task.FromResult(CommandResult.INVALID_ARGS);
}

View File

@@ -12,17 +12,21 @@ namespace TTT.CS2.Configs.ShopItems;
public class CS2OneShotDeagleConfig : IStorage<OneShotDeagleConfig>,
IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_onedeagle_price", "Price of the One-Shot Deagle item", 100,
"css_ttt_shop_onedeagle_price", "Price of the One-Shot Deagle item", 120,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<bool> CV_FRIENDLY_FIRE = new(
"css_ttt_shop_onedeagle_ff",
"Whether the One-Shot Deagle damages teammates", true);
"Whether the One-Shot Deagle damages teammates");
public static readonly FakeConVar<bool> CV_KILL_SHOOTER_ON_FF = new(
"css_ttt_shop_onedeagle_kill_shooter_on_ff",
"Whether the shooter is killed if they shoot a teammate", true);
public static readonly FakeConVar<string> CV_WEAPON = new(
"css_ttt_shop_onedeagle_weapon",
"Weapon entity name used for the One-Shot Deagle", "weapon_revolver",
ConVarFlags.FCVAR_NONE, new ItemValidator(allowMultiple: false));
"Weapon entity name used for the One-Shot Weapon", "weapon_revolver",
ConVarFlags.FCVAR_NONE, new ItemValidator(allowEmpty: false));
public void Dispose() { }
@@ -37,7 +41,8 @@ public class CS2OneShotDeagleConfig : IStorage<OneShotDeagleConfig>,
var cfg = new OneShotDeagleConfig {
Price = CV_PRICE.Value,
DoesFriendlyFire = CV_FRIENDLY_FIRE.Value,
Weapon = CV_WEAPON.Value
Weapon = CV_WEAPON.Value,
KillShooterOnFF = CV_KILL_SHOOTER_ON_FF.Value,
};
return Task.FromResult<OneShotDeagleConfig?>(cfg);

View File

@@ -18,7 +18,7 @@ public class KarmaSyncer(IServiceProvider provider) : IPluginModule {
provider.GetRequiredService<IPlayerFinder>();
public void Dispose() { }
public string Name => nameof(KarmaSyncer);
public string Id => nameof(KarmaSyncer);
public string Version => GitVersionInformation.FullSemVer;
public void Start() { }

View File

@@ -17,12 +17,6 @@ public class PlayerConnectionsHandler(IServiceProvider provider)
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
public void Start() { }
public void Start(BasePlugin? plugin, bool hotReload) {
@@ -34,12 +28,12 @@ public class PlayerConnectionsHandler(IServiceProvider provider)
CounterStrikeSharp.API.Core.Listeners.OnClientDisconnect>(
disconnectFromServer);
Server.NextWorldUpdate(() => {
foreach (var ev in Utilities.GetPlayers()
.Select(player => converter.GetPlayer(player))
.Select(gamePlayer => new PlayerJoinEvent(gamePlayer)))
bus.Dispatch(ev);
});
if (!hotReload) return;
foreach (var ev in Utilities.GetPlayers()
.Select(player => converter.GetPlayer(player))
.Select(gamePlayer => new PlayerJoinEvent(gamePlayer)))
bus.Dispatch(ev);
}
public void Dispose() { }

View File

@@ -1,4 +1,6 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
@@ -7,6 +9,7 @@ using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.CS2.API;
using TTT.CS2.Extensions;
using TTT.CS2.Hats;
using TTT.CS2.Roles;
@@ -18,40 +21,50 @@ using TTT.Game.Roles;
namespace TTT.CS2.GameHandlers;
public class RoleIconsHandler(IServiceProvider provider)
: BaseListener(provider), IPluginModule {
: BaseListener(provider), IPluginModule, IIconManager {
private static readonly string CT_MODEL =
"characters/models/ctm_fbi/ctm_fbi_varianth.vmdl";
private static readonly string T_MODEL =
"characters/models/tm_phoenix/tm_phoenix.vmdl";
private readonly IDictionary<int, IEnumerable<CPointWorldText>>
detectiveIcons = new Dictionary<int, IEnumerable<CPointWorldText>>();
private readonly IPlayerConverter<CCSPlayerController> players =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly ITextSpawner? textSpawner =
provider.GetService<ITextSpawner>();
private readonly IDictionary<int, IEnumerable<CPointWorldText>> traitorIcons =
new Dictionary<int, IEnumerable<CPointWorldText>>();
private readonly ulong[] visibilities = new ulong[64];
private readonly ISet<int> traitors = new HashSet<int>();
private HashSet<int> traitorsThisRound = new();
public void Start(BasePlugin? plugin) {
// private readonly IDictionary<int, IEnumerable<CPointWorldText>> icons =
// new Dictionary<int, IEnumerable<CPointWorldText>>();
private readonly IEnumerable<CPointWorldText>?[] icons =
new IEnumerable<CPointWorldText>[64];
public void Start(BasePlugin? plugin, bool hotReload) {
plugin
?.RegisterListener<CounterStrikeSharp.API.Core.Listeners.CheckTransmit>(
onTransmit);
}
[GameEventHandler]
public HookResult OnRoundEnd(EventRoundStart _, GameEventInfo _1) {
foreach (var text in Utilities
.FindAllEntitiesByDesignerName<CPointWorldText>("point_worldtext"))
text.AcceptInput("Kill");
return HookResult.Continue;
}
[UsedImplicitly]
[EventHandler(IgnoreCanceled = true)]
public void OnRoundStart(GameStateUpdateEvent ev) {
if (ev.NewState != State.IN_PROGRESS) return;
traitors.Clear();
traitorIcons.Clear();
detectiveIcons.Clear();
for (var i = 0; i < icons.Length; i++) removeIcon(i);
ClearAllVisibility();
traitorsThisRound.Clear();
}
[UsedImplicitly]
@@ -66,7 +79,7 @@ public class RoleIconsHandler(IServiceProvider provider)
}
// Remove in case we're re-assigning for some reason
removeAllIcons(player);
removeIcon(player.Slot);
player.SwitchTeam(ev.Role is DetectiveRole ?
CsTeam.CounterTerrorist :
@@ -77,10 +90,36 @@ public class RoleIconsHandler(IServiceProvider provider)
if (pawn == null || !pawn.IsValid) return;
pawn.SetModel(ev.Role is DetectiveRole ? CT_MODEL : T_MODEL);
if (ev.Role is InnocentRole) return;
assignIcon(player, ev.Role);
switch (ev.Role) {
case DetectiveRole: {
for (var i = 0; i < Server.MaxPlayers; i++)
AddVisiblePlayer(i, player.Slot);
break;
}
case TraitorRole: {
traitorsThisRound.Add(player.Slot);
foreach (var traitor in traitorsThisRound) {
AddVisiblePlayer(traitor, player.Slot);
AddVisiblePlayer(player.Slot, traitor);
}
break;
}
}
}
private void removeIcon(int slot) {
var existing = icons[slot];
if (existing == null) return;
foreach (var ent in existing) {
if (!ent.IsValid) continue;
ent.AcceptInput("Kill");
}
icons[slot] = null;
}
private void assignIcon(CCSPlayerController player, IRole role) {
@@ -88,39 +127,8 @@ public class RoleIconsHandler(IServiceProvider provider)
msg = role.Name.First(char.IsAsciiLetter).ToString(), color = role.Color
};
var roleIcon = textSpawner?.CreateTextHat(textSettings, player);
if (roleIcon == null) return;
if (role is DetectiveRole) {
detectiveIcons[player.Slot] = roleIcon;
return;
}
traitors.Add(player.Slot);
traitorIcons[player.Slot] = roleIcon;
}
private void removeAllIcons(CCSPlayerController player) {
removeTraitorIcon(player);
removeDetectiveIcon(player);
}
private void removeTraitorIcon(CCSPlayerController player) {
removeIcons(player.Slot, traitorIcons);
}
private void removeDetectiveIcon(CCSPlayerController player) {
removeIcons(player.Slot, detectiveIcons);
}
private void removeIcons(int slot,
IDictionary<int, IEnumerable<CPointWorldText>> cache) {
cache.Remove(slot, out var icons);
if (icons == null) return;
foreach (var icon in icons) {
if (!icon.IsValid) continue;
icon.Remove();
}
icons[player.Slot] = roleIcon;
}
[EventHandler(Priority = Priority.MONITOR)]
@@ -128,16 +136,62 @@ public class RoleIconsHandler(IServiceProvider provider)
var gamePlayer = players.GetPlayer(ev.Victim);
if (gamePlayer == null || !gamePlayer.IsValid) return;
removeAllIcons(gamePlayer);
removeIcon(gamePlayer.Slot);
}
// ReSharper disable once PossiblyImpureMethodCallOnReadonlyVariable
private void onTransmit(CCheckTransmitInfoList infoList) {
foreach (var (info, player) in infoList) {
if (player == null || !player.IsValid) continue;
if (traitors.Contains(player.Slot)) continue;
foreach (var icon in traitorIcons.Values.SelectMany(s => s))
info.TransmitEntities.Remove(icon);
hideIcons(info, player.Slot);
}
}
private void hideIcons(CCheckTransmitInfo info, int source) {
var visible = visibilities[source];
if (visible == ulong.MaxValue) return;
for (var i = 0; i < icons.Length; i++) {
if ((visible & 1UL << i) != 0) continue;
var iconList = icons[i];
if (iconList == null) continue;
foreach (var icon in iconList) info.TransmitEntities.Remove(icon);
}
}
public ulong GetVisiblePlayers(int client) {
if (client < 1 || client >= visibilities.Length)
throw new ArgumentOutOfRangeException(nameof(client));
return visibilities[client];
}
public void SetVisiblePlayers(int client, ulong playersBitmask) {
guardRange(client, nameof(client));
visibilities[client] = playersBitmask;
}
public void RevealToAll(int client) {
guardRange(client, nameof(client));
for (var i = 0; i < visibilities.Length; i++)
visibilities[i] |= 1UL << client;
}
public void AddVisiblePlayer(int client, int player) {
guardRange(client, nameof(client));
guardRange(player, nameof(player));
visibilities[client] |= 1UL << player;
}
public void RemoveVisiblePlayer(int client, int player) {
guardRange(client, nameof(client));
guardRange(player, nameof(player));
visibilities[client] &= ~(1UL << player);
}
private void guardRange(int index, string name) {
if (index < 0 || index >= visibilities.Length)
throw new ArgumentOutOfRangeException(name);
}
public void ClearAllVisibility() {
Array.Clear(visibilities, 0, visibilities.Length);
}
}

View File

@@ -20,11 +20,9 @@ public class ScreenColorApplier(IServiceProvider provider)
if (ev.Role is SpectatorRole) return;
var player = converter.GetPlayer(ev.Player);
var alphaColor = Color.FromArgb(16, ev.Role.Color);
var alphaColor = Color.FromArgb(64, ev.Role.Color);
if (player != null)
player.ColorScreen(alphaColor, 5f, 5f,
player.ColorScreen(alphaColor, 3, 1,
flags: PlayerExtensions.FadeFlags.FADE_OUT);
player?.PrintToCenterHtml("You are a " + ev.Role.Name, 20);
}
}

View File

@@ -8,6 +8,7 @@ namespace TTT.CS2.Player;
public class CCPlayerConverter : IPluginModule,
IPlayerConverter<CCSPlayerController> {
private readonly Dictionary<string, CS2Player> playerCache = new();
private readonly Dictionary<string, CCSPlayerController> reverseCache = new();
public IPlayer GetPlayer(CCSPlayerController player) {
if (playerCache.TryGetValue(player.SteamID.ToString(),
@@ -28,12 +29,19 @@ public class CCPlayerConverter : IPluginModule,
public CCSPlayerController? GetPlayer(IPlayer player) {
if (!ulong.TryParse(player.Id, out var steamId)) return null;
if (reverseCache.TryGetValue(player.Id, out var cachedPlayer)) {
if (cachedPlayer.IsValid) return cachedPlayer;
reverseCache.Remove(player.Id);
}
CCSPlayerController? result = null;
var gamePlayer = Utilities.GetPlayerFromSteamId(steamId);
if (gamePlayer is { IsValid: true }) result = gamePlayer;
var bot = Utilities.GetPlayerFromIndex((int)steamId);
if (bot is { IsValid: true }) result = bot;
if (result != null) reverseCache[player.Id] = result;
return result;
}

View File

@@ -11,27 +11,23 @@ public class CS2AliveSpoofer : IAliveSpoofer, IPluginModule {
public void SpoofAlive(CCSPlayerController player) {
if (player.IsBot) {
player.PawnIsAlive = true;
Utilities.SetStateChanged(player, "CCSPlayerController",
"m_bPawnIsAlive");
return;
Server.NextWorldUpdate(() => {
var pawn = player.Pawn.Value;
if (pawn == null || !pawn.IsValid) return;
pawn.DeathTime = 0;
Utilities.SetStateChanged(pawn, "CBasePlayerPawn", "m_flDeathTime");
Utilities.SetStateChanged(pawn, "CBasePlayerController",
"m_flDeathTime");
Server.NextWorldUpdate(() => {
player.PawnIsAlive = true;
Utilities.SetStateChanged(player, "CCSPlayerController",
"m_bPawnIsAlive");
});
});
}
FakeAlivePlayers.Add(player);
Server.NextWorldUpdate(() => {
var pawn = player.Pawn.Value;
if (pawn == null || !pawn.IsValid) return;
pawn.DeathTime = 0;
Utilities.SetStateChanged(pawn, "CBasePlayerPawn", "m_flDeathTime");
Utilities.SetStateChanged(pawn, "CBasePlayerController", "m_flDeathTime");
Server.NextWorldUpdate(() => {
player.PawnIsAlive = true;
Utilities.SetStateChanged(player, "CCSPlayerController",
"m_bPawnIsAlive");
});
});
}
public void UnspoofAlive(CCSPlayerController player) {

View File

@@ -9,8 +9,8 @@ namespace TTT.CS2.Player;
public class CS2InventoryManager(
IPlayerConverter<CCSPlayerController> converter) : IInventoryManager {
public void GiveWeapon(IOnlinePlayer player, IWeapon weapon) {
Server.NextWorldUpdate(() => {
public Task GiveWeapon(IOnlinePlayer player, IWeapon weapon) {
return Server.NextWorldUpdateAsync(() => {
var gamePlayer = converter.GetPlayer(player);
if (gamePlayer == null) return;
@@ -26,9 +26,20 @@ public class CS2InventoryManager(
// Set ammo if applicable
var weaponBase = player.GetWeaponBase(weapon.WeaponId);
if (weaponBase == null) {
if (weapon.WeaponId.Equals("weapon_revolver")) {
weaponBase = player.GetWeaponBase("weapon_deagle");
}
}
if (weaponBase == null) return;
if (weapon.CurrentAmmo != null) weaponBase.Clip1 = weapon.CurrentAmmo.Value;
if (weapon.ReserveAmmo != null) weaponBase.Clip2 = weapon.ReserveAmmo.Value;
if (weapon.ReserveAmmo != null)
weaponBase.ReserveAmmo[0] = weapon.ReserveAmmo.Value;
Utilities.SetStateChanged(weaponBase, "CBasePlayerWeapon", "m_iClip1");
Utilities.SetStateChanged(weaponBase, "CBasePlayerWeapon",
"m_pReserveAmmo");
}
public static gear_slot_t IntToSlot(int slot)
@@ -63,20 +74,17 @@ public class CS2InventoryManager(
|| !weapon.Value.DesignerName.StartsWith("weapon_"))
continue;
if (weapon.Value.Entity == null) continue;
if (!weapon.Value.OwnerEntity.IsValid) continue;
var weaponBase = weapon.Value.As<CBaseEntity>();
var weaponBase = weapon.Value.As<CCSWeaponBase>();
if (!weaponBase.IsValid || (weaponBase.Entity == null)) continue;
var weaponData = (weaponBase as CCSWeaponBase)?.VData;
if (weaponData == null) continue;
if (!slots.Contains(weaponData.GearSlot)) continue;
var vdata = weaponBase.VData;
if (vdata == null) continue;
if (!slots.Contains(vdata.GearSlot)) continue;
weapon.Value.AddEntityIOEvent("Kill", weapon.Value);
}
}
public void RemoveWeapon(IOnlinePlayer player, string weaponId) {
Server.NextWorldUpdate(() => {
public Task RemoveWeapon(IOnlinePlayer player, string weaponId) {
return Server.NextWorldUpdateAsync(() => {
if (!player.IsAlive) return;
var gamePlayer = converter.GetPlayer(player);
@@ -102,8 +110,8 @@ public class CS2InventoryManager(
});
}
public void RemoveWeaponInSlot(IOnlinePlayer player, int slot) {
Server.NextWorldUpdate(() => {
public Task RemoveWeaponInSlot(IOnlinePlayer player, int slot) {
return Server.NextWorldUpdateAsync(() => {
if (!player.IsAlive) return;
var gamePlayer = converter.GetPlayer(player);
@@ -113,8 +121,8 @@ public class CS2InventoryManager(
});
}
public void RemoveAllWeapons(IOnlinePlayer player) {
Server.NextWorldUpdate(() => {
public Task RemoveAllWeapons(IOnlinePlayer player) {
return Server.NextWorldUpdateAsync(() => {
if (!player.IsAlive) return;
var gamePlayer = converter.GetPlayer(player);

View File

@@ -58,15 +58,18 @@ public class CS2Player : IOnlinePlayer {
get => Player?.Pawn.Value != null ? Player.Pawn.Value.Health : 0;
set {
if (Player?.Pawn.Value == null) return;
Server.NextWorldUpdate(() => {
if (Player?.Pawn.Value == null) return;
if (value <= 0) {
Player.CommitSuicide(false, true);
return;
}
if (value <= 0) {
Player.CommitSuicide(false, true);
return;
}
Player.Pawn.Value.Health = value;
Utilities.SetStateChanged(Player.Pawn.Value, "CBaseEntity", "m_iHealth");
Player.Pawn.Value.Health = value;
Utilities.SetStateChanged(Player.Pawn.Value, "CBaseEntity",
"m_iHealth");
});
}
}

View File

@@ -11,7 +11,7 @@ public class LogsCommand(IServiceProvider provider) : ICommand {
public void Dispose() { }
public string Name => "logs";
public string Id => "logs";
public void Start() { }
// TODO: Restrict and verbalize usage

View File

@@ -13,7 +13,7 @@ public class TTTCommand(IServiceProvider provider) : ICommand {
provider.GetRequiredService<IMsgLocalizer>();
public void Dispose() { }
public string Name => "ttt";
public string Id => "ttt";
public string[] Usage => ["<modules/commands/listeners>"];
public void Start() { }
@@ -70,7 +70,7 @@ public class TTTCommand(IServiceProvider provider) : ICommand {
IEnumerable<ITerrorModule> listeners) {
foreach (var listener in listeners)
printVersionedEntry(info, listener.Version,
listener.Name + " - " + listener.GetType().Name);
listener.Id + " - " + listener.GetType().Name);
}
private void printVersionedEntry(ICommandInfo info, string version,

View File

@@ -42,7 +42,7 @@ public class KarmaStorage(IServiceProvider provider) : IKarmaService {
}
public void Dispose() { }
public string Name => nameof(KarmaStorage);
public string Id => nameof(KarmaStorage);
public string Version => GitVersionInformation.FullSemVer;
public async Task Write(IPlayer key, int newData) {

View File

@@ -25,7 +25,7 @@ public class TTT(IServiceProvider provider) : BasePlugin {
module.Start();
loadedModules.Add(module);
Logger.LogInformation(
$"Loaded {module.Version} {module.Name} {module.GetType().Namespace}");
$"Loaded {module.Version} {module.Id} {module.GetType().Namespace}");
}
var pluginModules = modules.Where(m => m is IPluginModule)
@@ -40,7 +40,7 @@ public class TTT(IServiceProvider provider) : BasePlugin {
RegisterAllAttributes(module);
loadedModules.Add(module);
Logger.LogInformation(
$"Registered {module.Version} {module.Name} {module.GetType().Namespace}");
$"Registered {module.Version} {module.Id} {module.GetType().Namespace}");
}
Logger.LogInformation("All modules loaded successfully.");
@@ -54,10 +54,10 @@ public class TTT(IServiceProvider provider) : BasePlugin {
foreach (var module in loadedModules)
try {
Logger.LogInformation($"Unloading {module.Name} ({module.Version})");
Logger.LogInformation($"Unloading {module.Id} ({module.Version})");
module.Dispose();
} catch (Exception e) {
Logger.LogError(e, $"Error unloading module {module.Name}");
Logger.LogError(e, $"Error unloading module {module.Id}");
}
base.Dispose(disposing);

View File

@@ -6,8 +6,8 @@ namespace TTT.Shop.Commands;
public class BalanceCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
public string Name => "balance";
public string[] Aliases => [Name, "bal", "credits", "money"];
public string Id => "balance";
public string[] Aliases => [Id, "bal", "credits", "money"];
public void Dispose() { }
public void Start() { }

View File

@@ -16,9 +16,9 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
private readonly IShop shop = provider.GetRequiredService<IShop>();
public void Dispose() { }
public string Name => "buy";
public string Id => "buy";
public void Start() { }
public string[] Aliases => [Name, "purchase", "b"];
public string[] Aliases => [Id, "purchase", "b"];
public async Task<CommandResult> Execute(IOnlinePlayer? executor,
ICommandInfo info) {
@@ -44,27 +44,15 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
return CommandResult.ERROR;
}
var bal = await shop.Load(executor);
if (item.Config.Price > bal) {
info.ReplySync(
$"You cannot afford '{item.Name}'. It costs {item.Config.Price}, but you have {bal}.");
return CommandResult.ERROR;
}
if (item.CanPurchase(executor) != PurchaseResult.SUCCESS) {
info.ReplySync($"You cannot purchase '{item.Name}'.");
return CommandResult.ERROR;
}
await shop.Write(executor, bal - item.Config.Price);
item.OnPurchase(executor);
shop.GiveItem(executor, item);
return CommandResult.SUCCESS;
var result = shop.TryPurchase(executor, item);
return result == PurchaseResult.SUCCESS ?
CommandResult.SUCCESS :
CommandResult.ERROR;
}
private IShopItem? searchItem(string query) {
var item = shop.Items.FirstOrDefault(it
=> it.Id.Equals(query, StringComparison.OrdinalIgnoreCase));
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;

View File

@@ -13,7 +13,7 @@ public class ListCommand(IServiceProvider provider) : ICommand {
public void Dispose() { }
public string Name => "list";
public string Id => "list";
public void Start() { }

View File

@@ -18,7 +18,7 @@ public class ShopCommand(IServiceProvider provider) : ICommand {
};
public void Dispose() { }
public string Name => "shop";
public string Id => "shop";
public void Start() { }
@@ -27,17 +27,17 @@ public class ShopCommand(IServiceProvider provider) : ICommand {
HashSet<string> sent = [];
if (info.ArgCount == 1) {
foreach (var (_, cmd) in subcommands) {
if (!sent.Add(cmd.Name)) continue;
if (!sent.Add(cmd.Id)) continue;
var uses = cmd.Usage.Where(use => !string.IsNullOrWhiteSpace(use))
.ToList();
var useString =
uses.Count > 0 ? "(" + string.Join(", ", uses) + ")" : "";
if (cmd.Description != null)
info.ReplySync(
$"{locale[GameMsgs.PREFIX]}{ChatColors.White}{cmd.Name} {ChatColors.Grey}- {ChatColors.BlueGrey}{cmd.Description}");
$"{locale[GameMsgs.PREFIX]}{ChatColors.White}{cmd.Id} {ChatColors.Grey}- {ChatColors.BlueGrey}{cmd.Description}");
else
info.ReplySync(
$"{locale[GameMsgs.PREFIX]}{ChatColors.White}{cmd.Name} {ChatColors.Grey}{useString}");
$"{locale[GameMsgs.PREFIX]}{ChatColors.White}{cmd.Id} {ChatColors.Grey}{useString}");
}
return Task.FromResult(CommandResult.SUCCESS);

View File

@@ -0,0 +1,29 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop.Items;
public abstract class BaseItem(IServiceProvider provider) : IShopItem {
protected readonly IServiceProvider Provider = provider;
protected readonly IShop Shop = provider.GetRequiredService<IShop>();
protected readonly IRoleAssigner Roles =
provider.GetRequiredService<IRoleAssigner>();
protected readonly IMsgLocalizer Locale =
provider.GetRequiredService<IMsgLocalizer>();
protected readonly IInventoryManager Inventory =
provider.GetRequiredService<IInventoryManager>();
public void Dispose() { }
public abstract string Name { get; }
public abstract string Description { get; }
public abstract ShopItemConfig Config { get; }
public abstract void OnPurchase(IOnlinePlayer player);
public abstract PurchaseResult CanPurchase(IOnlinePlayer player);
public void Start() { Shop.RegisterItem(this); }
}

View File

@@ -0,0 +1,42 @@
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.Shop.Items.Detective.Stickers;
public class StickerListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly IIconManager? icons = provider.GetService<IIconManager>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
[EventHandler(Priority = Priority.MONITOR)]
public void OnHurt(PlayerDamagedEvent ev) {
if (icons == null || ev.Attacker == null
|| !shop.HasItem<Stickers>(ev.Attacker))
return;
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
if (ev.Weapon == null) return;
if (!ev.Weapon.Contains("taser", StringComparison.OrdinalIgnoreCase))
return;
if (!ev.IsCanceled) return;
var victim = ev.Player;
var attacker = ev.Attacker;
if (attacker == null) return;
if (!Roles.GetRoles(attacker).Any(r => r is DetectiveRole)) return;
var player = converter.GetPlayer(victim);
if (player == null || !player.IsValid) return;
icons.RevealToAll(player.Slot);
Messenger.Message(victim, Locale[StickerMsgs.SHOP_ITEM_STICKERS_HIT]);
}
}

View File

@@ -0,0 +1,14 @@
using TTT.Locale;
namespace TTT.Shop.Items.Detective.Stickers;
public class StickerMsgs {
public static IMsg SHOP_ITEM_STICKERS
=> MsgFactory.Create(nameof(SHOP_ITEM_STICKERS));
public static IMsg SHOP_ITEM_STICKERS_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_STICKERS_DESC));
public static IMsg SHOP_ITEM_STICKERS_HIT
=> MsgFactory.Create(nameof(SHOP_ITEM_STICKERS_HIT));
}

View File

@@ -0,0 +1,47 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Extensions;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Role;
using TTT.API.Storage;
using TTT.Game.Roles;
using TTT.Locale;
namespace TTT.Shop.Items.Detective.Stickers;
public static class StickerExtensions {
public static void AddStickerServices(this IServiceCollection services) {
services.AddModBehavior<StickerListener>();
}
}
public class Stickers(IServiceProvider provider) : BaseItem(provider) {
private readonly StickerConfig config = provider
.GetService<IStorage<StickerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new StickerConfig();
private readonly IIconManager? icons = provider.GetService<IIconManager>();
public override string Name => Locale[StickerMsgs.SHOP_ITEM_STICKERS];
public override string Description
=> Locale[StickerMsgs.SHOP_ITEM_STICKERS_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) { }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
if (icons == null || !Roles.GetRoles(player).Any(r => r is DetectiveRole))
return PurchaseResult.ITEM_NOT_PURCHASABLE;
if (Shop.HasItem(player, this)) return PurchaseResult.ALREADY_OWNED;
return PurchaseResult.SUCCESS;
}
}
public record StickerConfig : ShopItemConfig {
public override int Price { get; init; } = 70;
public bool AnnounceReveals { get; init; } = false;
}

View File

@@ -1,3 +1,4 @@
using CounterStrikeSharp.API;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
@@ -22,38 +23,38 @@ public class DeagleDamageListener(IServiceProvider provider)
[UsedImplicitly]
[EventHandler]
public void OnDamage(PlayerDamagedEvent ev) {
Messenger.Debug("DeagleDamageListener: OnDamage");
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;
var attacker = ev.Attacker;
var victim = ev.Player;
if (attacker == null) return;
Messenger.Debug("DeagleDamageListener: Attacker is not null");
var deagleItem = shop.GetOwnedItems(attacker)
.FirstOrDefault(s => s.Id == OneShotDeagle.ID);
.FirstOrDefault(s => s is OneShotDeagle);
if (deagleItem == null) return;
Messenger.DebugAnnounce(
$"DeagleDamageListener: Attacker has deagle item, weapon: {ev.Weapon}");
if (ev.Weapon != config.Weapon) return;
if (ev.Weapon != config.Weapon) {
// CS2 specifically causes the weapon to be "weapon_deagle" even if
// the player is holding a revolver, so we need to check for that as well
if (ev.Weapon is not "weapon_deagle"
|| !config.Weapon.Equals("weapon_revolver"))
return;
}
var attackerRole = Roles.GetRoles(attacker);
var victimRole = Roles.GetRoles(victim);
shop.RemoveItem(attacker, deagleItem);
if (!config.DoesFriendlyFire && attackerRole.Intersect(victimRole).Any()) {
Messenger.DebugAnnounce(
"DeagleDamageListener: Friendly fire is off, roles intersect");
return;
if (attackerRole.Intersect(victimRole).Any()) {
if (config.KillShooterOnFF) attacker.Health = 0;
Messenger.Message(attacker, Locale[DeagleMsgs.SHOP_ITEM_DEAGLE_HIT_FF]);
if (!config.DoesFriendlyFire) {
ev.IsCanceled = true;
return;
}
}
Messenger.DebugAnnounce(
"DeagleDamageListener: One-shot kill conditions met");
if (victim is not IOnlinePlayer onlineVictim) return;
Messenger.DebugAnnounce("DeagleDamageListener: One-shot kill applied");
onlineVictim.Health = 0;
}
}

View File

@@ -8,4 +8,7 @@ public class DeagleMsgs {
public static IMsg SHOP_ITEM_DEAGLE_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_DEAGLE_DESC));
public static IMsg SHOP_ITEM_DEAGLE_HIT_FF
=> MsgFactory.Create(nameof(SHOP_ITEM_DEAGLE_HIT_FF));
}

View File

@@ -13,40 +13,33 @@ public static class DeagleServiceCollection {
}
}
public class OneShotDeagle(IServiceProvider provider) : IWeapon, IShopItem {
public const string ID = "ttt.shop.item.oneshotdeagle";
public class OneShotDeagle(IServiceProvider provider)
: BaseItem(provider), IWeapon {
private readonly OneShotDeagleConfig deagleConfigStorage = provider
.GetService<IStorage<OneShotDeagleConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneShotDeagleConfig();
private readonly IInventoryManager inventoryManager =
provider.GetRequiredService<IInventoryManager>();
public override string Name => Locale[DeagleMsgs.SHOP_ITEM_DEAGLE];
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
public override string Description
=> Locale[DeagleMsgs.SHOP_ITEM_DEAGLE_DESC];
public string Name => locale[DeagleMsgs.SHOP_ITEM_DEAGLE];
public override ShopItemConfig Config => deagleConfigStorage;
public void Start() { }
public void Dispose() { }
public string Description => locale[DeagleMsgs.SHOP_ITEM_DEAGLE_DESC];
public ShopItemConfig Config => deagleConfigStorage;
public void OnPurchase(IOnlinePlayer player) {
inventoryManager.GiveWeapon(player, this);
public override void OnPurchase(IOnlinePlayer player) {
Task.Run(async () => {
await Inventory.RemoveWeaponInSlot(player,
deagleConfigStorage.WeaponSlot);
await Inventory.GiveWeapon(player, this);
});
}
public PurchaseResult CanPurchase(IOnlinePlayer player) {
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return PurchaseResult.SUCCESS;
}
string IShopItem.Id => ID;
public string WeaponId => deagleConfigStorage.Weapon;
public int? ReserveAmmo { get; init; } = 0;
@@ -56,5 +49,7 @@ public class OneShotDeagle(IServiceProvider provider) : IWeapon, IShopItem {
public record OneShotDeagleConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public bool DoesFriendlyFire { get; init; } = true;
public bool KillShooterOnFF { get; init; } = false;
public string Weapon { get; init; } = "revolver";
public int WeaponSlot { get; init; } = 1;
}

View File

@@ -33,7 +33,33 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
public PurchaseResult TryPurchase(IOnlinePlayer player, IShopItem item,
bool printReason = true) {
return PurchaseResult.UNKNOWN_ERROR;
var cost = item.Config.Price;
var bal = balances.GetValueOrDefault(player.Id, 0);
if (cost > bal) {
if (printReason)
messenger?.Message(player,
localizer[ShopMsgs.SHOP_INSUFFICIENT_BALANCE(item, bal)]);
return PurchaseResult.INSUFFICIENT_FUNDS;
}
var canPurchase = item.CanPurchase(player);
if (canPurchase != PurchaseResult.SUCCESS) {
if (!printReason) return canPurchase;
if (canPurchase == PurchaseResult.UNKNOWN_ERROR)
messenger?.Message(player, localizer[ShopMsgs.SHOP_CANNOT_PURCHASE]);
else
messenger?.Message(player,
localizer[
ShopMsgs.SHOP_CANNOT_PURCHASE_WITH_REASON(
canPurchase.ToMessage())]);
return canPurchase;
}
balances[player.Id] = bal - cost;
GiveItem(player, item);
return PurchaseResult.SUCCESS;
}
public void AddBalance(IOnlinePlayer player, int amount, string reason = "",
@@ -66,6 +92,7 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
public void GiveItem(IOnlinePlayer player, IShopItem item) {
if (!items.ContainsKey(player.Id)) items[player.Id] = [];
items[player.Id].Add(item);
item.OnPurchase(player);
}
public IList<IShopItem> GetOwnedItems(IOnlinePlayer player) {
@@ -88,5 +115,5 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
Items.Clear();
}
public void Start() { RegisterItem(new OneShotDeagle(provider)); }
public void Start() { }
}

View File

@@ -20,5 +20,14 @@ 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);
}

View File

@@ -4,8 +4,7 @@ using TTT.API.Player;
namespace TTT.Shop;
public interface IShopItem : ITerrorModule {
new string Name { get; }
string Id { get; }
string Name { get; }
string Description { get; }
ShopItemConfig Config { get; }
void OnPurchase(IOnlinePlayer player);

View File

@@ -36,4 +36,23 @@ public enum PurchaseResult {
/// The item cannot be purchased multiple times, and the player already owns it.
/// </summary>
ALREADY_OWNED
}
public static class PurchaseResultExtensions {
public static string ToMessage(this PurchaseResult result) {
return result switch {
PurchaseResult.SUCCESS => "Purchase successful.",
PurchaseResult.INSUFFICIENT_FUNDS =>
"You do not have enough funds to complete this purchase.",
PurchaseResult.ITEM_NOT_FOUND => "The item was not found in the shop.",
PurchaseResult.ITEM_NOT_PURCHASABLE =>
"You cannot purchase this item at the moment.",
PurchaseResult.PURCHASE_CANCELED => "The purchase was canceled.",
PurchaseResult.UNKNOWN_ERROR =>
"An unknown error occurred during the purchase.",
PurchaseResult.ALREADY_OWNED =>
"You already own this item and cannot purchase it again.",
_ => "An unexpected error occurred."
};
}
}

View File

@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
using TTT.API.Extensions;
using TTT.Shop.Commands;
using TTT.Shop.Items;
using TTT.Shop.Items.Detective.Stickers;
using TTT.Shop.Listeners;
namespace TTT.Shop;
@@ -18,5 +19,6 @@ public static class ShopServiceCollection {
collection.AddModBehavior<BalanceCommand>();
collection.AddDeagleServices();
collection.AddStickerServices();
}
}

View File

@@ -16,4 +16,16 @@ public static class ShopMsgs {
return MsgFactory.Create(nameof(CREDITS_GIVEN_REASON), amo > 0 ? "+" : "-",
Math.Abs(amo), reason);
}
public static IMsg SHOP_INSUFFICIENT_BALANCE(IShopItem item, int bal) {
return MsgFactory.Create(nameof(SHOP_INSUFFICIENT_BALANCE), item.Name,
item.Config.Price, bal);
}
public static IMsg SHOP_CANNOT_PURCHASE
=> MsgFactory.Create(nameof(SHOP_CANNOT_PURCHASE));
public static IMsg SHOP_CANNOT_PURCHASE_WITH_REASON(string reason) {
return MsgFactory.Create(nameof(SHOP_CANNOT_PURCHASE_WITH_REASON), reason);
}
}

View File

@@ -1,6 +1,13 @@
SHOP_INACTIVE: "%PREFIX%The shop is currently closed."
SHOP_ITEM_DEAGLE: "One-Hit Deagle"
SHOP_ITEM_DEAGLE_DESC: "A one-hit kill deagle with a single bullet. Aim carefully!"
SHOP_ITEM_DEAGLE: "One-Hit Revolver"
SHOP_ITEM_DEAGLE_DESC: "A one-hit kill revolver with a single bullet. Aim carefully!"
SHOP_ITEM_DEAGLE_HIT_FF: "You hit a teammate!"
SHOP_ITEM_STICKERS: "Stickers"
SHOP_ITEM_STICKERS_DESC: "Reveal the roles of all players you taser to others."
SHOP_ITEM_STICKERS_HIT: "%PREFIX%You got stickered, your role is now visible to everyone."
SHOP_INSUFFICIENT_BALANCE: "%PREFIX%You cannot afford {white}{0}{grey}, it costs {yellow}{1}{grey} credit%s%, and you have {yellow}{2}{grey}."
SHOP_CANNOT_PURCHASE: "%PREFIX%You cannot purchase this item."
SHOP_CANNOT_PURCHASE_WITH_REASON: "%PREFIX%You cannot purchase this item: {red}{0}{grey}."
CREDITS_NAME: "credit"
CREDITS_GIVEN: "%PREFIX%{0}{1} %CREDITS_NAME%%s%"
CREDITS_GIVEN_REASON: "%PREFIX%{0}{1} %CREDITS_NAME%%s% {grey}({white}{2}{grey})"

View File

@@ -4,8 +4,19 @@ using TTT.API.Player;
namespace TTT.Test.Fakes;
public class FakeInventoryManager : IInventoryManager {
public void GiveWeapon(IOnlinePlayer player, IWeapon weapon) { }
public void RemoveWeapon(IOnlinePlayer player, string weaponId) { }
public void RemoveWeaponInSlot(IOnlinePlayer player, int slot) { }
public void RemoveAllWeapons(IOnlinePlayer player) { }
public Task GiveWeapon(IOnlinePlayer player, IWeapon weapon) {
return Task.CompletedTask;
}
public Task RemoveWeapon(IOnlinePlayer player, string weaponId) {
return Task.CompletedTask;
}
public Task RemoveWeaponInSlot(IOnlinePlayer player, int slot) {
return Task.CompletedTask;
}
public Task RemoveAllWeapons(IOnlinePlayer player) {
return Task.CompletedTask;
}
}

View File

@@ -10,7 +10,7 @@ public class MemoryKarmaStorage(IEventBus bus)
: KeyedMemoryStorage<IPlayer, int>, IKarmaService {
private readonly KarmaConfig config = new();
public void Dispose() { }
public string Name => nameof(MemoryKarmaStorage);
public string Id => nameof(MemoryKarmaStorage);
public string Version => GitVersionInformation.FullSemVer;
public void Start() { }

View File

@@ -17,7 +17,7 @@ public class LogsTest(IServiceProvider provider) : CommandTest(provider,
[Fact]
public async Task LogsCommand_WithoutGame_PrintsNoActiveGame() {
var player = TestPlayer.Random();
var info = new TestCommandInfo(Provider, player, Command.Name);
var info = new TestCommandInfo(Provider, player, Command.Id);
var result = await Commands.ProcessCommand(info);
Assert.Equal(CommandResult.ERROR, result);
Assert.Single(player.Messages);
@@ -32,7 +32,7 @@ public class LogsTest(IServiceProvider provider) : CommandTest(provider,
Provider.GetRequiredService<IGameManager>().CreateGame()?.Start();
var info = new TestCommandInfo(Provider, player, Command.Name);
var info = new TestCommandInfo(Provider, player, Command.Id);
var result = await Commands.ProcessCommand(info);
Assert.Equal(CommandResult.SUCCESS, result);
Assert.Contains(locale[GameMsgs.GAME_LOGS_HEADER], player.Messages);

View File

@@ -10,7 +10,7 @@ public class TTTTest(IServiceProvider provider)
var player = TestPlayer.Random();
Commands.ProcessCommand(new TestCommandInfo(Provider, player,
Command.Name));
Command.Id));
Assert.Single(player.Messages);
Assert.Contains(Command.Version, player.Messages.First());
@@ -24,7 +24,7 @@ public class TTTTest(IServiceProvider provider)
public void SubCommand_ShouldPrint_Modules(string cmd, string exp) {
var player = TestPlayer.Random();
Commands.ProcessCommand(new TestCommandInfo(Provider, player, Command.Name,
Commands.ProcessCommand(new TestCommandInfo(Provider, player, Command.Id,
cmd));
Assert.Contains(exp, player.Messages.First());

View File

@@ -4,7 +4,7 @@ using TTT.API.Player;
namespace TTT.Test.Game.Command;
public class TestEchoCommand : ICommand {
public string Name => "echo";
public string Id => "echo";
public void Start() { }
public string[] Aliases => ["echo", "say"];

View File

@@ -2,6 +2,7 @@
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Locale;
using TTT.Shop;
using TTT.Shop.Commands;
using TTT.Test.Game.Command;
@@ -12,11 +13,14 @@ namespace TTT.Test.Shop.Commands;
public class BuyTest {
private readonly ICommandManager manager;
private readonly IServiceProvider provider;
private readonly IMsgLocalizer locale;
private readonly IShop shop;
public BuyTest(IServiceProvider provider) {
manager = provider.GetRequiredService<ICommandManager>();
shop = provider.GetRequiredService<IShop>();
manager = provider.GetRequiredService<ICommandManager>();
shop = provider.GetRequiredService<IShop>();
locale = provider.GetRequiredService<IMsgLocalizer>();
this.provider = provider;
manager.RegisterCommand(new BuyCommand(provider));
@@ -76,12 +80,12 @@ public class BuyTest {
var player = TestPlayer.Random();
var info = new TestCommandInfo(provider, player, "buy", TestShopItem.ID);
shop.RegisterItem(new TestShopItem());
var item = new TestShopItem();
shop.RegisterItem(item);
var result = await manager.ProcessCommand(info);
Assert.Equal(CommandResult.ERROR, result);
Assert.Contains(
"You cannot afford 'Test Item'. It costs 100, but you have 0.",
Assert.Contains(locale[ShopMsgs.SHOP_INSUFFICIENT_BALANCE(item, 0)],
player.Messages);
}
@@ -112,7 +116,7 @@ public class BuyTest {
Assert.Equal(CommandResult.SUCCESS, result);
Assert.Contains(TestShopItem.ID,
shop.GetOwnedItems(player).Select(s => s.Id));
shop.GetOwnedItems(player).Select(s => s.Name));
}
[Fact]

View File

@@ -23,7 +23,7 @@ public class ShopTests(IServiceProvider provider) {
public void GiveItem_ShowsInInventory() {
shop.GiveItem(player, new TestShopItem());
Assert.Single(shop.GetOwnedItems(player));
Assert.Equal(TestShopItem.ID, shop.GetOwnedItems(player)[0].Id);
Assert.Equal(TestShopItem.ID, shop.GetOwnedItems(player)[0].Name);
}
[Fact]

View File

@@ -6,8 +6,8 @@ namespace TTT.Test.Shop;
public class TestShopItem : IShopItem {
public const string ID = "ttt.test.item.testitem";
public void Dispose() { }
public string Name => "Test Item";
public string Id => ID;
public string Id => "Test Item";
public string Name => ID;
public string Description => "A test item for unit tests.";
public ShopItemConfig Config { get; } = new TestItemConfig();