@@ 0,0 1,127 @@
+package main
+
+import "math/rand"
+
+// Game-feel timing constants. Respects PC4: all tunable numbers are named.
+const (
+ WeaponChargeMax = 8.0
+ ShieldRegen = 3.0
+ EnemyFireInterval = 6.0
+ HitFlashDuration = 0.2
+ PlayerHullMax = 20
+ EnemyHullMax = 15
+)
+
+// GameResult represents the current outcome of a combat encounter.
+type GameResult int
+
+const (
+ GameOngoing GameResult = iota
+ GameVictory
+ GameDefeat
+)
+
+// CombatSide holds the mutable state for one participant in combat.
+type CombatSide struct {
+ Hull int
+ MaxHull int
+ ShieldLayers int
+ ShieldRegenT float64
+ HitFlashT float64
+}
+
+// Combat holds the full state of one combat encounter.
+type Combat struct {
+ Player CombatSide
+ Enemy CombatSide
+ PlayerWeaponT float64
+ EnemyFireT float64
+ Result GameResult
+}
+
+// NewCombat returns a fresh combat with default hull values and all timers at zero.
+func NewCombat() Combat {
+ return Combat{
+ Player: CombatSide{Hull: PlayerHullMax, MaxHull: PlayerHullMax},
+ Enemy: CombatSide{Hull: EnemyHullMax, MaxHull: EnemyHullMax},
+ Result: GameOngoing,
+ }
+}
+
+// playerShieldMax returns the maximum shield layers the player can maintain
+// given current systems power. Formula: shields power / 2 (integer division).
+// Pure query — no mutation. Respects GPC3.
+func playerShieldMax(sys []System) int {
+ return sys[int(RoleShields)].PowerLevel / 2
+}
+
+// resolveIncoming applies one incoming hit to a CombatSide.
+// evasionPct is an integer 0–100; if the rng rolls under it the shot is dodged.
+// When the shot lands: shields absorb first; if no shields, hull takes 1 damage
+// (clamped to 0) and HitFlashT is set. Respects IV2, IV3.
+func resolveIncoming(side *CombatSide, evasionPct int, rng *rand.Rand) {
+ if rng.Intn(100) < evasionPct {
+ return // dodged
+ }
+ if side.ShieldLayers > 0 {
+ side.ShieldLayers--
+ return
+ }
+ if side.Hull > 0 {
+ side.Hull--
+ }
+ side.HitFlashT = HitFlashDuration
+}
+
+// updateCombat advances combat state by dt seconds.
+// No-ops if Result != GameOngoing. Respects IV2, IV3, IV4, IV5, IV6.
+func updateCombat(c *Combat, sys []System, dt float64, rng *rand.Rand) {
+ if c.Result != GameOngoing {
+ return
+ }
+
+ // Step 1: tick HitFlashT down on both sides, clamp ≥ 0.
+ c.Player.HitFlashT -= dt
+ if c.Player.HitFlashT < 0 {
+ c.Player.HitFlashT = 0
+ }
+ c.Enemy.HitFlashT -= dt
+ if c.Enemy.HitFlashT < 0 {
+ c.Enemy.HitFlashT = 0
+ }
+
+ // Step 2: compute shield max and clamp layers.
+ shieldMax := playerShieldMax(sys)
+ if c.Player.ShieldLayers > shieldMax {
+ c.Player.ShieldLayers = shieldMax
+ }
+
+ // Step 3: advance shield regen.
+ c.Player.ShieldRegenT += dt
+ for c.Player.ShieldRegenT >= ShieldRegen && c.Player.ShieldLayers < shieldMax {
+ c.Player.ShieldLayers++
+ c.Player.ShieldRegenT -= ShieldRegen
+ }
+
+ // Step 4: advance player weapon charge.
+ c.PlayerWeaponT += dt * float64(sys[int(RoleWeapons)].PowerLevel)
+ if c.PlayerWeaponT >= WeaponChargeMax {
+ resolveIncoming(&c.Enemy, 0, rng)
+ c.PlayerWeaponT = 0
+ }
+
+ // Step 5: advance enemy fire timer.
+ c.EnemyFireT += dt
+ if c.EnemyFireT >= EnemyFireInterval {
+ evasionPct := 5 * sys[int(RoleEngines)].PowerLevel
+ resolveIncoming(&c.Player, evasionPct, rng)
+ c.EnemyFireT = 0
+ }
+
+ // Step 6: win/lose check.
+ if c.Enemy.Hull == 0 && c.Player.Hull > 0 {
+ c.Result = GameVictory
+ } else if c.Player.Hull == 0 {
+ c.Result = GameDefeat
+ }
+}
@@ 0,0 1,295 @@
+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)
+ }
+}