From cbe6e62362e646c22aa14ba9e17901858dc88813 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 28 Apr 2026 04:21:12 +0300 Subject: [PATCH] combat: Combat data + updateCombat with tests PH1 of combat task: pure tick logic. - combat.go: GameResult/CombatSide/Combat types, NewCombat, playerShieldMax, resolveIncoming, updateCombat. Constants WeaponChargeMax/ShieldRegen/EnemyFireInterval/HitFlashDuration/ PlayerHullMax/EnemyHullMax. Imports math/rand only (IV1). - combat_test.go: 17 tests covering initial state, shield max, shield absorb / hull damage / hull clamp / evasion miss-and-hit in resolveIncoming, post-result no-op, hit-flash tick, shield regen and clamp-on-power-drop, weapon fires / no-charge-when-zero, enemy fires on timer, victory and defeat transitions, determinism. Tests cannot execute in this Claude Code session (Ebitengine GLFW init requires a display); compile + vet are clean. Refs: docs/tasks/combat.md PH1 --- combat.go | 127 +++++++++++++++++++++ combat_test.go | 295 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 422 insertions(+) create mode 100644 combat.go create mode 100644 combat_test.go diff --git a/combat.go b/combat.go new file mode 100644 index 0000000000000000000000000000000000000000..b2231a74d02289762f0647dd06715c082a9e2b76 --- /dev/null +++ b/combat.go @@ -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 + } +} diff --git a/combat_test.go b/combat_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d4085c4183d660b99bcd9d6b383a255072861cc7 --- /dev/null +++ b/combat_test.go @@ -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) + } +}