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) } }