Compare commits

...

66 Commits

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

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

**Additional updates:**

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

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

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

Key Changes:

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -58,7 +58,8 @@ public class GiveItemCommand(IServiceProvider provider) : ICommand {
private IShopItem? searchItem(string query) {
var item = shop.Items.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
=> it.Name.Replace(" ", "")
.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
// Karma deltas
public static readonly FakeConVar<int> CV_INNO_ON_TRAITOR = new(
"css_ttt_karma_inno_on_traitor",
"Karma gained when Innocent kills a Traitor", 5, ConVarFlags.FCVAR_NONE,
"Karma gained when Innocent kills a Traitor", 4, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_DETECTIVE = new(
@@ -59,19 +59,29 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_INNO_ON_INNO = new(
"css_ttt_karma_inno_on_inno",
"Karma lost when Innocent kills another Innocent", -4,
"Karma lost when Innocent kills another Innocent", -5,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_TRAITOR = new(
"css_ttt_karma_traitor_on_traitor",
"Karma lost when Traitor kills another Traitor", -5, ConVarFlags.FCVAR_NONE,
"Karma lost when Traitor kills another Traitor", -6, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_DETECTIVE = new(
"css_ttt_karma_inno_on_detective",
"Karma lost when Innocent kills a Detective", -6, ConVarFlags.FCVAR_NONE,
"Karma lost when Innocent kills a Detective", -8, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND = new(
"css_ttt_karma_per_round",
"Amount of karma a player will gain at the end of each round", 2,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND_WIN = new(
"css_ttt_karma_per_round_win",
"Amount of karma a player will gain at the end of each round if their team won",
4, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 50));
public void Dispose() { }
public void Start() { }
@@ -90,6 +100,8 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
KarmaTimeoutThreshold = CV_TIMEOUT_THRESHOLD.Value,
KarmaRoundTimeout = CV_ROUND_TIMEOUT.Value,
KarmaWarningWindow = TimeSpan.FromHours(CV_WARNING_WINDOW_HOURS.Value),
KarmaPerRound = CV_KARMA_PER_ROUND.Value,
KarmaPerRoundWin = CV_KARMA_PER_ROUND_WIN.Value,
INNO_ON_TRAITOR = CV_INNO_ON_TRAITOR.Value,
TRAITOR_ON_DETECTIVE = CV_TRAITOR_ON_DETECTIVE.Value,
INNO_ON_INNO_VICTIM = CV_INNO_ON_INNO_VICTIM.Value,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,9 @@ using TTT.Game.Roles;
namespace TTT.CS2.Game;
public class CS2Game(IServiceProvider provider) : RoundBasedGame(provider) {
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public override State State {
set {
var ev = new GameStateUpdateEvent(this, value);
@@ -33,9 +36,6 @@ public class CS2Game(IServiceProvider provider) : RoundBasedGame(provider) {
new TraitorRole(provider), new DetectiveRole(provider)
];
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
override protected void StartRound() {
Server.NextWorldUpdate(() => {
base.StartRound();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,9 @@ using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.CS2.API;
using TTT.CS2.Extensions;
using TTT.Game;
using TTT.Game.Events.Player;
namespace TTT.CS2.GameHandlers;
@@ -22,6 +25,9 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IBodyTracker bodies =
provider.GetRequiredService<IBodyTracker>();
public void Dispose() { }
public void Start() { }
@@ -33,6 +39,8 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
CommandInfo commandInfo) {
CsTeam requestedTeam;
if (player == null) return HookResult.Continue;
if (int.TryParse(commandInfo.GetArg(1), out var teamIndex))
requestedTeam = (CsTeam)teamIndex;
else
@@ -44,15 +52,21 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
};
if (games.ActiveGame is not { State: State.IN_PROGRESS }) {
if (player != null && player.LifeState != (int)LifeState_t.LIFE_ALIVE)
Server.NextWorldUpdate(player.Respawn);
if (player.GetHealth() <= 0) Server.NextWorldUpdate(player.Respawn);
return HookResult.Continue;
}
if (requestedTeam is CsTeam.CounterTerrorist or CsTeam.Terrorist)
if (player != null && player.Team is CsTeam.Spectator or CsTeam.None)
if (player.Team is CsTeam.Spectator or CsTeam.None)
return HookResult.Continue;
var apiPlayer = converter.GetPlayer(player);
// If the player is dead and already identified, let them move to spec
if (bodies.Bodies.Keys.Any(b
=> b.OfPlayer.Id == apiPlayer.Id && b.IsIdentified))
return HookResult.Continue;
return HookResult.Handled;
}
@@ -60,13 +74,15 @@ public class TeamChangeHandler(IServiceProvider provider) : IPluginModule {
[GameEventHandler]
public HookResult OnChangeTeam(EventPlayerTeam ev, GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
if (ev.Userid.LifeState == (int)LifeState_t.LIFE_ALIVE)
var team = (CsTeam)ev.Team;
if (team is not (CsTeam.Spectator or CsTeam.None))
return HookResult.Continue;
var apiPlayer = converter.GetPlayer(ev.Userid);
var playerDeath = new PlayerDeathEvent(apiPlayer);
bus.Dispatch(playerDeath);
Server.NextWorldUpdate(() => {
var playerDeath = new PlayerDeathEvent(apiPlayer);
bus.Dispatch(playerDeath);
});
return HookResult.Continue;
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
using ShopAPI.Configs.Traitor;
using TTT.API.Extensions;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.Game.Roles;
namespace TTT.CS2.Items.ClusterGrenade;
public static class ClusterGrenadeServiceCollection {
public static void AddClusterGrenade(this IServiceCollection services) {
services.AddModBehavior<ClusterGrenadeItem>();
services.AddModBehavior<ClusterGrenadeListener>();
}
}
public class ClusterGrenadeItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider) {
private ClusterGrenadeConfig config
=> Provider.GetService<IStorage<ClusterGrenadeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ClusterGrenadeConfig();
public override string Name
=> Locale[ClusterGrenadeMsgs.SHOP_ITEM_CLUSTER_GRENADE];
public override string Description
=> Locale[ClusterGrenadeMsgs.SHOP_ITEM_CLUSTER_GRENADE_DESC];
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) {
Inventory.GiveWeapon(player, config);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,9 +3,15 @@ using TTT.Locale;
namespace TTT.CS2.Items.Compass;
public class CompassMsgs {
public static IMsg SHOP_ITEM_COMPASS
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS));
public static IMsg SHOP_ITEM_COMPASS_PLAYER
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_PLAYER));
public static IMsg SHOP_ITEM_COMPASS_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_DESC));
public static IMsg SHOP_ITEM_COMPASS_PLAYER_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_PLAYER_DESC));
public static IMsg SHOP_ITEM_COMPASS_BODY
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_BODY));
public static IMsg SHOP_ITEM_COMPASS_BODY_DESC
=> MsgFactory.Create(nameof(SHOP_ITEM_COMPASS_BODY_DESC));
}

View File

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

View File

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

View File

@@ -18,11 +18,11 @@ public static class DnaScannerServiceCollection {
public class DnaScanner(IServiceProvider provider)
: RoleRestrictedItem<DetectiveRole>(provider) {
private readonly DnaScannerConfig config = provider
.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
private DnaScannerConfig config
=> Provider.GetService<IStorage<DnaScannerConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new DnaScannerConfig();
public override string Name => Locale[DnaMsgs.SHOP_ITEM_DNA];
public override string Description => Locale[DnaMsgs.SHOP_ITEM_DNA_DESC];

View File

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

View File

@@ -13,8 +13,8 @@ namespace TTT.CS2.Items.OneHitKnife;
public class OneHitKnifeListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly OneHitKnifeConfig config =
provider.GetService<IStorage<OneHitKnifeConfig>>()
private OneHitKnifeConfig config
=> Provider.GetService<IStorage<OneHitKnifeConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new OneHitKnifeConfig();
@@ -32,13 +32,12 @@ public class OneHitKnifeListener(IServiceProvider provider)
if (attacker == null) return;
if (!shop.HasItem<OneHitKnife>(attacker)) return;
if (victim is not IOnlinePlayer onlineVictim) return;
var friendly = Roles.GetRoles(attacker)
.Any(r => Roles.GetRoles(victim).Contains(r));
if (friendly && !config.FriendlyFire) return;
shop.RemoveItem<OneHitKnife>(attacker);
ev.HpLeft = 0;
ev.HpLeft = -100;
}
}

View File

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

View File

@@ -13,6 +13,7 @@ using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Game.Events.Body;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
@@ -41,6 +42,8 @@ public class PoisonShotsListener(IServiceProvider provider)
private readonly IShop shop = provider.GetRequiredService<IShop>();
private readonly Dictionary<string, IPlayer> killedWithPoison = new();
public override void Dispose() {
base.Dispose();
foreach (var timer in poisonTimers) timer.Dispose();
@@ -80,6 +83,7 @@ public class PoisonShotsListener(IServiceProvider provider)
foreach (var timer in poisonTimers) timer.Dispose();
poisonTimers.Clear();
poisonShots.Clear();
killedWithPoison.Clear();
}
[SuppressMessage("ReSharper", "AccessToModifiedClosure")]
@@ -114,19 +118,20 @@ public class PoisonShotsListener(IServiceProvider provider)
if (dmgEvent.IsCanceled) return true;
if (online.Health - config.PoisonConfig.DamagePerTick <= 0) {
killedWithPoison[online.Id] = effect.Shooter;
var deathEvent = new PlayerDeathEvent(online)
.WithKiller(effect.Shooter as IOnlinePlayer)
.WithWeapon($"[{Locale[PoisonShotMsgs.SHOP_ITEM_POISON_SHOTS]}]");
bus.Dispatch(deathEvent);
}
online.Health -= config.PoisonConfig.DamagePerTick;
effect.Ticks++;
effect.DamageGiven += config.PoisonConfig.DamagePerTick;
var gamePlayer = converter.GetPlayer(online);
gamePlayer?.ColorScreen(config.PoisonColor, 0.2f, 0.3f);
gamePlayer?.ExecuteClientCommand("play " + config.PoisonConfig.PoisonSound);
if (gamePlayer != null)
gamePlayer.DealPoisonDamage(config.PoisonConfig.DamagePerTick);
return effect.DamageGiven < config.PoisonConfig.TotalDamage;
}
@@ -157,4 +162,15 @@ public class PoisonShotsListener(IServiceProvider provider)
public int Ticks { get; set; }
public int DamageGiven { get; set; }
}
[UsedImplicitly]
[EventHandler]
public void OnRagdollSpawn(BodyCreateEvent ev) {
if (!killedWithPoison.TryGetValue(ev.Body.OfPlayer.Id, out var shooter))
return;
if (ev.Body.Killer != null && ev.Body.Killer.Id != ev.Body.OfPlayer.Id)
return;
ev.Body.Killer = shooter as IOnlinePlayer;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,8 +15,8 @@ using TTT.Karma.lang;
namespace TTT.CS2.Listeners;
public class KarmaBanner(IServiceProvider provider) : BaseListener(provider) {
private readonly KarmaConfig config =
provider.GetService<IStorage<KarmaConfig>>()
private KarmaConfig config
=> Provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,15 +32,16 @@ public class LogsCommand(IServiceProvider provider) : ICommand {
if (games.ActiveGame is not {
State: State.IN_PROGRESS or State.FINISHED
}) {
info.ReplySync("No active game to show logs for.");
messenger.Message(executor, localizer[GameMsgs.GAME_LOGS_NONE]);
return Task.FromResult(CommandResult.ERROR);
}
if (executor is { IsAlive: true })
if (executor is { IsAlive: true }) {
messenger.MessageAll(localizer[GameMsgs.LOGS_VIEWED_ALIVE(executor)]);
else if (icons != null && executor != null)
if (int.TryParse(executor.Id, out var slot))
icons.SetVisiblePlayers(slot, ulong.MaxValue);
} else if (icons != null && executor != null) {
icons.SetVisiblePlayers(executor, ulong.MaxValue);
messenger.Message(executor, localizer[GameMsgs.LOGS_VIEWED_INFO]);
}
games.ActiveGame.Logger.PrintLogs(executor);
return Task.FromResult(CommandResult.SUCCESS);

View File

@@ -183,7 +183,9 @@ public class RoundBasedGame(IServiceProvider provider) : IGame {
GameMsgs.GAME_STATE_STARTED(traitors, nonTraitors)]);
}
virtual protected ISet<IOnlinePlayer> GetParticipants() => finder.GetOnline();
virtual protected ISet<IOnlinePlayer> GetParticipants() {
return finder.GetOnline();
}
#region classDeps

View File

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

View File

@@ -27,6 +27,12 @@ public static class GameMsgs {
public static IMsg GAME_LOGS_FOOTER
=> MsgFactory.Create(nameof(GAME_LOGS_FOOTER));
public static IMsg GAME_LOGS_NONE
=> MsgFactory.Create(nameof(GAME_LOGS_NONE));
public static IMsg LOGS_VIEWED_INFO
=> MsgFactory.Create(nameof(LOGS_VIEWED_INFO));
public static IMsg ROLE_REVEAL_DEATH(IRole killerRole) {
return MsgFactory.Create(nameof(ROLE_REVEAL_DEATH),
GetRolePrefix(killerRole) + killerRole.Name);

View File

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

View File

@@ -48,9 +48,9 @@ public record KarmaConfig {
/// <summary>
/// Amount of karma a player will gain at the end of each round.
/// </summary>
public int KarmaPerRound { get; init; } = 3;
public int KarmaPerRound { get; init; } = 1;
public int KarmaPerRoundWin { get; init; } = 5;
public int KarmaPerRoundWin { get; init; } = 2;
public int INNO_ON_TRAITOR { get; init; } = 5;
public int TRAITOR_ON_DETECTIVE { get; init; } = 1;

View File

@@ -19,8 +19,11 @@ public sealed class KarmaStorage(IServiceProvider provider) : IKarmaService {
private const bool EnableCache = true;
private readonly IEventBus _bus = provider.GetRequiredService<IEventBus>();
private readonly IStorage<KarmaConfig>? _configStorage =
provider.GetService<IStorage<KarmaConfig>>();
private KarmaConfig _configStorage
=> provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
private readonly SemaphoreSlim _flushGate = new(1, 1);
@@ -38,12 +41,6 @@ public sealed class KarmaStorage(IServiceProvider provider) : IKarmaService {
public string Version => GitVersionInformation.FullSemVer;
public void Start() {
// Load configuration first
if (_configStorage is not null)
// Synchronously wait here since IKarmaService.Start is sync
_config = _configStorage.Load().GetAwaiter().GetResult()
?? new KarmaConfig();
// Open a dedicated connection used only by this service
_connection = new SqliteConnection(_config.DbString);
_connection.Open();

View File

@@ -63,19 +63,40 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
return null;
}
var item = shop.Items.FirstOrDefault(it
var searchSet = sortItems(player);
var item = searchSet.FirstOrDefault(it
=> it.Name.Equals(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
item = searchSet.FirstOrDefault(it
=> it.Name.Contains(query, StringComparison.OrdinalIgnoreCase));
if (item != null) return item;
item = shop.Items.FirstOrDefault(it
item = searchSet.FirstOrDefault(it
=> it.Description.Contains(query, StringComparison.OrdinalIgnoreCase));
return item;
}
private List<IShopItem> sortItems(IOnlinePlayer? player) {
var items = new List<IShopItem>(shop.Items).ToList();
items.Sort((a, b) => {
var aPrice = a.Config.Price;
var bPrice = b.Config.Price;
var aCanBuy = player != null
&& a.CanPurchase(player) == PurchaseResult.SUCCESS;
var bCanBuy = player != null
&& b.CanPurchase(player) == PurchaseResult.SUCCESS;
if (aCanBuy && !bCanBuy) return -1;
if (!aCanBuy && bCanBuy) return 1;
if (aPrice != bPrice) return aPrice.CompareTo(bPrice);
return string.Compare(a.Name, b.Name, StringComparison.Ordinal);
});
return items;
}
}

View File

@@ -10,14 +10,14 @@ using TTT.Locale;
namespace TTT.Shop.Commands;
public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
private readonly IDictionary<IOnlinePlayer, List<IShopItem>> cache =
new Dictionary<IOnlinePlayer, List<IShopItem>>();
private readonly IDictionary<string, List<IShopItem>> cache =
new Dictionary<string, List<IShopItem>>();
private readonly IGameManager games = provider
.GetRequiredService<IGameManager>();
private readonly IDictionary<IOnlinePlayer, DateTime> lastUpdate =
new Dictionary<IOnlinePlayer, DateTime>();
private readonly IDictionary<string, DateTime> lastUpdate =
new Dictionary<string, DateTime>();
private readonly IMsgLocalizer locale = provider
.GetRequiredService<IMsgLocalizer>();
@@ -37,7 +37,7 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
ICommandInfo info) {
var items = calculateSortedItems(executor);
if (executor != null) cache[executor] = items;
if (executor != null) cache[executor.Id] = items;
items = new List<IShopItem>(items);
items.Reverse();
@@ -63,14 +63,14 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
public List<IShopItem> GetSortedItems(IOnlinePlayer? player,
bool refresh = false) {
if (player == null) return calculateSortedItems(null);
if (refresh || !cache.ContainsKey(player))
cache[player] = calculateSortedItems(player);
return cache[player];
if (refresh || !cache.ContainsKey(player.Id))
cache[player.Id] = calculateSortedItems(player);
return cache[player.Id];
}
public DateTime? GetLastUpdate(IOnlinePlayer? player) {
if (player == null) return null;
lastUpdate.TryGetValue(player, out var time);
lastUpdate.TryGetValue(player.Id, out var time);
return time;
}
@@ -94,7 +94,7 @@ public class ListCommand(IServiceProvider provider) : ICommand, IItemSorter {
return string.Compare(a.Name, b.Name, StringComparison.Ordinal);
});
if (player != null) lastUpdate[player] = DateTime.Now;
if (player != null) lastUpdate[player.Id] = DateTime.Now;
return items;
}

View File

@@ -18,11 +18,11 @@ public static class StickerExtensions {
public class Stickers(IServiceProvider provider)
: RoleRestrictedItem<DetectiveRole>(provider) {
private readonly StickersConfig config = provider
.GetService<IStorage<StickersConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new StickersConfig();
private StickersConfig config
=> Provider.GetService<IStorage<StickersConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new StickersConfig();
public override string Name => Locale[StickerMsgs.SHOP_ITEM_STICKERS];

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ using TTT.API.Player;
using TTT.Game.Events.Body;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
namespace TTT.Shop.Listeners;
@@ -21,7 +22,7 @@ public class PlayerKillListener(IServiceProvider provider)
if (ev.Killer == null) return;
Task.Run(async () => {
var victimBal = await shop.Load(ev.Victim);
shop.AddBalance(ev.Killer, victimBal / 6, "Killed " + ev.Victim.Name);
shop.AddBalance(ev.Killer, victimBal / 2, "Killed " + ev.Victim.Name);
});
}
@@ -38,7 +39,7 @@ public class PlayerKillListener(IServiceProvider provider)
if (!isGoodKill(ev.Body.Killer, ev.Body.OfPlayer)) {
var killerBal = await shop.Load(killer);
shop.AddBalance(killer, -killerBal / 4 - victimBal / 2, "Bad Kill");
shop.AddBalance(killer, -killerBal / 3 - victimBal / 2, "Bad Kill");
return;
}
@@ -47,6 +48,7 @@ public class PlayerKillListener(IServiceProvider provider)
}
private bool isGoodKill(IPlayer attacker, IPlayer victim) {
return !Roles.GetRoles(attacker).Intersect(Roles.GetRoles(victim)).Any();
return Roles.GetRoles(attacker).OfType<TraitorRole>().Any()
!= Roles.GetRoles(victim).OfType<TraitorRole>().Any();
}
}

View File

@@ -17,8 +17,8 @@ public class RoleAssignCreditor(IServiceProvider provider)
provider.GetService<IStorage<ShopConfig>>()?.Load().GetAwaiter().GetResult()
?? new ShopConfig(provider);
private readonly KarmaConfig karmaConfig =
provider.GetService<IStorage<KarmaConfig>>()
private KarmaConfig karmaConfig
=> Provider.GetService<IStorage<KarmaConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new KarmaConfig();
@@ -50,8 +50,8 @@ public class RoleAssignCreditor(IServiceProvider provider)
}
private float getKarmaScale(float percent) {
if (percent >= 0.9) return 1.1f;
if (percent >= 0.8f) return 1;
if (percent >= 0.9) return 1;
if (percent >= 0.8f) return 0.9f;
if (percent >= 0.5) return 0.8f;
if (percent >= 0.3) return 0.5f;
return 0.25f;

View File

@@ -1,5 +1,7 @@
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs;
@@ -7,15 +9,16 @@ using TTT.API;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
namespace TTT.Shop;
public class PeriodicRewarder(IServiceProvider provider) : ITerrorModule {
private readonly ShopConfig config = provider
.GetService<IStorage<ShopConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ShopConfig(provider);
private ShopConfig config
=> provider.GetService<IStorage<ShopConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new ShopConfig(provider);
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
@@ -28,20 +31,88 @@ public class PeriodicRewarder(IServiceProvider provider) : ITerrorModule {
private readonly IShop shop = provider.GetRequiredService<IShop>();
private IDisposable? timer;
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
public void Dispose() { timer?.Dispose(); }
private IDisposable? rewardTimer, updateTimer;
public void Dispose() {
rewardTimer?.Dispose();
updateTimer?.Dispose();
}
private readonly Dictionary<string, List<Vector>> playerPositions = new();
public void Start() {
timer = scheduler.SchedulePeriodic(config.CreditRewardInterval,
rewardTimer = scheduler.SchedulePeriodic(config.CreditRewardInterval,
issueRewards);
updateTimer = scheduler.SchedulePeriodic(config.PositionUpdateInterval,
updatePositions);
}
private void issueRewards() {
if (games.ActiveGame is not { State: State.IN_PROGRESS }) return;
Server.NextWorldUpdate(() => {
foreach (var player in finder.GetOnline().Where(p => p.IsAlive))
shop.AddBalance(player, config.IntervalRewardAmount, "Alive");
var sortedPlayers = finder.GetOnline()
.Where(p => p.IsAlive && playerPositions.ContainsKey(p.Id))
.Select(p => (Player: p,
Volume: getVolumeTraveled(
playerPositions.GetValueOrDefault(p.Id, []))))
.OrderByDescending(t => t.Volume)
.ToList();
var count = sortedPlayers.Count;
for (var i = 0; i < count; i++) {
var (player, _) = sortedPlayers[i];
var position = count == 1 ? 1f : (float)(count - i - 1) / (count - 1);
var rewardAmount = scaleRewardAmount(position, config.MinRewardAmount,
config.MaxRewardAmount);
shop.AddBalance(player, rewardAmount, "Exploration");
}
});
}
private void updatePositions() {
if (games.ActiveGame is not { State: State.IN_PROGRESS }) return;
Server.NextWorldUpdate(() => {
foreach (var player in finder.GetOnline().Where(p => p.IsAlive)) {
var gamePlayer = converter.GetPlayer(player);
var position = gamePlayer?.Pawn.Value?.AbsOrigin;
if (position is null) continue;
position = position.Clone()!;
var positions = playerPositions.GetValueOrDefault(player.Id, []);
positions.Add(position);
// Keep only the last N positions based on the interval
var maxPositions = (int)(config.CreditRewardInterval.TotalSeconds
/ config.PositionUpdateInterval.TotalSeconds);
while (positions.Count > maxPositions) positions.RemoveAt(0);
playerPositions[player.Id] = positions;
}
});
}
private float getVolumeTraveled(List<Vector> positions) {
if (positions.Count < 2) return 0f;
var totalDistance = 0f;
for (var i = 1; i < positions.Count; i++)
totalDistance += positions[i].Distance(positions[i - 1]);
return totalDistance;
}
/// <summary>
/// Scales a reward amount between min and max based on position (0-1).
/// 0 = min, 1 = max.
/// </summary>
/// <param name="position"></param>
/// <param name="min"></param>
/// <param name="max"></param>
/// <returns></returns>
private int scaleRewardAmount(float position, int min, int max) {
return (int)Math.Ceiling(min + (max - min) * position);
}
}

View File

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

View File

@@ -4,6 +4,7 @@ using TTT.API.Extensions;
using TTT.CS2.Items.Armor;
using TTT.CS2.Items.BodyPaint;
using TTT.CS2.Items.Camouflage;
using TTT.CS2.Items.ClusterGrenade;
using TTT.CS2.Items.Compass;
using TTT.CS2.Items.DNA;
using TTT.CS2.Items.OneHitKnife;
@@ -38,16 +39,18 @@ public static class ShopServiceCollection {
collection.AddModBehavior<BalanceCommand>();
collection.AddArmorServices();
collection.AddBodyCompassServices();
collection.AddBodyPaintServices();
collection.AddC4Services();
collection.AddCamoServices();
collection.AddCompassServices();
collection.AddClusterGrenade();
collection.AddDamageStation();
collection.AddDeagleServices();
collection.AddDnaScannerServices();
collection.AddGlovesServices();
collection.AddHealthStation();
collection.AddHealthshot();
collection.AddInnoCompassServices();
collection.AddM4A1Services();
collection.AddOneHitKnifeService();
collection.AddPoisonShots();

View File

@@ -4,7 +4,7 @@ SHOP_ITEM_NOT_FOUND: "%SHOP_PREFIX%Could not find an item named \"{default}{0}{g
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_DEAGLE_HIT_FF: "%PREFIX%You hit a teammate!"
SHOP_ITEM_STICKERS: "Stickers"
SHOP_ITEM_STICKERS_DESC: "Reveal the roles of all players you taser to others."

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs;
public record ArmorConfig : ShopItemConfig {
public override int Price { get; init; } = 80;
public override int Price { get; init; } = 60;
public int Armor { get; init; } = 100;
public bool Helmet { get; init; } = true;
}

View File

@@ -4,6 +4,6 @@ namespace ShopAPI.Configs;
public record BodyPaintConfig : ShopItemConfig {
public override int Price { get; init; } = 40;
public int MaxUses { get; init; } = 1;
public int MaxUses { get; init; } = 2;
public Color ColorToApply { get; init; } = Color.GreenYellow;
}

View File

@@ -1,6 +1,6 @@
namespace ShopAPI.Configs;
public record CamoConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public override int Price { get; init; } = 75;
public float CamoVisibility { get; init; } = 0.4f;
}

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs.Detective;
public record DnaScannerConfig : ShopItemConfig {
public override int Price { get; init; } = 120;
public override int Price { get; init; } = 110;
public int MaxSamples { get; init; } = 0;
public TimeSpan DecayTime { get; init; } = TimeSpan.FromMinutes(2);
}

View File

@@ -5,7 +5,7 @@ namespace ShopAPI.Configs.Detective;
public record HealthStationConfig : StationConfig {
public override string UseSound { get; init; } = "sounds/buttons/blip1";
public override int Price { get; init; } = 60;
public override int Price { get; init; } = 50;
public override Color GetColor(float health) {
// 100% health = white
@@ -15,4 +15,9 @@ public record HealthStationConfig : StationConfig {
var b = 255;
return Color.FromArgb(r, g, b);
}
public override TimeSpan HealthInterval { get; init; } =
TimeSpan.FromSeconds(2);
public override int HealthIncrements { get; init; } = 10;
}

View File

@@ -1,5 +1,5 @@
namespace ShopAPI.Configs.Detective;
public record StickersConfig : ShopItemConfig {
public override int Price { get; init; } = 30;
public override int Price { get; init; } = 25;
}

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs;
public record M4A1Config : ShopItemConfig {
public override int Price { get; init; } = 85;
public override int Price { get; init; } = 75;
public int[] ClearSlots { get; init; } = [0, 1];
public string[] Weapons { get; init; } = ["m4a1", "usps"];
}

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs;
public record OneShotDeagleConfig : ShopItemConfig {
public override int Price { get; init; } = 100;
public override int Price { get; init; } = 125;
public bool DoesFriendlyFire { get; init; } = true;
public bool KillShooterOnFF { get; init; } = false;
public string Weapon { get; init; } = "revolver";

View File

@@ -34,7 +34,11 @@ public record ShopConfig(IRoleAssigner assigner) {
public TimeSpan CreditRewardInterval { get; init; } =
TimeSpan.FromSeconds(30);
public int IntervalRewardAmount { get; init; } = 8;
public TimeSpan PositionUpdateInterval { get; init; } =
TimeSpan.FromSeconds(5);
public int MaxRewardAmount { get; init; } = 15;
public int MinRewardAmount { get; init; } = 2;
public virtual int CreditsForKill(IOnlinePlayer attacker,
IOnlinePlayer victim) {

View File

@@ -2,7 +2,7 @@ namespace ShopAPI.Configs.Traitor;
// TODO: Support this config
public record C4Config : ShopItemConfig {
public override int Price { get; init; } = 140;
public override int Price { get; init; } = 90;
public string Weapon { get; init; } = "c4";
public int MaxC4PerRound { get; init; } = 0;
public int MaxC4AtOnce { get; init; } = 1;

View File

@@ -0,0 +1,13 @@
using TTT.API;
namespace ShopAPI.Configs.Traitor;
public record ClusterGrenadeConfig : ShopItemConfig, IWeapon {
public override int Price { get; init; } = 100;
public int GrenadeCount { get; init; } = 8;
public string WeaponId { get; } = "weapon_hegrenade";
public int? ReserveAmmo { get; } = null;
public int? CurrentAmmo { get; } = null;
public float UpForce { get; init; } = 200f;
public float ThrowForce { get; init; } = 300f;
}

View File

@@ -1,6 +1,6 @@
namespace ShopAPI.Configs.Traitor;
public record GlovesConfig : ShopItemConfig {
public override int Price { get; init; } = 65;
public int MaxUses { get; init; } = 3;
public override int Price { get; init; } = 40;
public int MaxUses { get; init; } = 5;
}

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs.Traitor;
public record PoisonConfig {
public TimeSpan TimeBetweenDamage { get; init; } = TimeSpan.FromSeconds(2.5);
public TimeSpan TimeBetweenDamage { get; init; } = TimeSpan.FromSeconds(1.5);
public int DamagePerTick { get; init; } = 5;
public int TotalDamage { get; init; } = 60;

View File

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

View File

@@ -1,7 +1,7 @@
namespace ShopAPI.Configs.Traitor;
public record PoisonSmokeConfig : ShopItemConfig {
public override int Price { get; init; } = 30;
public override int Price { get; init; } = 35;
public string Weapon { get; init; } = "smoke";

View File

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

View File

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

View File

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

View File

@@ -29,12 +29,13 @@ public class DeagleTests {
victim = finder.AddPlayer(TestPlayer.Random());
survivor = finder.AddPlayer(TestPlayer.Random());
bus.RegisterListener(new DeagleDamageListener(provider));
bus.RegisterListener(new TestDamageApplier(provider));
games.CreateGame()?.Start();
}
[Fact]
public void Deagle_Kills_OnDamage() {
bus.RegisterListener(new DeagleDamageListener(provider));
shop.GiveItem(testPlayer, item);
var playerDmgEvent =
@@ -47,7 +48,6 @@ public class DeagleTests {
[Fact]
public void Deagle_DoesNotKill_AfterFirstKill() {
bus.RegisterListener(new DeagleDamageListener(provider));
shop.GiveItem(testPlayer, item);
var playerDmgEvent =

View File

@@ -0,0 +1,18 @@
using JetBrains.Annotations;
using TTT.API.Events;
using TTT.API.Player;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
namespace TTT.Test.Shop.Items;
public class TestDamageApplier(IServiceProvider provider)
: BaseListener(provider) {
[UsedImplicitly]
[EventHandler(Priority = Priority.MONITOR)]
public void OnDamage(PlayerDamagedEvent ev) {
if (ev.Player is not IOnlinePlayer online) return;
online.Health = Math.Clamp(ev.HpLeft, 0, online.MaxHealth);
}
}