cs2-css-flashlight/Flashlight.Tests/FlashlightLogicTests.cs
Tobias Thiele b43f9fdea3 🔦 Fix flashlight beam not tracking view pitch
The light was positioned once at toggle time and then handed to the engine
via SetParent + SetParentAttachmentMaintainOffset on the pawn's
`axis_of_intent` attachment. That attachment carries the body's yaw but not
the view pitch, so once parented the beam could only ever rotate
horizontally — looking up or down did nothing.

Drop the parenting and drive the light's transform ourselves every tick from
the pawn's live AbsOrigin and V_angle, which is how the plugin behaved before
0.1.1 replaced the per-tick teleport with a parented entity.

Also:
- OnTick no longer short-circuits on !AllowUseKey, which otherwise stopped
  the light updating for servers that only expose css_fl_toggle.
- ForwardDistance now offsets the light horizontally only. Applying it along
  the pitched forward vector put the light below ground when looking straight
  down (crouched eye height 46 minus 54).
- Sweep up the light when the pawn is dead; an un-parented entity no longer
  dies with its pawn.
- Remove the now-unused AttachmentName config key.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 08:50:29 +02:00

223 lines
6.9 KiB
C#

using System.Numerics;
using Xunit;
namespace Flashlight.Tests;
public class FlashlightLogicTests
{
[Fact]
public void TryToggle_TogglesWhenAllowed()
{
var isOn = false;
var canToggle = true;
Assert.True(FlashlightLogic.TryToggle(ref isOn, ref canToggle));
Assert.True(isOn);
Assert.False(canToggle);
}
[Fact]
public void TryToggle_BlockedDuringCooldown()
{
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 ForwardFromAnglesDegrees_PitchDrivesVerticalComponent()
{
// Source angles are inverted on pitch: positive pitch looks down.
Assert.True(FlashlightLogic.ForwardFromAnglesDegrees(45f, 0f).Z < 0f);
Assert.True(FlashlightLogic.ForwardFromAnglesDegrees(-45f, 0f).Z > 0f);
}
[Fact]
public void HorizontalForwardFromYawDegrees_HasNoVerticalComponent()
{
foreach (var yaw in new[] { -180f, -90f, -45f, 0f, 45f, 90f, 180f })
{
Assert.Equal(0f, FlashlightLogic.HorizontalForwardFromYawDegrees(yaw).Z, 5);
}
}
[Fact]
public void CalculateLightTransform_AnglesCarryFullViewRotation()
{
var transform = FlashlightLogic.CalculateLightTransform(
pawnOrigin: new Vector3(0f, 0f, 0f),
pitch: -35f,
yaw: 90f,
roll: 12f,
eyeOffsetZ: 64f,
forwardDistance: 54f);
// Pitch must survive into the light's angles, otherwise the beam only tracks yaw.
Assert.Equal(-35f, transform.Angles.X, 3);
Assert.Equal(90f, transform.Angles.Y, 3);
Assert.Equal(12f, transform.Angles.Z, 3);
}
[Theory]
[InlineData(-89f)]
[InlineData(-45f)]
[InlineData(0f)]
[InlineData(45f)]
[InlineData(89f)]
public void CalculateLightTransform_PitchNeverMovesTheOrigin(float pitch)
{
var transform = FlashlightLogic.CalculateLightTransform(
new Vector3(0f, 0f, 0f),
pitch,
yaw: 0f,
roll: 0f,
eyeOffsetZ: 64f,
forwardDistance: 54f);
// The muzzle stays at eye height and eye-forward regardless of pitch, so
// looking up or down can never shove the light through the ceiling or floor.
Assert.Equal(54f, transform.Origin.X, 3);
Assert.Equal(0f, transform.Origin.Y, 3);
Assert.Equal(64f, transform.Origin.Z, 3);
}
[Fact]
public void CalculateLightTransform_YawDrivesHorizontalOffset()
{
var transform = FlashlightLogic.CalculateLightTransform(
new Vector3(10f, 20f, 30f),
pitch: 0f,
yaw: 90f,
roll: 0f,
eyeOffsetZ: 64f,
forwardDistance: 54f);
Assert.Equal(10f, transform.Origin.X, 3);
Assert.Equal(74f, transform.Origin.Y, 3);
Assert.Equal(94f, transform.Origin.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
{
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,
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("materials/effects/lightcookies/flashlight.vtex", config.LightCookie);
}
[Theory]
[InlineData("Any", 2, true)]
[InlineData("Any", 3, true)]
[InlineData("Any", 1, true)]
[InlineData("T", 2, true)]
[InlineData("T", 3, false)]
[InlineData("CT", 3, true)]
[InlineData("CT", 2, false)]
public void IsTeamAllowed_RespectsConfiguredSide(string allowedTeam, byte team, bool expected)
{
Assert.Equal(expected, FlashlightLogic.IsTeamAllowed(allowedTeam, team));
}
[Theory]
[InlineData("t", "T")]
[InlineData("Terrorist", "T")]
[InlineData("ct", "CT")]
[InlineData("CounterTerrorist", "CT")]
[InlineData("any", "Any")]
[InlineData("something-else", "Any")]
[InlineData(null, "Any")]
public void ConfigClamp_NormalizesAllowedTeam(string? input, string expected)
{
var config = new FlashlightConfig { AllowedTeam = input! };
config.Clamp();
Assert.Equal(expected, config.AllowedTeam);
}
}