Compare commits

...

34 Commits

Author SHA1 Message Date
MSWS
8ab4328d9b Fix YML 2025-10-01 15:01:35 -07:00
MSWS
c5a91f334d refactor: Improve item not found handling and code readability
- Add a new message for item not found in `en.yml` to enhance user error feedback
- Clean up `GiveItemCommand.cs` by importing `TTT.Locale` and removing debug messages
- Improve `CS2AliveSpoofer.cs` readability with better conditional logic and early return handling
- Enhance `BuyCommand.cs` by removing redundant searches and utilizing localization for consistency
- Change `DecayTime` in `DnaScannerConfig.cs` from 3 minutes to 10 seconds for faster testing
2025-10-01 14:55:28 -07:00
MSWS
b2f4474e8f Reformat 2025-10-01 14:39:15 -07:00
Isaac
8aee59a87e Feat/dna (resolves #87) (#91) 2025-10-01 14:21:04 -07:00
MSWS
e27bddf8e2 feat: Implement enhanced DNA scanner messaging +semver:minor
- Clean up `ShopAPI.csproj` by removing redundant project references.
- Remove the DNA Scanner item and its description from the English language file in `Shop`.
- Streamline `DnaScanner.cs` by eliminating an unnecessary using directive.
- Enhance `DnaListener.cs` to improve messaging logic with role information and implement new message templating for DNA scans.
- Refactor `DnaMsgs.cs` by adjusting namespaces and adding message properties for enhanced DNA scan functionalities.
- Update `CS2.csproj` by removing a duplicate project reference.
- Expand `en.yml` in `CS2` with translations for DNA scanner items and messages for DNA scanning, including specific scenarios involving suicide.
2025-10-01 14:17:27 -07:00
MSWS
4514e9baa0 feat: Introduce DNA scanner functionality
- Extend functionality in `DnaListener.cs` by adding dependency injection and enhancing the `OnPropPickup` event handler with player and body checks.
- Add `TimeOfDeath` property to `CS2Body.cs` for improved tracking of body creation time.
- Introduce `DnaScannerServiceCollection` in `DnaScanner.cs` for better service management and mod behavior registration.
- Extend `IBody` interface with `TimeOfDeath` to track deceased player identification time.
- Update `ShopServiceCollection.cs` to integrate `AddDnaScannerServices`, enhancing shop capabilities with DNA scanner features.
2025-10-01 11:23:31 -07:00
MSWS
eaf1ab627e Begin work on adding CS2-specific items
```
- Rename and reorganize directories for DNA-related items to new path under "CS2/Items/DNA"
- Relocate RoleRestrictedItem.cs while maintaining its original functionality
- Move and update DnaListener.cs with a low-priority event handler and add an execution order comment
- Rename BaseItem.cs file path as part of project structure reorganization
- Update CS2.csproj by adding "Items\" folder and correcting duplicate project reference
```
2025-09-30 18:22:13 -07:00
MSWS
57bef00055 Refactor Shop api into its own project, separate from impl 2025-09-30 18:18:07 -07:00
MSWS
7dd6d4dd38 Finalize stickers (resolves #89) 2025-09-30 17:46:52 -07:00
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
1a4e5e3e77 +semver:minor 2025-09-28 01:24:45 -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
MSWS
b427dc370e refactor: Remove debug logs and adjust event priorities
- Remove debug logging statements and simplify service registration logic in `ServiceCollectionExtensions.cs`
- Adjust the event handler's priority in `RoundShopClearer.cs` without functional changes
- Update wording in `README.md` for clearer public API usage
- Modify event handler priority in `PlayerStatsTracker.cs` while maintaining existing functionality
- Streamline `IOnlinePlayer.cs` by removing obsolete commented-out code and refining interface properties
- Lower event handler priority in `PlayerActionsLogger.cs` for player kills
2025-09-28 00:46:44 -07:00
MSWS
ede9badbd9 Add additional unit tests for shop 2025-09-28 00:34:55 -07:00
MSWS
5736588484 refactor: Rename Id to WeaponId across the codebase
- Rename property `Id` to `WeaponId` in `IWeapon.cs`, `BaseWeapon.cs`, and `OneShotDeagle.cs` for improved clarity.
- Update weapon removal method in `IInventoryManager.cs` to use `weapon.WeaponId`.
- Refactor `PlayerDamagedEvent.cs` to initialize `Weapon` property with `init` for stricter immutability.
- Revise `IsAlive` logic in `TestPlayer.cs` to adjust `Health` based on `IsAlive` status; deprecate the `Roles` property.
- Add `using` directive and `[UsedImplicitly]` attribute to `DeagleDamageListener.cs` for dependency management and traceability.
- Develop `DeagleTests.cs` to ensure proper functionality of Deagle weapon behaviors using Xunit.
2025-09-28 00:16:28 -07:00
MSWS
8a894c65e8 refactor: Adjust player color handling logic
- Adjust the alpha value handling in `SetColor` method within `PlayerExtensions.cs` to ensure it stays within limits.
- Simplify player color setting in `RoundTimerListener.cs` by using `Color.White` for improved readability.
- Update `BodySpawner.cs` to change player post-death color to fully opaque white and simplify round start color setting using `Color.White`.
2025-09-27 20:30:07 -07:00
MSWS
0634af8ad8 feat: Add weapon slot removal functionality
- Fix typo in comment and clarify kill detection process in `PlayerStatsTracker.cs`
- Add `RemoveWeaponInSlot` method for slot-based weapon removal in `FakeInventoryManager.cs` and update `IInventoryManager.cs` for enhanced functionality
- Reformat `BaseWeapon` constructor for readability and standardize file formatting
- Simplify `CS2Body.cs` API by removing overloaded weapon method and reinforce `IWeapon` interface usage
- Refactor `CS2InventoryManager.cs` for improved weapon management, add methods for slot conversion and weapon removal, and streamline code structure
- Add debug messaging to `DeagleDamageListener.cs` for better runtime clarity on friendly fire and one-shot kill conditions
- Modify `BodySpawner.cs` to incorporate `BaseWeapon` wrapper for improved weapon management without affecting core functionality
- Adjust `OnPlayerKill` event handler priority in `PlayerActionsLogger.cs` to ensure kill logging before game ends
2025-09-27 20:25:53 -07:00
MSWS
9f6c3f7be4 Check player count on round start before starting 2025-09-27 19:07:28 -07:00
MSWS
9eb313e9f1 feat: Introduce screen color features and state command
```
Enhance game management and command structure with new features and optimizations

- Add logic in `PlayerJoinStarting.cs` to handle failure in game instance creation and ensure game starts only upon successful creation.
- Introduce IMessenger service in `CS2GameManager.cs` using dependency injection; add debug logging for the game creation process.
- Implement new behavior for `ScreenColorApplier` in `CS2ServiceCollection.cs` to enhance listening capabilities.
- Create a new `StateCommand` in `StateCommand.cs` to check active game state and implement basic command lifecycle methods.
- Remove "screentext" command and add "state" and "screencolor" commands in `TestCommand.cs` to revamp command options.
- Simplify vector operations in `TextSpawner.cs` by replacing `GetRightVector`; enhance screen text positioning.
- Expand `VectorExtensions.cs` with `ToRight` and `ToUp` methods for better angle-to-vector conversions.
- Remove `ScreenTextCommand.cs` to streamline project and potentially refactor screen text feature.
- Add `ScreenColorApplier` to apply screen color effects upon role assignment, enhancing player interaction experience.
- Introduce `ScreenColorCommand` for player screen color effects with configurable parameters, enriching command functionality.
```
2025-09-26 20:28:35 -07:00
MSWS
9b5563aa8e feat: Refactor text positioning and add screen fade effects
- Refactor `TextSpawner.cs` to improve angle calculations and text positioning with respect to the player.
- Replace static `screenAngle` with local computations for better clarity and maintainability in `TextSpawner.cs`.
- Introduce `angle` object in `TextSpawner.cs` to enhance readability and explicitness in rotation management.
- Add `FadeFlags` enum and implement `ColorScreen` method in `PlayerExtensions.cs` to manage screen color fade effects.
- Enhance color fade handling in `PlayerExtensions.cs` by utilizing `UserMessage` with custom flag settings and improved color configurations.
2025-09-26 18:47:18 -07:00
MSWS
2058d0c780 Start work on screen text 2025-09-26 18:29:59 -07:00
99 changed files with 1463 additions and 353 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

@@ -11,7 +11,7 @@
<ItemGroup>
<PackageReference Include="CounterStrikeSharp.API" Version="1.0.332"/>
<PackageReference Include="Microsoft.Extensions.Localization.Abstractions" Version="8.0.3"/>
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="System.Text.Json" Version="8.0.5"/>
<PackageReference Include="YamlDotNet" Version="16.3.0"/>
</ItemGroup>

View File

@@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Shop", "TTT\Shop\Shop.cspro
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Karma", "TTT\Karma\Karma.csproj", "{AFC791EC-750C-423F-9F35-87636657E990}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ShopAPI", "TTT\ShopAPI\ShopAPI.csproj", "{16F720B5-9D45-47BF-8C80-4F91005E36D1}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -66,6 +68,10 @@ Global
{AFC791EC-750C-423F-9F35-87636657E990}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFC791EC-750C-423F-9F35-87636657E990}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFC791EC-750C-423F-9F35-87636657E990}.Release|Any CPU.Build.0 = Release|Any CPU
{16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{16F720B5-9D45-47BF-8C80-4F91005E36D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{16F720B5-9D45-47BF-8C80-4F91005E36D1}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
EndGlobalSection

View File

@@ -6,6 +6,6 @@
<RootNamespace>TTT.API</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Text.Json" Version="8.0.5" />
<PackageReference Include="System.Text.Json" Version="8.0.5"/>
</ItemGroup>
</Project>

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

@@ -12,32 +12,17 @@ public static class ServiceCollectionExtensions {
public static void AddModBehavior<TExtension>(
this IServiceCollection collection)
where TExtension : class, ITerrorModule {
if (typeof(TExtension).IsAssignableTo(typeof(IPluginModule))) {
# if DEBUG
Console.WriteLine(
$"[DEBUG] Registering {typeof(TExtension).Name} as IPluginModule");
# endif
if (typeof(TExtension).IsAssignableTo(typeof(IPluginModule)))
collection.AddTransient<IPluginModule>(provider
=> (provider.GetRequiredService<TExtension>() as IPluginModule)!);
}
if (typeof(TExtension).IsAssignableTo(typeof(IListener))) {
#if DEBUG
Console.WriteLine(
$"[DEBUG] Registering {typeof(TExtension).Name} as IListener");
# endif
if (typeof(TExtension).IsAssignableTo(typeof(IListener)))
collection.AddTransient<IListener>(provider
=> (provider.GetRequiredService<TExtension>() as IListener)!);
}
if (typeof(TExtension).IsAssignableTo(typeof(ICommand))) {
#if DEBUG
Console.WriteLine(
$"[DEBUG] Registering {typeof(TExtension).Name} as ICommand");
#endif
if (typeof(TExtension).IsAssignableTo(typeof(ICommand)))
collection.AddTransient<ICommand>(provider
=> (provider.GetRequiredService<TExtension>() as ICommand)!);
}
collection.AddScoped<TExtension>();

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

@@ -4,7 +4,7 @@ public interface IWeapon {
/// <summary>
/// The internal ID of the weapon, should match the ID of the weapon in the underlying game.
/// </summary>
public string Id { get; }
public string WeaponId { get; }
/// <summary>
/// The amount of ammo that is in reserve for this weapon.

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,23 +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.Id);
Task RemoveWeapon(IOnlinePlayer player, IWeapon weapon) {
return RemoveWeapon(player, weapon.WeaponId);
}
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

@@ -1,10 +1,6 @@
namespace TTT.API.Player;
public interface IOnlinePlayer : IPlayer {
// [Obsolete(
// "Roles are now managed via IRoleAssigner. Use IRoleAssigner.GetRoles(IPlayer) instead.")]
// ICollection<IRole> Roles { get; }
public int Health { get; set; }
public int MaxHealth { get; set; }
public int Armor { get; set; }

View File

@@ -12,10 +12,11 @@
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\Game\Game.csproj"/>
<ProjectReference Include="..\Karma\Karma.csproj"/>
<ProjectReference Include="..\Shop\Shop.csproj"/>
<ProjectReference Include="..\ShopAPI\ShopAPI.csproj"/>
</ItemGroup>
<ItemGroup>
<Folder Include="Items\"/>
<Folder Include="RayTrace\"/>
</ItemGroup>

View File

@@ -5,7 +5,6 @@ using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game;
using TTT.Game.Roles;
using TTT.Locale;
namespace TTT.CS2;
@@ -31,16 +30,13 @@ public class CS2Body(IServiceProvider provider, CRagdollProp ragdoll,
public IPlayer? Killer { get; private set; }
public string Id { get; } = ragdoll.Index.ToString();
public DateTime TimeOfDeath { get; } = DateTime.Now;
public CS2Body WithWeapon(IWeapon weapon) {
MurderWeapon = weapon;
return this;
}
public CS2Body WithWeapon(string weapon) {
return WithWeapon(new BaseWeapon(weapon));
}
public CS2Body WithKiller(IPlayer? killer) {
Killer = killer;
return this;

View File

@@ -1,5 +1,6 @@
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using TTT.API.Command;
using TTT.API.Extensions;
using TTT.API.Game;
@@ -21,7 +22,6 @@ using TTT.CS2.Player;
using TTT.Game;
using TTT.Locale;
using TTT.Shop;
using TTT.Shop.Items;
namespace TTT.CS2;
@@ -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>();
@@ -63,9 +63,12 @@ public static class CS2ServiceCollection {
collection.AddModBehavior<LateSpawnListener>();
collection.AddModBehavior<PlayerStatsTracker>();
collection.AddModBehavior<RoundTimerListener>();
collection.AddModBehavior<ScreenColorApplier>();
// Commands
#if DEBUG
collection.AddModBehavior<TestCommand>();
#endif
collection.AddScoped<IGameManager, CS2GameManager>();
collection.AddScoped<IInventoryManager, CS2InventoryManager>();

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,52 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Player;
using TTT.Locale;
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));
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

@@ -0,0 +1,35 @@
using System.Drawing;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
using TTT.CS2.Extensions;
namespace TTT.CS2.Command.Test;
public class ScreenColorCommand(IServiceProvider provider) : ICommand {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public string Id => "screencolor";
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(() => {
var player = converter.GetPlayer(executor);
float hold = 0.5f, fade = 0.5f;
if (info.ArgCount >= 2) float.TryParse(info.Args[1], out hold);
if (info.ArgCount >= 3) float.TryParse(info.Args[2], out fade);
player?.ColorScreen(Color.Red, hold, fade);
info.ReplySync("Colored your screen red.");
});
return Task.FromResult(CommandResult.SUCCESS);
}
}

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,27 @@
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Player;
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

@@ -0,0 +1,26 @@
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
public class StateCommand(IServiceProvider provider) : ICommand {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
public string Id => "state";
public void Dispose() { }
public void Start() { }
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (games.ActiveGame == null) {
info.ReplySync("ActiveGame is null.");
return Task.FromResult(CommandResult.SUCCESS);
}
info.ReplySync($"Current game state: {games.ActiveGame?.State}");
return Task.FromResult(CommandResult.SUCCESS);
}
}

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,13 +11,18 @@ 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));
subCommands.Add("stop", new StopCommand(provider));
subCommands.Add("forcealive", new ForceAliveCommand(provider));
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>
@@ -27,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

@@ -2,27 +2,31 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Cvars;
using CounterStrikeSharp.API.Modules.Cvars.Validators;
using ShopAPI.Configs;
using TTT.API;
using TTT.API.Storage;
using TTT.CS2.Validators;
using TTT.Shop.Items;
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

@@ -1,10 +1,15 @@
using System.Drawing;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.UserMessages;
namespace TTT.CS2.Extensions;
public static class PlayerExtensions {
public enum FadeFlags {
FADE_IN, FADE_OUT, FADE_STAYOUT
}
public static CBasePlayerWeapon? GetWeaponBase(
this CCSPlayerController player, string designerName) {
if (!player.IsValid) return null;
@@ -31,7 +36,31 @@ public static class PlayerExtensions {
if (!player.IsValid || pawn == null || !pawn.IsValid) return;
if (color.A == 255)
color = Color.FromArgb(pawn.Render.A, color.R, color.G, color.B);
color = Color.FromArgb(pawn.Render.A == 255 ? 255 : 254, color.R, color.G,
color.B);
pawn.SetColor(color);
}
public static void ColorScreen(this CCSPlayerController player, Color color,
float hold = 0.1f, float fade = 0.2f, FadeFlags flags = FadeFlags.FADE_IN,
bool withPurge = true) {
var fadeMsg = UserMessage.FromId(106);
fadeMsg.SetInt("duration", Convert.ToInt32(fade * 512));
fadeMsg.SetInt("hold_time", Convert.ToInt32(hold * 512));
var flag = flags switch {
FadeFlags.FADE_IN => 0x0001,
FadeFlags.FADE_OUT => 0x0002,
FadeFlags.FADE_STAYOUT => 0x0008,
_ => 0x0001
};
if (withPurge) flag |= 0x0010;
fadeMsg.SetInt("flags", flag);
fadeMsg.SetInt("color",
color.R | color.G << 8 | color.B << 16 | color.A << 24);
fadeMsg.Send(player);
}
}

View File

@@ -46,6 +46,40 @@ public static class VectorExtensions {
(float)(Math.Sin(yaw) * cosPitch), (float)-Math.Sin(pitch));
}
public static Vector ToRight(this QAngle angle) {
var pitch = angle.X * (Math.PI / 180.0);
var yaw = angle.Y * (Math.PI / 180.0);
var roll = angle.Z * (Math.PI / 180.0);
var sinPitch = Math.Sin(pitch);
var cosPitch = Math.Cos(pitch);
var sinYaw = Math.Sin(yaw);
var cosYaw = Math.Cos(yaw);
var sinRoll = Math.Sin(roll);
var cosRoll = Math.Cos(roll);
return new Vector((float)(sinYaw * sinPitch * cosRoll - cosYaw * sinRoll),
(float)(-cosYaw * sinPitch * cosRoll - sinYaw * sinRoll),
(float)(cosPitch * -sinRoll));
}
public static Vector ToUp(this QAngle angle) {
var pitch = angle.X * (Math.PI / 180.0);
var yaw = angle.Y * (Math.PI / 180.0);
var roll = angle.Z * (Math.PI / 180.0);
var sinPitch = Math.Sin(pitch);
var cosPitch = Math.Cos(pitch);
var sinYaw = Math.Sin(yaw);
var cosYaw = Math.Cos(yaw);
var sinRoll = Math.Sin(roll);
var cosRoll = Math.Cos(roll);
return new Vector((float)(-cosYaw * sinPitch * cosRoll - sinYaw * sinRoll),
(float)(-sinYaw * sinPitch * cosRoll + cosYaw * sinRoll),
(float)(cosPitch * cosRoll));
}
public static Vector Lerp(this Vector from, Vector to, float t) {
return new Vector(from.X + (to.X - from.X) * t,
from.Y + (to.Y - from.Y) * t, from.Z + (to.Z - from.Z) * t);

View File

@@ -1,11 +1,17 @@
using TTT.API.Game;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.Game;
using TTT.Game.Events.Game;
namespace TTT.CS2.Game;
public class CS2GameManager(IServiceProvider provider) : GameManager(provider) {
protected readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
public override IGame CreateGame() {
messenger.Debug("Attempting to create a new CS2 game...");
switch (ActiveGame) {
case { State: State.IN_PROGRESS or State.COUNTDOWN }:
throw new InvalidOperationException(
@@ -14,6 +20,7 @@ public class CS2GameManager(IServiceProvider provider) : GameManager(provider) {
return ActiveGame;
}
messenger.Debug("Creating a new CS2 game instance...");
ActiveGame = new CS2Game(Provider);
var ev = new GameInitEvent(ActiveGame);

View File

@@ -11,6 +11,7 @@ using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.Extensions;
using TTT.Game.Events.Body;
using TTT.Game.Roles;
namespace TTT.CS2.GameHandlers;
@@ -32,7 +33,7 @@ public class BodySpawner(IServiceProvider provider) : IPluginModule {
return HookResult.Continue;
var player = ev.Userid;
if (player == null || !player.IsValid) return HookResult.Continue;
player.SetColor(Color.FromArgb(0, 0, 0, 0));
player.SetColor(Color.FromArgb(0, 255, 255, 255));
var ragdollBody = makeGameRagdoll(player);
var body = new CS2Body(provider, ragdollBody, converter.GetPlayer(player));
@@ -40,7 +41,7 @@ public class BodySpawner(IServiceProvider provider) : IPluginModule {
if (ev.Attacker != null && ev.Attacker.IsValid)
body.WithKiller(converter.GetPlayer(ev.Attacker));
body.WithWeapon(ev.Weapon);
body.WithWeapon(new BaseWeapon(ev.Weapon));
var bodyCreatedEvent = new BodyCreateEvent(body);
bus.Dispatch(bodyCreatedEvent);
@@ -53,7 +54,7 @@ public class BodySpawner(IServiceProvider provider) : IPluginModule {
public HookResult OnStart(EventRoundStart ev, GameEventInfo _) {
Server.NextWorldUpdate(() => {
foreach (var player in Utilities.GetPlayers())
player.SetColor(Color.FromArgb(254, 255, 255, 255));
player.SetColor(Color.White);
});
return HookResult.Continue;
}

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

@@ -3,8 +3,6 @@ using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.Game.Events.Player;
@@ -17,12 +15,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 +26,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;
@@ -18,15 +20,17 @@ 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 IDictionary<int, IEnumerable<CPointWorldText>> icons =
// new Dictionary<int, IEnumerable<CPointWorldText>>();
private readonly IEnumerable<CPointWorldText>?[] icons =
new IEnumerable<CPointWorldText>[64];
private readonly IPlayerConverter<CCSPlayerController> players =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
@@ -34,24 +38,65 @@ public class RoleIconsHandler(IServiceProvider provider)
private readonly ITextSpawner? textSpawner =
provider.GetService<ITextSpawner>();
private readonly IDictionary<int, IEnumerable<CPointWorldText>> traitorIcons =
new Dictionary<int, IEnumerable<CPointWorldText>>();
private readonly HashSet<int> traitorsThisRound = new();
private readonly ISet<int> traitors = new HashSet<int>();
private readonly ulong[] visibilities = new ulong[64];
public void Start(BasePlugin? plugin) {
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);
}
public void ClearAllVisibility() {
Array.Clear(visibilities, 0, visibilities.Length);
}
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 +111,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 +122,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 +159,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 +168,29 @@ 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);
}
}
private void guardRange(int index, string name) {
if (index < 0 || index >= visibilities.Length)
throw new ArgumentOutOfRangeException(name);
}
}

View File

@@ -4,6 +4,7 @@ using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game;
@@ -15,6 +16,9 @@ public class RoundStart_GameStartHandler(IServiceProvider provider)
provider.GetService<IStorage<TTTConfig>>()?.Load().GetAwaiter().GetResult()
?? new TTTConfig();
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
@@ -28,6 +32,9 @@ public class RoundStart_GameStartHandler(IServiceProvider provider)
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

@@ -19,4 +19,7 @@ public interface ITextSpawner {
IEnumerable<CPointWorldText> CreateTextHat(TextSetting setting,
CCSPlayerController player);
IEnumerable<CPointWorldText> CreateTextScreen(TextSetting setting,
CCSPlayerController player);
}

View File

@@ -2,6 +2,7 @@
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
using Vector = CounterStrikeSharp.API.Modules.Utils.Vector;
namespace TTT.CS2.Hats;
@@ -36,6 +37,31 @@ public class TextSpawner : ITextSpawner {
return [one, two];
}
public IEnumerable<CPointWorldText> CreateTextScreen(TextSetting setting,
CCSPlayerController player) {
var screen = spawnScreen(setting, player);
return [screen];
}
public CPointWorldText spawnScreen(TextSetting setting,
CCSPlayerController player, float xOff = 0, float yOff = 0,
float zDist = 50) {
if (player.Pawn.Value == null || player.Pawn.Value.AbsRotation == null)
throw new Exception("Failed to get player rotation");
var eyes = player.GetEyePosition().Clone()!;
var localAngle = player.Pawn.Value.AbsRotation.Clone()!;
var forward = localAngle.Clone()!.ToForward();
var right = localAngle.ToRight();
var up = localAngle.ToUp();
var inFront = eyes + forward * zDist;
var centered = inFront + right * xOff + up * yOff;
var ent = CreateText(setting, centered,
new QAngle(localAngle.X + 180, localAngle.Y + 90, localAngle.Z + 270));
ent.AcceptInput("SetParent", player.Pawn.Value, null, "!activator");
return ent;
}
private CPointWorldText spawnHatPart(TextSetting setting,
CCSPlayerController player, float yRot) {
var position = player.Pawn.Value?.AbsOrigin;
@@ -46,19 +72,10 @@ public class TextSpawner : ITextSpawner {
position.Add(new Vector(0, 0, 72));
rotation = new QAngle(rotation.X, rotation.Y + yRot, rotation.Z + 90);
position.Add(GetRightVector(rotation) * -10);
position.Add(rotation.ToRight() * -10);
var ent = CreateText(setting, position, rotation);
ent.AcceptInput("SetParent", player.Pawn.Value, null, "!activator");
return ent;
}
public static Vector GetRightVector(QAngle rotation) {
var forward = new Vector {
X = (float)Math.Cos(rotation.Y * Math.PI / 180),
Y = (float)Math.Sin(rotation.Y * Math.PI / 180),
Z = 0
};
return forward;
}
}

View File

@@ -0,0 +1,74 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.API;
using TTT.CS2.Events;
using TTT.Game.Events.Game;
using TTT.Game.Listeners;
namespace TTT.CS2.Items.DNA;
public class DnaListener(IServiceProvider provider) : BaseListener(provider) {
private static readonly TimeSpan cooldown = TimeSpan.FromSeconds(15);
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
private readonly DnaScannerConfig config = provider
.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
private readonly Dictionary<string, DateTime> lastMessages = new();
private readonly IShop shop = provider.GetRequiredService<IShop>();
// Low priority to allow body identification to happen first
[UsedImplicitly]
[EventHandler(Priority = Priority.LOW)]
public void OnPropPickup(PropPickupEvent ev) {
if (ev.Player is not IOnlinePlayer player) return;
if (!shop.HasItem<DnaScanner>(player)) return;
if (!bodies.TryLookup(ev.Prop.Index.ToString(), out var body)) return;
if (body == null) return;
var victimRole = Roles.GetRoles(body.OfPlayer).FirstOrDefault();
if (victimRole == null) return;
if (lastMessages.TryGetValue(player.Id + "." + body.Id,
out var lastMessageTime))
if (DateTime.Now - lastMessageTime < cooldown)
return;
lastMessages[player.Id + "." + body.Id] = DateTime.Now;
if (DateTime.Now - body.TimeOfDeath > config.DecayTime) {
Messenger.Message(player,
Locale[DnaMsgs.SHOP_ITEM_DNA_EXPIRED(victimRole, body.OfPlayer)]);
return;
}
if (body.Killer == null)
Messenger.Message(player,
Locale[
DnaMsgs.SHOP_ITEM_DNA_SCANNED_SUICIDE(victimRole, body.OfPlayer)]);
else
Messenger.Message(player,
Locale[
DnaMsgs.SHOP_ITEM_DNA_SCANNED(victimRole, body.OfPlayer,
body.Killer)]);
}
[EventHandler]
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
lastMessages.Clear();
}
}

View File

@@ -0,0 +1,30 @@
using TTT.API.Player;
using TTT.API.Role;
using TTT.Game;
using TTT.Locale;
namespace TTT.CS2.Items.DNA;
public class DnaMsgs {
public static IMsg SHOP_ITEM_DNA => MsgFactory.Create(nameof(SHOP_ITEM_DNA));
public static IMsg SHOP_ITEM_DNA_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_DNA_DESC));
public static IMsg SHOP_ITEM_DNA_SCANNED(IRole victimRole, IPlayer player,
IPlayer killer) {
return MsgFactory.Create(nameof(SHOP_ITEM_DNA_SCANNED),
GameMsgs.GetRolePrefix(victimRole), player.Name, killer.Name);
}
public static IMsg
SHOP_ITEM_DNA_SCANNED_SUICIDE(IRole victimRole, IPlayer player) {
return MsgFactory.Create(nameof(SHOP_ITEM_DNA_SCANNED_SUICIDE),
GameMsgs.GetRolePrefix(victimRole), player.Name);
}
public static IMsg SHOP_ITEM_DNA_EXPIRED(IRole victimRole, IPlayer player) {
return MsgFactory.Create(nameof(SHOP_ITEM_DNA_EXPIRED),
GameMsgs.GetRolePrefix(victimRole), player.Name);
}
}

View File

@@ -0,0 +1,37 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
using TTT.Shop;
using TTT.Shop.Items;
namespace TTT.CS2.Items.DNA;
public static class DnaScannerServiceCollection {
public static void AddDnaScannerServices(this IServiceCollection collection) {
collection.AddModBehavior<DnaScanner>();
collection.AddModBehavior<DnaListener>();
}
}
public class DnaScanner(IServiceProvider provider)
: RoleRestrictedItem<DetectiveRole>(provider) {
private readonly DnaScannerConfig config = provider
.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
public override string Name => Locale[DnaMsgs.SHOP_ITEM_DNA];
public override string Description => Locale[DnaMsgs.SHOP_ITEM_DNA_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) { }
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
if (Shop.HasItem(player, this)) return PurchaseResult.ALREADY_OWNED;
return base.CanPurchase(player);
}
}

View File

@@ -42,9 +42,9 @@ public class PlayerStatsTracker(IServiceProvider provider) : IListener {
revealedDeaths.Add(gamePlayer.Slot);
}
// Needs to be higher so we detect the kill the game ends
// Needs to be higher so we detect the kill before the game ends
// in the case that this is the last player
[EventHandler(Priority = Priority.HIGHER)]
[EventHandler(Priority = Priority.HIGH)]
public void OnKill(PlayerDeathEvent ev) {
var killer = ev.Killer == null ? null : converter.GetPlayer(ev.Killer);
var assister =

View File

@@ -43,7 +43,7 @@ public class RoundTimerListener(IServiceProvider provider)
player.Respawn();
foreach (var player in Utilities.GetPlayers())
player.SetColor(Color.FromArgb(254, 255, 255, 255));
player.SetColor(Color.White);
});
return;

View File

@@ -0,0 +1,27 @@
using System.Drawing;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Events;
using TTT.API.Player;
using TTT.CS2.Extensions;
using TTT.CS2.Roles;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
namespace TTT.CS2.Listeners;
public class ScreenColorApplier(IServiceProvider provider)
: BaseListener(provider) {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
[EventHandler]
public void OnRoleAssign(PlayerRoleAssignEvent ev) {
if (ev.Role is SpectatorRole) return;
var player = converter.GetPlayer(ev.Player);
var alphaColor = Color.FromArgb(64, ev.Role.Color);
if (player != null)
player.ColorScreen(alphaColor, 3, 1, PlayerExtensions.FadeFlags.FADE_OUT);
}
}

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,25 @@ public class CS2AliveSpoofer : IAliveSpoofer, IPluginModule {
public void SpoofAlive(CCSPlayerController player) {
if (player.IsBot) {
player.PawnIsAlive = true;
Utilities.SetStateChanged(player, "CCSPlayerController",
"m_bPawnIsAlive");
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");
});
});
return;
}
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

@@ -1,5 +1,6 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using TTT.API;
using TTT.API.Player;
using TTT.CS2.Extensions;
@@ -8,24 +9,17 @@ 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;
gamePlayer.GiveNamedItem(weapon.Id);
if (weapon.ReserveAmmo == null && weapon.CurrentAmmo == null) return;
var weaponBase = gamePlayer.GetWeaponBase(weapon.Id);
if (weaponBase == null) return;
if (weapon.CurrentAmmo != null)
weaponBase.Clip1 = weapon.CurrentAmmo.Value;
if (weapon.ReserveAmmo != null)
weaponBase.Clip2 = weapon.ReserveAmmo.Value;
giveWeapon(gamePlayer, weapon);
});
}
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);
@@ -51,8 +45,19 @@ public class CS2InventoryManager(
});
}
public void RemoveAllWeapons(IOnlinePlayer player) {
Server.NextWorldUpdate(() => {
public Task RemoveWeaponInSlot(IOnlinePlayer player, int slot) {
return Server.NextWorldUpdateAsync(() => {
if (!player.IsAlive) return;
var gamePlayer = converter.GetPlayer(player);
if (gamePlayer == null) return;
clearSlot(gamePlayer, IntToSlot(slot));
});
}
public Task RemoveAllWeapons(IOnlinePlayer player) {
return Server.NextWorldUpdateAsync(() => {
if (!player.IsAlive) return;
var gamePlayer = converter.GetPlayer(player);
@@ -61,4 +66,69 @@ public class CS2InventoryManager(
gamePlayer.RemoveWeapons();
});
}
private void giveWeapon(CCSPlayerController player, IWeapon weapon) {
if (player.Team is CsTeam.None or CsTeam.Spectator) return;
// Give the weapon
player.GiveNamedItem(weapon.WeaponId);
// 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.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) {
return slot switch {
0 => gear_slot_t.GEAR_SLOT_RIFLE,
1 => gear_slot_t.GEAR_SLOT_PISTOL,
2 => gear_slot_t.GEAR_SLOT_KNIFE,
3 => gear_slot_t.GEAR_SLOT_UTILITY,
4 => gear_slot_t.GEAR_SLOT_C4,
_ => gear_slot_t.GEAR_SLOT_FIRST
};
}
public static int SlotToInt(gear_slot_t slot) {
return slot switch {
gear_slot_t.GEAR_SLOT_RIFLE => 0,
gear_slot_t.GEAR_SLOT_PISTOL => 1,
gear_slot_t.GEAR_SLOT_KNIFE => 2,
gear_slot_t.GEAR_SLOT_UTILITY => 3,
gear_slot_t.GEAR_SLOT_C4 => 4,
_ => -1
};
}
private void clearSlot(CCSPlayerController player,
params gear_slot_t[] slots) {
if (player.Team is CsTeam.None or CsTeam.Spectator) return;
var weapons = player.Pawn.Value?.WeaponServices?.MyWeapons;
if (weapons == null || weapons.Count == 0) return;
foreach (var weapon in weapons) {
if (!weapon.IsValid || weapon.Value == null) continue;
if (!weapon.Value.IsValid
|| !weapon.Value.DesignerName.StartsWith("weapon_"))
continue;
if (weapon.Value.Entity == null) continue;
var weaponBase = weapon.Value.As<CCSWeaponBase>();
if (!weaponBase.IsValid || weaponBase.Entity == null) continue;
var vdata = weaponBase.VData;
if (vdata == null) continue;
if (!slots.Contains(vdata.GearSlot)) continue;
weapon.Value.AddEntityIOEvent("Kill", weapon.Value);
}
}
}

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

@@ -1,2 +1,8 @@
ROLE_SPECTATOR: "Spectator"
TASER_SCANNED: "%PREFIX%You scanned {0}{grey}, they are %an% {1}{grey}!"
TASER_SCANNED: "%PREFIX%You scanned {0}{grey}, they are %an% {1}{grey}!"
DNA_PREFIX: " {darkblue}D{blue}N{lightblue}A{grey} | {grey}"
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}%s%{grey}."
SHOP_ITEM_DNA_SCANNED_SUICIDE: "%DNA_PREFIX%You scanned {0}{1}'%s% {grey}body, they killed themselves."
SHOP_ITEM_DNA_EXPIRED: "%DNA_PREFIX%You scanned {0}{1}'%s% {grey}body, but the DNA has expired."

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

@@ -80,6 +80,6 @@ public class PlayerDamagedEvent(IOnlinePlayer player, IOnlinePlayer? attacker,
}
}
public string? Weapon { get; private set; }
public string? Weapon { get; init; }
public bool IsCanceled { get; set; }
}

View File

@@ -9,4 +9,5 @@ public interface IBody {
IWeapon? MurderWeapon { get; }
IPlayer? Killer { get; }
string Id { get; }
DateTime TimeOfDeath { get; }
}

View File

@@ -8,7 +8,8 @@ namespace TTT.Game.Listeners.Loggers;
public class PlayerActionsLogger(IServiceProvider provider)
: BaseListener(provider) {
[EventHandler]
// Needs to be higher so we detect the kill before the game ends
[EventHandler(Priority = Priority.HIGH)]
[UsedImplicitly]
public void OnPlayerKill(PlayerDeathEvent ev) {
if (Games.ActiveGame is not { State: State.IN_PROGRESS }) return;

View File

@@ -25,6 +25,9 @@ public class PlayerJoinStarting(IServiceProvider provider)
$"There are {playerCount} Players online, starting the game...");
var game = Games.CreateGame();
if (game == null)
Messenger.DebugAnnounce("Failed to create a new game instance.");
game?.Start(config.RoundCfg.CountDownDuration);
}
}

View File

@@ -1,5 +1,4 @@
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using TTT.API.Game;
using TTT.API.Messages;

View File

@@ -4,7 +4,7 @@ namespace TTT.Game.Roles;
public class BaseWeapon(string id, int? reserve = null, int? current = null)
: IWeapon {
public string Id { get; } = id;
public string WeaponId { get; } = id;
public int? ReserveAmmo { get; } = reserve;
public int? CurrentAmmo { get; } = current;
}

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

@@ -5,7 +5,7 @@ the subsidiary projects (i.e. not [Versioning](../Versioning)).
# Structure
## [API](./API)
The public API for TTT. Use this to add-on extra features, modules, roles, etc.
The public API for TTT. Include this to add-on extra features, modules, roles, etc.
## [CS2](./CS2)
A linker for CS2 to TTT. This adds support for CS2 to TTT.

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Player;
@@ -6,8 +7,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

@@ -1,4 +1,6 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Game;
using TTT.API.Player;
@@ -16,9 +18,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) {
@@ -35,36 +37,22 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
if (info.ArgCount == 1) return CommandResult.PRINT_USAGE;
var query = string.Join(" ", info.Args.Skip(1));
info.ReplySync($"Searching for item: {query}");
var item = searchItem(query);
var item = searchItem(query);
if (item == null) {
info.ReplySync($"Item '{query}' not found.");
info.ReplySync(locale[ShopMsgs.SHOP_ITEM_NOT_FOUND(query)]);
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

@@ -1,4 +1,6 @@
using Microsoft.Extensions.DependencyInjection;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Messages;
using TTT.API.Player;
@@ -13,14 +15,15 @@ public class ListCommand(IServiceProvider provider) : ICommand {
public void Dispose() { }
public string Name => "list";
public string Id => "list";
public void Start() { }
public Task<CommandResult>
Execute(IOnlinePlayer? executor, ICommandInfo info) {
foreach (var item in shop.Items)
messenger.Message(executor, $"{item.Name} - {item.Description}");
messenger.Message(executor,
$"{ChatColors.Grey}- {ChatColors.White}{item.Name} {ChatColors.Grey}- {item.Description}");
return Task.FromResult(CommandResult.SUCCESS);
}

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,41 @@
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
namespace TTT.Shop.Items.Detective.Stickers;
public class StickerListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IIconManager? icons = provider.GetService<IIconManager>();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[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;
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,40 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
namespace TTT.Shop.Items.Detective.Stickers;
public static class StickerExtensions {
public static void AddStickerServices(this IServiceCollection services) {
services.AddModBehavior<Stickers>();
services.AddModBehavior<StickerListener>();
}
}
public class Stickers(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(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 (Shop.HasItem(player, this)) return PurchaseResult.ALREADY_OWNED;
return base.CanPurchase(player);
}
}

View File

@@ -1,4 +1,7 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
@@ -18,32 +21,38 @@ public class DeagleDamageListener(IServiceProvider provider)
private readonly IShop shop = provider.GetRequiredService<IShop>();
[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())
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;
}
}
if (victim is not IOnlinePlayer onlineVictim) return;
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

@@ -1,60 +1,48 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Configs;
using TTT.API;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Locale;
namespace TTT.Shop.Items;
public static class DeagleServiceCollection {
public static void AddDeagleServices(this IServiceCollection collection) {
collection.AddModBehavior<OneShotDeagle>();
collection.AddModBehavior<DeagleDamageListener>();
}
}
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 PurchaseResult CanPurchase(IOnlinePlayer player) {
return PurchaseResult.SUCCESS;
}
string IShopItem.Id => ID;
public string Id => deagleConfigStorage.Weapon;
public string WeaponId => deagleConfigStorage.Weapon;
public int? ReserveAmmo { get; init; } = 0;
public int? CurrentAmmo { get; init; } = 1;
}
public record OneShotDeagleConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public bool DoesFriendlyFire { get; init; } = true;
public string Weapon { get; init; } = "revolver";
public override void OnPurchase(IOnlinePlayer player) {
Task.Run(async () => {
await Inventory.RemoveWeaponInSlot(player,
deagleConfigStorage.WeaponSlot);
await Inventory.GiveWeapon(player, this);
});
}
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return PurchaseResult.SUCCESS;
}
}

View File

@@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Player;
using TTT.API.Storage;

View File

@@ -1,5 +1,6 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.Game.Events.Game;
@@ -12,7 +13,7 @@ public class RoundShopClearer(IServiceProvider provider) : IListener {
public void Dispose() { bus.UnregisterListener(this); }
[EventHandler(IgnoreCanceled = true, Priority = Priority.LOWER)]
[EventHandler(IgnoreCanceled = true, Priority = Priority.LOW)]
[UsedImplicitly]
public void OnRoundStart(GameStateUpdateEvent ev) {
// Only clear balances if the round is in progress

View File

@@ -1,11 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API;
using TTT.API.Events;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.Locale;
using TTT.Shop.Events;
using TTT.Shop.Items;
namespace TTT.Shop;
@@ -26,14 +26,37 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
public ISet<IShopItem> Items { get; } = new HashSet<IShopItem>();
public bool RegisterItem(IShopItem item) {
item.Start();
return Items.Add(item);
}
public bool RegisterItem(IShopItem item) { return Items.Add(item); }
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 +89,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 +112,5 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
Items.Clear();
}
public void Start() { RegisterItem(new OneShotDeagle(provider)); }
public void Start() { }
}

View File

@@ -16,6 +16,8 @@
<ItemGroup>
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\Game\Game.csproj"/>
<ProjectReference Include="..\CS2\CS2.csproj"/>
<ProjectReference Include="..\ShopAPI\ShopAPI.csproj"/>
</ItemGroup>
</Project>

View File

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

View File

@@ -1,39 +0,0 @@
namespace TTT.Shop;
public enum PurchaseResult {
/// <summary>
/// The purchase was successful.
/// </summary>
SUCCESS,
/// <summary>
/// The player does not have enough funds to purchase the item.
/// </summary>
INSUFFICIENT_FUNDS,
/// <summary>
/// The item was not found in the shop or does not exist.
/// </summary>
ITEM_NOT_FOUND,
/// <summary>
/// The player cannot purchase this item, either due to per-player restrictions
/// or the item being unavailable for purchase at the moment.
/// </summary>
ITEM_NOT_PURCHASABLE,
/// <summary>
/// An event canceled the purchase.
/// </summary>
PURCHASE_CANCELED,
/// <summary>
/// An unknown error occurred during the purchase process.
/// </summary>
UNKNOWN_ERROR,
/// <summary>
/// The item cannot be purchased multiple times, and the player already owns it.
/// </summary>
ALREADY_OWNED
}

View File

@@ -1,7 +1,10 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Extensions;
using TTT.CS2.Items.DNA;
using TTT.Shop.Commands;
using TTT.Shop.Items;
using TTT.Shop.Items.Detective.Stickers;
using TTT.Shop.Listeners;
namespace TTT.Shop;
@@ -18,5 +21,7 @@ public static class ShopServiceCollection {
collection.AddModBehavior<BalanceCommand>();
collection.AddDeagleServices();
collection.AddStickerServices();
collection.AddDnaScannerServices();
}
}

View File

@@ -5,8 +5,14 @@ namespace TTT.Shop;
public static class ShopMsgs {
public static IMsg SHOP_INACTIVE => MsgFactory.Create(nameof(SHOP_INACTIVE));
public static IMsg SHOP_ITEM_NOT_FOUND(string query)
=> MsgFactory.Create(nameof(SHOP_ITEM_NOT_FOUND), query);
public static IMsg CREDITS_NAME => MsgFactory.Create(nameof(CREDITS_NAME));
public static IMsg SHOP_CANNOT_PURCHASE
=> MsgFactory.Create(nameof(SHOP_CANNOT_PURCHASE));
public static IMsg CREDITS_GIVEN(int amo) {
return MsgFactory.Create(nameof(CREDITS_GIVEN), amo > 0 ? "+" : "-",
Math.Abs(amo));
@@ -16,4 +22,13 @@ 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_WITH_REASON(string reason) {
return MsgFactory.Create(nameof(SHOP_CANNOT_PURCHASE_WITH_REASON), reason);
}
}

View File

@@ -1,6 +1,17 @@
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_NOT_FOUND: "%PREFIX%Could not find an item named \"{default}{0}{grey}\"."
SHOP_ITEM_DEAGLE: "One-Hit Revolver"
SHOP_ITEM_DEAGLE_DESC: "A one-hit kill revolver with a single bullet. Aim carefully!"
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})"

31
TTT/ShopAPI/BaseItem.cs Normal file
View File

@@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Player;
using TTT.API.Role;
using TTT.Locale;
namespace TTT.Shop.Items;
public abstract class BaseItem(IServiceProvider provider) : IShopItem {
protected readonly IInventoryManager Inventory =
provider.GetRequiredService<IInventoryManager>();
protected readonly IMsgLocalizer Locale =
provider.GetRequiredService<IMsgLocalizer>();
protected readonly IServiceProvider Provider = provider;
protected readonly IRoleAssigner Roles =
provider.GetRequiredService<IRoleAssigner>();
protected readonly IShop Shop = provider.GetRequiredService<IShop>();
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,9 @@
using TTT.Shop;
namespace ShopAPI.Configs;
public record DnaScannerConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public int MaxSamples { get; init; } = 0;
public TimeSpan DecayTime { get; init; } = TimeSpan.FromSeconds(10);
}

View File

@@ -0,0 +1,11 @@
using TTT.Shop;
namespace ShopAPI.Configs;
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

@@ -0,0 +1,7 @@
using TTT.Shop;
namespace ShopAPI.Configs;
public record StickerConfig : ShopItemConfig {
public override int Price { get; init; } = 70;
}

View File

@@ -1,7 +1,8 @@
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Shop;
namespace TTT.Shop;
namespace ShopAPI;
public interface IShop : IKeyedStorage<IPlayer, int>,
IKeyWritable<IPlayer, int> {
@@ -20,5 +21,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

@@ -0,0 +1,65 @@
namespace TTT.Shop;
public enum PurchaseResult {
/// <summary>
/// The purchase was successful.
/// </summary>
SUCCESS,
/// <summary>
/// The player does not have enough funds to purchase the item.
/// </summary>
INSUFFICIENT_FUNDS,
/// <summary>
/// The item was not found in the shop or does not exist.
/// </summary>
ITEM_NOT_FOUND,
/// <summary>
/// The player cannot purchase this item, either due to per-player restrictions
/// or the item being unavailable for purchase at the moment.
/// </summary>
ITEM_NOT_PURCHASABLE,
/// <summary>
/// The player does not have the required role to purchase this item.
/// </summary>
WRONG_ROLE,
/// <summary>
/// An event canceled the purchase.
/// </summary>
PURCHASE_CANCELED,
/// <summary>
/// An unknown error occurred during the purchase process.
/// </summary>
UNKNOWN_ERROR,
/// <summary>
/// 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.WRONG_ROLE =>
"You do not have the required role to purchase this item",
PurchaseResult.ALREADY_OWNED =>
"You already own this item and cannot purchase it again",
_ => "An unexpected error occurred"
};
}
}

View File

@@ -0,0 +1,13 @@
using TTT.API.Player;
using TTT.API.Role;
namespace TTT.Shop.Items;
public abstract class RoleRestrictedItem<T>(IServiceProvider provider)
: BaseItem(provider) where T : IRole {
public override PurchaseResult CanPurchase(IOnlinePlayer player) {
return Roles.GetRoles(player).Any(r => r is T) ?
PurchaseResult.SUCCESS :
PurchaseResult.WRONG_ROLE;
}
}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\Game\Game.csproj"/>
</ItemGroup>
</Project>

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 Task GiveWeapon(IOnlinePlayer player, IWeapon weapon) {
return Task.CompletedTask;
}
public void RemoveAllWeapons(IOnlinePlayer player) { }
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

@@ -9,8 +9,7 @@ public class TTTTest(IServiceProvider provider)
public void Command_ShouldPrint_Version() {
var player = TestPlayer.Random();
Commands.ProcessCommand(new TestCommandInfo(Provider, player,
Command.Name));
Commands.ProcessCommand(new TestCommandInfo(Provider, player, Command.Id));
Assert.Single(player.Messages);
Assert.Contains(Command.Version, player.Messages.First());
@@ -24,7 +23,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

@@ -1,8 +1,8 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Shop;
using TTT.Shop.Listeners;
using Xunit;

View File

@@ -1,7 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
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;
@@ -10,13 +12,16 @@ using Xunit;
namespace TTT.Test.Shop.Commands;
public class BuyTest {
private readonly IMsgLocalizer locale;
private readonly ICommandManager manager;
private readonly IServiceProvider provider;
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 +81,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 +117,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

@@ -0,0 +1,69 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Game.Events.Player;
using TTT.Shop.Items;
using Xunit;
namespace TTT.Test.Shop.Items;
public class DeagleTests {
private readonly IEventBus bus;
private readonly OneShotDeagle item;
private readonly IServiceProvider provider;
private readonly IShop shop;
private readonly TestPlayer testPlayer;
private readonly IOnlinePlayer victim, survivor;
public DeagleTests(IServiceProvider provider) {
this.provider = provider;
var games = provider.GetRequiredService<IGameManager>();
var finder = provider.GetRequiredService<IPlayerFinder>();
shop = provider.GetRequiredService<IShop>();
bus = provider.GetRequiredService<IEventBus>();
item = new OneShotDeagle(provider);
testPlayer = (finder.AddPlayer(TestPlayer.Random()) as TestPlayer)!;
victim = finder.AddPlayer(TestPlayer.Random());
survivor = finder.AddPlayer(TestPlayer.Random());
games.CreateGame()?.Start();
}
[Fact]
public void Deagle_Kills_OnDamage() {
bus.RegisterListener(new DeagleDamageListener(provider));
shop.GiveItem(testPlayer, item);
var playerDmgEvent =
new PlayerDamagedEvent(victim, testPlayer, 1, 99) {
Weapon = item.WeaponId
};
bus.Dispatch(playerDmgEvent);
Assert.Equal(0, victim.Health);
Assert.False(victim.IsAlive);
}
[Fact]
public void Deagle_DoesNotKill_AfterFirstKill() {
bus.RegisterListener(new DeagleDamageListener(provider));
shop.GiveItem(testPlayer, item);
var playerDmgEvent =
new PlayerDamagedEvent(victim, testPlayer, 1, 99) {
Weapon = item.WeaponId
};
bus.Dispatch(playerDmgEvent);
var secondDmgEvent =
new PlayerDamagedEvent(survivor, testPlayer, 1, 99) {
Weapon = item.WeaponId
};
bus.Dispatch(secondDmgEvent);
Assert.NotEqual(0, survivor.Health);
}
}

View File

@@ -0,0 +1,57 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.Shop.Listeners;
using Xunit;
namespace TTT.Test.Shop;
public class ShopTests(IServiceProvider provider) {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IOnlinePlayer player = TestPlayer.Random();
private readonly IShop shop = provider.GetRequiredService<IShop>();
[Fact]
public void GiveItem_ShowsInInventory() {
shop.GiveItem(player, new TestShopItem());
Assert.Single(shop.GetOwnedItems(player));
Assert.Equal(TestShopItem.ID, shop.GetOwnedItems(player)[0].Name);
}
[Fact]
public async Task ClearBalances_ClearsBalances() {
shop.AddBalance(player, 500, "Test");
Assert.Equal(500, await shop.Load(player));
shop.ClearBalances();
Assert.Equal(0, await shop.Load(player));
}
[Fact]
public void ClearItems_ClearsItems() {
shop.GiveItem(player, new TestShopItem());
shop.ClearItems();
Assert.Empty(shop.GetOwnedItems(player));
}
[Fact]
public void Shop_ClearsItems_OnNewRound() {
bus.RegisterListener(new RoundShopClearer(provider));
finder.AddPlayers(player, TestPlayer.Random());
games.CreateGame()?.Start();
shop.GiveItem(player, new TestShopItem());
games.ActiveGame?.EndGame();
games.CreateGame()?.Start();
Assert.Empty(shop.GetOwnedItems(player));
}
}

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();

View File

@@ -2,6 +2,7 @@ using System.Reactive.Concurrency;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using Microsoft.Reactive.Testing;
using ShopAPI;
using TTT.API.Command;
using TTT.API.Events;
using TTT.API.Extensions;
@@ -15,7 +16,6 @@ using TTT.Game.Commands;
using TTT.Game.Roles;
using TTT.Karma;
using TTT.Locale;
using TTT.Shop;
using TTT.Test.Abstract;
using TTT.Test.Fakes;

View File

@@ -16,7 +16,15 @@ public class TestPlayer(string id, string name) : IOnlinePlayer {
public int Health { get; set; } = 100;
public int MaxHealth { get; set; } = 100;
public int Armor { get; set; } = 100;
public bool IsAlive { get; set; } = true;
public bool IsAlive {
get => Health > 0;
set {
if (!value)
Health = 0;
else if (Health <= 0) Health = 1;
}
}
public static TestPlayer Random() {
return new TestPlayer(new Random().NextInt64().ToString(),