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.