package main
import (
"math/rand"
"testing"
)
// TestNewCombat_initialState verifies IV3: all hull values in range, timers zero,
// result ongoing.
func TestNewCombat_initialState(t *testing.T) {
c := NewCombat()
if c.Player.Hull != 20 || c.Player.MaxHull != 20 {
t.Errorf("Player Hull=%d MaxHull=%d, want 20/20", c.Player.Hull, c.Player.MaxHull)
}
if c.Player.ShieldLayers != 0 {
t.Errorf("Player.ShieldLayers=%d, want 0", c.Player.ShieldLayers)
}
if c.Enemy.Hull != 15 || c.Enemy.MaxHull != 15 {
t.Errorf("Enemy Hull=%d MaxHull=%d, want 15/15", c.Enemy.Hull, c.Enemy.MaxHull)
}
if c.PlayerWeaponT != 0 || c.EnemyFireT != 0 {
t.Errorf("timers non-zero: PlayerWeaponT=%f EnemyFireT=%f", c.PlayerWeaponT, c.EnemyFireT)
}
if c.Result != GameOngoing {
t.Errorf("Result=%v, want GameOngoing", c.Result)
}
}
// TestPlayerShieldMax_table covers the power 0..4 → 0,0,1,1,2 mapping.
func TestPlayerShieldMax_table(t *testing.T) {
cases := []struct {
power int
want int
}{
{0, 0},
{1, 0},
{2, 1},
{3, 1},
{4, 2},
}
for _, tc := range cases {
sys := NewStartingSystems()
sys[int(RoleShields)].PowerLevel = tc.power
got := playerShieldMax(sys)
if got != tc.want {
t.Errorf("power=%d: got %d, want %d", tc.power, got, tc.want)
}
}
}
// TestResolveIncoming_shieldsAbsorb verifies that a hit on a shielded side
// decrements layers but leaves hull untouched.
func TestResolveIncoming_shieldsAbsorb(t *testing.T) {
rng := rand.New(rand.NewSource(0))
side := CombatSide{Hull: 10, MaxHull: 10, ShieldLayers: 2}
resolveIncoming(&side, 0, rng) // evasion=0, always hits
if side.ShieldLayers != 1 {
t.Errorf("ShieldLayers=%d, want 1", side.ShieldLayers)
}
if side.Hull != 10 {
t.Errorf("Hull=%d after shield absorb, want 10", side.Hull)
}
if side.HitFlashT != 0 {
t.Errorf("HitFlashT=%f, want 0 when shield absorbed", side.HitFlashT)
}
}
// TestResolveIncoming_hullDamage verifies that a hit with no shields deals 1 hull
// and sets HitFlashT.
func TestResolveIncoming_hullDamage(t *testing.T) {
rng := rand.New(rand.NewSource(0))
side := CombatSide{Hull: 5, MaxHull: 10, ShieldLayers: 0}
resolveIncoming(&side, 0, rng)
if side.Hull != 4 {
t.Errorf("Hull=%d, want 4", side.Hull)
}
if side.HitFlashT != HitFlashDuration {
t.Errorf("HitFlashT=%f, want %f", side.HitFlashT, HitFlashDuration)
}
}
// TestResolveIncoming_hullClampsAtZero verifies IV3: hull never goes below 0.
func TestResolveIncoming_hullClampsAtZero(t *testing.T) {
rng := rand.New(rand.NewSource(0))
side := CombatSide{Hull: 0, MaxHull: 10, ShieldLayers: 0}
resolveIncoming(&side, 0, rng)
if side.Hull != 0 {
t.Errorf("Hull=%d after hit at 0, want 0", side.Hull)
}
}
// TestResolveIncoming_evasionMiss verifies that when the RNG rolls under the
// evasion threshold the hit is dodged and no state changes.
func TestResolveIncoming_evasionMiss(t *testing.T) {
// Use a source that produces a deterministic low value (< evasionPct).
// rand.New(rand.NewSource(7)).Intn(100) == 25 for the first call.
src := rand.NewSource(7)
rng := rand.New(src)
firstRoll := rand.New(rand.NewSource(7)).Intn(100)
if firstRoll >= 50 {
t.Skipf("seed 7 first roll=%d >= 50; pick a different seed", firstRoll)
}
side := CombatSide{Hull: 10, MaxHull: 10, ShieldLayers: 0}
resolveIncoming(&side, 50, rng) // 50% evasion, roll < 50 → miss
if side.Hull != 10 {
t.Errorf("Hull=%d after evasion miss, want 10", side.Hull)
}
if side.HitFlashT != 0 {
t.Errorf("HitFlashT=%f after evasion miss, want 0", side.HitFlashT)
}
}
// TestResolveIncoming_evasionHit verifies that when the RNG rolls at or above
// the evasion threshold the hit lands.
func TestResolveIncoming_evasionHit(t *testing.T) {
// Use seed 0 with a low evasion percentage so the first roll is guaranteed
// to be >= evasionPct (1%).
rng := rand.New(rand.NewSource(0))
side := CombatSide{Hull: 10, MaxHull: 10, ShieldLayers: 0}
resolveIncoming(&side, 1, rng) // 1% evasion — roll almost certainly >= 1
// We need to verify the hit landed; hull should decrease.
if side.Hull != 9 {
t.Errorf("Hull=%d, want 9 (hit landed)", side.Hull)
}
}
// TestUpdateCombat_postResultNoOp verifies IV5: state is frozen once a result is set.
func TestUpdateCombat_postResultNoOp(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleWeapons)].PowerLevel = 4
c := NewCombat()
c.Result = GameVictory
c.Enemy.Hull = 5
c.Player.HitFlashT = 0.1
snap := c
updateCombat(&c, sys, 1.0, rng)
if c != snap {
t.Errorf("state mutated after GameVictory: got %+v, want %+v", c, snap)
}
}
// TestUpdateCombat_hitFlashTicksDown verifies that HitFlashT decrements and clamps at 0.
func TestUpdateCombat_hitFlashTicksDown(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems() // all power 0 — no weapons, no enemy fire crosses interval
c := NewCombat()
c.Player.HitFlashT = 0.05
c.Enemy.HitFlashT = 0.15
updateCombat(&c, sys, 1.0/60.0, rng)
if c.Player.HitFlashT != 0 {
t.Errorf("Player.HitFlashT=%f, want 0 (clamped)", c.Player.HitFlashT)
}
if c.Enemy.HitFlashT <= 0 {
t.Errorf("Enemy.HitFlashT=%f, want >0 (partially ticked)", c.Enemy.HitFlashT)
}
}
// TestUpdateCombat_shieldRegen verifies shields regen when power=4 and
// ShieldRegenT crosses the threshold.
func TestUpdateCombat_shieldRegen(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleShields)].PowerLevel = 4 // max=2 shields
c := NewCombat()
c.Player.ShieldLayers = 0
// Advance enough time to trigger one regen tick.
updateCombat(&c, sys, ShieldRegen+0.1, rng)
if c.Player.ShieldLayers != 1 {
t.Errorf("ShieldLayers=%d, want 1 after regen", c.Player.ShieldLayers)
}
}
// TestUpdateCombat_shieldsClampOnPowerDrop verifies IV4: layers clamp down when
// power is reduced below what current layers require.
func TestUpdateCombat_shieldsClampOnPowerDrop(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleShields)].PowerLevel = 0 // power dropped to 0 → max=0
c := NewCombat()
c.Player.ShieldLayers = 2
updateCombat(&c, sys, 1.0/60.0, rng)
if c.Player.ShieldLayers != 0 {
t.Errorf("ShieldLayers=%d after power drop, want 0", c.Player.ShieldLayers)
}
}
// TestUpdateCombat_weaponChargeFires verifies that the weapon timer fires when it
// crosses WeaponChargeMax and deals 1 hull to the enemy.
func TestUpdateCombat_weaponChargeFires(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleWeapons)].PowerLevel = 1
c := NewCombat()
c.PlayerWeaponT = WeaponChargeMax - 0.01 // just below threshold
enemyHullBefore := c.Enemy.Hull
updateCombat(&c, sys, 1.0, rng) // dt=1.0: 0.01 over threshold
if c.Enemy.Hull != enemyHullBefore-1 {
t.Errorf("Enemy.Hull=%d, want %d (one hit)", c.Enemy.Hull, enemyHullBefore-1)
}
if c.PlayerWeaponT != 0 {
t.Errorf("PlayerWeaponT=%f, want 0 after firing", c.PlayerWeaponT)
}
}
// TestUpdateCombat_weaponZeroPowerNoCharge verifies that zero weapons power means
// PlayerWeaponT does not advance.
func TestUpdateCombat_weaponZeroPowerNoCharge(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleWeapons)].PowerLevel = 0
c := NewCombat()
c.PlayerWeaponT = 3.0
updateCombat(&c, sys, 1.0, rng)
if c.PlayerWeaponT != 3.0 {
t.Errorf("PlayerWeaponT=%f, want 3.0 (no change)", c.PlayerWeaponT)
}
}
// TestUpdateCombat_enemyFiresOnTimer verifies that the enemy fires when EnemyFireT
// crosses EnemyFireInterval and damages the player (no engines power → no evasion).
func TestUpdateCombat_enemyFiresOnTimer(t *testing.T) {
// Use a seed where the evasion roll at 0% always hits.
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleEngines)].PowerLevel = 0 // 0% evasion
c := NewCombat()
c.EnemyFireT = EnemyFireInterval - 0.01
playerHullBefore := c.Player.Hull
updateCombat(&c, sys, 1.0, rng)
if c.Player.Hull != playerHullBefore-1 {
t.Errorf("Player.Hull=%d, want %d (enemy hit)", c.Player.Hull, playerHullBefore-1)
}
if c.EnemyFireT != 0 {
t.Errorf("EnemyFireT=%f, want 0 after firing", c.EnemyFireT)
}
}
// TestUpdateCombat_victoryTransition verifies IV5: enemy reaches 0 hull → GameVictory.
func TestUpdateCombat_victoryTransition(t *testing.T) {
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleWeapons)].PowerLevel = 1
c := NewCombat()
c.Enemy.Hull = 1
c.PlayerWeaponT = WeaponChargeMax - 0.01
updateCombat(&c, sys, 1.0, rng)
if c.Result != GameVictory {
t.Errorf("Result=%v, want GameVictory", c.Result)
}
}
// TestUpdateCombat_defeatTransition verifies IV5: player reaches 0 hull → GameDefeat.
func TestUpdateCombat_defeatTransition(t *testing.T) {
// Need a seed that won't dodge (0% evasion, engines=0).
rng := rand.New(rand.NewSource(42))
sys := NewStartingSystems()
sys[int(RoleEngines)].PowerLevel = 0
sys[int(RoleShields)].PowerLevel = 0
c := NewCombat()
c.Player.Hull = 1
c.Player.ShieldLayers = 0
c.EnemyFireT = EnemyFireInterval - 0.01
updateCombat(&c, sys, 1.0, rng)
if c.Result != GameDefeat {
t.Errorf("Result=%v, want GameDefeat", c.Result)
}
}
// TestUpdateCombat_determinism verifies IV6: same seed + same inputs → identical
// state after N ticks.
func TestUpdateCombat_determinism(t *testing.T) {
sys := NewStartingSystems()
sys[int(RoleWeapons)].PowerLevel = 2
sys[int(RoleEngines)].PowerLevel = 1
sys[int(RoleShields)].PowerLevel = 2
run := func() Combat {
rng := rand.New(rand.NewSource(99))
c := NewCombat()
for i := 0; i < 500; i++ {
if c.Result != GameOngoing {
break
}
updateCombat(&c, sys, 1.0/60.0, rng)
}
return c
}
a := run()
b := run()
if a != b {
t.Errorf("non-deterministic: run1=%+v run2=%+v", a, b)
}
}