~bigbes/game-prototype-ftl

ef6e0d58a72b3d2fbb6316a9a9c132bfa139252e — Eugene Blikh 30 days ago aff16d4
systems-power: System data + addPower/removePower with tests

systems-power
2 files changed, 250 insertions(+), 0 deletions(-)

A systems.go
A systems_test.go
A systems.go => systems.go +77 -0
@@ 0,0 1,77 @@
package main

// ReactorCap is the maximum total power that can be distributed across all
// ship systems at any time.
const ReactorCap = 8

// System holds the power state of a single ship system.
// Instances are indexed in a slice by int(RoomRole) so that callers can do
// a direct O(1) lookup when the role is known; addPower / removePower still
// do a linear scan by Role field for safety.
type System struct {
	Role       RoomRole
	PowerLevel int
	MaxLevel   int
}

// NewStartingSystems returns a 5-element slice indexed by int(RoomRole).
// All systems start at PowerLevel 0.
func NewStartingSystems() []System {
	return []System{
		int(RolePilot):   {Role: RolePilot, PowerLevel: 0, MaxLevel: 1},
		int(RoleWeapons): {Role: RoleWeapons, PowerLevel: 0, MaxLevel: 4},
		int(RoleShields): {Role: RoleShields, PowerLevel: 0, MaxLevel: 4},
		int(RoleMedBay):  {Role: RoleMedBay, PowerLevel: 0, MaxLevel: 2},
		int(RoleEngines): {Role: RoleEngines, PowerLevel: 0, MaxLevel: 4},
	}
}

// reactorUsed returns the sum of PowerLevel across all systems.
func reactorUsed(sys []System) int {
	total := 0
	for _, s := range sys {
		total += s.PowerLevel
	}
	return total
}

// addPower attempts to raise the PowerLevel of the system with the given role
// by 1. It returns false (no-op) if:
//   - the system is already at its MaxLevel (IV4), or
//   - the reactor would exceed reactorCap (IV3).
//
// Respects IV2: this is the sole mutator of System.PowerLevel for increments.
func addPower(sys []System, role RoomRole, reactorCap int) bool {
	for i := range sys {
		if sys[i].Role != role {
			continue
		}
		if sys[i].PowerLevel >= sys[i].MaxLevel {
			return false
		}
		if reactorUsed(sys) >= reactorCap {
			return false
		}
		sys[i].PowerLevel++
		return true
	}
	return false
}

// removePower attempts to lower the PowerLevel of the system with the given
// role by 1. It returns false (no-op) if the system is already at 0 (IV4).
//
// Respects IV2: this is the sole mutator of System.PowerLevel for decrements.
func removePower(sys []System, role RoomRole) bool {
	for i := range sys {
		if sys[i].Role != role {
			continue
		}
		if sys[i].PowerLevel <= 0 {
			return false
		}
		sys[i].PowerLevel--
		return true
	}
	return false
}

A systems_test.go => systems_test.go +173 -0
@@ 0,0 1,173 @@
package main

import "testing"

// TestNewStartingSystems_layout verifies the 5-entry slice is indexed by
// int(RoomRole) and that each entry carries the correct role and cap.
func TestNewStartingSystems_layout(t *testing.T) {
	sys := NewStartingSystems()
	if len(sys) != 5 {
		t.Fatalf("expected 5 systems, got %d", len(sys))
	}

	type want struct {
		role     RoomRole
		maxLevel int
	}
	cases := []want{
		{RolePilot, 1},
		{RoleWeapons, 4},
		{RoleShields, 4},
		{RoleMedBay, 2},
		{RoleEngines, 4},
	}

	for _, w := range cases {
		idx := int(w.role)
		s := sys[idx]
		if s.Role != w.role {
			t.Errorf("sys[%d].Role = %v, want %v", idx, s.Role, w.role)
		}
		if s.MaxLevel != w.maxLevel {
			t.Errorf("sys[%d].MaxLevel = %d, want %d", idx, s.MaxLevel, w.maxLevel)
		}
		if s.PowerLevel != 0 {
			t.Errorf("sys[%d].PowerLevel = %d, want 0", idx, s.PowerLevel)
		}
	}
}

// TestReactorUsed_zero verifies that fresh systems report zero reactor usage.
func TestReactorUsed_zero(t *testing.T) {
	sys := NewStartingSystems()
	if got := reactorUsed(sys); got != 0 {
		t.Fatalf("reactorUsed on fresh systems = %d, want 0", got)
	}
}

// TestReactorUsed_sums verifies that reactorUsed returns the sum of all PowerLevels.
func TestReactorUsed_sums(t *testing.T) {
	sys := NewStartingSystems()
	// Directly set some levels for this test only (bypassing addPower to isolate the sum logic).
	sys[int(RolePilot)].PowerLevel = 1
	sys[int(RoleWeapons)].PowerLevel = 3
	sys[int(RoleEngines)].PowerLevel = 2
	want := 1 + 3 + 2
	if got := reactorUsed(sys); got != want {
		t.Fatalf("reactorUsed = %d, want %d", got, want)
	}
}

// TestAddPower_happy verifies a normal add when below cap and reactor has headroom.
func TestAddPower_happy(t *testing.T) {
	sys := NewStartingSystems()
	ok := addPower(sys, RoleWeapons, ReactorCap)
	if !ok {
		t.Fatal("addPower returned false, expected true")
	}
	if sys[int(RoleWeapons)].PowerLevel != 1 {
		t.Fatalf("PowerLevel = %d, want 1", sys[int(RoleWeapons)].PowerLevel)
	}
}

// TestAddPower_systemAtCap verifies that adding beyond MaxLevel is rejected.
func TestAddPower_systemAtCap(t *testing.T) {
	sys := NewStartingSystems()
	// Pilot cap is 1; add once to hit the cap.
	if !addPower(sys, RolePilot, ReactorCap) {
		t.Fatal("first addPower on Pilot should succeed")
	}
	if sys[int(RolePilot)].PowerLevel != 1 {
		t.Fatalf("expected level 1 after first add, got %d", sys[int(RolePilot)].PowerLevel)
	}
	// Now at cap — must be rejected.
	ok := addPower(sys, RolePilot, ReactorCap)
	if ok {
		t.Fatal("addPower at cap returned true, expected false")
	}
	if sys[int(RolePilot)].PowerLevel != 1 {
		t.Fatalf("PowerLevel changed after rejected add: got %d", sys[int(RolePilot)].PowerLevel)
	}
}

// TestAddPower_reactorFull verifies that adding power when reactor is exhausted is rejected.
func TestAddPower_reactorFull(t *testing.T) {
	sys := NewStartingSystems()
	// Fill reactor to cap using Weapons (cap 4) and Engines (cap 4).
	for i := 0; i < 4; i++ {
		if !addPower(sys, RoleWeapons, ReactorCap) {
			t.Fatalf("addPower Weapons step %d failed unexpectedly", i)
		}
	}
	for i := 0; i < 4; i++ {
		if !addPower(sys, RoleEngines, ReactorCap) {
			t.Fatalf("addPower Engines step %d failed unexpectedly", i)
		}
	}
	if reactorUsed(sys) != ReactorCap {
		t.Fatalf("expected reactor full (%d), got %d", ReactorCap, reactorUsed(sys))
	}
	// Shields is below cap but reactor is full — must be rejected.
	ok := addPower(sys, RoleShields, ReactorCap)
	if ok {
		t.Fatal("addPower with full reactor returned true, expected false")
	}
	if sys[int(RoleShields)].PowerLevel != 0 {
		t.Fatalf("PowerLevel changed on rejected add: got %d", sys[int(RoleShields)].PowerLevel)
	}
}

// TestAddPower_invariantsHold verifies IV3 and IV4 across all outcomes of addPower.
func TestAddPower_invariantsHold(t *testing.T) {
	checkInvariants := func(sys []System) {
		t.Helper()
		used := reactorUsed(sys)
		if used > ReactorCap {
			t.Errorf("IV3 violated: reactorUsed %d > ReactorCap %d", used, ReactorCap)
		}
		for i, s := range sys {
			if s.PowerLevel < 0 || s.PowerLevel > s.MaxLevel {
				t.Errorf("IV4 violated: sys[%d].PowerLevel %d not in [0, %d]", i, s.PowerLevel, s.MaxLevel)
			}
		}
	}

	roles := []RoomRole{RolePilot, RoleWeapons, RoleShields, RoleMedBay, RoleEngines}
	sys := NewStartingSystems()

	// Repeatedly try to add power to every system in a round-robin until the
	// reactor is full, then one more round of attempts (all should fail).
	for round := 0; round < 10; round++ {
		for _, r := range roles {
			addPower(sys, r, ReactorCap) //nolint:errcheck — we only care about invariants
			checkInvariants(sys)
		}
	}
}

// TestRemovePower_happy verifies that removing from a powered system decrements level.
func TestRemovePower_happy(t *testing.T) {
	sys := NewStartingSystems()
	if !addPower(sys, RoleShields, ReactorCap) {
		t.Fatal("addPower Shields failed")
	}
	ok := removePower(sys, RoleShields)
	if !ok {
		t.Fatal("removePower returned false, expected true")
	}
	if sys[int(RoleShields)].PowerLevel != 0 {
		t.Fatalf("PowerLevel = %d, want 0", sys[int(RoleShields)].PowerLevel)
	}
}

// TestRemovePower_zeroLevel verifies that removing from an unpowered system is rejected.
func TestRemovePower_zeroLevel(t *testing.T) {
	sys := NewStartingSystems()
	ok := removePower(sys, RoleEngines)
	if ok {
		t.Fatal("removePower at level 0 returned true, expected false")
	}
	if sys[int(RoleEngines)].PowerLevel != 0 {
		t.Fatalf("PowerLevel changed after rejected remove: got %d", sys[int(RoleEngines)].PowerLevel)
	}
}