From a0ef6311eef2007b4d78e6e035a5f629078d1df0 Mon Sep 17 00:00:00 2001 From: Tobias Thiele Date: Tue, 21 Jul 2026 07:26:30 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A6=20Modernize=20flashlight=20for=20C?= =?UTF-8?q?SS/.NET=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump to CounterStrikeSharp 1.0.371 and .NET 10, replace per-tick light spawns with a parented light_barn, add plugin config, real unit tests, and refresh CI plus docs. Co-authored-by: Cursor --- .github/workflows/build.yml | 50 +- .gitignore | 5 +- Flashlight.Tests/Flashlight.Tests.csproj | 13 +- Flashlight.Tests/FlashlightLogicTests.cs | 235 +++++----- Flashlight/Flashlight.cs | 429 ++++++++++-------- Flashlight/Flashlight.csproj | 4 +- Flashlight/FlashlightConfig.cs | 101 +++++ Flashlight/FlashlightLogic.cs | 62 +++ Flashlight/LogHelper.cs | 56 --- Flashlight/PlayerFlashlightState.cs | 18 + README.md | 99 ++-- .../2026-07-21-flashlight-modernization.md | 61 +++ ...6-07-21-flashlight-modernization-design.md | 148 ++++++ 13 files changed, 847 insertions(+), 434 deletions(-) create mode 100644 Flashlight/FlashlightConfig.cs create mode 100644 Flashlight/FlashlightLogic.cs delete mode 100644 Flashlight/LogHelper.cs create mode 100644 Flashlight/PlayerFlashlightState.cs create mode 100644 docs/superpowers/plans/2026-07-21-flashlight-modernization.md create mode 100644 docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b79b2cc..0b1625d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,48 +2,64 @@ name: Build on: push: + branches: + - main tags: - "*" + pull_request: + branches: + - main env: - BUILD_NUMBER: ${{ github.tag }} PROJECT_NAME: "Flashlight" PROJECT_PATH: "./Flashlight/Flashlight.csproj" OUTPUT_PATH: "./Flashlight/Flashlight" PUBLISH_PATH: "./Flashlight/Flashlight" - + DOTNET_VERSION: "10.0.x" jobs: - build: - permissions: write-all + test: runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: ${{ env.DOTNET_VERSION }} + - name: Restore + run: dotnet restore + - name: Test + run: dotnet test -c Release --no-restore + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore run: dotnet restore - name: Build - run: dotnet build ${{ env.PROJECT_PATH }} -c Flashlight -o ${{ env.OUTPUT_PATH }} + run: dotnet build ${{ env.PROJECT_PATH }} -c Release -o ${{ env.OUTPUT_PATH }} publish: - if: github.event_name == 'push' + if: startsWith(github.ref, 'refs/tags/') permissions: write-all runs-on: ubuntu-latest needs: build steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Setup .NET - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v4 with: - dotnet-version: 7.0.x + dotnet-version: ${{ env.DOTNET_VERSION }} - name: Restore run: dotnet restore - name: Build - run: dotnet build ${{ env.PROJECT_PATH }} -c Flashlight -o ${{ env.OUTPUT_PATH }} + run: dotnet build ${{ env.PROJECT_PATH }} -c Release -o ${{ env.OUTPUT_PATH }} - name: Clean files run: | rm -f \ @@ -56,11 +72,11 @@ jobs: filename: '${{ env.PROJECT_NAME }}.zip' path: ${{ env.PUBLISH_PATH }} - name: Create Release - uses: ncipollo/release-action@v1.12.0 + uses: ncipollo/release-action@v1.14.0 with: artifacts: "${{ env.PROJECT_NAME }}.zip" - name: "${{ env.BUILD_NUMBER }}" - tag: "${{ env.BUILD_NUMBER }}" + name: "${{ github.ref_name }}" + tag: "${{ github.ref_name }}" allowUpdates: true body: | Place the plugin in game/csgo/addons/counterstrikesharp/plugins/Flashlight diff --git a/.gitignore b/.gitignore index 992febe..1a95523 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .idea -Flashlight/bin -Flashlight/obj \ No newline at end of file +**/bin/ +**/obj/ +.DS_Store \ No newline at end of file diff --git a/Flashlight.Tests/Flashlight.Tests.csproj b/Flashlight.Tests/Flashlight.Tests.csproj index f4a8da4..fd0ff01 100644 --- a/Flashlight.Tests/Flashlight.Tests.csproj +++ b/Flashlight.Tests/Flashlight.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable false @@ -9,21 +9,20 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all - - \ No newline at end of file + diff --git a/Flashlight.Tests/FlashlightLogicTests.cs b/Flashlight.Tests/FlashlightLogicTests.cs index 7219fca..06e0d47 100644 --- a/Flashlight.Tests/FlashlightLogicTests.cs +++ b/Flashlight.Tests/FlashlightLogicTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using Xunit; namespace Flashlight.Tests; @@ -5,137 +6,119 @@ namespace Flashlight.Tests; public class FlashlightLogicTests { [Fact] - public void FlashlightState_TogglesCorrectly() + public void TryToggle_TogglesWhenAllowed() { - // Test the core logic of flashlight state toggling - var playerKey = "test_player_1"; - var flashlightState = new Dictionary(); - - // Initial state should be off - flashlightState[playerKey] = false; - Assert.False(flashlightState[playerKey]); - - // Toggle on - flashlightState[playerKey] = !flashlightState[playerKey]; - Assert.True(flashlightState[playerKey]); - - // Toggle off - flashlightState[playerKey] = !flashlightState[playerKey]; - Assert.False(flashlightState[playerKey]); - } - - [Fact] - public void FlashlightState_TracksMultiplePlayers() - { - var playerStates = new Dictionary(); - var player1 = "player_1"; - var player2 = "player_2"; - var player3 = "player_3"; - - // Initialize all off - playerStates[player1] = false; - playerStates[player2] = false; - playerStates[player3] = false; - - // Toggle player 1 on - playerStates[player1] = !playerStates[player1]; - Assert.True(playerStates[player1]); - Assert.False(playerStates[player2]); - Assert.False(playerStates[player3]); - - // Toggle player 2 on - playerStates[player2] = !playerStates[player2]; - Assert.True(playerStates[player1]); - Assert.True(playerStates[player2]); - Assert.False(playerStates[player3]); - } - - [Fact] - public void ToggleCooldown_PreventsRapidToggling() - { - // Simulate the cooldown mechanism + var isOn = false; var canToggle = true; - - // First toggle - should work - Assert.True(canToggle); - canToggle = false; // Simulate setting cooldown - - // Second toggle - should be blocked + + Assert.True(FlashlightLogic.TryToggle(ref isOn, ref canToggle)); + Assert.True(isOn); Assert.False(canToggle); - - // After cooldown expires - canToggle = true; - Assert.True(canToggle); } - + [Fact] - public void CrouchState_UpdatesCorrectly() + public void TryToggle_BlockedDuringCooldown() { - var isCrouching = false; - var buttons = 0; - const int DuckButton = 1 << 2; // Typical duck button bit - - // Not crouching initially - Assert.False(isCrouching); - - // Press duck button - buttons |= DuckButton; - if ((buttons & DuckButton) != 0) + var isOn = true; + var canToggle = false; + + Assert.False(FlashlightLogic.TryToggle(ref isOn, ref canToggle)); + Assert.True(isOn); + Assert.False(canToggle); + } + + [Fact] + public void IsUsePressedEdge_OnlyTrueOnRisingEdge() + { + Assert.True(FlashlightLogic.IsUsePressedEdge(true, false)); + Assert.False(FlashlightLogic.IsUsePressedEdge(true, true)); + Assert.False(FlashlightLogic.IsUsePressedEdge(false, true)); + Assert.False(FlashlightLogic.IsUsePressedEdge(false, false)); + } + + [Theory] + [InlineData(false, 64f, 46f, 64f)] + [InlineData(true, 64f, 46f, 46f)] + public void GetEyeOffsetZ_UsesCrouchWhenDucking( + bool crouching, + float stand, + float crouch, + float expected) + { + Assert.Equal(expected, FlashlightLogic.GetEyeOffsetZ(crouching, stand, crouch)); + } + + [Fact] + public void CalculateLightOrigin_AppliesEyeAndForwardOffsets() + { + var origin = FlashlightLogic.CalculateLightOrigin( + new Vector3(10f, 20f, 30f), + new Vector3(1f, 0f, 0f), + eyeOffsetZ: 64f, + forwardDistance: 54f); + + Assert.Equal(new Vector3(64f, 20f, 94f), origin); + } + + [Fact] + public void ForwardFromAnglesDegrees_YawZeroLooksAlongX() + { + var forward = FlashlightLogic.ForwardFromAnglesDegrees(0f, 0f); + + Assert.Equal(1f, forward.X, 3); + Assert.Equal(0f, forward.Y, 3); + Assert.Equal(0f, forward.Z, 3); + } + + [Fact] + public void ShouldCreateAndDestroyLight_Policies() + { + Assert.True(FlashlightLogic.ShouldCreateLight(isOn: true, hasValidLight: false)); + Assert.False(FlashlightLogic.ShouldCreateLight(isOn: true, hasValidLight: true)); + Assert.True(FlashlightLogic.ShouldDestroyLight(isOn: false, hasValidLight: true)); + Assert.False(FlashlightLogic.ShouldDestroyLight(isOn: false, hasValidLight: false)); + } + + [Fact] + public void ConfigClamp_FixesInvalidValues() + { + var config = new FlashlightConfig { - isCrouching = true; - } - Assert.True(isCrouching); - - // Release duck button - buttons &= ~DuckButton; - if ((buttons & DuckButton) == 0) - { - isCrouching = false; - } - Assert.False(isCrouching); + ToggleCooldownSeconds = -1f, + Brightness = -5f, + Range = 0f, + ColorTemperature = 50f, + SoftX = -1f, + SoftY = -1f, + Skirt = -1f, + SkirtNear = -1f, + SizeX = -1f, + SizeY = -1f, + SizeZ = -1f, + ForwardDistance = -10f, + StandEyeOffsetZ = -1f, + CrouchEyeOffsetZ = -1f, + AttachmentName = " ", + LightCookie = "" + }; + + config.Clamp(); + + Assert.Equal(0f, config.ToggleCooldownSeconds); + Assert.Equal(0f, config.Brightness); + Assert.Equal(1f, config.Range); + Assert.Equal(1000f, config.ColorTemperature); + Assert.Equal(0f, config.SoftX); + Assert.Equal(0f, config.SoftY); + Assert.Equal(0f, config.Skirt); + Assert.Equal(0f, config.SkirtNear); + Assert.Equal(0f, config.SizeX); + Assert.Equal(0f, config.SizeY); + Assert.Equal(0f, config.SizeZ); + Assert.Equal(0f, config.ForwardDistance); + Assert.Equal(0f, config.StandEyeOffsetZ); + Assert.Equal(0f, config.CrouchEyeOffsetZ); + Assert.Equal("axis_of_intent", config.AttachmentName); + Assert.Equal("materials/effects/lightcookies/flashlight.vtex", config.LightCookie); } - - [Fact] - public void LightPosition_CalculatesCrouchOffsetCorrectly() - { - // Test the position calculation logic - var baseZ = 100f; - var standOffset = 64.03f; - var crouchOffset = 46.03f; - - // Standing position - var standPosition = baseZ + standOffset; - Assert.Equal(164.03f, standPosition); - - // Crouching position - var crouchPosition = baseZ + crouchOffset; - Assert.Equal(146.03f, crouchPosition); - } - - [Fact] - public void FlashlightEntity_Management() - { - // Test entity tracking dictionary behavior - var playerEntities = new Dictionary(); - var playerKey = "test_player"; - - // No entity initially - Assert.False(playerEntities.TryGetValue(playerKey, out _)); - - // Add entity - var light = new FakeLightEntity { IsValid = true }; - playerEntities[playerKey] = light; - Assert.True(playerEntities.TryGetValue(playerKey, out var retrieved)); - Assert.True(retrieved?.IsValid); - - // Remove entity - playerEntities.Remove(playerKey); - Assert.False(playerEntities.TryGetValue(playerKey, out _)); - } - - private class FakeLightEntity - { - public bool IsValid { get; set; } - public void Remove() => IsValid = false; - } -} \ No newline at end of file +} diff --git a/Flashlight/Flashlight.cs b/Flashlight/Flashlight.cs index f162ba7..75935b9 100644 --- a/Flashlight/Flashlight.cs +++ b/Flashlight/Flashlight.cs @@ -1,149 +1,127 @@ ๏ปฟusing System.Drawing; +using System.Numerics; using CounterStrikeSharp.API; using CounterStrikeSharp.API.Core; using CounterStrikeSharp.API.Core.Attributes; using CounterStrikeSharp.API.Core.Attributes.Registration; using CounterStrikeSharp.API.Modules.Commands; -using Vector = CounterStrikeSharp.API.Modules.Utils.Vector; +using CounterStrikeSharp.API.Modules.Utils; +using Microsoft.Extensions.Logging; namespace Flashlight; -[MinimumApiVersion(363)] -public class Flashlight : BasePlugin +[MinimumApiVersion(371)] +public class FlashlightPlugin : BasePlugin, IPluginConfig { public override string ModuleAuthor => "creazy.eth"; public override string ModuleName => "Flashlight"; public override string ModuleDescription => "Flashlight for Counter-Strike 2"; - public override string ModuleVersion => "0.0.7"; + public override string ModuleVersion => "0.1.0"; - private static string ModuleDisplayName => "Flashlight"; - - // TODO: Change crouch-tracking to a more elegant solution - // TODO: Add config and make light entity values configurable - // TODO: Maybe replace light_omni2 with light_rect or something else - // FIXED: EyeAngles -> V_angle for CSS API v1.0.363+ compatibility - // FIXED: Entity validity check before Remove() to prevent crashes on team switch (Issue #2) + public FlashlightConfig Config { get; set; } = new(); - public static Flashlight? Instance { get; private set; } - - private readonly List _connectedPlayers = new(); - private readonly Dictionary _playerUsingFlashlight = new(); - private readonly Dictionary _playerIsCrouching = new(); - private readonly Dictionary _playerCanToggle = new(); - private readonly Dictionary _playerFlashlight = new(); + private readonly Dictionary _playerStates = new(); + + public void OnConfigParsed(FlashlightConfig config) + { + config.Clamp(); + Config = config; + } public override void Load(bool hotReload) { - Instance = this; - - LogHelper.LogToConsole(ConsoleColor.Green, $"{ModuleName} v{ModuleVersion} loading..."); - - RegisterListener(() => - { - foreach (var player in _connectedPlayers.Where(player => player is { IsValid: true, IsBot: false, PawnIsAlive: true })) - { - ToggleFlashlight(player); - - if (_playerCanToggle[player] == false) continue; - - if ((player.Buttons & PlayerButtons.Use) != 0) - { - _playerCanToggle[player] = false; - var currentPlayer = player; // Capture for closure - - if (_playerUsingFlashlight[player] == false) - { - _playerUsingFlashlight[player] = true; - - Instance?.AddTimer(0.25f, () => - { - _playerCanToggle[currentPlayer] = true; - }); - } - else - { - _playerUsingFlashlight[player] = false; - - Instance?.AddTimer(0.25f, () => - { - _playerCanToggle[currentPlayer] = true; - }); - } - } - - if ((player.Buttons & PlayerButtons.Duck) != 0) - { - _playerIsCrouching[player] = true; - } - else - { - _playerIsCrouching[player] = false; - } - } - }); + Logger.LogInformation("{Name} v{Version} loading...", ModuleName, ModuleVersion); - LogHelper.LogToConsole($"{ModuleDisplayName} v{ModuleVersion} loaded!"); + RegisterListener(OnTick); + + if (hotReload) + { + foreach (var player in Utilities.GetPlayers().Where(IsEligiblePlayer)) + { + EnsureState(player); + } + } + + Logger.LogInformation("{Name} v{Version} loaded!", ModuleName, ModuleVersion); } - + + public override void Unload(bool hotReload) + { + foreach (var slot in _playerStates.Keys.ToList()) + { + DestroyLight(slot); + } + + _playerStates.Clear(); + } + + private void OnTick() + { + if (!Config.Enabled || !Config.AllowUseKey) + { + return; + } + + foreach (var player in Utilities.GetPlayers()) + { + if (!IsEligiblePlayer(player) || !player.PawnIsAlive) + { + continue; + } + + var state = EnsureState(player); + var usePressed = (player.Buttons & PlayerButtons.Use) != 0; + + if (FlashlightLogic.IsUsePressedEdge(usePressed, state.WasUsePressed)) + { + TryToggleFlashlight(player, state); + } + + state.WasUsePressed = usePressed; + } + } + [GameEventHandler] public HookResult OnPlayerConnectFull(EventPlayerConnectFull @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _connectedPlayers.Add(player); - _playerUsingFlashlight[player] = false; - _playerIsCrouching[player] = false; - _playerCanToggle[player] = true; - - LogHelper.LogToConsole(ConsoleColor.Green, $"{player.PlayerName} connected"); - + if (player is null || !IsEligiblePlayer(player)) + { + return HookResult.Continue; + } + + EnsureState(player); + Logger.LogInformation("{Player} connected", player.PlayerName); return HookResult.Continue; } - + [GameEventHandler] public HookResult OnPlayerDisconnect(EventPlayerDisconnect @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _connectedPlayers.Remove(player); - _playerUsingFlashlight.Remove(player); - _playerIsCrouching.Remove(player); - _playerCanToggle.Remove(player); - - _playerFlashlight.TryGetValue(player, out var flashlight); - // Fix #2: Check entity validity before removing - if (flashlight != null && flashlight.IsValid) + if (player is null || !player.IsValid) { - flashlight.Remove(); + return HookResult.Continue; } - _playerFlashlight.Remove(player); - - LogHelper.LogToConsole(ConsoleColor.Green, $"{player.PlayerName} disconnected"); - + + var slot = player.Slot; + DestroyLight(slot); + _playerStates.Remove(slot); return HookResult.Continue; } - + [GameEventHandler] public HookResult OnPlayerSpawn(EventPlayerSpawn @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - if (_connectedPlayers.Contains(player) == false) + if (player is null || !IsEligiblePlayer(player)) { - _connectedPlayers.Add(player); - _playerUsingFlashlight[player] = false; - _playerIsCrouching[player] = false; - _playerCanToggle[player] = true; + return HookResult.Continue; } - - _playerUsingFlashlight[player] = false; - + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } @@ -151,108 +129,173 @@ public class Flashlight : BasePlugin public HookResult OnPlayerDeath(EventPlayerDeath @event, GameEventInfo info) { var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - _playerUsingFlashlight[player] = false; - + if (player is null || !IsEligiblePlayer(player)) + { + return HookResult.Continue; + } + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } [GameEventHandler] public HookResult OnPlayerTeam(EventPlayerTeam @event, GameEventInfo info) { - // Fix #2: Handle team switch - clean up flashlight entity var player = @event.Userid; - - if (!player.IsValid || player.IsBot) return HookResult.Continue; - - // Turn off flashlight and clean up entity when switching teams - _playerUsingFlashlight[player] = false; - - if (_playerFlashlight.TryGetValue(player, out var flashlight)) + if (player is null || !IsEligiblePlayer(player)) { - if (flashlight != null && flashlight.IsValid) - { - flashlight.Remove(); - } - _playerFlashlight.Remove(player); + return HookResult.Continue; } - + + var state = EnsureState(player); + state.IsOn = false; + DestroyLight(player.Slot); return HookResult.Continue; } - public void ToggleFlashlight(CCSPlayerController player) - { - if (_playerUsingFlashlight[player] == false) - { - if (_playerFlashlight.TryGetValue(player, out var value)) - { - // Fix #2: Check entity validity before removing - if (value.IsValid) - { - value.Remove(); - } - _playerFlashlight.Remove(player); - } - - return; - } - - var entity = _playerFlashlight.TryGetValue(player, out var flashlight) ? flashlight : Utilities.CreateEntityByName("light_omni2"); - - if (entity == null || !entity.IsValid) - { - LogHelper.LogToConsole("Failed to create entity!"); - return; - } - - entity.DirectLight = 3; - - var pawn = player.PlayerPawn.Value; - if (pawn?.AbsOrigin == null || pawn.V_angle == null) - { - LogHelper.LogToConsole("Failed to get player pawn data!"); - return; - } - - entity.Teleport( - new Vector( - pawn.AbsOrigin.X, - pawn.AbsOrigin.Y, - pawn.AbsOrigin.Z + (_playerIsCrouching[player] ? 46.03f : 64.03f) - ), - pawn.V_angle, - pawn.AbsVelocity - ); - - entity.OuterAngle = 45f; - entity.Enabled = true; - entity.Color = Color.White; - entity.ColorTemperature = 6500; - entity.Brightness = 1f; - entity.Range = 5000f; - - entity.DispatchSpawn(); - _playerFlashlight[player] = entity; - } - [ConsoleCommand("css_fl_toggle", "Toggles the flashlight")] [CommandHelper(whoCanExecute: CommandUsage.CLIENT_ONLY)] - public void ToggleFlashlight(CCSPlayerController caller, CommandInfo? info) + public void OnToggleCommand(CCSPlayerController? caller, CommandInfo info) { - if (!caller.IsValid || !caller.PawnIsAlive) return; - - if (_playerCanToggle[caller] == false) return; - - _playerUsingFlashlight[caller] = !_playerUsingFlashlight[caller]; - _playerCanToggle[caller] = false; - - var currentCaller = caller; // Capture for closure - - Instance?.AddTimer(0.25f, () => + if (!Config.Enabled || caller is null || !IsEligiblePlayer(caller) || !caller.PawnIsAlive) { - _playerCanToggle[currentCaller] = true; + return; + } + + TryToggleFlashlight(caller, EnsureState(caller)); + } + + private void TryToggleFlashlight(CCSPlayerController player, PlayerFlashlightState state) + { + var isOn = state.IsOn; + var canToggle = state.CanToggle; + + if (!FlashlightLogic.TryToggle(ref isOn, ref canToggle)) + { + return; + } + + state.IsOn = isOn; + state.CanToggle = canToggle; + + if (state.IsOn) + { + CreateAndParentLight(player, state); + } + else + { + DestroyLight(player.Slot); + } + + var slot = player.Slot; + AddTimer(Config.ToggleCooldownSeconds, () => + { + if (_playerStates.TryGetValue(slot, out var current)) + { + current.CanToggle = true; + } }); } -} \ No newline at end of file + + private void CreateAndParentLight(CCSPlayerController player, PlayerFlashlightState state) + { + DestroyLight(player.Slot); + + var pawn = player.PlayerPawn.Value; + if (pawn is null || !pawn.IsValid || pawn.AbsOrigin is null || pawn.V_angle is null) + { + Logger.LogWarning("Failed to get pawn data for {Player}", player.PlayerName); + state.IsOn = false; + return; + } + + var light = Utilities.CreateEntityByName("light_barn"); + if (light is null || !light.IsValid) + { + Logger.LogWarning("Failed to create light_barn for {Player}", player.PlayerName); + state.IsOn = false; + return; + } + + var isCrouching = (player.Buttons & PlayerButtons.Duck) != 0; + var eyeOffsetZ = FlashlightLogic.GetEyeOffsetZ( + isCrouching, + Config.StandEyeOffsetZ, + Config.CrouchEyeOffsetZ); + + var angles = pawn.V_angle; + var forward = FlashlightLogic.ForwardFromAnglesDegrees(angles.X, angles.Y); + var origin = FlashlightLogic.CalculateLightOrigin( + new Vector3(pawn.AbsOrigin.X, pawn.AbsOrigin.Y, pawn.AbsOrigin.Z), + forward, + eyeOffsetZ, + Config.ForwardDistance); + + light.Enabled = true; + light.Color = Color.FromArgb(255, Config.ColorR, Config.ColorG, Config.ColorB); + light.ColorTemperature = Config.ColorTemperature; + light.Brightness = Config.Brightness; + light.Range = Config.Range; + light.SoftX = Config.SoftX; + light.SoftY = Config.SoftY; + light.Skirt = Config.Skirt; + light.SkirtNear = Config.SkirtNear; + light.CastShadows = Config.CastShadows ? 1 : 0; + light.DirectLight = 3; + light.SizeParams.X = Config.SizeX; + light.SizeParams.Y = Config.SizeY; + light.SizeParams.Z = Config.SizeZ; + + light.Teleport( + origin, + new Vector3(angles.X, angles.Y, angles.Z), + null); + + using (var keyValues = new CEntityKeyValues()) + { + keyValues.SetString("lightcookie", Config.LightCookie); + light.DispatchSpawn(keyValues); + } + + light.AcceptInput("SetParent", pawn, light, "!activator"); + light.AcceptInput("SetParentAttachmentMaintainOffset", null, null, Config.AttachmentName); + + state.Light = light; + state.IsOn = true; + } + + private void DestroyLight(int slot) + { + if (!_playerStates.TryGetValue(slot, out var state)) + { + return; + } + + var light = state.Light; + state.Light = null; + + if (light is not null && light.IsValid) + { + light.Remove(); + } + } + + private PlayerFlashlightState EnsureState(CCSPlayerController player) + { + if (_playerStates.TryGetValue(player.Slot, out var state)) + { + return state; + } + + state = new PlayerFlashlightState(); + _playerStates[player.Slot] = state; + return state; + } + + private static bool IsEligiblePlayer(CCSPlayerController? player) + { + return player is { IsValid: true, IsBot: false }; + } +} diff --git a/Flashlight/Flashlight.csproj b/Flashlight/Flashlight.csproj index 5b93a8e..2be7208 100644 --- a/Flashlight/Flashlight.csproj +++ b/Flashlight/Flashlight.csproj @@ -1,13 +1,13 @@ - net8.0 + net10.0 enable enable - + diff --git a/Flashlight/FlashlightConfig.cs b/Flashlight/FlashlightConfig.cs new file mode 100644 index 0000000..14c131f --- /dev/null +++ b/Flashlight/FlashlightConfig.cs @@ -0,0 +1,101 @@ +using System.Text.Json.Serialization; +using CounterStrikeSharp.API.Core; + +namespace Flashlight; + +public class FlashlightConfig : BasePluginConfig +{ + [JsonPropertyName("Enabled")] + public bool Enabled { get; set; } = true; + + [JsonPropertyName("AllowUseKey")] + public bool AllowUseKey { get; set; } = true; + + [JsonPropertyName("ToggleCooldownSeconds")] + public float ToggleCooldownSeconds { get; set; } = 0.25f; + + [JsonPropertyName("Brightness")] + public float Brightness { get; set; } = 1.0f; + + [JsonPropertyName("Range")] + public float Range { get; set; } = 2048f; + + [JsonPropertyName("ColorR")] + public byte ColorR { get; set; } = 255; + + [JsonPropertyName("ColorG")] + public byte ColorG { get; set; } = 255; + + [JsonPropertyName("ColorB")] + public byte ColorB { get; set; } = 255; + + [JsonPropertyName("ColorTemperature")] + public float ColorTemperature { get; set; } = 6500f; + + [JsonPropertyName("CastShadows")] + public bool CastShadows { get; set; } = true; + + [JsonPropertyName("SoftX")] + public float SoftX { get; set; } = 1.0f; + + [JsonPropertyName("SoftY")] + public float SoftY { get; set; } = 1.0f; + + [JsonPropertyName("Skirt")] + public float Skirt { get; set; } = 0.5f; + + [JsonPropertyName("SkirtNear")] + public float SkirtNear { get; set; } = 1.0f; + + [JsonPropertyName("SizeX")] + public float SizeX { get; set; } = 45f; + + [JsonPropertyName("SizeY")] + public float SizeY { get; set; } = 45f; + + [JsonPropertyName("SizeZ")] + public float SizeZ { get; set; } = 0.03f; + + [JsonPropertyName("ForwardDistance")] + public float ForwardDistance { get; set; } = 54f; + + [JsonPropertyName("StandEyeOffsetZ")] + public float StandEyeOffsetZ { get; set; } = 64f; + + [JsonPropertyName("CrouchEyeOffsetZ")] + public float CrouchEyeOffsetZ { get; set; } = 46f; + + [JsonPropertyName("AttachmentName")] + public string AttachmentName { get; set; } = "axis_of_intent"; + + [JsonPropertyName("LightCookie")] + public string LightCookie { get; set; } = "materials/effects/lightcookies/flashlight.vtex"; + + public void Clamp() + { + ToggleCooldownSeconds = Math.Max(0f, ToggleCooldownSeconds); + Brightness = Math.Max(0f, Brightness); + Range = Math.Max(1f, Range); + ColorTemperature = Math.Clamp(ColorTemperature, 1000f, 12000f); + SoftX = Math.Max(0f, SoftX); + SoftY = Math.Max(0f, SoftY); + Skirt = Math.Max(0f, Skirt); + SkirtNear = Math.Max(0f, SkirtNear); + SizeX = Math.Max(0f, SizeX); + SizeY = Math.Max(0f, SizeY); + SizeZ = Math.Max(0f, SizeZ); + ForwardDistance = Math.Max(0f, ForwardDistance); + StandEyeOffsetZ = Math.Max(0f, StandEyeOffsetZ); + CrouchEyeOffsetZ = Math.Max(0f, CrouchEyeOffsetZ); + + if (string.IsNullOrWhiteSpace(AttachmentName)) + { + AttachmentName = "axis_of_intent"; + } + + if (string.IsNullOrWhiteSpace(LightCookie)) + { + LightCookie = "materials/effects/lightcookies/flashlight.vtex"; + } + } +} diff --git a/Flashlight/FlashlightLogic.cs b/Flashlight/FlashlightLogic.cs new file mode 100644 index 0000000..f938225 --- /dev/null +++ b/Flashlight/FlashlightLogic.cs @@ -0,0 +1,62 @@ +using System.Numerics; + +namespace Flashlight; + +public static class FlashlightLogic +{ + public static bool TryToggle(ref bool isOn, ref bool canToggle) + { + if (!canToggle) + { + return false; + } + + isOn = !isOn; + canToggle = false; + return true; + } + + public static bool IsUsePressedEdge(bool usePressed, bool wasUsePressed) + { + return usePressed && !wasUsePressed; + } + + public static float GetEyeOffsetZ(bool isCrouching, float standOffset, float crouchOffset) + { + return isCrouching ? crouchOffset : standOffset; + } + + public static Vector3 CalculateLightOrigin( + Vector3 pawnOrigin, + Vector3 forward, + float eyeOffsetZ, + float forwardDistance) + { + return new Vector3( + pawnOrigin.X + forward.X * forwardDistance, + pawnOrigin.Y + forward.Y * forwardDistance, + pawnOrigin.Z + eyeOffsetZ + forward.Z * forwardDistance); + } + + public static Vector3 ForwardFromAnglesDegrees(float pitch, float yaw) + { + var pitchRad = pitch * (MathF.PI / 180f); + var yawRad = yaw * (MathF.PI / 180f); + var cosPitch = MathF.Cos(pitchRad); + + return new Vector3( + cosPitch * MathF.Cos(yawRad), + cosPitch * MathF.Sin(yawRad), + -MathF.Sin(pitchRad)); + } + + public static bool ShouldCreateLight(bool isOn, bool hasValidLight) + { + return isOn && !hasValidLight; + } + + public static bool ShouldDestroyLight(bool isOn, bool hasValidLight) + { + return !isOn && hasValidLight; + } +} diff --git a/Flashlight/LogHelper.cs b/Flashlight/LogHelper.cs deleted file mode 100644 index 4ccdd0b..0000000 --- a/Flashlight/LogHelper.cs +++ /dev/null @@ -1,56 +0,0 @@ -using CounterStrikeSharp.API; -using CounterStrikeSharp.API.Core; -using CounterStrikeSharp.API.Modules.Utils; - -namespace Flashlight; - -public static class LogHelper -{ - public static void LogToConsole(string messageToLog) - { - Console.WriteLine($"[{Flashlight.Instance?.ModuleName}] -> {messageToLog}"); - } - - public static void LogToConsole(ConsoleColor color, string messageToLog) - { - Console.ForegroundColor = color; - Console.WriteLine($"[{Flashlight.Instance?.ModuleName}] -> {messageToLog}"); - Console.ResetColor(); - } - - public static void LogToChat(CCSPlayerController? player, string messageToLog) - { - player?.PrintToChat($"[{ChatColors.Purple}{Flashlight.Instance?.ModuleName}{ChatColors.Default}]{ReplaceTags(messageToLog + "{DEFAULT}")}"); - } - - public static void LogToChatAll(string messageToLog) - { - Server.PrintToChatAll($"[{ChatColors.Purple}{Flashlight.Instance?.ModuleName}{ChatColors.Default}]{ReplaceTags(messageToLog + "{DEFAULT}")}"); - } - - public static string ReplaceTags(this string text) - { - if (text.StartsWith("{")) text = $" {text}"; - text = text.Replace("{DEFAULT}", $"{ChatColors.Default}"); - text = text.Replace("{WHITE}", $"{ChatColors.White}"); - text = text.Replace("{DARKRED}", $"{ChatColors.Darkred}"); - text = text.Replace("{GREEN}", $"{ChatColors.Green}"); - text = text.Replace("{LIGHTYELLOW}", $"{ChatColors.LightYellow}"); - text = text.Replace("{LIGHTBLUE}", $"{ChatColors.LightBlue}"); - text = text.Replace("{OLIVE}", $"{ChatColors.Olive}"); - text = text.Replace("{LIME}", $"{ChatColors.Lime}"); - text = text.Replace("{RED}", $"{ChatColors.Red}"); - text = text.Replace("{PURPLE}", $"{ChatColors.Purple}"); - text = text.Replace("{GREY}", $"{ChatColors.Grey}"); - text = text.Replace("{YELLOW}", $"{ChatColors.Yellow}"); - text = text.Replace("{GOLD}", $"{ChatColors.Gold}"); - text = text.Replace("{SILVER}", $"{ChatColors.Silver}"); - text = text.Replace("{BLUE}", $"{ChatColors.Blue}"); - text = text.Replace("{DARKBLUE}", $"{ChatColors.DarkBlue}"); - text = text.Replace("{BLUEGREY}", $"{ChatColors.BlueGrey}"); - text = text.Replace("{MAGENTA}", $"{ChatColors.Magenta}"); - text = text.Replace("{LIGHTRED}", $"{ChatColors.LightRed}"); - - return text; - } -} \ No newline at end of file diff --git a/Flashlight/PlayerFlashlightState.cs b/Flashlight/PlayerFlashlightState.cs new file mode 100644 index 0000000..ff2a9e6 --- /dev/null +++ b/Flashlight/PlayerFlashlightState.cs @@ -0,0 +1,18 @@ +using CounterStrikeSharp.API.Core; + +namespace Flashlight; + +public sealed class PlayerFlashlightState +{ + public bool IsOn { get; set; } + public bool CanToggle { get; set; } = true; + public bool WasUsePressed { get; set; } + public CBarnLight? Light { get; set; } + + public void Reset() + { + IsOn = false; + CanToggle = true; + WasUsePressed = false; + } +} diff --git a/README.md b/README.md index 885ce66..f866d8e 100644 --- a/README.md +++ b/README.md @@ -1,68 +1,105 @@ -# ๐Ÿ”ฆ Flashlight +# Flashlight -Flashlight is a plugin for Counter-Strike 2 that adds a flashlight feature for players. It is written in C# and uses the CounterStrikeSharp API. ๐ŸŽฎ +Flashlight is a Counter-Strike 2 server plugin written in C# with [CounterStrikeSharp](https://docs.cssharp.dev). It gives human players a toggleable flashlight using a parented `light_barn` entity. -## โญ Features +## Features -- ๐Ÿ’ก Players can toggle the flashlight on and off using `Use` key (the default key for this is `E`) or `/fl_toggle` in chat which could be bound to a different key. -- ๐Ÿ’€ The flashlight is automatically turned off when the player dies or respawns. -- ๐Ÿšซ The flashlight is only available to human players, not bots. +- Toggle with the Use key (`E` by default) or `css_fl_toggle` +- One `light_barn` per player, parented to the pawn attachment (no per-tick spawn/teleport) +- Configurable brightness, range, color, shadows, offsets, and attachment +- Automatically turns off on death, spawn, and team change +- Bots ignored -## ๐Ÿ”ง Installation +## Requirements -1. โฌ‡๏ธ Download the latest release from the GitHub repository. -2. ๐Ÿ“ Extract the ZIP file. -3. ๐Ÿ“‚ Place the plugin in the `game/csgo/addons/counterstrikesharp/plugins/Flashlight` directory. +- CounterStrikeSharp **1.0.371+** (API minimum version enforced) +- .NET **10** runtime as provided by your CounterStrikeSharp install -## ๐Ÿ’ป Usage +## Installation -โŒจ๏ธ Use the `Use` key to toggle the flashlight on and off. The default key for this is `E`. Or use the `/fl_toggle` command in chat which could then be bound to a different key. +1. Download the latest release ZIP. +2. Extract it. +3. Place the plugin folder in `game/csgo/addons/counterstrikesharp/plugins/Flashlight`. + +## Usage + +- Press Use (`E`) to toggle, if enabled in config. +- Or bind a key: -Example bind: ``` bind f "css_fl_toggle" ``` -## ๐Ÿ› ๏ธ Development +## Configuration + +On first load, CounterStrikeSharp writes a JSON config for the plugin. Defaults: + +| Key | Default | Description | +| --- | --- | --- | +| `Enabled` | `true` | Master switch | +| `AllowUseKey` | `true` | Allow Use-key toggle | +| `ToggleCooldownSeconds` | `0.25` | Toggle cooldown | +| `Brightness` | `1.0` | Light brightness | +| `Range` | `2048` | Light range | +| `ColorR` / `ColorG` / `ColorB` | `255` | Light color | +| `ColorTemperature` | `6500` | Kelvin temperature | +| `CastShadows` | `true` | Cast dynamic shadows | +| `SoftX` / `SoftY` | `1.0` | Softness | +| `Skirt` / `SkirtNear` | `0.5` / `1.0` | Skirt falloff | +| `SizeX` / `SizeY` / `SizeZ` | `45` / `45` / `0.03` | Beam size params | +| `ForwardDistance` | `54` | Spawn offset along view forward | +| `StandEyeOffsetZ` | `64` | Standing eye height offset | +| `CrouchEyeOffsetZ` | `46` | Crouching eye height offset | +| `AttachmentName` | `axis_of_intent` | Parent attachment | +| `LightCookie` | `materials/effects/lightcookies/flashlight.vtex` | Flashlight cookie texture | + +## Development ### Prerequisites -- .NET 8.0 SDK -- CounterStrikeSharp API v1.0.363+ +- .NET 10 SDK +- CounterStrikeSharp.API 1.0.371+ -### Building +### Build ```bash dotnet restore dotnet build ``` -### Testing +### Test ```bash dotnet test ``` -## ๐Ÿค Contributing +Unit tests cover toggle/cooldown logic, Use-key edge detection, origin math, create/destroy policy, and config clamping. Entity parenting requires a live CS2 server. -Contributions are welcome. Please open an issue or submit a pull request on GitHub. ๐Ÿ™ +## Changelog -## ๐Ÿ“‹ Changelog +### v0.1.0 -### v0.0.6 (Latest) -- โœ… Updated to .NET 8.0 -- โœ… Updated to CounterStrikeSharp.API v1.0.363 -- โœ… Added xUnit test project with core logic tests -- โœ… Updated GitHub Actions workflow with testing -- โœ… Improved code compatibility with latest CS2/CSS API +- Updated to .NET 10 and CounterStrikeSharp.API 1.0.371 +- Replaced per-tick `light_omni2` spawn/teleport with parented `light_barn` +- Added `IPluginConfig` settings for light and toggle behavior +- Added focused xUnit tests for pure helpers +- Updated GitHub Actions for .NET 10, PR tests, and tag releases +- Switched logging to `BasePlugin.Logger` + +### v0.0.6 + +- Updated to .NET 8.0 and CounterStrikeSharp.API v1.0.363 +- Added initial xUnit test project +- Improved CSS API compatibility (`V_angle`, entity validity checks) ### v0.0.5 + - Initial release -## ๐Ÿ“ƒ License +## License -This project is licensed under the GNU General Public License. โš–๏ธ +GNU General Public License. See `LICENSE`. -## โœ๏ธ Author +## Author -This project was created by [creazy.eth](https://github.com/creazy231) \ No newline at end of file +[creazy.eth](https://github.com/creazy231) diff --git a/docs/superpowers/plans/2026-07-21-flashlight-modernization.md b/docs/superpowers/plans/2026-07-21-flashlight-modernization.md new file mode 100644 index 0000000..edbae99 --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-flashlight-modernization.md @@ -0,0 +1,61 @@ +# Flashlight Modernization Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Modernize the flashlight plugin to .NET 10 / CSS 1.0.371 with a parented `light_barn` implementation, config, real unit tests, fixed CI, and updated README. + +**Architecture:** Pure helpers in `FlashlightLogic` + config clamps in `FlashlightConfig`; plugin owns lifecycle and wires Use/command/events; one `CBarnLight` per active player, parented once, toggled via create/destroy. + +**Tech Stack:** .NET 10, CounterStrikeSharp.API 1.0.371, xUnit, GitHub Actions + +## Global Constraints + +- Target framework: `net10.0` +- Package: `CounterStrikeSharp.API` `1.0.371` +- `[MinimumApiVersion(371)]` +- Module version: `0.1.0` +- No per-tick entity create/spawn/teleport in steady state +- Spec: `docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md` + +--- + +### Task 1: Project targets + pure logic + tests + +**Files:** +- Modify: `Flashlight/Flashlight.csproj` +- Modify: `Flashlight.Tests/Flashlight.Tests.csproj` +- Create: `Flashlight/FlashlightConfig.cs` +- Create: `Flashlight/FlashlightLogic.cs` +- Create: `Flashlight/PlayerFlashlightState.cs` +- Modify: `Flashlight.Tests/FlashlightLogicTests.cs` + +- [x] Retarget both projects to `net10.0`; bump CSS to `1.0.371`; bump test packages as needed +- [x] Implement `FlashlightConfig` with defaults from the spec and a `Clamp()` method +- [x] Implement pure `FlashlightLogic` helpers: try-toggle with cooldown, eye Z offset, origin from base+forward +- [x] Replace smoke tests with tests against those helpers +- [x] Run `dotnet test` and confirm pass + +### Task 2: Plugin rewrite (parented light_barn) + +**Files:** +- Modify: `Flashlight/Flashlight.cs` +- Delete or gut: `Flashlight/LogHelper.cs` (prefer `Logger`) + +- [x] Implement `BasePlugin, IPluginConfig` +- [x] OnTick: Use-key edge + cooldown only when `AllowUseKey` +- [x] Create/parent/enable `light_barn` once on toggle on; remove on toggle off +- [x] Cleanup on death/spawn/team/disconnect/unload +- [x] Keep `css_fl_toggle` +- [x] Run `dotnet build` and confirm success + +### Task 3: CI + README + +**Files:** +- Modify: `.github/workflows/build.yml` +- Modify: `README.md` + +- [x] CI: .NET 10, restore, test, release build +- [x] README: versions, parented light, config table, build/test, changelog 0.1.0 +- [x] Run `dotnet test` once more + +--- diff --git a/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md b/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md new file mode 100644 index 0000000..dcc2079 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-flashlight-modernization-design.md @@ -0,0 +1,148 @@ +# Flashlight Plugin Modernization Design + +**Date:** 2026-07-21 +**Status:** Approved +**Version target:** 0.1.0 + +## Goal + +Modernize the CounterStrikeSharp flashlight plugin to the latest CSS/.NET stack, replace the per-tick spawn/teleport light path with a parented `light_barn` implementation, add `IPluginConfig` for admin tuning, improve testability, and update the README. + +## Background + +The current plugin (`Flashlight` v0.0.7) targets CounterStrikeSharp.API `1.0.363` on .NET 8. While the flashlight is on, `OnTick` creates a `light_omni2` and calls `DispatchSpawn` every server frame. That is the dominant performance problem. Existing tests only exercise inlined dictionary/bool logic and do not cover extractable plugin helpers. CI still installs .NET 7. + +Latest CounterStrikeSharp.API is `1.0.371` and targets .NET 10. CS2Fixes demonstrates the preferred flashlight pattern: spawn `light_barn` once, set a flashlight lightcookie via entity keyvalues, parent to the player pawn attachment, and toggle enablement rather than teleporting every tick. + +## Decisions + +| Topic | Decision | +| --- | --- | +| Runtime | .NET 10 + CounterStrikeSharp.API 1.0.371 | +| Light entity | `light_barn` (`CBarnLight`), parented once | +| Position updates | Engine parenting; no per-tick teleport | +| Config | `IPluginConfig` with JSON config | +| Logging | Prefer `BasePlugin.Logger` | +| README | Fully updated for new runtime, behavior, config, build/test | +| Module version | 0.1.0 | + +## Architecture + +``` +FlashlightPlugin (BasePlugin, IPluginConfig) + โ”œโ”€โ”€ FlashlightConfig // JSON-backed settings + clamps + โ”œโ”€โ”€ PlayerFlashlightState // per-player flags + entity handle + โ”œโ”€โ”€ FlashlightService // create / parent / enable / destroy + โ””โ”€โ”€ FlashlightLogic // pure helpers (toggle, cooldown, offsets) +``` + +### Runtime flow + +1. `OnConfigParsed` validates/clamps config. +2. `Load` registers `OnTick` (Use-key edge + cooldown only), game event handlers, and command `css_fl_toggle`. +3. When a player turns the light on: + - Create `CBarnLight` via `Utilities.CreateEntityByName("light_barn")`. + - Apply config (brightness, range, color, temperature, soft/skirt/size, cast shadows, direct light). + - Compute initial origin: pawn origin + eye Z offset + forward * `ForwardDistance`. + - `Teleport` once using `System.Numerics.Vector3` overloads (avoid legacy `Vector` allocs). + - `DispatchSpawn(CEntityKeyValues)` with `lightcookie` = configured path (default flashlight vtex). + - `AcceptInput("SetParent", pawn, โ€ฆ)` then `AcceptInput("SetParentAttachmentMaintainOffset", โ€ฆ, AttachmentName)`. + - Set `Enabled = true`. +4. When turned off: set `Enabled = false` and remove the entity (or disable and keep โ€” prefer remove to avoid orphaned entities across pawn changes). +5. Cleanup on death, team change, disconnect, and plugin unload. + +### Why not keep tick teleport? + +Parenting follows view/attachment with far less managed work and no entity churn. Tick work is limited to scanning connected humans for Use-button edges and cooldown expiry. + +## Config surface + +File written/loaded by CSS config system (standard plugin config JSON). + +| Key | Type | Default | Notes | +| --- | --- | --- | --- | +| `Enabled` | bool | `true` | Master switch | +| `AllowUseKey` | bool | `true` | Toggle via Use (`E`) | +| `ToggleCooldownSeconds` | float | `0.25` | Clamp โ‰ฅ 0 | +| `Brightness` | float | `1.0` | | +| `Range` | float | `2048` | Match CS2Fixes-style defaults | +| `ColorR` / `ColorG` / `ColorB` | byte | `255` | White | +| `ColorTemperature` | float | `6500` | | +| `CastShadows` | bool | `true` | Maps to `CastShadows` int | +| `SoftX` / `SoftY` | float | `1.0` | | +| `Skirt` | float | `0.5` | | +| `SkirtNear` | float | `1.0` | | +| `SizeX` / `SizeY` / `SizeZ` | float | `45` / `45` / `0.03` | `SizeParams` | +| `ForwardDistance` | float | `54` | Avoid AWP blocking beam | +| `StandEyeOffsetZ` | float | `64` | | +| `CrouchEyeOffsetZ` | float | `46` | Used at spawn time only | +| `AttachmentName` | string | `axis_of_intent` | Parent attachment | +| `LightCookie` | string | `materials/effects/lightcookies/flashlight.vtex` | | + +Invalid values are clamped or rejected in `OnConfigParsed` with log warnings; plugin remains loadable when possible. + +## Player state + +Replace multiple `Dictionary` maps with one structure keyed by player slot (or controller), holding: + +- `IsOn` +- `CanToggle` +- `Light` (`CBarnLight?`) + +Crouch tracking for continuous Z updates is unnecessary once the light is parented; crouch offset is only applied at creation time. Optional: read duck state at spawn for initial Z only. + +## Event / command behavior (unchanged UX) + +- Use key toggles when `AllowUseKey` is true and cooldown allows. +- `css_fl_toggle` remains client-only command alternative. +- Flashlight turns off on death and spawn; entity cleaned on team change and disconnect. +- Bots ignored. + +## Testing strategy + +Full CSS entity lifecycle cannot be unit-tested without a game server. Extract and test pure logic: + +1. Toggle state transitions and cooldown gating. +2. Config clamp helpers (range, cooldown, color channels). +3. Initial position offset calculation (stand/crouch Z + forward distance given basis vectors). +4. Enable/disable policy: when entity should be created vs destroyed. + +Use xUnit on .NET 10. Keep Moq only if needed; prefer plain helpers over mocking CSS types. + +## CI / packaging + +- GitHub Actions: .NET 10 SDK, `dotnet restore`, `dotnet test`, release build on tags. +- Remove stale .NET 7 setup. +- Publish zip layout unchanged: `plugins/Flashlight/`. + +## README updates + +- Prerequisites: .NET 10, CSS 1.0.371+. +- Behavior: parented `light_barn`, Use + command. +- Config table with defaults. +- Build / test instructions. +- Changelog entry for 0.1.0 (API bump, performance rewrite, config, tests, CI). + +## Out of scope + +- Particle flashlight mode (CS2Fixes mode 2). +- Admin permissions / VIP-only flashlight. +- Client-side HUD indicators. +- Migrating to CounterStrikeSharp 2.0 alpha. + +## Risks / mitigations + +| Risk | Mitigation | +| --- | --- | +| Attachment name missing on some models | Configurable `AttachmentName`; fall back to parent-only if attachment input fails | +| `CEntityKeyValues` lightcookie path differs | Use CS2Fixes-proven path; document override | +| .NET 10 server prerequisite | Document clearly; MinimumApiVersion 371 | +| Parenting breaks on pawn swap | Recreate light on spawn; destroy on death/team | + +## Success criteria + +- Builds against CounterStrikeSharp.API 1.0.371 on net10.0. +- No entity create/spawn/teleport in the steady-state OnTick path. +- Config file generated and honored. +- Unit tests cover pure helpers and pass in CI. +- README matches shipped behavior and versions.