This commit is contained in:
code002lover 2025-06-09 22:13:12 +02:00
parent 326b99c464
commit 0aee847089
11 changed files with 583 additions and 389 deletions

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ obj/
*.user *.user
*.dll *.dll
fuchsbau/ fuchsbau/
**/target/

View File

@ -1,3 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using CustomPlayerEffects;
using Interactables.Interobjects.DoorUtils; using Interactables.Interobjects.DoorUtils;
using InventorySystem.Items; using InventorySystem.Items;
using InventorySystem.Items.Firearms.Modules; using InventorySystem.Items.Firearms.Modules;
@ -61,6 +63,11 @@ public sealed class CustomClasses : Plugin
/// </summary> /// </summary>
public GamblerConfig GamblerConfig { get; private set; } = new(); public GamblerConfig GamblerConfig { get; private set; } = new();
/// <summary>
/// Configuration for the ShadowStepper class.
/// </summary>
public ShadowStepperConfig ShadowStepperConfig { get; private set; } = new();
/// <inheritdoc/> /// <inheritdoc/>
public override void Enable() public override void Enable()
{ {
@ -68,6 +75,12 @@ public sealed class CustomClasses : Plugin
ServerEvents.RoundEnded += OnRoundEnded; ServerEvents.RoundEnded += OnRoundEnded;
Scp914Events.ProcessingPickup += OnScp914ProcessingPickup; Scp914Events.ProcessingPickup += OnScp914ProcessingPickup;
Scp914Events.ProcessingInventoryItem += OnScp914ProcessingInventoryItem; Scp914Events.ProcessingInventoryItem += OnScp914ProcessingInventoryItem;
PlayerEvents.Escaped += OnEscaped;
}
private void OnEscaped(PlayerEscapedEventArgs ev)
{
} }
/// <inheritdoc/> /// <inheritdoc/>
@ -77,6 +90,7 @@ public sealed class CustomClasses : Plugin
ServerEvents.RoundEnded -= OnRoundEnded; ServerEvents.RoundEnded -= OnRoundEnded;
Scp914Events.ProcessingPickup -= OnScp914ProcessingPickup; Scp914Events.ProcessingPickup -= OnScp914ProcessingPickup;
Scp914Events.ProcessingInventoryItem -= OnScp914ProcessingInventoryItem; Scp914Events.ProcessingInventoryItem -= OnScp914ProcessingInventoryItem;
PlayerEvents.Escaped -= OnEscaped;
} }
private void OnRoundEnded(RoundEndedEventArgs ev) private void OnRoundEnded(RoundEndedEventArgs ev)
@ -84,6 +98,7 @@ public sealed class CustomClasses : Plugin
_classManager.ResetSpawnStates(); _classManager.ResetSpawnStates();
} }
[SuppressMessage("ReSharper", "RedundantJumpStatement")]
private void OnPlayerSpawned(PlayerSpawnedEventArgs ev) private void OnPlayerSpawned(PlayerSpawnedEventArgs ev)
{ {
if (_classManager.TryHandleSpawn(ev.Player, JanitorConfig, typeof(JanitorConfig))) return; if (_classManager.TryHandleSpawn(ev.Player, JanitorConfig, typeof(JanitorConfig))) return;
@ -91,6 +106,7 @@ public sealed class CustomClasses : Plugin
if (_classManager.TryHandleSpawn(ev.Player, HeadGuardConfig, typeof(HeadGuardConfig))) return; if (_classManager.TryHandleSpawn(ev.Player, HeadGuardConfig, typeof(HeadGuardConfig))) return;
if (_classManager.TryHandleSpawn(ev.Player, MedicConfig, typeof(MedicConfig))) return; if (_classManager.TryHandleSpawn(ev.Player, MedicConfig, typeof(MedicConfig))) return;
if (_classManager.TryHandleSpawn(ev.Player, GamblerConfig, typeof(GamblerConfig))) return; if (_classManager.TryHandleSpawn(ev.Player, GamblerConfig, typeof(GamblerConfig))) return;
if (_classManager.TryHandleSpawn(ev.Player, ShadowStepperConfig, typeof(ShadowStepperConfig))) return;
} }
private static void OnScp914ProcessingPickup(Scp914ProcessingPickupEventArgs ev) private static void OnScp914ProcessingPickup(Scp914ProcessingPickupEventArgs ev)
@ -133,6 +149,7 @@ public class CustomClassManager
RegisterHandler<HeadGuardConfig>(new HeadGuardHandler()); RegisterHandler<HeadGuardConfig>(new HeadGuardHandler());
RegisterHandler<MedicConfig>(new MedicHandler()); RegisterHandler<MedicConfig>(new MedicHandler());
RegisterHandler<GamblerConfig>(new GamblerHandler()); RegisterHandler<GamblerConfig>(new GamblerHandler());
RegisterHandler<ShadowStepperConfig>(new ShadowStepperHandler());
} }
/// <summary> /// <summary>
@ -219,14 +236,26 @@ public interface ICustomClassHandler
/// <param name="config">The configuration for the custom class.</param> /// <param name="config">The configuration for the custom class.</param>
/// <param name="random">A random number generator.</param> /// <param name="random">A random number generator.</param>
void HandleSpawn(Player player, CustomClassConfig config, Random random); void HandleSpawn(Player player, CustomClassConfig config, Random random);
void HandleEscape(Player player, CustomClassConfig config);
}
public abstract class CustomClassHandler: ICustomClassHandler
{
public abstract void HandleSpawn(Player player, CustomClassConfig config, Random random);
public virtual void HandleEscape(Player player, CustomClassConfig config)
{
//Intentionally left blank
}
} }
/// <summary> /// <summary>
/// Handler for the Janitor custom class. /// Handler for the Janitor custom class.
/// </summary> /// </summary>
public class JanitorHandler(CustomClassManager manager) : ICustomClassHandler public class JanitorHandler(CustomClassManager manager) : CustomClassHandler
{ {
public void HandleSpawn(Player player, CustomClassConfig config, Random random) public override void HandleSpawn(Player player, CustomClassConfig config, Random random)
{ {
var scp914 = Map.Rooms.FirstOrDefault(r => r.Name == RoomName.Lcz914); var scp914 = Map.Rooms.FirstOrDefault(r => r.Name == RoomName.Lcz914);
if (scp914 == null) if (scp914 == null)
@ -247,9 +276,9 @@ public class JanitorHandler(CustomClassManager manager) : ICustomClassHandler
/// <summary> /// <summary>
/// Handler for the Research Subject custom class. /// Handler for the Research Subject custom class.
/// </summary> /// </summary>
public class ResearchSubjectHandler(CustomClassManager manager) : ICustomClassHandler public class ResearchSubjectHandler(CustomClassManager manager) : CustomClassHandler
{ {
public void HandleSpawn(Player player, CustomClassConfig config, Random random) public override void HandleSpawn(Player player, CustomClassConfig config, Random random)
{ {
var scientist = Player.ReadyList.FirstOrDefault(p => p.Role == RoleTypeId.Scientist); var scientist = Player.ReadyList.FirstOrDefault(p => p.Role == RoleTypeId.Scientist);
if (scientist == null) if (scientist == null)
@ -270,9 +299,9 @@ public class ResearchSubjectHandler(CustomClassManager manager) : ICustomClassHa
/// <summary> /// <summary>
/// Handler for the Head Guard custom class. /// Handler for the Head Guard custom class.
/// </summary> /// </summary>
public class HeadGuardHandler : ICustomClassHandler public class HeadGuardHandler : CustomClassHandler
{ {
public void HandleSpawn(Player player, CustomClassConfig config, Random random) public override void HandleSpawn(Player player, CustomClassConfig config, Random random)
{ {
player.RemoveItem(ItemType.KeycardGuard); player.RemoveItem(ItemType.KeycardGuard);
KeycardItem.CreateCustomKeycardTaskForce(player, "Head Guard Keycard", $"HG. {player.Nickname}", new KeycardLevels(1, 1, 2), Color.blue, Color.cyan, "1", 0); KeycardItem.CreateCustomKeycardTaskForce(player, "Head Guard Keycard", $"HG. {player.Nickname}", new KeycardLevels(1, 1, 2), Color.blue, Color.cyan, "1", 0);
@ -305,9 +334,9 @@ public class HeadGuardHandler : ICustomClassHandler
/// <summary> /// <summary>
/// Handler for the Medic custom class. /// Handler for the Medic custom class.
/// </summary> /// </summary>
public class MedicHandler : ICustomClassHandler public class MedicHandler : CustomClassHandler
{ {
public void HandleSpawn(Player player, CustomClassConfig config, Random random) public override void HandleSpawn(Player player, CustomClassConfig config, Random random)
{ {
foreach (var spawnItem in config.Items) foreach (var spawnItem in config.Items)
{ {
@ -318,12 +347,38 @@ public class MedicHandler : ICustomClassHandler
} }
} }
/// <summary>
/// Handler for ShadowStepper custom class.
/// </summary>
public class ShadowStepperHandler : CustomClassHandler
{
public override void HandleSpawn(Player player, CustomClassConfig config, Random random)
{
ApplyEffects(player);
player.SendBroadcast("You're a <color=#000000>ShadowStepper</color>!", 3);
}
public override void HandleEscape(Player player, CustomClassConfig config)
{
base.HandleEscape(player, config);
ApplyEffects(player);
}
private static void ApplyEffects(Player player)
{
player.ReferenceHub.playerEffectsController.ChangeState<SilentWalk>(100,float.MaxValue);
player.ReferenceHub.playerEffectsController.ChangeState<Slowness>(20,float.MaxValue);
}
}
/// <summary> /// <summary>
/// Base handler for simple item-giving custom classes. /// Base handler for simple item-giving custom classes.
/// </summary> /// </summary>
public abstract class SimpleAddItemHandler : ICustomClassHandler public abstract class SimpleAddItemHandler : CustomClassHandler
{ {
public virtual void HandleSpawn(Player player, CustomClassConfig config, Random random) public override void HandleSpawn(Player player, CustomClassConfig config, Random random)
{ {
foreach (var spawnItem in config.Items) foreach (var spawnItem in config.Items)
{ {
@ -375,7 +430,7 @@ public abstract class CustomClassConfig
/// <summary> /// <summary>
/// Configuration for the Research Subject class. /// Configuration for the Research Subject class.
/// </summary> /// </summary>
public sealed class ResearchSubjectConfig : CustomClassConfig { } public sealed class ResearchSubjectConfig : CustomClassConfig;
/// <summary> /// <summary>
/// Configuration for the Janitor class. /// Configuration for the Janitor class.
@ -419,6 +474,14 @@ public sealed class GamblerConfig : CustomClassConfig
public override ItemType[] Items { get; set; } = [ItemType.Coin, ItemType.Coin]; public override ItemType[] Items { get; set; } = [ItemType.Coin, ItemType.Coin];
} }
/// <summary>
/// Configuration for the Shadow Stepper class.
/// </summary>
public sealed class ShadowStepperConfig : CustomClassConfig
{
}
/// <summary> /// <summary>
/// Tracks the spawn state for a custom class. /// Tracks the spawn state for a custom class.
/// </summary> /// </summary>

View File

@ -70,7 +70,11 @@ public class GrowingZombies : Plugin
// Add damage resistance after eating multiple corpses // Add damage resistance after eating multiple corpses
if (corpsesEaten >= 3) if (corpsesEaten >= 3)
ev.Player.ReferenceHub.playerEffectsController.ChangeState<DamageReduction>((byte)(corpsesEaten*2), float.MaxValue); {
var damageReductionIntensity = (byte)Math.Min(corpsesEaten * 2, 100); // Half-Percent
ev.Player.ReferenceHub.playerEffectsController.ChangeState<DamageReduction>(damageReductionIntensity,
float.MaxValue);
}
// Add regeneration effect after eating multiple corpses // Add regeneration effect after eating multiple corpses
if (corpsesEaten < 5) return; if (corpsesEaten < 5) return;

View File

@ -59,109 +59,131 @@ public class Plugin : LabApi.Loader.Features.Plugins.Plugin
PlayerEvents.Left -= OnLeft; PlayerEvents.Left -= OnLeft;
} }
private void UpdateHints() private static string CollectHint()
{ {
var hintTexts = new List<string>(); var hintTexts = new List<string>();
lock (_hintsLock) foreach (var player in Player.ReadyList.Where(x => !x.IsDummy && (x.IsSCP || x.Role is RoleTypeId.Scp0492)))
{ {
foreach (var player in Player.ReadyList.Where(x => !x.IsHost && !x.IsDummy && (x.IsSCP || x.Role is RoleTypeId.Scp0492))) var text =
{ $" <size=25><color=red>{player.RoleBase.RoleName}</color> | <color=#6761cd>{player.HumeShield}</color> | <color=#da0101>{player.Health}</color> | <color=grey>{player.Zone}</color></size> ";
var text =
$" <size=25><color=red>{player.RoleBase.RoleName}</color> | <color=#6761cd>{player.HumeShield}</color> | <color=#da0101>{player.Health}</color> | <color=grey>{player.Zone}</color></size> ";
switch (player.RoleBase) switch (player.RoleBase)
{
case Scp096Role scp:
text += "\n";
scp.SubroutineModule.TryGetSubroutine(out Scp096TargetsTracker tracker);
if (!tracker) break;
var targetColor = tracker.Targets.Count > 0 ? "red" : "grey";
text += $"<color=grey>Targets:</color> <color={targetColor}>{tracker.Targets.Count}</color>";
break;
case Scp3114Role scp3114:
text += "\n";
var stolenRole = scp3114.CurIdentity.StolenRole;
if (scp3114.Disguised)
{
text += $" {stolenRole}";
}
else
{
text += " None";
}
break;
case Scp079Role scp079:
text =
$" <size=25><color=red>{player.RoleBase.RoleName}</color> | <color=grey>{scp079.CurrentCamera.Room.Zone}</color></size> ";
text += "\n";
scp079.SubroutineModule.TryGetSubroutine(out Scp079AuxManager auxManager);
scp079.SubroutineModule.TryGetSubroutine(out Scp079TierManager tierManager);
if (!auxManager || !tierManager) break;
text +=
$" <color=#FFEF00>AUX: {auxManager.CurrentAuxFloored}</color> / {auxManager.MaxAux} | <color=#FFD700>Level {tierManager.AccessTierLevel}</color>";
break;
case ZombieRole:
var count = GrowingZombies.GrowingZombies.Instance.ZombieCorpseCount[player];
const string corpseColor = "E68A8A";
text += "\n";
text += $" <color=#{corpseColor}>Corpses eaten: {count}</color>";
break;
}
hintTexts.Add(text);
}
var hintText = string.Join("\n", hintTexts);
foreach (var player in Player.ReadyList.Where(x => !x.IsHost && !x.IsDummy))
{ {
Logger.Debug($"Updating hint for {player.DisplayName}"); case Scp096Role scp:
UpdateHint(player, hintText); text += "\n";
scp.SubroutineModule.TryGetSubroutine(out Scp096TargetsTracker tracker);
if (!tracker) break;
var targetColor = tracker.Targets.Count > 0 ? "red" : "grey";
text += $"<color=grey>Targets:</color> <color={targetColor}>{tracker.Targets.Count}</color>";
break;
case Scp3114Role scp3114:
text += "\n";
var stolenRole = scp3114.CurIdentity.StolenRole;
if (scp3114.Disguised)
{
text += $" {stolenRole}";
}
else
{
text += " None";
}
break;
case Scp079Role scp079:
text =
$" <size=25><color=red>{player.RoleBase.RoleName}</color> | <color=grey>{scp079.CurrentCamera.Room.Zone}</color></size> ";
text += "\n";
scp079.SubroutineModule.TryGetSubroutine(out Scp079AuxManager auxManager);
scp079.SubroutineModule.TryGetSubroutine(out Scp079TierManager tierManager);
if (!auxManager || !tierManager) break;
text +=
$" <color=#FFEF00>AUX: {auxManager.CurrentAuxFloored}</color> / {auxManager.MaxAux} | <color=#FFD700>Level {tierManager.AccessTierLevel}</color>";
break;
case ZombieRole:
var count = GrowingZombies.GrowingZombies.Instance.ZombieCorpseCount[player];
const string corpseColor = "E68A8A";
text += "\n";
text += $" <color=#{corpseColor}>Corpses eaten: {count}</color>";
break;
} }
hintTexts.Add(text);
} }
return string.Join("\n", hintTexts);
}
private void UpdateHints()
{
var hintText = CollectHint();
foreach (var player in Player.ReadyList.Where(x => !x.IsDummy))
{
try
{
UpdateHint(player, hintText);
} catch (Exception e)
{
Logger.Warn("Caught exception while updating hint for player");
Logger.Error(e);
}
}
} }
private void UpdateHint(Player player, string hintText) private void UpdateHint(Player player, string hintText)
{ {
if (!_spectatorHints.TryGetValue(player, out var hint)) bool isContained;
lock (_hintsLock)
{ {
Logger.Debug($"No hint found for player {player.DisplayName}"); isContained = _spectatorHints.ContainsKey(player);
return; }
if (!isContained)
{
CreateHint(player);
} }
Logger.Debug( if (_spectatorHints == null) return;
$"Player {player.Nickname} is on team {player.RoleBase.Team} with Role {player.Role} | hide: {player.RoleBase.Team != Team.SCPs}"); lock (_hintsLock)
hint.Hide = player.RoleBase.Team != Team.SCPs && player.Role != RoleTypeId.Scp0492 && player.Role != RoleTypeId.Overwatch; {
if (!hint.Hide) hint.Text = hintText; var hint = _spectatorHints[player];
hint.Hide = player.RoleBase.Team != Team.SCPs && player.Role != RoleTypeId.Scp0492 && player.Role != RoleTypeId.Overwatch;
if (!hint.Hide) hint.Text = hintText;
}
} }
private void OnJoin(PlayerJoinedEventArgs ev) private void OnJoin(PlayerJoinedEventArgs ev)
{ {
if (ev.Player.IsDummy || ev.Player.IsHost) return; if (ev.Player.IsDummy || ev.Player.IsHost) return;
CreateHint(ev.Player);
}
private void CreateHint(Player player)
{
var hint = new Hint var hint = new Hint
{ {
Text = "", Alignment = HintAlignment.Left, YCoordinate = 100, Hide = true Text = "", Alignment = HintAlignment.Left, YCoordinate = 100, Hide = true
}; };
var playerDisplay = PlayerDisplay.Get(ev.Player); var playerDisplay = PlayerDisplay.Get(player);
playerDisplay.AddHint(hint); playerDisplay.AddHint(hint);
lock (_hintsLock) lock (_hintsLock)
{ {
_spectatorHints[ev.Player] = hint; _spectatorHints[player] = hint;
} }
} }

View File

@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<Costura>
</Costura>
</Weavers>

View File

@ -1,176 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. -->
<xs:element name="Weavers">
<xs:complexType>
<xs:all>
<xs:element name="Costura" minOccurs="0" maxOccurs="1">
<xs:complexType>
<xs:all>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with line breaks</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX86Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinX64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="UnmanagedWinArm64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element minOccurs="0" maxOccurs="1" name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with line breaks.</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="CreateTemporaryAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>This will copy embedded files to disk before loading them into memory. This is helpful for some scenarios that expected an assembly to be loaded from a physical file.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeDebugSymbols" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if .pdbs for reference assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeReferences" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls if runtime assemblies are also embedded.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UseRuntimeReferencePaths" type="xs:boolean">
<xs:annotation>
<xs:documentation>Controls whether the runtime assemblies are embedded with their full path or only with their assembly name.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCompression" type="xs:boolean">
<xs:annotation>
<xs:documentation>Embedded assemblies are compressed by default, and uncompressed when they are loaded. You can turn compression off with this option.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableCleanup" type="xs:boolean">
<xs:annotation>
<xs:documentation>As part of Costura, embedded assemblies are no longer included as part of the build. This cleanup can be turned off.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="DisableEventSubscription" type="xs:boolean">
<xs:annotation>
<xs:documentation>The attach method no longer subscribes to the `AppDomain.AssemblyResolve` (.NET 4.x) and `AssemblyLoadContext.Resolving` (.NET 6.0+) events.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="LoadAtModuleInit" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura by default will load as part of the module initialization. This flag disables that behavior. Make sure you call CosturaUtility.Initialize() somewhere in your code.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IgnoreSatelliteAssemblies" type="xs:boolean">
<xs:annotation>
<xs:documentation>Costura will by default use assemblies with a name like 'resources.dll' as a satellite resource and prepend the output path. This flag disables that behavior.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="ExcludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to exclude from the default action of "embed all Copy Local references", delimited with |</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="IncludeRuntimeAssemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of runtime assembly names to include from the default action of "embed all Copy Local references", delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged32Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX86Assemblies instead</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinX86Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X86 (32 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="Unmanaged64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>Obsolete, use UnmanagedWinX64Assemblies instead</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinX64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged X64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="UnmanagedWinArm64Assemblies" type="xs:string">
<xs:annotation>
<xs:documentation>A list of unmanaged Arm64 (64 bit) assembly names to include, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="PreloadOrder" type="xs:string">
<xs:annotation>
<xs:documentation>The order of preloaded assemblies, delimited with |.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:all>
<xs:attribute name="VerifyAssembly" type="xs:boolean">
<xs:annotation>
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="VerifyIgnoreCodes" type="xs:string">
<xs:annotation>
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation>
</xs:annotation>
</xs:attribute>
<xs:attribute name="GenerateXsd" type="xs:boolean">
<xs:annotation>
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation>
</xs:annotation>
</xs:attribute>
</xs:complexType>
</xs:element>
</xs:schema>

97
StatsTracker/Rust/Cargo.lock generated Normal file
View File

@ -0,0 +1,97 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "stats_tracker"
version = "0.1.0"
dependencies = [
"bincode",
]
[[package]]
name = "syn"
version = "2.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ce2b7fc941b3a24138a0a7cf8e858bfc6a992e7978a068a5c760deb0ed43caf"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"

View File

@ -0,0 +1,18 @@
[package]
name = "stats_tracker"
version = "0.1.0"
edition = "2024"
[dependencies]
bincode = "2.0"
[lib]
crate-type = ["cdylib"]
name = "stats_tracker"
[profile.release]
opt-level = "z"
codegen-units = 1
lto = "fat"
overflow-checks = false
panic = "abort"

View File

@ -0,0 +1,98 @@
use std::{
ffi::{CStr, c_char},
fs::File,
};
use bincode::{Decode, Encode};
#[derive(Decode, Encode, Clone)]
#[repr(C)]
pub struct PlayerStat {
kills: u32,
deaths: u32,
team_damage: u32,
}
#[derive(Decode, Encode, Clone, Default)]
#[repr(C)]
pub struct ItemStat {
item_name: String,
item_count: u32,
}
#[derive(Decode, Encode, Clone)]
#[repr(C)]
pub struct Player {
player_id: String,
player_stats: PlayerStat,
player_items: [ItemStat; 256],
}
impl Default for Player {
fn default() -> Self {
Self {
player_id: String::new(),
player_stats: PlayerStat {
kills: 0,
deaths: 0,
team_damage: 0,
},
player_items: std::array::from_fn(|_| ItemStat::default()),
}
}
}
#[unsafe(no_mangle)]
/// # Safety
/// `player_id` must be a valid, null-terminated C string pointer.
pub unsafe extern "C" fn get_player_stats(player_id: *const c_char) -> *const Player {
let player_id = unsafe { CStr::from_ptr(player_id) }
.to_string_lossy()
.into_owned();
let db_location = "./stats.data";
let player_stats: Vec<Player> = match File::open(db_location) {
Ok(mut data) => bincode::decode_from_std_read(&mut data, bincode::config::standard())
.unwrap_or_default(),
Err(_) => Vec::new(),
};
player_stats
.iter()
.find(|p| p.player_id == player_id)
.unwrap_or(&Player::default())
}
#[unsafe(no_mangle)]
/// # Safety
/// `player` must be a valid pointer to a `Player` struct.
pub unsafe extern "C" fn save_player_stats(player: *const Player) -> bool {
let player = unsafe { &*player };
let db_location = "./stats.data";
let mut player_stats: Vec<Player> = match File::open(db_location) {
Ok(mut data) => bincode::decode_from_std_read(&mut data, bincode::config::standard())
.unwrap_or_default(),
Err(_) => Vec::new(),
};
// Find and update existing player or add new player
if let Some(existing_player) = player_stats
.iter_mut()
.find(|p| p.player_id == player.player_id)
{
*existing_player = player.clone();
} else {
player_stats.push(player.clone());
}
// Save updated stats
match File::create(db_location) {
Ok(mut file) => {
bincode::encode_into_std_write(&player_stats, &mut file, bincode::config::standard())
.is_ok()
}
Err(_) => false,
}
}

View File

@ -1,13 +1,40 @@
using LabApi.Features; using LabApi.Features;
using LabApi.Loader.Features.Plugins; using LabApi.Loader.Features.Plugins;
using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis;
using System.Data.SQLite; using System.Reflection;
using System.Runtime.InteropServices;
using LabApi.Events.Arguments.PlayerEvents; using LabApi.Events.Arguments.PlayerEvents;
using LabApi.Events.Handlers; using LabApi.Events.Handlers;
using LabApi.Loader; using LabApi.Features.Console;
namespace StatsTracker namespace StatsTracker
{ {
[SuppressMessage("ReSharper", "InconsistentNaming")]
public struct PlayerStat
{
public uint kills;
public uint deaths;
public uint team_damage;
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
public struct ItemStat
{
public string item_name;
public uint item_count;
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
public struct Player
{
public string player_id;
public PlayerStat player_stats;
public ItemStat[] player_items;
}
[SuppressMessage("ReSharper", "InconsistentNaming")]
public class StatsTracker : Plugin public class StatsTracker : Plugin
{ {
public override string Name => "StatsTracker"; public override string Name => "StatsTracker";
@ -15,148 +42,188 @@ namespace StatsTracker
public override Version Version { get; } = new(1, 0, 0); public override Version Version { get; } = new(1, 0, 0);
public override string Description => "Tracks stats for players."; public override string Description => "Tracks stats for players.";
public override Version RequiredApiVersion { get; } = new(LabApiProperties.CompiledVersion); public override Version RequiredApiVersion { get; } = new(LabApiProperties.CompiledVersion);
private string _dbPath; private const string RustDllName = "stats_tracker";
private readonly ConcurrentDictionary<string, PlayerStats> _currentSessionStats = new();
[DllImport(RustDllName, EntryPoint="get_player_stats", CallingConvention = CallingConvention.Cdecl)]
private class PlayerStats private static extern ref Player GetPlayerStats(ref string player_id);
{
public int Kills { get; set; } [DllImport(RustDllName, EntryPoint="save_player_stats", CallingConvention = CallingConvention.Cdecl)]
public int Deaths { get; set; } private static extern bool SavePlayerStats(ref Player player);
public Dictionary<ItemType, int> ItemUsage { get; set; } = new();
}
public override void Enable() public override void Enable()
{ {
_dbPath = Path.Combine(this.GetConfigDirectory().FullName, "stats.db"); var pathVariable = Environment.GetEnvironmentVariable("PATH");
InitializeDatabase(); Logger.Debug($"PATH: {pathVariable}");
var extractedDllPath = ExtractRustDll(); // Call extraction
if (string.IsNullOrEmpty(extractedDllPath))
{
Logger.Error("Failed to extract Rust DLL. Exiting.");
return;
}
var libDirectory = Path.GetDirectoryName(extractedDllPath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
SetDllDirectory(libDirectory);
Logger.Info($"Windows: Added '{libDirectory}' to DLL search path.");
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
Environment.SetEnvironmentVariable("LD_LIBRARY_PATH", libDirectory + ":" + Environment.GetEnvironmentVariable("LD_LIBRARY_PATH"));
Logger.Info($"Linux: Extracted library to '{libDirectory}'. Relying on default search paths and manual LD_LIBRARY_PATH.");
}
else
{
Logger.Error("Only windows and linux are supported.");
return;
}
PlayerEvents.Death += OnPlayerDied; PlayerEvents.Death += OnPlayerDied;
PlayerEvents.UsedItem += OnItemUsed; PlayerEvents.UsedItem += OnItemUsed;
PlayerEvents.Left += OnPlayerLeft; PlayerEvents.Hurt += OnHurt;
}
// Conditional P/Invoke for OS-specific functions (like SetDllDirectory on Windows)
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true, ExactSpelling = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetDllDirectory(string lpPathName);
private static string ExtractRustDll()
{
string dllFilename;
string resourceSubPath; // Folder inside NativeLibs
string extractedLibFilename;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
dllFilename = $"{RustDllName}.dll";
resourceSubPath = "x86_64_pc_windows_gnu";
extractedLibFilename = dllFilename; // On Windows, we keep the original name
}
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
{
dllFilename = $"lib{RustDllName}.so"; // Linux uses lib prefix and .so suffix
resourceSubPath = "x86_64_unknown_linux_gnu";
extractedLibFilename = dllFilename; // On Linux, keep the lib*.so name
}
else
{
Logger.Error("Unsupported operating system detected.");
return null;
}
// Determine where to extract the DLL.
var targetDirectory = AppDomain.CurrentDomain.BaseDirectory;
var extractedLibPath = Path.Combine(targetDirectory, extractedLibFilename);
// Check if the DLL already exists (e.g., from a previous run or development environment)
if (File.Exists(extractedLibPath))
{
Logger.Warn($"Rust lib '{dllFilename}' already exists at '{extractedLibPath}'. Skipping extraction.");
return extractedLibPath; // Return the path directly if it exists
}
try
{
// Adjust this resource name to match your actual embedded resource name.
// Based on your previous comment:
var resourceName = $"StatsTracker.Rust.target.{resourceSubPath}.release.{dllFilename}";
Logger.Info($"Attempting to load embedded resource: {resourceName}");
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(resourceName))
{
if (stream == null)
{
Logger.Error($"Error: Embedded resource '{resourceName}' not found.");
Logger.Info("Available resources:");
foreach (var res in Assembly.GetExecutingAssembly().GetManifestResourceNames())
{
Logger.Info($"- {res}");
}
return null; // Return null to indicate failure
}
using (var fileStream = File.Create(extractedLibPath))
{
stream.CopyTo(fileStream);
}
}
Logger.Info($"Successfully extracted '{RustDllName}' to '{extractedLibPath}'.");
return extractedLibPath; // Return the path after successful extraction
}
catch (Exception ex)
{
Logger.Error($"Error extracting Rust DLL to {extractedLibPath}: {ex.Message}");
return null; // Return null to indicate failure
}
} }
public override void Disable() public override void Disable()
{ {
PlayerEvents.Death -= OnPlayerDied; PlayerEvents.Death -= OnPlayerDied;
PlayerEvents.UsedItem -= OnItemUsed; PlayerEvents.UsedItem -= OnItemUsed;
PlayerEvents.Left -= OnPlayerLeft;
// Save any remaining stats
foreach (var player in _currentSessionStats)
{
SavePlayerStats(player.Key, player.Value);
}
_currentSessionStats.Clear();
} }
private void InitializeDatabase() private static void OnHurt(PlayerHurtEventArgs ev)
{ {
using var connection = new SQLiteConnection($"Data Source={_dbPath}"); switch (ev.Attacker)
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = """
CREATE TABLE IF NOT EXISTS PlayerStats (
UserId TEXT PRIMARY KEY,
Kills INTEGER DEFAULT 0,
Deaths INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS ItemUsage (
UserId TEXT,
ItemType INTEGER,
UsageCount INTEGER DEFAULT 0,
PRIMARY KEY (UserId, ItemType)
);
""";
command.ExecuteNonQuery();
}
private void OnPlayerDied(PlayerDeathEventArgs ev)
{
if (ev.Attacker != null)
{ {
var killerStats = _currentSessionStats.GetOrAdd(ev.Attacker.UserId, _ => new PlayerStats()); case null:
killerStats.Kills++; case { DoNotTrack: true }:
return;
} }
var victimStats = _currentSessionStats.GetOrAdd(ev.Player.UserId, _ => new PlayerStats()); if(ev.Attacker.Team != ev.Player.Team) return;
victimStats.Deaths++;
}
private void OnItemUsed(PlayerUsedItemEventArgs ev)
{
var stats = _currentSessionStats.GetOrAdd(ev.Player.UserId, _ => new PlayerStats());
if (!stats.ItemUsage.ContainsKey(ev.UsableItem.Type)) var userId = ev.Attacker.UserId;
stats.ItemUsage[ev.UsableItem.Type] = 0; var killerStats = GetPlayerStats(ref userId);
killerStats.player_stats.team_damage++;
stats.ItemUsage[ev.UsableItem.Type]++; SavePlayerStats(ref killerStats);
} }
private void OnPlayerLeft(PlayerLeftEventArgs ev) private static void OnPlayerDied(PlayerDeathEventArgs ev)
{ {
if (_currentSessionStats.TryRemove(ev.Player.UserId, out var stats)) if (ev.Attacker != null && ev.Attacker.Nickname != "emeraldo" && !ev.Attacker.DoNotTrack)
{ {
SavePlayerStats(ev.Player.UserId, stats); var userId = ev.Attacker.UserId;
var killerStats = GetPlayerStats(ref userId);
killerStats.player_stats.kills++;
SavePlayerStats(ref killerStats);
} }
if (ev.Player.DoNotTrack)
{
Logger.Debug($"Do Not Track: {ev.Player.Nickname}");
return;
}
var victimId = ev.Player.UserId;
var victimStats = GetPlayerStats(ref victimId);
victimStats.player_stats.deaths++;
SavePlayerStats(ref victimStats);
} }
private void SavePlayerStats(string userId, PlayerStats stats) private static void OnItemUsed(PlayerUsedItemEventArgs ev)
{ {
using var connection = new SQLiteConnection($"Data Source={_dbPath}"); if (ev.Player.DoNotTrack)
connection.Open();
using var transaction = connection.BeginTransaction();
try
{ {
// Update player stats Logger.Debug($"Do Not Track: {ev.Player.Nickname}");
using (var command = connection.CreateCommand()) return;
{
command.CommandText = """
INSERT INTO PlayerStats (UserId, Kills, Deaths)
VALUES (@userId, @kills, @deaths)
ON CONFLICT(UserId) DO UPDATE SET
Kills = Kills + @kills,
Deaths = Deaths + @deaths;
""";
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@kills", stats.Kills);
command.Parameters.AddWithValue("@deaths", stats.Deaths);
command.ExecuteNonQuery();
}
// Update item usage
foreach (var itemInfoPair in stats.ItemUsage)
{
var itemType = itemInfoPair.Key;
var count = itemInfoPair.Value;
using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO ItemUsage (UserId, ItemType, UsageCount)
VALUES (@userId, @itemType, @count)
ON CONFLICT(UserId, ItemType) DO UPDATE SET
UsageCount = UsageCount + @count;
""";
command.Parameters.AddWithValue("@userId", userId);
command.Parameters.AddWithValue("@itemType", (int)itemType);
command.Parameters.AddWithValue("@count", count);
command.ExecuteNonQuery();
}
transaction.Commit();
}
catch
{
transaction.Rollback();
throw;
} }
var userId = ev.Player.UserId;
var stats = GetPlayerStats(ref userId);
var stat = stats.player_items[(int)ev.UsableItem.Type];
stat.item_count++;
stat.item_name = ev.UsableItem.Type.ToString();
} }
} }
} }

View File

@ -5,6 +5,7 @@
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable> <Nullable>disable</Nullable>
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' ">
@ -21,14 +22,18 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Costura.Fody" Version="6.0.0"> <EmbeddedResource Include="Rust/target/x86_64-pc-windows-gnu/release/stats_tracker.dll" />
<PrivateAssets>all</PrivateAssets> <EmbeddedResource Include="Rust/target/x86_64-unknown-linux-gnu/release/libstats_tracker.so" />
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Northwood.LabAPI" Version="1.0.2"/> <PackageReference Include="Northwood.LabAPI" Version="1.0.2"/>
<PackageReference Include="System.Data.SQLite" Version="1.0.119" />
</ItemGroup> </ItemGroup>
<Target Name="RustBuild" BeforeTargets="PrepareForBuild">
<Exec Command="echo 'Configuration: $(Configuration)'"/>
<Exec Command="cargo build -r --target x86_64-pc-windows-gnu" WorkingDirectory="./Rust"/>
<Exec Command="cargo build -r --target x86_64-unknown-linux-gnu" WorkingDirectory="./Rust"/>
</Target>
<ItemGroup> <ItemGroup>
<Reference Include="0Harmony"> <Reference Include="0Harmony">
@ -59,4 +64,4 @@
<HintPath>..\dependencies\UnityEngine.CoreModule.dll</HintPath> <HintPath>..\dependencies\UnityEngine.CoreModule.dll</HintPath>
</Reference> </Reference>
</ItemGroup> </ItemGroup>
</Project> </Project>