Skip to content

Commit 2d48797

Browse files
committed
feat: debugger mvp
1 parent ebc36fb commit 2d48797

File tree

13 files changed

+3375
-36
lines changed

13 files changed

+3375
-36
lines changed

pkg/cardinal/cardinal.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ func NewWorld(opts WorldOptions) (*World, error) {
112112

113113
if *options.Debug {
114114
debug := newDebugModule(world)
115+
debug.control.isPaused.Store(true)
115116
world.debug = &debug
116117
}
117118

@@ -152,19 +153,48 @@ func (w *World) run(ctx context.Context) error {
152153
ticker := time.NewTicker(time.Duration(float64(time.Second) / w.options.TickRate))
153154
defer ticker.Stop()
154155

155-
// TODO: select from debug channel to pause/play ticks.
156156
for {
157+
if w.debug != nil && w.debug.control.isPaused.Load() {
158+
select {
159+
case <-w.debug.control.resumeCh:
160+
w.debug.control.isPaused.Store(false)
161+
case replyCh := <-w.debug.control.stepCh:
162+
if err := w.Tick(time.Now()); err != nil {
163+
replyCh <- 0
164+
return eris.Wrap(err, "failed to run tick during step")
165+
}
166+
replyCh <- w.currentTick.height
167+
case replyCh := <-w.debug.control.resetCh:
168+
replyCh <- w.reset()
169+
case <-ctx.Done():
170+
return ctx.Err()
171+
}
172+
continue
173+
}
174+
157175
select {
158176
case <-ticker.C:
159177
if err := w.Tick(time.Now()); err != nil {
160178
return eris.Wrap(err, "failed to run tick")
161179
}
180+
case replyCh := <-w.debug.control.pauseCh:
181+
w.debug.control.isPaused.Store(true)
182+
replyCh <- w.currentTick.height
162183
case <-ctx.Done():
163184
return ctx.Err()
164185
}
165186
}
166187
}
167188

189+
func (w *World) reset() error {
190+
w.world.Reset()
191+
w.commands.Clear()
192+
w.events.Clear()
193+
w.currentTick.height = 0
194+
w.currentTick.timestamp = time.Time{}
195+
return nil
196+
}
197+
168198
func (w *World) Tick(timestamp time.Time) error {
169199
// TODO: commands returned to be used for debug epoch log.
170200
_ = w.commands.Drain()

pkg/cardinal/debug.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cardinal
33
import (
44
"context"
55
"net/http"
6+
"sync/atomic"
67
"time"
78

89
"connectrpc.com/connect"
@@ -17,10 +18,31 @@ import (
1718
"github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1/cardinalv1connect"
1819
)
1920

21+
// tickControl manages pause/resume/step/reset signaling for the tick loop.
22+
type tickControl struct {
23+
pauseCh chan chan uint64 // Request pause, receives tick height when paused
24+
resumeCh chan struct{} // Signal to resume
25+
stepCh chan chan uint64 // Request step, receives tick height after step
26+
resetCh chan chan error // Request reset, receives error result
27+
isPaused atomic.Bool // Current pause state
28+
stepReady chan struct{} // Signals that step result is ready to be read
29+
}
30+
31+
func newTickControl() *tickControl {
32+
return &tickControl{
33+
pauseCh: make(chan chan uint64),
34+
resumeCh: make(chan struct{}),
35+
stepCh: make(chan chan uint64),
36+
resetCh: make(chan chan error),
37+
stepReady: make(chan struct{}),
38+
}
39+
}
40+
2041
// TODO: add tick log here.
2142
type debugModule struct {
2243
world *World
2344
server *http.Server
45+
control *tickControl
2446
reflector *jsonschema.Reflector
2547
commands map[string]*structpb.Struct
2648
events map[string]*structpb.Struct
@@ -30,6 +52,7 @@ type debugModule struct {
3052
func newDebugModule(world *World) debugModule {
3153
return debugModule{
3254
world: world,
55+
control: newTickControl(),
3356
commands: make(map[string]*structpb.Struct),
3457
events: make(map[string]*structpb.Struct),
3558
components: make(map[string]*structpb.Struct),
@@ -144,3 +167,88 @@ func (d *debugModule) buildTypeSchemas(cache map[string]*structpb.Struct) []*car
144167
}
145168
return schemas
146169
}
170+
171+
// Pause stops tick execution and returns the current tick height.
172+
func (d *debugModule) Pause(
173+
_ context.Context,
174+
_ *connect.Request[cardinalv1.PauseRequest],
175+
) (*connect.Response[cardinalv1.PauseResponse], error) {
176+
if d.control.isPaused.Load() {
177+
return nil, connect.NewError(connect.CodeFailedPrecondition, eris.New("world is already paused"))
178+
}
179+
180+
replyCh := make(chan uint64, 1)
181+
d.control.pauseCh <- replyCh
182+
tickHeight := <-replyCh
183+
184+
return connect.NewResponse(&cardinalv1.PauseResponse{
185+
TickHeight: tickHeight,
186+
}), nil
187+
}
188+
189+
// Resume continues tick execution after a pause.
190+
func (d *debugModule) Resume(
191+
_ context.Context,
192+
_ *connect.Request[cardinalv1.ResumeRequest],
193+
) (*connect.Response[cardinalv1.ResumeResponse], error) {
194+
if !d.control.isPaused.Load() {
195+
return nil, connect.NewError(connect.CodeFailedPrecondition, eris.New("world is not paused"))
196+
}
197+
198+
d.control.resumeCh <- struct{}{}
199+
200+
return connect.NewResponse(&cardinalv1.ResumeResponse{}), nil
201+
}
202+
203+
// Step executes a single tick. Only works when paused.
204+
func (d *debugModule) Step(
205+
_ context.Context,
206+
_ *connect.Request[cardinalv1.StepRequest],
207+
) (*connect.Response[cardinalv1.StepResponse], error) {
208+
if !d.control.isPaused.Load() {
209+
return nil, connect.NewError(connect.CodeFailedPrecondition, eris.New("world must be paused to step"))
210+
}
211+
212+
replyCh := make(chan uint64, 1)
213+
d.control.stepCh <- replyCh
214+
tickHeight := <-replyCh
215+
216+
return connect.NewResponse(&cardinalv1.StepResponse{
217+
TickHeight: tickHeight,
218+
}), nil
219+
}
220+
221+
// Reset restores the world to its initial state (before tick 0).
222+
func (d *debugModule) Reset(
223+
_ context.Context,
224+
_ *connect.Request[cardinalv1.ResetRequest],
225+
) (*connect.Response[cardinalv1.ResetResponse], error) {
226+
if !d.control.isPaused.Load() {
227+
return nil, connect.NewError(connect.CodeFailedPrecondition, eris.New("world must be paused to reset"))
228+
}
229+
230+
replyCh := make(chan error, 1)
231+
d.control.resetCh <- replyCh
232+
if err := <-replyCh; err != nil {
233+
return nil, connect.NewError(connect.CodeInternal, err)
234+
}
235+
236+
return connect.NewResponse(&cardinalv1.ResetResponse{}), nil
237+
}
238+
239+
// GetState returns the current world state snapshot.
240+
func (d *debugModule) GetState(
241+
_ context.Context,
242+
_ *connect.Request[cardinalv1.GetStateRequest],
243+
) (*connect.Response[cardinalv1.GetStateResponse], error) {
244+
snapshot, err := d.world.world.SerializeToProto()
245+
if err != nil {
246+
return nil, connect.NewError(connect.CodeInternal, eris.Wrap(err, "failed to serialize world state"))
247+
}
248+
249+
return connect.NewResponse(&cardinalv1.GetStateResponse{
250+
TickHeight: d.world.currentTick.height,
251+
IsPaused: d.control.isPaused.Load(),
252+
Snapshot: snapshot,
253+
}), nil
254+
}

pkg/cardinal/internal/command/command.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,11 @@ func (m *Manager) Drain() []Command {
125125
}
126126
return all
127127
}
128+
129+
// Clear discards all pending commands from both queues and buffers.
130+
func (m *Manager) Clear() {
131+
for id := range m.queues {
132+
m.queues[id].Drain(&m.commands[id])
133+
m.commands[id] = m.commands[id][:0]
134+
}
135+
}

pkg/cardinal/internal/ecs/world.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,11 @@ func (w *World) Serialize() ([]byte, error) {
8383
return proto.MarshalOptions{Deterministic: true}.Marshal(worldState)
8484
}
8585

86+
// SerializeToProto returns the World's state as a proto message.
87+
func (w *World) SerializeToProto() (*cardinalv1.CardinalSnapshot, error) {
88+
return w.state.toProto()
89+
}
90+
8691
// Deserialize populates the World's state from a byte slice.
8792
// This should only be called after the World has been properly initialized with components registered.
8893
func (w *World) Deserialize(data []byte) error {
@@ -97,3 +102,10 @@ func (w *World) Deserialize(data []byte) error {
97102
w.initDone = true
98103
return nil
99104
}
105+
106+
// Reset clears the world state back to its initial empty state.
107+
// Components remain registered but all entities and archetypes are cleared.
108+
func (w *World) Reset() {
109+
w.state.reset()
110+
w.initDone = false
111+
}

pkg/cardinal/internal/ecs/world_state.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ func newWorldState() *worldState {
4848
return &ws
4949
}
5050

51+
// reset clears all entity data while preserving registered components.
52+
func (ws *worldState) reset() {
53+
ws.mu.Lock()
54+
defer ws.mu.Unlock()
55+
56+
ws.nextID = 0
57+
ws.free = make([]EntityID, 0)
58+
ws.entityArch = newSparseSet()
59+
ws.archetypes = make([]*archetype, 1)
60+
ws.archetypes[voidArchetypeID] = ws.newArchetype(voidArchetypeID, bitmap.Bitmap{})
61+
}
62+
5163
// -------------------------------------------------------------------------------------------------
5264
// Entity operations
5365
// -------------------------------------------------------------------------------------------------

pkg/cardinal/internal/event/event.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,21 @@ func (m *Manager) flush() {
8484
}
8585
}
8686

87+
// Clear discards all pending events from the channel and buffer.
88+
func (m *Manager) Clear() {
89+
m.mu.Lock()
90+
defer m.mu.Unlock()
91+
92+
for {
93+
select {
94+
case <-m.channel:
95+
default:
96+
m.buffer = m.buffer[:0]
97+
return
98+
}
99+
}
100+
}
101+
87102
// Dispatch loops through emitted events and calls their handler functions based on the event kind.
88103
// Returns all errors collected from handlers.
89104
func (m *Manager) Dispatch() error {

pkg/cardinal/system.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -245,7 +245,7 @@ type OtherWorld struct {
245245
ShardID string
246246
}
247247

248-
// Send sends a command to an external service.
248+
// SendCommand sends a command to an external service.
249249
//
250250
// Example:
251251
//

0 commit comments

Comments
 (0)