Repository: mlange-42/arche
Pull request #67 (2023-02-13 22:24:47)

World statistics
Adds method `World.Stats()` for inspecting otherwise inaccessible world statistics


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a4f462..a597e43 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,11 +7,13 @@
 * Generic queries support optional, additional and excluded components (#53, #56, #58, #59, #60, #63)
 * Logic filters for complex queries (#54, #58, #61)
 * `Query` and `World` have a method `Mask(Entity)` to access archetype masks (#65)
+* Added method `World.Stats()` for inspecting otherwise inaccessible world statistics (#67)
 
 ### Other
 
 * Overhaul of the module structure, with generics and filters in separate packages (#55, #57, #61, #64)
 * Generic queries are compiled to masks and cached on first build (#62)
+* Boilerplate code for generic filters and queries is auto-generated with `go generate` (#64)
 
 ## [[v0.3.1]](https://github.com/mlange-42/arche/compare/v0.3.0...v0.3.1)
 
diff --git a/ecs/archetype.go b/ecs/archetype.go
index 59d95db..cf83bfe 100644
--- a/ecs/archetype.go
+++ b/ecs/archetype.go
@@ -122,6 +122,11 @@ func (a *archetype) Len() uint32 {
 	return a.entities.Len()
 }
 
+// Cap reports the current capacity of the archetype
+func (a *archetype) Cap() uint32 {
+	return a.entities.Cap()
+}
+
 // Set overwrites a component with the data behind the given pointer
 func (a *archetype) Set(index uint32, id ID, comp interface{}) unsafe.Pointer {
 	return a.components[a.indices[id]].Set(index, comp)
diff --git a/ecs/entity_pool.go b/ecs/entity_pool.go
index 29a649e..1b76cc3 100644
--- a/ecs/entity_pool.go
+++ b/ecs/entity_pool.go
@@ -55,3 +55,13 @@ func (p *entityPool) Recycle(e Entity) {
 func (p *entityPool) Alive(e Entity) bool {
 	return e.id != 0 && e.gen == p.entities[e.id].gen
 }
+
+func (p *entityPool) Len() int {
+	return len(p.entities) - 1 - int(p.available)
+}
+func (p *entityPool) Cap() int {
+	return len(p.entities) - 1
+}
+func (p *entityPool) Available() int {
+	return int(p.available)
+}
diff --git a/ecs/paged.go b/ecs/paged.go
index 648fbb6..0781866 100644
--- a/ecs/paged.go
+++ b/ecs/paged.go
@@ -10,7 +10,7 @@ type pagedArr32[T any] struct {
 }
 
 func (p *pagedArr32[T]) Add(value T) {
-	if len(p.pages) == 0 || p.lenLast == fixedPageSize {
+	if p.len == 0 || p.lenLast == fixedPageSize {
 		p.pages = append(p.pages, [fixedPageSize]T{})
 		p.lenLast = 0
 	}
diff --git a/ecs/stats/stats.go b/ecs/stats/stats.go
new file mode 100644
index 0000000..301a963
--- /dev/null
+++ b/ecs/stats/stats.go
@@ -0,0 +1,79 @@
+package stats
+
+import (
+	"fmt"
+	"reflect"
+	"strings"
+)
+
+// WorldStats provide statistics for a [World].
+type WorldStats struct {
+	// Entity statistics
+	Entities EntityStats
+	// Total number of components
+	ComponentCount int
+	// Component types, indexed by component ID
+	ComponentTypes []reflect.Type
+	// Locked state of the world
+	Locked bool
+	// Archetype statistics
+	Archetypes []ArchetypeStats
+}
+
+// EntityStats provide statistics about [World] entities.
+type EntityStats struct {
+	// Currently used/alive entities
+	Used int
+	// Current capacity of the entity pool
+	Capacity int
+	// Recycled/available entities
+	Recycled int
+}
+
+// ArchetypeStats provide statistics for an archetype.
+type ArchetypeStats struct {
+	// Number of entities in the archetype
+	Size int
+	// Capacity of the archetype
+	Capacity int
+	// Number of components
+	Components int
+	// Component IDs
+	ComponentIDs []uint8
+	// Component types for ComponentIDs
+	ComponentTypes []reflect.Type
+}
+
+func (s *WorldStats) String() string {
+	b := strings.Builder{}
+
+	fmt.Fprintf(&b, "World -- Components: %d, Archetypes: %d, Locked: %t\n", s.ComponentCount, len(s.Archetypes), s.Locked)
+
+	typeNames := make([]string, len(s.ComponentTypes))
+	for i, tp := range s.ComponentTypes {
+		typeNames[i] = tp.Name()
+	}
+	fmt.Fprintf(&b, "  Components: %s\n", strings.Join(typeNames, ", "))
+	fmt.Fprint(&b, s.Entities.String())
+
+	for _, arch := range s.Archetypes {
+		fmt.Fprint(&b, arch.String())
+	}
+
+	return b.String()
+}
+
+func (s *EntityStats) String() string {
+	return fmt.Sprintf("Entities -- Used: %d, Recycled: %d, Capacity: %d\n", s.Used, s.Recycled, s.Capacity)
+}
+
+func (s *ArchetypeStats) String() string {
+	typeNames := make([]string, len(s.ComponentTypes))
+	for i, tp := range s.ComponentTypes {
+		typeNames[i] = tp.Name()
+	}
+	return fmt.Sprintf(
+		"Archetype -- Components: %d, Entities: %d, Capacity: %d\n  Components: %s\n",
+		s.Components, s.Size, s.Capacity, strings.Join(typeNames, ", "),
+	)
+}
diff --git a/ecs/storage.go b/ecs/storage.go
index 71d4bba..733860e 100644
--- a/ecs/storage.go
+++ b/ecs/storage.go
@@ -23,12 +23,12 @@ func (s *storage) Init(tp reflect.Type, increment int) {
 	align := uintptr(tp.Align())
 	size = (size + (align - 1)) / align * align
 
-	s.buffer = reflect.New(reflect.ArrayOf(increment, tp)).Elem()
+	s.buffer = reflect.New(reflect.ArrayOf(0, tp)).Elem()
 	s.bufferAddress = s.buffer.Addr().UnsafePointer()
 	s.typeOf = tp
 	s.itemSize = size
 	s.len = 0
-	s.cap = uint32(increment)
+	s.cap = 0
 	s.capacityIncrement = uint32(increment)
 }
 
@@ -138,6 +138,11 @@ func (s *storage) Len() uint32 {
 	return s.len
 }
 
+// Cap returns the capacity of the storage
+func (s *storage) Cap() uint32 {
+	return s.cap
+}
+
 // toSlice converts the content of a storage to a slice of structs
 func toSlice[T any](s storage) []T {
 	res := make([]T, s.Len())
diff --git a/ecs/world.go b/ecs/world.go
index a735e94..648b3d6 100644
--- a/ecs/world.go
+++ b/ecs/world.go
@@ -3,6 +3,8 @@ package ecs
 import (
 	"reflect"
 	"unsafe"
+
+	"github.com/mlange-42/arche/ecs/stats"
 )
 
 // ComponentID returns the ID for a component type via generics. Registers the type if it is not already registered.
@@ -34,19 +36,17 @@ func NewWorld() World {
 
 // FromConfig creates a new [World] from a [Config]
 func FromConfig(conf Config) World {
-	arch := archetype{}
-	arch.Init(conf.CapacityIncrement)
-	arches := pagedArr32[archetype]{}
-	arches.Add(arch)
-	return World{
+	w := World{
 		config:     conf,
 		entities:   []entityIndex{{arch: nil, index: 0}},
 		entityPool: newEntityPool(conf.CapacityIncrement),
 		bitPool:    newBitPool(),
 		registry:   newComponentRegistry(),
-		archetypes: arches,
+		archetypes: pagedArr32[archetype]{},
 		locks:      BitMask(0),
 	}
+	w.createArchetype(0)
+	return w
 }
 
 // NewEntity returns a new or recycled [Entity].
@@ -285,6 +285,48 @@ func (w *World) IsLocked() bool {
 	return w.locks != 0
 }
 
+// Stats reports statistics for inspecting the World.
+func (w *World) Stats() *stats.WorldStats {
+	entities := stats.EntityStats{
+		Used:     w.entityPool.Len(),
+		Capacity: w.entityPool.Cap(),
+		Recycled: w.entityPool.Available(),
+	}
+
+	compCount := len(w.registry.Components)
+	types := append([]reflect.Type{}, w.registry.Types[:compCount]...)
+
+	archetypes := make([]stats.ArchetypeStats, w.archetypes.Len())
+	for i := 0; i < w.archetypes.Len(); i++ {
+		arch := w.archetypes.Get(i)
+
+		ids := arch.Components()
+		aCompCount := int(len(ids))
+		aTypes := make([]reflect.Type, aCompCount)
+		for j, id := range ids {
+			aTypes[j] = w.registry.ComponentType(id)
+		}
+
+		stats := stats.ArchetypeStats{
+			Size:           int(arch.Len()),
+			Capacity:       int(arch.Cap()),
+			Components:     aCompCount,
+			ComponentIDs:   ids,
+			ComponentTypes: aTypes,
+		}
+
+		archetypes[i] = stats
+	}
+
+	return &stats.WorldStats{
+		Entities:       entities,
+		ComponentCount: compCount,
+		ComponentTypes: types,
+		Locked:         w.IsLocked(),
+		Archetypes:     archetypes,
+	}
+}
+
 func (w *World) copyTo(entity Entity, id ID, comp interface{}) unsafe.Pointer {
 	if !w.Has(entity, id) {
 		panic("can't copy component into entity that has no such component type")
diff --git a/examples/generic/main.go b/examples/generic/main.go
index 76bf332..fbbfdf7 100644
--- a/examples/generic/main.go
+++ b/examples/generic/main.go
@@ -1,6 +1,7 @@
 package main
 
 import (
+	"fmt"
 	"math/rand"
 
 	"github.com/mlange-42/arche/ecs"
@@ -81,4 +82,5 @@ func main() {
 		pos.X += vel.X
 		pos.Y += vel.Y
 	}
+	fmt.Println(world.Stats().String())
 }


Test patch:
diff --git a/ecs/stats/stats_test.go b/ecs/stats/stats_test.go
new file mode 100644
index 0000000..dff3a51
--- /dev/null
+++ b/ecs/stats/stats_test.go
@@ -0,0 +1,24 @@
+package stats
+
+import (
+	"fmt"
+	"reflect"
+	"testing"
+)
+
+func TestStats(t *testing.T) {
+	stats := WorldStats{
+		Entities:       EntityStats{},
+		ComponentCount: 1,
+		ComponentTypes: []reflect.Type{reflect.TypeOf(1)},
+		Locked:         false,
+		Archetypes: []ArchetypeStats{{
+			Size:           1,
+			Capacity:       128,
+			Components:     1,
+			ComponentIDs:   []uint8{0},
+			ComponentTypes: []reflect.Type{reflect.TypeOf(1)},
+		}},
+	}
+	fmt.Println(stats.String())
+}
diff --git a/ecs/world_test.go b/ecs/world_test.go
index 6293b5f..4b2346c 100644
--- a/ecs/world_test.go
+++ b/ecs/world_test.go
@@ -316,6 +316,24 @@ func TestWorldLock(t *testing.T) {
 	assert.Panics(t, func() { world.Remove(entity, posID) })
 }
 
+func TestWorldStats(t *testing.T) {
+	w := NewWorld()
+
+	posID := ComponentID[position](&w)
+	rotID := ComponentID[rotation](&w)
+
+	e0 := w.NewEntity()
+	e1 := w.NewEntity()
+	e2 := w.NewEntity()
+
+	w.Add(e0, posID)
+	w.Add(e1, posID, rotID)
+	w.Add(e2, posID, rotID)
+
+	stats := w.Stats()
+	fmt.Println(stats.String())
+}
+
 func TestRegisterComponents(t *testing.T) {
 	world := NewWorld()