Compare commits

...

50 Commits

Author SHA1 Message Date
Isaac
8d9506a1cc fix: Additial special round fixes +semver:patch (#189) 2025-11-10 16:22:00 -08:00
MSWS
d9f49473eb fix: Additial special round fixes +semver:patch 2025-11-10 16:19:42 -08:00
Isaac
042c48c0a6 Fix logic of round conflicts (#188) 2025-11-10 15:34:58 -08:00
MSWS
c2ada273a8 Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-11-10 15:32:40 -08:00
MSWS
deb2e1cab2 fix: Round confliction logic +semver:patch 2025-11-10 15:32:36 -08:00
MSWS
dfe86b0242 fix: Round confliction logic 2025-11-10 15:02:40 -08:00
Isaac
c40c89b624 Fix Vanilla Round Inversion (#187) 2025-11-10 14:48:30 -08:00
MSWS
eff68897a0 Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-11-10 14:45:55 -08:00
MSWS
63afe31e3b fix: Vanilla round inverted +semver:patch 2025-11-10 14:45:48 -08:00
MSWS
a7fa2afe15 fix: Vanilla round invert 2025-11-10 14:45:34 -08:00
Isaac
1df2722ce7 Force rebuild for new major 2.0.0 (#186) 2025-11-10 14:39:02 -08:00
MSWS
7749deabd3 Force rebuild for new major 2.0.0 2025-11-10 14:36:18 -08:00
Isaac
9079fe6c41 BREAKING CHANGES: New Special Rounds and Updates +semver:major (#185)
## Shop
- Added the ability for multiple special rounds to occur at once
- Added Rich and Low Grav Rounds
- Overhauled the Poison Gun
  - Only pistol shots count towards poison bullet usage
  - Poison bullets will be silent when shot
- Item descriptions will now be printed upon purchase

## Fixes
- Reduced the volume of the Special Round cue
- Increased the strictness of Karma
- Miscellaneous bug fixes
2025-11-10 14:15:29 -08:00
MSWS
f8b67c5194 Update getmulti params 2025-11-10 13:56:03 -08:00
MSWS
77281aa8c6 Fix lowgrav convar fetching 2025-11-10 12:56:43 -08:00
MSWS
20497bbb4d Synchronize special round 2025-11-10 12:48:59 -08:00
MSWS
133083003d Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-11-10 12:45:05 -08:00
MSWS
ad3603c833 Allow testing rounds with whitespace in name 2025-11-10 12:44:27 -08:00
MSWS
6d7149a3f5 fix: Properly track rounds 2025-11-10 03:56:23 -08:00
MSWS
24cd1295b6 refactor: Cleanup and reformat 2025-11-10 03:53:14 -08:00
MSWS
125daa515e Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-11-10 03:33:01 -08:00
MSWS
722c29bde7 BREADKING CHANGE feat: Add rich and low grav rounds 2025-11-10 03:32:56 -08:00
MSWS
3c0fd74c2a feat: Add rich and low grav rounds 2025-11-10 03:31:03 -08:00
MSWS
60de8b54db fix: Avoid logging debugged-in items 2025-11-10 01:39:21 -08:00
MSWS
e7dfbca35d feat: Add special round stats tracking 2025-11-10 01:36:15 -08:00
MSWS
3a463b29c6 Update additional descriptions 2025-11-10 01:11:09 -08:00
MSWS
e11a8e20e5 feat: Update poison shots, start updating descriptions to be purchase info +semver:minor 2025-11-10 01:07:02 -08:00
MSWS
b0a7ec60e0 update: Reduce special round start volume 2025-11-10 00:53:12 -08:00
MSWS
0aa28b1076 fix: Missing dependencies warning 2025-11-09 18:29:42 -08:00
Isaac
2b04486e65 feat: Add tripwire defuse reward (#179) 2025-11-08 22:33:44 -08:00
MSWS
b53920282b feat: Add tripwire defuse reward 2025-11-08 22:29:57 -08:00
Isaac
3b97c77065 +semver:patch (#178) 2025-11-08 22:13:00 -08:00
MSWS
5167291ab4 +semver:patch 2025-11-08 22:10:41 -08:00
Isaac
83715fff1f Station Stacking and Round Start Sound Cue
- Added special round start sound cue
- Multiple Damage / Hurt Stations will now stack their effects
2025-11-08 22:04:01 -08:00
MSWS
d76f93c0c7 Merge branch 'dev' of github.com:MSWS/TTT into dev 2025-11-08 22:01:31 -08:00
MSWS
39da6e8702 cleanup: Remove redundant checks 2025-11-08 22:01:26 -08:00
MSWS
d628df6116 fix: return -> continue within loop 2025-11-08 21:58:52 -08:00
Isaac
da232907f7 Merge branch 'main' into dev 2025-11-08 21:44:39 -08:00
MSWS
703144f04b update: Additional async removals 2025-11-08 21:37:39 -08:00
MSWS
3e947959ac fix: Cleanup and adjust detective tag assignment 2025-11-08 21:30:11 -08:00
MSWS
aed5b4ace4 feat: Apply same logic to health stations 2025-11-08 21:09:13 -08:00
MSWS
0abbb0c07a feat: Allow damage station stacking, add specialround sound cue 2025-11-08 20:47:23 -08:00
MSWS
8131a85bb6 fix: Tripwire damage sound being inversely played 2025-11-08 18:03:59 -08:00
Isaac
8d068b675b feat: Tripwire Defusing and Damage Listening (#176) 2025-11-06 18:41:10 -08:00
MSWS
84d81fc3db Add additional req to MapChange listener 2025-11-06 18:33:48 -08:00
MSWS
0531d3afb5 update: Reduce cost of taser from 120 -> 110 2025-11-06 18:17:57 -08:00
MSWS
eb48bc68f3 Fine tuning 2025-11-06 18:17:02 -08:00
MSWS
40b153d938 fix: Magically working, no idea what caused crashes 2025-11-06 17:59:37 -08:00
MSWS
e35551b830 Adjust raytrace 2025-11-05 18:45:13 -08:00
Isaac
29693f99c5 fix: Nerf tripwire damage, fix damage station giving health +semver:patch (#174) 2025-11-03 22:40:02 -08:00
58 changed files with 831 additions and 260 deletions

View File

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

View File

@@ -21,4 +21,55 @@ public interface IPlayerFinder {
var matches = GetOnline().Where(p => p.Name.Contains(name)).ToList();
return matches.Count == 1 ? matches[0] : null;
}
List<IOnlinePlayer> GetMulti(string query, out string name,
IOnlinePlayer? executor = null) {
var result = query switch {
"@all" => GetOnline().ToList(),
"@me" => executor != null ? new List<IOnlinePlayer> { executor } : [],
"@!me" => executor != null ?
GetOnline().Where(p => p.Id != executor.Id).ToList() :
GetOnline().ToList(),
_ => GetSingle(query) != null ?
new List<IOnlinePlayer> { GetSingle(query)! } : []
};
name = "no players found";
name = query switch {
"@all" => "all players",
"@me" => executor != null ? executor.Name : "no one",
"@!me" => executor != null ?
$"all players except {executor.Name}" :
"all players",
_ => GetSingle(query) != null ?
GetSingle(query)!.Name :
"no players found"
};
return result;
}
IOnlinePlayer? GetSingle(string query) {
if (query.StartsWith("#")) {
var id = query[1..];
var byId = GetPlayerById(id);
if (byId != null) return byId;
var byName = GetOnline().FirstOrDefault(p => p.Name == id);
return byName;
}
var byNameExact = GetOnline().FirstOrDefault(p => p.Name == query);
if (byNameExact != null) return byNameExact;
var contains = GetOnline().Where(p => p.Name.Contains(query)).ToList();
if (contains.Count == 1) return contains[0];
contains = GetOnline()
.Where(p
=> p.Name.Contains(query, StringComparison.InvariantCultureIgnoreCase))
.ToList();
return contains.Count == 1 ? contains[0] : null;
}
}

View File

@@ -1,5 +1,3 @@
using TTT.CS2.Items.Tripwire;
namespace TTT.CS2.API.Items;
public interface ITripwireTracker {

View File

@@ -5,4 +5,9 @@ using TTT.API.Player;
namespace TTT.CS2.API.Items;
public record TripwireInstance(IOnlinePlayer owner, CEnvBeam Beam,
CDynamicProp TripwireProp, Vector StartPos, Vector EndPos);
CDynamicProp TripwireProp, Vector StartPos, Vector EndPos) {
public override string ToString() {
return
$"TripwireInstance(Owner={owner}, StartPos={StartPos}, EndPos={EndPos})";
}
}

View File

@@ -9,12 +9,11 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\SpecialRoundAPI\SpecialRoundAPI\SpecialRoundAPI.csproj"/>
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\Game\Game.csproj"/>
<ProjectReference Include="..\Karma\Karma.csproj"/>
<ProjectReference Include="..\ShopAPI\ShopAPI.csproj"/>
<ProjectReference Include="..\SpecialRoundAPI\SpecialRoundAPI.csproj" />
<ProjectReference Include="..\SpecialRoundAPI\SpecialRoundAPI.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -1,9 +1,7 @@
using CounterStrikeSharp.API;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Events;
using TTT.API.Command;
using TTT.API.Events;
using TTT.API.Player;
namespace TTT.CS2.Command.Test;
@@ -33,24 +31,15 @@ public class GiveItemCommand(IServiceProvider provider) : ICommand {
return Task.FromResult(CommandResult.ERROR);
}
var target = executor;
List<IOnlinePlayer> targets = [executor];
Server.NextWorldUpdateAsync(() => {
if (info.ArgCount == 3) {
var result = finder.GetPlayerByName(info.Args[2]);
if (result == null) {
info.ReplySync($"Player '{info.Args[2]}' not found.");
return;
}
var name = executor.Name;
if (info.ArgCount == 3)
targets = finder.GetMulti(info.Args[2], out name, executor);
foreach (var player in targets) shop.GiveItem(player, item);
target = result;
}
var purchaseEv = new PlayerPurchaseItemEvent(target, item);
provider.GetRequiredService<IEventBus>().Dispatch(purchaseEv);
shop.GiveItem(target, item);
info.ReplySync($"Gave item '{item.Name}' to {target.Name}.");
info.ReplySync($"Gave item '{item.Name}' to {name}.");
});
return Task.FromResult(CommandResult.SUCCESS);
}

View File

@@ -15,6 +15,9 @@ public class SetRoleCommand(IServiceProvider provider) : ICommand {
private readonly IEventBus bus = provider.GetRequiredService<IEventBus>();
private readonly IPlayerFinder finder =
provider.GetRequiredService<IPlayerFinder>();
public void Dispose() { }
public string Id => "setrole";
@@ -24,7 +27,10 @@ public class SetRoleCommand(IServiceProvider provider) : ICommand {
Execute(IOnlinePlayer? executor, ICommandInfo info) {
if (executor == null) return Task.FromResult(CommandResult.PLAYER_ONLY);
IRole roleToAssign = new TraitorRole(provider);
// IOnlinePlayer targetPlayer = executor;
List<IOnlinePlayer> targets = [executor];
var targetName = executor.Name;
IRole roleToAssign = new TraitorRole(provider);
if (info.ArgCount == 2)
switch (info.Args[1].ToLowerInvariant()) {
case "d" or "det" or "detective" or "ct":
@@ -33,18 +39,29 @@ public class SetRoleCommand(IServiceProvider provider) : ICommand {
case "i" or "inn" or "innocent":
roleToAssign = new InnocentRole(provider);
break;
default:
targets = finder.GetMulti(info.Args[1], out targetName, executor);
break;
}
if (info.ArgCount == 3)
targets = finder.GetMulti(info.Args[2], out targetName, executor);
Server.NextWorldUpdate(() => {
var ev = new PlayerRoleAssignEvent(executor, roleToAssign);
bus.Dispatch(ev);
if (ev.IsCanceled) {
info.ReplySync("Role assignment was canceled.");
return;
foreach (var player in targets) {
var ev = new PlayerRoleAssignEvent(player, roleToAssign);
bus.Dispatch(ev);
if (ev.IsCanceled) {
info.ReplySync("Role assignment was canceled.");
return;
}
assigner.Write(player, [ev.Role]);
ev.Role.OnAssign(player);
}
assigner.Write(executor, [ev.Role]);
ev.Role.OnAssign(executor);
info.ReplySync(
"Assigned " + roleToAssign.Name + " to " + targetName + ".");
});
return Task.FromResult(CommandResult.SUCCESS);
}

View File

@@ -24,16 +24,16 @@ public class SpecialRoundCommand(IServiceProvider provider) : ICommand {
}
if (info.ArgCount == 1) {
tracker.TryStartSpecialRound(null);
Server.NextWorldUpdate(() => tracker.TryStartSpecialRound());
info.ReplySync("Started a random special round.");
return Task.FromResult(CommandResult.SUCCESS);
}
var rounds = provider.GetServices<ITerrorModule>()
.OfType<AbstractSpecialRound>()
.ToDictionary(r => r.Name.ToLower(), r => r);
.ToDictionary(r => r.Name.ToLower().Replace(" ", ""), r => r);
var roundName = info.Args[1].ToLower();
var roundName = string.Join("", info.Args.Skip(1)).ToLower();
if (!rounds.TryGetValue(roundName, out var round)) {
info.ReplySync($"No special round found with name '{roundName}'.");
foreach (var name in rounds.Keys) info.ReplySync($"- {name}");
@@ -41,7 +41,7 @@ public class SpecialRoundCommand(IServiceProvider provider) : ICommand {
}
Server.NextWorldUpdate(() => {
tracker.TryStartSpecialRound(round);
tracker.TryStartSpecialRound([round]);
info.ReplySync($"Started special round '{roundName}'.");
});
return Task.FromResult(CommandResult.SUCCESS);

View File

@@ -48,17 +48,17 @@ public class CS2GameConfig : IStorage<TTTConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_TRAITOR_ARMOR = new(
"css_ttt_rolearmor_traitor",
"Amount of armor to give to traitors at start of round", 100,
"Amount of armor to give to traitors at start of round", 0,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<int> CV_DETECTIVE_ARMOR = new(
"css_ttt_rolearmor_detective",
"Amount of armor to give to detectives at start of round", 100,
"Amount of armor to give to detectives at start of round", 0,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<int> CV_INNOCENT_ARMOR = new(
"css_ttt_rolearmor_innocent",
"Amount of armor to give to innocents at start of round", 100,
"Amount of armor to give to innocents at start of round", 0,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 1000));
public static readonly FakeConVar<string> CV_TRAITOR_WEAPONS = new(

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", 4, ConVarFlags.FCVAR_NONE,
"Karma gained when Innocent kills a Traitor", 2, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_TRAITOR_ON_DETECTIVE = new(
@@ -55,21 +55,21 @@ public class CS2KarmaConfig : IStorage<KarmaConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_INNO_ON_INNO_VICTIM = new(
"css_ttt_karma_inno_on_inno_victim",
"Karma gained or lost when Innocent kills another Innocent who was a victim",
-1, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
-2, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_INNO_ON_INNO = new(
"css_ttt_karma_inno_on_inno",
"Karma lost when Innocent kills another Innocent", -5,
"Karma lost when Innocent kills another Innocent", -8,
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", -6, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
"Karma lost when Traitor kills another Traitor", -12,
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", -8, ConVarFlags.FCVAR_NONE,
"Karma lost when Innocent kills a Detective", -15, ConVarFlags.FCVAR_NONE,
new RangeValidator<int>(-50, 50));
public static readonly FakeConVar<int> CV_KARMA_PER_ROUND = new(

View File

@@ -11,7 +11,7 @@ namespace TTT.CS2.Configs.ShopItems;
public class CS2TaserConfig : IStorage<TaserConfig>, IPluginModule {
public static readonly FakeConVar<int> CV_PRICE = new(
"css_ttt_shop_taser_price", "Price of the Taser item", 120,
"css_ttt_shop_taser_price", "Price of the Taser item", 110,
ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public static readonly FakeConVar<string> CV_WEAPON = new(

View File

@@ -35,7 +35,7 @@ public class CS2TripwireConfig : IStorage<TripwireConfig>, IPluginModule {
public static readonly FakeConVar<float> CV_MAX_DISTANCE_SQUARED = new(
"css_ttt_shop_tripwire_max_distance_squared",
"Maximum placement distance squared for Tripwire", 400f * 400f,
"Maximum placement distance squared for Tripwire", 50000f,
ConVarFlags.FCVAR_NONE, new RangeValidator<float>(0f, 1000000f));
public static readonly FakeConVar<float> CV_INITIATION_TIME = new(
@@ -45,7 +45,7 @@ public class CS2TripwireConfig : IStorage<TripwireConfig>, IPluginModule {
public static readonly FakeConVar<float> CV_SIZE_SQUARED = new(
"css_ttt_shop_tripwire_size_squared",
"Size of tripwire for the purposes of bullet-detection", 300f,
"Size of tripwire for the purposes of bullet/defuse-detection", 10f,
ConVarFlags.FCVAR_NONE, new RangeValidator<float>(1f, 100000f));
public static readonly FakeConVar<int> CV_COLOR_R = new(
@@ -70,7 +70,7 @@ public class CS2TripwireConfig : IStorage<TripwireConfig>, IPluginModule {
public static readonly FakeConVar<float> CV_DEFUSE_TIME = new(
"css_ttt_shop_tripwire_defuse_time",
"Time required to fully defuse the Tripwire (in seconds)", 5f,
"Time required to fully defuse the Tripwire (in seconds)", 6f,
ConVarFlags.FCVAR_NONE, new RangeValidator<float>(0f, 30f));
public static readonly FakeConVar<float> CV_DEFUSE_RATE = new(
@@ -78,6 +78,11 @@ public class CS2TripwireConfig : IStorage<TripwireConfig>, IPluginModule {
"Rate at which Tripwire defuses are processed (in seconds)", 0.5f,
ConVarFlags.FCVAR_NONE, new RangeValidator<float>(0.01f, 5f));
public static readonly FakeConVar<int> CV_DEFUSE_REWARD = new(
"css_ttt_shop_tripwire_defuse_reward",
"Amount of money rewarded to a player for successfully defusing a tripwire",
20, ConVarFlags.FCVAR_NONE, new RangeValidator<int>(0, 10000));
public void Dispose() { }
public void Start() { }
@@ -102,7 +107,8 @@ public class CS2TripwireConfig : IStorage<TripwireConfig>, IPluginModule {
CV_COLOR_B.Value),
TripwireThickness = CV_THICKNESS.Value,
DefuseTime = TimeSpan.FromSeconds(CV_DEFUSE_TIME.Value),
DefuseRate = TimeSpan.FromSeconds(CV_DEFUSE_RATE.Value)
DefuseRate = TimeSpan.FromSeconds(CV_DEFUSE_RATE.Value),
DefuseReward = CV_DEFUSE_REWARD.Value
};
return Task.FromResult<TripwireConfig?>(cfg);

View File

@@ -97,7 +97,7 @@ public static class VectorExtensions {
public static Vector toVector(this Vector3 vec) {
return new Vector(vec.X, vec.Y, vec.Z);
}
public static QAngle toAngle(this Vector vec) {
var pitch = (float)(Math.Atan2(-vec.Z,
Math.Sqrt(vec.X * vec.X + vec.Y * vec.Y)) * (180.0 / Math.PI));

View File

@@ -1,5 +1,4 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using TTT.API;
using TTT.API.Game;
@@ -20,7 +19,8 @@ public class MapChangeCausesEndListener(IServiceProvider provider)
}
private void onMapChange(string mapName) {
if (games.ActiveGame is not { State: State.IN_PROGRESS or State.COUNTDOWN })
return;
games.ActiveGame?.EndGame(new EndReason("Map Change"));
Server.PrintToConsole("Detected map change, ending active game.");
}
}

View File

@@ -3,16 +3,20 @@ using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Core.Attributes.Registration;
using CounterStrikeSharp.API.Modules.UserMessages;
using CounterStrikeSharp.API.Modules.Utils;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs.Traitor;
using SpecialRoundAPI;
using TTT.API;
using TTT.API.Events;
using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
using TTT.Game.Events.Body;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
@@ -49,11 +53,16 @@ public class PoisonShotsListener(IServiceProvider provider)
foreach (var timer in poisonTimers) timer.Dispose();
}
public void Start(BasePlugin? plugin) {
base.Start();
plugin?.HookUserMessage(452, onWeaponSound);
}
[UsedImplicitly]
[GameEventHandler]
public HookResult OnFire(EventWeaponFire ev, GameEventInfo _) {
if (ev.Userid == null) return HookResult.Continue;
if (!Tag.GUNS.Contains(ev.Weapon)) return HookResult.Continue;
if (!Tag.PISTOLS.Contains(ev.Weapon)) return HookResult.Continue;
if (converter.GetPlayer(ev.Userid) is not IOnlinePlayer player)
return HookResult.Continue;
var remainingShots = usePoisonShot(player);
@@ -156,6 +165,47 @@ public class PoisonShotsListener(IServiceProvider provider)
return 0;
}
private HookResult onWeaponSound(UserMessage msg) {
var defIndex = msg.ReadUInt("item_def_index");
if (!WeaponSoundIndex.PISTOLS.Contains(defIndex))
return HookResult.Continue;
var splits = msg.DebugString.Split("\n");
if (splits.Length < 5) return HookResult.Continue;
var angleLines = msg.DebugString.Split("\n")[1..4]
.Select(s => s.Trim())
.ToList();
if (!angleLines[0].Contains('x') || !angleLines[1].Contains('y')
|| !angleLines[2].Contains('z'))
return HookResult.Continue;
var x = float.Parse(angleLines[0].Split(' ')[1]);
var y = float.Parse(angleLines[1].Split(' ')[1]);
var z = float.Parse(angleLines[2].Split(' ')[1]);
var vec = new Vector(x, y, z);
var player = findPlayerByCoord(vec);
if (player == null) return HookResult.Continue;
if (converter.GetPlayer(player) is not IOnlinePlayer apiPlayer)
return HookResult.Continue;
if (poisonShots.TryGetValue(apiPlayer, out var shots) && shots > 0) {
msg.Recipients.Clear();
return HookResult.Handled;
}
return HookResult.Continue;
}
private CCSPlayerController? findPlayerByCoord(Vector vec) {
foreach (var pl in Utilities.GetPlayers()) {
var origin = pl.GetEyePosition();
if (origin == null) continue;
var dist = vec.DistanceSquared(origin);
if (dist < 1) return pl;
}
return null;
}
[UsedImplicitly]
[EventHandler]

View File

@@ -50,14 +50,15 @@ public class DamageStation(IServiceProvider provider)
&& !Roles.GetRoles(m.ApiPlayer).Any(r => r is TraitorRole))
.ToList();
// accumulate contributions per player: ApiPlayer -> list of (stationInfo, damage, gamePlayer)
var playerDamageMap =
new Dictionary<IOnlinePlayer, List<(StationInfo info, int damage,
CCSPlayerController gamePlayer)>>();
foreach (var (prop, info) in Props) {
if (_Config.TotalHealthGiven != 0 && Math.Abs(info.HealthGiven)
> Math.Abs(_Config.TotalHealthGiven)) {
toRemove.Add(prop);
continue;
}
if (!prop.IsValid || prop.AbsOrigin == null) {
> Math.Abs(_Config.TotalHealthGiven) || !prop.IsValid
|| prop.AbsOrigin == null) {
toRemove.Add(prop);
continue;
}
@@ -78,30 +79,69 @@ public class DamageStation(IServiceProvider provider)
var damageAmount =
Math.Abs((int)Math.Floor(_Config.HealthIncrements * healthScale));
var dmgEvent = new PlayerDamagedEvent(player,
info.Owner as IOnlinePlayer, damageAmount) { Weapon = $"[{Name}]" };
if (damageAmount <= 0) continue;
bus.Dispatch(dmgEvent);
damageAmount = dmgEvent.DmgDealt;
if (player.Health + damageAmount <= 0) {
killedWithStation[player.Id] = info;
var playerDeath = new PlayerDeathEvent(player)
.WithKiller(info.Owner as IOnlinePlayer)
.WithWeapon($"[{Name}]");
bus.Dispatch(playerDeath);
if (!playerDamageMap.TryGetValue(player, out var list)) {
list = [];
playerDamageMap[player] = list;
}
gamePlayer.EmitSound("Player.DamageFall", SELF(gamePlayer.Slot), 0.2f);
player.Health -= damageAmount;
info.HealthGiven += damageAmount;
list.Add((info, damageAmount, gamePlayer));
}
}
// Apply accumulated damage per player once
applyDamage(playerDamageMap);
// remove invalid/expired props
foreach (var prop in toRemove) Props.Remove(prop);
}
private void applyDamage(
Dictionary<IOnlinePlayer, List<(StationInfo info, int damage,
CCSPlayerController gamePlayer)>> playerDamageMap) {
foreach (var kv in playerDamageMap) {
var player = kv.Key;
var contribs = kv.Value;
var totalDamage = contribs.Sum(c => c.damage);
if (totalDamage <= 0) continue;
// choose the station that contributed the most damage to attribute the kill to
var dominantInfo = contribs.OrderByDescending(c => c.damage).First().info;
var gamePlayer = contribs.First().gamePlayer;
// dispatch single PlayerDamagedEvent with total damage
var dmgEvent = new PlayerDamagedEvent(player,
dominantInfo.Owner as IOnlinePlayer, totalDamage) {
Weapon = $"[{Name}]"
};
bus.Dispatch(dmgEvent);
totalDamage = dmgEvent.DmgDealt;
// if this will kill the player, attribute death to the dominant station
if (player.Health - totalDamage <= 0) {
killedWithStation[player.Id] = dominantInfo;
var playerDeath = new PlayerDeathEvent(player)
.WithKiller(dominantInfo.Owner as IOnlinePlayer)
.WithWeapon($"[{Name}]");
bus.Dispatch(playerDeath);
}
gamePlayer.EmitSound("Player.DamageFall", SELF(gamePlayer.Slot), 0.2f);
// apply damage to player's health
player.Health -= totalDamage;
// update each station's HealthGiven by its own contribution
foreach (var (info, damage, _) in contribs) info.HealthGiven += damage;
}
}
private static RecipientFilter SELF(int slot) {
return new RecipientFilter(slot);
}

View File

@@ -30,6 +30,28 @@ public class HealthStation(IServiceProvider provider)
override protected void onInterval() {
var players = Utilities.GetPlayers();
var toRemove = new List<CPhysicsPropMultiplayer>();
// build per-player potential heal contributions and gather props to remove
var perPlayerContrib = BuildPerPlayerContributions(players, toRemove);
// apply the accumulated heals in a single pass per player
applyAccumulatedHeals(perPlayerContrib);
// remove invalid/expired props
foreach (var prop in toRemove) Props.Remove(prop);
}
/// <summary>
/// Scan all props and build a map: Player -> list of (StationInfo, potentialHeal).
/// Also fills toRemove for invalid/expired props.
/// </summary>
private
Dictionary<CCSPlayerController, List<(StationInfo info, int potential)>>
BuildPerPlayerContributions(IReadOnlyList<CCSPlayerController> players,
List<CPhysicsPropMultiplayer> toRemove) {
var result =
new Dictionary<CCSPlayerController, List<(StationInfo, int)>>();
foreach (var (prop, info) in Props) {
if (_Config.TotalHealthGiven != 0
&& Math.Abs(info.HealthGiven) > _Config.TotalHealthGiven) {
@@ -52,19 +74,130 @@ public class HealthStation(IServiceProvider provider)
.ToList();
foreach (var (player, dist) in playerDists) {
var maxHp = player.Pawn.Value?.MaxHealth ?? 100;
var healthScale = 1.0 - dist / _Config.MaxRange;
var maxHealAmo =
(int)Math.Ceiling(_Config.HealthIncrements * healthScale);
var newHealth = Math.Min(player.GetHealth() + maxHealAmo, maxHp);
var healthGiven = newHealth - player.GetHealth();
player.SetHealth(newHealth);
info.HealthGiven += healthGiven;
var potentialHeal = ComputePotentialHeal(dist);
if (potentialHeal <= 0) continue;
if (healthGiven > 0) player.EmitSound("HealthShot.Pickup", null, 0.1f);
if (!result.TryGetValue(player, out var list)) {
list = [];
result[player] = list;
}
list.Add((info, potentialHeal));
}
}
foreach (var prop in toRemove) Props.Remove(prop);
return result;
}
/// <summary>
/// Compute potential heal from a station given the distance.
/// Mirrors your original logic: ceil(HealthIncrements * healthScale).
/// </summary>
private int ComputePotentialHeal(double dist) {
var healthScale = 1.0 - dist / _Config.MaxRange;
return (int)Math.Ceiling(_Config.HealthIncrements * healthScale);
}
/// <summary>
/// Apply heals to each player once per tick.
/// Distributes the actual heal proportionally across contributing stations,
/// updates station.Info.HealthGiven and emits a single pickup sound if needed.
/// </summary>
private void applyAccumulatedHeals(
Dictionary<CCSPlayerController, List<(StationInfo info, int potential)>>
perPlayerContrib) {
foreach (var kv in perPlayerContrib) {
var player = kv.Key;
var contribs = kv.Value;
var maxHp = player.Pawn.Value?.MaxHealth ?? 100;
var currentHp = player.GetHealth();
if (currentHp >= maxHp) continue;
var totalPotential = contribs.Sum(c => c.potential);
if (totalPotential <= 0) continue;
var totalHealAvailable = Math.Min(totalPotential, maxHp - currentHp);
if (totalHealAvailable <= 0) continue;
var potentials = contribs.Select(c => c.potential).ToList();
var allocations =
allocateProportionalInteger(totalHealAvailable, potentials);
// safety clamp: never allocate more than a station's potential
for (var i = 0; i < allocations.Count; i++)
if (allocations[i] > potentials[i])
allocations[i] = potentials[i];
// if clamping reduced the total, try to redistribute remaining amount
var allocatedSum = allocations.Sum();
if (allocatedSum < totalHealAvailable) {
var remaining = totalHealAvailable - allocatedSum;
for (var i = 0; i < allocations.Count && remaining > 0; i++) {
var headroom = potentials[i] - allocations[i];
if (headroom <= 0) continue;
var give = Math.Min(headroom, remaining);
allocations[i] += give;
remaining -= give;
}
allocatedSum = allocations.Sum();
}
// apply heal to player
var actualAllocated = Math.Min(allocatedSum, maxHp - currentHp);
if (actualAllocated <= 0) continue;
var newHealth = Math.Min(currentHp + actualAllocated, maxHp);
player.SetHealth(newHealth);
// update station HealthGiven
for (var i = 0; i < allocations.Count; i++) {
var info = contribs[i].info;
var allocated = allocations[i];
if (allocated > 0) info.HealthGiven += allocated;
}
// emit a single pickup sound if any heal applied
player.EmitSound("HealthShot.Pickup", null, 0.1f);
}
}
/// <summary>
/// Proportionally distribute an integer total across integer potentials.
/// Uses floor of shares and assigns leftover to largest fractional remainders.
/// Returns a list of allocations with same length as potentials.
/// </summary>
private List<int>
allocateProportionalInteger(int total, List<int> potentials) {
var allocations = new List<int>(new int[potentials.Count]);
var remainders = new List<(int idx, double rem)>();
var sumBase = 0;
var totalPotential = potentials.Sum();
if (totalPotential <= 0) return allocations;
for (var i = 0; i < potentials.Count; i++) {
var potential = potentials[i];
var share = (double)potential / totalPotential * total;
var baseAlloc = (int)Math.Floor(share);
var rem = share - baseAlloc;
allocations[i] = baseAlloc;
sumBase += baseAlloc;
remainders.Add((i, rem));
}
var leftover = total - sumBase;
if (leftover <= 0) return allocations;
remainders = remainders.OrderByDescending(r => r.rem).ToList();
var idx = 0;
while (leftover > 0 && idx < remainders.Count) {
allocations[remainders[idx].idx] += 1;
leftover--;
idx++;
}
return allocations;
}
}

View File

@@ -12,21 +12,21 @@ using TTT.CS2.Extensions;
namespace TTT.CS2.Items.Tripwire;
public class TripwireDamageListener(IServiceProvider provider) : IPluginModule {
public void Dispose() { }
public void Start() { }
private readonly ITripwireActivator? tripwireActivator =
provider.GetRequiredService<ITripwireActivator>();
private readonly ITripwireTracker? tripwires =
provider.GetService<ITripwireTracker>();
private readonly ITripwireActivator? tripwireActivator =
provider.GetRequiredService<ITripwireActivator>();
private TripwireConfig config
=> provider.GetService<IStorage<TripwireConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new TripwireConfig();
public void Dispose() { }
public void Start() { }
[UsedImplicitly]
[GameEventHandler]
public HookResult OnBulletImpact(EventBulletImpact ev, GameEventInfo info) {

View File

@@ -1,12 +1,14 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Messages;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.API.Items;
using TTT.CS2.Extensions;
using TTT.CS2.RayTrace.Class;
using TTT.CS2.RayTrace.Enum;
using TTT.Locale;
@@ -15,17 +17,17 @@ namespace TTT.CS2.Items.Tripwire;
public class TripwireDefuserListener(IServiceProvider provider)
: IPluginModule {
private readonly ITripwireTracker? tripwireTracker =
provider.GetService<ITripwireTracker>();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
private readonly IMessenger messenger =
provider.GetRequiredService<IMessenger>();
private readonly IMsgLocalizer locale =
provider.GetRequiredService<IMsgLocalizer>();
private readonly ITripwireTracker? tripwireTracker =
provider.GetService<ITripwireTracker>();
private TripwireConfig config
=> provider.GetService<IStorage<TripwireConfig>>()
@@ -56,20 +58,29 @@ public class TripwireDefuserListener(IServiceProvider provider)
}
private TripwireInstance? getTargetTripwire(CCSPlayerController player) {
if (tripwireTracker == null) return null;
var raytrace =
player.GetGameTraceByEyePosition(TraceMask.MaskSolid, Contents.Player,
player.GetGameTraceByEyePosition(TraceMask.MaskSolid, Contents.NoDraw,
player);
if (raytrace == null) return null;
if (raytrace == null || tripwireTracker.ActiveTripwires.Count == 0)
return null;
var raytracePos = raytrace.Value.EndPos.toVector();
if (!raytrace.Value.HitEntityByDesignerName<CDynamicProp>(out var prop,
"prop_dynamic"))
var closest =
tripwireTracker?.ActiveTripwires.MinBy(i
=> i.StartPos.DistanceSquared(raytracePos));
if (closest == null || closest.StartPos.DistanceSquared(raytracePos)
> config.TripwireSizeSquared)
return null;
var instance =
tripwireTracker?.ActiveTripwires.FirstOrDefault(r
=> r.TripwireProp == prop);
return instance;
if (player.Pawn.Value?.AbsOrigin == null
|| closest.StartPos.DistanceSquared(player.Pawn.Value.AbsOrigin)
> config.MaxPlacementDistanceSquared)
return null;
return closest;
}
private void startDefuseTimer(CCSPlayerController player,
@@ -80,18 +91,27 @@ public class TripwireDefuserListener(IServiceProvider provider)
private void tickDefuse(CCSPlayerController player, TripwireInstance instance,
DateTime startTime) {
if (!player.IsValid) return;
if ((player.Buttons & PlayerButtons.Use) != PlayerButtons.Use) return;
var apiPlayer = converter.GetPlayer(player);
var progress = (DateTime.Now - startTime) / config.DefuseTime;
var timeLeft = config.DefuseTime * progress;
if (progress >= 1) {
tripwireTracker?.RemoveTripwire(instance);
if ((player.Buttons & PlayerButtons.Use) != PlayerButtons.Use) {
messenger.Message(apiPlayer,
locale[TripwireMsgs.SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED]);
return;
}
var apiPlayer = converter.GetPlayer(player);
var target = getTargetTripwire(player);
var progress = (DateTime.Now - startTime) / config.DefuseTime;
var timeLeft = config.DefuseTime - config.DefuseTime * progress;
if (progress >= 1) {
instance.TripwireProp.EmitSound("c4.disarmfinish", null, 0.2f, 1.5f);
tripwireTracker?.RemoveTripwire(instance);
if (apiPlayer is IOnlinePlayer online)
provider.GetService<IShop>()
?.AddBalance(online, config.DefuseReward, "Tripwire Defusal");
return;
}
var target = getTargetTripwire(player);
if (target != instance) {
messenger.Message(apiPlayer,
locale[TripwireMsgs.SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED]);
@@ -100,6 +120,8 @@ public class TripwireDefuserListener(IServiceProvider provider)
player.PrintToCenter(
locale[TripwireMsgs.SHOP_ITEM_TRIPWIRE_DEFUSING(progress, timeLeft)]);
var pitch = 1.5f - (float)progress;
player.EmitSound("c4.keypressquiet", pitch: pitch);
var ticksDelay = (int)Math.Round(64 * config.DefuseRate.TotalSeconds);
Server.RunOnTick(Server.TickCount + ticksDelay,
() => tickDefuse(player, instance, startTime));

View File

@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Drawing;
using System.Reactive.Concurrency;
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
@@ -36,25 +35,17 @@ public static class TripwireServiceCollection {
public class TripwireItem(IServiceProvider provider)
: RoleRestrictedItem<TraitorRole>(provider), IPluginModule, ITripwireTracker {
private TripwireConfig config
=> Provider.GetService<IStorage<TripwireConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new TripwireConfig();
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly IScheduler scheduler =
provider.GetRequiredService<IScheduler>();
public List<TripwireInstance> ActiveTripwires { get; } = [];
public void RemoveTripwire(TripwireInstance instance) {
instance.Beam.Remove();
instance.TripwireProp.Remove();
ActiveTripwires.Remove(instance);
}
private TripwireConfig config
=> Provider.GetService<IStorage<TripwireConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new TripwireConfig();
public override string Name => Locale[TripwireMsgs.SHOP_ITEM_TRIPWIRE];
@@ -71,6 +62,14 @@ public class TripwireItem(IServiceProvider provider)
onPrecache);
}
public List<TripwireInstance> ActiveTripwires { get; } = [];
public void RemoveTripwire(TripwireInstance instance) {
instance.Beam.Remove();
instance.TripwireProp.Remove();
ActiveTripwires.Remove(instance);
}
private void onPrecache(ResourceManifest manifest) {
manifest.AddResource(
"models/generic/conveyor_control_panel_01/conveyor_button_02.vmdl");

View File

@@ -27,12 +27,12 @@ public class TripwireMovementListener(IServiceProvider provider)
private readonly IPlayerConverter<CCSPlayerController> converter =
provider.GetRequiredService<IPlayerConverter<CCSPlayerController>>();
private readonly ITripwireTracker? tripwireTracker =
provider.GetService<ITripwireTracker>();
private readonly Dictionary<string, TripwireInstance> killedWithTripwire =
new();
private readonly ITripwireTracker? tripwireTracker =
provider.GetService<ITripwireTracker>();
private TripwireConfig config
=> Provider.GetService<IStorage<TripwireConfig>>()
?.Load()
@@ -44,6 +44,16 @@ public class TripwireMovementListener(IServiceProvider provider)
plugin?.AddTimer(0.2f, checkTripwires, TimerFlags.REPEAT);
}
public void ActivateTripwire(TripwireInstance instance) {
tripwireTracker?.RemoveTripwire(instance);
instance.TripwireProp.EmitSound("Flashbang.ExplodeDistant");
foreach (var player in Finder.GetOnline()) {
if (!dealTripwireDamage(instance, player, out var gamePlayer)) continue;
gamePlayer.EmitSound("Player.BurnDamage");
}
}
private void checkTripwires() {
if (tripwireTracker == null) return;
foreach (var wire in new List<TripwireInstance>(tripwireTracker
@@ -96,16 +106,6 @@ public class TripwireMovementListener(IServiceProvider provider)
ev.Body.Killer = info.owner;
}
public void ActivateTripwire(TripwireInstance instance) {
tripwireTracker?.RemoveTripwire(instance);
instance.TripwireProp.EmitSound("Flashbang.ExplodeDistant");
foreach (var player in Finder.GetOnline()) {
if (dealTripwireDamage(instance, player, out var gamePlayer)) continue;
gamePlayer?.EmitSound("Player.BurnDamage");
}
}
private bool dealTripwireDamage(TripwireInstance instance,
IOnlinePlayer player,
[NotNullWhen(true)] out CCSPlayerController? gamePlayer) {

View File

@@ -12,12 +12,12 @@ public class TripwireMsgs {
public static IMsg SHOP_ITEM_TRIPWIRE_TOOFAR
=> MsgFactory.Create(nameof(SHOP_ITEM_TRIPWIRE_TOOFAR));
public static IMsg SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED
=> MsgFactory.Create(nameof(SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED));
public static IMsg
SHOP_ITEM_TRIPWIRE_DEFUSING(double progress, TimeSpan time) {
return MsgFactory.Create(nameof(SHOP_ITEM_TRIPWIRE_DEFUSING),
progress.ToString("P"), time.TotalSeconds);
progress.ToString("P0"), time.TotalSeconds.ToString("F1"));
}
public static IMsg SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED
=> MsgFactory.Create(nameof(SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED));
}

View File

@@ -4,8 +4,10 @@ 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.ThirdParties.eGO;
using TTT.Game.Events.Game;
using TTT.Game.Events.Player;
using TTT.Game.Listeners;
using TTT.Game.Roles;
@@ -24,31 +26,41 @@ public class WardenTagAssigner(IServiceProvider provider)
public void OnRoleAssign(PlayerRoleAssignEvent ev) {
var maul = EgoApi.MAUL.Get();
if (maul == null) return;
Server.NextWorldUpdate(() => {
var gamePlayer = converter.GetPlayer(ev.Player);
if (gamePlayer == null) return;
if (ev.Role is not DetectiveRole) return;
var gamePlayer = converter.GetPlayer(ev.Player);
if (gamePlayer == null) return;
Task.Run(async () => {
if (ev.Role is DetectiveRole) {
var oldTag = await maul.getTagService().GetTag(gamePlayer.SteamID);
var oldTagColor =
await maul.getTagService().GetTagColor(gamePlayer.SteamID);
oldTags[ev.Player.Id] = (oldTag, oldTagColor);
}
Task.Run(async () => {
var oldTag = await maul.getTagService().GetTag(gamePlayer.SteamID);
var oldTagColor =
await maul.getTagService().GetTagColor(gamePlayer.SteamID);
if (oldTag != "[DETECTIVE]")
oldTags[ev.Player.Id] = (oldTag, oldTagColor);
await Server.NextWorldUpdateAsync(() => {
if (ev.Role is DetectiveRole) {
maul.getTagService().SetTag(gamePlayer, "[DETECTIVE]", false);
maul.getTagService()
.SetTagColor(gamePlayer, ChatColors.DarkBlue, false);
} else if (oldTags.TryGetValue(ev.Player.Id, out var oldTag)) {
maul.getTagService().SetTag(gamePlayer, oldTag.Item1, false);
maul.getTagService().SetTagColor(gamePlayer, oldTag.Item2, false);
oldTags.Remove(ev.Player.Id);
}
});
await Server.NextWorldUpdateAsync(() => {
maul.getTagService().SetTag(gamePlayer, "[DETECTIVE]", false);
maul.getTagService()
.SetTagColor(gamePlayer, ChatColors.DarkBlue, false);
});
});
}
[UsedImplicitly]
[EventHandler]
public void OnGameEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
var maul = EgoApi.MAUL.Get();
if (maul == null) return;
foreach (var (playerId, (oldTag, oldTagColor)) in oldTags) {
var apiPlayer = Finder.GetPlayerById(playerId);
if (apiPlayer == null) continue;
var csPlayer = converter.GetPlayer(apiPlayer);
if (csPlayer == null || !csPlayer.IsValid) continue;
maul.getTagService().SetTag(csPlayer, oldTag, false);
maul.getTagService().SetTagColor(csPlayer, oldTagColor, false);
}
oldTags.Clear();
}
}

View File

@@ -2,6 +2,7 @@ using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using CounterStrikeSharp.API.Modules.Utils;
using TTT.API.Player;
using TTT.CS2.Extensions;
namespace TTT.CS2.Player;
@@ -87,18 +88,17 @@ public class CS2Player : IOnlinePlayer, IEquatable<CS2Player> {
}
public int Armor {
get => Player?.PawnArmor ?? 0;
get => Player != null && Player.IsValid ? Player.GetArmor().Item1 : 0;
set {
if (Player == null) return;
Player.PawnArmor = value;
Utilities.SetStateChanged(Player, "CCSPlayerController", "m_iPawnArmor");
if (Player == null || !Player.IsValid) return;
Player.SetArmor(value);
}
}
public bool IsAlive {
get
=> Player != null && Player is {
=> Player != null && Player.IsValid && Player is {
Team : CsTeam.CounterTerrorist or CsTeam.Terrorist,
Pawn.Value.Health: > 0
};

View File

@@ -8,54 +8,54 @@ AFK_MOVED: "%PREFIX%You were moved to spectators 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."
SHOP_ITEM_DNA_DESC: "Scan bodies to reveal the person who killed them"
SHOP_ITEM_DNA_SCANNED: "%DNA_PREFIX%You scanned {0}{1}'%s% {grey}body, their killer was {red}{2}{grey}."
SHOP_ITEM_DNA_SCANNED_OTHER: "%DNA_PREFIX%You scanned {0}{1}'%s% {grey}body, {2}."
SHOP_ITEM_DNA_EXPIRED: "%DNA_PREFIX%You scanned {0}{1}'%s% {grey}body, but the DNA has expired."
SHOP_ITEM_STATION_HEALTH: "Health Station"
SHOP_ITEM_STATION_HEALTH_DESC: "A health station that heals players around it."
SHOP_ITEM_STATION_HEALTH_DESC: "The health station will heall all players around it"
SHOP_ITEM_STATION_HURT: "Hurt Station"
SHOP_ITEM_STATION_HURT_DESC: "A station that hurts non-Traitors around it."
SHOP_ITEM_STATION_HURT_DESC: "The hurt station will damage all non-Traitors around it"
SHOP_ITEM_CAMO: "Camouflage"
SHOP_ITEM_CAMO_DESC: "Disguise yourself and make yourself harder to see."
SHOP_ITEM_CAMO_DESC: "You are now harder to see"
SHOP_ITEM_BODY_PAINT: "Body Paint"
SHOP_ITEM_BODY_PAINT_DESC: "Paint bodies to make them appear identified."
SHOP_ITEM_BODY_PAINT_DESC: "Interacting with bodies will now paint them, making them appear identified"
SHOP_ITEM_BODY_PAINT_OUT: "%PREFIX% You ran out of body paint."
SHOP_ITEM_POISON_SHOTS: "Poison Shots"
SHOP_ITEM_POISON_SHOTS_DESC: "Your bullets are coated in a mildly poisonous substance."
SHOP_ITEM_POISON_SHOTS_DESC: "The next 5 shots from your {red}pistols{grey} are coated with poison"
SHOP_ITEM_POISON_HIT: "%PREFIX% You hit {green}{0}{grey} with a {lightpurple}poison shot{grey}."
SHOP_ITEM_POISON_OUT: "%PREFIX% You are out of poison shots."
SHOP_ITEM_POISON_SMOKE: "Poison Smoke"
SHOP_ITEM_POISON_SMOKE_DESC: "Throw a grenade that releases poisonous gas."
SHOP_ITEM_POISON_SMOKE_DESC: "The smoke grenade will damage all non-Traitors inside it over time"
SHOP_ITEM_ARMOR: "Armor with Helmet"
SHOP_ITEM_ARMOR_DESC: "Wear armor that reduces incoming damage."
SHOP_ITEM_ARMOR_DESC: ""
SHOP_ITEM_ONE_HIT_KNIFE: "One-Hit Knife"
SHOP_ITEM_ONE_HIT_KNIFE_DESC: "Your next knife hit will be a guaranteed kill."
SHOP_ITEM_ONE_HIT_KNIFE_DESC: "Your {red}next knife{grey} attack will instantly kill your target"
SHOP_ITEM_COMPASS_PLAYER: "Player Compass"
SHOP_ITEM_COMPASS_PLAYER_DESC: "Reveals the direction that the nearest non-Traitor is in."
SHOP_ITEM_COMPASS_PLAYER_DESC: ""
SHOP_ITEM_COMPASS_BODY: "Body Compass"
SHOP_ITEM_COMPASS_BODY_DESC: "Reveals the direction that the nearest unidentified body is in."
SHOP_ITEM_COMPASS_BODY_DESC: ""
SHOP_ITEM_SILENT_AWP: "Silent AWP"
SHOP_ITEM_SILENT_AWP_DESC: "Receive a silenced AWP with limited ammo."
SHOP_ITEM_SILENT_AWP_DESC: ""
SHOP_ITEM_CLUSTER_GRENADE: "Cluster Grenade"
SHOP_ITEM_CLUSTER_GRENADE_DESC: "A grenade that splits into multiple smaller grenades."
SHOP_ITEM_CLUSTER_GRENADE_DESC: ""
SHOP_ITEM_TELEPORT_DECOY: "Teleport Decoy"
SHOP_ITEM_TELEPORT_DECOY_DESC: "A decoy that teleports you to it upon explosion."
SHOP_ITEM_TELEPORT_DECOY_DESC: "The decoy will teleport you to its location {default}once it explodes{grey}"
SHOP_ITEM_TRIPWIRE: "Tripwire"
SHOP_ITEM_TRIPWIRE_DESC: "A tripwire that explodes when triggered."
SHOP_ITEM_TRIPWIRE_DESC: "The tripwire will activate once anyone crosses it"
SHOP_ITEM_TRIPWIRE_TOOFAR: "%PREFIX%You are too far away to place the tripwire."
SHOP_ITEM_TRIPWIRE_DEFUSING: "%PREFIX%Defusing tripwire... ({0}%, {1} second%s% left)."
SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED: "%PREFIX%You stopped defusing tripwire."
SHOP_ITEM_TRIPWIRE_DEFUSING: "Defusing... ({0}, {1} second%s% left)."
SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED: "%PREFIX%You stopped defusing the tripwire."

View File

@@ -21,9 +21,9 @@ public record TTTConfig {
public int TraitorHealth { get; init; } = 100;
public int DetectiveHealth { get; init; } = 100;
public int InnocentHealth { get; init; } = 100;
public int TraitorArmor { get; init; } = 100;
public int DetectiveArmor { get; init; } = 100;
public int InnocentArmor { get; init; } = 100;
public int TraitorArmor { get; init; }
public int DetectiveArmor { get; init; }
public int InnocentArmor { get; init; }
public string[]? TraitorWeapons { get; init; } = ["knife", "pistol"];

View File

@@ -51,9 +51,14 @@ public class BuyCommand(IServiceProvider provider) : ICommand {
}
var result = shop.TryPurchase(executor, item);
return Task.FromResult(result == PurchaseResult.SUCCESS ?
CommandResult.SUCCESS :
CommandResult.ERROR);
if (result == PurchaseResult.SUCCESS) {
info.ReplySync(locale[ShopMsgs.SHOP_PURCHASED(item)]);
if (!string.IsNullOrWhiteSpace(item.Description))
info.ReplySync(locale[ShopMsgs.SHOP_PURCHASED_DETAIL(item)]);
return Task.FromResult(CommandResult.SUCCESS);
}
return Task.FromResult(CommandResult.ERROR);
}
private IShopItem? searchItem(IOnlinePlayer? player, string query) {

View File

@@ -38,6 +38,7 @@ public class C4ShopItem(IServiceProvider provider)
public override ShopItemConfig Config => config;
public override void OnPurchase(IOnlinePlayer player) {
c4sBought++;
Inventory.GiveWeapon(player, new BaseWeapon(config.Weapon));
}
@@ -46,13 +47,10 @@ public class C4ShopItem(IServiceProvider provider)
if (c4sBought > config.MaxC4PerRound)
return PurchaseResult.ITEM_NOT_PURCHASABLE;
if (config.MaxC4AtOnce > 0) {
var count = 0;
if (finder.GetOnline()
.Where(p => Shop.HasItem<C4ShopItem>(p))
.Any(_ => count++ >= config.MaxC4AtOnce))
if (config.MaxC4AtOnce > 0)
if (finder.GetOnline().Count(p => Shop.HasItem<C4ShopItem>(p))
> config.MaxC4AtOnce)
return PurchaseResult.ITEM_NOT_PURCHASABLE;
}
return base.CanPurchase(player);
}

View File

@@ -10,6 +10,7 @@ using TTT.API.Game;
using TTT.API.Player;
using TTT.API.Storage;
using TTT.CS2.Extensions;
using TTT.Locale;
namespace TTT.Shop;
@@ -23,6 +24,9 @@ public class PeriodicRewarder(IServiceProvider provider) : ITerrorModule {
private readonly IGameManager games =
provider.GetRequiredService<IGameManager>();
private readonly IMsgLocalizer localizer =
provider.GetRequiredService<IMsgLocalizer>();
private readonly Dictionary<string, List<Vector>> playerPositions = new();
private readonly IScheduler scheduler =
@@ -67,7 +71,8 @@ public class PeriodicRewarder(IServiceProvider provider) : ITerrorModule {
var position = count == 1 ? 1f : (float)(count - i - 1) / (count - 1);
var rewardAmount = scaleRewardAmount(position, config.MinRewardAmount,
config.MaxRewardAmount);
shop.AddBalance(player, rewardAmount, "Exploration");
shop.AddBalance(player, rewardAmount,
localizer[ShopMsgs.SHOP_EXPLORATION]);
}
});
}

View File

@@ -58,11 +58,8 @@ public class Shop(IServiceProvider provider) : ITerrorModule, IShop {
bus.Dispatch(purchaseEvent);
if (purchaseEvent.IsCanceled) return PurchaseResult.PURCHASE_CANCELED;
AddBalance(player, -cost, item.Name);
AddBalance(player, -cost, item.Name, printReason);
GiveItem(player, item);
if (printReason)
messenger?.Message(player, localizer[ShopMsgs.SHOP_PURCHASED(item)]);
return PurchaseResult.SUCCESS;
}

View File

@@ -7,12 +7,6 @@
<RootNamespace>TTT.Shop</RootNamespace>
</PropertyGroup>
<ItemGroup>
<Reference Include="Microsoft.Extensions.DependencyInjection.Abstractions">
<HintPath>..\..\..\..\..\..\.nuget\packages\microsoft.extensions.dependencyinjection.abstractions\8.0.0\lib\net8.0\Microsoft.Extensions.DependencyInjection.Abstractions.dll</HintPath>
</Reference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\Game\Game.csproj"/>

View File

@@ -12,6 +12,9 @@ public static class ShopMsgs {
public static IMsg CREDITS_NAME => MsgFactory.Create(nameof(CREDITS_NAME));
public static IMsg SHOP_EXPLORATION
=> MsgFactory.Create(nameof(SHOP_EXPLORATION));
public static IMsg SHOP_CANNOT_PURCHASE
=> MsgFactory.Create(nameof(SHOP_CANNOT_PURCHASE));
@@ -19,6 +22,10 @@ public static class ShopMsgs {
return MsgFactory.Create(nameof(SHOP_PURCHASED), item.Name);
}
public static IMsg SHOP_PURCHASED_DETAIL(IShopItem item) {
return MsgFactory.Create(nameof(SHOP_PURCHASED_DETAIL), item.Description);
}
public static IMsg SHOP_ITEM_NOT_FOUND(string query) {
return MsgFactory.Create(nameof(SHOP_ITEM_NOT_FOUND), query);
}

View File

@@ -2,37 +2,40 @@ SHOP_PREFIX: "{green}SHOP {grey}| "
SHOP_INACTIVE: "%SHOP_PREFIX%The shop is currently closed."
SHOP_ITEM_NOT_FOUND: "%SHOP_PREFIX%Could not find an item named \"{default}{0}{grey}\"."
SHOP_EXPLORATION: "Exploration"
SHOP_ITEM_DEAGLE: "One-Hit Revolver"
SHOP_ITEM_DEAGLE_DESC: "A one-hit kill revolver with a single bullet. Aim carefully!"
SHOP_ITEM_DEAGLE_DESC: "If you hit an enemy, they will die instantly. Hitting a teammate will kill you instead"
SHOP_ITEM_DEAGLE_HIT_FF: "%PREFIX%You hit a teammate!"
SHOP_ITEM_DEAGLE_VICTIM: "%PREFIX%You were hit by a {yellow}One-Hit Revolver{grey}."
SHOP_ITEM_STICKERS: "Stickers"
SHOP_ITEM_STICKERS_DESC: "Reveal the roles of all players you taser to others."
SHOP_ITEM_STICKERS_HIT: "%SHOP_PREFIX%You got stickered, your role is now visible to everyone."
SHOP_ITEM_STICKERS_DESC: "When you tase a player, their role will be shown to everyone"
SHOP_ITEM_STICKERS_HIT: "%SHOP_PREFIX%You got {green}stickered{grey}, your role is now visible to everyone."
SHOP_ITEM_C4: "C4 Explosive"
SHOP_ITEM_C4_DESC: "A powerful explosive that blows up after a delay."
SHOP_ITEM_C4_DESC: "The bomb will deal damage to everyone including you and fellow {red}Traitors{grey}"
SHOP_ITEM_M4A1: "M4A1 Rifle and USP-S"
SHOP_ITEM_M4A1_DESC: "A fully automatic rifle with a silencer accompanied by a silenced pistol."
SHOP_ITEM_M4A1_DESC: ""
SHOP_ITEM_GLOVES: "Gloves"
SHOP_ITEM_GLOVES_DESC: "Lets you kill without DNA being left behind, or move bodies without identifying the body."
SHOP_ITEM_GLOVES_DESC: "You can now kill without leaving DNA behind, or move bodies without IDing them"
SHOP_ITEM_GLOVES_USED_BODY: "%SHOP_PREFIX%You used your gloves to move a body without leaving DNA. ({yellow}{0}{grey}/{yellow}{1}{grey} use%s% left)."
SHOP_ITEM_GLOVES_USED_KILL: "%SHOP_PREFIX%You used your gloves to kill without leaving DNA evidence. ({yellow}{0}{grey}/{yellow}{1}{grey} use%s% left)."
SHOP_ITEM_GLOVES_WORN_OUT: "%SHOP_PREFIX%Your gloves wore out."
SHOP_ITEM_TASER: "Taser"
SHOP_ITEM_TASER_DESC: "A taser that allows you to identify the roles of players you hit."
SHOP_ITEM_TASER_DESC: "Tasing a player will tell you their role"
SHOP_ITEM_HEALTHSHOT: "Healthshot"
SHOP_ITEM_HEALTHSHOT_DESC: "A healthshot that heals you gradually for 50 health."
SHOP_ITEM_HEALTHSHOT_DESC: ""
SHOP_INSUFFICIENT_BALANCE: "%SHOP_PREFIX%You cannot afford {white}{0}{grey}, it costs {yellow}{1}{grey} %CREDITS_NAME%%s%, and you have {yellow}{2}{grey}."
SHOP_CANNOT_PURCHASE: "%SHOP_PREFIX%You cannot purchase this item."
SHOP_CANNOT_PURCHASE_WITH_REASON: "%SHOP_PREFIX%You cannot purchase this item: {red}{0}{grey}."
SHOP_PURCHASED: "%SHOP_PREFIX%You purchased {white}{0}{grey}."
SHOP_PURCHASED_DETAIL: "%SHOP_PREFIX%{white}{0}{grey}."
SHOP_LIST_FOOTER: "%SHOP_PREFIX%You are %an% {0}{grey}, you have {yellow}{1}{grey} %CREDITS_NAME%%s%."
CREDITS_NAME: "point"

View File

@@ -19,4 +19,6 @@ public record TripwireConfig : ShopItemConfig {
public TimeSpan DefuseTime { get; init; } = TimeSpan.FromSeconds(5);
public TimeSpan DefuseRate { get; init; } = TimeSpan.FromMilliseconds(500);
public int DefuseReward { get; init; } = 20;
}

View File

@@ -40,7 +40,7 @@ public enum PurchaseResult {
/// <summary>
/// The item cannot be purchased multiple times, and the player already owns it.
/// </summary>
ALREADY_OWNED,
ALREADY_OWNED
}
public static class PurchaseResultExtensions {

View File

@@ -0,0 +1,11 @@
using SpecialRoundAPI;
namespace SpecialRound.Events;
/// <summary>
/// Called when a special round is enabled.
/// Note that multiple special rounds may be enabled per round.
/// </summary>
/// <param name="round"></param>
public class SpecialRoundEnableEvent(AbstractSpecialRound round)
: SpecialRoundEvent(round);

View File

@@ -0,0 +1,8 @@
using SpecialRoundAPI;
using TTT.API.Events;
namespace SpecialRound.Events;
public abstract class SpecialRoundEvent(AbstractSpecialRound round) : Event {
public AbstractSpecialRound Round { get; } = round;
}

View File

@@ -0,0 +1,43 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Modules.Cvars;
using Microsoft.Extensions.DependencyInjection;
using SpecialRound.lang;
using SpecialRoundAPI;
using SpecialRoundAPI.Configs;
using TTT.API.Game;
using TTT.API.Storage;
using TTT.Game.Events.Game;
using TTT.Locale;
namespace SpecialRound.Rounds;
public class LowGravRound(IServiceProvider provider)
: AbstractSpecialRound(provider) {
private int originalGravity = 800;
public override string Name => "Low Grav";
public override IMsg Description => RoundMsgs.SPECIAL_ROUND_LOWGRAV;
public override SpecialRoundConfig Config => config;
private LowGravRoundConfig config
=> Provider.GetService<IStorage<LowGravRoundConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new LowGravRoundConfig();
public override void ApplyRoundEffects() {
var cvar = ConVar.Find("sv_gravity");
if (cvar == null) return;
originalGravity = (int) Math.Round(cvar.GetPrimitiveValue<float>());
var newGravity = (int)(originalGravity * config.GravityMultiplier);
Server.NextWorldUpdate(()
=> Server.ExecuteCommand($"sv_gravity {newGravity}"));
}
public override void OnGameState(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
Server.NextWorldUpdate(()
=> Server.ExecuteCommand($"sv_gravity {originalGravity}"));
}
}

View File

@@ -0,0 +1,50 @@
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI.Events;
using SpecialRound.lang;
using SpecialRoundAPI;
using SpecialRoundAPI.Configs;
using TTT.API.Events;
using TTT.API.Storage;
using TTT.Game.Events.Game;
using TTT.Locale;
namespace SpecialRound.Rounds;
public class RichRound(IServiceProvider provider)
: AbstractSpecialRound(provider) {
public override string Name => "Rich";
public override IMsg Description => RoundMsgs.SPECIAL_ROUND_RICH;
public override SpecialRoundConfig Config => config;
private RichRoundConfig config
=> Provider.GetService<IStorage<RichRoundConfig>>()
?.Load()
.GetAwaiter()
.GetResult() ?? new RichRoundConfig();
public override void ApplyRoundEffects() { }
[UsedImplicitly]
[EventHandler]
public void OnBalanceChange(PlayerBalanceEvent ev) {
if (!Tracker.ActiveRounds.Contains(this)) return;
if (ev.Reason == "Round Start") {
var newBal = (int)(ev.NewBalance * config.BonusCreditsMultiplier);
ev.NewBalance = newBal;
return;
}
if (ev.NewBalance <= ev.OldBalance) return;
var gain = ev.NewBalance - ev.OldBalance;
gain = (int)(gain * config.AdditiveCreditsMultiplier);
ev.NewBalance = ev.OldBalance + gain;
}
public override bool ConflictsWith(AbstractSpecialRound other) {
return other is VanillaRound;
}
public override void OnGameState(GameStateUpdateEvent ev) { }
}

View File

@@ -82,7 +82,7 @@ public class SpeedRound(IServiceProvider provider)
public void OnDeath(PlayerDeathEvent ev) {
var game = games.ActiveGame;
if (game == null) return;
if (Tracker.CurrentRound != this) return;
if (!Tracker.ActiveRounds.Contains(this)) return;
var victimRoles = roles.GetRoles(ev.Victim);
if (!victimRoles.Any(r => r is InnocentRole)) return;

View File

@@ -14,19 +14,6 @@ namespace SpecialRound.Rounds;
public class SuppressedRound(IServiceProvider provider)
: AbstractSpecialRound(provider), IPluginModule {
private static readonly HashSet<uint> silencedWeapons = new() {
1, // deagle
2, // dual berettas
3, // five seven
30, // tec9
32, // p2000
36, // p250
4, // glock
61, // usp-s
63, // cz75 auto
64 // r8 revolver
};
private BasePlugin? plugin;
public override string Name => "Suppressed";
public override IMsg Description => RoundMsgs.SPECIAL_ROUND_SUPPRESSED;
@@ -47,7 +34,8 @@ public class SuppressedRound(IServiceProvider provider)
private HookResult onWeaponSound(UserMessage msg) {
var defIndex = msg.ReadUInt("item_def_index");
if (!silencedWeapons.Contains(defIndex)) return HookResult.Continue;
if (!WeaponSoundIndex.PISTOLS.Contains(defIndex))
return HookResult.Continue;
msg.Recipients.Clear();
return HookResult.Handled;

View File

@@ -35,10 +35,14 @@ public class VanillaRound(IServiceProvider provider)
public override void OnGameState(GameStateUpdateEvent ev) { }
public override bool ConflictsWith(AbstractSpecialRound other) {
return other is RichRound;
}
[UsedImplicitly]
[EventHandler(Priority = Priority.HIGH)]
public void OnPurchase(PlayerPurchaseItemEvent ev) {
if (Tracker.CurrentRound != this) return;
if (!Tracker.ActiveRounds.Contains(this)) return;
ev.IsCanceled = true;
messenger.Message(ev.Player, locale[RoundMsgs.VANILLA_ROUND_REMINDER]);

View File

@@ -7,11 +7,10 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\SpecialRoundAPI\SpecialRoundAPI\SpecialRoundAPI.csproj"/>
<ProjectReference Include="..\API\API.csproj"/>
<ProjectReference Include="..\CS2\CS2.csproj"/>
<ProjectReference Include="..\Game\Game.csproj"/>
<ProjectReference Include="..\SpecialRoundAPI\SpecialRoundAPI.csproj" />
<ProjectReference Include="..\SpecialRoundAPI\SpecialRoundAPI.csproj"/>
</ItemGroup>
</Project>

View File

@@ -9,12 +9,15 @@ public static class SpecialRoundCollection {
public static void AddSpecialRounds(this IServiceCollection services) {
services.AddModBehavior<ISpecialRoundStarter, SpecialRoundStarter>();
services.AddModBehavior<ISpecialRoundTracker, SpecialRoundTracker>();
services.AddModBehavior<SpecialRoundSoundNotifier>();
services.AddModBehavior<SpeedRound>();
services.AddModBehavior<BhopRound>();
services.AddModBehavior<VanillaRound>();
services.AddModBehavior<SuppressedRound>();
services.AddModBehavior<SilentRound>();
services.AddModBehavior<LowGravRound>();
services.AddModBehavior<PistolRound>();
services.AddModBehavior<RichRound>();
services.AddModBehavior<SilentRound>();
services.AddModBehavior<SpeedRound>();
services.AddModBehavior<SuppressedRound>();
services.AddModBehavior<VanillaRound>();
}
}

View File

@@ -0,0 +1,17 @@
using CounterStrikeSharp.API;
using JetBrains.Annotations;
using SpecialRound.Events;
using TTT.API.Events;
using TTT.Game.Listeners;
namespace SpecialRound;
public class SpecialRoundSoundNotifier(IServiceProvider provider)
: BaseListener(provider) {
[UsedImplicitly]
[EventHandler]
public void OnSpecialRoundStart(SpecialRoundEnableEvent ev) {
foreach (var player in Utilities.GetPlayers())
player.EmitSound("UI.XP.Star.Spend", null, 0.2f);
}
}

View File

@@ -1,6 +1,7 @@
using CounterStrikeSharp.API.Core;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using SpecialRound.Events;
using SpecialRound.lang;
using SpecialRoundAPI;
using TTT.API;
@@ -29,16 +30,22 @@ public class SpecialRoundStarter(IServiceProvider provider)
plugin?.RegisterListener<Listeners.OnMapStart>(onMapChange);
}
public AbstractSpecialRound?
TryStartSpecialRound(AbstractSpecialRound? round) {
round ??= getSpecialRound();
Messenger.MessageAll(Locale[RoundMsgs.SPECIAL_ROUND_STARTED(round)]);
Messenger.MessageAll(Locale[round.Description]);
public List<AbstractSpecialRound> TryStartSpecialRound(
List<AbstractSpecialRound>? rounds = null) {
rounds ??= getSpecialRounds();
round?.ApplyRoundEffects();
tracker.CurrentRound = round;
Messenger.MessageAll(Locale[RoundMsgs.SPECIAL_ROUND_STARTED(rounds)]);
foreach (var round in rounds) {
var roundStart = new SpecialRoundEnableEvent(round);
Bus.Dispatch(roundStart);
Messenger.MessageAll(Locale[round.Description]);
round.ApplyRoundEffects();
}
tracker.ActiveRounds.AddRange(rounds);
tracker.RoundsSinceLastSpecial = 0;
return round;
return rounds;
}
private void onMapChange(string mapName) { roundsSinceMapChange = 0; }
@@ -57,16 +64,31 @@ public class SpecialRoundStarter(IServiceProvider provider)
if (roundsSinceMapChange < config.MinRoundsAfterMapChange) return;
if (Random.Shared.NextSingle() > config.SpecialRoundChance) return;
var specialRound = getSpecialRound();
var specialRound = getSpecialRounds();
TryStartSpecialRound(specialRound);
}
private AbstractSpecialRound getSpecialRound() {
private List<AbstractSpecialRound> getSpecialRounds() {
var selectedRounds = new List<AbstractSpecialRound>();
do {
var round = pickWeightedRound(selectedRounds);
if (round == null) break;
selectedRounds.Add(round);
} while (config.MultiRoundChance > Random.Shared.NextSingle());
return selectedRounds;
}
private AbstractSpecialRound? pickWeightedRound(
List<AbstractSpecialRound> exclude) {
var rounds = Provider.GetServices<ITerrorModule>()
.OfType<AbstractSpecialRound>()
.Where(r => r.Config.Weight > 0)
.Where(r => r.Config.Weight > 0 && !exclude.Contains(r))
.Where(r => !exclude.Any(er => er.ConflictsWith(r) || r.ConflictsWith(er)))
.ToList();
if (rounds.Count == 0) return null;
var totalWeight = rounds.Sum(r => r.Config.Weight);
var roll = Random.Shared.NextDouble() * totalWeight;
foreach (var round in rounds) {
@@ -74,7 +96,6 @@ public class SpecialRoundStarter(IServiceProvider provider)
if (roll <= 0) return round;
}
throw new InvalidOperationException(
"Failed to select a special round. This should never happen.");
return null;
}
}

View File

@@ -9,7 +9,7 @@ namespace SpecialRound;
public class SpecialRoundTracker : ISpecialRoundTracker, ITerrorModule,
IListener {
public AbstractSpecialRound? CurrentRound { get; set; }
public List<AbstractSpecialRound> ActiveRounds { get; } = new();
public int RoundsSinceLastSpecial { get; set; }
public void Dispose() { }
public void Start() { }
@@ -18,6 +18,6 @@ public class SpecialRoundTracker : ISpecialRoundTracker, ITerrorModule,
[EventHandler]
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
CurrentRound = null;
ActiveRounds.Clear();
}
}

View File

@@ -5,4 +5,11 @@ public record SpecialRoundsConfig {
public int MinPlayersForSpecial { get; init; } = 5;
public int MinRoundsAfterMapChange { get; init; } = 2;
public float SpecialRoundChance { get; init; } = 0.2f;
/// <summary>
/// If a special round is started, the chance that another special round
/// will start in conjunction with it. This check is run until it fails,
/// or we run out of special rounds to start.
/// </summary>
public float MultiRoundChance { get; init; } = 0.33f;
}

View File

@@ -25,7 +25,16 @@ public class RoundMsgs {
public static IMsg SPECIAL_ROUND_PISTOL
=> MsgFactory.Create(nameof(SPECIAL_ROUND_PISTOL));
public static IMsg SPECIAL_ROUND_STARTED(AbstractSpecialRound round) {
return MsgFactory.Create(nameof(SPECIAL_ROUND_STARTED), round.Name);
public static IMsg SPECIAL_ROUND_RICH
=> MsgFactory.Create(nameof(SPECIAL_ROUND_RICH));
public static IMsg SPECIAL_ROUND_LOWGRAV
=> MsgFactory.Create(nameof(SPECIAL_ROUND_LOWGRAV));
public static IMsg SPECIAL_ROUND_STARTED(List<AbstractSpecialRound> round) {
var roundNames = round.Count == 1 ?
round[0].Name :
string.Join(", ", round.Select(r => r.Name));
return MsgFactory.Create(nameof(SPECIAL_ROUND_STARTED), roundNames);
}
}

View File

@@ -5,4 +5,6 @@ SPECIAL_ROUND_VANILLA: " {green}VANILLA{grey}: The shop has been disabled!"
SPECIAL_ROUND_SUPPRESSED: " {grey}SUPPRESSED{grey}: All pistols are silent!"
SPECIAL_ROUND_SILENT: " {grey}SILENT{grey}: All players are muted!"
SPECIAL_ROUND_PISTOL: " {blue}PISTOL{grey}: You can only use pistols this round!"
SPECIAL_ROUND_RICH: " {gold}RICH{grey}: All players start with extra credits!"
SPECIAL_ROUND_LOWGRAV: " {lightblue}LOW GRAVITY{grey}: Players can jump higher and fall slower!"
VANILLA_ROUND_REMINDER: "%SHOP_PREFIX%This is a {purple}Vanilla{grey} round. The shop is disabled."

View File

@@ -19,6 +19,8 @@ public abstract class AbstractSpecialRound(IServiceProvider provider)
public abstract void ApplyRoundEffects();
public virtual bool ConflictsWith(AbstractSpecialRound _) { return false; }
[UsedImplicitly]
[EventHandler]
public abstract void OnGameState(GameStateUpdateEvent ev);

View File

@@ -0,0 +1,6 @@
namespace SpecialRoundAPI.Configs;
public record LowGravRoundConfig : SpecialRoundConfig {
public override float Weight { get; init; } = 0.6f;
public float GravityMultiplier { get; init; } = 0.5f;
}

View File

@@ -0,0 +1,7 @@
namespace SpecialRoundAPI.Configs;
public record RichRoundConfig : SpecialRoundConfig {
public override float Weight { get; init; } = 0.75f;
public float BonusCreditsMultiplier { get; init; } = 2.0f;
public float AdditiveCreditsMultiplier { get; init; } = 3.0f;
}

View File

@@ -8,6 +8,6 @@ public interface ISpecialRoundStarter {
/// </summary>
/// <param name="round"></param>
/// <returns></returns>
public AbstractSpecialRound?
TryStartSpecialRound(AbstractSpecialRound? round);
public List<AbstractSpecialRound>? TryStartSpecialRound(
List<AbstractSpecialRound>? round = null);
}

View File

@@ -1,6 +1,7 @@
namespace SpecialRoundAPI;
public interface ISpecialRoundTracker {
public AbstractSpecialRound? CurrentRound { get; set; }
public List<AbstractSpecialRound> ActiveRounds { get; }
public int RoundsSinceLastSpecial { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace SpecialRoundAPI;
public class WeaponSoundIndex {
public static readonly HashSet<uint> PISTOLS = [
1, // deagle
2, // dual berettas
3, // five seven
30, // tec9
32, // p2000
36, // p250
4, // glock
61, // usp-s
63, // cz75 auto
64
];
}

View File

@@ -0,0 +1,44 @@
using System.Text;
using System.Text.Json;
using JetBrains.Annotations;
using Microsoft.Extensions.DependencyInjection;
using SpecialRound.Events;
using SpecialRoundAPI;
using TTT.API.Events;
using TTT.API.Game;
using TTT.Game.Events.Game;
using TTT.Game.Listeners;
namespace Stats;
public class SpecialRoundListener(IServiceProvider provider)
: BaseListener(provider) {
private readonly HttpClient client =
provider.GetRequiredService<HttpClient>();
private readonly IRoundTracker tracker =
provider.GetRequiredService<IRoundTracker>();
private AbstractSpecialRound? round;
[UsedImplicitly]
[EventHandler]
public void OnRoundStart(SpecialRoundEnableEvent ev) { round = ev.Round; }
[UsedImplicitly]
[EventHandler]
public void OnRoundEnd(GameStateUpdateEvent ev) {
if (ev.NewState != State.FINISHED) return;
if (round == null) return;
var data = new { special_round = round.Name };
var payload = new StringContent(JsonSerializer.Serialize(data),
Encoding.UTF8, "application/json");
Task.Run(async () => {
var id = tracker.CurrentRoundId;
if (id == null) return;
await client.PatchAsync("round/" + id.Value, payload);
});
}
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<ProjectReference Include="..\Game\Game.csproj"/>
<ProjectReference Include="..\ShopAPI\ShopAPI.csproj"/>
<ProjectReference Include="..\SpecialRound\SpecialRound.csproj"/>
</ItemGroup>
</Project>