Compare commits

...

19 Commits

Author SHA1 Message Date
MSWS
a7fa2afe15 fix: Vanilla round invert 2025-11-10 14:45:34 -08:00
MSWS
7749deabd3 Force rebuild for new major 2.0.0 2025-11-10 14:36:18 -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
MSWS
b53920282b feat: Add tripwire defuse reward 2025-11-08 22:29:57 -08:00
53 changed files with 530 additions and 197 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

@@ -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

@@ -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;

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

@@ -42,8 +42,8 @@ public class HealthStation(IServiceProvider provider)
}
/// <summary>
/// Scan all props and build a map: Player -> list of (StationInfo, potentialHeal).
/// Also fills toRemove for invalid/expired props.
/// 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)>>
@@ -90,8 +90,8 @@ public class HealthStation(IServiceProvider provider)
}
/// <summary>
/// Compute potential heal from a station given the distance.
/// Mirrors your original logic: ceil(HealthIncrements * healthScale).
/// 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;
@@ -99,9 +99,9 @@ public class HealthStation(IServiceProvider provider)
}
/// <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.
/// 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)>>
@@ -125,7 +125,7 @@ public class HealthStation(IServiceProvider provider)
allocateProportionalInteger(totalHealAvailable, potentials);
// safety clamp: never allocate more than a station's potential
for (int i = 0; i < allocations.Count; i++)
for (var i = 0; i < allocations.Count; i++)
if (allocations[i] > potentials[i])
allocations[i] = potentials[i];
@@ -164,9 +164,9 @@ public class HealthStation(IServiceProvider provider)
}
/// <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.
/// 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) {

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,6 +1,7 @@
using CounterStrikeSharp.API;
using CounterStrikeSharp.API.Core;
using Microsoft.Extensions.DependencyInjection;
using ShopAPI;
using ShopAPI.Configs.Traitor;
using TTT.API;
using TTT.API.Messages;
@@ -16,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>>()
@@ -99,11 +100,14 @@ public class TripwireDefuserListener(IServiceProvider provider)
}
var progress = (DateTime.Now - startTime) / config.DefuseTime;
var timeLeft = config.DefuseTime - (config.DefuseTime * progress);
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;
}

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("P0"), time.TotalSeconds.ToString("F1"));
}
public static IMsg SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED
=> MsgFactory.Create(nameof(SHOP_ITEM_TRIPWIRE_DEFUSING_CANCELED));
}

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: "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

@@ -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

@@ -1,6 +0,0 @@
using SpecialRoundAPI;
namespace SpecialRound.Events;
public class SpecialRoundStartEvent(AbstractSpecialRound round)
: SpecialRoundEvent(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

@@ -10,7 +10,7 @@
<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

@@ -11,11 +11,13 @@ public static class SpecialRoundCollection {
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

@@ -10,8 +10,8 @@ public class SpecialRoundSoundNotifier(IServiceProvider provider)
: BaseListener(provider) {
[UsedImplicitly]
[EventHandler]
public void OnSpecialRoundStart(SpecialRoundStartEvent ev) {
public void OnSpecialRoundStart(SpecialRoundEnableEvent ev) {
foreach (var player in Utilities.GetPlayers())
player.EmitSound("UI.XP.Star.Spend", null, 0.8f);
player.EmitSound("UI.XP.Star.Spend", null, 0.2f);
}
}

View File

@@ -30,20 +30,22 @@ public class SpecialRoundStarter(IServiceProvider provider)
plugin?.RegisterListener<Listeners.OnMapStart>(onMapChange);
}
public AbstractSpecialRound?
TryStartSpecialRound(AbstractSpecialRound? round) {
round ??= getSpecialRound();
public List<AbstractSpecialRound> TryStartSpecialRound(
List<AbstractSpecialRound>? rounds = null) {
rounds ??= getSpecialRounds();
var roundStart = new SpecialRoundStartEvent(round);
Bus.Dispatch(roundStart);
Messenger.MessageAll(Locale[RoundMsgs.SPECIAL_ROUND_STARTED(rounds)]);
Messenger.MessageAll(Locale[RoundMsgs.SPECIAL_ROUND_STARTED(round)]);
Messenger.MessageAll(Locale[round.Description]);
foreach (var round in rounds) {
var roundStart = new SpecialRoundEnableEvent(round);
Bus.Dispatch(roundStart);
Messenger.MessageAll(Locale[round.Description]);
round.ApplyRoundEffects();
}
round.ApplyRoundEffects();
tracker.CurrentRound = round;
tracker.ActiveRounds.AddRange(rounds);
tracker.RoundsSinceLastSpecial = 0;
return round;
return rounds;
}
private void onMapChange(string mapName) { roundsSinceMapChange = 0; }
@@ -62,16 +64,32 @@ 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) {
@@ -79,7 +97,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>