~bigbes/game-prototype-ftl

cbe6e62362e646c22aa14ba9e17901858dc88813 — Eugene Blikh 30 days ago 036d6fe
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
2 files changed, 422 insertions(+), 0 deletions(-)

A combat.go
A combat_test.go
A combat.go => combat.go +127 -0
@@ 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
	}
}

A combat_test.go => combat_test.go +295 -0
@@ 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)
	}
}