diff --git a/go.mod b/go.mod index 7939186cd..2c02b7366 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,13 @@ go 1.25.5 require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.10-20250912141014-52f32327d4b0.1 buf.build/go/protovalidate v1.0.0 + connectrpc.com/connect v1.19.1 + connectrpc.com/validate v0.6.0 github.com/caarlos0/env/v11 v11.3.1 - github.com/coocood/freecache v1.2.4 - github.com/cosmos/ics23/go v0.11.0 github.com/expr-lang/expr v1.17.6 github.com/getsentry/sentry-go v0.36.2 github.com/goccy/go-json v0.10.5 + github.com/google/uuid v1.6.0 github.com/invopop/jsonschema v0.13.0 github.com/kelindar/bitmap v1.5.3 github.com/nats-io/nats-server/v2 v2.12.1 @@ -23,7 +24,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 go.opentelemetry.io/otel/sdk v1.38.0 go.opentelemetry.io/otel/trace v1.38.0 - golang.org/x/sync v0.18.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f google.golang.org/grpc v1.75.0 google.golang.org/protobuf v1.36.10 @@ -36,15 +36,11 @@ require ( github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/cosmos/gogoproto v1.7.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/google/cel-go v0.26.1 // indirect - github.com/google/go-cmp v0.7.0 // indirect github.com/google/go-tpm v0.9.6 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect github.com/kelindar/simd v1.1.2 // indirect @@ -65,11 +61,11 @@ require ( go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect golang.org/x/crypto v0.45.0 // indirect - golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index c7cab6cef..72b056a4c 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,10 @@ buf.build/go/protovalidate v1.0.0 h1:IAG1etULddAy93fiBsFVhpj7es5zL53AfB/79CVGtyY buf.build/go/protovalidate v1.0.0/go.mod h1:KQmEUrcQuC99hAw+juzOEAmILScQiKBP1Oc36vvCLW8= cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/validate v0.6.0 h1:DcrgDKt2ZScrUs/d/mh9itD2yeEa0UbBBa+i0mwzx+4= +connectrpc.com/validate v0.6.0/go.mod h1:ihrpI+8gVbLH1fvVWJL1I3j0CfWnF8P/90LsmluRiZs= github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op h1:+OSa/t11TFhqfrX0EOSqQBDJ0YlpmK0rDSiB19dg9M0= github.com/antithesishq/antithesis-sdk-go v0.4.3-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -16,16 +20,7 @@ github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5m github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/coocood/freecache v1.2.4 h1:UdR6Yz/X1HW4fZOuH0Z94KwG851GWOSknua5VUbb/5M= -github.com/coocood/freecache v1.2.4/go.mod h1:RBUWa/Cy+OHdfTGFEhEuE1pMCMX51Ncizj7rthiQ3vk= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= -github.com/cosmos/gogoproto v1.7.0 h1:79USr0oyXAbxg3rspGh/m4SWNyoz/GLaAh0QlCe2fro= -github.com/cosmos/gogoproto v1.7.0/go.mod h1:yWChEv5IUEYURQasfyBW5ffkMHR/90hiHgbNgrtp4j0= -github.com/cosmos/ics23/go v0.11.0 h1:jk5skjT0TqX5e5QJbEnwXIS2yI2vnmLOgpQPeM5RtnU= -github.com/cosmos/ics23/go v0.11.0/go.mod h1:A8OjxPE67hHST4Icw94hOxxFEJMBG031xIGF/JHNIY0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -140,12 +135,10 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= -golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -159,8 +152,8 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9 h1:jm6v6kMRpTYKxBRrDkYAitNJegUeO1Mf3Kt80obv0gg= +google.golang.org/genproto/googleapis/api v0.0.0-20250922171735-9219d122eba9/go.mod h1:LmwNphe5Afor5V3R5BppOULHOnt2mCIf+NxMd4XiygE= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f h1:1FTH6cpXFsENbPR5Bu8NQddPSaUUE6NA2XdZdDSAJK4= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= diff --git a/pkg/cardinal/cardinal.go b/pkg/cardinal/cardinal.go index ccfccf9ec..44d1bccef 100644 --- a/pkg/cardinal/cardinal.go +++ b/pkg/cardinal/cardinal.go @@ -2,14 +2,14 @@ package cardinal import ( "context" - "crypto/sha256" "os/signal" "syscall" "time" - "github.com/argus-labs/world-engine/pkg/assert" - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/cardinal/service" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/command" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/ecs" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/event" + "github.com/argus-labs/world-engine/pkg/cardinal/snapshot" "github.com/argus-labs/world-engine/pkg/micro" "github.com/argus-labs/world-engine/pkg/telemetry" "github.com/argus-labs/world-engine/pkg/telemetry/posthog" @@ -19,24 +19,33 @@ import ( // World represents your game world and serves as the main entry point for Cardinal. type World struct { - *micro.Shard // Embedded base shard functionalities - tel telemetry.Telemetry // Telemetry for logging and tracing + world *ecs.World // The ECS world storing the game's state and systems + commands command.Manager // Receives commands for systems + events event.Manager // Collects and dispatches events + address *micro.ServiceAddress // This world's NATS address + service *service // micro.Service wrapper + snapshotStorage snapshot.Storage // Snapshot storage + debug *debugModule // For debug only utils and services + currentTick Tick // The current tick + options WorldOptions // Options + tel telemetry.Telemetry // Telemetry for logging and tracing } // NewWorld creates a new game world with the specified configuration. func NewWorld(opts WorldOptions) (*World, error) { - config, err := loadWorldConfig() + // Load and validate options. + envs, err := loadWorldOptionsEnv() if err != nil { - return nil, eris.Wrap(err, "failed to load world config") + return nil, eris.Wrap(err, "failed to load world options env vars") } - options := newDefaultWorldOptions() - config.applyToOptions(&options) + options.apply(envs.toOptions()) options.apply(opts) if err := options.validate(); err != nil { return nil, eris.Wrap(err, "invalid world options") } + // Setup telemetry. tel, err := telemetry.New(telemetry.Options{ ServiceName: "cardinal", SentryOptions: sentry.Options{ @@ -52,40 +61,56 @@ func NewWorld(opts WorldOptions) (*World, error) { } defer tel.RecoverAndFlush(true) - client, err := micro.NewClient(micro.WithLogger(tel.GetLogger("client"))) - if err != nil { - return nil, eris.Wrap(err, "failed to initialize micro client") + world := &World{ + world: ecs.NewWorld(), + commands: command.NewManager(), + events: event.NewManager(1024), // Default event channel capacity + address: micro.GetAddress( + options.Region, micro.RealmWorld, options.Organization, options.Project, options.ShardID), + currentTick: Tick{height: 0}, // timestamp will be set by cardinal.Tick + options: options, + tel: tel, } - address := micro.GetAddress(options.Region, micro.RealmWorld, options.Organization, options.Project, options.ShardID) - - cardinal := newCardinal() - - cardinalShard, err := micro.NewShard(cardinal, micro.ShardOptions{ - Client: client, - Address: address, - EpochFrequency: options.EpochFrequency, - TickRate: options.TickRate, - Telemetry: &tel, - SnapshotStorageType: options.SnapshotStorageType, - SnapshotStorageOptions: options.SnapshotStorageOptions, + // Set ECS on componet register callback (used for introspect). + world.world.OnComponentRegister(func(zero ecs.Component) error { + return world.debug.register("component", zero) }) - if err != nil { - return nil, eris.Wrap(err, "failed to initialize shard") - } - // Initialize service only if we're in leader mode. - if cardinalShard.Mode() == micro.ModeLeader { - err := cardinal.initService(client, address, &tel) + // Create the service. + service := newService(world) + world.service = service + + // Register event handlers with the service's (NATS) publishers. + world.events.RegisterHandler(event.KindDefault, service.publishDefaultEvent) + world.events.RegisterHandler(event.KindInterShardCommand, service.publishInterShardCommand) + + // Setup snapshot storage. + switch options.SnapshotStorageType { + case snapshot.StorageTypeJetStream: + snapshotJS, err := snapshot.NewJetStreamStorage(snapshot.JetStreamStorageOptions{ + Logger: tel.GetLogger("snapshot"), + Address: world.address, + }) if err != nil { - return nil, eris.Wrap(err, "failed to initialize cardinal service") + return nil, eris.Wrap(err, "failed to create jetstream snapshot storage") } + world.snapshotStorage = snapshotJS + case snapshot.StorageTypeNop: + world.snapshotStorage = snapshot.NewNopStorage() + case snapshot.StorageTypeUndefined: + fallthrough + default: + panic("unreachable") + } + + // Create the debug module only if debug is on. + if *options.Debug { + debug := newDebugModule(world) + world.debug = &debug } - return &World{ - Shard: cardinalShard, - tel: tel, - }, nil + return world, nil } // StartGame launches your game and runs it until stopped. @@ -96,177 +121,160 @@ func (w *World) StartGame() { defer w.shutdown() defer w.tel.RecoverAndFlush(true) + // Start the NATS connection and handler. + if err := w.service.init(); err != nil { + panic(eris.Wrap(err, "failed to initialize service")) + } + + // Start the debug server. + w.debug.Init(":8080") + w.tel.CaptureEvent(ctx, "Start Game", nil) - if err := w.Run(ctx); err != nil { + if err := w.run(ctx); err != nil { w.tel.CaptureException(ctx, err) w.tel.Logger.Error().Err(err).Msg("failed running world") } } -func (w *World) getWorld() *ecs.World { - base, ok := w.Base().(*cardinal) - assert.That(ok, "cardinal shard didn't embed cardinal") - - return base.world -} - -// shutdown performs graceful cleanup of world resources, such as closing services -// and releasing any held resources. It is called automatically on shutdown. -func (w *World) shutdown() { - // Create a timeout context for shutdown. - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() +func (w *World) run(ctx context.Context) error { + // Initialize world schedulers. + w.world.Init() - w.tel.Logger.Info().Msg("Shutting down world") - - base, ok := w.Base().(*cardinal) - assert.That(ok, "cardinal shard didn't embed cardinal") - - if err := base.shutdown(); err != nil { - w.tel.Logger.Error().Err(err).Msg("cardinal shutdown error") - w.tel.CaptureException(ctx, err) + if err := w.restore(ctx); err != nil { + return eris.Wrap(err, "failed to restore state from snapshot") } - // Shutdown telemetry. - if err := w.tel.Shutdown(ctx); err != nil { - w.tel.Logger.Error().Err(err).Msg("telemetry shutdown error") + logger := w.tel.GetLogger("shard") + logger.Info().Msg("starting core shard loop") + + ticker := time.NewTicker(time.Duration(float64(time.Second) / w.options.TickRate)) + defer ticker.Stop() + + // TODO: select from debug channel to pause/play ticks. + for { + select { + case <-ticker.C: + if err := w.Tick(ctx, time.Now()); err != nil { + return eris.Wrap(err, "failed to run tick") + } + case <-ctx.Done(): + return ctx.Err() + } } - - w.tel.Logger.Info().Msg("World shutdown complete") } -// ------------------------------------------------------------------------------------------------- -// Cardinal shard implementation -// ------------------------------------------------------------------------------------------------- +func (w *World) Tick(ctx context.Context, timestamp time.Time) error { + // TODO: commands returned to be used for debug epoch log. + _ = w.commands.Drain() -// cardinal implements the methods for the micro.ShardEngine interface. -type cardinal struct { - world *ecs.World // The ECS world storing the game's state and systems - service *service.ShardService // Microservice for handling network communication + w.currentTick.timestamp = timestamp - // Snapshot caching for performance optimization. This is here so we don't have to reserialize - // the world state when it hasn't changed. - cachedSnapshot []byte // Cached serialized state - isDirty bool // True if state has changed since last snapshot -} + // Tick ECS world. + err := w.world.Tick() + if err != nil { + return eris.Wrap(err, "one or more systems failed") + } -var _ micro.ShardEngine = &cardinal{} + // Emit events. + if err := w.events.Dispatch(); err != nil { + w.tel.Logger.Warn().Err(err).Msg("errors encountered dispatching events") + } -// newCardinal creates a new cardinal instance. -func newCardinal() *cardinal { - return &cardinal{ - world: ecs.NewWorld(), - service: nil, // Will be initialized only in leader mode - cachedSnapshot: nil, - isDirty: true, // Start as dirty to force initial snapshot generation + // Publish snapshot. + if w.currentTick.height%uint64(w.options.SnapshotRate) == 0 { + snapshotCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + w.snapshot(snapshotCtx, timestamp) + cancel() } -} -func (c *cardinal) Init() error { - c.world.Init() + // Increment tick height. + w.currentTick.height++ + return nil } -func (c *cardinal) StateHash() ([]byte, error) { - snapshot, err := c.Snapshot() - if err != nil { - return nil, eris.Wrap(err, "failed to create snapshot for state hash") - } - - hash := sha256.Sum256(snapshot) - return hash[:], nil -} +func (w *World) restore(ctx context.Context) error { + logger := w.tel.GetLogger("snapshot") -func (c *cardinal) Tick(tick micro.Tick) error { - events, err := c.world.Tick(tick.Data.Commands) + logger.Debug().Msg("restoring from snapshot") + snap, err := w.snapshotStorage.Load(ctx) if err != nil { - return eris.Wrap(err, "one or more systems failed") + if eris.Is(err, snapshot.ErrSnapshotNotFound) { + logger.Debug().Msg("no snapshot found") + return nil + } + return eris.Wrap(err, "failed to load snapshot") } - c.invalidateCache() // Mark state as dirty since it has changed - - // Publish events only if systems completed successfully and service is initialized. - if c.service != nil { - c.service.Publish(events) + // Attempt to restore ECS world from snapshot. + if err := w.world.Deserialize(snap.Data); err != nil { + return eris.Wrap(err, "failed to restore world from snapshot") } + // Only update shard state after successful restoration and validation. + w.currentTick.height = snap.TickHeight + 1 + return nil } -func (c *cardinal) Replay(tick micro.Tick) error { - _, err := c.world.Tick(tick.Data.Commands) +// snapshot persists the world state as a best-effort operation. Snapshots are best effort only, and +// we just log errors instead of returning it, which would cause the world to stop and restart, +// effectively losing unsaved state. If a snapshot fails, the main loop still continues and we retry +// in the next snapshot call. +func (w *World) snapshot(ctx context.Context, timestamp time.Time) { + data, err := w.world.Serialize() if err != nil { - return eris.Wrap(err, "one or more systems failed") + w.tel.Logger.Warn().Err(err).Msg("failed to serialize world for snapshot") + return } - - c.invalidateCache() // Mark state as dirty since it has changed - return nil -} - -func (c *cardinal) Snapshot() ([]byte, error) { - if !c.isDirty && c.cachedSnapshot != nil { - return c.cachedSnapshot, nil + snap := &snapshot.Snapshot{ + TickHeight: w.currentTick.height, + Timestamp: timestamp, + Data: data, + Version: snapshot.CurrentVersion, } - - data, err := c.world.Serialize() - if err != nil { - return nil, eris.Wrap(err, "failed to serialize world state") + if err := w.snapshotStorage.Store(ctx, snap); err != nil { + w.tel.Logger.Warn().Err(err).Msg("failed to store snapshot") + return } - - // Cache the snapshot and mark as clean. - c.cachedSnapshot = data - c.isDirty = false - return data, nil + w.tel.Logger.Info().Msg("published snapshot") } -func (c *cardinal) Restore(data []byte) error { - if err := c.world.Deserialize(data); err != nil { - return eris.Wrap(err, "failed to restore world state") - } +// shutdown performs graceful cleanup of world resources, such as closing services +// and releasing any held resources. It is called automatically on shutdown. +func (w *World) shutdown() { + // Create a timeout context for shutdown. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - // Re-initialize schedulers since we don't call Init to do it for us. - c.world.Init() + w.tel.Logger.Info().Msg("Shutting down world") - c.invalidateCache() // Mark state as dirty since it has changed - return nil -} + snapshotCtx, snapshotCancel := context.WithTimeout(ctx, 2*time.Second) + w.snapshot(snapshotCtx, time.Now()) + snapshotCancel() -func (c *cardinal) Reset() { - c.world = ecs.NewWorld() - c.invalidateCache() // Mark state as dirty since it has changed -} + // Shutdown debug server. + if err := w.debug.Shutdown(ctx); err != nil { + w.tel.Logger.Error().Err(err).Msg("debug server shutdown error") + w.tel.CaptureException(ctx, err) + } -func (c *cardinal) shutdown() error { - if c.service != nil { - if err := c.service.Close(); err != nil { - return eris.Wrap(err, "failed to close service") - } + // Shutdown shard service. + if err := w.service.shutdown(); err != nil { + w.tel.Logger.Error().Err(err).Msg("service shutdown error") + w.tel.CaptureException(ctx, err) } - return nil -} -// initService initializes the cardinal service for leader mode. -func (c *cardinal) initService( - client *micro.Client, - address *micro.ServiceAddress, - tel *telemetry.Telemetry, -) error { - microService, err := service.NewShardService(service.ShardServiceOptions{ - Client: client, - Address: address, - World: c.world, - Telemetry: tel, - }) - if err != nil { - return eris.Wrap(err, "failed to create micro service") + // Shutdown telemetry. + if err := w.tel.Shutdown(ctx); err != nil { + w.tel.Logger.Error().Err(err).Msg("telemetry shutdown error") } - c.service = microService - return nil + + w.tel.Logger.Info().Msg("World shutdown complete") } -// invalidateCache invalidates the snapshot cache and marks the state as dirty. -func (c *cardinal) invalidateCache() { - c.cachedSnapshot = nil - c.isDirty = true +type Tick struct { + height uint64 + timestamp time.Time } diff --git a/pkg/cardinal/config.go b/pkg/cardinal/config.go index 023f779ca..8e5192a78 100644 --- a/pkg/cardinal/config.go +++ b/pkg/cardinal/config.go @@ -1,91 +1,38 @@ package cardinal import ( - "github.com/argus-labs/world-engine/pkg/micro" + "github.com/argus-labs/world-engine/pkg/assert" + "github.com/argus-labs/world-engine/pkg/cardinal/snapshot" "github.com/caarlos0/env/v11" "github.com/rotisserie/eris" ) -// worldConfig holds the configuration for a Cardinal World instance. -// Configuration can be set via environment variables with the specified defaults. -type worldConfig struct { - // Region the shard is deployed to. - Region string `env:"CARDINAL_REGION"` - - // The organization that owns this world. - Organization string `env:"CARDINAL_ORG" envDefault:"organization"` - - // Name of the project within the organization. - Project string `env:"CARDINAL_PROJECT" envDefault:"project"` - - // Unique ID of this world's instance. - ShardID string `env:"CARDINAL_SHARD_ID" envDefault:"service"` -} - -// loadWorldConfig loads the world configuration from environment variables. -func loadWorldConfig() (worldConfig, error) { - cfg := worldConfig{} - - if err := env.Parse(&cfg); err != nil { - return cfg, eris.Wrap(err, "failed to parse world config") - } - - if err := cfg.validate(); err != nil { - return cfg, eris.Wrap(err, "failed to validate config") - } - - return cfg, nil -} - -// validate performs validation on the loaded configuration. -func (cfg *worldConfig) validate() error { - if cfg.Region == "" { - return eris.New("region cannot be empty") - } - if cfg.Organization == "" { - return eris.New("organization cannot be empty") - } - if cfg.Project == "" { - return eris.New("project cannot be empty") - } - if cfg.ShardID == "" { - return eris.New("shard ID cannot be empty") - } - - return nil -} - -// applyToOptions applies the configuration values to the given ShardOptions. -func (cfg *worldConfig) applyToOptions(opt *WorldOptions) { - opt.Region = cfg.Region - opt.Organization = cfg.Organization - opt.Project = cfg.Project - opt.ShardID = cfg.ShardID -} +const MinEpochFrequency = 10 type WorldOptions struct { - Region string // Region the shard is deployed to - Organization string // The organization that owns this world - Project string // Name of the project within the organization - ShardID string // Unique ID for of world's instance - EpochFrequency uint32 // Number of ticks per epoch - TickRate float64 // Number of ticks per second - SnapshotStorageType micro.SnapshotStorageType // Snapshot storage type - SnapshotStorageOptions micro.SnapshotStorageOptions // Optional snapshot storage options + Region string // Region the shard is deployed to + Organization string // The organization that owns this world + Project string // Name of the project within the organization + ShardID string // Unique ID for of world's instance + TickRate float64 // Number of ticks per second + SnapshotStorageType snapshot.StorageType // Snapshot storage type + SnapshotRate uint32 // Number of ticks per snapshot + Debug *bool // Enable debug server } // newDefaultWorldOptions creates WorldOptions with default values. func newDefaultWorldOptions() WorldOptions { - // Set these to invalid values to force users to pass in the correct options. + // Initialize optional fields with defaults and initialize required fields with invalid values to + // force callers to provide them explicitly. return WorldOptions{ - Region: "", - Organization: "", - Project: "", - ShardID: "", - EpochFrequency: 0, - TickRate: 0, - SnapshotStorageType: micro.SnapshotStorageNop, // Default to nop snapshot - SnapshotStorageOptions: nil, + Region: "", + Organization: "", + Project: "", + ShardID: "", + TickRate: 0, + SnapshotStorageType: snapshot.StorageTypeNop, // Default to nop snapshot + SnapshotRate: 0, + Debug: nil, } } @@ -103,17 +50,17 @@ func (opt *WorldOptions) apply(newOpt WorldOptions) { if newOpt.ShardID != "" { opt.ShardID = newOpt.ShardID } - if newOpt.EpochFrequency != 0 { - opt.EpochFrequency = newOpt.EpochFrequency - } if newOpt.TickRate != 0.0 { opt.TickRate = newOpt.TickRate } - if newOpt.SnapshotStorageType != micro.SnapshotStorageUndefined { + if newOpt.SnapshotStorageType.IsValid() { opt.SnapshotStorageType = newOpt.SnapshotStorageType } - if newOpt.SnapshotStorageOptions != nil { - opt.SnapshotStorageOptions = newOpt.SnapshotStorageOptions + if newOpt.SnapshotRate != 0 { + opt.SnapshotRate = newOpt.SnapshotRate + } + if newOpt.Debug != nil { + opt.Debug = newOpt.Debug } } @@ -131,14 +78,17 @@ func (opt *WorldOptions) validate() error { if opt.ShardID == "" { return eris.New("shard ID cannot be empty") } - if opt.EpochFrequency < micro.MinEpochFrequency { - return eris.Errorf("epoch frequency must be at least %d", micro.MinEpochFrequency) - } if opt.TickRate == 0.0 { return eris.New("tick rate cannot be 0") } - if opt.SnapshotStorageType == micro.SnapshotStorageUndefined { - return eris.New("invalid snapshot storage type") + if !opt.SnapshotStorageType.IsValid() { + return eris.New("snapshot storage type must be specified") + } + if opt.SnapshotRate == 0 { + return eris.New("snapshot frequency cannot be 0") + } + if opt.Debug == nil { + return eris.New("debug must be specified") } return nil } @@ -159,3 +109,71 @@ func (opt *WorldOptions) getSentryTags() map[string]string { "shard_id": opt.ShardID, } } + +// ------------------------------------------------------------------------------------------------- +// World options environment variables +// ------------------------------------------------------------------------------------------------- + +// TODO: update envs. +// worldOptionsEnv are WorldOption values set through env variables. +type worldOptionsEnv struct { + // Region the shard is deployed to. + Region string `env:"CARDINAL_REGION"` + + // The organization that owns this world. + Organization string `env:"CARDINAL_ORG"` + + // Name of the project within the organization. + Project string `env:"CARDINAL_PROJECT"` + + // Unique ID of this world's instance. + ShardID string `env:"CARDINAL_SHARD_ID"` + + // Snapshot storage type ("NOP" or "JETSTREAM"). + SnapshotStorageTypeStr string `env:"CARDINAL_SNAPSHOT_STORAGE_TYPE" envDefault:"NOP"` + + // Number of ticks per snapshot. + SnapshotRate uint32 `env:"CARDINAL_SNAPSHOT_RATE"` + + // Enable debug server. + Debug bool `env:"CARDINAL_DEBUG" envDefault:"false"` +} + +// loadWorldOptionsEnv loads the world options from environment variables. +func loadWorldOptionsEnv() (worldOptionsEnv, error) { + cfg := worldOptionsEnv{} + + if err := env.Parse(&cfg); err != nil { + return cfg, eris.Wrap(err, "failed to parse world config") + } + + if err := cfg.validate(); err != nil { + return cfg, eris.Wrap(err, "failed to validate config") + } + + return cfg, nil +} + +// validate performs validation on the loaded configuration. +func (cfg *worldOptionsEnv) validate() error { + if _, err := snapshot.ParseStorageType(cfg.SnapshotStorageTypeStr); err != nil { + return err + } + return nil +} + +// toOptions converts the worldOptionsEnv to WorldOptions. +func (cfg *worldOptionsEnv) toOptions() WorldOptions { + snapshotStorageType, err := snapshot.ParseStorageType(cfg.SnapshotStorageTypeStr) + assert.That(err == nil, "config not validated") + + return WorldOptions{ + Region: cfg.Region, + Organization: cfg.Organization, + Project: cfg.Project, + ShardID: cfg.ShardID, + SnapshotStorageType: snapshotStorageType, + SnapshotRate: cfg.SnapshotRate, + Debug: &cfg.Debug, + } +} diff --git a/pkg/cardinal/debug.go b/pkg/cardinal/debug.go new file mode 100644 index 000000000..3681f2308 --- /dev/null +++ b/pkg/cardinal/debug.go @@ -0,0 +1,146 @@ +package cardinal + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + "connectrpc.com/validate" + "github.com/goccy/go-json" + "github.com/invopop/jsonschema" + "github.com/rotisserie/eris" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" + cardinalv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1" + "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1/cardinalv1connect" +) + +// TODO: add tick log here. +type debugModule struct { + world *World + server *http.Server + reflector *jsonschema.Reflector + commands map[string]*structpb.Struct + events map[string]*structpb.Struct + components map[string]*structpb.Struct +} + +func newDebugModule(world *World) debugModule { + return debugModule{ + world: world, + commands: make(map[string]*structpb.Struct), + events: make(map[string]*structpb.Struct), + components: make(map[string]*structpb.Struct), + reflector: &jsonschema.Reflector{ + Anonymous: true, // Don't add $id based on package path + ExpandedStruct: true, // Inline the struct fields directly + }, + } +} + +func (d *debugModule) register(kind string, value schema.Serializable) error { + if d == nil { + return nil + } + + var catalog map[string]*structpb.Struct + switch kind { + case "command": + catalog = d.commands + case "event": + catalog = d.events + case "component": + catalog = d.components + default: + panic("this is an internal function, this should never panic") + } + + name := value.Name() + if _, exists := catalog[name]; exists { + return nil + } + + jsonSchema := d.reflector.Reflect(value) + data, err := json.Marshal(jsonSchema) + if err != nil { + return eris.Wrap(err, "failed to marshal json schema") + } + + var schemaMap map[string]any + if err := json.Unmarshal(data, &schemaMap); err != nil { + return eris.Wrap(err, "failed to unmarshal json schema") + } + + // Remove redundant fields. + delete(schemaMap, "$schema") + delete(schemaMap, "type") + delete(schemaMap, "additionalProperties") + + schemaStruct, err := structpb.NewStruct(schemaMap) + if err != nil { + return eris.Wrap(err, "failed to create struct from schema") + } + + catalog[name] = schemaStruct + return nil +} + +// Init initializes and starts the connect server for the debug service. +func (d *debugModule) Init(addr string) { + if d == nil { + return + } + + logger := d.world.tel.GetLogger("debug") + + mux := http.NewServeMux() + mux.Handle(cardinalv1connect.NewDebugServiceHandler(d, connect.WithInterceptors(validate.NewInterceptor()))) + + d.server = &http.Server{ + Addr: addr, + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } + + logger.Info().Str("addr", addr).Msg("Debug service initialized") + + go func() { + _ = d.server.ListenAndServe() + }() +} + +// Shutdown gracefully shuts down the debug server. +func (d *debugModule) Shutdown(ctx context.Context) error { + if d == nil || d.server == nil { + return nil + } + return d.server.Shutdown(ctx) +} + +var _ cardinalv1connect.DebugServiceHandler = (*debugModule)(nil) + +// Introspect returns metadata about the registered types in the world. +func (d *debugModule) Introspect( + _ context.Context, + _ *connect.Request[cardinalv1.IntrospectRequest], +) (*connect.Response[cardinalv1.IntrospectResponse], error) { + return connect.NewResponse(&cardinalv1.IntrospectResponse{ + Commands: d.buildTypeSchemas(d.commands), + Components: d.buildTypeSchemas(d.components), + Events: d.buildTypeSchemas(d.events), + }), nil +} + +// buildTypeSchemas converts the internal schema cache to proto TypeSchema messages. +func (d *debugModule) buildTypeSchemas(cache map[string]*structpb.Struct) []*cardinalv1.TypeSchema { + schemas := make([]*cardinalv1.TypeSchema, 0, len(cache)) + for name, schemaStruct := range cache { + schemas = append(schemas, &cardinalv1.TypeSchema{ + Name: name, + Schema: schemaStruct, + }) + } + return schemas +} diff --git a/pkg/cardinal/ecs/command.go b/pkg/cardinal/ecs/command.go deleted file mode 100644 index a255f79b5..000000000 --- a/pkg/cardinal/ecs/command.go +++ /dev/null @@ -1,96 +0,0 @@ -package ecs - -import ( - "math" - "reflect" - - "github.com/argus-labs/world-engine/pkg/assert" - "github.com/argus-labs/world-engine/pkg/micro" - "github.com/rotisserie/eris" -) - -// CommandID is a unique identifier for a command type. -type CommandID uint32 - -// MaxCommandID is the maximum number of command types that can be registered. -const MaxCommandID = math.MaxUint32 - 1 - -// Command is the interface that all commands must implement. -// Commands are predefined user actions that are handled by systems. -type Command interface { //nolint:iface // ecs.Command must be a subset of micro.ShardCommand - micro.ShardCommand -} - -// commandManager manages the registration and storage of commands. -type commandManager struct { - nextID CommandID // The next command ID - catalog map[string]CommandID // Command name -> Command ID - commands [][]micro.Command // Command ID -> command - types map[string]reflect.Type // Command name -> reflect.Type -} - -// newCommandManager creates a new commandManager. -func newCommandManager() commandManager { - return commandManager{ - nextID: 0, - catalog: make(map[string]CommandID), - commands: make([][]micro.Command, 0), - types: make(map[string]reflect.Type), - } -} - -// register registers a new command type. If the command is already registered, the existing ID -// is returned. -func (c *commandManager) register(name string, typ reflect.Type) (CommandID, error) { - if name == "" { - return 0, eris.New("command name cannot be empty") - } - - // If the command is already registered, return the existing ID. - if id, exists := c.catalog[name]; exists { - return id, nil - } - - if c.nextID > MaxCommandID { - return 0, eris.New("max number of commands exceeded") - } - - const initialCommandBufferCapacity = 128 - c.catalog[name] = c.nextID - c.commands = append(c.commands, make([]micro.Command, 0, initialCommandBufferCapacity)) - c.types[name] = typ - c.nextID++ - assert.That(int(c.nextID) == len(c.commands), "command id doesn't match number of commands") - - return c.nextID - 1, nil -} - -// get retrieves a list of commands for a given command name. -func (c *commandManager) get(name string) ([]micro.Command, error) { - id, exists := c.catalog[name] - if !exists { - return nil, eris.Errorf("command %s is not registered", name) - } - return c.commands[id], nil -} - -// clear clears the command buffer. -func (c *commandManager) clear() { - for id := range c.commands { - c.commands[id] = c.commands[id][:0] - assert.That(len(c.commands[id]) == 0, "commands not cleared properly") - } -} - -// receiveCommands receives a list of commands and stores them in the commandManager. -// All commands are assumed to be pre-validated by the micro layer (micro.commandManager.Enqueue), -// which rejects unregistered commands before they reach ECS. An unknown command name here indicates -// a mismatch between micro and ECS command registration, which is a programming error, so we should -// fail fast (and loudly) instead of silently ignoring it. -func (c *commandManager) receiveCommands(commands []micro.Command) { - for _, command := range commands { - id, exists := c.catalog[command.Name] - assert.That(exists, "command %s is not registered", command.Name) - c.commands[id] = append(c.commands[id], command) - } -} diff --git a/pkg/cardinal/ecs/command_internal_test.go b/pkg/cardinal/ecs/command_internal_test.go deleted file mode 100644 index 7be3ca866..000000000 --- a/pkg/cardinal/ecs/command_internal_test.go +++ /dev/null @@ -1,176 +0,0 @@ -package ecs - -import ( - "math/rand/v2" - "reflect" - "testing" - - "github.com/argus-labs/world-engine/pkg/micro" - "github.com/argus-labs/world-engine/pkg/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ------------------------------------------------------------------------------------------------- -// Model-based fuzzing command manager operations -// ------------------------------------------------------------------------------------------------- -// This test verifies the commandManager implementation correctness using model-based testing. It -// compares our implementation against a map[string][]micro.Command as the model by applying random -// sequences of receive/clear/get operations to both and asserting equivalence. -// Commands are pre-registered since the micro layer guarantees only registered commands reach ECS. -// ------------------------------------------------------------------------------------------------- - -func TestCommand_ModelFuzz(t *testing.T) { - t.Parallel() - prng := testutils.NewRand(t) - - const numCommands = 10 - const opsMax = 1 << 15 // 32_768 iterations - - impl := newCommandManager() - model := make(map[string][]micro.Command) // name -> commands buffer - - // Setup: pre-register a fixed set of command names. - for range numCommands { - name := randValidCommandName(prng) - _, err := impl.register(name, reflect.TypeOf(nil)) - require.NoError(t, err) - model[name] = []micro.Command{} - } - - for range opsMax { - op := testutils.RandWeightedOp(prng, commandOps) - switch op { - case cr_receive: - batchSize := prng.IntN(200) + 1 - batch := make([]micro.Command, batchSize) - for i := range batchSize { - name := testutils.RandMapKey(prng, model) - batch[i] = micro.Command{Name: name} - } - - impl.receiveCommands(batch) - for _, cmd := range batch { - name := cmd.Name - model[name] = append(model[name], cmd) - } - - // NOTE: World calls clear before every tick so commands from previous ticks aren't processed - // again in the current tick. Here, we call clear randomly to explore edge cases and make sure - // the implementation is sound even when we're not clearing before every get. - case cr_clear: - impl.clear() - for name := range model { - model[name] = model[name][:0] - } - - case cr_get: - name := testutils.RandMapKey(prng, model) - implBuf, err := impl.get(name) - require.NoError(t, err) - assert.Equal(t, model[name], implBuf, "buffer content mismatch for %q", name) - - default: - panic("unreachable") - } - } - - // Final state check: all buffers match model. - assert.Len(t, impl.catalog, len(model), "catalog length mismatch") - for name, modelBuf := range model { - implBuf, err := impl.get(name) - require.NoError(t, err, "command %q should be registered", name) - assert.Len(t, implBuf, len(modelBuf), "buffer length mismatch for %q", name) - } -} - -type commandOp uint8 - -const ( - cr_receive commandOp = 50 - cr_clear commandOp = 20 - cr_get commandOp = 30 -) - -var commandOps = []commandOp{cr_receive, cr_clear, cr_get} - -func randValidCommandName(prng *rand.Rand) string { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" - length := prng.IntN(50) + 1 // 1-50 characters - b := make([]byte, length) - for i := range b { - b[i] = chars[prng.IntN(len(chars))] - } - return string(b) -} - -// ------------------------------------------------------------------------------------------------- -// Model-based fuzzing command registration -// ------------------------------------------------------------------------------------------------- -// This test verifies the commandManager registration correctness using model-based testing. It -// compares our implementation against a map[string]CommandID as the model by applying random -// register operations and asserting equivalence. We also verify structural invariants: -// name-id bijection and ID uniqueness. -// ------------------------------------------------------------------------------------------------- - -func TestCommand_RegisterModelFuzz(t *testing.T) { - t.Parallel() - prng := testutils.NewRand(t) - - const opsMax = 1 << 15 // 32_768 iterations - - impl := newCommandManager() - model := make(map[string]CommandID) // name -> ID - - for range opsMax { - name := randValidCommandName(prng) - implID, err := impl.register(name, reflect.TypeOf(nil)) - require.NoError(t, err) - - if modelID, exists := model[name]; exists { - assert.Equal(t, modelID, implID, "ID mismatch for re-registered %q", name) - } else { - model[name] = implID - } - } - - // Property: bijection holds between names and IDs. - seenIDs := make(map[CommandID]string) - for name, id := range impl.catalog { - if prevName, seen := seenIDs[id]; seen { - t.Errorf("ID %d is mapped by both %q and %q", id, prevName, name) - } - seenIDs[id] = name - } - - // Property: all IDs in catalog are in range [0, nextID). - for name, id := range impl.catalog { - assert.Less(t, id, impl.nextID, "ID for %q is out of range", name) - } - - // Final state check: catalog matches model. - assert.Len(t, impl.catalog, len(model), "catalog length mismatch") - for name, modelID := range model { - implID, exists := impl.catalog[name] - require.True(t, exists, "command %q should be registered", name) - assert.Equal(t, modelID, implID, "ID mismatch for %q", name) - } - - // Simple test to confirm that registering the same name repeatedly is a no-op. - t.Run("registration idempotence", func(t *testing.T) { - t.Parallel() - - id1, err := impl.register("hello", reflect.TypeOf(nil)) - require.NoError(t, err) - - id2, err := impl.register("hello", reflect.TypeOf(nil)) - require.NoError(t, err) - - assert.Equal(t, id1, id2) - - id3, err := impl.register("a_different_name", reflect.TypeOf(nil)) - require.NoError(t, err) - - assert.Equal(t, id1+1, id3) - }) -} diff --git a/pkg/cardinal/ecs/event.go b/pkg/cardinal/ecs/event.go deleted file mode 100644 index 1cfa1d386..000000000 --- a/pkg/cardinal/ecs/event.go +++ /dev/null @@ -1,151 +0,0 @@ -package ecs - -import ( - "reflect" - "sync" - - "github.com/argus-labs/world-engine/pkg/assert" - "github.com/rotisserie/eris" -) - -// Event is an interface that all events must implement. -// Events are packets of information that are sent from systems to the outside world. -type Event = Command - -// EventKind is a type that represents the kind of event. -type EventKind uint8 - -const ( - // EventKindDefault is the default event kind. - EventKindDefault EventKind = 1 - - // Reserve 0 for zero value / unspecified event kind in protobuf. - // Reserve 14 more values (2...15) for future ecs event kind. - // Users of the `ecs` package should start with CustomEventKindStart for their custom event kinds. - // Example: - // - // const ( - // EventKindCustom = iota + ecs.CustomEventKindStart - // ) -) - -const CustomEventKindStart = 16 - -// RawEvent is the format of ECS output. It has a kind and a payload. The kind determines the type -// of event contained in the payload. Users of ECS can define custom event kinds and handle them in -// their own event handlers. -type RawEvent struct { - Kind EventKind // The kind of event - Payload any // The payload of the event -} - -const ( - defaultEventChannelCapacity = 1024 - defaultEventBufferCapacity = 128 -) - -// eventManager manages the registration and storage of events. -type eventManager struct { - events chan RawEvent // Channel for collecting events emitted by systems - buffer []RawEvent // Buffer for storing events to be outputted - mu sync.Mutex // Mutex for buffer access during flush - registry map[string]uint32 // Map from event name to event ID - types map[string]reflect.Type // Event name -> reflect.Type - nextID uint32 // Next available event ID -} - -// newEventManager creates a new eventManager with optional configuration. -func newEventManager(opts ...eventManagerOption) *eventManager { - em := &eventManager{ - events: make(chan RawEvent, defaultEventChannelCapacity), - buffer: make([]RawEvent, 0, defaultEventBufferCapacity), - registry: make(map[string]uint32), - types: make(map[string]reflect.Type), - nextID: 0, - } - for _, opt := range opts { - opt(em) - } - return em -} - -// register registers an event type and returns its ID. If already registered, returns existing ID. -// This is used just to check for duplicate WithEvent handlers in a system. -func (e *eventManager) register(name string, typ reflect.Type) (uint32, error) { - if name == "" { - return 0, eris.New("event name cannot be empty") - } - - if id, exists := e.registry[name]; exists { - return id, nil - } - - if e.nextID > MaxCommandID { - return 0, eris.New("max number of events exceeded") - } - - e.registry[name] = e.nextID - e.types[name] = typ - e.nextID++ - return e.nextID - 1, nil -} - -// enqueue enqueues an event into the eventManager. -// If the channel is full, it flushes the channel to the buffer first. -func (e *eventManager) enqueue(kind EventKind, payload any) { - event := RawEvent{Kind: kind, Payload: payload} - select { - case e.events <- event: - // Happy path: channel has capacity. - default: - // Channel full: flush to buffer, then send. - e.mu.Lock() - e.flush() - e.mu.Unlock() - - e.events <- event - } -} - -// getEvents retrieves all events from the eventManager. -func (e *eventManager) getEvents() []RawEvent { - e.mu.Lock() - defer e.mu.Unlock() - - e.flush() - - return e.buffer -} - -// flush drains the channel into the buffer. Called when channel is full. -// TThis method expects the caller to hold tthe mutex lock. -func (e *eventManager) flush() { - for { - select { - case event := <-e.events: - e.buffer = append(e.buffer, event) - default: - return - } - } -} - -// clear clears the event buffer. -func (e *eventManager) clear() { - e.mu.Lock() - defer e.mu.Unlock() - e.buffer = e.buffer[:0] - assert.That(len(e.buffer) == 0, "event buffer not cleared properly") -} - -// ------------------------------------------------------------------------------------------------- -// Options -// ------------------------------------------------------------------------------------------------- - -type eventManagerOption func(*eventManager) - -func withChannelCapacity(capacity int) eventManagerOption { - return func(em *eventManager) { - em.events = make(chan RawEvent, capacity) - } -} diff --git a/pkg/cardinal/ecs/event_internal_test.go b/pkg/cardinal/ecs/event_internal_test.go deleted file mode 100644 index 9e5258196..000000000 --- a/pkg/cardinal/ecs/event_internal_test.go +++ /dev/null @@ -1,192 +0,0 @@ -package ecs - -import ( - "reflect" - "testing" - "testing/synctest" - - "github.com/argus-labs/world-engine/pkg/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ------------------------------------------------------------------------------------------------- -// Model-based fuzzing event manager operations -// ------------------------------------------------------------------------------------------------- -// This test verifies the eventManager implementation correctness using model-based testing. It -// compares our implementation against two slices (inFlight and buffer) as the model by applying -// random sequences of enqueue/getEvents/clear operations to both and asserting equivalence. -// The model tracks events in two stages: inFlight (channel) and buffer (drained events). -// ------------------------------------------------------------------------------------------------- - -func TestEvent_ModelFuzz(t *testing.T) { - t.Parallel() - prng := testutils.NewRand(t) - - const opsMax = 1 << 15 // 32_768 iterations - - impl := newEventManager() - // Model: track in-flight (channel) and buffered events separately - inFlight := make([]RawEvent, 0) // events enqueued but not yet drained - buffer := make([]RawEvent, 0) // events in the buffer after getEvents - - for range opsMax { - op := testutils.RandWeightedOp(prng, eventOps) - switch op { - case em_enqueue: - n := prng.IntN(10) + 1 - for range n { - kind := EventKind(prng.IntN(int(CustomEventKindStart)) + 1) - payload := prng.Int() - event := RawEvent{Kind: kind, Payload: payload} - - impl.enqueue(kind, payload) - inFlight = append(inFlight, event) - } - case em_get: - implEvents := impl.getEvents() - buffer = append(buffer, inFlight...) - inFlight = inFlight[:0] - assert.Equal(t, buffer, implEvents, "getEvents mismatch") - case em_clear: - impl.clear() - buffer = buffer[:0] - default: - panic("unreachable") - } - } - - // Final state check: drain remaining and compare to model. - implEvents := impl.getEvents() - buffer = append(buffer, inFlight...) - assert.Equal(t, buffer, implEvents, "final buffer mismatch") -} - -type eventOp uint8 - -const ( - em_enqueue eventOp = 75 - em_get eventOp = 20 - em_clear eventOp = 5 -) - -var eventOps = []eventOp{em_enqueue, em_get, em_clear} - -// ------------------------------------------------------------------------------------------------- -// Channel overflow regression test -// ------------------------------------------------------------------------------------------------- -// This test verifies that enqueue does not block when the channel is full. Before the fix, -// enqueue would block indefinitely when the channel capacity (1024) was exceeded, causing -// a deadlock. After the fix, enqueue should flush the channel to the buffer when full. -// ------------------------------------------------------------------------------------------------- - -func TestEvent_EnqueueChannelFull(t *testing.T) { - t.Parallel() - - synctest.Test(t, func(t *testing.T) { - const channelCapacity = 16 - const totalEvents = channelCapacity * 3 // Well beyond channel capacity - - impl := newEventManager(withChannelCapacity(channelCapacity)) - - // Enqueue more events than channel capacity. - // Before fix: this blocks forever after 16 events, causing deadlock. - // After fix: this completes without blocking. - done := false - go func() { - for i := range totalEvents { - impl.enqueue(EventKindDefault, i) - } - done = true - }() - - // Wait for all goroutines to complete or durably block. - // If enqueue blocks, synctest.Test will detect deadlock and fail. - synctest.Wait() - - if !done { - t.Fatal("enqueue blocked: channel overflow not handled") - } - - // Verify all events are captured. - events := impl.getEvents() - assert.Len(t, events, totalEvents, "expected all %d events to be captured", totalEvents) - - // Verify data integrity. - for i, event := range events { - assert.Equal(t, EventKindDefault, event.Kind, "event kind mismatch at index %d", i) - assert.Equal(t, i, event.Payload, "payload mismatch at index %d", i) - } - }) -} - -// ------------------------------------------------------------------------------------------------- -// Model-based fuzzing event registration -// ------------------------------------------------------------------------------------------------- -// This test verifies the eventManager registration correctness using model-based testing. It -// compares our implementation against a map[string]uint32 as the model by applying random -// register operations and asserting equivalence. We also verify structural invariants: -// name-id bijection and ID uniqueness. -// ------------------------------------------------------------------------------------------------- - -func TestEvent_RegisterModelFuzz(t *testing.T) { - t.Parallel() - prng := testutils.NewRand(t) - - const opsMax = 1 << 15 // 32_768 iterations - - impl := newEventManager() - model := make(map[string]uint32) // name -> ID - - for range opsMax { - name := randValidCommandName(prng) - implID, err := impl.register(name, reflect.TypeOf(name)) - require.NoError(t, err) - - if modelID, exists := model[name]; exists { - assert.Equal(t, modelID, implID, "ID mismatch for re-registered %q", name) - } else { - model[name] = implID - } - } - - // Property: bijection holds between names and IDs. - seenIDs := make(map[uint32]string) - for name, id := range impl.registry { - if prevName, seen := seenIDs[id]; seen { - t.Errorf("ID %d is mapped by both %q and %q", id, prevName, name) - } - seenIDs[id] = name - } - - // Property: all IDs in registry are in range [0, nextID). - for name, id := range impl.registry { - assert.Less(t, id, impl.nextID, "ID for %q is out of range", name) - } - - // Final state check: registry matches model. - assert.Len(t, impl.registry, len(model), "registry length mismatch") - for name, modelID := range model { - implID, exists := impl.registry[name] - require.True(t, exists, "event %q should be registered", name) - assert.Equal(t, modelID, implID, "ID mismatch for %q", name) - } - - // Simple test to confirm that registering the same name repeatedly is a no-op. - t.Run("registration idempotence", func(t *testing.T) { - t.Parallel() - - id1, err := impl.register("hello", reflect.TypeOf(nil)) - require.NoError(t, err) - - id2, err := impl.register("hello", reflect.TypeOf(nil)) - require.NoError(t, err) - - assert.Equal(t, id1, id2) - - id3, err := impl.register("a_different_name", reflect.TypeOf(nil)) - require.NoError(t, err) - - assert.Equal(t, id1+1, id3) - }) -} diff --git a/pkg/cardinal/ecs/internal/testutils/commands.go b/pkg/cardinal/ecs/internal/testutils/commands.go deleted file mode 100644 index 583425dfc..000000000 --- a/pkg/cardinal/ecs/internal/testutils/commands.go +++ /dev/null @@ -1,39 +0,0 @@ -package testutils - -// Commands. - -type AttackPlayerCommand struct{ Value int } - -func (AttackPlayerCommand) Name() string { return "attack-player" } - -type InvalidEmptyCommand struct{} - -func (InvalidEmptyCommand) Name() string { return "" } - -type CreatePlayerCommand struct{ Value int } - -func (CreatePlayerCommand) Name() string { return "create-player" } - -// Events. - -type PlayerDeathEvent struct{ Value int } - -func (PlayerDeathEvent) Name() string { return "player-death" } - -type ItemDropEvent struct{ Value int } - -func (ItemDropEvent) Name() string { return "item-drop" } - -type EmptySubjectEvent struct{ Value int } - -func (EmptySubjectEvent) Name() string { return "" } - -// System events. - -type PlayerDeathSystemEvent struct{ Value int } - -func (PlayerDeathSystemEvent) Name() string { return "player-death-system" } - -type ItemDropSystemEvent struct{ Value int } - -func (ItemDropSystemEvent) Name() string { return "item-drop-system" } diff --git a/pkg/cardinal/ecs/internal/testutils/components.go b/pkg/cardinal/ecs/internal/testutils/components.go deleted file mode 100644 index aa1685df8..000000000 --- a/pkg/cardinal/ecs/internal/testutils/components.go +++ /dev/null @@ -1,33 +0,0 @@ -package testutils - -type Health struct { - Value int `json:"value"` -} - -func (Health) Name() string { return "Health" } - -type Position struct{ X, Y int } - -func (Position) Name() string { return "Position" } - -type Velocity struct{ X, Y int } - -func (Velocity) Name() string { return "Velocity" } - -type Experience struct{ Value int } - -func (Experience) Name() string { return "Experience" } - -type PlayerTag struct{ Tag string } - -func (PlayerTag) Name() string { return "PlayerTag" } - -type Level struct{ Value int } - -func (Level) Name() string { return "Level" } - -type MapComponent struct { - Items map[string]int `json:"items"` -} - -func (MapComponent) Name() string { return "MapComponent" } diff --git a/pkg/cardinal/ecs/search_test.go b/pkg/cardinal/ecs/search_test.go deleted file mode 100644 index 2c089162a..000000000 --- a/pkg/cardinal/ecs/search_test.go +++ /dev/null @@ -1,327 +0,0 @@ -package ecs_test - -import ( - "testing" - - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - . "github.com/argus-labs/world-engine/pkg/cardinal/ecs/internal/testutils" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -type initSystemState struct { - Position ecs.Exact[struct{ ecs.Ref[Position] }] - Health ecs.Exact[struct{ ecs.Ref[Health] }] - Velocity ecs.Exact[struct{ ecs.Ref[Velocity] }] - PositionHealth ecs.Exact[struct { - Position ecs.Ref[Position] - Health ecs.Ref[Health] - }] - PositionVelocity ecs.Exact[struct { - Position ecs.Ref[Position] - Velocity ecs.Ref[Velocity] - }] - HealthVelocity ecs.Exact[struct { - Health ecs.Ref[Health] - Velocity ecs.Ref[Velocity] - }] -} - -func TestSearch_Validation(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - params ecs.SearchParam - wantErr bool - }{ - { - name: "empty component list", - params: ecs.SearchParam{ - Find: []string{}, - Match: ecs.MatchExact, - }, - wantErr: true, - }, - { - name: "invalid match type", - params: ecs.SearchParam{ - Find: []string{"Position"}, - Match: "invalid", - }, - wantErr: true, - }, - { - name: "unregistered component", - params: ecs.SearchParam{ - Find: []string{"UnregisteredComponent"}, - Match: ecs.MatchExact, - }, - wantErr: true, - }, - { - name: "invalid where clause syntax", - params: ecs.SearchParam{ - Find: []string{"Health"}, - Match: ecs.MatchExact, - Where: "Health.Value >", - }, - wantErr: true, - }, - { - name: "valid params", - params: ecs.SearchParam{ - Find: []string{"Position"}, - Match: ecs.MatchExact, - Where: "Position.X > 0", - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - w := ecs.NewWorld() - ecs.RegisterSystem(w, func(state *initSystemState) error { - return nil // Placeholder system to register components - }, ecs.WithHook(ecs.Init)) - - w.Init() - - _, err := w.Tick(nil) - require.NoError(t, err) - - _, err = w.NewSearch(tt.params) - if tt.wantErr { - assert.Error(t, err) - } else { - assert.NoError(t, err) - } - }) - } -} - -func TestSearch_FindAndMatch(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - params ecs.SearchParam - setup func(*initSystemState) error - validate func(*testing.T, []map[string]any) - }{ - { - name: "exact match single component", - params: ecs.SearchParam{ - Find: []string{"Position"}, - Match: ecs.MatchExact, - }, - setup: func(state *initSystemState) error { - _, position := state.Position.Create() - position.Set(Position{X: 1, Y: 2}) - - _, position = state.Position.Create() - position.Set(Position{X: 3, Y: 4}) - - return nil - }, - validate: func(t *testing.T, results []map[string]any) { - assert.Len(t, results, 2) - assert.Contains(t, results, - map[string]any{"Position": Position{X: 1, Y: 2}, "_id": uint32(0)}) - assert.Contains(t, results, - map[string]any{"Position": Position{X: 3, Y: 4}, "_id": uint32(1)}) - }, - }, - { - name: "contains match single component", - params: ecs.SearchParam{ - Find: []string{"Position"}, - Match: ecs.MatchContains, - }, - setup: func(state *initSystemState) error { - _, position := state.Position.Create() - position.Set(Position{X: 1, Y: 2}) - - _, positionHealth := state.PositionHealth.Create() - positionHealth.Position.Set(Position{X: 3, Y: 4}) - positionHealth.Health.Set(Health{Value: 100}) - - return nil - }, - validate: func(t *testing.T, results []map[string]any) { - assert.Len(t, results, 2) - assert.Contains(t, results, - map[string]any{"Position": Position{X: 1, Y: 2}, "_id": uint32(0)}) - assert.Contains(t, results, - map[string]any{ - "Position": Position{X: 3, Y: 4}, - "Health": Health{Value: 100}, - "_id": uint32(1), - }) - }, - }, - { - name: "empty result for no matching entities", - params: ecs.SearchParam{ - Find: []string{"Health"}, - Match: ecs.MatchExact, - }, - setup: func(state *initSystemState) error { - _, position := state.Position.Create() - position.Set(Position{X: 1, Y: 2}) - return nil - }, - validate: func(t *testing.T, results []map[string]any) { - assert.Empty(t, results) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - w := ecs.NewWorld() - ecs.RegisterSystem(w, tt.setup, ecs.WithHook(ecs.Init)) - - w.Init() - - _, err := w.Tick(nil) - require.NoError(t, err) - - results, err := w.NewSearch(tt.params) - require.NoError(t, err) - - tt.validate(t, results) - }) - } -} - -// TestSearch_Where tests the Where clause filtering functionality. -func TestSearch_Where(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - params ecs.SearchParam - setup func(*initSystemState) error - validate func(*testing.T, []map[string]any) - }{ - { - name: "filter by health value", - params: ecs.SearchParam{ - Find: []string{"Health"}, - Match: ecs.MatchContains, - Where: "Health.Value > 75", - }, - setup: func(state *initSystemState) error { - _, health := state.Health.Create() - health.Set(Health{Value: 100}) - - _, health = state.Health.Create() - health.Set(Health{Value: 50}) - - _, health = state.Health.Create() - health.Set(Health{Value: 80}) - - return nil - }, - validate: func(t *testing.T, results []map[string]any) { - assert.Len(t, results, 2) - for _, entity := range results { - health := entity["Health"].(Health) - assert.Greater(t, health.Value, 75) - } - }, - }, - { - name: "filter by position coordinates", - params: ecs.SearchParam{ - Find: []string{"Position"}, - Match: ecs.MatchContains, - Where: "Position.X > 0 && Position.Y > 0", - }, - setup: func(state *initSystemState) error { - _, position := state.Position.Create() - position.Set(Position{X: 1, Y: 2}) - - _, position = state.Position.Create() - position.Set(Position{X: -1, Y: 2}) - - _, position = state.Position.Create() - position.Set(Position{X: 3, Y: -4}) - - _, position = state.Position.Create() - position.Set(Position{X: 5, Y: 6}) - - return nil - }, - validate: func(t *testing.T, results []map[string]any) { - assert.Len(t, results, 2) - for _, entity := range results { - pos := entity["Position"].(Position) - assert.Positive(t, pos.X) - assert.Positive(t, pos.Y) - } - }, - }, - { - name: "complex filter with multiple components", - params: ecs.SearchParam{ - Find: []string{"Position", "Health"}, - Match: ecs.MatchContains, - Where: "Position.X > 0 && Health.Value >= 100", - }, - setup: func(state *initSystemState) error { - _, positionHealth := state.PositionHealth.Create() - positionHealth.Position.Set(Position{X: 1, Y: 2}) - positionHealth.Health.Set(Health{Value: 100}) - - _, positionHealth = state.PositionHealth.Create() - positionHealth.Position.Set(Position{X: -1, Y: 2}) - positionHealth.Health.Set(Health{Value: 100}) - - _, positionHealth = state.PositionHealth.Create() - positionHealth.Position.Set(Position{X: 3, Y: 4}) - positionHealth.Health.Set(Health{Value: 50}) - - _, positionHealth = state.PositionHealth.Create() - positionHealth.Position.Set(Position{X: 5, Y: 6}) - positionHealth.Health.Set(Health{Value: 150}) - - return nil - }, - validate: func(t *testing.T, results []map[string]any) { - assert.Len(t, results, 2) - for _, entity := range results { - pos := entity["Position"].(Position) - health := entity["Health"].(Health) - assert.Positive(t, pos.X) - assert.GreaterOrEqual(t, health.Value, 100) - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - w := ecs.NewWorld() - ecs.RegisterSystem(w, tt.setup, ecs.WithHook(ecs.Init)) - - w.Init() - - _, err := w.Tick(nil) - require.NoError(t, err) - - results, err := w.NewSearch(tt.params) - require.NoError(t, err) - - tt.validate(t, results) - }) - } -} diff --git a/pkg/cardinal/ecs/system.go b/pkg/cardinal/ecs/system.go deleted file mode 100644 index 8b09b2a80..000000000 --- a/pkg/cardinal/ecs/system.go +++ /dev/null @@ -1,86 +0,0 @@ -package ecs - -import ( - "fmt" - - "github.com/rotisserie/eris" -) - -// System is a function that contains game logic. -type System[T any] func(state *T) error - -func RegisterSystem[T any](w *World, system System[T], opts ...SystemOption) { - // Apply options to the default config. - cfg := newSystemConfig() - for _, opt := range opts { - opt(&cfg) - } - - // Initialize the fields in the system state. - state := new(T) - componentDeps, err := initializeSystemState(w, state, cfg.modifiers) - if err != nil { - panic(eris.Wrapf(err, "failed to register system %T", system)) - } - - name := fmt.Sprintf("%T", system) - systemFn := func() error { return system(state) } - - switch cfg.hook { - case Init: - w.initSystems = append(w.initSystems, initSystem{name: name, fn: systemFn}) - case PreUpdate, Update, PostUpdate: - w.scheduler[cfg.hook].register(name, componentDeps, systemFn) - default: - panic("invalid system hook") - } -} - -// initSystem represents a system that should be run once during world initialization. -type initSystem struct { - name string // The name of the system - fn func() error // Function that wraps a System -} - -// systemConfig holds all configurable options for system registration. -type systemConfig struct { - // The hook that determines when the system should be executed. - hook SystemHook - // Functions that can be applied to the system state fields during initialization. - modifiers map[systemStateFieldType]func(any) error -} - -// newSystemConfig creates a new system config with default values. -func newSystemConfig() systemConfig { - return systemConfig{ - hook: Update, - modifiers: make(map[systemStateFieldType]func(any) error, 0), - } -} - -// SystemOption is a function that configures a SystemConfig. -type SystemOption func(*systemConfig) - -// SystemHook defines when a system should be executed in the update cycle. -type SystemHook uint8 - -const ( - // PreUpdate runs before the main update. - PreUpdate SystemHook = 0 - // Update runs during the main update phase. - Update SystemHook = 1 - // PostUpdate runs after the main update. - PostUpdate SystemHook = 2 - // Init runs once during world initialization. - Init SystemHook = 3 -) - -// WithHook returns an option to set the system hook. -func WithHook(hook SystemHook) SystemOption { - return func(cfg *systemConfig) { cfg.hook = hook } -} - -// WithModifier returns an option to set a modifier for a specific field type. -func WithModifier(fieldType systemStateFieldType, fn func(any) error) SystemOption { - return func(cfg *systemConfig) { cfg.modifiers[fieldType] = fn } -} diff --git a/pkg/cardinal/ecs/system_state.go b/pkg/cardinal/ecs/system_state.go deleted file mode 100644 index c96563d99..000000000 --- a/pkg/cardinal/ecs/system_state.go +++ /dev/null @@ -1,792 +0,0 @@ -package ecs - -import ( - "iter" - "math" - "reflect" - - "github.com/argus-labs/world-engine/pkg/assert" - "github.com/argus-labs/world-engine/pkg/micro" - "github.com/kelindar/bitmap" - "github.com/rotisserie/eris" -) - -// systemStateField defines the interface for system state initialization and basic entity -// operations. All system state fields must implement this interface. -type systemStateField interface { - init(*World) (bitmap.Bitmap, error) - tag() systemStateFieldType -} - -var _ systemStateField = &BaseSystemState{} -var _ systemStateField = &WithCommand[Command]{} -var _ systemStateField = &WithEvent[Event]{} -var _ systemStateField = &WithSystemEventReceiver[SystemEvent]{} -var _ systemStateField = &WithSystemEventEmitter[SystemEvent]{} -var _ systemStateField = &search[any]{} -var _ systemStateField = &Contains[any]{} -var _ systemStateField = &Exact[any]{} - -// ------------------------------------------------------------------------------------------------- -// Base System State Field -// ------------------------------------------------------------------------------------------------- - -// BaseSystemState is a barebones system state field that can be embedded in your custom system -// state types to allow your systems to access the world state. It provides a raw event emitter that -// can be used to emit custom events. -// -// Example: -// -// // Define your system state by embedding BaseState. -// type DebugSystemState struct { -// ecs.BaseSystemState -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func DebugSystem(state *DebugSystemState) error { -// state.EmitRawEvent(EventKindCustom, "my custom event") -// return nil -// } -type BaseSystemState struct { - world *World -} - -// init initializes the base system state. -func (b *BaseSystemState) init(w *World) (bitmap.Bitmap, error) { - b.world = w - return bitmap.Bitmap{}, nil -} - -// tag returns the type of system state field. -func (b *BaseSystemState) tag() systemStateFieldType { - return FieldBase -} - -// UnsafeWorldState returns a pointer to the world's underlying state. Use this only when you know -// what you're doing as it's possible to mess up the world state. -func (b *BaseSystemState) UnsafeWorldState() *worldState { //nolint:revive // it's ok - return b.world.state -} - -// EmitRawEvent emits a raw event to the world with the given event kind and payload. -func (b *BaseSystemState) EmitRawEvent(kind EventKind, payload any) { - b.world.events.enqueue(kind, payload) -} - -// ------------------------------------------------------------------------------------------------- -// Commands Fields -// ------------------------------------------------------------------------------------------------- - -// WithCommand is a generic system state field that allows systems to receive commands of type T. -// The commands are automatically registered when the system is registered. -// -// Example: -// -// // Define a command type for spawning players. -// type SpawnPlayer struct{ Nickname string } -// -// func (SpawnPlayer) Name() string { return "spawn-player" } -// -// // Define your system state. -// type SpawnSystemState struct { -// SpawnPlayerCommands ecs.WithCommand[SpawnPlayer] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func SpawnSystem(state *SpawnSystemState) error { -// for cmd := range state.SpawnPlayerCommands.Iter() { -// persona := cmd.Persona() -// spawnData := cmd.Payload() -// // Process spawn commands based on persona and payload. -// } -// return nil -// } -type WithCommand[T Command] struct { - world *World -} - -// init initializes the command state field. -func (m *WithCommand[T]) init(w *World) (bitmap.Bitmap, error) { - var zero T - - id, err := w.commands.register(zero.Name(), reflect.TypeOf(zero)) - if err != nil { - return bitmap.Bitmap{}, eris.Wrapf(err, "failed to register command %s", zero.Name()) - } - m.world = w - - // Set the command ID in the bitmap so we can check that a system doesn't contain multiple - // WithCommand fields with the same command type. - deps := bitmap.Bitmap{} - deps.Set(uint32(id)) - - return deps, nil -} - -// tag returns the type of system state field. -func (m *WithCommand[T]) tag() systemStateFieldType { - return FieldCommand -} - -// Iter returns an iterator over all commands of type T. -// -// Example usage: -// -// for cmd := range state.SpawnPlayerCommands.Iter() { -// persona := cmd.Persona() -// payload := cmd.Payload() -// // Process each command -// } -func (m *WithCommand[T]) Iter() iter.Seq[CommandContext[T]] { - var zero T - commands, err := m.world.commands.get(zero.Name()) - assert.That(err == nil, "command not automatically registered %s", zero.Name()) - - return func(yield func(CommandContext[T]) bool) { - for _, command := range commands { - ctx := newCommandContext[T](&command) - if !yield(ctx) { - return - } - } - } -} - -// CommandContext wraps a micro.Command and provides typed access to command data and metadata. -type CommandContext[T Command] struct { - raw *micro.Command -} - -// newCommandContext creates a new CommandContext wrapping the given micro.Command. -func newCommandContext[T Command](raw *micro.Command) CommandContext[T] { - return CommandContext[T]{raw: raw} -} - -// Payload returns the strongly-typed command payload. -func (c CommandContext[T]) Payload() T { - payload, ok := c.raw.Payload.(T) - assert.That(ok, "mismatched command type passed to ecs") - return payload -} - -// Persona returns the persona (sender) of the command. -func (c CommandContext[T]) Persona() string { - return c.raw.Persona -} - -// ------------------------------------------------------------------------------------------------- -// Events Fields -// ------------------------------------------------------------------------------------------------- - -// WithEvent is a generic system state field that allows systems to emit events of type T. -// -// Example: -// -// // Define an event type for level ups. -// type LevelUp struct{ Nickname string } -// -// func (LevelUp) Name() string { return "level-up" } -// -// type LevelUpSystemState struct { -// LevelUpEvents ecs.WithEvent[LevelUp] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func LevelUpSystem(state *LevelUpSystemState) error { -// // Emit a level up event. -// state.LevelUpEvents.Emit(LevelUp{Nickname: "Player1"}) -// return nil -// } -type WithEvent[T Event] struct { - world *World -} - -// init initializes the event state field. -func (e *WithEvent[T]) init(w *World) (bitmap.Bitmap, error) { - var zero T - - id, err := w.events.register(zero.Name(), reflect.TypeOf(zero)) - if err != nil { - return bitmap.Bitmap{}, eris.Wrapf(err, "failed to register event %s", zero.Name()) - } - e.world = w - - deps := bitmap.Bitmap{} - deps.Set(id) - return deps, nil -} - -// tag returns the type of system state field. -func (e *WithEvent[T]) tag() systemStateFieldType { - return FieldEvent -} - -// Emit emits an event of tpe T. -// -// Example: -// -// state.LevelUpEvents.Emit(LevelUp{Nickname: "Player1"}) -func (e *WithEvent[T]) Emit(event T) { - e.world.events.enqueue(EventKindDefault, event) -} - -// ------------------------------------------------------------------------------------------------- -// System Event Fields -// ------------------------------------------------------------------------------------------------- - -// WithSystemEventReceiver is a generic system state field that allows systems to receive system -// events of type T. System events are automatically registered when the system is registered. -// -// Example: -// -// // Define a system event for player deaths. -// type PlayerDeath struct{ Nickname string } -// -// func (PlayerDeath) Name() string { return "player-death" } -// -// type GraveyardSystemState struct { -// PlayerDeathSystemEvents ecs.WithSystemEventReceiver[PlayerDeath] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func GraveyardSystem(state *GraveyardSystemState) error { -// // Receive system events emitted from another system. -// for systemEvent := range state.PlayerDeathSystemEvents.Iter() { -// // Process the system event. -// } -// return nil -// } -type WithSystemEventReceiver[T SystemEvent] struct { - world *World -} - -// init initializes the system event state field. -func (s *WithSystemEventReceiver[T]) init(w *World) (bitmap.Bitmap, error) { - var zero T - - id, err := w.systemEvents.register(zero.Name()) - if err != nil { - return bitmap.Bitmap{}, eris.Wrapf(err, "failed to register system event") - } - s.world = w - - // Set the system event ID in the bitmap so the scheduler can order the systems correctly. - deps := bitmap.Bitmap{} - deps.Set(id) - - return deps, nil -} - -// tag returns the type of system state field. -func (s *WithSystemEventReceiver[T]) tag() systemStateFieldType { - return FieldSystemEventReceiver -} - -// Iter returns an iterator over all system events of type T. -// -// Example usage: -// -// for systemEvent := range state.PlayerDeathEvents.Iter() { -// // Process each system event -// } -func (s *WithSystemEventReceiver[T]) Iter() iter.Seq[T] { - var zero T - systemEvents := s.world.systemEvents.get(zero.Name()) - - return func(yield func(T) bool) { - for _, systemEvent := range systemEvents { - if !yield(systemEvent.(T)) { //nolint:errcheck // We know the type - return - } - } - } -} - -// WithSystemEventEmitter is a generic system state field that allows systems to emit system events -// of type T. System events are automatically registered when the system is registered. -// -// Example: -// -// // Define a system event for player deaths. -// type PlayerDeath struct{ Nickname string } -// -// func (PlayerDeath) Name() string { return "player-death" } -// -// type CombatSystemState struct { -// PlayerDeathSystemEvents ecs.WithSystemEventEmitter[PlayerDeath] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func CombatSystem(state *CombatSystemState) error { -// // Emit a player death event to be handled in another system. -// state.PlayerDeathEvents.Emit(PlayerDeath{Nickname: "Player1"}) -// return nil -// } -type WithSystemEventEmitter[T SystemEvent] struct { - world *World -} - -// init initializes the system event state field. -func (s *WithSystemEventEmitter[T]) init(w *World) (bitmap.Bitmap, error) { - var zero T - - id, err := w.systemEvents.register(zero.Name()) - if err != nil { - return bitmap.Bitmap{}, eris.Wrapf(err, "failed to register system event") - } - s.world = w - - // Set the system event ID in the bitmap so the scheduler can order the systems correctly. - deps := bitmap.Bitmap{} - deps.Set(id) - - return deps, nil -} - -// tag returns the type of system state field. -func (s *WithSystemEventEmitter[T]) tag() systemStateFieldType { - return FieldSystemEventEmitter -} - -// Emit emits a system event of type T. -// -// Example: -// -// state.PlayerDeathEvents.Emit(PlayerDeath{Nickname: "Player1"}) -func (s *WithSystemEventEmitter[T]) Emit(systemEvent T) { - var zero T - s.world.systemEvents.enqueue(zero.Name(), systemEvent) -} - -// ------------------------------------------------------------------------------------------------- -// Component Search Fields -// ------------------------------------------------------------------------------------------------- - -// search provides type-safe component queries for entities in the world state. It uses reflection -// during initialization to figure out which components to include in the query. T must be a struct -// type composed of fields of only the type Ref[Component], e.g.: -// -// type Particle struct { -// Position ecs.Ref[Position] -// Velocity ecs.Ref[Velocity] -// } -// -// search is used as the base implementation for ecs.Contains and ecs.Exact which provide the -// matching behaviors for finding entities with specific component combinations. Every component -// type used in T will be automatically registered when the system is registered. -type search[T any] struct { - world *World // Reference to the world - components bitmap.Bitmap // Bitmap of component types this search looks for - result T // Reusable instance of the result type - fields []ref // Cached references to result's fields to be initialized in Iter -} - -// init initializes the search by analyzing the generic type's struct fields and caching its -// component dependencies. -func (s *search[T]) init(w *World) (bitmap.Bitmap, error) { - var zero T - resultType := reflect.TypeOf(zero) - resultValue := reflect.ValueOf(&s.result).Elem() - - s.world = w - s.fields = make([]ref, resultType.NumField()) - - for i := range resultType.NumField() { - // Store a ref of the field in the search to be initialized during Iter. - field := resultType.Field(i) - fieldRef, ok := resultValue.Field(i).Addr().Interface().(ref) - if !ok { - return bitmap.Bitmap{}, eris.Errorf("field %s must be of type Ref[Component], got %s", field.Name, field.Type) - } - s.fields[i] = fieldRef - - // Register the component. - cid, err := fieldRef.register(w) - if err != nil { - return bitmap.Bitmap{}, err - } - - // Set the component ID in the bitmap so the scheduler can order the systems correctly. - s.components.Set(cid) - } - - return s.components, nil -} - -// tag returns the type of system state field. -func (s *search[T]) tag() systemStateFieldType { - return FieldComponent -} - -// Create creates a new entity with the given components. Returns an error if any of the components -// are not defined in the search field. -// -// Example: -// -// entity, err := state.Mob.Create(Health{Value: 100}, Position{X: 0, Y: 0}) -// if err != nil { -// state.Logger().Error().Err(err).Msg("Failed to create entity") -// } -// // Use entity... -func (s *search[T]) Create() (EntityID, T) { - ws := s.world.state - eid := ws.newEntityWithArchetype(s.components) - - for i := range s.fields { - s.fields[i].attach(ws, eid) // Attach the entity and world state buffer to the ref - } - - return eid, s.result -} - -// Destroy deletes an entity and all its components from the world. -// -// Example: -// -// ok := state.Mob.Destroy(entityID) -// if !ok { -// state.Logger().Warn().Msg("Entity doesn't exist or is already destroyed") -// } -func (s *search[T]) Destroy(eid EntityID) bool { - return Destroy(s.world.state, eid) -} - -// getByID retrieves an entity's components by its ID using the provided match function to validate -// that the entity's archetype matches the search criteria. -func (s *search[T]) getByID(eid EntityID, match func(*archetype) bool) (T, error) { - ws := s.world.state - - aid, exists := ws.entityArch.get(eid) - if !exists { - var zero T - return zero, ErrEntityNotFound - } - - arch := ws.archetypes[aid] - if !match(arch) { - var zero T - return zero, ErrArchetypeMismatch - } - - for i := range s.fields { - s.fields[i].attach(ws, eid) // Attach the entity and world state buffer to the ref - } - return s.result, nil -} - -// iter returns an iterator over all entities that match the given archetypes. -func (s *search[T]) iter(archetypeIDs []archetypeID) iter.Seq2[EntityID, T] { - ws := s.world.state - return func(yield func(EntityID, T) bool) { - for _, id := range archetypeIDs { - arch := ws.archetypes[id] - for _, eid := range arch.entities { - for i := range s.fields { - s.fields[i].attach(ws, eid) // Attach the entity and world state buffer to the ref - } - - if !yield(eid, s.result) { - return - } - } - } - } -} - -// Contains provides a search that matches archetypes containing all specified component types, -// potentially along with additional components. -// -// Example: -// -// type MovementSystemState struct { -// Movers ecs.Contains[struct { -// Position ecs.Ref[Position] -// Velocity ecs.Ref[Velocity] -// }] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func MovementSystem(state *MovementSystemState) error { -// for entity, mover := range state.Movers.Iter() { -// // Process entity and compnents. -// } -// return nil -// } -type Contains[T any] struct{ search[T] } - -// Iter returns an iterator over entities and their components that match the Contains search. -// -// Example: -// -// for _, mover := range state.Movers.Iter() { -// pos := mover.Position.Get() -// vel := mover.Velocity.Get() -// mover.Position.Set(Position{X: pos.X + vel.X, Y: pos.Y + vel.Y}) -// } -func (c *Contains[T]) Iter() iter.Seq2[EntityID, T] { - return c.iter(c.world.state.archContains(c.components)) -} - -// GetByID retrieves an entity's components by its ID. Returns ErrEntityNotFound if the entity -// doesn't exist, or ErrArchetypeMismatch if the entity doesn't contain all the required components. -// -// Example: -// -// mob, err := state.Mob.GetByID(entityID) -// if err != nil { -// state.Logger().Warn().Err(err).Msg("Entity not found or doesn't match") -// return err -// } -// health := mob.Health.Get() -func (c *Contains[T]) GetByID(eid EntityID) (T, error) { - return c.getByID(eid, func(arch *archetype) bool { - return arch.contains(c.components) - }) -} - -// Exact provides a search that matches archetypes containing exactly the specified component types, -// without any additional components. -// -// Example: -// -// type PlayerSystemState struct { -// Players ecs.Exact[struct { -// Tag ecs.Ref[PlayerTag] -// Health ecs.Ref[Health] -// }] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func PlayerSystem(state *PlayerSystemState) error { -// for entity, player := range state.Players.Iter() { -// // Process entity and compnents. -// } -// return nil -// } -type Exact[T any] struct{ search[T] } - -// Iter returns an iterator over entities and their components that match the Exact query. -// -// Example: -// -// for _, player := range state.Players.Iter() { -// health := player.Health.Get() -// player.Health.Set(Health{HP: health.HP + 100}) -// } -func (c *Exact[T]) Iter() iter.Seq2[EntityID, T] { - archetypes := make([]int, 0, 1) - if id, ok := c.world.state.archExact(c.components); ok { - archetypes = append(archetypes, id) - } - return c.iter(archetypes) -} - -// GetByID retrieves an entity's components by its ID. Returns ErrEntityNotFound if the entity -// doesn't exist, or ErrArchetypeMismatch if the entity doesn't have exactly the required components. -// -// Example: -// -// player, err := state.Players.GetByID(entityID) -// if err != nil { -// state.Logger().Warn().Err(err).Msg("Entity not found or doesn't match") -// return err -// } -// health := player.Health.Get() -func (c *Exact[T]) GetByID(eid EntityID) (T, error) { - return c.getByID(eid, func(arch *archetype) bool { - return arch.exact(c.components) - }) -} - -// ------------------------------------------------------------------------------------------------- -// Component Handles -// ------------------------------------------------------------------------------------------------- - -// ref is an internal interface for component references. -type ref interface { - attach(*worldState, EntityID) - register(*World) (componentID, error) -} - -var _ ref = &Ref[Component]{} - -// Ref provides a type-safe handle to a component on an entity. -type Ref[T Component] struct { - ws *worldState // Internal reference to the world state - entity EntityID // The entity's ID -} - -// attach sets the entity and world state to the Ref so that Get and Set works properly. -func (r *Ref[T]) attach(ws *worldState, eid EntityID) { - r.ws = ws - r.entity = eid -} - -// TODO: might be possible to get the read/write type of the component in the query so we can -// optimize the scheduler by running read-only systems in parallel. e.g., we can have two different -// ref types, ReadOnlyRef and ReadWriteRef. For the read-only ref, we don't have to set its ID in -// the system bitmap. - -// register returns the registerAndGetComponent type for this Ref. -func (r *Ref[T]) register(w *World) (componentID, error) { - return registerComponent[T](w.state) -} - -// Get retrieves the component value for this Ref's entity. -// -// This is the recommended system-friendly alternative to ecs.Get() for accessing components within systems. -// -// Example: -// -// for _, player := range state.Players.Iter() { -// health := player.Health.Get() -// } -func (r *Ref[T]) Get() T { - component, err := Get[T](r.ws, r.entity) - assert.That(err == nil, "entity doesn't exist or doesn't contain the component") // Shouldn't happen - return component -} - -// Set updates the component value for this Ref's entity. -// -// This is the recommended system-friendly alternative to ecs.Set() for modifying components within systems. -// -// Example: -// -// for _, player := range state.Players.Iter() { -// player.Health.Set(Health{HP: 100}) -// } -func (r *Ref[T]) Set(component T) { - err := Set(r.ws, r.entity, component) - assert.That(err == nil, "entity doesn't exist") // Shouldn't happen -} - -// ------------------------------------------------------------------------------------------------- -// Internal -// ------------------------------------------------------------------------------------------------- - -// systemStateFieldType is an enum type for system state field types. -type systemStateFieldType uint8 - -const ( - // FieldComponent is the systemStateFieldType for Contains and Exact. - FieldComponent systemStateFieldType = iota - // FieldSystemEventReceiver is the systemStateFieldType for WithSystemEventReceiver. - FieldSystemEventReceiver - // FieldSystemEventEmitter is the systemStateFieldType for WithSystemEventEmitter. - FieldSystemEventEmitter - // FieldBase is the systemStateFieldType for BaseSystemState. - FieldBase - // FieldEvent is the systemStateFieldType for WithEvent. - FieldEvent - // FieldCommand is the systemStateFieldType for WithCommand. - FieldCommand -) - -// Helper function to initialize fields when registering systems. -func initializeSystemState[T any]( //nolint:gocognit // Will refactor after things are stable - w *World, - state *T, - modifiers map[systemStateFieldType]func(any) error, -) (bitmap.Bitmap, error) { - // Bitmaps used by the scheduler as the system's dependencies. - var componentDeps bitmap.Bitmap - var systemEventDeps bitmap.Bitmap - - // Bitmaps to check for duplicate fields that operate on the same type. A system cannot process - // multiples of the same type, e.g. multiple WithCommand[T] with the same T type. - var commandDeps bitmap.Bitmap - var eventDeps bitmap.Bitmap - var systemEventReceiverDeps bitmap.Bitmap - var systemEventEmitterDeps bitmap.Bitmap - - // For each field in the system state, initialize the field and collect its dependencies. - value := reflect.ValueOf(state).Elem() - for i := range value.NumField() { - field := value.Field(i) - fieldType := value.Type().Field(i) - - // If the field is not exported, return an error. - if !field.CanAddr() { - return componentDeps, eris.Errorf("field %s must be exported", fieldType.Name) - } - - // If the field doesn't implement systemStateField, return an error. This shouldn't happen - // as long as the user sticks to the provided system state field types. - fieldInstance := field.Addr().Interface() - stateField, ok := fieldInstance.(systemStateField) - if !ok { - return componentDeps, eris.Errorf("field %s must implement SystemStateField", fieldType.Name) - } - - // Initialize the field and collect its dependencies. - deps, err := stateField.init(w) - if err != nil { - return componentDeps, eris.Wrapf(err, "failed to initialize field %s", fieldType.Name) - } - - // Add field dependencies to the system dependencies. - tag := stateField.tag() - switch tag { - case FieldComponent: - componentDeps.Or(deps) - case FieldSystemEventReceiver: - if hasDuplicate(systemEventReceiverDeps, deps) { - return componentDeps, eris.New( - "systems cannot declare multiple WithSystemEventReceiver fields of the same system event type") - } - systemEventReceiverDeps.Or(deps) // Add to seen list - systemEventDeps.Or(deps) // Add to scheduler deps - case FieldSystemEventEmitter: - if hasDuplicate(systemEventEmitterDeps, deps) { - return componentDeps, eris.New( - "systems cannot declare multiple WithSystemEventEmitter fields of the same system event type") - } - systemEventEmitterDeps.Or(deps) // Add to seen list - systemEventDeps.Or(deps) // Add to scheduler deps - case FieldCommand: - if hasDuplicate(commandDeps, deps) { - return componentDeps, eris.New("systems cannot process multiple commands of the same type") - } - commandDeps.Or(deps) // Add to seen list - case FieldEvent: - if hasDuplicate(eventDeps, deps) { - return componentDeps, eris.New("systems cannot declare multiple WithEvent fields of the same event type") - } - eventDeps.Or(deps) // Add to seen list - case FieldBase: - } - - // Run the field modifier functions if they're set. - for t, modifier := range modifiers { - if t == tag { - if err := modifier(fieldInstance); err != nil { - return componentDeps, eris.Wrapf(err, "error initializing field %s", fieldType.Name) - } - } - } - } - - // Add system event deps to component deps. - n := w.state.components.nextID - assert.That(systemEventDeps.Count()+int(n) <= math.MaxUint32-1, "system dependencies exceed max limit") - systemEventDeps.Range(func(x uint32) { - componentDeps.Set(n + x) - }) - - return componentDeps, nil -} - -// hasDuplicate checks if any bits in deps are already set in aggregate. -func hasDuplicate(aggregate, deps bitmap.Bitmap) bool { - clone := deps.Clone(nil) - clone.And(aggregate) - return clone.Count() != 0 -} diff --git a/pkg/cardinal/ecs/system_state_internal_test.go b/pkg/cardinal/ecs/system_state_internal_test.go deleted file mode 100644 index 21d43abfb..000000000 --- a/pkg/cardinal/ecs/system_state_internal_test.go +++ /dev/null @@ -1,483 +0,0 @@ -package ecs - -import ( - "fmt" - "math/rand/v2" - "slices" - "testing" - - "github.com/argus-labs/world-engine/pkg/micro" - "github.com/argus-labs/world-engine/pkg/testutils" - "github.com/kelindar/bitmap" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// ------------------------------------------------------------------------------------------------- -// Exhaustive search[T] iteration test -// ------------------------------------------------------------------------------------------------- -// This test exhaustively enumerates all combinations of the following parameters against a fixed -// archetype pool ({}, {A}, {A,B}, {B,C}) to verify search[T].iter() correctness: -// -// - Entity count per archetype (0, 1, or 2): 0 tests empty archetypes yield nothing, 1 tests -// single-entity iteration, 2 tests multi-entity iteration. Higher counts would cause factorial -// explosion of the state space (n!). -// - Search components (all subsets of {A, B, C}): tests all 8 possible combinations. -// - Match type (exact vs contains): tests both archetype matching strategies. -// ------------------------------------------------------------------------------------------------- - -func TestSearch_IterExhaustive(t *testing.T) { - t.Parallel() - - // Archetype component sets: {}, {A}, {A,B}, {B,C}. - archComponents := [4][]uint32{{}, {cidA}, {cidA, cidB}, {cidB, cidC}} - - gen := testutils.NewGen() - for !gen.Done() { - world := NewWorld() - ws := world.state - - var s search[struct { - A Ref[testutils.ComponentA] - B Ref[testutils.ComponentB] - C Ref[testutils.ComponentC] - }] - _, err := s.init(world) - require.NoError(t, err) - - // Create 0-2 entities per archetype. - var model []entityRecord - var entityCounts [4]int - for i, cids := range archComponents { - var bm bitmap.Bitmap - for _, cid := range cids { - bm.Set(cid) - } - entityCounts[i] = gen.Intn(2) - for range entityCounts[i] { - eid := ws.newEntityWithArchetype(bm) - aid, ok := ws.entityArch.get(eid) - assert.True(t, ok) - rec := entityRecord{eid: eid, archID: aid, componentSet: cids} - for _, cid := range cids { - switch cid { - case cidA: - rec.compA = testutils.ComponentA{X: float64(eid), Y: float64(eid) * 2, Z: float64(eid) * 3} - err = setComponent(ws, eid, rec.compA) - require.NoError(t, err) - case cidB: - rec.compB = testutils.ComponentB{ID: uint64(eid), Label: "test", Enabled: true} - err = setComponent(ws, eid, rec.compB) - require.NoError(t, err) - case cidC: - rec.compC = testutils.ComponentC{Values: [8]int32{int32(eid)}, Counter: uint16(eid)} - err = setComponent(ws, eid, rec.compC) - require.NoError(t, err) - } - } - model = append(model, rec) - } - } - - // Pick non-empty subset of {A,B,C} using 3-bit mask [1,7]. - mask := gen.Intn(7) - var searchBm bitmap.Bitmap - for i, cid := range []uint32{cidA, cidB, cidC} { - if mask&(1< command ID + queues []Queue // queue for incoming commands, indexed by command ID + commands [][]Command // read-only commands slice used by ECS systems, indexed by command ID +} + +// NewManager creates a new command manager. +func NewManager() Manager { + // We don't have to preallocate the slices as allocations only happen during command registration, + // which happens before we start accepting requests and run systems. + return Manager{ + nextID: 0, + catalog: make(map[string]ID), + queues: make([]Queue, 0), + commands: make([][]Command, 0), + } +} + +// Register registers the command type with the command manager. +func (m *Manager) Register(name string, queue Queue) (ID, error) { + if name == "" { + return 0, eris.New("command name cannot be empty") + } + + // If the command is already registered, return the existing ID. + if id, exists := m.catalog[name]; exists { + return id, nil + } + + if m.nextID > MaxID { + return 0, eris.New("max number of commands exceeded") + } + + id := m.nextID + m.catalog[name] = id + m.commands = append(m.commands, make([]Command, 0, initialCommandBufferCapacity)) + m.queues = append(m.queues, queue) + + m.nextID++ + assert.That(int(m.nextID) == len(m.commands), "command id doesn't match number of commands") + + return id, nil +} + +// Enqueue stores a command in its corresponding queue. The queues map isn't lock protected, and it +// is expected that there exists only 1 caller for each command type, therefore each caller reads +// a different key. This is ok because concurrent reads on Go maps are allowed. +func (m *Manager) Enqueue(command *iscv1.Command) error { + // We're doing 2 lookups here to keep the Enqueue caller simple, at the cost of less performance. + // If this is determined to be a bottleneck in the future, do what callers of Get do and store the + // ID of the command in the caller, so we can do a direct index with Enqueue(id, command). + name := command.GetName() + id, exists := m.catalog[name] + if !exists { + return eris.Errorf("unregistered command: %s", name) + } + return m.queues[id].Enqueue(command) +} + +// Get retrieves a slice of commands given the command ID. The ID is returned from Register, and +// callers are expected to store it for calls to Get. This API is used vs using the command's name +// as the index as that requires an extra map lookup. We sacrifice extra complexity at the caller +// to make sure lookups are fast as Get is a hot path as it is called every tick. +func (m *Manager) Get(id ID) ([]Command, error) { + if id >= m.nextID { + return nil, eris.Errorf("unregistered command id: %d", id) + } + return m.commands[id], nil +} + +// Drain collects commands from the queues to read-only command buffers. It also returns a list of +// all commands collected thus far (used by the transaction log). Drain is expected to be called at +// the start of each tick. +func (m *Manager) Drain() []Command { + // Clear buffers from previous tick to reuse the slices. + for id := range m.commands { + m.commands[id] = m.commands[id][:0] + } + + all := make([]Command, 0, len(m.commands)*initialCommandBufferCapacity) + for id, queue := range m.queues { + queue.Drain(&m.commands[id]) + all = append(all, m.commands[id]...) + } + return all +} diff --git a/pkg/cardinal/internal/command/command_test.go b/pkg/cardinal/internal/command/command_test.go new file mode 100644 index 000000000..36a998b2a --- /dev/null +++ b/pkg/cardinal/internal/command/command_test.go @@ -0,0 +1,350 @@ +package command_test + +import ( + "math/rand/v2" + "sync" + "testing" + "testing/synctest" + + "github.com/argus-labs/world-engine/pkg/cardinal/internal/command" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" + "github.com/argus-labs/world-engine/pkg/testutils" + iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" + "github.com/rotisserie/eris" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ------------------------------------------------------------------------------------------------- +// Model-based fuzzing command manager operations +// ------------------------------------------------------------------------------------------------- +// This test verifies the command manager implementation correctness by applying random sequences of +// operations and comparing it against a model implementation. Command registration is tested +// separately as it's not part of the "day-to-day" operations of the command manager. +// ------------------------------------------------------------------------------------------------- + +func TestCommand_ModelFuzz(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + + const ( + opsMax = 1 << 15 // 32_768 iterations + opEnqueue = "enqueue" + opDrain = "drain" + opGet = "get" + ) + + impl := command.NewManager() + model := newModelManager() + + // Slice of "generator" helper functions to create typed commands. + generators := make([]func() command.Payload, 3) + generators[0] = registerCommand[testutils.CommandA](t, prng, &impl, model) + generators[1] = registerCommand[testutils.CommandB](t, prng, &impl, model) + generators[2] = registerCommand[testutils.CommandC](t, prng, &impl, model) + + // Randomize operation weights. + operations := []string{opEnqueue, opDrain, opGet} + weights := testutils.RandOpWeights(prng, operations) + + for range opsMax { + op := testutils.RandWeightedOp(prng, weights) + switch op { + case opEnqueue: + // Pick a random command type and enqueue. + payload := generators[prng.IntN(len(generators))]() + pbPayload, err := schema.ToProtoStruct(payload) + require.NoError(t, err) + + persona := testutils.RandString(prng, 8) + cmdpb := &iscv1.Command{ + Name: payload.Name(), + Persona: &iscv1.Persona{Id: persona}, + Payload: pbPayload, + } + + err = impl.Enqueue(cmdpb) + require.NoError(t, err) + + model.enqueue(payload.Name(), command.Command{ + Name: payload.Name(), + Persona: persona, + Payload: payload, + }) + + case opDrain: + modelAll := model.drain() + implAll := impl.Drain() + + // Property: drain returns all enqueued commands. + assert.Len(t, implAll, len(modelAll), "drain count mismatch") + assert.ElementsMatch(t, modelAll, implAll, "drain content mismatch") + + // Property: per-command-type buffers match model via Get. + for _, gen := range generators { + name := gen().Name() + + modelBuf, err := model.get(name) + require.NoError(t, err) + + implBuf, err := impl.Get(model.catalog[name]) + require.NoError(t, err) + + assert.Equal(t, modelBuf, implBuf, "buffer mismatch for command %q", name) + } + + case opGet: + // Get for a random command type should match model. + name := generators[prng.IntN(len(generators))]().Name() + + modelBuf, err := model.get(name) + require.NoError(t, err) + + implBuf, err := impl.Get(model.catalog[name]) + require.NoError(t, err) + + assert.Equal(t, modelBuf, implBuf, "get mismatch for command %q", name) + + default: + panic("unreachable") + } + } + + // Final state check: drain and verify all buffers match model. + modelAll := model.drain() + implAll := impl.Drain() + assert.Len(t, implAll, len(modelAll), "final drain count mismatch") + assert.ElementsMatch(t, modelAll, implAll, "final drain content mismatch") + + for _, gen := range generators { + name := gen().Name() + + modelBuf, err := model.get(name) + require.NoError(t, err) + + implBuf, err := impl.Get(model.catalog[name]) + require.NoError(t, err) + + assert.Equal(t, modelBuf, implBuf, "final buffer mismatch for command %q", name) + } +} + +func registerCommand[T command.Payload]( + t *testing.T, prng *rand.Rand, impl *command.Manager, model *modelManager, +) func() command.Payload { + t.Helper() + + var zero T + name := zero.Name() + + id, err := impl.Register(name, command.NewQueue[T]()) + require.NoError(t, err) + + model.register(id, name) + + switch name { + case testutils.CommandA{}.Name(): + return func() command.Payload { + return testutils.CommandA{X: prng.Float64(), Y: prng.Float64(), Z: prng.Float64()} + } + case testutils.CommandB{}.Name(): + return func() command.Payload { + return testutils.CommandB{ + ID: uint64(prng.IntN(1 << 50)), // Use smaller values to avoid JSON precision loss + Label: testutils.RandString(prng, 10), + Enabled: prng.IntN(2) == 1, + } + } + case testutils.CommandC{}.Name(): + return func() command.Payload { + return testutils.CommandC{Values: [8]int32{}, Counter: uint16(prng.Int())} + } + default: + panic("unreachable") + } +} + +// modelManager is a simple reference implementation of command.Manager for model-based testing. +// NOTE: The #1 most important aspect of a model is "obvious correctness". The code must be simple +// and obviously correct, no matter the cost on other aspects like performance. Ideally the model +// is small enough to be inlined in the test, but for larger types factoring it out makes the test +// function clearer to read. +type modelManager struct { + nextID command.ID + catalog map[string]command.ID + queued map[string][]command.Command // commands not yet drained + commands map[string][]command.Command // commands buffer (result of Get) +} + +func newModelManager() *modelManager { + return &modelManager{ + nextID: 0, + catalog: make(map[string]command.ID), + queued: make(map[string][]command.Command), + commands: make(map[string][]command.Command), + } +} + +func (m *modelManager) register(id command.ID, name string) { + m.catalog[name] = id + m.queued[name] = []command.Command{} + m.commands[name] = []command.Command{} +} + +func (m *modelManager) enqueue(name string, cmd command.Command) error { + if _, exists := m.catalog[name]; !exists { + return eris.Errorf("unregistered command: %s", name) + } + m.queued[name] = append(m.queued[name], cmd) + return nil +} + +func (m *modelManager) get(name string) ([]command.Command, error) { + if _, exists := m.catalog[name]; !exists { + return nil, eris.Errorf("unregistered command: %s", name) + } + return m.commands[name], nil +} + +func (m *modelManager) drain() []command.Command { + for name := range m.commands { + // Move queued commands to commands buffer and clear queues. + m.commands[name] = m.queued[name] + m.queued[name] = []command.Command{} + } + + var all []command.Command + for _, cmds := range m.commands { + all = append(all, cmds...) + } + return all +} + +// ------------------------------------------------------------------------------------------------- +// Model-based fuzzing command registration +// ------------------------------------------------------------------------------------------------- +// This test verifies the command manager registration correctness by applying random sequences of +// operations and comparing against a Go map as the model. +// ------------------------------------------------------------------------------------------------- + +func TestCommand_RegisterModelFuzz(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + + const opsMax = 1 << 15 // 32_768 iterations + + impl := command.NewManager() + model := make(map[string]command.ID) // name -> ID + + for range opsMax { + name := testutils.RandString(prng, 50) + implID, err := impl.Register(name, nil) + require.NoError(t, err) + + if modelID, exists := model[name]; exists { + // Property: re-registering the same name returns the same ID. + assert.Equal(t, modelID, implID, "ID mismatch for re-registered %q", name) + } else { + model[name] = implID + } + } + + // Property: bijection holds between names and IDs (no two names share an ID). + seenIDs := make(map[command.ID]string) + for name, id := range model { + if prevName, seen := seenIDs[id]; seen { + t.Errorf("ID %d is mapped by both %q and %q", id, prevName, name) + } + seenIDs[id] = name + } + + // Property: all IDs are sequential starting from 0. + for name, id := range model { + assert.Less(t, id, command.ID(len(model)), "ID for %q is out of range", name) + } + + // Property: Get works for all registered commands. + for name, id := range model { + buf, err := impl.Get(id) + require.NoError(t, err, "Get failed for command %q with ID %d", name, id) + assert.Empty(t, buf, "buffer should be empty for %q", name) + } +} + +// ------------------------------------------------------------------------------------------------- +// Concurrent enqueue test +// ------------------------------------------------------------------------------------------------- +// This test verifies that concurrent enqueues are thread-safe and all commands are properly stored. +// ------------------------------------------------------------------------------------------------- + +func TestCommand_ConcurrentEnqueue(t *testing.T) { + t.Parallel() + + const ( + numGoroutines = 10 + commandsPerRoutine = 1000 + ) + + synctest.Test(t, func(t *testing.T) { + impl := command.NewManager() + + _, err := impl.Register(testutils.CommandA{}.Name(), command.NewQueue[testutils.CommandA]()) + require.NoError(t, err) + _, err = impl.Register(testutils.CommandB{}.Name(), command.NewQueue[testutils.CommandB]()) + require.NoError(t, err) + + var wg sync.WaitGroup + var mu sync.Mutex + expected := make([]command.Command, 0, numGoroutines*commandsPerRoutine) + + for range numGoroutines { + wg.Go(func() { + // Initialize prng in each goroutine separately because rand/v2.Rand isn't concurrent-safe. + prng := testutils.NewRand(t) + + for i := range commandsPerRoutine { + var payload command.Payload + if prng.IntN(2) == 0 { + payload = testutils.CommandA{X: float64(i), Y: prng.Float64(), Z: 0} + } else { + payload = testutils.CommandB{ID: uint64(i), Label: "test", Enabled: true} + } + + pbPayload, err := schema.ToProtoStruct(payload) + if err != nil { + t.Errorf("ToProtoStruct failed: %v", err) + return + } + + cmdpb := &iscv1.Command{ + Name: payload.Name(), + Persona: &iscv1.Persona{Id: "test-persona"}, + Payload: pbPayload, + } + + if err := impl.Enqueue(cmdpb); err != nil { + t.Errorf("Enqueue failed: %v", err) + return + } + + mu.Lock() + expected = append(expected, command.Command{ + Name: payload.Name(), + Persona: "test-persona", + Payload: payload, + }) + mu.Unlock() + } + }) + } + + // Wait for all goroutines to complete their work. + wg.Wait() + + // Drain all commands and verify count and content. + all := impl.Drain() + expectedTotal := numGoroutines * commandsPerRoutine + assert.Len(t, all, expectedTotal, "total command count mismatch") + assert.ElementsMatch(t, expected, all, "command content mismatch") + }) +} diff --git a/pkg/cardinal/internal/command/queue.go b/pkg/cardinal/internal/command/queue.go new file mode 100644 index 000000000..977009be2 --- /dev/null +++ b/pkg/cardinal/internal/command/queue.go @@ -0,0 +1,84 @@ +package command + +import ( + "sync" + + iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" + "github.com/goccy/go-json" + "github.com/rotisserie/eris" +) + +// Queue defines the interface for command queuing operations. +// It provides methods to enqueue commands and drain all queued commands. +type Queue interface { + Enqueue(*iscv1.Command) error + Drain(target *[]Command) + Len() int +} + +var _ Queue = &sliceQueue[Payload]{} + +// TODO: figure out whether to make this configurable. +// initialQueueCapacity is the starting capacity of queue. +const initialQueueCapacity = 1024 + +// sliceQueue is a generic unbounded sliceQueue for handling commands of a specific type. +// It implements the Queue interface and provides type-safe command processing. +type sliceQueue[T Payload] struct { + commands []Command + mu sync.Mutex +} + +// NewQueue creates a new command queue with an initial buffer capacity. +func NewQueue[T Payload]() Queue { + return &sliceQueue[T]{ + commands: make([]Command, 0, initialQueueCapacity), + } +} + +// Enqueue validates and adds a command to the queue. It performs type checking to ensure the +// command matches the expected type T, unmarshals the command payload, and appends it to the queue. +// Returns an error if validation fails or marshaling/unmarshaling operations fail. +func (q *sliceQueue[T]) Enqueue(command *iscv1.Command) error { + var zero T + + if command.GetName() != zero.Name() { + return eris.Errorf("mismatched command name, expected %s, actual %s", zero.Name(), command.GetName()) + } + + jsonBytes, err := command.GetPayload().MarshalJSON() + if err != nil { + return eris.Wrap(err, "failed to marshal command payload to json") + } + + if err := json.Unmarshal(jsonBytes, &zero); err != nil { + return eris.Wrap(err, "failed to unmarshal to command") + } + + q.mu.Lock() + q.commands = append(q.commands, Command{ + Name: zero.Name(), + Address: command.GetAddress(), + Persona: command.GetPersona().GetId(), + Payload: zero, + }) + q.mu.Unlock() + return nil +} + +// Drain returns all queued commands to the target slice and resets the queue. +func (q *sliceQueue[T]) Drain(target *[]Command) { + q.mu.Lock() + defer q.mu.Unlock() + + *target = append(*target, q.commands...) + q.commands = q.commands[:0] +} + +// Len returns the length of the queue. +func (q *sliceQueue[T]) Len() int { + q.mu.Lock() + defer q.mu.Unlock() + + return len(q.commands) +} diff --git a/pkg/cardinal/internal/command/queue_test.go b/pkg/cardinal/internal/command/queue_test.go new file mode 100644 index 000000000..ce1d99e09 --- /dev/null +++ b/pkg/cardinal/internal/command/queue_test.go @@ -0,0 +1,112 @@ +package command_test + +import ( + "testing" + + "github.com/argus-labs/world-engine/pkg/cardinal/internal/command" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" + "github.com/argus-labs/world-engine/pkg/testutils" + iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ------------------------------------------------------------------------------------------------- +// Model-based fuzzing queue operations +// ------------------------------------------------------------------------------------------------- +// This test verifies the queue implementation correctness by applying random sequences of queue +// operations and comparing it against a regular Go slice as the model. +// ------------------------------------------------------------------------------------------------- + +func TestQueue_ModelFuzz(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + + const ( + opsMax = 1 << 15 // 32_768 iterations + opEnqueue = "enqueue" + opDrain = "drain" + ) + + // Randomize operation weights. + operations := []string{opEnqueue, opDrain} + weights := testutils.RandOpWeights(prng, operations) + + impl := command.NewQueue[testutils.SimpleCommand]() + model := make([]command.Command, 0) + + for range opsMax { + op := testutils.RandWeightedOp(prng, weights) + switch op { + case opEnqueue: + + cmd := testutils.SimpleCommand{Value: int(prng.Int32())} + payload, err := schema.ToProtoStruct(cmd) + require.NoError(t, err) + + name := cmd.Name() + corruptName := prng.IntN(10) == 1 // 10% chance to corrupt the command name. + if corruptName { + name = "wrong-name" + } + persona := "value doesn't matter" + + cmdpb := &iscv1.Command{ + Name: name, + Persona: &iscv1.Persona{Id: persona}, + Payload: payload, + } + + sizeBefore := impl.Len() + err = impl.Enqueue(cmdpb) + + if corruptName { + // Property: enqueue with wrong name must fail. + require.Error(t, err, "enqueue should fail for mismatched command name") + // Property: queue size unchanged after failed enqueue. + assert.Equal(t, sizeBefore, impl.Len(), "queue size should not change on error") + } else { + require.NoError(t, err, "enqueue should succeed for valid command") + model = append(model, command.Command{ + Name: name, + Persona: persona, + Payload: cmd, + }) + } + + case opDrain: + var implResult []command.Command + impl.Drain(&implResult) + + // Property: drain returns all enqueued commands. + assert.Len(t, implResult, len(model), "drain count mismatch") + + // Property: FIFO ordering preserved. + for i := range implResult { + assert.Equal(t, model[i], implResult[i], "command[%d] mismatch", i) + } + + // Property: queue is empty after drain. + assert.Zero(t, impl.Len(), "queue should be empty after drain") + + // Property: second drain yields nothing (idempotent). + var secondDrain []command.Command + impl.Drain(&secondDrain) + assert.Empty(t, secondDrain, "second drain should yield no new commands") + + // Clear model. + model = model[:0] + + default: + panic("unreachable") + } + } + + // Final state check: drain remaining and verify equivalence. + var finalResult []command.Command + impl.Drain(&finalResult) + assert.Len(t, finalResult, len(model), "final drain count mismatch") + for i := range finalResult { + assert.Equal(t, model[i], finalResult[i], "final command[%d] mismatch", i) + } +} diff --git a/pkg/cardinal/ecs/README.md b/pkg/cardinal/internal/ecs/README.md similarity index 100% rename from pkg/cardinal/ecs/README.md rename to pkg/cardinal/internal/ecs/README.md diff --git a/pkg/cardinal/ecs/archetype.go b/pkg/cardinal/internal/ecs/archetype.go similarity index 100% rename from pkg/cardinal/ecs/archetype.go rename to pkg/cardinal/internal/ecs/archetype.go diff --git a/pkg/cardinal/ecs/archetype_internal_test.go b/pkg/cardinal/internal/ecs/archetype_internal_test.go similarity index 94% rename from pkg/cardinal/ecs/archetype_internal_test.go rename to pkg/cardinal/internal/ecs/archetype_internal_test.go index 5e4c78352..1153c0ecf 100644 --- a/pkg/cardinal/ecs/archetype_internal_test.go +++ b/pkg/cardinal/internal/ecs/archetype_internal_test.go @@ -1,7 +1,6 @@ package ecs import ( - "reflect" "slices" "testing" @@ -14,17 +13,24 @@ import ( // ------------------------------------------------------------------------------------------------- // Model-based fuzzing archetype operations // ------------------------------------------------------------------------------------------------- -// This test verifies the archetype implementation correctness using model-based testing. It -// compares our implementation against Go's map tracking entity->archetype ownership by applying -// random sequences of new/move/remove operations to both and asserting equivalence. -// We also verify extra invariants such as bijection consistency and global entity uniqueness. +// This test verifies the archetype implementation correctness by applying random sequences of +// operations and comparing it against a regular Go map of entity->archetype as the model. // ------------------------------------------------------------------------------------------------- func TestArchetype_ModelFuzz(t *testing.T) { t.Parallel() prng := testutils.NewRand(t) - const opsMax = 1 << 15 // 32_768 iterations + const ( + opsMax = 1 << 15 // 32_768 iterations + opNew = "new" + opMove = "move" + opRemove = "remove" + ) + + // Randomize operation weights. + operations := []string{opNew, opMove, opRemove} + weights := testutils.RandOpWeights(prng, operations) pool := newArchetypePool() model := make(map[EntityID]*archetype) // Just track archetype entity ownership @@ -34,9 +40,9 @@ func TestArchetype_ModelFuzz(t *testing.T) { var next EntityID for range opsMax { - op := testutils.RandWeightedOp(prng, archetypeOps) + op := testutils.RandWeightedOp(prng, weights) switch op { - case a_new: + case opNew: eid := next next++ entities = append(entities, eid) @@ -50,7 +56,7 @@ func TestArchetype_ModelFuzz(t *testing.T) { assert.True(t, exists) assert.Equal(t, eid, arch.entities[row]) - case a_remove: + case opRemove: if len(entities) == 0 { continue } @@ -66,7 +72,7 @@ func TestArchetype_ModelFuzz(t *testing.T) { _, exists := arch.rows.get(eid) assert.False(t, exists) - case a_move: + case opMove: if len(entities) == 0 { continue } @@ -142,16 +148,6 @@ func TestArchetype_ModelFuzz(t *testing.T) { } } -type archetypeOp uint8 - -const ( - a_new archetypeOp = 40 - a_move archetypeOp = 35 - a_remove archetypeOp = 25 -) - -var archetypeOps = []archetypeOp{a_new, a_move, a_remove} - // ------------------------------------------------------------------------------------------------- // Exhaustive archetype move test // ------------------------------------------------------------------------------------------------- @@ -351,13 +347,11 @@ func TestArchetype_SerializationSmoke(t *testing.T) { cid1, err := cm.register( testutils.ComponentA{}.Name(), newColumnFactory[testutils.ComponentA](), - reflect.TypeOf(testutils.ComponentA{}), ) require.NoError(t, err) cid2, err := cm.register( testutils.ComponentB{}.Name(), newColumnFactory[testutils.ComponentB](), - reflect.TypeOf(testutils.ComponentB{}), ) require.NoError(t, err) @@ -397,7 +391,6 @@ func TestArchetype_DeserializationNegative(t *testing.T) { cid1, err := cm.register( testutils.ComponentA{}.Name(), newColumnFactory[testutils.ComponentA](), - reflect.TypeOf(testutils.ComponentA{}), ) require.NoError(t, err) diff --git a/pkg/cardinal/ecs/bench_internal_test.go b/pkg/cardinal/internal/ecs/bench_internal_test.go similarity index 89% rename from pkg/cardinal/ecs/bench_internal_test.go rename to pkg/cardinal/internal/ecs/bench_internal_test.go index 3ba619ca4..980fb3aa7 100644 --- a/pkg/cardinal/ecs/bench_internal_test.go +++ b/pkg/cardinal/internal/ecs/bench_internal_test.go @@ -311,7 +311,8 @@ func BenchmarkECS2_Iteration_Pure(b *testing.B) { } search := Exact[struct{ Position Ref[Position3D] }]{} - _, _ = search.init(w) + meta := &systemInitMetadata{world: w, systemEvents: make(map[string]struct{})} + _ = search.init(meta) b.StartTimer() for _, result := range search.Iter() { _ = result @@ -344,7 +345,8 @@ func BenchmarkECS2_Iteration_Pure(b *testing.B) { Transform Ref[Transform] Inventory Ref[Inventory] }]{} - _, _ = search.init(w) + meta := &systemInitMetadata{world: w, systemEvents: make(map[string]struct{})} + _ = search.init(meta) b.StartTimer() for _, result := range search.Iter() { _ = result @@ -390,7 +392,8 @@ func BenchmarkECS2_Iteration_Pure(b *testing.B) { Physics Ref[Physics] NetworkSync Ref[NetworkSync] }]{} - _, _ = search.init(w) + meta := &systemInitMetadata{world: w, systemEvents: make(map[string]struct{})} + _ = search.init(meta) b.StartTimer() for _, result := range search.Iter() { _ = result @@ -413,7 +416,8 @@ func BenchmarkECS2_Iteration_Pure(b *testing.B) { } search := Contains[struct{ Position Ref[Position3D] }]{} - _, _ = search.init(w) + meta := &systemInitMetadata{world: w, systemEvents: make(map[string]struct{})} + _ = search.init(meta) b.StartTimer() for _, result := range search.Iter() { _ = result @@ -444,7 +448,8 @@ func BenchmarkECS2_Iteration_Pure(b *testing.B) { Velocity Ref[Velocity3D] Health Ref[Health2] }]{} - _, _ = search.init(w) + meta := &systemInitMetadata{world: w, systemEvents: make(map[string]struct{})} + _ = search.init(meta) b.StartTimer() for _, result := range search.Iter() { _ = result @@ -486,7 +491,8 @@ func BenchmarkECS2_Iteration_Pure(b *testing.B) { Inventory Ref[Inventory] PlayerStats Ref[PlayerStats] }]{} - _, _ = search.init(w) + meta := &systemInitMetadata{world: w, systemEvents: make(map[string]struct{})} + _ = search.init(meta) b.StartTimer() for _, result := range search.Iter() { _ = result @@ -502,40 +508,29 @@ func BenchmarkECS2_Iteration_GetSet(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { w := setup2(Position3D{}) + state1 := &getSetSystemState1{} - // Setup system: create 100 entities - setupSystem := func(state *getSetSystemState1) error { + _ = RegisterSystem(w, state1, "setup1", func() { for j := 0; j < 100; j++ { - _, entity := state.Entities.Create() + _, entity := state1.Entities.Create() entity.Position.Set(Position3D{X: float64(j), Y: float64(j), Z: float64(j)}) } - return nil - } + }, Init) - // GetSet system: get and set components - getSetSystem := func(state *getSetSystemState1) error { + _ = RegisterSystem(w, state1, "getset1", func() { b.StartTimer() - for _, entity := range state.Entities.Iter() { - // Get the position + for _, entity := range state1.Entities.Iter() { pos := entity.Position.Get() - // Mutate it pos.X += 1.0 - // Set it back entity.Position.Set(pos) } b.StopTimer() - return nil - } - - RegisterSystem(w, setupSystem, WithHook(Init)) - RegisterSystem(w, getSetSystem) + }, Update) w.Init() - // First tick runs init systems only (creates entities) - _, _ = w.Tick(nil) - // Second tick runs the getSetSystem (what we want to benchmark) - _, _ = w.Tick(nil) + _ = w.Tick() + _ = w.Tick() } }) @@ -543,46 +538,35 @@ func BenchmarkECS2_Iteration_GetSet(b *testing.B) { b.ResetTimer() for i := 0; i < b.N; i++ { w := setup2(Position3D{}, Velocity3D{}, Health2{}, Transform{}, Inventory{}) + state5 := &getSetSystemState5{} - // Setup system: create 100 entities - setupSystem := func(state *getSetSystemState5) error { + _ = RegisterSystem(w, state5, "setup5", func() { for j := 0; j < 100; j++ { - _, entity := state.Entities.Create() + _, entity := state5.Entities.Create() entity.Position.Set(Position3D{X: float64(j), Y: float64(j), Z: float64(j)}) entity.Velocity.Set(Velocity3D{X: float64(j), Y: float64(j), Z: float64(j)}) entity.Health.Set(Health2{Current: j, Max: 100}) entity.Transform.Set(Transform{Scale: 1.0, Rotation: float64(j)}) entity.Inventory.Set(Inventory{Items: []string{"item"}, Capacity: 10}) } - return nil - } + }, Init) - // GetSet system: get position, set velocity - getSetSystem := func(state *getSetSystemState5) error { + _ = RegisterSystem(w, state5, "getset5", func() { b.StartTimer() - for _, entity := range state.Entities.Iter() { - // Get position + for _, entity := range state5.Entities.Iter() { pos := entity.Position.Get() - // Get and mutate velocity based on position vel := entity.Velocity.Get() vel.X = pos.X * 0.1 vel.Y = pos.Y * 0.1 - // Set velocity back entity.Velocity.Set(vel) } b.StopTimer() - return nil - } - - RegisterSystem(w, setupSystem, WithHook(Init)) - RegisterSystem(w, getSetSystem) + }, Update) w.Init() - // First tick runs init systems only (creates entities) - _, _ = w.Tick(nil) - // Second tick runs the getSetSystem (what we want to benchmark) - _, _ = w.Tick(nil) + _ = w.Tick() + _ = w.Tick() } }) @@ -593,11 +577,11 @@ func BenchmarkECS2_Iteration_GetSet(b *testing.B) { Position3D{}, Velocity3D{}, Health2{}, Transform{}, Inventory{}, PlayerStats{}, AIBehavior{}, Renderer{}, Physics{}, NetworkSync{}, ) + state10 := &getSetSystemState10{} - // Setup system: create 100 entities - setupSystem := func(state *getSetSystemState10) error { + _ = RegisterSystem(w, state10, "setup10", func() { for j := 0; j < 100; j++ { - _, entity := state.Entities.Create() + _, entity := state10.Entities.Create() entity.Position.Set(Position3D{X: float64(j), Y: float64(j), Z: float64(j)}) entity.Velocity.Set(Velocity3D{X: float64(j), Y: float64(j), Z: float64(j)}) entity.Health.Set(Health2{Current: j, Max: 100}) @@ -612,69 +596,58 @@ func BenchmarkECS2_Iteration_GetSet(b *testing.B) { IsDirty: false, Interpolate: true, }) } - return nil - } + }, Init) - // GetSet system: get position and health, set physics and renderer - getSetSystem := func(state *getSetSystemState10) error { + _ = RegisterSystem(w, state10, "getset10", func() { b.StartTimer() - for _, entity := range state.Entities.Iter() { - // Get position and health + for _, entity := range state10.Entities.Iter() { pos := entity.Position.Get() health := entity.Health.Get() - // Mutate physics based on position physics := entity.Physics.Get() physics.Mass = pos.X * 0.01 entity.Physics.Set(physics) - // Mutate renderer based on health renderer := entity.Renderer.Get() renderer.Visible = health.Current > 50 entity.Renderer.Set(renderer) } b.StopTimer() - return nil - } - - RegisterSystem(w, setupSystem, WithHook(Init)) - RegisterSystem(w, getSetSystem) + }, Update) w.Init() - // First tick runs init systems only (creates entities) - _, _ = w.Tick(nil) - // Second tick runs the getSetSystem (what we want to benchmark) - _, _ = w.Tick(nil) + _ = w.Tick() + _ = w.Tick() } }) } func setup2(components ...Component) *World { w := NewWorld() - ws := w.state + w.OnComponentRegister(func(Component) error { return nil }) for _, c := range components { switch c.(type) { case Position3D: - _, _ = registerComponent[Position3D](ws) + _, _ = registerComponent[Position3D](w) case Velocity3D: - _, _ = registerComponent[Velocity3D](ws) + _, _ = registerComponent[Velocity3D](w) case Health2: - _, _ = registerComponent[Health2](ws) + _, _ = registerComponent[Health2](w) case Transform: - _, _ = registerComponent[Transform](ws) + _, _ = registerComponent[Transform](w) case Inventory: - _, _ = registerComponent[Inventory](ws) + _, _ = registerComponent[Inventory](w) case PlayerStats: - _, _ = registerComponent[PlayerStats](ws) + _, _ = registerComponent[PlayerStats](w) case AIBehavior: - _, _ = registerComponent[AIBehavior](ws) + _, _ = registerComponent[AIBehavior](w) case Renderer: - _, _ = registerComponent[Renderer](ws) + _, _ = registerComponent[Renderer](w) case Physics: - _, _ = registerComponent[Physics](ws) + _, _ = registerComponent[Physics](w) case NetworkSync: - _, _ = registerComponent[NetworkSync](ws) + _, _ = registerComponent[NetworkSync](w) } } return w diff --git a/pkg/cardinal/ecs/column.go b/pkg/cardinal/internal/ecs/column.go similarity index 100% rename from pkg/cardinal/ecs/column.go rename to pkg/cardinal/internal/ecs/column.go diff --git a/pkg/cardinal/ecs/column_internal_test.go b/pkg/cardinal/internal/ecs/column_internal_test.go similarity index 87% rename from pkg/cardinal/ecs/column_internal_test.go rename to pkg/cardinal/internal/ecs/column_internal_test.go index 3c3f48cc2..5b45d9105 100644 --- a/pkg/cardinal/ecs/column_internal_test.go +++ b/pkg/cardinal/internal/ecs/column_internal_test.go @@ -11,31 +11,40 @@ import ( // ------------------------------------------------------------------------------------------------- // Model-based fuzzing column operations // ------------------------------------------------------------------------------------------------- -// This test verifies the column implementation correctness using model-based testing. It compares -// our implementation against Go's slice with swap-remove semantics as the model by applying random -// sequences of extend/set/get/remove operations to both and asserting equivalence. +// This test verifies the archetype implementation correctness by applying random sequences of +// operations and comparing it against a regular Go slice as the model. // ------------------------------------------------------------------------------------------------- func TestColumn_ModelFuzz(t *testing.T) { t.Parallel() prng := testutils.NewRand(t) - const opsMax = 1 << 15 // 32_768 iterations + const ( + opsMax = 1 << 15 // 32_768 iterations + opExtend = "extend" + opSet = "set" + opGet = "get" + opRemove = "remove" + ) + + // Randomize operation weights. + operations := []string{opExtend, opSet, opGet, opRemove} + weights := testutils.RandOpWeights(prng, operations) impl := newColumn[testutils.SimpleComponent]() model := make([]testutils.SimpleComponent, 0, columnCapacity) for range opsMax { - op := testutils.RandWeightedOp(prng, columnOps) + op := testutils.RandWeightedOp(prng, weights) switch op { - case c_extend: + case opExtend: impl.extend() model = append(model, testutils.SimpleComponent{}) // Property: length increases by 1. assert.Equal(t, len(model), impl.len(), "extend length mismatch") - case c_set: + case opSet: if len(model) == 0 { continue } @@ -49,7 +58,7 @@ func TestColumn_ModelFuzz(t *testing.T) { // Property: get(k) after set(k) returns same value. assert.Equal(t, value, impl.get(row), "set(%d) then get value mismatch", row) - case c_get: + case opGet: if len(model) == 0 { continue } @@ -61,7 +70,7 @@ func TestColumn_ModelFuzz(t *testing.T) { // Property: get(k) returns same value as model. assert.Equal(t, modelValue, implValue, "get(%d) value mismatch", row) - case c_remove: + case opRemove: if len(model) == 0 { continue } @@ -94,17 +103,6 @@ func TestColumn_ModelFuzz(t *testing.T) { } } -type columnOp uint8 - -const ( - c_extend columnOp = 20 - c_set columnOp = 35 - c_remove columnOp = 30 - c_get columnOp = 15 -) - -var columnOps = []columnOp{c_extend, c_set, c_remove, c_get} - // ------------------------------------------------------------------------------------------------- // Serialization smoke test // ------------------------------------------------------------------------------------------------- diff --git a/pkg/cardinal/ecs/component.go b/pkg/cardinal/internal/ecs/component.go similarity index 85% rename from pkg/cardinal/ecs/component.go rename to pkg/cardinal/internal/ecs/component.go index 0b66b3d7e..be319055b 100644 --- a/pkg/cardinal/ecs/component.go +++ b/pkg/cardinal/internal/ecs/component.go @@ -1,16 +1,16 @@ package ecs import ( - "reflect" "regexp" "github.com/argus-labs/world-engine/pkg/assert" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" "github.com/rotisserie/eris" ) // Component is the interface that all components must implement. // Components are pure data containers that can be attached to entities. -type Component interface { //nolint:iface // We may add more methods in the future. +type Component interface { //nolint:iface // may extend later // Name returns a unique string identifier for the component type. // This should be consistent across program executions. // @@ -23,7 +23,7 @@ type Component interface { //nolint:iface // We may add more methods in the futu // Invalid examples: "player-data", "123Invalid", "my.component", "has space" // // These rules ensure component names work correctly in query expressions. - Name() string + schema.Serializable } // componentID is a unique identifier for a component type. @@ -32,10 +32,9 @@ type componentID = uint32 // componentManager manages component type registration and lookup. type componentManager struct { - nextID componentID // The next available component ID - catalog map[string]componentID // Component name -> component ID - factories []columnFactory // Component ID -> column factory - types map[string]reflect.Type // Component name -> reflect.Type + nextID componentID // The next available component ID + catalog map[string]componentID // Component name -> component ID + factories []columnFactory // Component ID -> column factory } // newComponentManager creates a new component manager. @@ -44,7 +43,6 @@ func newComponentManager() componentManager { nextID: 0, catalog: make(map[string]componentID), factories: make([]columnFactory, 0), - types: make(map[string]reflect.Type), } } @@ -70,7 +68,7 @@ func validateComponentName(name string) error { // register registers a new component type and returns its ID. // If the component is already registered, no-op. -func (cm *componentManager) register(name string, factory columnFactory, typ reflect.Type) (componentID, error) { +func (cm *componentManager) register(name string, factory columnFactory) (componentID, error) { // Validate component name follows expr identifier rules if err := validateComponentName(name); err != nil { return 0, err @@ -83,7 +81,6 @@ func (cm *componentManager) register(name string, factory columnFactory, typ ref cm.catalog[name] = cm.nextID cm.factories = append(cm.factories, factory) - cm.types[name] = typ cm.nextID++ assert.That(int(cm.nextID) == len(cm.factories), "component id doesn't match number of components") diff --git a/pkg/cardinal/ecs/component_internal_test.go b/pkg/cardinal/internal/ecs/component_internal_test.go similarity index 86% rename from pkg/cardinal/ecs/component_internal_test.go rename to pkg/cardinal/internal/ecs/component_internal_test.go index 24490b278..5c4d0a62d 100644 --- a/pkg/cardinal/ecs/component_internal_test.go +++ b/pkg/cardinal/internal/ecs/component_internal_test.go @@ -2,7 +2,6 @@ package ecs import ( "math/rand/v2" - "reflect" "testing" "github.com/argus-labs/world-engine/pkg/testutils" @@ -13,27 +12,35 @@ import ( // ------------------------------------------------------------------------------------------------- // Model-based fuzzing component registration // ------------------------------------------------------------------------------------------------- -// This test verifies the componentManager registration correctness using model-based testing. It -// compares our implementation against a map[string]componentID as the model by applying random -// sequences of register/getID operations to both and asserting equivalence. -// We also verify structural invariants: name-id bijection and component id uniqueness. +// This test verifies the archetype implementation correctness by applying random sequences of +// operations and comparing it against a regular Go map of name->id as the model. We also verify +// structural invariants: name-id bijection and component id uniqueness. // ------------------------------------------------------------------------------------------------- func TestComponent_RegisterModelFuzz(t *testing.T) { t.Parallel() prng := testutils.NewRand(t) - const opsMax = 1 << 15 // 32_768 iterations + const ( + opsMax = 1 << 15 // 32_768 iterations + opRegister = "register" + opGetID = "getID" + ) + + // Randomize operation weights. + operations := []string{opRegister, opGetID} + weights := testutils.RandOpWeights(prng, operations) impl := newComponentManager() model := make(map[string]componentID) // name -> cid for range opsMax { - // 70% register, 30% getID - if prng.Float64() < 0.7 { //nolint:nestif // it's not bad + op := testutils.RandWeightedOp(prng, weights) + switch op { + case opRegister: name := randValidComponentName(prng) - implID, implErr := impl.register(name, nil, reflect.TypeOf(name)) // we don't use the columnFactory so it's ok + implID, implErr := impl.register(name, nil) // we don't use the columnFactory so it's ok modelID, modelExists := model[name] if modelExists { @@ -45,7 +52,8 @@ func TestComponent_RegisterModelFuzz(t *testing.T) { require.NoError(t, implErr) model[name] = implID } - } else { + + case opGetID: // Bias toward registered names (80%) to test retrieval path. var name string if len(model) > 0 && prng.Float64() < 0.8 { @@ -62,6 +70,9 @@ func TestComponent_RegisterModelFuzz(t *testing.T) { if modelExists { assert.Equal(t, modelID, implID, "getID(%s) ID mismatch", name) } + + default: + panic("unreachable") } } @@ -95,15 +106,15 @@ func TestComponent_RegisterModelFuzz(t *testing.T) { cm := newComponentManager() - id1, err := cm.register("hello", nil, reflect.TypeOf("hello")) + id1, err := cm.register("hello", nil) require.NoError(t, err) - id2, err := cm.register("hello", nil, reflect.TypeOf("hello")) + id2, err := cm.register("hello", nil) require.NoError(t, err) assert.Equal(t, id1, id2) - id3, err := cm.register("a_different_name", nil, reflect.TypeOf("a_different_name")) + id3, err := cm.register("a_different_name", nil) require.NoError(t, err) assert.Equal(t, id1+1, id3) diff --git a/pkg/cardinal/ecs/ecs.go b/pkg/cardinal/internal/ecs/ecs.go similarity index 100% rename from pkg/cardinal/ecs/ecs.go rename to pkg/cardinal/internal/ecs/ecs.go diff --git a/pkg/cardinal/ecs/errors.go b/pkg/cardinal/internal/ecs/errors.go similarity index 100% rename from pkg/cardinal/ecs/errors.go rename to pkg/cardinal/internal/ecs/errors.go diff --git a/pkg/cardinal/ecs/scheduler.go b/pkg/cardinal/internal/ecs/scheduler.go similarity index 85% rename from pkg/cardinal/ecs/scheduler.go rename to pkg/cardinal/internal/ecs/scheduler.go index 262831045..4d955286f 100644 --- a/pkg/cardinal/ecs/scheduler.go +++ b/pkg/cardinal/internal/ecs/scheduler.go @@ -1,20 +1,19 @@ package ecs import ( + "sync" "sync/atomic" "slices" "github.com/kelindar/bitmap" - "github.com/rotisserie/eris" - "golang.org/x/sync/errgroup" ) // systemMetadata contains the metadata for a system. type systemMetadata struct { name string // The name of the system deps bitmap.Bitmap // Bitmap of system dependencies (components + system events) - fn func() error // Function that wraps a System + fn func() // Function that wraps a System } // systemScheduler manages the execution of systems in a dependency-aware concurrent manner. @@ -42,23 +41,23 @@ func newSystemScheduler() systemScheduler { } // register registers a system with the scheduler. -func (s *systemScheduler) register(name string, systemDep bitmap.Bitmap, systemFn func() error) { +func (s *systemScheduler) register(name string, systemDep bitmap.Bitmap, systemFn func()) { s.systems = append(s.systems, systemMetadata{name: name, deps: systemDep, fn: systemFn}) } // Run executes the systems in the order of their dependencies. It returns an error if any system // returns an error. If multiple systems fail, all errors are wrapped in a single error. -func (s *systemScheduler) Run() error { +func (s *systemScheduler) Run() { // Fast path: no systems in hook. if len(s.systems) == 0 { - return nil + return } executionQueue := make(chan int, len(s.systems)) defer close(executionQueue) currentIndegree, nextIndegree := s.getCurrentAndNextIndegrees() - g := new(errgroup.Group) + var wg sync.WaitGroup // Schedule all tier 0 systems for _, systemID := range s.tier0 { @@ -68,15 +67,8 @@ func (s *systemScheduler) Run() error { // Launch goroutines to execute systems for range s.systems { systemID := <-executionQueue - g.Go(func() error { - // Do not return the system error early here so that the dependent systems can be scheduled to - // run first. If we return early then some systems might not run. We do this so that we can - // guarantee all of the systems are executed (`for range s.systems`) instead of being - // optimistic about it. - var err error - if err = s.systems[systemID].fn(); err != nil { // The error assignment is intended here - err = eris.Wrapf(err, "system %s failed", s.systems[systemID].name) - } + wg.Go(func() { + s.systems[systemID].fn() // Process all systems that depend on this one. for _, dependent := range s.graph[systemID] { @@ -88,15 +80,10 @@ func (s *systemScheduler) Run() error { executionQueue <- dependent } } - - return err }) } - if err := g.Wait(); err != nil { - return eris.Wrap(err, "system returned an error") - } - return nil + wg.Wait() // Wait for all systems to finish } // getCurrentAndNextIndegrees returns the current and next indegrees. It also switches the active diff --git a/pkg/cardinal/ecs/scheduler_internal_test.go b/pkg/cardinal/internal/ecs/scheduler_internal_test.go similarity index 98% rename from pkg/cardinal/ecs/scheduler_internal_test.go rename to pkg/cardinal/internal/ecs/scheduler_internal_test.go index 87467bc71..9c359c943 100644 --- a/pkg/cardinal/ecs/scheduler_internal_test.go +++ b/pkg/cardinal/internal/ecs/scheduler_internal_test.go @@ -12,7 +12,6 @@ import ( "github.com/argus-labs/world-engine/pkg/testutils" "github.com/kelindar/bitmap" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" ) // ------------------------------------------------------------------------------------------------- @@ -55,7 +54,7 @@ func TestScheduler_RunFuzzConcurrent(t *testing.T) { scheduler := newSystemScheduler() for i, sys := range systems { systemID := i - scheduler.register(sys.name, sys.deps, func() error { + scheduler.register(sys.name, sys.deps, func() { start := clock.Add(2) // We add a sleep here to simulate goroutine interleaving for a more realistic test // scenario. In synctest.Test, the time package uses a fake clock, so this sleep doesn't @@ -68,7 +67,6 @@ func TestScheduler_RunFuzzConcurrent(t *testing.T) { assert.Zero(t, events[systemID], "system %d executed more than once", systemID) events[systemID] = struct{ start, end int64 }{start: start, end: end} mu.Unlock() - return nil }) } scheduler.createSchedule() @@ -81,8 +79,7 @@ func TestScheduler_RunFuzzConcurrent(t *testing.T) { events[i] = struct{ start, end int64 }{} } - err := scheduler.Run() - require.NoError(t, err) + scheduler.Run() // Property: All systems execute exactly once. for i, ev := range events { diff --git a/pkg/cardinal/ecs/search.go b/pkg/cardinal/internal/ecs/search.go similarity index 100% rename from pkg/cardinal/ecs/search.go rename to pkg/cardinal/internal/ecs/search.go diff --git a/pkg/cardinal/internal/ecs/search_test.go b/pkg/cardinal/internal/ecs/search_test.go new file mode 100644 index 000000000..02aa9b6b5 --- /dev/null +++ b/pkg/cardinal/internal/ecs/search_test.go @@ -0,0 +1,327 @@ +package ecs_test + +// +// import ( +// "testing" +// +// "github.com/argus-labs/world-engine/pkg/cardinal/ecs" +// . "github.com/argus-labs/world-engine/pkg/cardinal/ecs/internal/testutils" +// "github.com/stretchr/testify/assert" +// "github.com/stretchr/testify/require" +// ) +// +// type initSystemState struct { +// Position ecs.Exact[struct{ ecs.Ref[Position] }] +// Health ecs.Exact[struct{ ecs.Ref[Health] }] +// Velocity ecs.Exact[struct{ ecs.Ref[Velocity] }] +// PositionHealth ecs.Exact[struct { +// Position ecs.Ref[Position] +// Health ecs.Ref[Health] +// }] +// PositionVelocity ecs.Exact[struct { +// Position ecs.Ref[Position] +// Velocity ecs.Ref[Velocity] +// }] +// HealthVelocity ecs.Exact[struct { +// Health ecs.Ref[Health] +// Velocity ecs.Ref[Velocity] +// }] +// } +// +// func TestSearch_Validation(t *testing.T) { +// t.Parallel() +// +// tests := []struct { +// name string +// params ecs.SearchParam +// wantErr bool +// }{ +// { +// name: "empty component list", +// params: ecs.SearchParam{ +// Find: []string{}, +// Match: ecs.MatchExact, +// }, +// wantErr: true, +// }, +// { +// name: "invalid match type", +// params: ecs.SearchParam{ +// Find: []string{"Position"}, +// Match: "invalid", +// }, +// wantErr: true, +// }, +// { +// name: "unregistered component", +// params: ecs.SearchParam{ +// Find: []string{"UnregisteredComponent"}, +// Match: ecs.MatchExact, +// }, +// wantErr: true, +// }, +// { +// name: "invalid where clause syntax", +// params: ecs.SearchParam{ +// Find: []string{"Health"}, +// Match: ecs.MatchExact, +// Where: "Health.Value >", +// }, +// wantErr: true, +// }, +// { +// name: "valid params", +// params: ecs.SearchParam{ +// Find: []string{"Position"}, +// Match: ecs.MatchExact, +// Where: "Position.X > 0", +// }, +// wantErr: false, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// t.Parallel() +// +// w := ecs.NewWorld() +// state := &initSystemState{} +// _ = ecs.RegisterSystem(w, state, "init", func() {}, ecs.Init) +// +// w.Init() +// +// err := w.Tick() +// require.NoError(t, err) +// +// _, err = w.NewSearch(tt.params) +// if tt.wantErr { +// assert.Error(t, err) +// } else { +// assert.NoError(t, err) +// } +// }) +// } +// } +// +// func TestSearch_FindAndMatch(t *testing.T) { +// t.Parallel() +// +// tests := []struct { +// name string +// params ecs.SearchParam +// setup func(*initSystemState) error +// validate func(*testing.T, []map[string]any) +// }{ +// { +// name: "exact match single component", +// params: ecs.SearchParam{ +// Find: []string{"Position"}, +// Match: ecs.MatchExact, +// }, +// setup: func(state *initSystemState) error { +// _, position := state.Position.Create() +// position.Set(Position{X: 1, Y: 2}) +// +// _, position = state.Position.Create() +// position.Set(Position{X: 3, Y: 4}) +// +// return nil +// }, +// validate: func(t *testing.T, results []map[string]any) { +// assert.Len(t, results, 2) +// assert.Contains(t, results, +// map[string]any{"Position": Position{X: 1, Y: 2}, "_id": uint32(0)}) +// assert.Contains(t, results, +// map[string]any{"Position": Position{X: 3, Y: 4}, "_id": uint32(1)}) +// }, +// }, +// { +// name: "contains match single component", +// params: ecs.SearchParam{ +// Find: []string{"Position"}, +// Match: ecs.MatchContains, +// }, +// setup: func(state *initSystemState) error { +// _, position := state.Position.Create() +// position.Set(Position{X: 1, Y: 2}) +// +// _, positionHealth := state.PositionHealth.Create() +// positionHealth.Position.Set(Position{X: 3, Y: 4}) +// positionHealth.Health.Set(Health{Value: 100}) +// +// return nil +// }, +// validate: func(t *testing.T, results []map[string]any) { +// assert.Len(t, results, 2) +// assert.Contains(t, results, +// map[string]any{"Position": Position{X: 1, Y: 2}, "_id": uint32(0)}) +// assert.Contains(t, results, +// map[string]any{ +// "Position": Position{X: 3, Y: 4}, +// "Health": Health{Value: 100}, +// "_id": uint32(1), +// }) +// }, +// }, +// { +// name: "empty result for no matching entities", +// params: ecs.SearchParam{ +// Find: []string{"Health"}, +// Match: ecs.MatchExact, +// }, +// setup: func(state *initSystemState) error { +// _, position := state.Position.Create() +// position.Set(Position{X: 1, Y: 2}) +// return nil +// }, +// validate: func(t *testing.T, results []map[string]any) { +// assert.Empty(t, results) +// }, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// t.Parallel() +// +// w := ecs.NewWorld() +// ecs.RegisterSystem(w, tt.setup, ecs.WithHook(ecs.Init)) +// +// w.Init() +// +// err := w.Tick() +// require.NoError(t, err) +// +// results, err := w.NewSearch(tt.params) +// require.NoError(t, err) +// +// tt.validate(t, results) +// }) +// } +// } +// +// // TestSearch_Where tests the Where clause filtering functionality. +// func TestSearch_Where(t *testing.T) { +// t.Parallel() +// +// tests := []struct { +// name string +// params ecs.SearchParam +// setup func(*initSystemState) error +// validate func(*testing.T, []map[string]any) +// }{ +// { +// name: "filter by health value", +// params: ecs.SearchParam{ +// Find: []string{"Health"}, +// Match: ecs.MatchContains, +// Where: "Health.Value > 75", +// }, +// setup: func(state *initSystemState) error { +// _, health := state.Health.Create() +// health.Set(Health{Value: 100}) +// +// _, health = state.Health.Create() +// health.Set(Health{Value: 50}) +// +// _, health = state.Health.Create() +// health.Set(Health{Value: 80}) +// +// return nil +// }, +// validate: func(t *testing.T, results []map[string]any) { +// assert.Len(t, results, 2) +// for _, entity := range results { +// health := entity["Health"].(Health) +// assert.Greater(t, health.Value, 75) +// } +// }, +// }, +// { +// name: "filter by position coordinates", +// params: ecs.SearchParam{ +// Find: []string{"Position"}, +// Match: ecs.MatchContains, +// Where: "Position.X > 0 && Position.Y > 0", +// }, +// setup: func(state *initSystemState) error { +// _, position := state.Position.Create() +// position.Set(Position{X: 1, Y: 2}) +// +// _, position = state.Position.Create() +// position.Set(Position{X: -1, Y: 2}) +// +// _, position = state.Position.Create() +// position.Set(Position{X: 3, Y: -4}) +// +// _, position = state.Position.Create() +// position.Set(Position{X: 5, Y: 6}) +// +// return nil +// }, +// validate: func(t *testing.T, results []map[string]any) { +// assert.Len(t, results, 2) +// for _, entity := range results { +// pos := entity["Position"].(Position) +// assert.Positive(t, pos.X) +// assert.Positive(t, pos.Y) +// } +// }, +// }, +// { +// name: "complex filter with multiple components", +// params: ecs.SearchParam{ +// Find: []string{"Position", "Health"}, +// Match: ecs.MatchContains, +// Where: "Position.X > 0 && Health.Value >= 100", +// }, +// setup: func(state *initSystemState) error { +// _, positionHealth := state.PositionHealth.Create() +// positionHealth.Position.Set(Position{X: 1, Y: 2}) +// positionHealth.Health.Set(Health{Value: 100}) +// +// _, positionHealth = state.PositionHealth.Create() +// positionHealth.Position.Set(Position{X: -1, Y: 2}) +// positionHealth.Health.Set(Health{Value: 100}) +// +// _, positionHealth = state.PositionHealth.Create() +// positionHealth.Position.Set(Position{X: 3, Y: 4}) +// positionHealth.Health.Set(Health{Value: 50}) +// +// _, positionHealth = state.PositionHealth.Create() +// positionHealth.Position.Set(Position{X: 5, Y: 6}) +// positionHealth.Health.Set(Health{Value: 150}) +// +// return nil +// }, +// validate: func(t *testing.T, results []map[string]any) { +// assert.Len(t, results, 2) +// for _, entity := range results { +// pos := entity["Position"].(Position) +// health := entity["Health"].(Health) +// assert.Positive(t, pos.X) +// assert.GreaterOrEqual(t, health.Value, 100) +// } +// }, +// }, +// } +// +// for _, tt := range tests { +// t.Run(tt.name, func(t *testing.T) { +// t.Parallel() +// +// w := ecs.NewWorld() +// ecs.RegisterSystem(w, tt.setup, ecs.WithHook(ecs.Init)) +// +// w.Init() +// +// err := w.Tick() +// require.NoError(t, err) +// +// results, err := w.NewSearch(tt.params) +// require.NoError(t, err) +// +// tt.validate(t, results) +// }) +// } +// } diff --git a/pkg/cardinal/ecs/sparse.go b/pkg/cardinal/internal/ecs/sparse.go similarity index 100% rename from pkg/cardinal/ecs/sparse.go rename to pkg/cardinal/internal/ecs/sparse.go diff --git a/pkg/cardinal/ecs/sparse_internal_test.go b/pkg/cardinal/internal/ecs/sparse_internal_test.go similarity index 86% rename from pkg/cardinal/ecs/sparse_internal_test.go rename to pkg/cardinal/internal/ecs/sparse_internal_test.go index 59d2f89aa..031f1bf91 100644 --- a/pkg/cardinal/ecs/sparse_internal_test.go +++ b/pkg/cardinal/internal/ecs/sparse_internal_test.go @@ -10,9 +10,8 @@ import ( // ------------------------------------------------------------------------------------------------- // Model-based fuzzing sparse set operations // ------------------------------------------------------------------------------------------------- -// This test verifies the sparseSet implementation correctness using model-based testing. It -// compares our implementation against a Go's map as the model by applying random sequences of -// set/get/remove operations to both and asserting equivalence. +// This test verifies the queue implementation correctness by applying random sequences of +// operations and comparing it against a regular Go map as the model. // ------------------------------------------------------------------------------------------------- func TestSparseSet_ModelFuzz(t *testing.T) { @@ -20,10 +19,17 @@ func TestSparseSet_ModelFuzz(t *testing.T) { prng := testutils.NewRand(t) const ( - opsMax = 1 << 15 // 32_768 iterations - eidMax = 10_000 + opsMax = 1 << 15 // 32_768 iterations + eidMax = 10_000 + opSet = "set" + opGet = "get" + opRemove = "remove" ) + // Randomize operation weights. + operations := []string{opSet, opGet, opRemove} + weights := testutils.RandOpWeights(prng, operations) + impl := newSparseSet() model := make(map[EntityID]int, sparseCapacity) @@ -31,9 +37,9 @@ func TestSparseSet_ModelFuzz(t *testing.T) { for range opsMax { key := EntityID(prng.IntN(eidMax)) - op := testutils.RandWeightedOp(prng, sparseSetOps) + op := testutils.RandWeightedOp(prng, weights) switch op { - case s_set: + case opSet: value := prng.Int() impl.set(key, value) model[key] = value @@ -43,7 +49,7 @@ func TestSparseSet_ModelFuzz(t *testing.T) { assert.True(t, ok, "set(%d) then get should exist", key) assert.Equal(t, value, got, "set(%d) then get value mismatch", key) - case s_get: + case opGet: // Bias toward existing keys (80%) to test value retrieval path. if len(model) > 0 && prng.Float64() < 0.8 { key = testutils.RandMapKey(prng, model) @@ -62,7 +68,7 @@ func TestSparseSet_ModelFuzz(t *testing.T) { assert.Equal(t, sparseTombstone, impl[key], "get(%d) non-existent key should be tombstone", key) } - case s_remove: + case opRemove: implOk := impl.remove(key) _, modelOk := model[key] delete(model, key) @@ -90,16 +96,6 @@ func TestSparseSet_ModelFuzz(t *testing.T) { } } -type sparseSetOp uint8 - -const ( - s_set sparseSetOp = 55 - s_remove sparseSetOp = 35 - s_get sparseSetOp = 10 -) - -var sparseSetOps = []sparseSetOp{s_set, s_remove, s_get} - // ------------------------------------------------------------------------------------------------- // Serialization smoke test // ------------------------------------------------------------------------------------------------- diff --git a/pkg/cardinal/internal/ecs/system.go b/pkg/cardinal/internal/ecs/system.go new file mode 100644 index 000000000..d1452621b --- /dev/null +++ b/pkg/cardinal/internal/ecs/system.go @@ -0,0 +1,547 @@ +package ecs + +import ( + "iter" + "math" + "reflect" + + "github.com/argus-labs/world-engine/pkg/assert" + "github.com/kelindar/bitmap" + "github.com/rotisserie/eris" +) + +// SystemHook defines when a system should be executed in the update cycle. +type SystemHook uint8 + +const ( + // PreUpdate runs before the main update. + PreUpdate SystemHook = 0 + // Update runs during the main update phase. + Update SystemHook = 1 + // PostUpdate runs after the main update. + PostUpdate SystemHook = 2 + // Init runs once during world initialization. + Init SystemHook = 3 +) + +// initSystem represents a system that should be run once during world initialization. +type initSystem struct { + name string // The name of the system + fn func() // Function that wraps a System +} + +func RegisterSystem[T any](world *World, state *T, name string, system func(), hook SystemHook) error { + deps, err := initSystemFields(state, world) + if err != nil { + return eris.Wrap(err, "failed to init ecs fields") + } + + switch hook { + case Init: + world.initSystems = append(world.initSystems, initSystem{name: name, fn: system}) + case PreUpdate, Update, PostUpdate: + world.scheduler[hook].register(name, deps, system) + default: + return eris.Errorf("invalid system hook %d", hook) + } + + return nil +} + +type systemInitMetadata struct { + world *World + // Bitmaps used by the scheduler as the system's dependencies. + depsComponent bitmap.Bitmap + depsSystemEvent bitmap.Bitmap + systemEvents map[string]struct{} +} + +func initSystemFields[T any](state *T, world *World) (bitmap.Bitmap, error) { + meta := systemInitMetadata{ + world: world, + systemEvents: make(map[string]struct{}), + } + + value := reflect.ValueOf(state).Elem() + for i := range value.NumField() { + field := value.Field(i) + fieldType := value.Type().Field(i) + + assert.That(field.CanAddr(), "ecs.RegisterSystem must be called by cardinal.RegisterSystem") + + fieldInstance := field.Addr().Interface() + ecsField, ok := fieldInstance.(SystemField) + + // We move the bulk of system field checking to cardinal.initSytemFields so we just have to + // initialize the ecs.SystemFields in this function. + if !ok { + continue + } + + // Initialize the field and collect its dependencies. + if err := ecsField.init(&meta); err != nil { + return bitmap.Bitmap{}, eris.Wrapf(err, "failed to initialize field %s", fieldType.Name) + } + } + + // Add system event deps to component deps. + deps := meta.depsComponent.Clone(nil) + n := world.state.components.nextID + assert.That(meta.depsSystemEvent.Count()+int(n) <= math.MaxUint32-1, "system dependencies exceed max limit") + meta.depsSystemEvent.Range(func(x uint32) { + deps.Set(n + x) + }) + + return deps, nil +} + +type SystemField interface { + init(meta *systemInitMetadata) error +} + +var _ SystemField = &WithSystemEventReceiver[SystemEvent]{} +var _ SystemField = &WithSystemEventEmitter[SystemEvent]{} +var _ SystemField = &search[any]{} +var _ SystemField = &Contains[any]{} +var _ SystemField = &Exact[any]{} + +// ------------------------------------------------------------------------------------------------- +// System Event Fields +// ------------------------------------------------------------------------------------------------- + +// WithSystemEventReceiver is a generic system state field that allows systems to receive system +// events of type T. System events are automatically registered when the system is registered. +// +// Example: +// +// // Define a system event for player deaths. +// type PlayerDeath struct{ Nickname string } +// +// func (PlayerDeath) Name() string { return "player-death" } +// +// type GraveyardSystemState struct { +// PlayerDeathSystemEvents ecs.WithSystemEventReceiver[PlayerDeath] +// // Other fields... +// } +// +// // Your system function receives a pointer to your system state. +// func GraveyardSystem(state *GraveyardSystemState) error { +// // Receive system events emitted from another system. +// for systemEvent := range state.PlayerDeathSystemEvents.Iter() { +// // Process the system event. +// } +// return nil +// } +type WithSystemEventReceiver[T SystemEvent] struct { + manager *systemEventManager +} + +// init initializes the system event state field. +func (s *WithSystemEventReceiver[T]) init(meta *systemInitMetadata) error { + var zero T + name := zero.Name() + + if _, ok := meta.systemEvents[name]; ok { + return eris.Errorf("systems cannot process multiple system events of the same type: %s", name) + } + + id, err := meta.world.systemEvents.register(name) + if err != nil { + return eris.Wrapf(err, "failed to register system event %s", name) + } + s.manager = &meta.world.systemEvents + + meta.systemEvents[name] = struct{}{} // Add to system's system events set for duplicate field check + meta.depsSystemEvent.Set(id) // Add the system event ID to the system event dependencies + return nil +} + +// Iter returns an iterator over all system events of type T. +// +// Example usage: +// +// for systemEvent := range state.PlayerDeathEvents.Iter() { +// // Process each system event +// } +func (s *WithSystemEventReceiver[T]) Iter() iter.Seq[T] { + var zero T + systemEvents := s.manager.get(zero.Name()) + + return func(yield func(T) bool) { + for _, systemEvent := range systemEvents { + payload, ok := systemEvent.(T) + assert.That(ok, "mismatched system event type") + if !yield(payload) { + return + } + } + } +} + +// WithSystemEventEmitter is a generic system state field that allows systems to emit system events +// of type T. System events are automatically registered when the system is registered. +// +// Example: +// +// // Define a system event for player deaths. +// type PlayerDeath struct{ Nickname string } +// +// func (PlayerDeath) Name() string { return "player-death" } +// +// type CombatSystemState struct { +// PlayerDeathSystemEvents ecs.WithSystemEventEmitter[PlayerDeath] +// // Other fields... +// } +// +// // Your system function receives a pointer to your system state. +// func CombatSystem(state *CombatSystemState) error { +// // Emit a player death event to be handled in another system. +// state.PlayerDeathEvents.Emit(PlayerDeath{Nickname: "Player1"}) +// return nil +// } +type WithSystemEventEmitter[T SystemEvent] struct { + manager *systemEventManager +} + +// init initializes the system event state field. +func (s *WithSystemEventEmitter[T]) init(meta *systemInitMetadata) error { + var zero T + name := zero.Name() + + if _, ok := meta.systemEvents[name]; ok { + return eris.Errorf("systems cannot process multiple system events of the same type: %s", name) + } + + id, err := meta.world.systemEvents.register(name) + if err != nil { + return eris.Wrapf(err, "failed to register system event %s", name) + } + s.manager = &meta.world.systemEvents + + meta.systemEvents[name] = struct{}{} // Add to system's system events set for duplicate field check + meta.depsSystemEvent.Set(id) // Add the system event ID to the system event dependencies + return nil +} + +// Emit emits a system event of type T. +// +// Example: +// +// state.PlayerDeathEvents.Emit(PlayerDeath{Nickname: "Player1"}) +func (s *WithSystemEventEmitter[T]) Emit(systemEvent T) { + var zero T + s.manager.enqueue(zero.Name(), systemEvent) +} + +// ------------------------------------------------------------------------------------------------- +// Component Search Fields +// ------------------------------------------------------------------------------------------------- + +// search provides type-safe component queries for entities in the world state. It uses reflection +// during initialization to figure out which components to include in the query. T must be a struct +// type composed of fields of only the type Ref[Component], e.g.: +// +// type Particle struct { +// Position ecs.Ref[Position] +// Velocity ecs.Ref[Velocity] +// } +// +// search is used as the base implementation for ecs.Contains and ecs.Exact which provide the +// matching behaviors for finding entities with specific component combinations. Every component +// type used in T will be automatically registered when the system is registered. +type search[T any] struct { + world *World // Reference to the world + components bitmap.Bitmap // Bitmap of component types this search looks for + result T // Reusable instance of the result type + fields []ref // Cached references to result's fields to be initialized in Iter +} + +// init initializes the search by analyzing the generic type's struct fields and caching its +// component dependencies. +func (s *search[T]) init(meta *systemInitMetadata) error { + var zero T + resultType := reflect.TypeOf(zero) + resultValue := reflect.ValueOf(&s.result).Elem() + + s.world = meta.world + s.fields = make([]ref, resultType.NumField()) + + for i := range resultType.NumField() { + // Store a ref of the field in the search to be initialized during Iter. + field := resultType.Field(i) + fieldRef, ok := resultValue.Field(i).Addr().Interface().(ref) + if !ok { + return eris.Errorf("field %s must be of type Ref[Component], got %s", field.Name, field.Type) + } + s.fields[i] = fieldRef + + // Register the component. + cid, err := fieldRef.register(s.world) + if err != nil { + return eris.Wrapf(err, "failed to register component %d", cid) + } + + s.components.Set(cid) // Add to local component set (used for archetype lookups) + meta.depsComponent.Set(cid) // Add to system component deps (used by scheduler) + } + return nil +} + +// Create creates a new entity with the given components. Returns an error if any of the components +// are not defined in the search field. +// +// Example: +// +// entity, err := state.Mob.Create(Health{Value: 100}, Position{X: 0, Y: 0}) +// if err != nil { +// state.Logger().Error().Err(err).Msg("Failed to create entity") +// } +// // Use entity... +func (s *search[T]) Create() (EntityID, T) { + ws := s.world.state + eid := ws.newEntityWithArchetype(s.components) + + for i := range s.fields { + s.fields[i].attach(ws, eid) // Attach the entity and world state buffer to the ref + } + + return eid, s.result +} + +// Destroy deletes an entity and all its components from the world. +// +// Example: +// +// ok := state.Mob.Destroy(entityID) +// if !ok { +// state.Logger().Warn().Msg("Entity doesn't exist or is already destroyed") +// } +func (s *search[T]) Destroy(eid EntityID) bool { + return Destroy(s.world.state, eid) +} + +// getByID retrieves an entity's components by its ID using the provided match function to validate +// that the entity's archetype matches the search criteria. +func (s *search[T]) getByID(eid EntityID, match func(*archetype) bool) (T, error) { + ws := s.world.state + + aid, exists := ws.entityArch.get(eid) + if !exists { + var zero T + return zero, ErrEntityNotFound + } + + arch := ws.archetypes[aid] + if !match(arch) { + var zero T + return zero, ErrArchetypeMismatch + } + + for i := range s.fields { + s.fields[i].attach(ws, eid) // Attach the entity and world state buffer to the ref + } + return s.result, nil +} + +// iter returns an iterator over all entities that match the given archetypes. +func (s *search[T]) iter(archetypeIDs []archetypeID) iter.Seq2[EntityID, T] { + ws := s.world.state + return func(yield func(EntityID, T) bool) { + for _, id := range archetypeIDs { + arch := ws.archetypes[id] + for _, eid := range arch.entities { + for i := range s.fields { + s.fields[i].attach(ws, eid) // Attach the entity and world state buffer to the ref + } + + if !yield(eid, s.result) { + return + } + } + } + } +} + +// Contains provides a search that matches archetypes containing all specified component types, +// potentially along with additional components. +// +// Example: +// +// type MovementSystemState struct { +// Movers ecs.Contains[struct { +// Position ecs.Ref[Position] +// Velocity ecs.Ref[Velocity] +// }] +// // Other fields... +// } +// +// // Your system function receives a pointer to your system state. +// func MovementSystem(state *MovementSystemState) error { +// for entity, mover := range state.Movers.Iter() { +// // Process entity and compnents. +// } +// return nil +// } +type Contains[T any] struct{ search[T] } + +// Iter returns an iterator over entities and their components that match the Contains search. +// +// Example: +// +// for _, mover := range state.Movers.Iter() { +// pos := mover.Position.Get() +// vel := mover.Velocity.Get() +// mover.Position.Set(Position{X: pos.X + vel.X, Y: pos.Y + vel.Y}) +// } +func (c *Contains[T]) Iter() iter.Seq2[EntityID, T] { + return c.iter(c.world.state.archContains(c.components)) +} + +// GetByID retrieves an entity's components by its ID. Returns ErrEntityNotFound if the entity +// doesn't exist, or ErrArchetypeMismatch if the entity doesn't contain all the required components. +// +// Example: +// +// mob, err := state.Mob.GetByID(entityID) +// if err != nil { +// state.Logger().Warn().Err(err).Msg("Entity not found or doesn't match") +// return err +// } +// health := mob.Health.Get() +func (c *Contains[T]) GetByID(eid EntityID) (T, error) { + return c.getByID(eid, func(arch *archetype) bool { + return arch.contains(c.components) + }) +} + +// Exact provides a search that matches archetypes containing exactly the specified component types, +// without any additional components. +// +// Example: +// +// type PlayerSystemState struct { +// Players ecs.Exact[struct { +// Tag ecs.Ref[PlayerTag] +// Health ecs.Ref[Health] +// }] +// // Other fields... +// } +// +// // Your system function receives a pointer to your system state. +// func PlayerSystem(state *PlayerSystemState) error { +// for entity, player := range state.Players.Iter() { +// // Process entity and compnents. +// } +// return nil +// } +type Exact[T any] struct{ search[T] } + +// Iter returns an iterator over entities and their components that match the Exact query. +// +// Example: +// +// for _, player := range state.Players.Iter() { +// health := player.Health.Get() +// player.Health.Set(Health{HP: health.HP + 100}) +// } +func (c *Exact[T]) Iter() iter.Seq2[EntityID, T] { + archetypes := make([]int, 0, 1) + if id, ok := c.world.state.archExact(c.components); ok { + archetypes = append(archetypes, id) + } + return c.iter(archetypes) +} + +// GetByID retrieves an entity's components by its ID. Returns ErrEntityNotFound if the entity +// doesn't exist, or ErrArchetypeMismatch if the entity doesn't have exactly the required components. +// +// Example: +// +// player, err := state.Players.GetByID(entityID) +// if err != nil { +// state.Logger().Warn().Err(err).Msg("Entity not found or doesn't match") +// return err +// } +// health := player.Health.Get() +func (c *Exact[T]) GetByID(eid EntityID) (T, error) { + return c.getByID(eid, func(arch *archetype) bool { + return arch.exact(c.components) + }) +} + +// ------------------------------------------------------------------------------------------------- +// Component Handles +// ------------------------------------------------------------------------------------------------- + +// ref is an internal interface for component references. +type ref interface { + attach(*worldState, EntityID) + register(*World) (componentID, error) +} + +var _ ref = &Ref[Component]{} + +// Ref provides a type-safe handle to a component on an entity. +type Ref[T Component] struct { + ws *worldState // Internal reference to the world state + entity EntityID // The entity's ID +} + +// attach sets the entity and world state to the Ref so that Get and Set works properly. +func (r *Ref[T]) attach(ws *worldState, eid EntityID) { + r.ws = ws + r.entity = eid +} + +// TODO: might be possible to get the read/write type of the component in the query so we can +// optimize the scheduler by running read-only systems in parallel. e.g., we can have two different +// ref types, ReadOnlyRef and ReadWriteRef. For the read-only ref, we don't have to set its ID in +// the system bitmap. + +// register returns the registerAndGetComponent type for this Ref. +func (r *Ref[T]) register(w *World) (componentID, error) { + return registerComponent[T](w) +} + +// Get retrieves the component value for this Ref's entity. +// +// This is the recommended system-friendly alternative to ecs.Get() for accessing components within systems. +// +// Example: +// +// for _, player := range state.Players.Iter() { +// health := player.Health.Get() +// } +func (r *Ref[T]) Get() T { + component, err := Get[T](r.ws, r.entity) + assert.That(err == nil, "entity doesn't exist or doesn't contain the component") // Shouldn't happen + return component +} + +// Set updates the component value for this Ref's entity. +// +// This is the recommended system-friendly alternative to ecs.Set() for modifying components within systems. +// +// Example: +// +// for _, player := range state.Players.Iter() { +// player.Health.Set(Health{HP: 100}) +// } +func (r *Ref[T]) Set(component T) { + err := Set(r.ws, r.entity, component) + assert.That(err == nil, "entity doesn't exist") // Shouldn't happen +} + +// Remove removes the component from this Ref's entity. +// +// This is the recommended system-friendly alternative to ecs.Remove() for removing components within systems. +// +// Example: +// +// for _, player := range state.Players.Iter() { +// player.Shield.Remove() +// } +func (r *Ref[T]) Remove() { + err := Remove[T](r.ws, r.entity) + assert.That(err == nil, "entity doesn't exist or doesn't contain the component") // Shouldn't happen +} diff --git a/pkg/cardinal/ecs/system_event.go b/pkg/cardinal/internal/ecs/system_event.go similarity index 94% rename from pkg/cardinal/ecs/system_event.go rename to pkg/cardinal/internal/ecs/system_event.go index 70f8219da..7942e5117 100644 --- a/pkg/cardinal/ecs/system_event.go +++ b/pkg/cardinal/internal/ecs/system_event.go @@ -4,6 +4,7 @@ import ( "math" "github.com/argus-labs/world-engine/pkg/assert" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" "github.com/rotisserie/eris" ) @@ -16,7 +17,9 @@ const maxSystemEventID = math.MaxUint32 - 1 // SystemEvent is an interface that all system events must implement. // SystemEvents are events emitted by a system to be handled by another system. -type SystemEvent = Command +type SystemEvent interface { //nolint:iface // may extend later + schema.Serializable +} // systemEventManager manages the registration and storage of system events. type systemEventManager struct { diff --git a/pkg/cardinal/ecs/system_event_internal_test.go b/pkg/cardinal/internal/ecs/system_event_internal_test.go similarity index 80% rename from pkg/cardinal/ecs/system_event_internal_test.go rename to pkg/cardinal/internal/ecs/system_event_internal_test.go index 3e7f67ac4..5b17bda5c 100644 --- a/pkg/cardinal/ecs/system_event_internal_test.go +++ b/pkg/cardinal/internal/ecs/system_event_internal_test.go @@ -12,17 +12,26 @@ import ( // ------------------------------------------------------------------------------------------------- // Model-based fuzzing system-event manager operations // ------------------------------------------------------------------------------------------------- -// This test verifies the systemEventManager implementation correctness using model-based testing. -// It compares our implementation against a map[string][]SystemEvent as the model by applying random -// sequences of enqueue/get/clear operations to both and asserting equivalence. System events are -// pre-registered since WithSystemEventEmitter/Receiver.init guarantees registration before use. +// This test verifies the queue implementation correctness by applying random sequences of +// operations and comparing it against a regular Go map of name->[]SystemEvent as the model. +// System events are pre-registered since WithSystemEventEmitter/Receiver.init guarantees +// registration before use. // ------------------------------------------------------------------------------------------------- func TestSystemEvent_ModelFuzz(t *testing.T) { t.Parallel() prng := testutils.NewRand(t) - const opsMax = 1 << 15 // 32_768 iterations + const ( + opsMax = 1 << 15 // 32_768 iterations + opEnqueue = "enqueue" + opGet = "get" + opClear = "clear" + ) + + // Randomize operation weights. + operations := []string{opEnqueue, opGet, opClear} + weights := testutils.RandOpWeights(prng, operations) impl := newSystemEventManager() model := make(map[string][]SystemEvent) // name -> system-event buffer @@ -35,16 +44,16 @@ func TestSystemEvent_ModelFuzz(t *testing.T) { } for range opsMax { - op := testutils.RandWeightedOp(prng, systemEventOps) + op := testutils.RandWeightedOp(prng, weights) switch op { - case sem_enqueue: + case opEnqueue: name := testutils.RandMapKey(prng, model) event := randSystemEventByName(prng, name) impl.enqueue(name, event) model[name] = append(model[name], event) - case sem_get: + case opGet: name := testutils.RandMapKey(prng, model) implSysEvents := impl.get(name) @@ -56,7 +65,7 @@ func TestSystemEvent_ModelFuzz(t *testing.T) { assert.Equal(t, modelSysEvents[i], implSysEvents[i], "get(%s)[%d] mismatch", name, i) } - case sem_clear: + case opClear: impl.clear() for name := range model { model[name] = []SystemEvent{} @@ -84,16 +93,6 @@ func TestSystemEvent_ModelFuzz(t *testing.T) { } } -type systemEventOp uint8 - -const ( - sem_enqueue systemEventOp = 46 - sem_get systemEventOp = 44 - sem_clear systemEventOp = 10 -) - -var systemEventOps = []systemEventOp{sem_enqueue, sem_get, sem_clear} - var allSystemEventNames = []string{ testutils.SystemEventA{}.Name(), testutils.SystemEventB{}.Name(), testutils.SystemEventC{}.Name(), } @@ -114,10 +113,8 @@ func randSystemEventByName(prng *rand.Rand, name string) SystemEvent { // ------------------------------------------------------------------------------------------------- // Model-based fuzzing system-event registration // ------------------------------------------------------------------------------------------------- -// This test verifies the systemEventManager registration correctness using model-based testing. It -// compares our implementation against a map[string]systemEventID as the model by applying random -// register operations and asserting equivalence. We also verify structural invariants: -// name-id bijection and ID uniqueness. +// This test verifies the system event manager registration correctness by applying random sequences +// of operations and comparing against a Go map as the model. // ------------------------------------------------------------------------------------------------- func TestSystemEvent_RegisterModelFuzz(t *testing.T) { @@ -130,7 +127,7 @@ func TestSystemEvent_RegisterModelFuzz(t *testing.T) { model := make(map[string]systemEventID) // name -> ID for range opsMax { - name := randValidCommandName(prng) // Reuse the command name generator as they're identical + name := randValidEventName(prng) // Reuse the command name generator as they're identical implID, err := impl.register(name) require.NoError(t, err) @@ -181,3 +178,13 @@ func TestSystemEvent_RegisterModelFuzz(t *testing.T) { assert.Equal(t, id1+1, id3) }) } + +func randValidEventName(prng *rand.Rand) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_" + length := prng.IntN(50) + 1 // 1-50 characters + b := make([]byte, length) + for i := range b { + b[i] = chars[prng.IntN(len(chars))] + } + return string(b) +} diff --git a/pkg/cardinal/internal/ecs/system_internal_test.go b/pkg/cardinal/internal/ecs/system_internal_test.go new file mode 100644 index 000000000..488a99c3c --- /dev/null +++ b/pkg/cardinal/internal/ecs/system_internal_test.go @@ -0,0 +1,249 @@ +package ecs + +import ( + "testing" + + "github.com/argus-labs/world-engine/pkg/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TODO: test system registration to make sure scheduler deps are correct. + +// ------------------------------------------------------------------------------------------------- +// Search, Contains, Exact, smoke tests +// ------------------------------------------------------------------------------------------------- +// The search fields and Ref are just light wrappers over the world state operations, Which is +// already tested. Here, we just check if the regular search operations work. Most of the +// complicated logic in initialization where the cached result is created using reflection. We can +// verify it's working if the operations work correctly. +// ------------------------------------------------------------------------------------------------- + +func TestSearch_Smoke(t *testing.T) { + t.Parallel() + + t.Run("iter contains", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newSearchFixture(t) + + // Create random movers and singles; only movers should appear in Iter. + var expectedIDs []EntityID + for range prng.IntN(100) { + if prng.IntN(2) == 0 { + eid, _ := fixture.Movers.Create() + expectedIDs = append(expectedIDs, eid) + } else { + fixture.Singles.Create() + } + } + + var moverIDs []EntityID + for eid := range fixture.Movers.Iter() { + moverIDs = append(moverIDs, eid) + } + assert.Equal(t, expectedIDs, moverIDs) + }) + + t.Run("iter exact", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newSearchFixture(t) + + // Create random singles and movers; only singles should appear in Iter. + var expectedIDs []EntityID + for range prng.IntN(100) { + if prng.IntN(2) == 0 { + eid, _ := fixture.Singles.Create() + expectedIDs = append(expectedIDs, eid) + } else { + fixture.Movers.Create() + } + } + + var singleIDs []EntityID + for eid := range fixture.Singles.Iter() { + singleIDs = append(singleIDs, eid) + } + assert.Equal(t, expectedIDs, singleIDs) + }) + + t.Run("get by id", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newSearchFixture(t) + + compB := testutils.ComponentB{ID: prng.Uint64(), Label: testutils.RandString(prng, 8), Enabled: prng.IntN(2) == 1} + moverID, mover := fixture.Movers.Create() + mover.A.Set(testutils.ComponentA{X: prng.Float64(), Y: prng.Float64(), Z: prng.Float64()}) + mover.B.Set(compB) + + singleID, single := fixture.Singles.Create() + single.A.Set(testutils.ComponentA{X: prng.Float64(), Y: prng.Float64(), Z: prng.Float64()}) + + // Success: correct archetype. + moverResult, err := fixture.Movers.GetByID(moverID) + require.NoError(t, err) + assert.Equal(t, compB, moverResult.B.Get()) + + // Wrong archetype. + _, err = fixture.Movers.GetByID(singleID) + require.ErrorIs(t, err, ErrArchetypeMismatch) + + _, err = fixture.Singles.GetByID(moverID) + require.ErrorIs(t, err, ErrArchetypeMismatch) + + // Nonexistent entity. + _, err = fixture.Movers.GetByID(999) + require.ErrorIs(t, err, ErrEntityNotFound) + }) + + t.Run("get set remove", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newSearchFixture(t) + + eid, mover := fixture.Movers.Create() + + // Get returns zero value before Set. + assert.Equal(t, testutils.ComponentA{}, mover.A.Get()) + + // Set then Get round-trips the value. + compA := testutils.ComponentA{X: prng.Float64(), Y: prng.Float64(), Z: prng.Float64()} + mover.A.Set(compA) + assert.Equal(t, compA, mover.A.Get()) + + // Overwrite with a new value. + compA2 := testutils.ComponentA{X: prng.Float64(), Y: prng.Float64(), Z: prng.Float64()} + mover.A.Set(compA2) + assert.Equal(t, compA2, mover.A.Get()) + + // Remove changes the archetype, so the entity no longer matches Movers. + mover.A.Remove() + _, err := fixture.Movers.GetByID(eid) + require.ErrorIs(t, err, ErrArchetypeMismatch) + }) + + t.Run("destroy", func(t *testing.T) { + t.Parallel() + fixture := newSearchFixture(t) + + eid, _ := fixture.Movers.Create() + + // Destroy succeeds once, then fails on the same ID. + assert.True(t, fixture.Movers.Destroy(eid)) + assert.False(t, fixture.Movers.Destroy(eid)) + }) +} + +type searchFixture struct { + // These are the fields under test. They must be public/exported. + Movers Contains[struct { + A Ref[testutils.ComponentA] + B Ref[testutils.ComponentB] + }] + Singles Exact[struct { + A Ref[testutils.ComponentA] + }] +} + +func newSearchFixture(t *testing.T) *searchFixture { + t.Helper() + + world := NewWorld() + + fixture := &searchFixture{} + _, err := initSystemFields(fixture, world) + require.NoError(t, err) + + return fixture +} + +// ------------------------------------------------------------------------------------------------- +// WithSystemEvent smoke tests +// ------------------------------------------------------------------------------------------------- +// WithSystemEventEmitter and WithSystemEventReceiver are just light wrappers over the +// systemEventManager, which is already tested. Here, we just check if the regular system event +// operations work correctly. +// ------------------------------------------------------------------------------------------------- + +func TestSystem_WithSystemEvent(t *testing.T) { + t.Parallel() + + t.Run("round trip", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newSystemEventFixture(t) + + count := prng.IntN(10_000) + model := make([]testutils.SimpleSystemEvent, count) + for i := range count { + model[i] = testutils.SimpleSystemEvent{Value: prng.Int()} + } + + for _, event := range model { + fixture.Emitter.Emit(event) + } + + var results []testutils.SimpleSystemEvent + for event := range fixture.Receiver.Iter() { + results = append(results, event) + } + + assert.Len(t, results, len(model), "completeness: expected %d events, got %d", len(model), len(results)) + for i, result := range results { + assert.Equal(t, model[i], result, "round-trip integrity: event mismatch at index %d", i) + } + }) + + t.Run("empty iteration", func(t *testing.T) { + t.Parallel() + + fixture := newSystemEventFixture(t) + + count := 0 + for range fixture.Receiver.Iter() { + count++ + } + assert.Equal(t, 0, count) + }) + + t.Run("early termination", func(t *testing.T) { + t.Parallel() + + fixture := newSystemEventFixture(t) + + for i := range 10 { + fixture.Emitter.Emit(testutils.SimpleSystemEvent{Value: i}) + } + + count := 0 + for range fixture.Receiver.Iter() { + count++ + break + } + assert.Equal(t, 1, count) + }) +} + +type systemEventFixture struct { + Emitter WithSystemEventEmitter[testutils.SimpleSystemEvent] + Receiver WithSystemEventReceiver[testutils.SimpleSystemEvent] +} + +func newSystemEventFixture(t *testing.T) *systemEventFixture { + t.Helper() + + world := NewWorld() + fixture := &systemEventFixture{} + + meta := &systemInitMetadata{world: world, systemEvents: make(map[string]struct{})} + err := fixture.Emitter.init(meta) + require.NoError(t, err) + + meta = &systemInitMetadata{world: world, systemEvents: make(map[string]struct{})} + err = fixture.Receiver.init(meta) + require.NoError(t, err) + + return fixture +} diff --git a/pkg/cardinal/internal/ecs/world.go b/pkg/cardinal/internal/ecs/world.go new file mode 100644 index 000000000..662ced5ed --- /dev/null +++ b/pkg/cardinal/internal/ecs/world.go @@ -0,0 +1,99 @@ +package ecs + +import ( + cardinalv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1" + "github.com/rotisserie/eris" + "google.golang.org/protobuf/proto" +) + +// World represents the root ECS state. +type World struct { + state *worldState + initDone bool // Tracks if init systems have been executed + initSystems []initSystem // Initialization systems, run once during the genesis tick + scheduler [3]systemScheduler // Systems schedulers (PreTick, Update, PostTick) + systemEvents systemEventManager // Manages system events + onComponentRegister func(Component) error // Callback called when a component is registered +} + +// NewWorld creates a new World instance. +func NewWorld() *World { + world := &World{ + state: newWorldState(), + initDone: false, + initSystems: make([]initSystem, 0), + scheduler: [3]systemScheduler{}, + systemEvents: newSystemEventManager(), + } + + for i := range world.scheduler { + world.scheduler[i] = newSystemScheduler() + } + + return world +} + +// Init initializes the system schedulers by creating their schedules. +func (w *World) Init() { + for i := range w.scheduler { + w.scheduler[i].createSchedule() + } +} + +// Tick passes external events into the event manager and executes the +// registered systems in order. If any system returns an error, the entire tick is considered +// failed, changes are discarded, and the error is returned. If the tick succeeds, the events +// emmitted during the tick is returned. +func (w *World) Tick() error { + // Run init systems once on first tick. + if !w.initDone { + for _, system := range w.initSystems { + system.fn() + } + w.initDone = true + return nil + } + + // Clear system events after each tick. + defer w.systemEvents.clear() + + // Run the systems. + for i := range w.scheduler { + w.scheduler[i].Run() + } + + return nil +} + +func (w *World) OnComponentRegister(callback func(zero Component) error) { + w.onComponentRegister = callback +} + +// ------------------------------------------------------------------------------------------------- +// Serialization methods +// ------------------------------------------------------------------------------------------------- + +// Serialize converts the World's state to a byte slice for serialization. +// Only serializes the WorldState as components, systems, and managers are recreated on startup. +func (w *World) Serialize() ([]byte, error) { + worldState, err := w.state.toProto() + if err != nil { + return nil, err + } + return proto.MarshalOptions{Deterministic: true}.Marshal(worldState) +} + +// Deserialize populates the World's state from a byte slice. +// This should only be called after the World has been properly initialized with components registered. +func (w *World) Deserialize(data []byte) error { + var worldState cardinalv1.WorldState + if err := proto.Unmarshal(data, &worldState); err != nil { + return eris.Wrap(err, "failed to unmarshal world state") + } + if err := w.state.fromProto(&worldState); err != nil { + return err + } + // Mark init as done to prevent re-running init systems after restore. + w.initDone = true + return nil +} diff --git a/pkg/cardinal/ecs/world_internal_test.go b/pkg/cardinal/internal/ecs/world_internal_test.go similarity index 95% rename from pkg/cardinal/ecs/world_internal_test.go rename to pkg/cardinal/internal/ecs/world_internal_test.go index 5f2175e0a..21976da73 100644 --- a/pkg/cardinal/ecs/world_internal_test.go +++ b/pkg/cardinal/internal/ecs/world_internal_test.go @@ -110,11 +110,12 @@ func newTestWorld(t *testing.T) *World { t.Helper() w := NewWorld() - _, err := registerComponent[testutils.ComponentA](w.state) + w.OnComponentRegister(func(Component) error { return nil }) + _, err := registerComponent[testutils.ComponentA](w) require.NoError(t, err) - _, err = registerComponent[testutils.ComponentB](w.state) + _, err = registerComponent[testutils.ComponentB](w) require.NoError(t, err) - _, err = registerComponent[testutils.ComponentC](w.state) + _, err = registerComponent[testutils.ComponentC](w) require.NoError(t, err) return w } diff --git a/pkg/cardinal/ecs/world_state.go b/pkg/cardinal/internal/ecs/world_state.go similarity index 95% rename from pkg/cardinal/ecs/world_state.go rename to pkg/cardinal/internal/ecs/world_state.go index 1229164b0..d434bc17d 100644 --- a/pkg/cardinal/ecs/world_state.go +++ b/pkg/cardinal/internal/ecs/world_state.go @@ -2,7 +2,6 @@ package ecs import ( "math" - "reflect" "sync" "github.com/argus-labs/world-engine/pkg/assert" @@ -258,9 +257,14 @@ func removeComponent[T Component](ws *worldState, eid EntityID) error { } // registerComponent registers a component type with the world state. -func registerComponent[T Component](ws *worldState) (componentID, error) { +func registerComponent[T Component](w *World) (componentID, error) { var zero T - return ws.components.register(zero.Name(), newColumnFactory[T](), reflect.TypeOf(zero)) + if w.onComponentRegister != nil { + if err := w.onComponentRegister(zero); err != nil { + return 0, eris.Wrap(err, "component registered callback failed") + } + } + return w.state.components.register(zero.Name(), newColumnFactory[T]()) } // ------------------------------------------------------------------------------------------------- @@ -268,7 +272,7 @@ func registerComponent[T Component](ws *worldState) (componentID, error) { // ------------------------------------------------------------------------------------------------- // toProto converts the worldState to a protobuf message for serialization. -func (ws *worldState) toProto() (*cardinalv1.CardinalSnapshot, error) { +func (ws *worldState) toProto() (*cardinalv1.WorldState, error) { freeIDs := make([]uint32, len(ws.free)) for i, entityID := range ws.free { freeIDs[i] = uint32(entityID) @@ -283,7 +287,7 @@ func (ws *worldState) toProto() (*cardinalv1.CardinalSnapshot, error) { pbArchetypes[i] = pbArch } - return &cardinalv1.CardinalSnapshot{ + return &cardinalv1.WorldState{ NextId: uint32(ws.nextID), FreeIds: freeIDs, EntityArch: ws.entityArch.toInt64Slice(), @@ -292,7 +296,7 @@ func (ws *worldState) toProto() (*cardinalv1.CardinalSnapshot, error) { } // fromProto populates the worldState from a protobuf message. -func (ws *worldState) fromProto(pb *cardinalv1.CardinalSnapshot) error { +func (ws *worldState) fromProto(pb *cardinalv1.WorldState) error { ws.nextID = EntityID(pb.GetNextId()) ws.free = make([]EntityID, len(pb.GetFreeIds())) diff --git a/pkg/cardinal/ecs/world_state_internal_test.go b/pkg/cardinal/internal/ecs/world_state_internal_test.go similarity index 91% rename from pkg/cardinal/ecs/world_state_internal_test.go rename to pkg/cardinal/internal/ecs/world_state_internal_test.go index 2afda1e2d..928855245 100644 --- a/pkg/cardinal/ecs/world_state_internal_test.go +++ b/pkg/cardinal/internal/ecs/world_state_internal_test.go @@ -17,9 +17,8 @@ import ( // ------------------------------------------------------------------------------------------------- // Model-based fuzzing world state operations // ------------------------------------------------------------------------------------------------- -// This test verifies the worldState implementation correctness using model-based testing. It -// compares our implementation against a map[EntityID]map[string]Component as the model by applying -// random sequences of entity and component operations to both and asserting equivalence. +// This test verifies the worldState implementation correctness by applying random sequences of +// operations and comparing it against a Go map of map[EntityID]map[string]any as the model. // We also verify structural invariants: entity-archetype bijection and global entity uniqueness. // ------------------------------------------------------------------------------------------------- @@ -27,15 +26,27 @@ func TestWorldState_ModelFuzz(t *testing.T) { t.Parallel() prng := testutils.NewRand(t) - const opsMax = 1 << 15 // 32_768 iterations + const ( + opsMax = 1 << 15 // 32_768 iterations + opEntityNew = "entityNew" + opEntityRemove = "entityRemove" + opCompSetUpdate = "compSetUpdate" + opCompSetMove = "compSetMove" + opCompRemove = "compRemove" + opCompGet = "compGet" + ) + + // Randomize operation weights. + operations := []string{opEntityNew, opEntityRemove, opCompSetUpdate, opCompSetMove, opCompRemove, opCompGet} + weights := testutils.RandOpWeights(prng, operations) impl := newTestWorldState(t) model := make(map[EntityID]map[string]any) for range opsMax { - op := testutils.RandWeightedOp(prng, worldStateOps) + op := testutils.RandWeightedOp(prng, weights) switch op { - case ws_entityNew: + case opEntityNew: eid := impl.newEntity() model[eid] = make(map[string]any) @@ -43,7 +54,7 @@ func TestWorldState_ModelFuzz(t *testing.T) { _, exists := impl.entityArch.get(eid) assert.True(t, exists, "newEntity(%d) should exist in entityArch", eid) - case ws_entityRemove: + case opEntityRemove: eid := EntityID(prng.IntN(10_000)) // Default to random (which might not exist). // Bias toward existing entities (80%) to test actual removal path. if len(model) > 0 && prng.Float64() < 0.8 { @@ -61,7 +72,7 @@ func TestWorldState_ModelFuzz(t *testing.T) { _, exists := impl.entityArch.get(eid) assert.False(t, exists, "removeEntity(%d) should not exist in entityArch", eid) - case ws_compSetUpdate: + case opCompSetUpdate: if len(model) == 0 { continue } @@ -86,7 +97,7 @@ func TestWorldState_ModelFuzz(t *testing.T) { assert.True(t, exists, "setComponentUpdate(%d) entity should exist", eid) assert.Equal(t, aidBefore, aidAfter, "setComponentUpdate(%d) archetype should not change", eid) - case ws_compSetMove: + case opCompSetMove: if len(model) == 0 { continue } @@ -111,7 +122,7 @@ func TestWorldState_ModelFuzz(t *testing.T) { assert.True(t, exists, "setComponentMove(%d) entity should exist", eid) assert.NotEqual(t, aidBefore, aidAfter, "setComponentMove(%d) archetype should change", eid) - case ws_compRemove: + case opCompRemove: if len(model) == 0 { continue } @@ -125,7 +136,7 @@ func TestWorldState_ModelFuzz(t *testing.T) { _, ok := getComponentAbstract(t, impl, eid, c.Name()) assert.False(t, ok, "removeComponent(%d, %s) then get should not exist", eid, c.Name()) - case ws_compGet: + case opCompGet: if len(model) == 0 { continue } @@ -196,21 +207,6 @@ func TestWorldState_ModelFuzz(t *testing.T) { } } -type worldStateOp uint8 - -const ( - ws_entityNew worldStateOp = 16 - ws_entityRemove worldStateOp = 14 - ws_compSetUpdate worldStateOp = 15 // Update existing component (no archetype change) - ws_compSetMove worldStateOp = 30 // Set new component (triggers archetype move) - ws_compRemove worldStateOp = 20 - ws_compGet worldStateOp = 5 -) - -var worldStateOps = []worldStateOp{ - ws_entityNew, ws_entityRemove, ws_compSetUpdate, ws_compSetMove, ws_compRemove, ws_compGet, -} - func getComponentAbstract(t *testing.T, impl *worldState, eid EntityID, name string) (Component, bool) { var res Component var err error @@ -243,11 +239,11 @@ func setComponentAbstract(t *testing.T, impl *worldState, eid EntityID, c Compon name := c.Name() switch name { case testutils.ComponentA{}.Name(): - err = setComponent[testutils.ComponentA](impl, eid, c.(testutils.ComponentA)) + err = setComponent(impl, eid, c.(testutils.ComponentA)) case testutils.ComponentB{}.Name(): - err = setComponent[testutils.ComponentB](impl, eid, c.(testutils.ComponentB)) + err = setComponent(impl, eid, c.(testutils.ComponentB)) case testutils.ComponentC{}.Name(): - err = setComponent[testutils.ComponentC](impl, eid, c.(testutils.ComponentC)) + err = setComponent(impl, eid, c.(testutils.ComponentC)) default: panic("unreachable") } @@ -300,22 +296,33 @@ func TestWorldState_EntityFuzz(t *testing.T) { prng := testutils.NewRand(t) const ( - opsMax = 1 << 15 // 32_768 iterations - createRemoveRatio = 0.6 + opsMax = 1 << 15 // 32_768 iterations + opCreate = "create" + opRemove = "remove" ) + // Randomize operation weights. + operations := []string{opCreate, opRemove} + weights := testutils.RandOpWeights(prng, operations) + impl := newTestWorldState(t) prevNextID := impl.nextID for range opsMax { - if prng.Float64() < createRemoveRatio { + op := testutils.RandWeightedOp(prng, weights) + switch op { + case opCreate: impl.newEntity() - } else { + + case opRemove: if impl.nextID == 0 { continue } eid := EntityID(prng.IntN(int(impl.nextID))) impl.removeEntity(eid) // May return false if already removed. + + default: + panic("unreachable") } // Property: nextID is monotonically non-decreasing. @@ -352,9 +359,7 @@ func TestWorldState_EntityFuzzConcurrent(t *testing.T) { var wg sync.WaitGroup for range numGoroutines { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { // Initialize prng in each goroutine separately because rand/v2.Rand isn't concurrent-safe. prng := testutils.NewRand(t) @@ -371,7 +376,7 @@ func TestWorldState_EntityFuzzConcurrent(t *testing.T) { removeCount.Add(1) } } - }() + }) } wg.Wait() @@ -444,14 +449,15 @@ func TestWorldState_EntityID_FIFO(t *testing.T) { func newTestWorldState(t *testing.T) *worldState { t.Helper() - ws := newWorldState() - _, err := registerComponent[testutils.ComponentA](ws) + w := NewWorld() + w.OnComponentRegister(func(Component) error { return nil }) + _, err := registerComponent[testutils.ComponentA](w) require.NoError(t, err) - _, err = registerComponent[testutils.ComponentB](ws) + _, err = registerComponent[testutils.ComponentB](w) require.NoError(t, err) - _, err = registerComponent[testutils.ComponentC](ws) + _, err = registerComponent[testutils.ComponentC](w) require.NoError(t, err) - return ws + return w.state } // ------------------------------------------------------------------------------------------------- diff --git a/pkg/cardinal/internal/event/event.go b/pkg/cardinal/internal/event/event.go new file mode 100644 index 000000000..046667eb3 --- /dev/null +++ b/pkg/cardinal/internal/event/event.go @@ -0,0 +1,111 @@ +package event + +import ( + "math" + "sync" + + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" + "github.com/rotisserie/eris" +) + +// Event represents an event emitted from a system. +type Event struct { + Kind Kind // The event kind + Payload any // The event payload itself +} + +// Payload is the interface all default event payloads must implement. +type Payload interface { + schema.Serializable +} + +// Kind is a type that represents the kind of event. +type Kind uint8 + +const ( + KindDefault Kind = 0 // The default event type + KindInterShardCommand Kind = 1 // Inter-shard commands +) + +// Handler is a function called to handle emitted events. +type Handler func(Event) error + +// initialCommandBufferCapacity is the starting capacity of command buffers. +const initialEventBufferCapacity = 128 + +// Manager manages event registration, stores events emitted by systems, and dispatches their +// handlers at the end of every tick. +type Manager struct { + handlers []Handler // Event handlers, indexed by event kind + channel chan Event // Channel for collecting events emitted by systems + buffer []Event // Overflow buffer for when channel is full + mu sync.Mutex // Mutex for buffer access during flush +} + +// NewManager creates a new event manager with the specified channel capacity. +func NewManager(channelCapacity int) Manager { + return Manager{ + handlers: make([]Handler, math.MaxUint8+1), + channel: make(chan Event, channelCapacity), + buffer: make([]Event, 0, initialEventBufferCapacity), + } +} + +// Enqueue enqueues an event into the eventManager. +// If the channel is full, it flushes the channel to the buffer first. +func (m *Manager) Enqueue(event Event) { + select { + case m.channel <- event: + // Happy path: channel has capacity. + default: + // Channel full: flush to buffer, then send. + m.flush() + m.channel <- event + } +} + +// flush drains the channel into the buffer. Called when channel is full. +// This method expects the caller to hold tthe mutex lock. +func (m *Manager) flush() { + m.mu.Lock() + defer m.mu.Unlock() + + for { + select { + case event := <-m.channel: + m.buffer = append(m.buffer, event) + default: + return + } + } +} + +// Dispatch loops through emitted events and calls their handler functions based on the event kind. +// Returns all errors collected from handlers. +func (m *Manager) Dispatch() error { + m.flush() + + m.mu.Lock() + defer m.mu.Unlock() + + var errs []error + for _, event := range m.buffer { + handler := m.handlers[event.Kind] + if err := handler(event); err != nil { + errs = append(errs, err) + } + } + + // Clear the buffer after processing. + m.buffer = m.buffer[:0] + + if len(errs) > 0 { + return eris.Errorf("event dispatch encountered %d error(s): %v", len(errs), errs) + } + return nil +} + +// RegisterHandler registers the handler function for a specific event kind. +func (m *Manager) RegisterHandler(kind Kind, fn Handler) { + m.handlers[kind] = fn +} diff --git a/pkg/cardinal/internal/event/event_test.go b/pkg/cardinal/internal/event/event_test.go new file mode 100644 index 000000000..3da726ef1 --- /dev/null +++ b/pkg/cardinal/internal/event/event_test.go @@ -0,0 +1,144 @@ +package event_test + +import ( + "sync" + "testing" + "testing/synctest" + + "github.com/argus-labs/world-engine/pkg/cardinal/internal/event" + "github.com/argus-labs/world-engine/pkg/testutils" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// ------------------------------------------------------------------------------------------------- +// Model-based fuzzing event manager operations +// ------------------------------------------------------------------------------------------------- +// This test verifies the event manager implementation correctness by applying random sequences of +// operations and comparing it against a Go slice as the model. +// ------------------------------------------------------------------------------------------------- + +func TestEvent_ModelFuzz(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + + const ( + opsMax = 1 << 15 // 32_768 iterations + opEnqueue = "enqueue" + opDispatch = "dispatch" + ) + + impl := event.NewManager(1024) + model := make([]event.Event, 0) // Queue of pending events + + // Slice to capture events dispatched by handlers. + var dispatched []event.Event + var mu sync.Mutex + + // Register handlers for kinds 0 to N-1. + numKinds := prng.IntN(256) + 1 // 1-256 kinds + for i := range numKinds { + impl.RegisterHandler(event.Kind(i), func(e event.Event) error { + mu.Lock() + dispatched = append(dispatched, e) + mu.Unlock() + return nil + }) + } + + // Randomize operation weights. + operations := []string{opEnqueue, opDispatch} + weights := testutils.RandOpWeights(prng, operations) + + // Run opsMax iterations. + for range opsMax { + op := testutils.RandWeightedOp(prng, weights) + switch op { + case opEnqueue: + // Pick a random registered event kind and create event with random payload. + kind := event.Kind(prng.IntN(numKinds)) + e := event.Event{Kind: kind, Payload: prng.Int()} + + impl.Enqueue(e) + model = append(model, e) + + case opDispatch: + err := impl.Dispatch() + require.NoError(t, err) + + // Property: dispatched events must match model (pending queue). + assert.ElementsMatch(t, model, dispatched, "dispatched events mismatch") + + // Clear model and dispatched slice. + model = model[:0] + dispatched = dispatched[:0] + + default: + panic("unreachable") + } + } + + // Final state check. + err := impl.Dispatch() + require.NoError(t, err) + + // Property: all enqueued events must be dispatched. + assert.ElementsMatch(t, model, dispatched, "final dispatched events mismatch") +} + +// ------------------------------------------------------------------------------------------------- +// Channel overflow regression test +// ------------------------------------------------------------------------------------------------- +// This test verifies that enqueue does not block when the channel is full. Before the fix, +// enqueue would block indefinitely when the channel capacity (1024) was exceeded, causing +// a deadlock. After the fix, enqueue should flush the channel to the buffer when full. +// ------------------------------------------------------------------------------------------------- + +func TestEvent_EnqueueChannelFull(t *testing.T) { + t.Parallel() + + synctest.Test(t, func(t *testing.T) { + const channelCapacity = 16 + const totalEvents = channelCapacity * 3 // Well beyond channel capacity + + impl := event.NewManager(channelCapacity) + + // Register a handler for the default kind. + var dispatched []event.Event + impl.RegisterHandler(event.KindDefault, func(e event.Event) error { + dispatched = append(dispatched, e) + return nil + }) + + // Enqueue more events than channel capacity. + // Before fix: this blocks forever after 16 events, causing deadlock. + // After fix: this completes without blocking. + done := false + go func() { + for i := range totalEvents { + impl.Enqueue(event.Event{Kind: event.KindDefault, Payload: i}) + } + done = true + }() + + // Wait for all goroutines to complete or durably block. + // If enqueue blocks, synctest.Test will detect deadlock and fail. + synctest.Wait() + + if !done { + t.Fatal("enqueue blocked: channel overflow not handled") + } + + // Verify all events are captured. + err := impl.Dispatch() + require.NoError(t, err) + + assert.Len(t, dispatched, totalEvents, "expected all %d events to be captured", totalEvents) + + // Verify data integrity. + for i, evt := range dispatched { + assert.Equal(t, event.KindDefault, evt.Kind, "event kind mismatch at index %d", i) + assert.Equal(t, i, evt.Payload, "payload mismatch at index %d", i) + } + }) +} diff --git a/pkg/cardinal/internal/schema/schema.go b/pkg/cardinal/internal/schema/schema.go new file mode 100644 index 000000000..371788036 --- /dev/null +++ b/pkg/cardinal/internal/schema/schema.go @@ -0,0 +1,36 @@ +package schema + +import ( + "github.com/goccy/go-json" + "github.com/rotisserie/eris" + "google.golang.org/protobuf/types/known/structpb" +) + +// TODO: should we just encode JSON []byte instead of using proto struct? +// TODO: JSON converts u64s to f64 which loses precision of some types. Consider other serialization +// format or create a custom one. + +// Serializable is the interface that all user-defined types (components, commands, events) must implement. +type Serializable interface { + Name() string +} + +// ToProtoStruct converts a Serializable to a protobuf struct. +func ToProtoStruct(s Serializable) (*structpb.Struct, error) { + bytes, err := json.Marshal(s) + if err != nil { + return nil, eris.Wrap(err, "failed to marshal schema") + } + + var m map[string]any + if err := json.Unmarshal(bytes, &m); err != nil { + return nil, eris.Wrap(err, "failed to unmarshal schema to map[string]any") + } + + pbStruct, err := structpb.NewStruct(m) + if err != nil { + return nil, eris.Wrap(err, "failed to convert map to structpb.Struct") + } + + return pbStruct, nil +} diff --git a/pkg/cardinal/protoutil/marshal.go b/pkg/cardinal/protoutil/marshal.go deleted file mode 100644 index 84c6fc0a6..000000000 --- a/pkg/cardinal/protoutil/marshal.go +++ /dev/null @@ -1,66 +0,0 @@ -// Package protoutil provides marshaling utilities for converting ECS types to protobuf types. -// -// This package exists to decouple the core ECS package from protobuf dependencies. Instead of -// having marshal methods directly on ECS types (like RawEvent.Marshal()), this package provides -// conversion functions that can be used by Cardinal and other higher-level packages that need to -// serialize ECS types to protobuf format. -package protoutil - -import ( - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/micro" - iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" - "github.com/goccy/go-json" - "github.com/rotisserie/eris" - "google.golang.org/protobuf/types/known/structpb" -) - -// MarshalCommand converts an ecs.Command to its protobuf representation. -func MarshalCommand(command ecs.Command, dst *micro.ServiceAddress, personaID string) (*iscv1.Command, error) { - pbStruct, err := marshalToStruct(command) - if err != nil { - return nil, eris.Wrap(err, "failed to marshal command into structpb") - } - - return &iscv1.Command{ - Name: command.Name(), - Payload: pbStruct, - Address: dst, - Persona: &iscv1.Persona{ - Id: personaID, - }, - }, nil -} - -// MarshalEvent converts an ecs.Event to its protobuf representation. -func MarshalEvent(event ecs.Event) (*iscv1.Event, error) { - pbStruct, err := marshalToStruct(event) - if err != nil { - return nil, eris.Wrap(err, "failed to marshal event into structpb") - } - - return &iscv1.Event{ - Name: event.Name(), - Payload: pbStruct, - }, nil -} - -// marshalToStruct is a helper function to convert an arbitrary struct type into a protobuf- -// compatible format. -func marshalToStruct(payload any) (*structpb.Struct, error) { - bytes, err := json.Marshal(payload) - if err != nil { - return nil, eris.Wrap(err, "failed to marshal payload") - } - - var m map[string]any - if err := json.Unmarshal(bytes, &m); err != nil { - return nil, eris.Wrap(err, "failed to unmarshal payload to map[string]any") - } - - pbStruct, err := structpb.NewStruct(m) - if err != nil { - return nil, eris.Wrap(err, "failed to convert map to structpb.Struct") - } - return pbStruct, nil -} diff --git a/pkg/cardinal/protoutil/marshal_test.go b/pkg/cardinal/protoutil/marshal_test.go deleted file mode 100644 index 608c13aa6..000000000 --- a/pkg/cardinal/protoutil/marshal_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package protoutil_test - -import ( - "testing" - - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/cardinal/protoutil" - "github.com/argus-labs/world-engine/pkg/cardinal/testutils" - "github.com/argus-labs/world-engine/pkg/micro" - iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func TestMarshalCommand(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - command ecs.Command - checkFunc func(t *testing.T, result *iscv1.Command) - }{ - { - name: "marshal command", - command: testutils.NewSimpleCommand("test-command", "test-payload"), - checkFunc: func(t *testing.T, result *iscv1.Command) { - assert.Equal(t, "test-command", result.GetName()) - assert.NotNil(t, result.GetPayload()) - assert.Equal(t, "test-command", result.GetPayload().GetFields()["name"].GetStringValue()) - assert.Equal(t, "test-payload", result.GetPayload().GetFields()["payload"].GetStringValue()) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - dst := micro.GetAddress("test", micro.RealmWorld, "test", "project", "destination") - result, err := protoutil.MarshalCommand(tt.command, dst, "test-persona") - - require.NoError(t, err) - require.NotNil(t, result) - tt.checkFunc(t, result) - }) - } -} - -func TestMarshalEvent(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - event ecs.Event - checkFunc func(t *testing.T, result *iscv1.Event) - }{ - { - name: "marshal event", - event: testutils.NewSimpleEvent("test-event", "test-payload"), - checkFunc: func(t *testing.T, result *iscv1.Event) { - assert.Equal(t, "test-event", result.GetName()) - assert.NotNil(t, result.GetPayload()) - assert.Equal(t, "test-event", result.GetPayload().GetFields()["name"].GetStringValue()) - assert.Equal(t, "test-payload", result.GetPayload().GetFields()["payload"].GetStringValue()) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - result, err := protoutil.MarshalEvent(tt.event) - - require.NoError(t, err) - require.NotNil(t, result) - tt.checkFunc(t, result) - }) - } -} - -func BenchmarkMarshalCommand(b *testing.B) { - command := testutils.NewSimpleCommand("benchmark-command", "benchmark-payload") - dst := micro.GetAddress("test", micro.RealmWorld, "test", "project", "destination") - - for i := 0; i < b.N; i++ { - _, err := protoutil.MarshalCommand(command, dst, "test-persona") - if err != nil { - b.Fatal(err) - } - } -} - -func BenchmarkMarshalEvent(b *testing.B) { - event := testutils.NewSimpleEvent("benchmark-event", "benchmark-payload") - - for i := 0; i < b.N; i++ { - _, err := protoutil.MarshalEvent(event) - if err != nil { - b.Fatal(err) - } - } -} diff --git a/pkg/cardinal/service/query.go b/pkg/cardinal/query.go similarity index 74% rename from pkg/cardinal/service/query.go rename to pkg/cardinal/query.go index ed90316e7..accdcb48d 100644 --- a/pkg/cardinal/service/query.go +++ b/pkg/cardinal/query.go @@ -1,13 +1,13 @@ -package service +package cardinal import ( "context" "sync" + "buf.build/go/protovalidate" "github.com/goccy/go-json" - "buf.build/go/protovalidate" - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/ecs" "github.com/argus-labs/world-engine/pkg/micro" iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" "github.com/rotisserie/eris" @@ -15,28 +15,28 @@ import ( "google.golang.org/protobuf/types/known/structpb" ) -// Query is a struct that represents a query to the world. +// query is a struct that represents a query to the world. // For now it has the same structure as ecs.SearchParam, but we might add more fields in the future // so it's better to keep these as separate types. -type Query struct { +type query struct { // List of component names to search for. - Find []string `json:"find"` + find []string // Match type: "exact" or "contains". - Match ecs.SearchMatch `json:"match"` + match ecs.SearchMatch // Optional expr language string to filter the results. // See https://expr-lang.org/ for documentation. - Where string `json:"where,omitempty"` + where string } -// reset resets the Query object for reuse. -func (q *Query) reset() { - q.Find = q.Find[:0] // Reuse the underlying array - q.Match = "" - q.Where = "" +// reset resets the query object for reuse. +func (q *query) reset() { + q.find = q.find[:0] // Reuse the underlying array + q.match = "" + q.where = "" } // handleQuery creates a new query handler for the world. -func (s *ShardService) handleQuery(ctx context.Context, req *micro.Request) *micro.Response { +func (s *service) handleQuery(ctx context.Context, req *micro.Request) *micro.Response { // Check if world is shutting down. select { case <-ctx.Done(): @@ -45,16 +45,16 @@ func (s *ShardService) handleQuery(ctx context.Context, req *micro.Request) *mic // Continue processing. } - query, err := parseQuery(&s.queryPool, req) + q, err := parseQuery(&s.queryPool, req) if err != nil { return micro.NewErrorResponse(req, eris.Wrap(err, "failed to parse request payload"), codes.Internal) } - defer s.queryPool.Put(query) + defer s.queryPool.Put(q) - results, err := s.world.NewSearch(ecs.SearchParam{ - Find: query.Find, - Match: query.Match, - Where: query.Where, + results, err := s.world.world.NewSearch(ecs.SearchParam{ + Find: q.find, + Match: q.match, + Where: q.where, }) if err != nil { return micro.NewErrorResponse(req, eris.Wrap(err, "failed to search entities"), codes.Internal) @@ -69,7 +69,7 @@ func (s *ShardService) handleQuery(ctx context.Context, req *micro.Request) *mic } // parseQuery parses the query from the payload. -func parseQuery(pool *sync.Pool, req *micro.Request) (*Query, error) { +func parseQuery(pool *sync.Pool, req *micro.Request) (*query, error) { var payload iscv1.Query if err := req.Payload.UnmarshalTo(&payload); err != nil { return nil, eris.Wrap(err, "failed to unmarshal payload into query") @@ -78,15 +78,15 @@ func parseQuery(pool *sync.Pool, req *micro.Request) (*Query, error) { return nil, eris.Wrap(err, "failed to validate query") } - query := pool.Get().(*Query) //nolint:errcheck // we know the type - query.reset() + q := pool.Get().(*query) //nolint:errcheck // we know the type + q.reset() // Set query fields from the iscv1 query. - query.Find = payload.GetFind() - query.Match = ecs.SearchMatch(iscv1MatchToString(payload.GetMatch())) - query.Where = payload.GetWhere() + q.find = payload.GetFind() + q.match = ecs.SearchMatch(iscv1MatchToString(payload.GetMatch())) + q.where = payload.GetWhere() - return query, nil + return q, nil } // serializeQueryResults serializes the results into a protobuf message. diff --git a/pkg/cardinal/query_internal_test.go b/pkg/cardinal/query_internal_test.go new file mode 100644 index 000000000..285df1243 --- /dev/null +++ b/pkg/cardinal/query_internal_test.go @@ -0,0 +1,227 @@ +package cardinal + +// import ( +// "fmt" +// "sync" +// "testing" +// +// "github.com/argus-labs/world-engine/pkg/cardinal/internal/ecs" +// "github.com/argus-labs/world-engine/pkg/micro" +// iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" +// microv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1" +// "google.golang.org/protobuf/types/known/anypb" +// ) +// +// // Test components. +// type PlayerTag struct{ Nickname string } +// +// func (PlayerTag) Name() string { return "PlayerTag" } +// +// type Health struct{ HP int } +// +// func (Health) Name() string { return "Health" } +// +// type Position struct{ X, Y int } +// +// func (Position) Name() string { return "Position" } +// +// type Score struct{ Value int } +// +// func (Score) Name() string { return "Score" } +// +// type Inventory struct{ Items []string } +// +// func (Inventory) Name() string { return "Inventory" } +// +// type InitSystemState struct { +// ecs.BaseSystemState +// Query ecs.Contains[struct { +// Tag ecs.Ref[PlayerTag] +// Health ecs.Ref[Health] +// Position ecs.Ref[Position] +// Score ecs.Ref[Score] +// Inventory ecs.Ref[Inventory] +// }] +// } +// +// func initSystem(state *InitSystemState) error { +// // Create 100 entities with PlayerTag + Health with varying HP values +// for i := range 100 { +// _, entity := state.Query.Create() +// entity.Tag.Set(PlayerTag{Nickname: fmt.Sprintf("player%d", i)}) +// entity.Health.Set(Health{HP: 100 + (i * 5)}) // HP ranges from 100 to 595 +// } +// +// // Create 50 entities with PlayerTag + Position in different quadrants +// for i := range 50 { +// x := (i % 5) * 20 // X: 0, 20, 40, 60, 80 repeating +// y := (i / 5) * 20 // Y: increases by 20 every 5 entities +// _, entity := state.Query.Create() +// entity.Tag.Set(PlayerTag{Nickname: fmt.Sprintf("scout%d", i)}) +// entity.Position.Set(Position{X: x, Y: y}) +// } +// +// // Create 50 entities with all components (for complex queries) +// for i := range 50 { +// hp := 200 + (i * 10) // HP ranges from 200 to 690 +// x := -100 + (i * 5) // X ranges from -100 to 145 +// y := i * 5 // Y ranges from 0 to 245 +// score := 1000 + (i * 50) // Score ranges from 1000 to 3450 +// +// items := []string{"potion"} +// if i%3 == 0 { +// items = append(items, "sword") +// } +// if i%4 == 0 { +// items = append(items, "bow", "arrows") +// } +// if i%5 == 0 { +// items = append(items, "shield") +// } +// +// _, entity := state.Query.Create() +// entity.Tag.Set(PlayerTag{Nickname: fmt.Sprintf("hero%d", i)}) +// entity.Health.Set(Health{HP: hp}) +// entity.Position.Set(Position{X: x, Y: y}) +// entity.Score.Set(Score{Value: score}) +// entity.Inventory.Set(Inventory{Items: items}) +// } +// +// // Create 50 Position-only entities in a grid pattern +// for i := range 50 { +// x := ((i % 7) * 30) - 90 // X: -90 to 90 in steps of 30 +// y := ((i / 7) * 30) - 90 // Y: -90 to 90 in steps of 30 +// _, entity := state.Query.Create() +// entity.Position.Set(Position{X: x, Y: y}) +// } +// +// // Create 25 Health-only entities with varying HP +// for i := range 25 { +// _, entity := state.Query.Create() +// entity.Health.Set(Health{HP: 50 + (i * 20)}) // HP ranges from 50 to 530 +// } +// +// // Create 25 Score-only entities +// for i := range 25 { +// _, entity := state.Query.Create() +// entity.Score.Set(Score{Value: 500 + (i * 100)}) // Scores from 500 to 2900 +// } +// +// return nil +// } +// +// func BenchmarkQuery(b *testing.B) { +// // Setup test cases +// benchmarks := []struct { +// name string +// find []string +// match ecs.SearchMatch +// where string +// }{ +// { +// name: "simple position query", +// find: []string{"Position"}, +// match: ecs.MatchExact, +// }, +// { +// name: "multi-component query", +// find: []string{"Position", "Health"}, +// match: ecs.MatchContains, +// }, +// { +// name: "query with filter", +// find: []string{"Position", "Health"}, +// match: ecs.MatchContains, +// where: "Health.HP > 150", +// }, +// { +// name: "complex filter", +// find: []string{"Position", "Health", "PlayerTag"}, +// match: ecs.MatchContains, +// where: "Health.HP > 200 && Position.X > Position.Y", +// }, +// } +// +// for _, bm := range benchmarks { +// b.Run(bm.name, func(b *testing.B) { +// world := ecs.NewWorld() +// ecs.RegisterSystem(world, initSystem, ecs.WithHook(ecs.Init)) +// +// world.Init() +// +// err := world.Tick() +// if err != nil { +// b.Fatal("failed to initialize world:", err) +// } +// +// pool := sync.Pool{ +// New: func() any { +// return &query{ +// find: make([]string, 0, 8), +// } +// }, +// } +// +// req := createTestRequest(b, bm.find, bm.match, bm.where) +// +// b.ResetTimer() +// for b.Loop() { +// // Parse query. +// query, err := parseQuery(&pool, req) +// if err != nil { +// b.Fatal("parse query failed:", err) +// } +// +// // Search. +// results, err := world.NewSearch(ecs.SearchParam{ +// Find: query.find, +// Match: query.match, +// Where: query.where, +// }) +// if err != nil { +// b.Fatal("search failed:", err) +// } +// +// // Serialize results. +// _, err = serializeQueryResults(results) +// if err != nil { +// b.Fatal("serialize failed:", err) +// } +// +// // Return the query object. +// pool.Put(query) +// } +// }) +// } +// } +// +// func createTestRequest(t testing.TB, find []string, match ecs.SearchMatch, where string) *micro.Request { +// iscMsg := &iscv1.Query{ +// Find: find, +// Match: searchMatchToISCQueryMatch(match), +// Where: where, +// } +// +// // Pack the ISC Message into Any +// anyMsg, err := anypb.New(iscMsg) +// if err != nil { +// t.Fatal("failed to pack message into Any:", err) +// } +// +// // Create the final Request using micro.Request +// return µ.Request{ +// ServiceAddress: µv1.ServiceAddress{}, +// Payload: anyMsg, +// } +// } +// +// func searchMatchToISCQueryMatch(m ecs.SearchMatch) iscv1.Query_Match { +// switch m { +// case ecs.MatchExact: +// return iscv1.Query_MATCH_EXACT +// case ecs.MatchContains: +// return iscv1.Query_MATCH_CONTAINS +// default: +// return iscv1.Query_MATCH_UNSPECIFIED +// } +// } diff --git a/pkg/cardinal/service.go b/pkg/cardinal/service.go new file mode 100644 index 000000000..082be7c5f --- /dev/null +++ b/pkg/cardinal/service.go @@ -0,0 +1,191 @@ +package cardinal + +import ( + "context" + "sync" + + "buf.build/go/protovalidate" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/command" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/event" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" + "github.com/argus-labs/world-engine/pkg/micro" + iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" + "github.com/rotisserie/eris" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" +) + +// service extends micro.Service with Cardinal specific functionality. +type service struct { + world *World // Reference to the Cardinal world + client *micro.Client // NATS Client + service *micro.Service // NATS handler + commands map[string]struct{} // Set of commands to handle + queryPool sync.Pool // Pool for query objects +} + +// newService creates a new shard service. +func newService(world *World) *service { + s := &service{ + world: world, + client: nil, // Will be initialized at Init + service: nil, // Will be initialized at Init + commands: make(map[string]struct{}), + queryPool: sync.Pool{ + New: func() any { + return &query{ + // Pre-allocate space for 8 components which should cover most cases. + find: make([]string, 0, 8), + } + }, + }, + } + + return s +} + +func (s *service) init() error { + client, err := micro.NewClient(micro.WithLogger(s.world.tel.GetLogger("service"))) + if err != nil { + return eris.Wrap(err, "failed to initialize micro client") + } + s.client = client + + microService, err := micro.NewService(client, s.world.address, &s.world.tel) + if err != nil { + return eris.Wrap(err, "failed to create micro service") + } + s.service = microService + + // Register endpoints. + if err = s.service.AddEndpoint("ping", s.handlePing); err != nil { + return eris.Wrap(err, "failed to register ping handler") + } + + if err = s.service.AddEndpoint("query", s.handleQuery); err != nil { + return eris.Wrap(err, "failed to register query handler") + } + + for cmd := range s.commands { + if err := s.service.AddGroup("command").AddEndpoint(cmd, s.handleCommand); err != nil { + return eris.Wrapf(err, "failed to register %s command handler", cmd) + } + } + + return nil +} + +func (s *service) shutdown() error { + if s.service != nil { + if err := s.service.Close(); err != nil { + return eris.Wrap(err, "failed to close micro.Service") + } + } + + s.client.Close() + + return nil +} + +func (s *service) registerCommandHandler(name string) { + s.commands[name] = struct{}{} +} + +// ------------------------------------------------------------------------------------------------- +// Request handlers +// ------------------------------------------------------------------------------------------------- + +// handlePing responds to health-check requests. Used by NATS CLI or K8s probes to verify +// the shard is connected to NATS and running. Accepts empty or valid Request payload. +func (s *service) handlePing(_ context.Context, req *micro.Request) *micro.Response { + return micro.NewSuccessResponse(req, nil) +} + +// handleQuery is in query.go. + +// handleCommand receives commands from clients and enqueues it in the world's command manager. +func (s *service) handleCommand(ctx context.Context, req *micro.Request) *micro.Response { + // Check if shard is shutting down. + select { + case <-ctx.Done(): + return micro.NewErrorResponse(req, eris.Wrap(ctx.Err(), "context cancelled"), codes.Canceled) + default: + // Continue processing. + } + + cmd := &iscv1.Command{} + if err := req.Payload.UnmarshalTo(cmd); err != nil { + return micro.NewErrorResponse(req, eris.Wrap(err, "failed to parse request payload"), codes.InvalidArgument) + } + + if err := protovalidate.Validate(cmd); err != nil { + return micro.NewErrorResponse(req, eris.Wrap(err, "failed to validate command"), codes.InvalidArgument) + } + + if micro.String(s.world.address) != micro.String(cmd.GetAddress()) { + return micro.NewErrorResponse(req, eris.New("command address doesn't match shard address"), codes.InvalidArgument) + } + + if err := s.world.commands.Enqueue(cmd); err != nil { + return micro.NewErrorResponse(req, eris.Wrap(err, "failed to enqueue command"), codes.InvalidArgument) + } + + return micro.NewSuccessResponse(req, nil) +} + +// ------------------------------------------------------------------------------------------------- +// Event publishers +// ------------------------------------------------------------------------------------------------- + +func (s *service) publishDefaultEvent(evt event.Event) error { + payload, ok := evt.Payload.(event.Payload) + if !ok { + return eris.Errorf("invalid event payload type: %T", evt.Payload) + } + + // Craft target service address `.event..`. + target := micro.String(s.world.address) + ".event." + payload.Name() + + payloadPb, err := schema.ToProtoStruct(payload) + if err != nil { + return eris.Wrap(err, "failed to marshal event payload") + } + + eventPb := &iscv1.Event{ + Name: payload.Name(), + Payload: payloadPb, + } + + bytes, err := proto.Marshal(eventPb) + if err != nil { + return eris.Wrap(err, "failed to marshal iscv1.Event") + } + + return s.client.Publish(target, bytes) +} + +func (s *service) publishInterShardCommand(evt event.Event) error { + isc, ok := evt.Payload.(command.Command) + if !ok { + return eris.Errorf("invalid inter shard command %v", isc) + } + + payload, err := schema.ToProtoStruct(isc.Payload) + if err != nil { + return eris.Wrap(err, "failed to marshal command payload") + } + + commandPb := &iscv1.Command{ + Name: isc.Payload.Name(), + Address: isc.Address, + Persona: &iscv1.Persona{Id: isc.Persona}, + Payload: payload, + } + + _, err = s.client.Request(context.Background(), isc.Address, "command."+isc.Payload.Name(), commandPb) + if err != nil { + return eris.Wrapf(err, "failed to send inter-shard command %s to shard", isc.Payload.Name()) + } + + return nil +} diff --git a/pkg/cardinal/service/introspect.go b/pkg/cardinal/service/introspect.go deleted file mode 100644 index 29414d034..000000000 --- a/pkg/cardinal/service/introspect.go +++ /dev/null @@ -1,119 +0,0 @@ -package service - -import ( - "context" - "reflect" - "sync" - - "github.com/argus-labs/world-engine/pkg/micro" - - "github.com/goccy/go-json" - "github.com/invopop/jsonschema" - "github.com/rotisserie/eris" - "google.golang.org/grpc/codes" - "google.golang.org/protobuf/types/known/structpb" -) - -type Introspect struct { - Cache map[string]any // lazily built on first request, immutable after init - Once sync.Once - BuildError error // BuildError persists the first cache-build failure (sync.Once won’t retry). -} - -// handleIntrospect returns metadata about the registered types in the world. -// The result is cached on first call since type registrations are immutable after init. -// This endpoint is intended for dev tooling (e.g., AI agents, debugging tools). -func (s *ShardService) handleIntrospect(ctx context.Context, req *micro.Request) *micro.Response { - // Check if world is shutting down. - select { - case <-ctx.Done(): - return micro.NewErrorResponse(req, eris.Wrap(ctx.Err(), "context cancelled"), codes.Canceled) - default: - // Continue processing. - } - - // Build cache on first call (thread-safe via sync.Once) - s.introspect.Once.Do(func() { - s.introspect.Cache, s.introspect.BuildError = s.buildIntrospectCache() - }) - if s.introspect.BuildError != nil { - return micro.NewErrorResponse( - req, eris.Wrap(s.introspect.BuildError, "failed to build introspect cache"), codes.Internal) - } - - result, err := structpb.NewStruct(s.introspect.Cache) - if err != nil { - return micro.NewErrorResponse(req, eris.Wrap(err, "failed to build introspect result"), codes.Internal) - } - - return micro.NewSuccessResponse(req, result) -} - -// buildIntrospectCache builds the introspection metadata for commands, components, and events. -// This is called once and cached for subsequent requests. -func (s *ShardService) buildIntrospectCache() (map[string]any, error) { - componentsSchemas, err := getJSONSchemas(s.world.ComponentTypes()) - if err != nil { - return nil, eris.Wrap(err, "failed to get components JSON schemas") - } - - commandsSchemas, err := getJSONSchemas(s.world.CommandTypes()) - if err != nil { - return nil, eris.Wrap(err, "failed to get commands JSON schemas") - } - - eventsSchemas, err := getJSONSchemas(s.world.EventTypes()) - if err != nil { - return nil, eris.Wrap(err, "failed to get events JSON schemas") - } - - return map[string]any{ - "commands": commandsSchemas, - "components": componentsSchemas, - "events": eventsSchemas, - }, nil -} - -// getJSONSchemas is a generic helper that converts a map of type names to reflect.Type -// into a list of JSON schema objects. -func getJSONSchemas(types map[string]reflect.Type) ([]any, error) { - result := make([]any, 0, len(types)) - for name, typ := range types { - schema := reflectSchema(typ) - schemaMap, err := schemaToMap(schema) - if err != nil { - return nil, eris.Wrapf(err, "failed to convert schema") - } - result = append(result, map[string]any{ - "name": name, - "schema": schemaMap, - }) - } - return result, nil -} - -// reflectSchema generates a JSON Schema from a reflect.Type using invopop/jsonschema. -func reflectSchema(t reflect.Type) *jsonschema.Schema { - r := &jsonschema.Reflector{ - Anonymous: true, // Don't add $id based on package path - ExpandedStruct: true, // Inline the struct fields directly - } - return r.ReflectFromType(t) -} - -// schemaToMap converts a jsonschema.Schema to a map[string]any. -func schemaToMap(schema any) (map[string]any, error) { - data, err := json.Marshal(schema) - if err != nil { - return nil, eris.Wrap(err, "failed to marshal schema") - } - var result map[string]any - if err := json.Unmarshal(data, &result); err != nil { - return nil, eris.Wrap(err, "failed to unmarshal schema") - } - // Remove redundant fields that are always the same for structs - delete(result, "$schema") - delete(result, "type") // Always "object" for structs - delete(result, "additionalProperties") // Always false for structs - return result, nil -} diff --git a/pkg/cardinal/service/introspect_internal_test.go b/pkg/cardinal/service/introspect_internal_test.go deleted file mode 100644 index 7b3561371..000000000 --- a/pkg/cardinal/service/introspect_internal_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package service - -import ( - "context" - "reflect" - "testing" - - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/micro" - microv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" -) - -// ------------------------------------------------------------------------------------------------- -// Test types and helpers -// ------------------------------------------------------------------------------------------------- -// These types are used exclusively for introspect testing. They register components, commands, and -// events via the system state pattern to populate the world's type registry. -// ------------------------------------------------------------------------------------------------- - -type IntrospectTestComponent struct { - Value int `json:"value"` -} - -func (IntrospectTestComponent) Name() string { return "IntrospectTestComponent" } - -type NestedTestComponent struct { - Inner struct { - X float64 `json:"x"` - Y float64 `json:"y"` - } `json:"inner"` - Tags []string `json:"tags"` -} - -func (NestedTestComponent) Name() string { return "NestedTestComponent" } - -type IntrospectTestCommand struct { - Action string `json:"action"` - Target int `json:"target"` -} - -func (IntrospectTestCommand) Name() string { return "IntrospectTestCommand" } - -type IntrospectTestEvent struct { - Message string `json:"message"` -} - -func (IntrospectTestEvent) Name() string { return "IntrospectTestEvent" } - -type IntrospectInitSystemState struct { - ecs.BaseSystemState - Query ecs.Contains[struct { - Comp ecs.Ref[IntrospectTestComponent] - }] - Commands ecs.WithCommand[IntrospectTestCommand] - Events ecs.WithEvent[IntrospectTestEvent] -} - -func introspectInitSystem(_ *IntrospectInitSystemState) error { - return nil -} - -func createIntrospectTestWorld(t *testing.T) *ecs.World { - t.Helper() - world := ecs.NewWorld() - - ecs.RegisterSystem(world, introspectInitSystem, ecs.WithHook(ecs.Init)) - - world.Init() - - _, err := world.Tick(nil) - require.NoError(t, err, "world tick failed") - - return world -} - -func createIntrospectTestRequest() *micro.Request { - return µ.Request{ - ServiceAddress: µv1.ServiceAddress{}, - } -} - -// ------------------------------------------------------------------------------------------------- -// handleIntrospect endpoint tests -// ------------------------------------------------------------------------------------------------- -// These tests verify the handleIntrospect HTTP handler behavior including success paths, caching -// via sync.Once, and proper error handling when context is cancelled. -// ------------------------------------------------------------------------------------------------- - -func TestHandleIntrospect_Success(t *testing.T) { - t.Parallel() - world := createIntrospectTestWorld(t) - svc := &ShardService{world: world} - - resp := svc.handleIntrospect(context.Background(), createIntrospectTestRequest()) - - require.NotNil(t, resp) - assert.Equal(t, codes.OK, codes.Code(resp.Status.GetCode())) - assert.NotNil(t, resp.Payload) -} - -func TestHandleIntrospect_CachesResult(t *testing.T) { - t.Parallel() - world := createIntrospectTestWorld(t) - svc := &ShardService{world: world} - - resp1 := svc.handleIntrospect(context.Background(), createIntrospectTestRequest()) - require.Equal(t, codes.OK, codes.Code(resp1.Status.GetCode())) - - cacheAfterFirst := svc.introspect.Cache - - resp2 := svc.handleIntrospect(context.Background(), createIntrospectTestRequest()) - require.Equal(t, codes.OK, codes.Code(resp2.Status.GetCode())) - - assert.Equal(t, cacheAfterFirst, svc.introspect.Cache, "cache should be reused") - assert.NotNil(t, svc.introspect.Cache) -} - -func TestHandleIntrospect_ContextCancelled(t *testing.T) { - t.Parallel() - world := createIntrospectTestWorld(t) - svc := &ShardService{world: world} - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - - resp := svc.handleIntrospect(ctx, createIntrospectTestRequest()) - - require.NotNil(t, resp) - assert.NotEqual(t, codes.OK, codes.Code(resp.Status.GetCode())) -} - -// ------------------------------------------------------------------------------------------------- -// buildIntrospectCache tests -// ------------------------------------------------------------------------------------------------- -// These tests verify that the cache builder correctly collects all registered types (commands, -// components, events) from the world and structures them appropriately for the response. -// ------------------------------------------------------------------------------------------------- - -func TestBuildIntrospectCache_ContainsAllTypes(t *testing.T) { - t.Parallel() - world := createIntrospectTestWorld(t) - svc := &ShardService{world: world} - - cache, err := svc.buildIntrospectCache() - require.NoError(t, err) - - assert.Contains(t, cache, "commands") - assert.Contains(t, cache, "components") - assert.Contains(t, cache, "events") - - commands, ok := cache["commands"].([]any) - require.True(t, ok) - assertContainsTypeName(t, commands, "IntrospectTestCommand") - - events, ok := cache["events"].([]any) - require.True(t, ok) - assertContainsTypeName(t, events, "IntrospectTestEvent") - - components, ok := cache["components"].([]any) - require.True(t, ok) - assertContainsTypeName(t, components, "IntrospectTestComponent") -} - -func TestBuildIntrospectCache_EmptyWorld(t *testing.T) { - t.Parallel() - world := ecs.NewWorld() - world.Init() - - svc := &ShardService{world: world} - - cache, err := svc.buildIntrospectCache() - require.NoError(t, err) - - commands, ok := cache["commands"].([]any) - require.True(t, ok) - assert.Empty(t, commands) - - events, ok := cache["events"].([]any) - require.True(t, ok) - assert.Empty(t, events) - - components, ok := cache["components"].([]any) - require.True(t, ok) - assert.Empty(t, components) -} - -// ------------------------------------------------------------------------------------------------- -// JSON schema generation tests -// ------------------------------------------------------------------------------------------------- -// These tests verify the JSON schema generation helpers produce valid schemas and correctly remove -// redundant fields ($schema, type, additionalProperties) that are always the same for structs. -// ------------------------------------------------------------------------------------------------- - -func TestGetJSONSchemas_GeneratesValidSchemas(t *testing.T) { - t.Parallel() - world := createIntrospectTestWorld(t) - - schemas, err := getJSONSchemas(world.CommandTypes()) - require.NoError(t, err) - require.NotEmpty(t, schemas) - - for _, item := range schemas { - schemaMap, ok := item.(map[string]any) - require.True(t, ok, "schema item should be a map") - assert.Contains(t, schemaMap, "name") - assert.Contains(t, schemaMap, "schema") - - schema, ok := schemaMap["schema"].(map[string]any) - require.True(t, ok, "schema should be a map") - assert.NotContains(t, schema, "$schema", "redundant $schema should be removed") - assert.NotContains(t, schema, "type", "redundant type should be removed") - } -} - -func TestSchemaToMap_RemovesRedundantFields(t *testing.T) { - t.Parallel() - type SimpleStruct struct { - Field string `json:"field"` - } - - schema := reflectSchema(reflect.TypeOf(SimpleStruct{})) - result, err := schemaToMap(schema) - - require.NoError(t, err) - assert.NotContains(t, result, "$schema") - assert.NotContains(t, result, "type") - assert.NotContains(t, result, "additionalProperties") -} - -func assertContainsTypeName(t *testing.T, items []any, typeName string) { - t.Helper() - for _, item := range items { - m, ok := item.(map[string]any) - if !ok { - continue - } - if name, valid := m["name"].(string); valid && name == typeName { - return - } - } - t.Errorf("expected to find type %q in schemas", typeName) -} diff --git a/pkg/cardinal/service/publish.go b/pkg/cardinal/service/publish.go deleted file mode 100644 index 945aee611..000000000 --- a/pkg/cardinal/service/publish.go +++ /dev/null @@ -1,87 +0,0 @@ -package service - -import ( - "context" - - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/cardinal/protoutil" - "github.com/argus-labs/world-engine/pkg/micro" - "github.com/rotisserie/eris" - "google.golang.org/protobuf/proto" -) - -// Cardinal's custom event kinds. -const ( - // EventKindInterShardCommand is the event kind for inter-shard commands. - EventKindInterShardCommand ecs.EventKind = iota + ecs.CustomEventKindStart -) - -// InterShardCommand is a wrapper around an ecs.Command that contains the target shard and the command to send. -type InterShardCommand struct { - Target *micro.ServiceAddress - Command ecs.Command -} - -// Publish publishes a list of raw events. -func (s *ShardService) Publish(events []ecs.RawEvent) { - for _, event := range events { - go func(raw ecs.RawEvent) { - var err error - switch raw.Kind { - case ecs.EventKindDefault: - err = s.publishEvent(raw) - case EventKindInterShardCommand: - err = s.publishInterShardCommand(raw) - default: - err = eris.Errorf("unknown event kind %T", raw.Kind) - } - if err != nil { - logger := s.tel.GetLogger("publish") - logger.Error().Err(err).Msg("Failed to publish raw event") - return - } - }(event) - } -} - -func (s *ShardService) publishEvent(raw ecs.RawEvent) error { - event, ok := raw.Payload.(ecs.Event) - if !ok { - return eris.Errorf("invalid event %v", event) - } - - // Craft target service address `.event..`. - target := micro.String(s.Address) + ".event." + event.Name() - - pbEvent, err := protoutil.MarshalEvent(event) - if err != nil { - return eris.Wrap(err, "failed to marshal event") - } - - payload, err := proto.Marshal(pbEvent) - if err != nil { - return eris.Wrap(err, "failed to marshal iscv1.Event") - } - - return s.client.Publish(target, payload) -} - -func (s *ShardService) publishInterShardCommand(raw ecs.RawEvent) error { - isc, ok := raw.Payload.(InterShardCommand) - if !ok { - return eris.Errorf("invalid inter shard command %v", isc) - } - - pbCommand, err := protoutil.MarshalCommand(isc.Command, isc.Target, micro.String(s.Address)) - if err != nil { - return eris.Wrap(err, "failed to marshal command") - } - - _, err = s.client.Request(context.Background(), isc.Target, "command."+isc.Command.Name(), pbCommand) - if err != nil { - err = eris.Wrapf(err, "failed to send inter-shard command %s to shard", isc.Command.Name()) - return err - } - - return nil -} diff --git a/pkg/cardinal/service/query_internal_test.go b/pkg/cardinal/service/query_internal_test.go deleted file mode 100644 index 92c1b2966..000000000 --- a/pkg/cardinal/service/query_internal_test.go +++ /dev/null @@ -1,227 +0,0 @@ -package service - -import ( - "fmt" - "sync" - "testing" - - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/micro" - iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" - microv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1" - "google.golang.org/protobuf/types/known/anypb" -) - -// Test components. -type PlayerTag struct{ Nickname string } - -func (PlayerTag) Name() string { return "PlayerTag" } - -type Health struct{ HP int } - -func (Health) Name() string { return "Health" } - -type Position struct{ X, Y int } - -func (Position) Name() string { return "Position" } - -type Score struct{ Value int } - -func (Score) Name() string { return "Score" } - -type Inventory struct{ Items []string } - -func (Inventory) Name() string { return "Inventory" } - -type InitSystemState struct { - ecs.BaseSystemState - Query ecs.Contains[struct { - Tag ecs.Ref[PlayerTag] - Health ecs.Ref[Health] - Position ecs.Ref[Position] - Score ecs.Ref[Score] - Inventory ecs.Ref[Inventory] - }] -} - -func initSystem(state *InitSystemState) error { - // Create 100 entities with PlayerTag + Health with varying HP values - for i := range 100 { - _, entity := state.Query.Create() - entity.Tag.Set(PlayerTag{Nickname: fmt.Sprintf("player%d", i)}) - entity.Health.Set(Health{HP: 100 + (i * 5)}) // HP ranges from 100 to 595 - } - - // Create 50 entities with PlayerTag + Position in different quadrants - for i := range 50 { - x := (i % 5) * 20 // X: 0, 20, 40, 60, 80 repeating - y := (i / 5) * 20 // Y: increases by 20 every 5 entities - _, entity := state.Query.Create() - entity.Tag.Set(PlayerTag{Nickname: fmt.Sprintf("scout%d", i)}) - entity.Position.Set(Position{X: x, Y: y}) - } - - // Create 50 entities with all components (for complex queries) - for i := range 50 { - hp := 200 + (i * 10) // HP ranges from 200 to 690 - x := -100 + (i * 5) // X ranges from -100 to 145 - y := i * 5 // Y ranges from 0 to 245 - score := 1000 + (i * 50) // Score ranges from 1000 to 3450 - - items := []string{"potion"} - if i%3 == 0 { - items = append(items, "sword") - } - if i%4 == 0 { - items = append(items, "bow", "arrows") - } - if i%5 == 0 { - items = append(items, "shield") - } - - _, entity := state.Query.Create() - entity.Tag.Set(PlayerTag{Nickname: fmt.Sprintf("hero%d", i)}) - entity.Health.Set(Health{HP: hp}) - entity.Position.Set(Position{X: x, Y: y}) - entity.Score.Set(Score{Value: score}) - entity.Inventory.Set(Inventory{Items: items}) - } - - // Create 50 Position-only entities in a grid pattern - for i := range 50 { - x := ((i % 7) * 30) - 90 // X: -90 to 90 in steps of 30 - y := ((i / 7) * 30) - 90 // Y: -90 to 90 in steps of 30 - _, entity := state.Query.Create() - entity.Position.Set(Position{X: x, Y: y}) - } - - // Create 25 Health-only entities with varying HP - for i := range 25 { - _, entity := state.Query.Create() - entity.Health.Set(Health{HP: 50 + (i * 20)}) // HP ranges from 50 to 530 - } - - // Create 25 Score-only entities - for i := range 25 { - _, entity := state.Query.Create() - entity.Score.Set(Score{Value: 500 + (i * 100)}) // Scores from 500 to 2900 - } - - return nil -} - -func BenchmarkQuery(b *testing.B) { - // Setup test cases - benchmarks := []struct { - name string - find []string - match ecs.SearchMatch - where string - }{ - { - name: "simple position query", - find: []string{"Position"}, - match: ecs.MatchExact, - }, - { - name: "multi-component query", - find: []string{"Position", "Health"}, - match: ecs.MatchContains, - }, - { - name: "query with filter", - find: []string{"Position", "Health"}, - match: ecs.MatchContains, - where: "Health.HP > 150", - }, - { - name: "complex filter", - find: []string{"Position", "Health", "PlayerTag"}, - match: ecs.MatchContains, - where: "Health.HP > 200 && Position.X > Position.Y", - }, - } - - for _, bm := range benchmarks { - b.Run(bm.name, func(b *testing.B) { - world := ecs.NewWorld() - ecs.RegisterSystem(world, initSystem, ecs.WithHook(ecs.Init)) - - world.Init() - - _, err := world.Tick(nil) - if err != nil { - b.Fatal("failed to initialize world:", err) - } - - pool := sync.Pool{ - New: func() any { - return &Query{ - Find: make([]string, 0, 8), - } - }, - } - - req := createTestRequest(b, bm.find, bm.match, bm.where) - - b.ResetTimer() - for b.Loop() { - // Parse query. - query, err := parseQuery(&pool, req) - if err != nil { - b.Fatal("parse query failed:", err) - } - - // Search. - results, err := world.NewSearch(ecs.SearchParam{ - Find: query.Find, - Match: query.Match, - Where: query.Where, - }) - if err != nil { - b.Fatal("search failed:", err) - } - - // Serialize results. - _, err = serializeQueryResults(results) - if err != nil { - b.Fatal("serialize failed:", err) - } - - // Return the query object. - pool.Put(query) - } - }) - } -} - -func createTestRequest(t testing.TB, find []string, match ecs.SearchMatch, where string) *micro.Request { - iscMsg := &iscv1.Query{ - Find: find, - Match: searchMatchToISCQueryMatch(match), - Where: where, - } - - // Pack the ISC Message into Any - anyMsg, err := anypb.New(iscMsg) - if err != nil { - t.Fatal("failed to pack message into Any:", err) - } - - // Create the final Request using micro.Request - return µ.Request{ - ServiceAddress: µv1.ServiceAddress{}, - Payload: anyMsg, - } -} - -func searchMatchToISCQueryMatch(m ecs.SearchMatch) iscv1.Query_Match { - switch m { - case ecs.MatchExact: - return iscv1.Query_MATCH_EXACT - case ecs.MatchContains: - return iscv1.Query_MATCH_CONTAINS - default: - return iscv1.Query_MATCH_UNSPECIFIED - } -} diff --git a/pkg/cardinal/service/service.go b/pkg/cardinal/service/service.go deleted file mode 100644 index 4dbcdf3a7..000000000 --- a/pkg/cardinal/service/service.go +++ /dev/null @@ -1,108 +0,0 @@ -package service - -import ( - "context" - "sync" - - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/micro" - "github.com/argus-labs/world-engine/pkg/telemetry" - "github.com/rotisserie/eris" -) - -// ShardService extends micro.Service with Cardinal specific functionality. -type ShardService struct { - *micro.Service - - client *micro.Client // NATS client - world *ecs.World // Reference to the ECS world - tel *telemetry.Telemetry // Telemetry for logging and tracing - queryPool sync.Pool // Pool for query objects - introspect Introspect // Introspection metadata cache -} - -// NewShardService creates a new shard service. -func NewShardService(opts ShardServiceOptions) (*ShardService, error) { - if err := opts.Validate(); err != nil { - return nil, eris.Wrap(err, "invalid options passed") - } - - s := &ShardService{ - world: opts.World, - client: opts.Client, - tel: opts.Telemetry, - queryPool: sync.Pool{ - New: func() any { - return &Query{ - // Pre-allocate space for 8 components which should cover most cases. - Find: make([]string, 0, 8), - } - }, - }, - } - - service, err := micro.NewService(opts.Client, opts.Address, opts.Telemetry) - if err != nil { - return s, eris.Wrap(err, "failed to create micro service") - } - - s.Service = service - - if err := s.registerEndpoints(); err != nil { - return s, eris.Wrap(err, "failed to register endpoints") - } - - return s, nil -} - -// RegisterEndpoints registers the service endpoints for handling requests. -func (s *ShardService) registerEndpoints() error { - err := s.AddEndpoint("ping", s.handlePing) - if err != nil { - return eris.Wrap(err, "failed to register ping handler") - } - err = s.AddEndpoint("query", s.handleQuery) - if err != nil { - return eris.Wrap(err, "failed to register query handler") - } - err = s.AddEndpoint("introspect", s.handleIntrospect) - if err != nil { - return eris.Wrap(err, "failed to register introspect handler") - } - return nil -} - -// handlePing responds to health-check requests. Used by NATS CLI or K8s probes to verify -// the shard is connected to NATS and running. Accepts empty or valid Request payload. -func (s *ShardService) handlePing(ctx context.Context, req *micro.Request) *micro.Response { - return micro.NewSuccessResponse(req, nil) -} - -// ------------------------------------------------------------------------------------------------- -// Options -// ------------------------------------------------------------------------------------------------- - -// ShardServiceOptions contains the configuration for creating a new ShardService. -type ShardServiceOptions struct { - Client *micro.Client // NATS client for inter-service communication - Address *micro.ServiceAddress // This Cardinal shard's service address - World *ecs.World // Reference to the ECS world - Telemetry *telemetry.Telemetry // Telemetry for logging and tracing -} - -// Validate checks that all required fields in ShardServiceOptions are not nil. -func (opts ShardServiceOptions) Validate() error { - if opts.Client == nil { - return eris.New("client cannot be nil") - } - if opts.Address == nil { - return eris.New("address cannot be nil") - } - if opts.World == nil { - return eris.New("world cannot be nil") - } - if opts.Telemetry == nil { - return eris.New("telemetry cannot be nil") - } - return nil -} diff --git a/pkg/cardinal/snapshot/snapshot.go b/pkg/cardinal/snapshot/snapshot.go new file mode 100644 index 000000000..6d6639b32 --- /dev/null +++ b/pkg/cardinal/snapshot/snapshot.go @@ -0,0 +1,78 @@ +package snapshot + +import ( + "context" + "errors" + "strings" + "time" + + "github.com/rotisserie/eris" +) + +// Snapshot represents a point-in-time capture of shard state. +// This is an alias to the protobuf-generated type for better API ergonomics. +type Snapshot struct { + TickHeight uint64 + Timestamp time.Time + Data []byte + Version uint32 +} + +const CurrentVersion uint32 = 1 + +var ErrSnapshotNotFound = errors.New("snapshot not found") + +// Storage provides persistence for shard snapshots. +// Implementations handle atomic storage with automatic backup of previous snapshots. +type Storage interface { + // Store saves the snapshot, atomically replacing any existing snapshot. + // The previous snapshot should be preserved as backup if possible. + Store(ctx context.Context, snapshot *Snapshot) error + + // Load retrieves the current snapshot. + // Returns an error if no snapshot exists. + Load(ctx context.Context) (*Snapshot, error) +} + +// StorageType defines the type of snapshot storage to use. +type StorageType uint8 + +const ( + StorageTypeUndefined StorageType = iota + StorageTypeNop + StorageTypeJetStream +) + +const ( + nopStorageString = "NOP" + jetStreamStorageString = "JETSTREAM" + undefinedStorageString = "UNDEFINED" +) + +func (s StorageType) String() string { + switch s { + case StorageTypeUndefined: + return undefinedStorageString + case StorageTypeNop: + return nopStorageString + case StorageTypeJetStream: + return jetStreamStorageString + default: + return undefinedStorageString + } +} + +func (s StorageType) IsValid() bool { + return s == StorageTypeNop || s == StorageTypeJetStream +} + +func ParseStorageType(s string) (StorageType, error) { + switch strings.ToUpper(s) { + case nopStorageString: + return StorageTypeNop, nil + case jetStreamStorageString: + return StorageTypeJetStream, nil + default: + return StorageTypeUndefined, eris.Errorf("invalid shard mode: %s", s) + } +} diff --git a/pkg/cardinal/snapshot/storage_jetstream.go b/pkg/cardinal/snapshot/storage_jetstream.go new file mode 100644 index 000000000..870cdbd78 --- /dev/null +++ b/pkg/cardinal/snapshot/storage_jetstream.go @@ -0,0 +1,163 @@ +package snapshot + +import ( + "context" + "fmt" + "io" + "math" + + "buf.build/go/protovalidate" + "github.com/argus-labs/world-engine/pkg/micro" + cardinalv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1" + "github.com/caarlos0/env/v11" + "github.com/nats-io/nats.go/jetstream" + "github.com/rotisserie/eris" + "github.com/rs/zerolog" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/timestamppb" +) + +const defaultObjectName = "snapshot" + +// JetStreamStorage implements SnapshotStorage using NATS JetStream ObjectStore. +type JetStreamStorage struct { + os jetstream.ObjectStore +} + +var _ Storage = (*JetStreamStorage)(nil) + +// NewJetStreamStorage creates a new JetStream ObjectStore-based snapshot storage. +// It creates its own NATS client using the default configuration from environment variables. +func NewJetStreamStorage(opts JetStreamStorageOptions) (*JetStreamStorage, error) { + if err := opts.Validate(); err != nil { + return nil, eris.Wrap(err, "invalid options passed") + } + + // Just parse the env here for now. + // TODO: remove storage max bytes option or make it explicit. + if err := env.Parse(&opts); err != nil { + return nil, eris.Wrap(err, "failed to parse env") + } + + client, err := micro.NewClient(micro.WithLogger(opts.Logger)) + if err != nil { + return nil, eris.Wrap(err, "failed to create micro client") + } + + js, err := jetstream.New(client.Conn) + if err != nil { + return nil, eris.Wrap(err, "failed to create JetStream client") + } + + ctx := context.Background() + + // Same format as streams because it regular service address format isn't accepted. + bucketName := fmt.Sprintf("%s_%s_%s_snapshot", + opts.Address.GetOrganization(), opts.Address.GetProject(), opts.Address.GetServiceId()) + + if opts.SnapshotStorageMaxBytes > math.MaxInt64 { + return nil, eris.New("snapshot storage max bytes exceeds maximum int64 value") + } + + osConfig := jetstream.ObjectStoreConfig{ + Bucket: bucketName, + MaxBytes: int64(opts.SnapshotStorageMaxBytes), // Required by some NATS providers like Synadia Cloud + } + os, err := js.CreateObjectStore(ctx, osConfig) + if err != nil { + if eris.Is(err, jetstream.ErrBucketExists) { + // Bucket already exists, get the existing one. + os, err = js.ObjectStore(ctx, bucketName) + if err != nil { + return nil, eris.Wrapf(err, "failed to get existing ObjectStore (bucket=%s)", bucketName) + } + } else { + return nil, eris.Wrapf(err, "failed to create ObjectStore (bucket=%s, maxBytes=%d)", + osConfig.Bucket, osConfig.MaxBytes) + } + } + + return &JetStreamStorage{os: os}, nil +} + +func (j *JetStreamStorage) Store(ctx context.Context, snapshot *Snapshot) error { + var worldState cardinalv1.WorldState + if err := proto.Unmarshal(snapshot.Data, &worldState); err != nil { + return eris.Wrap(err, "failed to unmarshal world state") + } + snapshotPb := &cardinalv1.Snapshot{ + TickHeight: snapshot.TickHeight, + Timestamp: timestamppb.New(snapshot.Timestamp), + WorldState: &worldState, + Version: snapshot.Version, + } + data, err := proto.Marshal(snapshotPb) + if err != nil { + return eris.Wrap(err, "failed to marshal snapshot") + } + + // Overwrite the existing snapshot if any. + if _, err = j.os.PutBytes(ctx, defaultObjectName, data); err != nil { + return eris.Wrap(err, "failed to store snapshot in ObjectStore") + } + + return nil +} + +func (j *JetStreamStorage) Load(ctx context.Context) (*Snapshot, error) { + object, err := j.os.Get(ctx, defaultObjectName) + if err != nil { + if eris.Is(err, jetstream.ErrObjectNotFound) { + return nil, eris.Wrap(ErrSnapshotNotFound, "no snapshot exists") + } + return nil, eris.Wrap(err, "failed to get snapshot from ObjectStore") + } + defer func() { + _ = object.Close() + }() + + data, err := io.ReadAll(object) + if err != nil { + return nil, eris.Wrap(err, "failed to read from object") + } + + snapshotPb := cardinalv1.Snapshot{} + if err = proto.Unmarshal(data, &snapshotPb); err != nil { + return nil, eris.Wrap(err, "failed to unmarshal snapshot") + } + if err = protovalidate.Validate(&snapshotPb); err != nil { + return nil, eris.Wrap(err, "failed to validate snapshot") + } + + worldStateBytes, err := proto.Marshal(snapshotPb.GetWorldState()) + if err != nil { + return nil, eris.Wrap(err, "failed to marshal world state") + } + + return &Snapshot{ + TickHeight: snapshotPb.GetTickHeight(), + Timestamp: snapshotPb.GetTimestamp().AsTime(), + Data: worldStateBytes, + Version: snapshotPb.GetVersion(), + }, nil +} + +// ------------------------------------------------------------------------------------------------- +// Options +// ------------------------------------------------------------------------------------------------- + +type JetStreamStorageOptions struct { + Address *micro.ServiceAddress + Logger zerolog.Logger + + // Maximum bytes for snapshot storage (ObjectStore). Required by some NATS providers like Synadia Cloud. + SnapshotStorageMaxBytes uint64 `env:"CARDINAL_SNAPSHOT_STORAGE_MAX_BYTES" envDefault:"0"` +} + +func (opt *JetStreamStorageOptions) Validate() error { + if opt.Address == nil { + return eris.New("service address cannot be nil") + } + // SnapshotStorageMaxBytes can be 0 which means unlimited storage. No need to validate here. + return nil +} diff --git a/pkg/cardinal/snapshot/storage_nop.go b/pkg/cardinal/snapshot/storage_nop.go new file mode 100644 index 000000000..65f1889ee --- /dev/null +++ b/pkg/cardinal/snapshot/storage_nop.go @@ -0,0 +1,26 @@ +package snapshot + +import ( + "context" + + "github.com/rotisserie/eris" +) + +// NopStorage is a no-op implementation of SnapshotStorage. +// It's used when snapshots are not needed (e.g., development, testing). +type NopStorage struct{} + +var _ Storage = (*NopStorage)(nil) + +// NewNopStorage creates a new no-op snapshot storage. +func NewNopStorage() *NopStorage { + return &NopStorage{} +} + +func (n *NopStorage) Store(_ context.Context, _ *Snapshot) error { + return nil +} + +func (n *NopStorage) Load(_ context.Context) (*Snapshot, error) { + return nil, eris.Wrap(ErrSnapshotNotFound, "no snapshots available (using no-op storage)") +} diff --git a/pkg/cardinal/system.go b/pkg/cardinal/system.go index 574f8a9d0..7b04cf208 100644 --- a/pkg/cardinal/system.go +++ b/pkg/cardinal/system.go @@ -1,387 +1,242 @@ package cardinal import ( + "fmt" + "iter" "reflect" "time" "github.com/argus-labs/world-engine/pkg/assert" - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" - "github.com/argus-labs/world-engine/pkg/cardinal/service" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/command" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/ecs" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/event" "github.com/argus-labs/world-engine/pkg/micro" "github.com/rotisserie/eris" "github.com/rs/zerolog" ) -// ECS type aliases for easier user imports. -type ( - Exact[T any] = ecs.Exact[T] - Ref[T ecs.Component] = ecs.Ref[T] - Contains[T any] = ecs.Contains[T] -) +func RegisterSystem[T any](world *World, system func(*T), opts ...SystemOption) { + cfg := newSystemConfig() + for _, opt := range opts { + opt(&cfg) + } -// RegisterSystem registers a system and its state with the world. By default, systems are registered to the -// Update hook. This can be overridden with the optional WithHook option. -// -// Example: -// -// type RegenSystemState struct { -// cardinal.BaseSystemState -// Players ecs.Exact[struct { -// PlayerTag ecs.Ref[PlayerTag] -// Health ecs.Ref[Health] -// }] -// } -// -// world := cardinal.NewWorld() -// cardinal.RegisterSystem(world, func(state *RegenSystemState) error { -// // System logic here -// return nil -// }) -func RegisterSystem[T any](w *World, system ecs.System[T], opts ...ecs.SystemOption) { - // Check that the system state embeds BaseSystemState. + // Check that the system stateType embeds BaseSystemState. var zero T - state := reflect.TypeOf(zero) - if _, ok := state.FieldByName("BaseSystemState"); !ok { + stateType := reflect.TypeOf(zero) + if _, ok := stateType.FieldByName("BaseSystemState"); !ok { panic(eris.Errorf("system %T must embed cardinal.BaseSystemState", system)) } - // Apply Cardinal specific system field modifiers. - opts = append(opts, - ecs.WithModifier(ecs.FieldBase, baseSystemStateInit(w)), - ecs.WithModifier(ecs.FieldCommand, withCommandInit(w)), - ecs.WithModifier(ecs.FieldEvent, withEventInit(w)), - ecs.WithModifier(ecs.FieldSystemEventReceiver, withSystemEventReceiverInit(w)), - ecs.WithModifier(ecs.FieldSystemEventEmitter, withSystemEventEmitterInit(w)), - ) - ecs.RegisterSystem(w.getWorld(), system, opts...) + // Initialize the fields in the system state. + state := new(T) + + err := initSystemFields(state, world) + if err != nil { + panic(eris.Wrapf(err, "error initializing system fields")) + } + + name := fmt.Sprintf("%T", system) + systemFn := func() { system(state) } + + err = ecs.RegisterSystem(world.world, state, name, systemFn, cfg.hook) + if err != nil { + panic(eris.Wrapf(err, "error registering system")) + } } -// The following aliases are exported from ecs so that users don't have to import the ecs package. +func initSystemFields[T any](state *T, world *World) error { + meta := systemInitMetadata{ + world: world, + commands: make(map[string]struct{}), + events: make(map[string]struct{}), + } + + // For each field in the system state, initialize the field and collect its dependencies. + value := reflect.ValueOf(state).Elem() + for i := range value.NumField() { + field := value.Field(i) + fieldType := value.Type().Field(i) -// PreUpdate runs before the main update. -const PreUpdate = ecs.PreUpdate + // If the field is not exported, return an error. + if !field.CanAddr() { + return eris.Errorf("field %s must be exported", fieldType.Name) + } -// Update runs during the main update phase. -const Update = ecs.Update + fieldInstance := field.Addr().Interface() -// PostUpdate runs after the main update. -const PostUpdate = ecs.PostUpdate + cardinalField, ok := fieldInstance.(systemField) + if ok { + if err := cardinalField.init(&meta); err != nil { + return eris.Wrapf(err, "failed to initialize field %s", fieldType.Name) + } + continue + } -// Init runs once during world initialization. -const Init = ecs.Init + // ECS fields will be initialized separately, so we just have to check the rest of the fields + // are valid system field types. + if _, isECSField := fieldInstance.(ecs.SystemField); !isECSField { + return eris.Errorf("field %s is not a valid cardinal system field", fieldType.Name) + } + } + return nil +} -// WithHook returns an option to set the system hook. -func WithHook(hook ecs.SystemHook) ecs.SystemOption { - return ecs.WithHook(hook) +type systemInitMetadata struct { + world *World + commands map[string]struct{} + events map[string]struct{} } -// cardinalSystemStateField is an interface that cardinal's system field wrappers must implement. -type cardinalSystemStateField interface { - init(*World) error +type systemField interface { + init(meta *systemInitMetadata) error } -var _ cardinalSystemStateField = &BaseSystemState{} -var _ cardinalSystemStateField = &WithCommand[ecs.Command]{} -var _ cardinalSystemStateField = &WithEvent[ecs.Event]{} -var _ cardinalSystemStateField = &WithSystemEventReceiver[ecs.SystemEvent]{} -var _ cardinalSystemStateField = &WithSystemEventEmitter[ecs.SystemEvent]{} +var _ systemField = (*BaseSystemState)(nil) +var _ systemField = (*WithCommand[Command])(nil) +var _ systemField = (*WithEvent[Event])(nil) -// ----------------------------------------------------------------------------- -// Base System State Field -// ----------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------------------- +// Options +// ------------------------------------------------------------------------------------------------- + +// systemConfig holds all configurable options for system registration. +type systemConfig struct { + // The hook that determines when the system should be executed. + hook ecs.SystemHook +} + +// newSystemConfig creates a new system config with default values. +func newSystemConfig() systemConfig { + return systemConfig{ + hook: Update, + } +} + +// SystemOption is a function that configures a SystemConfig. +type SystemOption func(*systemConfig) + +// SystemHook defines when a system should be executed in the update cycle. +type SystemHook = ecs.SystemHook + +const ( + // PreUpdate runs before the main update. + PreUpdate = ecs.PreUpdate + // Update runs during the main update phase. + Update = ecs.Update + // PostUpdate runs after the main update. + PostUpdate = ecs.PostUpdate + // Init runs once during world initialization. + Init = ecs.Init +) + +// WithHook returns an option to set the system hook. +func WithHook(hook SystemHook) SystemOption { + return func(cfg *systemConfig) { cfg.hook = hook } +} + +// ------------------------------------------------------------------------------------------------- +// Base +// ------------------------------------------------------------------------------------------------- -// BaseSystemState provides common functionality for system state types. It should be embedded in your system state -// types to allow your systems to access the world state. -// -// Example: -// -// // Define your system state by embedding BaseState. -// type DebugSystemState struct { -// cardinal.BaseSystemState -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func DebugSystem(state *DebugSystemState) error { -// state.Logger().Debug().Int("tick", state.Tick()).Msg("...") -// return nil -// } type BaseSystemState struct { - ecs.BaseSystemState - cardinal *World + world *World } -// init initializes the base system state field. -func (b *BaseSystemState) init(w *World) error { - b.cardinal = w +func (b *BaseSystemState) init(meta *systemInitMetadata) error { + b.world = meta.world return nil } +// TODO: pass init args (similar to boot info) to get system name in logger. // Logger returns the logger for the world. func (b *BaseSystemState) Logger() *zerolog.Logger { - logger := b.cardinal.tel.GetLogger("system") + logger := b.world.tel.GetLogger("system") return &logger } // Tick returns the current tick of the world. func (b *BaseSystemState) Tick() uint64 { - tick, err := b.cardinal.CurrentTick() - assert.That(err == nil, "GetCurrentTick should never fail during system execution") - return tick.Header.TickHeight + return b.world.currentTick.height } // Timestamp returns the current timestamp of the world. func (b *BaseSystemState) Timestamp() time.Time { - tick, err := b.cardinal.CurrentTick() - assert.That(err == nil, "GetCurrentTick should never fail during system execution") - return tick.Header.Timestamp + return b.world.currentTick.timestamp } -// TODO: Rand method, ScheduleTasks(?) - -// baseSystemStateInit initializes the base system state field. It checks that the user is using -// cardinal.BaseSystemState instead of ecs.BaseSystemState. -func baseSystemStateInit(w *World) func(any) error { - return func(field any) error { - b, ok := field.(*BaseSystemState) - if !ok { - return eris.New("field must be cardinal.BaseSystemState") - } - return b.init(w) - } -} +// ------------------------------------------------------------------------------------------------- +// Commands +// ------------------------------------------------------------------------------------------------- -// ----------------------------------------------------------------------------- -// Command Field -// ----------------------------------------------------------------------------- +type Command = command.Payload -// WithCommand is a generic system state field that allows systems to receive commands of type T during each tick. -// Commands must embed a cardinal.BaseCommand that provides common methods for commands. The commands are automatically -// registered when the systems are registered. -// -// Example: -// -// // Define a command type for spawning players. -// type SpawnPlayerCommand struct { -// cardinal.BaseCommand -// Name string -// } -// -// func (SpawnPlayerCommand) Name() string { return "spawn-player" } -// -// // Define your system state. -// type SpawnSystemState struct { -// cardinal.BaseSystemState -// SpawnPlayerCommands cardinal.WithCommand[SpawnPlayerCommand] -// // Other fields... -// } -// -// // Your system function receives a pointer to your system state. -// func SpawnSystem(state *SpawnSystemState) error { -// // Process all spawn commands for the current tick. -// for command := range state.SpawnPlayerCommands.Iter() { -// state.Players.Create(PlayerTag{Name: command.Name}, Health{Value: 100}) -// } -// return nil -// } -type WithCommand[T ecs.Command] struct { - ecs.WithCommand[T] +type WithCommand[T Command] struct { + manager *command.Manager + id command.ID } -// init initializes the command state field, registers the command with the command manager, and creates a shard service -// handler for it. Returns an error if the command doesn't embed cardinal.BaseCommand. -func (c *WithCommand[T]) init(w *World) error { +func (c *WithCommand[T]) init(meta *systemInitMetadata) error { var zero T name := zero.Name() - // Use reflection to check if T embeds BaseCommand. - commandType := reflect.TypeOf(zero) - _, ok := commandType.FieldByName("BaseCommand") - if !ok { - return eris.Errorf("Command %s must embed cardinal.BaseCommand", name) + if _, ok := meta.commands[name]; ok { + return eris.Errorf("systems cannot process multiple commands of the same type: %s", name) } - if err := micro.RegisterCommand[T](w.Shard); err != nil { - return eris.Wrap(err, "failed to register command with shard") + id, err := meta.world.commands.Register(name, command.NewQueue[T]()) + if err != nil { + return eris.Wrapf(err, "failed to register command %s", name) } - return nil -} + // Register the command handler with NATS. NOTE: this just adds to the service's command name set, + // it doesn't create the NATS subscription/request handler immediately. This method is free of + // side effects so we can test without NATS. + meta.world.service.registerCommandHandler(name) -// withCommandInit initializes the command state field. It checks that the user is using -// cardinal.WithCommand[T] instead of ecs.WithCommand[T]. -func withCommandInit(w *World) func(any) error { - return func(field any) error { - c, ok := field.(cardinalSystemStateField) - if !ok { - return eris.New("field must be cardinal.WithCommand[T]") - } - return c.init(w) + if err := meta.world.debug.register("command", zero); err != nil { + return eris.Wrapf(err, "failed to register command to debug module %s", name) } -} -// BaseCommand is a base command type that all commands should embed. -type BaseCommand struct{} + meta.commands[name] = struct{}{} // Add to system commands set for duplicate field check -// ----------------------------------------------------------------------------- -// Event Field -// ----------------------------------------------------------------------------- - -// WithEvent is a generic system state field that allows systems to emit events of type T. Events must embed a -// cardinal.BaseEvent that provides common methods for events. At the end of each tick, events are published to their -// respective subjects. -// -// Example: -// -// // Define an event type for player deaths. -// type LevelUpEvent struct { -// cardinal.BaseEvent -// Nickname string -// } -// -// func (LevelUpEvent) Name() string { return "level-up" } -// -// type LevelUpSystemState struct { -// cardinal.BaseSystemState -// LevelUpEvents cardinal.WithEvent[LevelUpEvent] -// // Other fields... -// } -// -// // Emit a level up event from one system. -// func LevelUpSystem(state *LevelUpSystemState) error { -// state.LevelUpEvents.Emit(LevelUpEvent{Nickname: "Player1"}) -// return nil -// } -type WithEvent[T ecs.Event] struct { - ecs.WithEvent[T] -} - -// init initializes the event state field. It checks that the event type embeds cardinal.BaseEvent. -func (e *WithEvent[T]) init(_ *World) error { - var zero T - name := zero.Name() - - // Use reflection to check if T embeds BaseEvent. - eventType := reflect.TypeOf(zero) - _, ok := eventType.FieldByName("BaseEvent") - if !ok { - return eris.Errorf("Event %s must embed cardinal.BaseEvent", name) - } + c.manager = &meta.world.commands + c.id = id return nil } -// withEventInit initializes the event state field. It checks that the user is using cardinal.WithEvent[T] -// instead of ecs.WithEvent[T]. -func withEventInit(w *World) func(any) error { - return func(field any) error { - e, ok := field.(cardinalSystemStateField) - if !ok { - return eris.New("field must be cardinal.WithEvent[T]") - } - return e.init(w) - } -} - -// BaseEvent is a base event type that all events should embed. -type BaseEvent struct{} - -// ----------------------------------------------------------------------------- -// System Event Field -// -// These aren't used atm to add extra functionality on top of ecs's sytem event fields, but if we -// do need to do it one day, we can just update the init methods here. -// ----------------------------------------------------------------------------- - -// WithSystemEventReceiver is a generic system state field that allows systems to receive system events of type T. -// System events don't have to be registered, and can be used to communicate between systems. -// -// Example: -// -// // Define a system event for player deaths. -// type PlayerDeathEvent struct { Nickname string } -// -// func (PlayerDeathEvent) Name() string { return "player-death" } -// -// type GraveyardSystemState struct { -// cardinal.BaseSystemState -// PlayerDeathEvents cardinal.WithSystemEventReceiver[PlayerDeathEvent] -// // Other fields... -// } -// -// // Receive a player death event from another system. -// func GraveyardSystem(state *GraveyardSystemState) error { -// for event := range state.PlayerDeathEvents.Iter() { -// state.Logger().Info().Msgf("Player %s died", event.Nickname) -// } -// return nil -// } -type WithSystemEventReceiver[T ecs.SystemEvent] struct { - ecs.WithSystemEventReceiver[T] -} - -// init initializes the system event receiver state field. Does nothing atm as we don't have any custom behavior -// for system events. -func (e *WithSystemEventReceiver[T]) init(_ *World) error { - return nil -} - -// withSystemEventReceiverInit initializes the system event receiver state field. It checks that the user is using -// cardinal.WithSystemEventReceiver[T] instead of ecs.WithSystemEventReceiver[T]. -func withSystemEventReceiverInit(w *World) func(any) error { - return func(field any) error { - e, ok := field.(cardinalSystemStateField) - if !ok { - return eris.New("field must be cardinal.WithSystemEventReceiver[T]") +func (c *WithCommand[T]) Iter() iter.Seq[CommandContext[T]] { + var zero T + commands, err := c.manager.Get(c.id) + assert.That(err == nil, "command not automatically registered %s", zero.Name()) + + return func(yield func(CommandContext[T]) bool) { + for _, cmd := range commands { + if !yield(newCommandContext[T](cmd)) { + return + } } - return e.init(w) } } -// WithSystemEventEmitter is a generic system state field that allows systems to emit system events of type T. -// System events don't have to be registered, and can be used to communicate between systems. -// -// Example: -// -// // Define a system event for player deaths. -// type PlayerDeathEvent struct { Nickname string } -// -// func (PlayerDeathEvent) Name() string { return "player-death" } -// -// type CombatSystemState struct { -// PlayerDeathEvents ecs.WithSystemEventEmitter[PlayerDeathEvent] -// // Other fields... -// } -// -// // Emit a player death event from one system. -// func CombatSystem(state *CombatSystemState) error { -// state.PlayerDeathEvents.Emit(PlayerDeathEvent{Nickname: "Player1"}) -// return nil -// } -type WithSystemEventEmitter[T ecs.SystemEvent] struct { - ecs.WithSystemEventEmitter[T] +type CommandContext[T Command] struct { + Payload T + Persona string } -// init initializes the system event emitter state field. Does nothing atm as we don't have any custom behavior -// for system events. -func (e *WithSystemEventEmitter[T]) init(_ *World) error { - return nil -} +func newCommandContext[T Command](cmd command.Command) CommandContext[T] { + payload, ok := cmd.Payload.(T) + assert.That(ok, "mismatched command type passed to command context") -// withSystemEventEmitterInit initializes the system event emitter state field. It checks that the user is using -// cardinal.WithSystemEventEmitter[T] instead of ecs.WithSystemEventEmitter[T]. -func withSystemEventEmitterInit(w *World) func(any) error { - return func(field any) error { - e, ok := field.(cardinalSystemStateField) - if !ok { - return eris.New("field must be cardinal.WithSystemEventEmitter[T]") - } - return e.init(w) + return CommandContext[T]{ + Payload: payload, + Persona: cmd.Persona, } } -// ----------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------------------- // Inter-Shard Commands -// ----------------------------------------------------------------------------- +// ------------------------------------------------------------------------------------------------- // OtherWorld is a type that represents the address of an external service. type OtherWorld struct { @@ -405,10 +260,59 @@ type OtherWorld struct { // Winner: "Team 1", // }) // } -func (o OtherWorld) Send(state *BaseSystemState, command ecs.Command) { +func (o OtherWorld) SendCommand(state *BaseSystemState, cmd command.Payload) { serviceAddress := micro.GetAddress(o.Region, micro.RealmWorld, o.Organization, o.Project, o.ShardID) - state.EmitRawEvent(service.EventKindInterShardCommand, service.InterShardCommand{ - Target: serviceAddress, - Command: command, + state.world.events.Enqueue(event.Event{ + Kind: event.KindInterShardCommand, + Payload: command.Command{ + Name: cmd.Name(), + Persona: micro.String(state.world.address), + Address: serviceAddress, + Payload: cmd, + }, }) } + +// ------------------------------------------------------------------------------------------------- +// Events +// ------------------------------------------------------------------------------------------------- + +type Event = event.Payload + +type WithEvent[T Event] struct { + manager *event.Manager +} + +func (e *WithEvent[T]) init(meta *systemInitMetadata) error { + var zero T + name := zero.Name() + + if _, ok := meta.events[name]; ok { + return eris.Errorf("systems cannot process multiple events of the same type: %s", name) + } + + if err := meta.world.debug.register("event", zero); err != nil { + return eris.Wrapf(err, "failed to register command to debug module %s", name) + } + + meta.events[name] = struct{}{} // Add to system events set for duplicate field check + + e.manager = &meta.world.events + return nil +} + +func (e *WithEvent[T]) Emit(evt T) { + e.manager.Enqueue(event.Event{ + Kind: event.KindDefault, + Payload: evt, + }) +} + +type ( + Exact[T any] = ecs.Exact[T] + Contains[T any] = ecs.Contains[T] + Ref[T ecs.Component] = ecs.Ref[T] + WithSystemEventReceiver[T ecs.SystemEvent] = ecs.WithSystemEventReceiver[T] + WithSystemEventEmitter[T ecs.SystemEvent] = ecs.WithSystemEventEmitter[T] + EntityID = ecs.EntityID +) diff --git a/pkg/cardinal/system_internal_test.go b/pkg/cardinal/system_internal_test.go new file mode 100644 index 000000000..c1bdeab71 --- /dev/null +++ b/pkg/cardinal/system_internal_test.go @@ -0,0 +1,206 @@ +package cardinal + +import ( + "testing" + + "github.com/argus-labs/world-engine/pkg/cardinal/internal/command" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/event" + "github.com/argus-labs/world-engine/pkg/cardinal/internal/schema" + "github.com/argus-labs/world-engine/pkg/testutils" + iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TODO: test system registration, e.g. duplicate field detection, etc. + +// ------------------------------------------------------------------------------------------------- +// WithCommand smoke tests +// ------------------------------------------------------------------------------------------------- +// WithCommand is a light wrapper over command.Manager, which is already tested. Here, we just check +// if the regular command operations work correctly. +// ------------------------------------------------------------------------------------------------- + +func TestWithCommand_Smoke(t *testing.T) { + t.Parallel() + + t.Run("round trip", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newCommandFixture(t) + + count := prng.IntN(100) + model := make([]testutils.SimpleCommand, count) + personas := make([]string, count) + for i := range count { + model[i] = testutils.SimpleCommand{Value: prng.IntN(1_000_000)} // Bounded to avoid JSON float64 precision loss + personas[i] = testutils.RandString(prng, 8) + } + + for i, cmd := range model { + fixture.enqueueCommand(t, cmd, personas[i]) + } + fixture.world.commands.Drain() + + var results []CommandContext[testutils.SimpleCommand] + for ctx := range fixture.Command.Iter() { + results = append(results, ctx) + } + + assert.Len(t, results, len(model), "completeness: expected %d commands, got %d", len(model), len(results)) + for i, result := range results { + assert.Equal(t, model[i], result.Payload, "round-trip integrity: payload mismatch at index %d", i) + assert.Equal(t, personas[i], result.Persona, "round-trip integrity: persona mismatch at index %d", i) + } + }) + + t.Run("empty iteration", func(t *testing.T) { + t.Parallel() + + fixture := newCommandFixture(t) + fixture.world.commands.Drain() + + count := 0 + for range fixture.Command.Iter() { + count++ + } + assert.Equal(t, 0, count) + }) + + t.Run("early termination", func(t *testing.T) { + t.Parallel() + + fixture := newCommandFixture(t) + + for i := range 10 { + fixture.enqueueCommand(t, testutils.SimpleCommand{Value: i}, "player") + } + fixture.world.commands.Drain() + + count := 0 + for range fixture.Command.Iter() { + count++ + break + } + assert.Equal(t, 1, count) + }) +} + +type commandFixture struct { + world *World + Command WithCommand[testutils.SimpleCommand] +} + +func newCommandFixture(t *testing.T) *commandFixture { + t.Helper() + + world := &World{ + commands: command.NewManager(), + service: newService(nil), + } + + fixture := &commandFixture{world: world} + + meta := &systemInitMetadata{world: world, commands: make(map[string]struct{}), events: make(map[string]struct{})} + err := fixture.Command.init(meta) + require.NoError(t, err) + + return fixture +} + +// enqueueCommand is a helper that marshals a command payload to protobuf and enqueues it. +func (f *commandFixture) enqueueCommand(t *testing.T, payload command.Payload, persona string) { + t.Helper() + + // TODO: refactor to serializable after merge + pbPayload, err := schema.ToProtoStruct(payload) + require.NoError(t, err) + + cmdpb := &iscv1.Command{ + Name: payload.Name(), + Persona: &iscv1.Persona{Id: persona}, + Payload: pbPayload, + } + + err = f.world.commands.Enqueue(cmdpb) + require.NoError(t, err) +} + +// ------------------------------------------------------------------------------------------------- +// WithEvent smoke tests +// ------------------------------------------------------------------------------------------------- +// WithEvent is a light wrapper over event.Manager, which is already tested. Here, we just check if +// the regular event operations work correctly. +// ------------------------------------------------------------------------------------------------- + +func TestWithEvent_Smoke(t *testing.T) { + t.Parallel() + + t.Run("round trip", func(t *testing.T) { + t.Parallel() + prng := testutils.NewRand(t) + fixture := newEventFixture(t) + + count := prng.IntN(100) + model := make([]testutils.SimpleEvent, count) + for i := range count { + model[i] = testutils.SimpleEvent{Value: prng.Int()} + } + + for _, evt := range model { + fixture.Event.Emit(evt) + } + + // Dispatch collects events and calls registered handlers. + var collected []event.Event + fixture.world.events.RegisterHandler(event.KindDefault, func(evt event.Event) error { + collected = append(collected, evt) + return nil + }) + err := fixture.world.events.Dispatch() + require.NoError(t, err) + + assert.Len(t, collected, len(model), "completeness: expected %d events, got %d", len(model), len(collected)) + for i, evt := range collected { + payload, ok := evt.Payload.(testutils.SimpleEvent) + assert.True(t, ok, "event payload type mismatch at index %d", i) + assert.Equal(t, model[i], payload, "round-trip integrity: event mismatch at index %d", i) + } + }) + + t.Run("emit empty", func(t *testing.T) { + t.Parallel() + fixture := newEventFixture(t) + + var collected []event.Event + fixture.world.events.RegisterHandler(event.KindDefault, func(evt event.Event) error { + collected = append(collected, evt) + return nil + }) + err := fixture.world.events.Dispatch() + require.NoError(t, err) + + assert.Empty(t, collected) + }) +} + +type eventFixture struct { + world *World + Event WithEvent[testutils.SimpleEvent] +} + +func newEventFixture(t *testing.T) *eventFixture { + t.Helper() + + world := &World{ + events: event.NewManager(1024), + } + + fixture := &eventFixture{world: world} + + meta := &systemInitMetadata{world: world, commands: make(map[string]struct{}), events: make(map[string]struct{})} + err := fixture.Event.init(meta) + require.NoError(t, err) + + return fixture +} diff --git a/pkg/cardinal/testutils/commands.go b/pkg/cardinal/testutils/commands.go deleted file mode 100644 index 3d11eb5ca..000000000 --- a/pkg/cardinal/testutils/commands.go +++ /dev/null @@ -1,74 +0,0 @@ -package testutils - -// No imports needed since the CreateUnmarshalFunc is now obsolete - -// ------------------------------------------------------------------------------------------------- -// Test Commands -// ------------------------------------------------------------------------------------------------- - -// TestCommand is a simple command for testing purposes. -type TestCommand struct { - Value int `json:"value"` -} - -func (TestCommand) Name() string { return "test-command" } - -// AnotherTestCommand is another command type for testing multiple command types. -type AnotherTestCommand struct { - Value int `json:"value"` -} - -func (AnotherTestCommand) Name() string { return "another-test-command" } - -// SimpleCommand is a basic command with name and payload for marshal testing. -type SimpleCommand struct { - CommandName string `json:"name"` - Payload string `json:"payload"` -} - -func (c SimpleCommand) Name() string { - return c.CommandName -} - -// ------------------------------------------------------------------------------------------------- -// Test Events -// ------------------------------------------------------------------------------------------------- - -// SimpleEvent is a basic event with name and payload for marshal testing. -type SimpleEvent struct { - EventName string `json:"name"` - Payload string `json:"payload"` -} - -func (e SimpleEvent) Name() string { - return e.EventName -} - -// ------------------------------------------------------------------------------------------------- -// Test Payloads -// ------------------------------------------------------------------------------------------------- - -// TestPayload is a basic struct payload for raw event testing. -type TestPayload struct { - Key string `json:"key"` - Number int `json:"number"` -} - -// CustomPayload is a payload with boolean field for testing custom event kinds. -type CustomPayload struct { - Custom bool `json:"custom"` -} - -// ------------------------------------------------------------------------------------------------- -// Factory Functions -// ------------------------------------------------------------------------------------------------- - -// NewSimpleCommand creates a SimpleCommand with the given name and payload. -func NewSimpleCommand(name, payload string) SimpleCommand { - return SimpleCommand{CommandName: name, Payload: payload} -} - -// NewSimpleEvent creates a SimpleEvent with the given name and payload. -func NewSimpleEvent(name, payload string) SimpleEvent { - return SimpleEvent{EventName: name, Payload: payload} -} diff --git a/pkg/lobby/system/lobby.go b/pkg/lobby/system/lobby.go index 7a0e0b454..f5ededf50 100644 --- a/pkg/lobby/system/lobby.go +++ b/pkg/lobby/system/lobby.go @@ -7,7 +7,6 @@ import ( "time" "github.com/argus-labs/world-engine/pkg/cardinal" - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" "github.com/argus-labs/world-engine/pkg/lobby/component" "github.com/google/uuid" ) @@ -18,7 +17,6 @@ import ( // CreateLobbyCommand creates a new lobby with the sender as leader. type CreateLobbyCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response // Teams is the initial team configuration for the lobby. Teams []TeamConfig `json:"teams,omitempty"` @@ -41,7 +39,6 @@ func (CreateLobbyCommand) Name() string { return "lobby_create" } // JoinLobbyCommand joins an existing lobby via invite code. type JoinLobbyCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response InviteCode string `json:"invite_code"` // Required: invite code to join TeamName string `json:"team_name,omitempty"` // Optional: team to join by name (joins first available if empty) @@ -54,7 +51,6 @@ func (JoinLobbyCommand) Name() string { return "lobby_join" } // JoinTeamCommand moves a player to a different team. type JoinTeamCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response TeamName string `json:"team_name"` } @@ -64,7 +60,6 @@ func (JoinTeamCommand) Name() string { return "lobby_join_team" } // LeaveLobbyCommand leaves the current lobby. type LeaveLobbyCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response } @@ -73,7 +68,6 @@ func (LeaveLobbyCommand) Name() string { return "lobby_leave" } // SetReadyCommand sets the player's ready status. type SetReadyCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response IsReady bool `json:"is_ready"` } @@ -83,7 +77,6 @@ func (SetReadyCommand) Name() string { return "lobby_set_ready" } // KickPlayerCommand kicks a player from the lobby (leader only). type KickPlayerCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response TargetPlayerID string `json:"target_player_id"` } @@ -93,7 +86,6 @@ func (KickPlayerCommand) Name() string { return "lobby_kick" } // TransferLeaderCommand transfers leadership to another player. type TransferLeaderCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response TargetPlayerID string `json:"target_player_id"` } @@ -103,7 +95,6 @@ func (TransferLeaderCommand) Name() string { return "lobby_transfer_leader" } // StartSessionCommand starts the session (leader only). type StartSessionCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response } @@ -112,7 +103,6 @@ func (StartSessionCommand) Name() string { return "lobby_start_session" } // GenerateInviteCodeCommand generates a new invite code (leader only). type GenerateInviteCodeCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response } @@ -122,7 +112,6 @@ func (GenerateInviteCodeCommand) Name() string { return "lobby_generate_invite" // HeartbeatCommand is sent periodically by clients to indicate they're still connected. // Players who don't send heartbeats within the timeout period are automatically removed. type HeartbeatCommand struct { - cardinal.BaseCommand } // Name returns the command name. @@ -130,7 +119,6 @@ func (HeartbeatCommand) Name() string { return "lobby_heartbeat" } // UpdateSessionPassthroughCommand updates the session passthrough data (leader only). type UpdateSessionPassthroughCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response PassthroughData map[string]any `json:"passthrough_data"` } @@ -140,7 +128,6 @@ func (UpdateSessionPassthroughCommand) Name() string { return "lobby_update_sess // UpdatePlayerPassthroughCommand updates the player's own passthrough data. type UpdatePlayerPassthroughCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response PassthroughData map[string]any `json:"passthrough_data"` } @@ -150,7 +137,6 @@ func (UpdatePlayerPassthroughCommand) Name() string { return "lobby_update_playe // GetPlayerCommand fetches a specific player's component data. type GetPlayerCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response PlayerID string `json:"player_id"` // Target player ID (empty = self) } @@ -160,7 +146,6 @@ func (GetPlayerCommand) Name() string { return "lobby_get_player" } // GetAllPlayersCommand fetches all players in the caller's lobby. type GetAllPlayersCommand struct { - cardinal.BaseCommand RequestID string `json:"request_id"` // For matching request/response } @@ -173,7 +158,6 @@ func (GetAllPlayersCommand) Name() string { return "lobby_get_all_players" } // LobbyCreatedEvent is emitted when a lobby is created. type LobbyCreatedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` LeaderID string `json:"leader_id"` InviteCode string `json:"invite_code"` @@ -184,7 +168,6 @@ func (LobbyCreatedEvent) Name() string { return "lobby_created" } // PlayerJoinedEvent is emitted when a player joins a lobby. type PlayerJoinedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` TeamName string `json:"team_name"` Player component.PlayerComponent `json:"player"` @@ -195,7 +178,6 @@ func (PlayerJoinedEvent) Name() string { return "lobby_player_joined" } // PlayerLeftEvent is emitted when a player leaves a lobby. type PlayerLeftEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` PlayerID string `json:"player_id"` } @@ -205,7 +187,6 @@ func (PlayerLeftEvent) Name() string { return "lobby_player_left" } // PlayerKickedEvent is emitted when a player is kicked. type PlayerKickedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` PlayerID string `json:"player_id"` KickerID string `json:"kicker_id"` @@ -216,7 +197,6 @@ func (PlayerKickedEvent) Name() string { return "lobby_player_kicked" } // PlayerReadyEvent is emitted when a player changes ready status. type PlayerReadyEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` Player component.PlayerComponent `json:"player"` } @@ -226,7 +206,6 @@ func (PlayerReadyEvent) Name() string { return "lobby_player_ready" } // PlayerChangedTeamEvent is emitted when a player changes team. type PlayerChangedTeamEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` OldTeamName string `json:"old_team_name"` NewTeamName string `json:"new_team_name"` @@ -238,7 +217,6 @@ func (PlayerChangedTeamEvent) Name() string { return "lobby_player_changed_team" // LeaderChangedEvent is emitted when leadership is transferred. type LeaderChangedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` OldLeaderID string `json:"old_leader_id"` NewLeaderID string `json:"new_leader_id"` @@ -249,7 +227,6 @@ func (LeaderChangedEvent) Name() string { return "lobby_leader_changed" } // SessionStartedEvent is emitted when a session starts. type SessionStartedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` } @@ -258,7 +235,6 @@ func (SessionStartedEvent) Name() string { return "lobby_session_started" } // SessionEndedEvent is emitted when a session ends. type SessionEndedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` } @@ -267,7 +243,6 @@ func (SessionEndedEvent) Name() string { return "lobby_session_ended" } // InviteCodeGeneratedEvent is emitted when a new invite code is generated. type InviteCodeGeneratedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` InviteCode string `json:"invite_code"` } @@ -277,7 +252,6 @@ func (InviteCodeGeneratedEvent) Name() string { return "lobby_invite_generated" // LobbyDeletedEvent is emitted when a lobby is deleted. type LobbyDeletedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` } @@ -286,7 +260,6 @@ func (LobbyDeletedEvent) Name() string { return "lobby_deleted" } // PlayerTimedOutEvent is emitted when a player is removed due to missed heartbeats. type PlayerTimedOutEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` PlayerID string `json:"player_id"` } @@ -296,7 +269,6 @@ func (PlayerTimedOutEvent) Name() string { return "lobby_player_timed_out" } // SessionPassthroughUpdatedEvent is emitted when session passthrough data is updated. type SessionPassthroughUpdatedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` PassthroughData map[string]any `json:"passthrough_data"` } @@ -306,7 +278,6 @@ func (SessionPassthroughUpdatedEvent) Name() string { return "lobby_session_pass // PlayerPassthroughUpdatedEvent is emitted when a player's passthrough data is updated. type PlayerPassthroughUpdatedEvent struct { - cardinal.BaseEvent LobbyID string `json:"lobby_id"` Player component.PlayerComponent `json:"player"` } @@ -320,7 +291,6 @@ func (PlayerPassthroughUpdatedEvent) Name() string { return "lobby_player_passth // CreateLobbyResult is sent back to the client after CreateLobbyCommand. type CreateLobbyResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -333,7 +303,6 @@ func (r CreateLobbyResult) Name() string { return r.RequestID + "_create_lobby_r // JoinLobbyResult is sent back to the client after JoinLobbyCommand. type JoinLobbyResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -346,7 +315,6 @@ func (r JoinLobbyResult) Name() string { return r.RequestID + "_join_lobby_resul // JoinTeamResult is sent back to the client after JoinTeamCommand. type JoinTeamResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -358,7 +326,6 @@ func (r JoinTeamResult) Name() string { return r.RequestID + "_join_team_result" // LeaveLobbyResult is sent back to the client after LeaveLobbyCommand. type LeaveLobbyResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -369,7 +336,6 @@ func (r LeaveLobbyResult) Name() string { return r.RequestID + "_leave_lobby_res // SetReadyResult is sent back to the client after SetReadyCommand. type SetReadyResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -381,7 +347,6 @@ func (r SetReadyResult) Name() string { return r.RequestID + "_set_ready_result" // KickPlayerResult is sent back to the client after KickPlayerCommand. type KickPlayerResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -392,7 +357,6 @@ func (r KickPlayerResult) Name() string { return r.RequestID + "_kick_player_res // TransferLeaderResult is sent back to the client after TransferLeaderCommand. type TransferLeaderResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -403,7 +367,6 @@ func (r TransferLeaderResult) Name() string { return r.RequestID + "_transfer_le // StartSessionResult is sent back to the client after StartSessionCommand. type StartSessionResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -414,7 +377,6 @@ func (r StartSessionResult) Name() string { return r.RequestID + "_start_session // GenerateInviteCodeResult is sent back to the client after GenerateInviteCodeCommand. type GenerateInviteCodeResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -426,7 +388,6 @@ func (r GenerateInviteCodeResult) Name() string { return r.RequestID + "_generat // UpdateSessionPassthroughResult is sent back to the client after UpdateSessionPassthroughCommand. type UpdateSessionPassthroughResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -439,7 +400,6 @@ func (r UpdateSessionPassthroughResult) Name() string { // UpdatePlayerPassthroughResult is sent back to the client after UpdatePlayerPassthroughCommand. type UpdatePlayerPassthroughResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -453,7 +413,6 @@ func (r UpdatePlayerPassthroughResult) Name() string { // GetPlayerResult is sent back to the client after GetPlayerCommand. type GetPlayerResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -467,7 +426,6 @@ func (r GetPlayerResult) Name() string { // GetAllPlayersResult is sent back to the client after GetAllPlayersCommand. type GetAllPlayersResult struct { - cardinal.BaseEvent RequestID string `json:"request_id"` IsSuccess bool `json:"is_success"` Message string `json:"message"` @@ -485,7 +443,6 @@ func (r GetAllPlayersResult) Name() string { // NotifySessionStartCommand is sent to game shard when a session starts. type NotifySessionStartCommand struct { - cardinal.BaseCommand Lobby component.LobbyComponent `json:"lobby"` LobbyWorld cardinal.OtherWorld `json:"lobby_world"` } @@ -495,7 +452,6 @@ func (NotifySessionStartCommand) Name() string { return "lobby_notify_session_st // NotifySessionEndCommand is sent from game shard to lobby when session ends. type NotifySessionEndCommand struct { - cardinal.BaseCommand LobbyID string `json:"lobby_id"` } @@ -578,7 +534,7 @@ type InitSystemState struct { } // InitSystem creates singleton index entities. Runs once at tick 0. -func InitSystem(state *InitSystemState) error { +func InitSystem(state *InitSystemState) { // Create lobby index entity _, lobbyIdx := state.LobbyIndexes.Create() idx := component.LobbyIndexComponent{} @@ -590,8 +546,6 @@ func InitSystem(state *InitSystemState) error { _, cfg := state.Configs.Create() cfg.Config.Set(storedConfig) state.Logger().Info().Msg("Created lobby config entity") - - return nil } // ----------------------------------------------------------------------------- @@ -669,7 +623,7 @@ type LobbySystemState struct { // lobbyLookupResult holds the result of looking up a player's lobby. type lobbyLookupResult struct { lobbyID string - entityID ecs.EntityID + entityID cardinal.EntityID lobby component.LobbyComponent lobbyRef cardinal.Ref[component.LobbyComponent] } @@ -693,26 +647,26 @@ func getPlayerLobby( return nil } - lobbyEntity, err := lobbies.GetByID(ecs.EntityID(lobbyEntityID)) + lobbyEntity, err := lobbies.GetByID(cardinal.EntityID(lobbyEntityID)) if err != nil { return nil } return &lobbyLookupResult{ lobbyID: lobbyID, - entityID: ecs.EntityID(lobbyEntityID), + entityID: cardinal.EntityID(lobbyEntityID), lobby: lobbyEntity.Lobby.Get(), lobbyRef: lobbyEntity.Lobby, } } // LobbySystem processes lobby commands. -func LobbySystem(state *LobbySystemState) error { +func LobbySystem(state *LobbySystemState) { now := state.Timestamp().Unix() // Get lobby index var lobbyIndex component.LobbyIndexComponent - var lobbyIndexEntityID ecs.EntityID + var lobbyIndexEntityID cardinal.EntityID for entityID, idx := range state.LobbyIndexes.Iter() { lobbyIndex = idx.Index.Get() lobbyIndexEntityID = entityID @@ -752,8 +706,6 @@ func LobbySystem(state *LobbySystemState) error { if lobbyIndexEntity, err := state.LobbyIndexes.GetByID(lobbyIndexEntityID); err == nil { lobbyIndexEntity.Index.Set(lobbyIndex) } - - return nil } // timedOutPlayer holds info about a player who missed heartbeat deadline. @@ -825,7 +777,7 @@ func createPlayerEntity( playerID, lobbyID, teamID string, passthroughData map[string]any, now int64, -) (component.PlayerComponent, ecs.EntityID) { +) (component.PlayerComponent, cardinal.EntityID) { playerComp := component.PlayerComponent{ PlayerID: playerID, LobbyID: lobbyID, @@ -841,7 +793,7 @@ func createPlayerEntity( // lobbyToDestroy holds info about a lobby to be destroyed. type lobbyToDestroy struct { - entityID ecs.EntityID + entityID cardinal.EntityID lobbyID string } @@ -852,14 +804,14 @@ func processTimedOutLobby( lobbyIndex *component.LobbyIndexComponent, lobbyID string, players []timedOutPlayer, -) ([]ecs.EntityID, *lobbyToDestroy) { - var playerEntities []ecs.EntityID +) ([]cardinal.EntityID, *lobbyToDestroy) { + var playerEntities []cardinal.EntityID lobbyEntityID, exists := lobbyIndex.GetEntityID(lobbyID) if !exists { return nil, nil } - lobbyEntity, err := state.Lobbies.GetByID(ecs.EntityID(lobbyEntityID)) + lobbyEntity, err := state.Lobbies.GetByID(cardinal.EntityID(lobbyEntityID)) if err != nil { return nil, nil } @@ -870,7 +822,7 @@ func processTimedOutLobby( for _, p := range players { lobby.RemovePlayerFromTeam(p.playerID, p.teamID) lobbyIndex.RemovePlayerFromLobby(p.playerID) - playerEntities = append(playerEntities, ecs.EntityID(p.playerEntityID)) + playerEntities = append(playerEntities, cardinal.EntityID(p.playerEntityID)) state.Logger().Info(). Str("lobby_id", lobbyID). @@ -886,7 +838,7 @@ func processTimedOutLobby( lobbyIndex.RemoveLobby(lobbyID, lobby.InviteCode) state.Logger().Info().Str("lobby_id", lobbyID).Msg("Lobby marked for deletion (empty after timeout)") state.LobbyDeletedEvents.Emit(LobbyDeletedEvent{LobbyID: lobbyID}) - return playerEntities, &lobbyToDestroy{entityID: ecs.EntityID(lobbyEntityID), lobbyID: lobbyID} + return playerEntities, &lobbyToDestroy{entityID: cardinal.EntityID(lobbyEntityID), lobbyID: lobbyID} } // Handle leader timeout @@ -914,7 +866,7 @@ func processHeartbeatCommands( now, timeout int64, ) { for cmd := range state.HeartbeatCmds.Iter() { - playerID := cmd.Persona() + playerID := cmd.Persona lobbyID, exists := lobbyIndex.GetPlayerLobby(playerID) state.Logger().Debug(). @@ -975,7 +927,7 @@ func areAllPlayersReady( if !exists { return false } - playerEntity, err := state.Players.GetByID(ecs.EntityID(playerEntityID)) + playerEntity, err := state.Players.GetByID(cardinal.EntityID(playerEntityID)) if err != nil { return false } @@ -999,7 +951,7 @@ func gatherLobbyPlayers( if !pExists { continue } - pEntity, pErr := state.Players.GetByID(ecs.EntityID(pEntityID)) + pEntity, pErr := state.Players.GetByID(cardinal.EntityID(pEntityID)) if pErr != nil { continue } @@ -1039,8 +991,8 @@ func processCreateLobbyCommands( now, timeout int64, ) { for cmd := range state.CreateLobbyCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload // Check if player is already in a lobby if _, exists := lobbyIndex.GetPlayerLobby(playerID); exists { @@ -1154,8 +1106,8 @@ func processJoinLobbyCommands( now, timeout int64, ) { for cmd := range state.JoinLobbyCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload // Check if player is already in a lobby if _, exists := lobbyIndex.GetPlayerLobby(playerID); exists { @@ -1178,7 +1130,7 @@ func processJoinLobbyCommands( continue } - lobbyEntity, err := state.Lobbies.GetByID(ecs.EntityID(lobbyEntityID)) + lobbyEntity, err := state.Lobbies.GetByID(cardinal.EntityID(lobbyEntityID)) if err != nil { emitJoinLobbyFailure(state, payload.RequestID, "lobby not found") continue @@ -1245,8 +1197,8 @@ func processJoinLobbyCommands( func processJoinTeamCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.JoinTeamCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1312,7 +1264,7 @@ func processJoinTeamCommands(state *LobbySystemState, lobbyIndex *component.Lobb var playerComp component.PlayerComponent playerEntityID, exists := lobbyIndex.GetPlayerEntityID(playerID) if exists { - if playerEntity, err := state.Players.GetByID(ecs.EntityID(playerEntityID)); err == nil { + if playerEntity, err := state.Players.GetByID(cardinal.EntityID(playerEntityID)); err == nil { playerComp = playerEntity.Player.Get() playerComp.TeamID = newTeam.TeamID playerEntity.Player.Set(playerComp) @@ -1345,8 +1297,8 @@ func processJoinTeamCommands(state *LobbySystemState, lobbyIndex *component.Lobb func processLeaveLobbyCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.LeaveLobbyCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1363,7 +1315,7 @@ func processLeaveLobbyCommands(state *LobbySystemState, lobbyIndex *component.Lo // Delete player entity playerEntityID, exists := lobbyIndex.GetPlayerEntityID(playerID) if exists { - state.Players.Destroy(ecs.EntityID(playerEntityID)) + state.Players.Destroy(cardinal.EntityID(playerEntityID)) } // Remove player from lobby - use index for O(1) team lookup, then O(players in team) removal @@ -1434,8 +1386,8 @@ func processLeaveLobbyCommands(state *LobbySystemState, lobbyIndex *component.Lo func processSetReadyCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.SetReadyCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1469,7 +1421,7 @@ func processSetReadyCommands(state *LobbySystemState, lobbyIndex *component.Lobb }) continue } - playerEntity, err := state.Players.GetByID(ecs.EntityID(playerEntityID)) + playerEntity, err := state.Players.GetByID(cardinal.EntityID(playerEntityID)) if err != nil { state.SetReadyResults.Emit(SetReadyResult{ RequestID: payload.RequestID, @@ -1505,8 +1457,8 @@ func processSetReadyCommands(state *LobbySystemState, lobbyIndex *component.Lobb func processKickPlayerCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.KickPlayerCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1554,7 +1506,7 @@ func processKickPlayerCommands(state *LobbySystemState, lobbyIndex *component.Lo // Delete player entity targetPlayerEntityID, exists := lobbyIndex.GetPlayerEntityID(payload.TargetPlayerID) if exists { - state.Players.Destroy(ecs.EntityID(targetPlayerEntityID)) + state.Players.Destroy(cardinal.EntityID(targetPlayerEntityID)) } // Remove player from lobby - use index for O(1) team lookup, then O(players in team) removal @@ -1586,8 +1538,8 @@ func processKickPlayerCommands(state *LobbySystemState, lobbyIndex *component.Lo func processTransferLeaderCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.TransferLeaderCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1655,8 +1607,8 @@ func processStartSessionCommands( config *component.ConfigComponent, ) { for cmd := range state.StartSessionCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1729,7 +1681,7 @@ func processStartSessionCommands( Project: config.LobbyWorld.Project, ShardID: config.LobbyWorld.ShardID, } - gameWorld.Send(&state.BaseSystemState, NotifySessionStartCommand{ + gameWorld.SendCommand(&state.BaseSystemState, NotifySessionStartCommand{ Lobby: lobby, LobbyWorld: lobbyWorld, }) @@ -1749,14 +1701,14 @@ func processStartSessionCommands( func processNotifySessionEndCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.NotifySessionEndCmds.Iter() { - payload := cmd.Payload() + payload := cmd.Payload lobbyEntityID, exists := lobbyIndex.GetEntityID(payload.LobbyID) if !exists { continue } - lobbyEntity, err := state.Lobbies.GetByID(ecs.EntityID(lobbyEntityID)) + lobbyEntity, err := state.Lobbies.GetByID(cardinal.EntityID(lobbyEntityID)) if err != nil { continue } @@ -1778,7 +1730,7 @@ func processNotifySessionEndCommands(state *LobbySystemState, lobbyIndex *compon if !pExists { continue } - playerEntity, pErr := state.Players.GetByID(ecs.EntityID(playerEntityID)) + playerEntity, pErr := state.Players.GetByID(cardinal.EntityID(playerEntityID)) if pErr != nil { continue } @@ -1792,16 +1744,14 @@ func processNotifySessionEndCommands(state *LobbySystemState, lobbyIndex *compon Msg("Session ended") // Emit broadcast event - state.SessionEndedEvents.Emit(SessionEndedEvent{ - LobbyID: payload.LobbyID, - }) + state.SessionEndedEvents.Emit(SessionEndedEvent(payload)) } } func processGenerateInviteCodeCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.GenerateInviteCodeCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1877,8 +1827,8 @@ func processGenerateInviteCodeCommands(state *LobbySystemState, lobbyIndex *comp func processUpdateSessionPassthroughCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.UpdateSessionPassthroughCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1928,8 +1878,8 @@ func processUpdateSessionPassthroughCommands(state *LobbySystemState, lobbyIndex func processUpdatePlayerPassthroughCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.UpdatePlayerPassthroughCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) if result == nil { @@ -1952,7 +1902,7 @@ func processUpdatePlayerPassthroughCommands(state *LobbySystemState, lobbyIndex }) continue } - playerEntity, err := state.Players.GetByID(ecs.EntityID(playerEntityID)) + playerEntity, err := state.Players.GetByID(cardinal.EntityID(playerEntityID)) if err != nil { state.UpdatePlayerPassthroughResults.Emit(UpdatePlayerPassthroughResult{ RequestID: payload.RequestID, @@ -1988,8 +1938,8 @@ func processUpdatePlayerPassthroughCommands(state *LobbySystemState, lobbyIndex func processGetPlayerCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.GetPlayerCmds.Iter() { - callerID := cmd.Persona() - payload := cmd.Payload() + callerID := cmd.Persona + payload := cmd.Payload // Determine target player ID (self if empty) targetPlayerID := payload.PlayerID @@ -2008,7 +1958,7 @@ func processGetPlayerCommands(state *LobbySystemState, lobbyIndex *component.Lob continue } - playerEntity, err := state.Players.GetByID(ecs.EntityID(playerEntityID)) + playerEntity, err := state.Players.GetByID(cardinal.EntityID(playerEntityID)) if err != nil { state.GetPlayerResults.Emit(GetPlayerResult{ RequestID: payload.RequestID, @@ -2031,8 +1981,8 @@ func processGetPlayerCommands(state *LobbySystemState, lobbyIndex *component.Lob func processGetAllPlayersCommands(state *LobbySystemState, lobbyIndex *component.LobbyIndexComponent) { for cmd := range state.GetAllPlayersCmds.Iter() { - playerID := cmd.Persona() - payload := cmd.Payload() + playerID := cmd.Persona + payload := cmd.Payload // Get caller's lobby result := getPlayerLobby(playerID, lobbyIndex, &state.Lobbies) @@ -2054,7 +2004,7 @@ func processGetAllPlayersCommands(state *LobbySystemState, lobbyIndex *component if !exists { continue } - playerEntity, pErr := state.Players.GetByID(ecs.EntityID(playerEntityID)) + playerEntity, pErr := state.Players.GetByID(cardinal.EntityID(playerEntityID)) if pErr != nil { continue } @@ -2111,12 +2061,12 @@ type HeartbeatSystemState struct { } // HeartbeatSystem processes heartbeat commands and removes stale players. -func HeartbeatSystem(state *HeartbeatSystemState) error { +func HeartbeatSystem(state *HeartbeatSystemState) { now := state.Timestamp().Unix() // Get lobby index var lobbyIndex component.LobbyIndexComponent - var lobbyIndexEntityID ecs.EntityID + var lobbyIndexEntityID cardinal.EntityID for entityID, idx := range state.LobbyIndexes.Iter() { lobbyIndex = idx.Index.Get() lobbyIndexEntityID = entityID @@ -2154,7 +2104,7 @@ func HeartbeatSystem(state *HeartbeatSystemState) error { if lobbyIndexEntity, err := state.LobbyIndexes.GetByID(lobbyIndexEntityID); err == nil { lobbyIndexEntity.Index.Set(lobbyIndex) } - return nil + return } // Group timed out players by lobby for efficient processing @@ -2162,7 +2112,7 @@ func HeartbeatSystem(state *HeartbeatSystemState) error { // Process each affected lobby var lobbiesToDestroy []lobbyToDestroy - var playerEntitiesToDestroy []ecs.EntityID + var playerEntitiesToDestroy []cardinal.EntityID for lobbyID, players := range timedOutByLobby { playerEntities, toDestroy := processTimedOutLobby(state, &lobbyIndex, lobbyID, players) playerEntitiesToDestroy = append(playerEntitiesToDestroy, playerEntities...) @@ -2188,6 +2138,4 @@ func HeartbeatSystem(state *HeartbeatSystemState) error { if lobbyIndexEntity, err := state.LobbyIndexes.GetByID(lobbyIndexEntityID); err == nil { lobbyIndexEntity.Index.Set(lobbyIndex) } - - return nil } diff --git a/pkg/micro/service_internal_test.go b/pkg/micro/service_internal_test.go index 05f5c0cc5..2461a59e7 100644 --- a/pkg/micro/service_internal_test.go +++ b/pkg/micro/service_internal_test.go @@ -164,9 +164,8 @@ func randEndpointName(prng *rand.Rand) string { // ------------------------------------------------------------------------------------------------- // Model-based fuzzing endpoint registration // ------------------------------------------------------------------------------------------------- -// This test verifies Service endpoint registration correctness using model-based testing. It -// compares our implementation against a map[string]bool as the model by applying random sequences -// of AddEndpoint and AddGroup().AddEndpoint operations and asserting equivalence. +// This test verifies the endpoint registration implementation correctness by applying random +// sequences of operations and comparing it against a regular Go map as the model. // ------------------------------------------------------------------------------------------------- func TestService_RegisterModelFuzz(t *testing.T) { @@ -174,11 +173,17 @@ func TestService_RegisterModelFuzz(t *testing.T) { prng := testutils.NewRand(t) const ( - opsMax = 1 << 15 // 4096 iterations - maxEndpoints = 100 // limit unique endpoint names to increase collision chance - maxGroups = 10 // limit unique group names to increase collision chance + opsMax = 1 << 15 // 4096 iterations + maxEndpoints = 100 // limit unique endpoint names to increase collision chance + maxGroups = 10 // limit unique group names to increase collision chance + opAddEndpoint = "addEndpoint" + opAddGroupEndpoint = "addGroupEndpoint" ) + // Randomize operation weights. + operations := []string{opAddEndpoint, opAddGroupEndpoint} + weights := testutils.RandOpWeights(prng, operations) + impl, client := newTestService(t, prng) model := make(map[string]bool) // tracks registered endpoint names @@ -188,9 +193,9 @@ func TestService_RegisterModelFuzz(t *testing.T) { dummyHandler := func(_ context.Context, _ *Request) *Response { return nil } for range opsMax { - op := testutils.RandWeightedOp(prng, registrationOps) + op := testutils.RandWeightedOp(prng, weights) switch op { - case reg_addEndpoint: + case opAddEndpoint: name := "endpoint-" + strconv.Itoa(prng.IntN(maxEndpoints)) err := impl.AddEndpoint(name, dummyHandler) @@ -206,7 +211,7 @@ func TestService_RegisterModelFuzz(t *testing.T) { model[name] = true } - case reg_addGroupEndpoint: + case opAddGroupEndpoint: groupName := "group-" + strconv.Itoa(prng.IntN(maxGroups)) endpointName := "endpoint-" + strconv.Itoa(prng.IntN(maxEndpoints)) fullName := groupName + "." + endpointName @@ -238,15 +243,6 @@ func TestService_RegisterModelFuzz(t *testing.T) { } } -type registrationOp uint8 - -const ( - reg_addEndpoint registrationOp = 60 - reg_addGroupEndpoint registrationOp = 40 -) - -var registrationOps = []registrationOp{reg_addEndpoint, reg_addGroupEndpoint} - func newTestService(t *testing.T, prng *rand.Rand) (*Service, *Client) { t.Helper() diff --git a/pkg/micro/shard.go b/pkg/micro/shard.go deleted file mode 100644 index f9284435c..000000000 --- a/pkg/micro/shard.go +++ /dev/null @@ -1,268 +0,0 @@ -// Package micro provides distributed shard management functionality for the ECS framework. -// It implements a leader-follower model with deterministic replay for maintaining consistency -// across distributed game instances. -package micro - -import ( - "context" - "fmt" - - "github.com/argus-labs/world-engine/pkg/assert" - "github.com/argus-labs/world-engine/pkg/telemetry" - "github.com/nats-io/nats.go/jetstream" - "github.com/rotisserie/eris" -) - -// ShardEngine defines the interface that must be implemented by specific shard types. -// It provides the core functionality for initializing, processing inputs, and maintaining state. -type ShardEngine interface { - // Init initializes the shard's initial state. - Init() error - - // Tick processes the given input and advances the shard state. - Tick(Tick) error - - // Replay processes the given input during state reconstruction. - // This should produce the same result as Tick for deterministic replay. - Replay(Tick) error - - // StateHash returns a hash of the current shard state for consistency verification. - StateHash() ([]byte, error) - - // Snapshot captures the current shard state and returns it as serialized bytes. - Snapshot() ([]byte, error) - - // Restore restores the shard state from the given serialized bytes. - Restore(data []byte) error - - // Reset resets the engine to its clean initial state. - Reset() -} - -// Shard manages the shard lifecycle depending on the operation mode. -type Shard struct { - // Specific shard implementations, e.g. Cardinal, Registry, etc. - base ShardEngine - - // Networking and epoch JetStream management. - client *Client // NATS client - js jetstream.JetStream // JetStream client - stream jetstream.Stream // The epoch stream - consumer jetstream.Consumer // Reusable JetStream consumer - subject string // Epoch subject - commands commandManager // Receives commands - - // Epoch and tick book-keeping. - mode ShardMode // Shard mode - epochHeight uint64 // Epoch height - tickHeight uint64 // Tick height - frequency uint32 // Epoch frequency (number of ticks per epoch) - tickRate float64 // Tick rate (number of ticks per second) - ticks []Tick // List of ticks in the current epoch - - // Snapshots. - snapshotStorage SnapshotStorage // Snapshot storage - snapshotFrequency uint32 // Snapshot every N epochs - - // Utilities. - tel *telemetry.Telemetry - disablePersona bool -} - -// NewShard creates a new shard instance with the given base implementation and options. -func NewShard(base ShardEngine, opts ShardOptions) (*Shard, error) { - config, err := loadShardConfig() - if err != nil { - return nil, eris.Wrap(err, "failed to load shard config") - } - - options := newDefaultShardOptions() - config.applyToOptions(&options) - options.apply(opts) - if err := options.validate(); err != nil { - return nil, eris.Wrap(err, "invalid shard options") - } - - subject := Endpoint(options.Address, "epoch") - streamName := formatStreamName(options.Address) - - js, err := jetstream.New(options.Client.Conn) - if err != nil { - return nil, eris.Wrap(err, "failed to create jetstream client") - } - - streamConfig := jetstream.StreamConfig{ - Name: streamName, - Subjects: []string{subject}, - Retention: jetstream.LimitsPolicy, - Storage: jetstream.FileStorage, - Replicas: 1, - MaxBytes: int64(options.EpochStreamMaxBytes), - } - - logger := options.Telemetry.GetLogger("shard") - - // Try to get existing stream first, if it exists we'll update it - stream, err := js.Stream(context.Background(), streamName) //nolint: staticcheck, wastedassign // this is ok - if err != nil { - // Stream doesn't exist, try to create it - logger.Debug().Str("stream", streamConfig.Name).Msg("creating epoch stream") - stream, err = js.CreateStream(context.Background(), streamConfig) - if err != nil { - return nil, eris.Wrapf(err, "failed to create epoch stream (name=%s, subjects=%v, maxBytes=%d)", - streamConfig.Name, streamConfig.Subjects, streamConfig.MaxBytes) - } - } else { - // Stream exists, update it with new config - logger.Debug().Str("stream", streamConfig.Name).Msg("updating epoch stream") - stream, err = js.UpdateStream(context.Background(), streamConfig) - if err != nil { - return nil, eris.Wrapf(err, "failed to update existing epoch stream (name=%s, subjects=%v, maxBytes=%d)", - streamConfig.Name, streamConfig.Subjects, streamConfig.MaxBytes) - } - } - - consumer, err := stream.CreateConsumer(context.Background(), jetstream.ConsumerConfig{ - FilterSubject: subject, - DeliverPolicy: jetstream.DeliverAllPolicy, - AckPolicy: jetstream.AckExplicitPolicy, - }) - if err != nil { - return nil, eris.Wrap(err, "failed to create stream consumer") - } - - storage, err := createSnapshotStorage(options) - if err != nil { - return nil, eris.Wrap(err, "failed to create snapshot storage") - } - - s := &Shard{ - base: base, - - client: options.Client, - js: js, - stream: stream, - consumer: consumer, - subject: subject, - - mode: options.Mode, - epochHeight: 0, - tickHeight: 0, - frequency: options.EpochFrequency, - tickRate: options.TickRate, - ticks: make([]Tick, 0, int(options.EpochFrequency)), - snapshotStorage: storage, - snapshotFrequency: options.SnapshotFrequency, - - tel: options.Telemetry, - disablePersona: options.DisablePersona, - } - - commands, err := newCommandManager(s, options) - if err != nil { - return nil, eris.Wrap(err, "failed to create command manager") - } - s.commands = commands - - return s, nil -} - -// Run starts the shard's main execution loop. -func (s *Shard) Run(ctx context.Context) error { - if err := s.init(); err != nil { - return eris.Wrap(err, "failed to initialize shard") - } - - if err := s.sync(); err != nil { - return eris.Wrap(err, "failed to sync shard state") - } - - // Core shard loop based on the mode. - logger := s.tel.GetLogger("shard") - logger.Info().Str("mode", s.mode.String()).Msg("starting core shard loop") - switch s.mode { - case ModeLeader: - return s.runLeader(ctx) - case ModeFollower: - return s.runFollower(ctx) - case ModeUndefined: - assert.That(true, "unreachable") - } - - return nil -} - -// Mode returns the current operating mode of the shard (leader, follower, or undefined). -func (s *Shard) Mode() ShardMode { - return s.mode -} - -// Base returns the underlying shard engine implementation. -func (s *Shard) Base() ShardEngine { - return s.base -} - -func (s *Shard) IsDisablePersona() bool { - return s.disablePersona -} - -// RegisterCommand registers a command type T with the shard's command manager. -// This allows the shard to receive and process commands of the specified type. -func RegisterCommand[T ShardCommand](s *Shard) error { - return registerCommand[T](&s.commands) -} - -// CurrentTick returns the current tick information. -// Returns an error if called during the inter-tick period, whic happens after an epoch is published -// but before the next tick is started. -func (s *Shard) CurrentTick() (Tick, error) { - if len(s.ticks) == 0 { - return Tick{}, eris.New("cannot get current tick during inter-tick period") - } - - return s.ticks[len(s.ticks)-1], nil -} - -func createSnapshotStorage(opts ShardOptions) (SnapshotStorage, error) { - switch opts.SnapshotStorageType { - case SnapshotStorageNop: - return NewNopSnapshotStorage(), nil - - case SnapshotStorageJetStream: - jsOpts, err := newJetstreamSnapshotStorageOptions() - if err != nil { - return nil, eris.Wrap(err, "failed to initialize JetStream storage options") - } - jsOpts.apply(opts) - if err := jsOpts.validate(); err != nil { - return nil, eris.Wrap(err, "failed to validate JetStream storage options") - } - return NewJetStreamSnapshotStorage(jsOpts) - - case SnapshotStorageUndefined: - default: - } - return nil, eris.New("invalid snapshot storage type") -} - -// ------------------------------------------------------------------------------------------------- -// Utilities -// ------------------------------------------------------------------------------------------------- - -// formatStreamName creates a unique stream name based on the service address. -func formatStreamName(address *ServiceAddress) string { - return fmt.Sprintf("%s_%s_%s_epoch", - address.GetOrganization(), address.GetProject(), address.GetServiceId()) -} - -// epochPublishOptions creates publish options for epoch messages with deduplication and ordering. -// It uses ExpectLastSequence for ordering which is persisted with stream state and survives NATS restarts, -// unlike ExpectLastMsgID which relies on an in-memory deduplication cache. -// epochHeight is used as the expected last sequence since epochs map 1:1 with stream sequences. -func epochPublishOptions(subject string, epochHeight uint64) []jetstream.PublishOpt { - opts := []jetstream.PublishOpt{jetstream.WithMsgID(fmt.Sprintf("%s-%d", subject, epochHeight))} - if epochHeight > 0 { - opts = append(opts, jetstream.WithExpectLastSequence(epochHeight)) - } - return opts -} diff --git a/pkg/micro/shard_command.go b/pkg/micro/shard_command.go deleted file mode 100644 index 6ca8550d0..000000000 --- a/pkg/micro/shard_command.go +++ /dev/null @@ -1,195 +0,0 @@ -package micro - -import ( - "context" - - "buf.build/go/protovalidate" - "github.com/argus-labs/world-engine/pkg/telemetry" - iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" - "github.com/goccy/go-json" - "github.com/rotisserie/eris" - "google.golang.org/grpc/codes" -) - -// commandManager manages command registration, queuing, and routing for the shard. -type commandManager struct { - *Service // Embedded service for handling network requests - shard *Shard // Reference to the shard - address *ServiceAddress // This shard's address for command validation - channels map[string]commandChannel // Map of command names to their respective channels - buffer []Command // Reusable buffer for collecting commands from all channels - tel *telemetry.Telemetry // Telemetry instance for logging and metrics -} - -// newCommandManager creates a new command manager with the specified options. -func newCommandManager(shard *Shard, opt ShardOptions) (commandManager, error) { - service, err := NewService(opt.Client, opt.Address, opt.Telemetry) - if err != nil { - return commandManager{}, eris.Wrap(err, "failed to create service") - } - - return commandManager{ - Service: service, - shard: shard, - address: opt.Address, - channels: make(map[string]commandChannel), - buffer: make([]Command, 0), - tel: opt.Telemetry, - }, nil -} - -// Has returns true if the command has been registered. -func (c *commandManager) Has(name string) bool { - _, exists := c.channels[name] - return exists -} - -// Enqueue receives a command from an external source and stores it in the corresponding channel for -// the given command type. The command name is extracted from the command and used to route -// the command to the appropriate channel. -// Returns an error if the command type is not registered or if validation fails. -func (c *commandManager) Enqueue(command *iscv1.Command) error { - if err := c.validateCommand(command); err != nil { - return eris.Wrap(err, "command validation failed") - } - - name := command.GetName() - channel, exists := c.channels[name] - if !exists { - return eris.Errorf("unregistered command: %s", name) - } - - return channel.enqueue(command) -} - -// validateCommand validates a command's structure and destination address. -func (c *commandManager) validateCommand(command *iscv1.Command) error { - if err := protovalidate.Validate(command); err != nil { - return eris.Wrap(err, "failed to validate command") - } - - if String(c.address) != String(command.GetAddress()) { - return eris.New("command address doesn't match shard address") - } - - return nil -} - -// GetTickData retrieves all pending commands from all registered channels and returns them as a -// slice. It reuses an internal buffer for efficiency and drains all channels completely. -// The returned slice is valid until the next call to GetCommands. -func (c *commandManager) GetTickData() TickData { - // Clear buffers from previous tick to reuse the slices. - c.buffer = c.buffer[:0] - - for _, ch := range c.channels { - n := ch.length() - for range n { - c.buffer = append(c.buffer, ch.dequeue()) - } - } - - return TickData{Commands: c.buffer} -} - -// registerCommand registers a command type with the manager and creates network endpoints for leaders. -func registerCommand[T ShardCommand](c *commandManager) error { - var zero T - name := zero.Name() - - // If command is already registered, return early (idempotent). - if _, exists := c.channels[name]; exists { - return nil - } - - c.channels[name] = newChannel[T]() - - if c.shard.Mode() != ModeLeader { - return nil - } - - // Only leader nodes have to register the command handler endpoints. - return c.AddGroup("command").AddEndpoint(name, func(ctx context.Context, req *Request) *Response { - // Check if shard is shutting down. - select { - case <-ctx.Done(): - return NewErrorResponse(req, eris.Wrap(ctx.Err(), "context cancelled"), codes.Canceled) - default: - // Continue processing. - } - - command := &iscv1.Command{} - if err := req.Payload.UnmarshalTo(command); err != nil { - return NewErrorResponse(req, eris.Wrap(err, "failed to parse request payload"), codes.InvalidArgument) - } - - if err := c.Enqueue(command); err != nil { - return NewErrorResponse(req, eris.Wrap(err, "failed to enqueue command"), codes.InvalidArgument) - } - - return NewSuccessResponse(req, nil) - }) -} - -// ------------------------------------------------------------------------------------------------- -// Generic command channel -// ------------------------------------------------------------------------------------------------- - -// commandChannel defines the interface for command queuing operations. -// It provides methods to enqueue commands, dequeue them, and check the queue length. -type commandChannel interface { - enqueue(*iscv1.Command) error - dequeue() Command - length() int -} - -var _ commandChannel = newChannel[ShardCommand]() - -// Channel is a generic buffered channel for handling commands of a specific type. -// It implements the commandChannel interface and provides type-safe command processing. -type Channel[T ShardCommand] chan Command - -// newChannel creates a new buffered command channel with a default buffer size. -func newChannel[T ShardCommand]() Channel[T] { - const defaultChannelBufferSize = 1024 - return make(chan Command, defaultChannelBufferSize) -} - -// enqueue validates and adds a command to the channel. It performs type checking to ensure the -// command matches the expected type T, unmarshals the command payload, and sends it to the channel. -// Returns an error if validation fails or marshaling/unmarshaling operations fail. -func (c Channel[T]) enqueue(command *iscv1.Command) error { - var zero T - - if command.GetName() != zero.Name() { - return eris.Errorf("mismatched command name, expected %s, actual %s", zero.Name(), command.GetName()) - } - - jsonBytes, err := command.GetPayload().MarshalJSON() - if err != nil { - return eris.Wrap(err, "failed to marshal command payload to json") - } - - if err := json.Unmarshal(jsonBytes, &zero); err != nil { - return eris.Wrap(err, "failed to unmarshal to command") - } - - c <- Command{ - Name: zero.Name(), - Address: command.GetAddress(), - Persona: command.GetPersona().GetId(), - Payload: zero, - } - return nil -} - -// dequeue removes and returns the next command from the channel. -// This operation blocks if the channel is empty until a command becomes available. -func (c Channel[T]) dequeue() Command { - return <-c -} - -// length returns the current number of commands waiting in the channel buffer. -func (c Channel[T]) length() int { - return len(c) -} diff --git a/pkg/micro/shard_command_handler_internal_test.go b/pkg/micro/shard_command_handler_internal_test.go deleted file mode 100644 index df02e0a5d..000000000 --- a/pkg/micro/shard_command_handler_internal_test.go +++ /dev/null @@ -1,296 +0,0 @@ -package micro - -// import ( -// "testing" -// -// "github.com/argus-labs/world-engine/pkg/cardinal/protoutil" -// "github.com/argus-labs/world-engine/pkg/cardinal/testutils" -// microtestutils "github.com/argus-labs/world-engine/pkg/micro/testutils" -// "github.com/argus-labs/world-engine/pkg/sign" -// "github.com/argus-labs/world-engine/pkg/telemetry" -// iscv1 "github.com/argus-labs/world-engine/proto/gen/go/isc/v1" -// microv1 "github.com/argus-labs/world-engine/proto/gen/go/micro/v1" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// ) -// -// func TestCommandHandler(t *testing.T) { -// t.Parallel() -// -// tests := []struct { -// name string -// setupManager func(t *testing.T) *commandManager -// createCommand func(t *testing.T) *iscv1.Command -// wantErr bool -// errMsg string -// verifyEnqueued bool -// expectedCommands int -// }{ -// { -// name: "successful command enqueue with valid payload", -// setupManager: func(t *testing.T) *commandManager { -// m := createTestCommandManagerWithShutdown(t, false) -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// return m -// }, -// createCommand: createValidCommand, -// wantErr: false, -// verifyEnqueued: true, -// expectedCommands: 1, -// }, -// { -// name: "unregistered command returns error", -// setupManager: func(t *testing.T) *commandManager { -// return createTestCommandManagerWithShutdown(t, false) // Don't register command -// }, -// createCommand: createValidCommand, -// wantErr: true, -// errMsg: "unregistered command", -// verifyEnqueued: false, -// expectedCommands: 0, -// }, -// { -// name: "command unmarshaling failure", -// setupManager: func(t *testing.T) *commandManager { -// m := createTestCommandManagerWithShutdown(t, false) -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// return m -// }, -// createCommand: func(t *testing.T) *iscv1.Command { -// return &iscv1.Command{ -// CommandBytes: []byte("invalid-proto-bytes"), -// Signature: make([]byte, 64), -// AuthInfo: &iscv1.AuthInfo{ -// SignerAddress: make([]byte, 32), -// }, -// } -// }, -// wantErr: true, -// errMsg: "failed to unmarshal command bytes", -// verifyEnqueued: false, -// expectedCommands: 0, -// }, -// } -// -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// t.Parallel() -// -// manager := tt.setupManager(t) -// cmd := tt.createCommand(t) -// -// // Test direct enqueue (this is what the handler calls) -// err := manager.Enqueue(cmd) -// -// if tt.wantErr { -// require.Error(t, err) -// assert.Contains(t, err.Error(), tt.errMsg) -// } else { -// require.NoError(t, err) -// } -// -// // Verify enqueue state -// tickData := manager.GetTickData() -// assert.Len(t, tickData.Commands, tt.expectedCommands) -// -// if tt.verifyEnqueued && tt.expectedCommands > 0 { -// // Verify the command type and content -// command, ok := tickData.Commands[0].Command.Body.Payload.(testutils.TestCommand) -// assert.True(t, ok) -// assert.Equal(t, 42, command.Value) -// } -// }) -// } -// } -// -// // createTestCommandManagerWithShutdown creates a test command manager with configurable shutdown state. -// func createTestCommandManagerWithShutdown( -// t *testing.T, -// closed bool, //nolint:unparam // tests that use this aren't impelemented yet -// ) *commandManager { -// t.Helper() -// -// // Create a test NATS server and client -// natsServer := microtestutils.NewNATS(t) -// -// // Create a test client with NATS URL pointing to our test server -// client, err := NewClient(WithNATSConfig(NATSConfig{ -// URL: natsServer.Server.ClientURL(), -// })) -// require.NoError(t, err) -// -// // Create a test service address -// address := &ServiceAddress{ -// Realm: microv1.ServiceAddress_REALM_INTERNAL, -// Organization: "test-org", -// Project: "test-project", -// ServiceId: "test-service", -// } -// -// // Create command manager with telemetry -// tel, err := telemetry.New(telemetry.Options{ServiceName: "test-command-manager"}) -// require.NoError(t, err) -// m, err := newCommandManager(ShardOptions{ -// Client: client, -// Address: address, -// Mode: ModeFollower, -// Telemetry: &tel, -// }) -// require.NoError(t, err) -// -// return &m -// } -// -// // createValidCommand helper creates a valid signed command for testing. -// func createValidCommand(t *testing.T) *iscv1.Command { -// cmd := testutils.TestCommand{Value: 42} -// -// // Use protoutil to marshal command -// pbCommand, err := protoutil.MarshalCommand(cmd) -// require.NoError(t, err) -// -// // Create a test signer for generating valid signatures -// signer, err := sign.NewSigner("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", 0) -// require.NoError(t, err) -// -// // Add required fields for the command body -// pbCommand.Persona = &iscv1.Persona{Id: "test-persona"} -// pbCommand.Address = µv1.ServiceAddress{ -// Realm: microv1.ServiceAddress_REALM_WORLD, -// Organization: "test-org", -// Project: "test-project", -// ServiceId: "test-service", -// } -// -// // Use actual signing functionality to create properly signed command -// signedCmd, err := signer.SignCommand(pbCommand, iscv1.AuthInfo_MODE_PERSONA) -// require.NoError(t, err) -// -// return signedCmd -// } -// -// func TestCommandRegistration(t *testing.T) { -// t.Parallel() -// -// t.Run("command is registered correctly", func(t *testing.T) { -// t.Parallel() -// manager := createTestCommandManagerWithShutdown(t, false) -// -// // Register a command -// err := registerCommand[testutils.TestCommand](manager) -// require.NoError(t, err) -// -// // Verify the command is registered -// assert.True(t, manager.Has("test-command")) -// }) -// -// t.Run("follower mode registers command channel", func(t *testing.T) { -// t.Parallel() -// manager := createTestCommandManagerWithShutdown(t, false) -// -// // Register a command in follower mode -// err := registerCommand[testutils.TestCommand](manager) -// require.NoError(t, err) -// -// // Verify the command channel is registered -// assert.True(t, manager.Has("test-command")) -// }) -// -// t.Run("double registration fails for leader mode", func(t *testing.T) { -// t.Parallel() -// manager := createTestCommandManagerWithShutdown(t, false) -// -// // First registration should succeed -// err := registerCommand[testutils.TestCommand](manager) -// require.NoError(t, err) -// -// // Second registration should fail due to endpoint conflict -// err = registerCommand[testutils.TestCommand](manager) -// require.Error(t, err) -// assert.Contains(t, err.Error(), "endpoint already exists") -// }) -// -// t.Run("double registration succeeds for follower mode", func(t *testing.T) { -// t.Parallel() -// manager := createTestCommandManagerWithShutdown(t, false) -// -// // First registration should succeed -// err := registerCommand[testutils.TestCommand](manager) -// require.NoError(t, err) -// -// // Second registration should also succeed (no endpoints registered) -// err = registerCommand[testutils.TestCommand](manager) -// require.NoError(t, err) -// }) -// } -// -// // TestCommandVerification tests the command verification functionality. -// // This is currently a stub test since verification is commented out in the handler. -// func TestCommandVerification(t *testing.T) { -// t.Parallel() -// -// t.Run("command verification stub", func(t *testing.T) { -// t.Parallel() -// -// // TODO: This test is a stub for command verification functionality -// // that is currently commented out in registerCommand (lines 112-115). -// // When verification is re-enabled, this test should be expanded to cover: -// // -// // 1. Valid signature verification passes -// // 2. Invalid signature verification fails -// // 3. Missing signature verification fails -// // 4. Expired signature verification fails -// // 5. Signature from unregistered persona fails -// // -// // The verification logic should call something like: -// // if err := s.personas.VerifySignedCommand(command, true); err != nil { -// // return handleSpanError(span, req, eris.Wrap(err, "failed to verify signed command")) -// // } -// -// manager := createTestCommandManagerWithShutdown(t, false) -// err := registerCommand[testutils.TestCommand](manager) -// require.NoError(t, err) -// -// // Create a valid command -// cmd := createValidCommand(t) -// -// // For now, just test that enqueue works without verification -// err = manager.Enqueue(cmd) -// require.NoError(t, err, "Command should enqueue successfully without verification") -// -// // Verify command was enqueued -// tickData := manager.GetTickData() -// assert.Len(t, tickData.Commands, 1) -// -// // When verification is re-enabled, add tests like: -// // t.Run("invalid signature fails verification", func(t *testing.T) { ... }) -// // t.Run("expired signature fails verification", func(t *testing.T) { ... }) -// // etc. -// }) -// -// t.Run("verification placeholder - invalid signature", func(t *testing.T) { -// t.Parallel() -// -// // TODO: Implement when verification is re-enabled -// // This should test that commands with invalid signatures are rejected -// t.Skip("Command verification is currently disabled - implement when re-enabled") -// }) -// -// t.Run("verification placeholder - expired signature", func(t *testing.T) { -// t.Parallel() -// -// // TODO: Implement when verification is re-enabled -// // This should test that commands with expired signatures are rejected -// t.Skip("Command verification is currently disabled - implement when re-enabled") -// }) -// -// t.Run("verification placeholder - unregistered persona", func(t *testing.T) { -// t.Parallel() -// -// // TODO: Implement when verification is re-enabled -// // This should test that commands from unregistered personas are rejected -// t.Skip("Command verification is currently disabled - implement when re-enabled") -// }) -// } diff --git a/pkg/micro/shard_command_internal_test.go b/pkg/micro/shard_command_internal_test.go deleted file mode 100644 index 607c0b0bc..000000000 --- a/pkg/micro/shard_command_internal_test.go +++ /dev/null @@ -1,361 +0,0 @@ -package micro - -// TODO: fix. -// TODO: add tests to verify tick replay uses the correct timestamp depending on the shard mode. - -// import ( -// "strings" -// "sync" -// "testing" -// -// "github.com/argus-labs/world-engine/pkg/cardinal/testutils" -// "github.com/argus-labs/world-engine/pkg/ecs" -// microtestutils "github.com/argus-labs/world-engine/pkg/micro/testutils" -// "github.com/argus-labs/world-engine/pkg/sign" -// "github.com/argus-labs/world-engine/pkg/telemetry" -// iscv1 "github.com/argus-labs/world-engine/proto/gen/go/isc/v1" -// "github.com/goccy/go-json" -// "github.com/stretchr/testify/assert" -// "github.com/stretchr/testify/require" -// "google.golang.org/protobuf/types/known/structpb" -// ) -// -// func TestCommandManager_Has(t *testing.T) { -// t.Parallel() -// -// t.Run("returns true for registered command", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// got := m.Has("test-command") -// assert.True(t, got, "Has should return true for registered command") -// }) -// -// t.Run("returns false for unregistered command", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// got := m.Has("test-command") -// assert.False(t, got, "Has should return false for unregistered command") -// }) -// -// t.Run("returns true after multiple registrations", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// err = registerCommand[testutils.TestCommand](m) // Second registration should be idempotent -// require.NoError(t, err, "Second registration should be idempotent") -// got := m.Has("test-command") -// assert.True(t, got, "Has should return true after registrations") -// }) -// } -// -// func TestCommandManager_Enqueue(t *testing.T) { -// t.Parallel() -// -// t.Run("bulk enqueue same command", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// -// // Enqueue 100 commands -// for i := range 100 { -// cmd := testutils.TestCommand{Value: i} -// signedCmd := createAndSignCommand(t, cmd) -// require.NoError(t, m.Enqueue(signedCmd)) -// } -// -// // Verify all commands were enqueued -// tickData := m.GetTickData() -// -// require.Len(t, tickData.Commands, 100) -// for i, cmd := range tickData.Commands { -// testCmd, ok := cmd.Command.Body.Payload.(testutils.TestCommand) -// assert.True(t, ok) -// assert.Equal(t, i, testCmd.Value) -// } -// }) -// -// t.Run("enqueue to unregistered command returns error", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// cmd := testutils.TestCommand{Value: 42} -// signedCmd := createAndSignCommand(t, cmd) -// -// // Test should return error when trying to enqueue an unregistered command -// err := m.Enqueue(signedCmd) -// require.Error(t, err) -// }) -// -// t.Run("concurrent multi-type commands", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// err = registerCommand[testutils.AnotherTestCommand](m) -// require.NoError(t, err) -// -// const numGoroutines = 10 -// const commandsPerGoroutine = 10 -// var wg sync.WaitGroup -// -// // Launch multiple goroutines that enqueue different command types -// for i := range numGoroutines { -// wg.Add(1) -// go func(goroutineID int) { -// defer wg.Done() -// for j := range commandsPerGoroutine { -// // Alternate between command types -// if goroutineID%2 == 0 { -// cmd := testutils.TestCommand{Value: goroutineID*commandsPerGoroutine + j} -// signedCmd := createAndSignCommand(t, cmd) -// assert.NoError(t, m.Enqueue(signedCmd)) -// } else { -// cmd := testutils.AnotherTestCommand{Value: goroutineID*commandsPerGoroutine + j} -// signedCmd := createAndSignCommand(t, cmd) -// assert.NoError(t, m.Enqueue(signedCmd)) -// } -// } -// }(i) -// } -// -// wg.Wait() -// -// // Verify all commands were enqueued -// tickData := m.GetTickData() -// -// expectedTotal := numGoroutines * commandsPerGoroutine -// require.Len(t, tickData.Commands, expectedTotal) -// -// // Count commands of each type -// testCmdCount := 0 -// anotherCmdCount := 0 -// for _, cmd := range tickData.Commands { -// switch cmd.Command.Body.Payload.(type) { -// case testutils.TestCommand: -// testCmdCount++ -// case testutils.AnotherTestCommand: -// anotherCmdCount++ -// default: -// t.Errorf("unexpected command type: %T", cmd.Command.Body.Payload) -// } -// } -// -// // Verify we got the expected number of each command type -// // Half the goroutines use each type -// expectedPerType := (numGoroutines / 2) * commandsPerGoroutine -// assert.Equal(t, expectedPerType, testCmdCount) -// assert.Equal(t, expectedPerType, anotherCmdCount) -// }) -// } -// -// func TestCommandManager_GetTickData(t *testing.T) { -// t.Parallel() -// -// tests := []struct { -// name string -// setup func(*commandManager) -// verify func(*testing.T, TickData) -// }{ -// { -// name: "empty manager returns empty slice", -// verify: func(t *testing.T, tickData TickData) { -// assert.Empty(t, tickData.Commands) -// assert.NotNil(t, tickData.Commands) -// }, -// }, -// { -// name: "get commands from single channel", -// setup: func(m *commandManager) { -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// for i := range 5 { -// cmd := testutils.TestCommand{Value: i} -// signedCmd := createAndSignCommand(t, cmd) -// require.NoError(t, m.Enqueue(signedCmd)) -// } -// }, -// verify: func(t *testing.T, tickData TickData) { -// require.Len(t, tickData.Commands, 5) -// for i, cmd := range tickData.Commands { -// testCmd, ok := cmd.Command.Body.Payload.(testutils.TestCommand) -// assert.True(t, ok) -// assert.Equal(t, i, testCmd.Value) -// } -// }, -// }, -// { -// name: "get commands from multiple channels", -// setup: func(m *commandManager) { -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// err = registerCommand[testutils.AnotherTestCommand](m) -// require.NoError(t, err) -// for i := range 3 { -// cmd1 := testutils.TestCommand{Value: i} -// cmd2 := testutils.AnotherTestCommand{Value: i + 100} -// signedCmd1 := createAndSignCommand(t, cmd1) -// signedCmd2 := createAndSignCommand(t, cmd2) -// require.NoError(t, m.Enqueue(signedCmd1)) -// require.NoError(t, m.Enqueue(signedCmd2)) -// } -// }, -// verify: func(t *testing.T, tickData TickData) { -// require.Len(t, tickData.Commands, 6) -// testCmdCount := 0 -// anotherCmdCount := 0 -// for _, cmd := range tickData.Commands { -// switch payload := cmd.Command.Body.Payload.(type) { -// case testutils.TestCommand: -// assert.Equal(t, testCmdCount, payload.Value) -// testCmdCount++ -// case testutils.AnotherTestCommand: -// assert.Equal(t, anotherCmdCount+100, payload.Value) -// anotherCmdCount++ -// default: -// t.Errorf("unexpected command type: %T", payload) -// } -// } -// assert.Equal(t, 3, testCmdCount) -// assert.Equal(t, 3, anotherCmdCount) -// }, -// }, -// } -// -// for _, tt := range tests { -// t.Run(tt.name, func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// if tt.setup != nil { -// tt.setup(m) -// } -// -// tickData := m.GetTickData() -// tt.verify(t, tickData) -// }) -// } -// } -// -// func TestRegisterCommand_ModeBasedBehavior(t *testing.T) { -// t.Parallel() -// -// t.Run("follower mode does not create endpoints", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeFollower) -// -// // Register command in follower mode -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// -// // Command should be registered in the channel map -// assert.True(t, m.Has("test-command"), "Command should be registered in follower mode") -// -// // But no endpoint should be created in the service -// endpointName := "command.test-command" -// _, exists := m.endpoints[endpointName] -// assert.False(t, exists, "No endpoint should be created in follower mode") -// }) -// -// t.Run("leader mode creates endpoints", func(t *testing.T) { -// t.Parallel() -// m := createTestCommandManager(t, ModeLeader) -// -// // Register command in leader mode -// err := registerCommand[testutils.TestCommand](m) -// require.NoError(t, err) -// -// // Command should be registered in the channel map -// assert.True(t, m.Has("test-command"), "Command should be registered in leader mode") -// -// // Endpoint should be created in the service -// endpointName := "command.test-command" -// _, exists := m.endpoints[endpointName] -// assert.True(t, exists, "Endpoint should be created in leader mode") -// }) -// } -// -// func createTestCommandManager(t *testing.T, mode ShardMode) *commandManager { -// t.Helper() -// -// // Create a new NATS server for each test -// nats := microtestutils.NewNATS(t) -// -// // Create a test client with NATS URL pointing to our test server -// client, err := NewTestClient(nats.Server.ClientURL()) -// require.NoError(t, err) -// -// // Create command manager with telemetry -// tel, err := telemetry.New(telemetry.Options{ServiceName: "test-command-manager"}) -// require.NoError(t, err) -// -// opts := ShardOptions{ -// Client: client, -// Address: GetAddress(RealmInternal, "test-org", "test-proj", "test-id"), -// Mode: mode, -// EpochFrequency: 10, -// TickRate: 1, -// Telemetry: &tel, -// } -// -// testShard, err := NewShard(testShardEngine{}, opts) -// require.NoError(t, err) -// -// m, err := newCommandManager(testShard, opts) -// require.NoError(t, err) -// -// return &m -// } -// -// func createAndSignCommand(t *testing.T, cmd ecs.Command) *iscv1.Command { -// t.Helper() -// -// signer, err := sign.NewSigner(strings.Repeat("00", 32), 42) -// if err != nil { -// // Make it safe for use inside another goroutine. -// t.Errorf("failed to create signer: %v", err) -// return nil -// } -// -// // Marshal command to protobuf struct directly to avoid import cycle -// cmdBytes, err := json.Marshal(cmd) -// if err != nil { -// t.Errorf("failed to marshal test command to JSON: %v", err) -// return nil -// } -// -// var cmdMap map[string]any -// if err := json.Unmarshal(cmdBytes, &cmdMap); err != nil { -// t.Errorf("failed to unmarshal command to map: %v", err) -// return nil -// } -// -// pbStruct, err := structpb.NewStruct(cmdMap) -// if err != nil { -// t.Errorf("failed to create protobuf struct: %v", err) -// return nil -// } -// -// pbCommand := &iscv1.CommandBody{ -// Name: cmd.Name(), -// Payload: pbStruct, -// } -// -// signedCommand, err := signer.SignCommand(pbCommand, iscv1.AuthInfo_MODE_PERSONA) -// if err != nil { -// t.Errorf("failed to create signed command: %v", err) -// return nil -// } -// -// return signedCommand -// } -// -// type testShardEngine struct{} -// -// var _ ShardEngine = testShardEngine{} -// -// func (testShardEngine) Init() error { return nil } -// func (testShardEngine) StateHash() []byte { return nil } -// func (testShardEngine) Tick(tick Tick) error { return nil } -// func (testShardEngine) Replay(replay Tick) error { return nil } diff --git a/pkg/micro/shard_config.go b/pkg/micro/shard_config.go deleted file mode 100644 index afa91b111..000000000 --- a/pkg/micro/shard_config.go +++ /dev/null @@ -1,221 +0,0 @@ -package micro - -import ( - "slices" - "strings" - - "github.com/argus-labs/world-engine/pkg/assert" - "github.com/argus-labs/world-engine/pkg/telemetry" - "github.com/caarlos0/env/v11" - "github.com/rotisserie/eris" -) - -// ShardMode defines the operational mode of a shard instance. -type ShardMode uint8 - -const ( - ModeUndefined ShardMode = iota // Used as the zero value - ModeLeader // Leader mode processes input and publishes epochs - ModeFollower // Follower mode consumes epochs and replays state -) - -const ( - leaderModeString = "LEADER" - followerModeString = "FOLLOWER" - undefinedModeString = "UNDEFINED" -) - -func (m ShardMode) String() string { - switch m { - case ModeUndefined: - return undefinedModeString - case ModeLeader: - return leaderModeString - case ModeFollower: - return followerModeString - default: - return undefinedModeString - } -} - -// shardConfig holds the configuration for a shard instance. -// Configuration can be set via environment variables with the specified defaults. -type shardConfig struct { - // Shard mode configuration ("LEADER" or "FOLLOWER"). - ModeStr string `env:"SHARD_MODE" envDefault:"LEADER"` - - SnapshotStorageType string `env:"SHARD_SNAPSHOT_STORAGE_TYPE" envDefault:"nop"` - - SnapshotFrequency uint32 `env:"SHARD_SNAPSHOT_FREQUENCY" envDefault:"5"` - - DisablePersona bool `env:"SHARD_DISABLE_PERSONA" envDefault:"false"` - - // Maximum bytes for epoch stream. Required by some NATS providers like Synadia Cloud. - EpochStreamMaxBytes uint32 `env:"SHARD_EPOCH_STREAM_MAX_BYTES" envDefault:"0"` -} - -// loadShardConfig loads the shard configuration from environment variables. -func loadShardConfig() (shardConfig, error) { - cfg := shardConfig{} - - if err := env.Parse(&cfg); err != nil { - return cfg, eris.Wrap(err, "failed to parse shard options") - } - - if err := cfg.validate(); err != nil { - return cfg, eris.Wrap(err, "failed to validate config") - } - - return cfg, nil -} - -// validate performs validation on the loaded configuration. -func (cfg *shardConfig) validate() error { - cfg.ModeStr = strings.ToUpper(cfg.ModeStr) - validModes := []string{"LEADER", "FOLLOWER"} - if !slices.Contains(validModes, cfg.ModeStr) { - return eris.Errorf("invalid world mode: %s (must be one of %v)", cfg.ModeStr, validModes) - } - - cfg.SnapshotStorageType = strings.ToUpper(cfg.SnapshotStorageType) - validStorageTypes := []string{"NOP", "JETSTREAM"} - if !slices.Contains(validStorageTypes, cfg.SnapshotStorageType) { - return eris.Errorf("invalid snapshot storage type: %s (must be one of %v)", - cfg.SnapshotStorageType, validStorageTypes) - } - - if cfg.SnapshotFrequency == 0 { - return eris.New("snapshot frequency cannot be 0") - } - - // A EpochStreamMaxBytes value of 0 means unlimited epoch stream storage. This is the default, we - // don't need to validate it here. - - return nil -} - -// applyToOptions applies the configuration values to the given ShardOptions. -func (cfg *shardConfig) applyToOptions(opt *ShardOptions) { - var mode ShardMode - switch cfg.ModeStr { - case leaderModeString: - mode = ModeLeader - case followerModeString: - mode = ModeFollower - default: - assert.That(true, "unreachable") - } - - opt.Mode = mode - opt.SnapshotFrequency = cfg.SnapshotFrequency - opt.DisablePersona = cfg.DisablePersona - opt.EpochStreamMaxBytes = cfg.EpochStreamMaxBytes -} - -const MinEpochFrequency = 10 - -// ShardOptions contains configuration options for creating a new shard. -type ShardOptions struct { - Client *Client // NATS client - Address *ServiceAddress // Shard's service address - Mode ShardMode // Operation mode (Leader or Follower) - EpochFrequency uint32 // Number of ticks per epoch - TickRate float64 // Number of ticks per second - Telemetry *telemetry.Telemetry // Telemetry for logging and tracing - SnapshotStorageType SnapshotStorageType // Snapshot storage type - SnapshotStorageOptions SnapshotStorageOptions // Optional snapshot storage options - SnapshotFrequency uint32 // Number of epochs per snapshot - DisablePersona bool // Disable persona verification for development/testing - EpochStreamMaxBytes uint32 // Maximum bytes for epoch stream (required by some NATS providers) -} - -// newDefaultShardOptions creates ShardOptions with default values. -func newDefaultShardOptions() ShardOptions { - // Set these to invalid values to force users to pass in the correct options. - return ShardOptions{ - Client: nil, - Address: nil, - Mode: ModeLeader, - EpochFrequency: 0, - TickRate: 0, - Telemetry: nil, - SnapshotStorageType: SnapshotStorageUndefined, - SnapshotStorageOptions: nil, - SnapshotFrequency: 0, - EpochStreamMaxBytes: 0, // There is no invalid values for this, just set default of 0 - } -} - -// apply merges the given options into the current options, overriding non-zero values. -func (opt *ShardOptions) apply(newOpt ShardOptions) { - if newOpt.Client != nil { - opt.Client = newOpt.Client - } - if newOpt.Address != nil { - opt.Address = newOpt.Address - } - if newOpt.Telemetry != nil { - opt.Telemetry = newOpt.Telemetry - } - if newOpt.Mode != ModeUndefined { - opt.Mode = newOpt.Mode - } - if newOpt.EpochFrequency != 0 { - opt.EpochFrequency = newOpt.EpochFrequency - } - if newOpt.TickRate != 0.0 { - opt.TickRate = newOpt.TickRate - } - if newOpt.SnapshotStorageType != SnapshotStorageUndefined { - opt.SnapshotStorageType = newOpt.SnapshotStorageType - } - if newOpt.SnapshotStorageOptions != nil { - opt.SnapshotStorageOptions = newOpt.SnapshotStorageOptions - } - if newOpt.SnapshotFrequency != 0 { - opt.SnapshotFrequency = newOpt.SnapshotFrequency - } - // These options' zero values are always valid, so if unset they will always override opt. - // We'll just make them configurable only from the env var. - // These include: DisablePersona, EpochStreamMaxBytes -} - -// validate checks that all required options are set and valid. -func (opt *ShardOptions) validate() error { - if opt.Client == nil { - return eris.New("NATS client cannot be nil") - } - if opt.Address == nil { - return eris.New("service address cannot be nil") - } - if opt.Telemetry == nil { - return eris.New("telemetry cannot be nil") - } - if opt.EpochFrequency < MinEpochFrequency { - return eris.Errorf("epoch frequency must be at least %d", MinEpochFrequency) - } - if opt.TickRate == 0.0 { - return eris.New("tick rate cannot be 0") - } - - // Mode validation. - switch opt.Mode { - case ModeUndefined: - return eris.New("shard mode must be specified") - case ModeFollower, ModeLeader: - // Valid modes. - } - - // Snapshot storage validation. - if opt.SnapshotStorageType == SnapshotStorageUndefined { - return eris.New("snapshot storage type must be specified") - } - // SnapshotStorageOptions can be nil. - - // Snapshot frequency validation. - if opt.SnapshotFrequency == 0 { - return eris.New("snapshot frequency cannot be 0") - } - - return nil -} diff --git a/pkg/micro/shard_leader.go b/pkg/micro/shard_leader.go deleted file mode 100644 index b27435b2f..000000000 --- a/pkg/micro/shard_leader.go +++ /dev/null @@ -1,226 +0,0 @@ -package micro - -import ( - "context" - "slices" - "time" - - "github.com/argus-labs/world-engine/pkg/assert" - iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" - "github.com/goccy/go-json" - "github.com/rotisserie/eris" - "google.golang.org/protobuf/proto" - "google.golang.org/protobuf/types/known/structpb" - "google.golang.org/protobuf/types/known/timestamppb" -) - -// ------------------------------------------------------------------------------------------------- -// Leader mode -// ------------------------------------------------------------------------------------------------- - -// runLeader executes the leader mode main loop. -// It processes ticks at the configured tick rate and publishes epochs to followers. -func (s *Shard) runLeader(ctx context.Context) error { - ticker := time.NewTicker(time.Duration(float64(time.Second) / s.tickRate)) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - tickData := s.commands.GetTickData() - - tick := s.beginTick(tickData, time.Now()) - - err := s.base.Tick(tick) - if err != nil { - return eris.Wrap(err, "failed to run base tick function") - } - - // Only end the tick when there is no error. - err = s.endTick() - if err != nil { - return eris.Wrap(err, "failed to end tick") - } - case <-ctx.Done(): - return ctx.Err() - } - } -} - -// beginTick begins a new tick by recording the input and timestamp. -func (s *Shard) beginTick(data TickData, timestamp time.Time) Tick { - assert.That(len(s.ticks) < int(s.frequency), "last epoch is not submitted") - - // Copy commands slice to avoid aliasing issues with reused buffer. - commands := slices.Clone(data.Commands) - - tick := Tick{ - Header: TickHeader{ - TickHeight: s.tickHeight, - Timestamp: timestamp, - }, - Data: TickData{Commands: commands}, - } - - s.ticks = append(s.ticks, tick) - return tick -} - -// endTick completes a tick and potentially publishes an epoch. -func (s *Shard) endTick() error { - assert.That(len(s.ticks) > 0, "start tick wasn't called for this end tick") - - // Increment tick count at the end of the tick. - logger := s.tel.GetLogger("shard") - logger.Debug().Uint64("tick", s.tickHeight).Msg("tick completed") - s.tickHeight++ - - if len(s.ticks) == int(s.frequency) { //nolint:nestif // its ok - if s.mode == ModeLeader { - if err := s.publishEpoch(context.Background()); err != nil { - return eris.Wrap(err, "failed to published epoch") - } - - // Create snapshot asynchronously after successful epoch publishing, but only every snapshotFrequency epochs. - if s.epochHeight%uint64(s.snapshotFrequency) == 0 { - // Create snapshot metadata synchronously to avoid race conditions. - stateHash, err := s.base.StateHash() - if err != nil { - return eris.Wrap(err, "failed to get state hash for snapshot") - } - snapshot := &Snapshot{ - EpochHeight: s.epochHeight, - TickHeight: s.tickHeight - 1, - Timestamp: timestamppb.Now(), - StateHash: stateHash, - Data: nil, // Will be filled in the goroutine - } - go s.createAndStoreSnapshot(snapshot) - } - } - - // Increment epoch count after publishing the epoch. - logger.Debug().Uint64("epoch", s.epochHeight).Msg("epoch completed") - s.epochHeight++ - - // Clear ticks array to prepare for the next epoch. - s.ticks = s.ticks[:0] - } - - return nil -} - -// publishEpoch serializes the current epoch and publishes it to the stream. -func (s *Shard) publishEpoch(ctx context.Context) error { - logger := s.tel.GetLogger("shard") - logger.Debug().Uint64("epoch", s.epochHeight).Msg("publishing epoch") - - stateHash, err := s.base.StateHash() - if err != nil { - return eris.Wrap(err, "failed to get state hash for epoch") - } - epoch := iscv1.Epoch{ - EpochHeight: s.epochHeight, - Hash: stateHash, - } - - for _, tick := range s.ticks { - tickPb := &iscv1.Tick{ - Header: &iscv1.TickHeader{ - TickHeight: tick.Header.TickHeight, - Timestamp: timestamppb.New(tick.Header.Timestamp), - }, - Data: &iscv1.TickData{}, - } - - for _, command := range tick.Data.Commands { - commandPb := &iscv1.Command{ - Name: command.Name, - Address: command.Address, - Persona: &iscv1.Persona{Id: command.Persona}, - } - - if command.Payload != nil { - pbStruct, err := marshalToStruct(command.Payload) - if err != nil { - return eris.Wrap(err, "failed to marshal command payload") - } - commandPb.Payload = pbStruct - } - - tickPb.Data.Commands = append(tickPb.Data.Commands, commandPb) - } - - epoch.Ticks = append(epoch.Ticks, tickPb) - } - - payload, err := proto.Marshal(&epoch) - if err != nil { - return eris.Wrap(err, "failed to marshal epoch") - } - ack, err := s.js.Publish(ctx, s.subject, payload, epochPublishOptions(s.subject, s.epochHeight)...) - if err != nil { - return eris.Wrap(err, "failed to publish epoch") - } - - // Verify stream sequence matches expected epoch height (1:1 mapping). - // Stream sequences start at 1, so we'll have to compare with epochHeight+1. We can't set the - // stream sequence to start at 0, so we'll just have to deal with it here. - expectedSeq := s.epochHeight + 1 - if ack.Sequence != expectedSeq { - return eris.Errorf("epoch sequence mismatch: expected %d, got %d", expectedSeq, ack.Sequence) - } - - logger.Debug().Uint64("epoch", s.epochHeight).Uint64("seq", ack.Sequence).Hex("hash", stateHash).Msg("epoch published") - return nil -} - -// createAndStoreSnapshot creates and stores a snapshot in a background goroutine. -// This is called after successful epoch publishing to avoid blocking the critical path. -// The snapshot metadata is already populated; this function fills in the data and stores it. -func (s *Shard) createAndStoreSnapshot(snapshot *Snapshot) { - logger := s.tel.GetLogger("snapshot") - logger.Debug().Uint64("epoch", snapshot.EpochHeight).Msg("creating snapshot") - - engineData, err := s.base.Snapshot() - if err != nil { - logger.Error(). - Err(err). - Uint64("epoch", snapshot.EpochHeight). - Msg("failed to create snapshot") - return - } - snapshot.Data = engineData - - if err := s.snapshotStorage.Store(snapshot); err != nil { - logger.Error(). - Err(err). - Uint64("epoch", snapshot.EpochHeight). - Hex("hash", snapshot.StateHash). - Msg("failed to store snapshot") - return - } - - logger.Debug(). - Uint64("epoch", snapshot.EpochHeight). - Hex("hash", snapshot.StateHash). - Msg("snapshot stored") -} - -func marshalToStruct(payload any) (*structpb.Struct, error) { - bytes, err := json.Marshal(payload) - if err != nil { - return nil, eris.Wrap(err, "failed to marshal payload") - } - - var m map[string]any - if err := json.Unmarshal(bytes, &m); err != nil { - return nil, eris.Wrap(err, "failed to unmarshal payload to map[string]any") - } - - pbStruct, err := structpb.NewStruct(m) - if err != nil { - return nil, eris.Wrap(err, "failed to convert map to structpb.Struct") - } - return pbStruct, nil -} diff --git a/pkg/micro/shard_sync.go b/pkg/micro/shard_sync.go deleted file mode 100644 index 39a842e25..000000000 --- a/pkg/micro/shard_sync.go +++ /dev/null @@ -1,264 +0,0 @@ -package micro - -import ( - "bytes" - "context" - "strconv" - "strings" - - "buf.build/go/protovalidate" - "github.com/argus-labs/world-engine/pkg/assert" - iscv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1" - "github.com/nats-io/nats.go/jetstream" - "github.com/rotisserie/eris" - "google.golang.org/protobuf/proto" -) - -// ------------------------------------------------------------------------------------------------- -// Follower mode -// ------------------------------------------------------------------------------------------------- - -// runFollower executes the follower mode main loop. -// It continuously consumes epochs from the stream and replays them in real-time. -func (s *Shard) runFollower(ctx context.Context) error { - consumeCtx, err := s.consumer.Consume(func(msg jetstream.Msg) { - logger := s.tel.GetLogger("shard") - msgID := msg.Headers().Get("Nats-Msg-Id") - msgLogger := logger.With(). - Uint64("epoch", epochHeightFromMsgID(msgID, s.subject)). - Str("msg_id", msgID). - Logger() - - if err := s.replayEpoch(msg.Data()); err != nil { - msgLogger.Error().Err(err).Uint64("current_tick", s.tickHeight).Msg("failed to replay epoch") - return - } - - if err := msg.Ack(); err != nil { - msgLogger.Error().Err(err).Msg("failed to acknowledge message") - } - }) - if err != nil { - return eris.Wrap(err, "failed to start consuming epochs") - } - - // Consume is non-blocking, so when we return from this function, e.g. context is cancelled, the - // consumeCts.Stop() method is called to stop the consumer. - defer consumeCtx.Stop() - - <-ctx.Done() - return ctx.Err() -} - -// ------------------------------------------------------------------------------------------------- -// Initialization and Sync -// ------------------------------------------------------------------------------------------------- - -// init initializes the shard. It restores from a snapshot if available and in leader mode, -// otherwise runs the shard engine's init method. -func (s *Shard) init() error { - logger := s.tel.GetLogger("shard") - logger.Debug().Str("mode", string(s.mode)).Msg("initializing shard") - - if s.mode == ModeLeader && s.restoreSnapshot() { - return nil - } - - // Initialize the shard engine (registers components, sets up schedulers, etc.) - logger.Debug().Msg("initializing shard from scratch") - err := s.base.Init() - if err != nil { - return eris.Wrap(err, "failed to initialize shard") - } - - logger.Debug().Msg("shard initialized from scratch") - return nil -} - -// restoreSnapshot attempts to restore shard state from a snapshot. Returns true if restoration was -// successful, false if fallback to normal init is needed. -func (s *Shard) restoreSnapshot() bool { - logger := s.tel.GetLogger("shard") - - if !s.snapshotStorage.Exists() { - logger.Debug().Msg("no snapshot found") - return false - } - - logger.Debug().Msg("restoring from snapshot") - snapshot, err := s.snapshotStorage.Load() - if err != nil { - logger.Warn().Err(err).Msg("failed to load snapshot, falling back to normal init") - return false - } - - // Attempt to restore from snapshot. - if err := s.base.Restore(snapshot.Data); err != nil { - logger.Warn().Err(err).Msg("failed to restore engine from snapshot, resetting and falling back to normal init") - s.base.Reset() - return false - } - - // Validate restored state hash matches snapshot. - currentHash, err := s.base.StateHash() - if err != nil { - logger.Error().Err(err).Msg("failed to get current state hash, resetting and falling back to normal init") - s.base.Reset() - return false - } - if !bytes.Equal(currentHash, snapshot.StateHash) { - logger.Error(). - Str("expected_hash", string(snapshot.StateHash)). - Str("actual_hash", string(currentHash)). - Msg("snapshot state hash mismatch, resetting and falling back to normal init") - s.base.Reset() - return false - } - - // Only update shard state after successful restoration and validation. - s.epochHeight = snapshot.EpochHeight + 1 - s.tickHeight = snapshot.TickHeight + 1 - - logger.Info(). - Uint64("epoch", snapshot.EpochHeight). - Uint64("tick", snapshot.TickHeight). - Msg("successfully restored and validated snapshot") - - return true -} - -// sync replays epochs from the stream starting from the current epoch height to bring the shard up -// to the latest state. -func (s *Shard) sync() error { //nolint:gocognit // its fine - logger := s.tel.GetLogger("shard") - - // Set mode to follower for the duration of the sync. - originalMode := s.mode - s.mode = ModeFollower - defer func() { s.mode = originalMode }() - - // Get consumer info to determine how many messages are pending - cInfo, err := s.consumer.Info(context.Background()) - if err != nil { - return eris.Wrap(err, "failed to fetch consumer info") - } - - pending := cInfo.NumPending - startEpoch := s.epochHeight - logger.Info().Uint64("from_epoch", startEpoch).Uint64("pending_messages", pending).Msg("starting sync") - - const batchSize = 256 - for pending > 0 { - batch, err := s.consumer.FetchNoWait(batchSize) - if err != nil { - return eris.Wrap(err, "failed to fetch message batch") - } - - processed := uint64(0) - for message := range batch.Messages() { - processed++ - - msgID := message.Headers().Get("Nats-Msg-Id") - epochHeight := epochHeightFromMsgID(msgID, s.subject) - - // Skip epochs that are below current epoch height - if epochHeight < s.epochHeight { - if err := message.Ack(); err != nil { - return eris.Wrap(err, "failed to acknowledge skipped message") - } - continue - } - - if err := s.replayEpoch(message.Data()); err != nil { - return eris.Wrap(err, "failed to replay epoch") - } - - if err := message.Ack(); err != nil { - return eris.Wrap(err, "failed to acknowledge message") - } - } - - // Check for errors that occurred during message delivery. - if err := batch.Error(); err != nil { - return eris.Wrap(err, "error occurred during message batch delivery") - } - - pending -= processed - } - - endEpoch := s.epochHeight - logger.Info().Uint64("from_epoch", startEpoch).Uint64("to_epoch", endEpoch).Msg("sync completed") - return nil -} - -// replayEpoch deserializes and replays an epoch to reconstruct state. -// It validates the epoch and replays each tick to ensure deterministic state reconstruction. -func (s *Shard) replayEpoch(epochBytes []byte) error { - epoch := iscv1.Epoch{} - if err := proto.Unmarshal(epochBytes, &epoch); err != nil { - return eris.Wrap(err, "failed to unmarshal epoch") - } - if err := protovalidate.Validate(&epoch); err != nil { - return eris.Wrap(err, "failed to validate epoch") - } - - if epoch.GetEpochHeight() != s.epochHeight { - return eris.Errorf("mismatched epoch, expected: %d, actual: %d", s.epochHeight, epoch.GetEpochHeight()) - } - - for _, tick := range epoch.GetTicks() { - if err := s.replayTick(tick); err != nil { - return eris.Wrap(err, "failed to replay tick") - } - } - - currentHash, err := s.base.StateHash() - if err != nil { - return eris.Wrap(err, "failed to get current state hash") - } - if !bytes.Equal(epoch.GetHash(), currentHash) { - return eris.New("mismatched state hash") - } - - return nil -} - -// replayTick replays a single tick during epoch reconstruction. -// It deserializes the input and calls the replay function. -func (s *Shard) replayTick(tick *iscv1.Tick) error { - if tick.GetHeader().GetTickHeight() != s.tickHeight { - return eris.Errorf("mismatched tick, expected: %d, actual: %d", s.tickHeight, tick.GetHeader().GetTickHeight()) - } - - // Enqueue to command manager so the command payloads get marshalled to their corresponding concrete type. - for _, command := range tick.GetData().GetCommands() { - if err := s.commands.Enqueue(command); err != nil { - return eris.Wrap(err, "failed to enqueue command") - } - } - - tickData := s.commands.GetTickData() - - timestamp := tick.GetHeader().GetTimestamp().AsTime() - replayTick := s.beginTick(tickData, timestamp) - - err := s.base.Replay(replayTick) - if err != nil { - return eris.Wrap(err, "replay function failed") - } - - if err := s.endTick(); err != nil { - return eris.Wrap(err, "failed to end tick replay") - } - - return nil -} - -// epochHeightFromMsgID extracts the epoch height from a JetStream message ID. -// Message IDs are in the format "-". -func epochHeightFromMsgID(msgID, subject string) uint64 { - epochStr := strings.TrimPrefix(msgID, subject+"-") - epochHeight, err := strconv.ParseUint(epochStr, 10, 64) - assert.That(err == nil, "epoch isn't published in the expected format") - return epochHeight -} diff --git a/pkg/micro/snapshot.go b/pkg/micro/snapshot.go deleted file mode 100644 index 799a59fab..000000000 --- a/pkg/micro/snapshot.go +++ /dev/null @@ -1,57 +0,0 @@ -package micro - -import ( - microv1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1" -) - -// Snapshot represents a point-in-time capture of shard state. -// This is an alias to the protobuf-generated type for better API ergonomics. -type Snapshot = microv1.Snapshot - -// SnapshotStorage provides persistence for shard snapshots. -// Implementations handle atomic storage with automatic backup of previous snapshots. -type SnapshotStorage interface { - // Store saves the snapshot, atomically replacing any existing snapshot. - // The previous snapshot should be preserved as backup if possible. - Store(snapshot *Snapshot) error - - // Load retrieves the current snapshot. - // Returns an error if no snapshot exists. - Load() (*Snapshot, error) - - // Exists checks if a current snapshot is available. - Exists() bool -} - -// SnapshotStorageType defines the type of snapshot storage to use. -type SnapshotStorageType uint8 - -const ( - SnapshotStorageUndefined SnapshotStorageType = iota - SnapshotStorageNop - SnapshotStorageJetStream -) - -const ( - nopSnapshotStorageString = "NOP" - jetStreamSnapshotStorageString = "JETSTREAM" - undefinedSnapshotStorageString = "UNDEFINED" -) - -func (s SnapshotStorageType) String() string { - switch s { - case SnapshotStorageUndefined: - return undefinedSnapshotStorageString - case SnapshotStorageNop: - return nopSnapshotStorageString - case SnapshotStorageJetStream: - return jetStreamSnapshotStorageString - default: - return undefinedSnapshotStorageString - } -} - -type SnapshotStorageOptions interface { - validate() error - apply(ShardOptions) -} diff --git a/pkg/micro/storage_jetstream.go b/pkg/micro/storage_jetstream.go deleted file mode 100644 index 252679b2e..000000000 --- a/pkg/micro/storage_jetstream.go +++ /dev/null @@ -1,167 +0,0 @@ -package micro - -import ( - "context" - "fmt" - "io" - "math" - - "github.com/caarlos0/env/v11" - "github.com/nats-io/nats.go/jetstream" - "github.com/rotisserie/eris" - "google.golang.org/protobuf/proto" -) - -// JetStreamSnapshotStorage implements SnapshotStorage using NATS JetStream ObjectStore. -type JetStreamSnapshotStorage struct { - os jetstream.ObjectStore - objectName string -} - -var _ SnapshotStorage = (*JetStreamSnapshotStorage)(nil) - -// NewJetStreamSnapshotStorage creates a new JetStream ObjectStore-based snapshot storage. -// It creates its own NATS client using the default configuration from environment variables. -func NewJetStreamSnapshotStorage(opts JetStreamSnapshotStorageOptions) (*JetStreamSnapshotStorage, error) { - js, err := jetstream.New(opts.client.Conn) - if err != nil { - return nil, eris.Wrap(err, "failed to create JetStream client") - } - - ctx := context.Background() - - // Same format as streams because it regular service address format isn't accepted. - bucketName := fmt.Sprintf("%s_%s_%s_snapshot", - opts.address.GetOrganization(), opts.address.GetProject(), opts.address.GetServiceId()) - - if opts.SnapshotStorageMaxBytes > math.MaxInt64 { - return nil, eris.New("snapshot storage max bytes exceeds maximum int64 value") - } - - osConfig := jetstream.ObjectStoreConfig{ - Bucket: bucketName, - MaxBytes: int64(opts.SnapshotStorageMaxBytes), // Required by some NATS providers like Synadia Cloud - } - os, err := js.CreateObjectStore(ctx, osConfig) - if err != nil { - if eris.Is(err, jetstream.ErrBucketExists) { - // Bucket already exists, get the existing one. - os, err = js.ObjectStore(ctx, bucketName) - if err != nil { - return nil, eris.Wrapf(err, "failed to get existing ObjectStore (bucket=%s)", bucketName) - } - } else { - return nil, eris.Wrapf(err, "failed to create ObjectStore (bucket=%s, maxBytes=%d)", - osConfig.Bucket, osConfig.MaxBytes) - } - } - - return &JetStreamSnapshotStorage{os: os, objectName: opts.ObjectName}, nil -} - -func (j *JetStreamSnapshotStorage) Store(snapshot *Snapshot) error { - data, err := proto.Marshal(snapshot) - if err != nil { - return eris.Wrap(err, "failed to marshal snapshot") - } - - // Overwrite the existing snapshot if any. - if _, err = j.os.PutBytes(context.Background(), j.objectName, data); err != nil { - return eris.Wrap(err, "failed to store snapshot in ObjectStore") - } - - return nil -} - -func (j *JetStreamSnapshotStorage) Load() (*Snapshot, error) { - object, err := j.os.Get(context.Background(), j.objectName) - if err != nil { - if eris.Is(err, jetstream.ErrObjectNotFound) { - return nil, eris.New("no snapshot exists") - } - return nil, eris.Wrap(err, "failed to get snapshot from ObjectStore") - } - defer func() { - _ = object.Close() - }() - - data, err := io.ReadAll(object) - if err != nil { - return nil, eris.Wrap(err, "failed to read from object") - } - - var snapshot Snapshot - err = proto.Unmarshal(data, &snapshot) - if err != nil { - return nil, eris.Wrap(err, "failed to unmarshal snapshot") - } - - return &snapshot, nil -} - -func (j *JetStreamSnapshotStorage) Exists() bool { - _, err := j.os.GetInfo(context.Background(), j.objectName) - return err == nil -} - -// ------------------------------------------------------------------------------------------------- -// Options -// ------------------------------------------------------------------------------------------------- - -type JetStreamSnapshotStorageOptions struct { - ObjectName string `env:"SHARD_SNAPSHOT_JETSTREAM_OBJECT_NAME" envDefault:"snapshot"` - // Maximum bytes for snapshot storage (ObjectStore). // Required by some NATS providers like Synadia Cloud. - SnapshotStorageMaxBytes uint64 `env:"SHARD_SNAPSHOT_STORAGE_MAX_BYTES" envDefault:"0"` - - client *Client - address *ServiceAddress -} - -var _ SnapshotStorageOptions = (*JetStreamSnapshotStorageOptions)(nil) - -func newJetstreamSnapshotStorageOptions() (JetStreamSnapshotStorageOptions, error) { - // Set default values. - opts := JetStreamSnapshotStorageOptions{ - ObjectName: "snapshot", - SnapshotStorageMaxBytes: 0, - // Guaranteed to be not nil from a validated shard options. - client: nil, - address: nil, - } - - if err := env.Parse(&opts); err != nil { - return opts, eris.Wrap(err, "failed to parse env") - } - - return opts, nil -} - -func (opt *JetStreamSnapshotStorageOptions) apply(shardOpts ShardOptions) { - userOpts, ok := shardOpts.SnapshotStorageOptions.(*JetStreamSnapshotStorageOptions) - // Only apply user-provided options if it is the correct type. Otherwise stick to the defaults. - if ok { - if userOpts.ObjectName != "" { - opt.ObjectName = userOpts.ObjectName - } - } - - // shardOpts is already validated, just apply. - opt.client = shardOpts.Client - opt.address = shardOpts.Address -} - -// validate validates the options. We only need to validate the public fields as the private ones -// come from a validated ShardOptions. -func (opt *JetStreamSnapshotStorageOptions) validate() error { - if opt.ObjectName == "" { - return eris.New("object name cannot be empty") - } - if opt.client == nil { - return eris.New("NATS client cannot be nil") - } - if opt.address == nil { - return eris.New("service address cannot be nil") - } - // SnapshotStorageMaxBytes can be 0 which means unlimited storage. No need to validate here. - return nil -} diff --git a/pkg/micro/storage_nop.go b/pkg/micro/storage_nop.go deleted file mode 100644 index 69664af1e..000000000 --- a/pkg/micro/storage_nop.go +++ /dev/null @@ -1,26 +0,0 @@ -package micro - -import "github.com/rotisserie/eris" - -// NopSnapshotStorage is a no-op implementation of SnapshotStorage. -// It's used when snapshots are not needed (e.g., development, testing). -type NopSnapshotStorage struct{} - -var _ SnapshotStorage = (*NopSnapshotStorage)(nil) - -// NewNopSnapshotStorage creates a new no-op snapshot storage. -func NewNopSnapshotStorage() *NopSnapshotStorage { - return &NopSnapshotStorage{} -} - -func (n *NopSnapshotStorage) Store(_ *Snapshot) error { - return nil -} - -func (n *NopSnapshotStorage) Load() (*Snapshot, error) { - return nil, eris.New("no snapshots available (using no-op storage)") -} - -func (n *NopSnapshotStorage) Exists() bool { - return false -} diff --git a/pkg/micro/tick.go b/pkg/micro/tick.go deleted file mode 100644 index df3d87eae..000000000 --- a/pkg/micro/tick.go +++ /dev/null @@ -1,34 +0,0 @@ -package micro - -import ( - "time" -) - -// Tick represents a single execution step in the shard's lifecycle. -type Tick struct { - Header TickHeader // Metadata about when and which tick this represents - Data TickData // The actual commands processed during this tick -} - -// TickHeader contains metadata about the tick execution. -type TickHeader struct { - TickHeight uint64 // Tick height - Timestamp time.Time // When this tick was created -} - -// TickData contains the commands that were processed during this tick execution. -type TickData struct { - Commands []Command // List of commands executed in this tick -} - -// Command represents a command from a player or external system. -type Command struct { - Name string // The command name - Address *ServiceAddress // Service address this command is sent to - Persona string // Sender's persona - Payload any // The command payload itself -} - -type ShardCommand interface { - Name() string -} diff --git a/pkg/template/bare-bone/shards/game/main.go b/pkg/template/bare-bone/shards/game/main.go index e92b84871..624599026 100644 --- a/pkg/template/bare-bone/shards/game/main.go +++ b/pkg/template/bare-bone/shards/game/main.go @@ -6,8 +6,8 @@ import ( func main() { world, err := cardinal.NewWorld(cardinal.WorldOptions{ - TickRate: 1, - EpochFrequency: 10, + TickRate: 1, + SnapshotRate: 50, }) if err != nil { panic(err.Error()) diff --git a/pkg/template/basic/shards/game/event/event.go b/pkg/template/basic/shards/game/event/event.go index 942016cb3..11a3361ad 100644 --- a/pkg/template/basic/shards/game/event/event.go +++ b/pkg/template/basic/shards/game/event/event.go @@ -1,9 +1,6 @@ package event -import "github.com/argus-labs/world-engine/pkg/cardinal" - type PlayerDeath struct { - cardinal.BaseEvent Nickname string } @@ -12,7 +9,6 @@ func (PlayerDeath) Name() string { } type NewPlayer struct { - cardinal.BaseEvent Nickname string } diff --git a/pkg/template/basic/shards/game/main.go b/pkg/template/basic/shards/game/main.go index d1bd4d14d..64c45452a 100644 --- a/pkg/template/basic/shards/game/main.go +++ b/pkg/template/basic/shards/game/main.go @@ -1,17 +1,17 @@ package main import ( + "github.com/argus-labs/world-engine/pkg/cardinal/snapshot" "github.com/argus-labs/world-engine/pkg/template/basic/shards/game/system" "github.com/argus-labs/world-engine/pkg/cardinal" - "github.com/argus-labs/world-engine/pkg/micro" ) func main() { world, err := cardinal.NewWorld(cardinal.WorldOptions{ TickRate: 1, - EpochFrequency: 10, - SnapshotStorageType: micro.SnapshotStorageJetStream, + SnapshotRate: 50, + SnapshotStorageType: snapshot.StorageTypeJetStream, }) if err != nil { panic(err.Error()) diff --git a/pkg/template/basic/shards/game/system/external.go b/pkg/template/basic/shards/game/system/external.go index 4fc730138..60ac4bdfb 100644 --- a/pkg/template/basic/shards/game/system/external.go +++ b/pkg/template/basic/shards/game/system/external.go @@ -8,7 +8,6 @@ import ( // ExternalCommand should originate from another game shard. type ExternalCommand struct { - cardinal.BaseCommand Message string } @@ -17,7 +16,6 @@ func (ExternalCommand) Name() string { } type CallExternalCommand struct { - cardinal.BaseCommand Message string } @@ -30,13 +28,12 @@ type CallExternalSystemState struct { CallExternalCommands cardinal.WithCommand[CallExternalCommand] } -func CallExternalSystem(state *CallExternalSystemState) error { +func CallExternalSystem(state *CallExternalSystemState) { for cmd := range state.CallExternalCommands.Iter() { state.Logger().Info().Msg("Received call-external message") - otherworld.Matchmaking.Send(&state.BaseSystemState, CreatePlayerCommand{ - Nickname: cmd.Payload().Message, + otherworld.Matchmaking.SendCommand(&state.BaseSystemState, CreatePlayerCommand{ + Nickname: cmd.Payload.Message, }) } - return nil } diff --git a/pkg/template/basic/shards/game/system/graveyard.go b/pkg/template/basic/shards/game/system/graveyard.go index c26d05a7d..a6d009345 100644 --- a/pkg/template/basic/shards/game/system/graveyard.go +++ b/pkg/template/basic/shards/game/system/graveyard.go @@ -13,12 +13,11 @@ type GraveyardSystemState struct { Graves GraveSearch } -func GraveyardSystem(state *GraveyardSystemState) error { +func GraveyardSystem(state *GraveyardSystemState) { for event := range state.PlayerDeathSystemEvents.Iter() { _, entity := state.Graves.Create() entity.Grave.Set(component.Gravestone{Nickname: event.Nickname}) state.Logger().Info().Msgf("Created grave stone for player %s", event.Nickname) } - return nil } diff --git a/pkg/template/basic/shards/game/system/init_player_spawner.go b/pkg/template/basic/shards/game/system/init_player_spawner.go index ac8023529..6c3be2d2e 100644 --- a/pkg/template/basic/shards/game/system/init_player_spawner.go +++ b/pkg/template/basic/shards/game/system/init_player_spawner.go @@ -13,7 +13,7 @@ type PlayerSpawnerSystemState struct { Players PlayerSearch } -func PlayerSpawnerSystem(state *PlayerSpawnerSystemState) error { +func PlayerSpawnerSystem(state *PlayerSpawnerSystemState) { for i := range 10 { name := fmt.Sprintf("default-%d", i) @@ -23,5 +23,4 @@ func PlayerSpawnerSystem(state *PlayerSpawnerSystemState) error { state.Logger().Info().Uint32("entity", uint32(id)).Msgf("Created player %s", name) } - return nil } diff --git a/pkg/template/basic/shards/game/system/player_attack.go b/pkg/template/basic/shards/game/system/player_attack.go index 69131850d..a76051405 100644 --- a/pkg/template/basic/shards/game/system/player_attack.go +++ b/pkg/template/basic/shards/game/system/player_attack.go @@ -9,7 +9,6 @@ import ( ) type AttackPlayerCommand struct { - cardinal.BaseCommand Target string Damage uint32 } @@ -26,9 +25,9 @@ type AttackPlayerSystemState struct { Players PlayerSearch } -func AttackPlayerSystem(state *AttackPlayerSystemState) error { +func AttackPlayerSystem(state *AttackPlayerSystemState) { for cmd := range state.AttackPlayerCommands.Iter() { - command := cmd.Payload() + command := cmd.Payload for entity, player := range state.Players.Iter() { tag := player.Tag.Get() @@ -54,5 +53,4 @@ func AttackPlayerSystem(state *AttackPlayerSystemState) error { } } } - return nil } diff --git a/pkg/template/basic/shards/game/system/player_spawner.go b/pkg/template/basic/shards/game/system/player_spawner.go index 6807162a8..0ae9a229a 100644 --- a/pkg/template/basic/shards/game/system/player_spawner.go +++ b/pkg/template/basic/shards/game/system/player_spawner.go @@ -8,7 +8,6 @@ import ( ) type CreatePlayerCommand struct { - cardinal.BaseCommand Nickname string `json:"nickname"` } @@ -23,9 +22,9 @@ type CreatePlayerSystemState struct { Players PlayerSearch } -func CreatePlayerSystem(state *CreatePlayerSystemState) error { +func CreatePlayerSystem(state *CreatePlayerSystemState) { for cmd := range state.CreatePlayerCommands.Iter() { - command := cmd.Payload() + command := cmd.Payload _, entity := state.Players.Create() @@ -33,8 +32,7 @@ func CreatePlayerSystem(state *CreatePlayerSystemState) error { entity.Health.Set(component.Health{HP: 100}) state.NewPlayerEvents.Emit(event.NewPlayer{Nickname: command.Nickname}) - state.Logger().Info().Uint32("entity", uint32(0)).Str("persona", cmd.Persona()). + state.Logger().Info().Uint32("entity", uint32(0)).Str("persona", cmd.Persona). Msgf("Created player %s", command.Nickname) } - return nil } diff --git a/pkg/template/basic/shards/game/system/regen.go b/pkg/template/basic/shards/game/system/regen.go index 2a7ee8d37..bf6c1c215 100644 --- a/pkg/template/basic/shards/game/system/regen.go +++ b/pkg/template/basic/shards/game/system/regen.go @@ -13,9 +13,8 @@ type RegenSystemState struct { }] } -func RegenSystem(state *RegenSystemState) error { +func RegenSystem(state *RegenSystemState) { for _, health := range state.Iter() { // Another shorthand health.Set(component.Health{HP: health.Get().HP + 10}) } - return nil } diff --git a/pkg/template/multi-shard/shards/chat/command/user_chat.go b/pkg/template/multi-shard/shards/chat/command/user_chat.go index 300e07df3..5b9ac7c88 100644 --- a/pkg/template/multi-shard/shards/chat/command/user_chat.go +++ b/pkg/template/multi-shard/shards/chat/command/user_chat.go @@ -1,9 +1,6 @@ package command -import "github.com/argus-labs/world-engine/pkg/cardinal" - type UserChat struct { - cardinal.BaseCommand ArgusAuthID string `json:"argus_auth_id"` ArgusAuthName string `json:"argus_auth_name"` Message string `json:"message"` diff --git a/pkg/template/multi-shard/shards/chat/event/event.go b/pkg/template/multi-shard/shards/chat/event/event.go index 0b58d96da..78994f3e4 100644 --- a/pkg/template/multi-shard/shards/chat/event/event.go +++ b/pkg/template/multi-shard/shards/chat/event/event.go @@ -2,14 +2,11 @@ package event import ( "time" - - "github.com/argus-labs/world-engine/pkg/cardinal" ) // Someone typed a message in chat type UserChat struct { - cardinal.BaseEvent ArgusAuthID string `json:"argus_auth_id"` ArgusAuthName string `json:"argus_auth_name"` Message string `json:"message"` diff --git a/pkg/template/multi-shard/shards/chat/main.go b/pkg/template/multi-shard/shards/chat/main.go index f2b7f882d..4cb1e6eac 100644 --- a/pkg/template/multi-shard/shards/chat/main.go +++ b/pkg/template/multi-shard/shards/chat/main.go @@ -8,8 +8,8 @@ import ( func main() { world, err := cardinal.NewWorld(cardinal.WorldOptions{ - TickRate: 20, - EpochFrequency: 200, + TickRate: 20, + SnapshotRate: 50, }) if err != nil { panic(err.Error()) diff --git a/pkg/template/multi-shard/shards/chat/system/user_chat.go b/pkg/template/multi-shard/shards/chat/system/user_chat.go index 9f7e6c8f4..9f13f8f0b 100644 --- a/pkg/template/multi-shard/shards/chat/system/user_chat.go +++ b/pkg/template/multi-shard/shards/chat/system/user_chat.go @@ -17,9 +17,9 @@ type UserChatSystemState struct { ChatSearch ChatSearch } -func UserChatSystem(state *UserChatSystemState) error { +func UserChatSystem(state *UserChatSystemState) { for cmd := range state.UserChatCommands.Iter() { - command := cmd.Payload() + command := cmd.Payload timestamp := time.Now() @@ -44,5 +44,4 @@ func UserChatSystem(state *UserChatSystemState) error { Timestamp: timestamp, }) } - return nil } diff --git a/pkg/template/multi-shard/shards/game/command/player_leave.go b/pkg/template/multi-shard/shards/game/command/player_leave.go index d460643c0..68221735a 100644 --- a/pkg/template/multi-shard/shards/game/command/player_leave.go +++ b/pkg/template/multi-shard/shards/game/command/player_leave.go @@ -1,9 +1,6 @@ package command -import "github.com/argus-labs/world-engine/pkg/cardinal" - type PlayerLeave struct { - cardinal.BaseCommand ArgusAuthID string `json:"argus_auth_id"` } diff --git a/pkg/template/multi-shard/shards/game/command/player_move.go b/pkg/template/multi-shard/shards/game/command/player_move.go index aa149d2c3..eeb41ac6a 100644 --- a/pkg/template/multi-shard/shards/game/command/player_move.go +++ b/pkg/template/multi-shard/shards/game/command/player_move.go @@ -1,9 +1,6 @@ package command -import "github.com/argus-labs/world-engine/pkg/cardinal" - type MovePlayer struct { - cardinal.BaseCommand ArgusAuthID string `json:"argus_auth_id"` X uint32 `json:"x"` Y uint32 `json:"y"` diff --git a/pkg/template/multi-shard/shards/game/command/player_spawn.go b/pkg/template/multi-shard/shards/game/command/player_spawn.go index cc08a9ef3..fc5847065 100644 --- a/pkg/template/multi-shard/shards/game/command/player_spawn.go +++ b/pkg/template/multi-shard/shards/game/command/player_spawn.go @@ -1,11 +1,6 @@ package command -import ( - "github.com/argus-labs/world-engine/pkg/cardinal" -) - type PlayerSpawn struct { - cardinal.BaseCommand ArgusAuthID string `json:"argus_auth_id"` ArgusAuthName string `json:"argus_auth_name"` X uint32 `json:"x"` diff --git a/pkg/template/multi-shard/shards/game/event/event.go b/pkg/template/multi-shard/shards/game/event/event.go index d59647f1c..7340203a9 100644 --- a/pkg/template/multi-shard/shards/game/event/event.go +++ b/pkg/template/multi-shard/shards/game/event/event.go @@ -1,11 +1,8 @@ package event -import "github.com/argus-labs/world-engine/pkg/cardinal" - // New player has joined the quadrant type PlayerSpawn struct { - cardinal.BaseEvent ArgusAuthID string `json:"argus_auth_id"` ArgusAuthName string `json:"argus_auth_name"` X uint32 `json:"x"` @@ -19,7 +16,6 @@ func (PlayerSpawn) Name() string { // Player has moved inside the quadrant type PlayerMovement struct { - cardinal.BaseEvent ArgusAuthID string `json:"argus_auth_id"` ArgusAuthName string `json:"argus_auth_name"` X uint32 `json:"x"` @@ -33,7 +29,6 @@ func (PlayerMovement) Name() string { // Player has left the quadrant (either by leaving the quadrant or going offline) type PlayerDeparture struct { - cardinal.BaseEvent ArgusAuthID string `json:"argus_auth_id"` } diff --git a/pkg/template/multi-shard/shards/game/main.go b/pkg/template/multi-shard/shards/game/main.go index 57f5e91a7..3b583a65e 100644 --- a/pkg/template/multi-shard/shards/game/main.go +++ b/pkg/template/multi-shard/shards/game/main.go @@ -8,8 +8,8 @@ import ( func main() { world, err := cardinal.NewWorld(cardinal.WorldOptions{ - TickRate: 20, - EpochFrequency: 200, + TickRate: 20, + SnapshotRate: 50, }) if err != nil { panic(err.Error()) diff --git a/pkg/template/multi-shard/shards/game/system/online_status_updater.go b/pkg/template/multi-shard/shards/game/system/online_status_updater.go index 7e61a7b1e..c7e4c58ed 100644 --- a/pkg/template/multi-shard/shards/game/system/online_status_updater.go +++ b/pkg/template/multi-shard/shards/game/system/online_status_updater.go @@ -18,7 +18,7 @@ type OnlineStatusUpdaterState struct { PlayerDepartureEvent cardinal.WithEvent[event.PlayerDeparture] } -func OnlineStatusUpdater(state *OnlineStatusUpdaterState) error { +func OnlineStatusUpdater(state *OnlineStatusUpdaterState) { for entity, player := range state.Players.Iter() { isOnline := player.OnlineStatus.Get().Online lastActive := player.OnlineStatus.Get().LastActive @@ -36,5 +36,4 @@ func OnlineStatusUpdater(state *OnlineStatusUpdaterState) error { Msgf("Player %s (id: %s) is offline", player.PlayerTag.Get().ArgusAuthName, player.PlayerTag.Get().ArgusAuthID) } } - return nil } diff --git a/pkg/template/multi-shard/shards/game/system/player_leave.go b/pkg/template/multi-shard/shards/game/system/player_leave.go index d7c711471..4c24a879b 100644 --- a/pkg/template/multi-shard/shards/game/system/player_leave.go +++ b/pkg/template/multi-shard/shards/game/system/player_leave.go @@ -5,7 +5,6 @@ import ( "github.com/argus-labs/world-engine/pkg/template/multi-shard/shards/game/event" "github.com/argus-labs/world-engine/pkg/cardinal" - "github.com/argus-labs/world-engine/pkg/cardinal/ecs" ) type PlayerLeaveSystemState struct { @@ -16,15 +15,15 @@ type PlayerLeaveSystemState struct { } // PlayerLeaveSystem is called when a player leaves a quadrant (e.g. to join another quadrant). -func PlayerLeaveSystem(state *PlayerLeaveSystemState) error { - players := make(map[string]ecs.EntityID) +func PlayerLeaveSystem(state *PlayerLeaveSystemState) { + players := make(map[string]cardinal.EntityID) for entity, player := range state.Players.Iter() { players[player.Tag.Get().ArgusAuthID] = entity } for cmd := range state.PlayerLeaveCommands.Iter() { - command := cmd.Payload() + command := cmd.Payload entityID, exists := players[command.ArgusAuthID] if !exists { @@ -38,5 +37,4 @@ func PlayerLeaveSystem(state *PlayerLeaveSystemState) error { ArgusAuthID: command.ArgusAuthID, }) } - return nil } diff --git a/pkg/template/multi-shard/shards/game/system/player_move.go b/pkg/template/multi-shard/shards/game/system/player_move.go index 6ec163b12..7b9b14ce1 100644 --- a/pkg/template/multi-shard/shards/game/system/player_move.go +++ b/pkg/template/multi-shard/shards/game/system/player_move.go @@ -18,9 +18,9 @@ type MovePlayerSystemState struct { Players PlayerSearch } -func MovePlayerSystem(state *MovePlayerSystemState) error { +func MovePlayerSystem(state *MovePlayerSystemState) { for cmd := range state.MovePlayerCommands.Iter() { - command := cmd.Payload() + command := cmd.Payload for entity, player := range state.Players.Iter() { tag := player.Tag.Get() @@ -56,5 +56,4 @@ func MovePlayerSystem(state *MovePlayerSystemState) error { Msgf("Player %s (id: %s) moved to %d, %d", name, tag.ArgusAuthID, command.X, command.Y) } } - return nil } diff --git a/pkg/template/multi-shard/shards/game/system/player_set_updater.go b/pkg/template/multi-shard/shards/game/system/player_set_updater.go index 59b96cb54..6d9e35d0d 100644 --- a/pkg/template/multi-shard/shards/game/system/player_set_updater.go +++ b/pkg/template/multi-shard/shards/game/system/player_set_updater.go @@ -10,10 +10,9 @@ type PlayerSetUpdaterState struct { } // PlayerSetUpdater updates the playerSet with all players in the world state. -func PlayerSetUpdater(state *PlayerSetUpdaterState) error { +func PlayerSetUpdater(state *PlayerSetUpdaterState) { playerSet.Clear() for _, player := range state.Players.Iter() { playerSet.Add(player.Tag.Get().ArgusAuthID) } - return nil } diff --git a/pkg/template/multi-shard/shards/game/system/player_spawn.go b/pkg/template/multi-shard/shards/game/system/player_spawn.go index 48ae736a2..dd5d4c8bc 100644 --- a/pkg/template/multi-shard/shards/game/system/player_spawn.go +++ b/pkg/template/multi-shard/shards/game/system/player_spawn.go @@ -20,9 +20,9 @@ type SpawnPlayerSystemState struct { Players PlayerSearch } -func PlayerSpawnSystem(state *SpawnPlayerSystemState) error { +func PlayerSpawnSystem(state *SpawnPlayerSystemState) { for cmd := range state.SpawnPlayerCommands.Iter() { - command := cmd.Payload() + command := cmd.Payload // Regardless of whether the player exists or not, we emit a spawn event // Because the act of spawning is also creating (if they don’t already exist) @@ -50,11 +50,10 @@ func PlayerSpawnSystem(state *SpawnPlayerSystemState) error { Msgf("Created player %s (id: %s)", command.ArgusAuthName, command.ArgusAuthID) // Inform chat shard about the spawn - otherworld.Chat.Send(&state.BaseSystemState, chatcommand.UserChat{ + otherworld.Chat.SendCommand(&state.BaseSystemState, chatcommand.UserChat{ ArgusAuthID: command.ArgusAuthID, ArgusAuthName: command.ArgusAuthName, Message: fmt.Sprintf("%s joined at (%s)", command.ArgusAuthName, state.Timestamp().Format(time.RFC3339)), }) } - return nil } diff --git a/pkg/testutils/ecs.go b/pkg/testutils/ecs.go index dfb6f0661..ba06d8919 100644 --- a/pkg/testutils/ecs.go +++ b/pkg/testutils/ecs.go @@ -90,6 +90,33 @@ func (SimpleCommand) Name() string { return "simple_command" } +type CommandA struct { + X, Y, Z float64 +} + +func (CommandA) Name() string { + return "command_a" +} + +type CommandB struct { + ID uint64 + Label string + Enabled bool +} + +func (CommandB) Name() string { + return "command_b" +} + +type CommandC struct { + Values [8]int32 + Counter uint16 +} + +func (CommandC) Name() string { + return "command_c" +} + // ------------------------------------------------------------------------------------------------- // Events // ------------------------------------------------------------------------------------------------- diff --git a/pkg/testutils/random.go b/pkg/testutils/random.go index ef337ded8..06c976d08 100644 --- a/pkg/testutils/random.go +++ b/pkg/testutils/random.go @@ -4,9 +4,12 @@ import ( "fmt" "math/rand/v2" "os" + "slices" "strconv" "testing" "time" + + "github.com/argus-labs/world-engine/pkg/assert" ) var Seed uint64 //nolint:gochecknoglobals // intentionally global for test reproducibility @@ -39,21 +42,39 @@ func RandMapKey[K comparable, V any](r *rand.Rand, m map[K]V) K { panic("unreachable") } -// WeightedOp is a constraint for operation types that use their value as the weight. -type WeightedOp interface { - ~uint8 | ~uint16 | ~uint32 | ~int +// OpWeights maps operation names to their weights. +type OpWeights = map[string]uint64 + +// RandOpWeights randomly selects a subset of operations and assigns random weights (1-100) to each. +// At minimum 1 operation is enabled, at most all operations are enabled. +func RandOpWeights(r *rand.Rand, ops []string) OpWeights { + assert.That(len(ops) > 0, "you need multiple operations to randomize") + + // Randomly select how many operations to enable (1 to len(ops)). + numEnabled := 1 + r.IntN(len(ops)) + + // Shuffle and take the first numEnabled operations. + shuffled := slices.Clone(ops) + r.Shuffle(len(shuffled), func(i, j int) { + shuffled[i], shuffled[j] = shuffled[j], shuffled[i] + }) + + weights := make(map[string]uint64, numEnabled) + for i := range numEnabled { + weights[shuffled[i]] = uint64(1 + r.IntN(100)) //nolint:gosec // not gonna happen + } + return weights } -// RandWeightedOp returns a random operation from a slice, using each op's value as its weight. -func RandWeightedOp[T WeightedOp](r *rand.Rand, ops []T) T { - var total int - for _, op := range ops { - total += int(op) +// RandWeightedOp returns a random operation from a map, using each op's value as its weight. +func RandWeightedOp(r *rand.Rand, ops OpWeights) string { + var total uint64 + for _, weight := range ops { + total += weight } - pick := r.IntN(total) - for _, op := range ops { - weight := int(op) + pick := r.Uint64N(total) + for op, weight := range ops { if pick < weight { return op } @@ -61,3 +82,13 @@ func RandWeightedOp[T WeightedOp](r *rand.Rand, ops []T) T { } panic("unreachable") } + +// RandString generates a random alphanumeric string of the given length. +func RandString(r *rand.Rand, length int) string { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = chars[r.IntN(len(chars))] + } + return string(b) +} diff --git a/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Debug.cs b/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Debug.cs new file mode 100644 index 000000000..71ef26184 --- /dev/null +++ b/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Debug.cs @@ -0,0 +1,726 @@ +// +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: worldengine/cardinal/v1/debug.proto +// +#pragma warning disable 1591, 0612, 3021, 8981 +#region Designer generated code + +using pb = global::Google.Protobuf; +using pbc = global::Google.Protobuf.Collections; +using pbr = global::Google.Protobuf.Reflection; +using scg = global::System.Collections.Generic; +namespace WorldEngine.Proto.Cardinal.V1 { + + /// Holder for reflection information generated from worldengine/cardinal/v1/debug.proto + public static partial class DebugReflection { + + #region Descriptor + /// File descriptor for worldengine/cardinal/v1/debug.proto + public static pbr::FileDescriptor Descriptor { + get { return descriptor; } + } + private static pbr::FileDescriptor descriptor; + + static DebugReflection() { + byte[] descriptorData = global::System.Convert.FromBase64String( + string.Concat( + "CiN3b3JsZGVuZ2luZS9jYXJkaW5hbC92MS9kZWJ1Zy5wcm90bxIXd29ybGRl", + "bmdpbmUuY2FyZGluYWwudjEaHGdvb2dsZS9wcm90b2J1Zi9zdHJ1Y3QucHJv", + "dG8iEwoRSW50cm9zcGVjdFJlcXVlc3Qi1wEKEkludHJvc3BlY3RSZXNwb25z", + "ZRI/Cghjb21tYW5kcxgBIAMoCzIjLndvcmxkZW5naW5lLmNhcmRpbmFsLnYx", + "LlR5cGVTY2hlbWFSCGNvbW1hbmRzEkMKCmNvbXBvbmVudHMYAiADKAsyIy53", + "b3JsZGVuZ2luZS5jYXJkaW5hbC52MS5UeXBlU2NoZW1hUgpjb21wb25lbnRz", + "EjsKBmV2ZW50cxgDIAMoCzIjLndvcmxkZW5naW5lLmNhcmRpbmFsLnYxLlR5", + "cGVTY2hlbWFSBmV2ZW50cyJRCgpUeXBlU2NoZW1hEhIKBG5hbWUYASABKAlS", + "BG5hbWUSLwoGc2NoZW1hGAIgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVj", + "dFIGc2NoZW1hMnUKDERlYnVnU2VydmljZRJlCgpJbnRyb3NwZWN0Eioud29y", + "bGRlbmdpbmUuY2FyZGluYWwudjEuSW50cm9zcGVjdFJlcXVlc3QaKy53b3Js", + "ZGVuZ2luZS5jYXJkaW5hbC52MS5JbnRyb3NwZWN0UmVzcG9uc2VCdFpSZ2l0", + "aHViLmNvbS9hcmd1cy1sYWJzL3dvcmxkLWVuZ2luZS9wcm90by9nZW4vZ28v", + "d29ybGRlbmdpbmUvY2FyZGluYWwvdjE7Y2FyZGluYWx2MaoCHVdvcmxkRW5n", + "aW5lLlByb3RvLkNhcmRpbmFsLlYxYgZwcm90bzM=")); + descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, + new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.StructReflection.Descriptor, }, + new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { + new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest), global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest.Parser, null, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.IntrospectResponse), global::WorldEngine.Proto.Cardinal.V1.IntrospectResponse.Parser, new[]{ "Commands", "Components", "Events" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.TypeSchema), global::WorldEngine.Proto.Cardinal.V1.TypeSchema.Parser, new[]{ "Name", "Schema" }, null, null, null, null) + })); + } + #endregion + + } + #region Messages + /// + /// IntrospectRequest is the request message for the Introspect RPC. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class IntrospectRequest : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new IntrospectRequest()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::WorldEngine.Proto.Cardinal.V1.DebugReflection.Descriptor.MessageTypes[0]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IntrospectRequest() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IntrospectRequest(IntrospectRequest other) : this() { + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IntrospectRequest Clone() { + return new IntrospectRequest(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as IntrospectRequest); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(IntrospectRequest other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(IntrospectRequest other) { + if (other == null) { + return; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + } + } + } + #endif + + } + + /// + /// IntrospectResponse contains introspection metadata about the world. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class IntrospectResponse : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new IntrospectResponse()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::WorldEngine.Proto.Cardinal.V1.DebugReflection.Descriptor.MessageTypes[1]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IntrospectResponse() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IntrospectResponse(IntrospectResponse other) : this() { + commands_ = other.commands_.Clone(); + components_ = other.components_.Clone(); + events_ = other.events_.Clone(); + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public IntrospectResponse Clone() { + return new IntrospectResponse(this); + } + + /// Field number for the "commands" field. + public const int CommandsFieldNumber = 1; + private static readonly pb::FieldCodec _repeated_commands_codec + = pb::FieldCodec.ForMessage(10, global::WorldEngine.Proto.Cardinal.V1.TypeSchema.Parser); + private readonly pbc::RepeatedField commands_ = new pbc::RepeatedField(); + /// + /// JSON schemas for registered commands. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Commands { + get { return commands_; } + } + + /// Field number for the "components" field. + public const int ComponentsFieldNumber = 2; + private static readonly pb::FieldCodec _repeated_components_codec + = pb::FieldCodec.ForMessage(18, global::WorldEngine.Proto.Cardinal.V1.TypeSchema.Parser); + private readonly pbc::RepeatedField components_ = new pbc::RepeatedField(); + /// + /// JSON schemas for registered components. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Components { + get { return components_; } + } + + /// Field number for the "events" field. + public const int EventsFieldNumber = 3; + private static readonly pb::FieldCodec _repeated_events_codec + = pb::FieldCodec.ForMessage(26, global::WorldEngine.Proto.Cardinal.V1.TypeSchema.Parser); + private readonly pbc::RepeatedField events_ = new pbc::RepeatedField(); + /// + /// JSON schemas for registered events. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public pbc::RepeatedField Events { + get { return events_; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as IntrospectResponse); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(IntrospectResponse other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if(!commands_.Equals(other.commands_)) return false; + if(!components_.Equals(other.components_)) return false; + if(!events_.Equals(other.events_)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + hash ^= commands_.GetHashCode(); + hash ^= components_.GetHashCode(); + hash ^= events_.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + commands_.WriteTo(output, _repeated_commands_codec); + components_.WriteTo(output, _repeated_components_codec); + events_.WriteTo(output, _repeated_events_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + commands_.WriteTo(ref output, _repeated_commands_codec); + components_.WriteTo(ref output, _repeated_components_codec); + events_.WriteTo(ref output, _repeated_events_codec); + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + size += commands_.CalculateSize(_repeated_commands_codec); + size += components_.CalculateSize(_repeated_components_codec); + size += events_.CalculateSize(_repeated_events_codec); + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(IntrospectResponse other) { + if (other == null) { + return; + } + commands_.Add(other.commands_); + components_.Add(other.components_); + events_.Add(other.events_); + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + commands_.AddEntriesFrom(input, _repeated_commands_codec); + break; + } + case 18: { + components_.AddEntriesFrom(input, _repeated_components_codec); + break; + } + case 26: { + events_.AddEntriesFrom(input, _repeated_events_codec); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + commands_.AddEntriesFrom(ref input, _repeated_commands_codec); + break; + } + case 18: { + components_.AddEntriesFrom(ref input, _repeated_components_codec); + break; + } + case 26: { + events_.AddEntriesFrom(ref input, _repeated_events_codec); + break; + } + } + } + } + #endif + + } + + /// + /// TypeSchema represents the JSON schema for a registered type. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class TypeSchema : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new TypeSchema()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::WorldEngine.Proto.Cardinal.V1.DebugReflection.Descriptor.MessageTypes[2]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TypeSchema() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TypeSchema(TypeSchema other) : this() { + name_ = other.name_; + schema_ = other.schema_ != null ? other.schema_.Clone() : null; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public TypeSchema Clone() { + return new TypeSchema(this); + } + + /// Field number for the "name" field. + public const int NameFieldNumber = 1; + private string name_ = ""; + /// + /// Name of the type. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string Name { + get { return name_; } + set { + name_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + + /// Field number for the "schema" field. + public const int SchemaFieldNumber = 2; + private global::Google.Protobuf.WellKnownTypes.Struct schema_; + /// + /// JSON schema for the type. + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Struct Schema { + get { return schema_; } + set { + schema_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as TypeSchema); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(TypeSchema other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (Name != other.Name) return false; + if (!object.Equals(Schema, other.Schema)) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (Name.Length != 0) hash ^= Name.GetHashCode(); + if (schema_ != null) hash ^= Schema.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (Name.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Name); + } + if (schema_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Schema); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (Name.Length != 0) { + output.WriteRawTag(10); + output.WriteString(Name); + } + if (schema_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Schema); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (Name.Length != 0) { + size += 1 + pb::CodedOutputStream.ComputeStringSize(Name); + } + if (schema_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Schema); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(TypeSchema other) { + if (other == null) { + return; + } + if (other.Name.Length != 0) { + Name = other.Name; + } + if (other.schema_ != null) { + if (schema_ == null) { + Schema = new global::Google.Protobuf.WellKnownTypes.Struct(); + } + Schema.MergeFrom(other.Schema); + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 10: { + Name = input.ReadString(); + break; + } + case 18: { + if (schema_ == null) { + Schema = new global::Google.Protobuf.WellKnownTypes.Struct(); + } + input.ReadMessage(Schema); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 10: { + Name = input.ReadString(); + break; + } + case 18: { + if (schema_ == null) { + Schema = new global::Google.Protobuf.WellKnownTypes.Struct(); + } + input.ReadMessage(Schema); + break; + } + } + } + } + #endif + + } + + #endregion + +} + +#endregion Designer generated code diff --git a/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/DebugGrpc.cs b/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/DebugGrpc.cs new file mode 100644 index 000000000..d8bcf277d --- /dev/null +++ b/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/DebugGrpc.cs @@ -0,0 +1,198 @@ +// +// Generated by the protocol buffer compiler. DO NOT EDIT! +// source: worldengine/cardinal/v1/debug.proto +// +#pragma warning disable 0414, 1591, 8981, 0612 +#region Designer generated code + +using grpc = global::Grpc.Core; + +namespace WorldEngine.Proto.Cardinal.V1 { + /// + /// DebugService provides debugging and introspection endpoints for Cardinal. + /// This service is intended for dev tooling (e.g., AI agents, debugging tools). + /// + public static partial class DebugService + { + static readonly string __ServiceName = "worldengine.cardinal.v1.DebugService"; + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context) + { + #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION + if (message is global::Google.Protobuf.IBufferMessage) + { + context.SetPayloadLength(message.CalculateSize()); + global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter()); + context.Complete(); + return; + } + #endif + context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message)); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static class __Helper_MessageCache + { + public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T)); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static T __Helper_DeserializeMessage(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser parser) where T : global::Google.Protobuf.IMessage + { + #if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION + if (__Helper_MessageCache.IsBufferMessage) + { + return parser.ParseFrom(context.PayloadAsReadOnlySequence()); + } + #endif + return parser.ParseFrom(context.PayloadAsNewBuffer()); + } + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_worldengine_cardinal_v1_IntrospectRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest.Parser)); + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Marshaller __Marshaller_worldengine_cardinal_v1_IntrospectResponse = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::WorldEngine.Proto.Cardinal.V1.IntrospectResponse.Parser)); + + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + static readonly grpc::Method __Method_Introspect = new grpc::Method( + grpc::MethodType.Unary, + __ServiceName, + "Introspect", + __Marshaller_worldengine_cardinal_v1_IntrospectRequest, + __Marshaller_worldengine_cardinal_v1_IntrospectResponse); + + /// Service descriptor + public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor + { + get { return global::WorldEngine.Proto.Cardinal.V1.DebugReflection.Descriptor.Services[0]; } + } + + /// Base class for server-side implementations of DebugService + [grpc::BindServiceMethod(typeof(DebugService), "BindService")] + public abstract partial class DebugServiceBase + { + /// + /// Introspect returns metadata about the registered types in the world. + /// The result includes JSON schemas for commands, components, and events. + /// + /// The request received from the client. + /// The context of the server-side call handler being invoked. + /// The response to send back to the client (wrapped by a task). + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::System.Threading.Tasks.Task Introspect(global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest request, grpc::ServerCallContext context) + { + throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, "")); + } + + } + + /// Client for DebugService + public partial class DebugServiceClient : grpc::ClientBase + { + /// Creates a new client for DebugService + /// The channel to use to make remote calls. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public DebugServiceClient(grpc::ChannelBase channel) : base(channel) + { + } + /// Creates a new client for DebugService that uses a custom CallInvoker. + /// The callInvoker to use to make remote calls. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public DebugServiceClient(grpc::CallInvoker callInvoker) : base(callInvoker) + { + } + /// Protected parameterless constructor to allow creation of test doubles. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected DebugServiceClient() : base() + { + } + /// Protected constructor to allow creation of configured clients. + /// The client configuration. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected DebugServiceClient(ClientBaseConfiguration configuration) : base(configuration) + { + } + + /// + /// Introspect returns metadata about the registered types in the world. + /// The result includes JSON schemas for commands, components, and events. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::WorldEngine.Proto.Cardinal.V1.IntrospectResponse Introspect(global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return Introspect(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Introspect returns metadata about the registered types in the world. + /// The result includes JSON schemas for commands, components, and events. + /// + /// The request to send to the server. + /// The options for the call. + /// The response received from the server. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual global::WorldEngine.Proto.Cardinal.V1.IntrospectResponse Introspect(global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest request, grpc::CallOptions options) + { + return CallInvoker.BlockingUnaryCall(__Method_Introspect, null, options, request); + } + /// + /// Introspect returns metadata about the registered types in the world. + /// The result includes JSON schemas for commands, components, and events. + /// + /// The request to send to the server. + /// The initial metadata to send with the call. This parameter is optional. + /// An optional deadline for the call. The call will be cancelled if deadline is hit. + /// An optional token for canceling the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IntrospectAsync(global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken)) + { + return IntrospectAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken)); + } + /// + /// Introspect returns metadata about the registered types in the world. + /// The result includes JSON schemas for commands, components, and events. + /// + /// The request to send to the server. + /// The options for the call. + /// The call object. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public virtual grpc::AsyncUnaryCall IntrospectAsync(global::WorldEngine.Proto.Cardinal.V1.IntrospectRequest request, grpc::CallOptions options) + { + return CallInvoker.AsyncUnaryCall(__Method_Introspect, null, options, request); + } + /// Creates a new instance of client from given ClientBaseConfiguration. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + protected override DebugServiceClient NewInstance(ClientBaseConfiguration configuration) + { + return new DebugServiceClient(configuration); + } + } + + /// Creates service definition that can be registered with a server + /// An object implementing the server-side handling logic. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public static grpc::ServerServiceDefinition BindService(DebugServiceBase serviceImpl) + { + return grpc::ServerServiceDefinition.CreateBuilder() + .AddMethod(__Method_Introspect, serviceImpl.Introspect).Build(); + } + + /// Register service method with a service binder with or without implementation. Useful when customizing the service binding logic. + /// Note: this method is part of an experimental API that can change or be removed without any prior notice. + /// Service methods will be bound by calling AddMethod on this object. + /// An object implementing the server-side handling logic. + [global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)] + public static void BindService(grpc::ServiceBinderBase serviceBinder, DebugServiceBase serviceImpl) + { + serviceBinder.AddMethod(__Method_Introspect, serviceImpl == null ? null : new grpc::UnaryServerMethod(serviceImpl.Introspect)); + } + + } +} +#endregion diff --git a/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Snapshot.cs b/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Snapshot.cs index ab631dcbe..0ff834b0d 100644 --- a/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Snapshot.cs +++ b/proto/gen/csharp/WorldEngine/Proto/Cardinal/V1/Snapshot.cs @@ -26,23 +26,29 @@ static SnapshotReflection() { string.Concat( "CiZ3b3JsZGVuZ2luZS9jYXJkaW5hbC92MS9zbmFwc2hvdC5wcm90bxIXd29y", "bGRlbmdpbmUuY2FyZGluYWwudjEaG2J1Zi92YWxpZGF0ZS92YWxpZGF0ZS5w", - "cm90byKrAQoQQ2FyZGluYWxTbmFwc2hvdBIXCgduZXh0X2lkGAEgASgNUgZu", - "ZXh0SWQSGQoIZnJlZV9pZHMYAiADKA1SB2ZyZWVJZHMSHwoLZW50aXR5X2Fy", - "Y2gYAyADKANSCmVudGl0eUFyY2gSQgoKYXJjaGV0eXBlcxgEIAMoCzIiLndv", - "cmxkZW5naW5lLmNhcmRpbmFsLnYxLkFyY2hldHlwZVIKYXJjaGV0eXBlcyKz", - "AQoJQXJjaGV0eXBlEg4KAmlkGAEgASgFUgJpZBIrChFjb21wb25lbnRzX2Jp", - "dG1hcBgCIAEoDFIQY29tcG9uZW50c0JpdG1hcBISCgRyb3dzGAMgAygDUgRy", - "b3dzEhoKCGVudGl0aWVzGAQgAygNUghlbnRpdGllcxI5Cgdjb2x1bW5zGAUg", - "AygLMh8ud29ybGRlbmdpbmUuY2FyZGluYWwudjEuQ29sdW1uUgdjb2x1bW5z", - "IlgKBkNvbHVtbhIuCg5jb21wb25lbnRfbmFtZRgBIAEoCUIHukgEcgIQAVIN", - "Y29tcG9uZW50TmFtZRIeCgpjb21wb25lbnRzGAIgAygMUgpjb21wb25lbnRz", - "QnRaUmdpdGh1Yi5jb20vYXJndXMtbGFicy93b3JsZC1lbmdpbmUvcHJvdG8v", - "Z2VuL2dvL3dvcmxkZW5naW5lL2NhcmRpbmFsL3YxO2NhcmRpbmFsdjGqAh1X", - "b3JsZEVuZ2luZS5Qcm90by5DYXJkaW5hbC5WMWIGcHJvdG8z")); + "cm90bxofZ29vZ2xlL3Byb3RvYnVmL3RpbWVzdGFtcC5wcm90byLFAQoIU25h", + "cHNob3QSHwoLdGlja19oZWlnaHQYASABKARSCnRpY2tIZWlnaHQSOAoJdGlt", + "ZXN0YW1wGAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcFIJdGlt", + "ZXN0YW1wEkQKC3dvcmxkX3N0YXRlGAMgASgLMiMud29ybGRlbmdpbmUuY2Fy", + "ZGluYWwudjEuV29ybGRTdGF0ZVIKd29ybGRTdGF0ZRIYCgd2ZXJzaW9uGAQg", + "ASgNUgd2ZXJzaW9uIqUBCgpXb3JsZFN0YXRlEhcKB25leHRfaWQYASABKA1S", + "Bm5leHRJZBIZCghmcmVlX2lkcxgCIAMoDVIHZnJlZUlkcxIfCgtlbnRpdHlf", + "YXJjaBgDIAMoA1IKZW50aXR5QXJjaBJCCgphcmNoZXR5cGVzGAQgAygLMiIu", + "d29ybGRlbmdpbmUuY2FyZGluYWwudjEuQXJjaGV0eXBlUgphcmNoZXR5cGVz", + "IrMBCglBcmNoZXR5cGUSDgoCaWQYASABKAVSAmlkEisKEWNvbXBvbmVudHNf", + "Yml0bWFwGAIgASgMUhBjb21wb25lbnRzQml0bWFwEhIKBHJvd3MYAyADKANS", + "BHJvd3MSGgoIZW50aXRpZXMYBCADKA1SCGVudGl0aWVzEjkKB2NvbHVtbnMY", + "BSADKAsyHy53b3JsZGVuZ2luZS5jYXJkaW5hbC52MS5Db2x1bW5SB2NvbHVt", + "bnMiWAoGQ29sdW1uEi4KDmNvbXBvbmVudF9uYW1lGAEgASgJQge6SARyAhAB", + "Ug1jb21wb25lbnROYW1lEh4KCmNvbXBvbmVudHMYAiADKAxSCmNvbXBvbmVu", + "dHNCdFpSZ2l0aHViLmNvbS9hcmd1cy1sYWJzL3dvcmxkLWVuZ2luZS9wcm90", + "by9nZW4vZ28vd29ybGRlbmdpbmUvY2FyZGluYWwvdjE7Y2FyZGluYWx2MaoC", + "HVdvcmxkRW5naW5lLlByb3RvLkNhcmRpbmFsLlYxYgZwcm90bzM=")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, - new pbr::FileDescriptor[] { global::Buf.Validate.ValidateReflection.Descriptor, }, + new pbr::FileDescriptor[] { global::Buf.Validate.ValidateReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { - new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.CardinalSnapshot), global::WorldEngine.Proto.Cardinal.V1.CardinalSnapshot.Parser, new[]{ "NextId", "FreeIds", "EntityArch", "Archetypes" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.Snapshot), global::WorldEngine.Proto.Cardinal.V1.Snapshot.Parser, new[]{ "TickHeight", "Timestamp", "WorldState", "Version" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.WorldState), global::WorldEngine.Proto.Cardinal.V1.WorldState.Parser, new[]{ "NextId", "FreeIds", "EntityArch", "Archetypes" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.Archetype), global::WorldEngine.Proto.Cardinal.V1.Archetype.Parser, new[]{ "Id", "ComponentsBitmap", "Rows", "Entities", "Columns" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Cardinal.V1.Column), global::WorldEngine.Proto.Cardinal.V1.Column.Parser, new[]{ "ComponentName", "Components" }, null, null, null, null) })); @@ -52,19 +58,19 @@ static SnapshotReflection() { } #region Messages /// - /// CardinalSnapshot represents a complete snapshot of the world state. + /// Snapshot represents a point-in-time capture of shard state. /// [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] - public sealed partial class CardinalSnapshot : pb::IMessage + public sealed partial class Snapshot : pb::IMessage #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE , pb::IBufferMessage #endif { - private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new CardinalSnapshot()); + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new Snapshot()); private pb::UnknownFieldSet _unknownFields; [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public static pb::MessageParser Parser { get { return _parser; } } + public static pb::MessageParser Parser { get { return _parser; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] @@ -80,7 +86,7 @@ public sealed partial class CardinalSnapshot : pb::IMessage [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public CardinalSnapshot() { + public Snapshot() { OnConstruction(); } @@ -88,7 +94,337 @@ public CardinalSnapshot() { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public CardinalSnapshot(CardinalSnapshot other) : this() { + public Snapshot(Snapshot other) : this() { + tickHeight_ = other.tickHeight_; + timestamp_ = other.timestamp_ != null ? other.timestamp_.Clone() : null; + worldState_ = other.worldState_ != null ? other.worldState_.Clone() : null; + version_ = other.version_; + _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public Snapshot Clone() { + return new Snapshot(this); + } + + /// Field number for the "tick_height" field. + public const int TickHeightFieldNumber = 1; + private ulong tickHeight_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public ulong TickHeight { + get { return tickHeight_; } + set { + tickHeight_ = value; + } + } + + /// Field number for the "timestamp" field. + public const int TimestampFieldNumber = 2; + private global::Google.Protobuf.WellKnownTypes.Timestamp timestamp_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::Google.Protobuf.WellKnownTypes.Timestamp Timestamp { + get { return timestamp_; } + set { + timestamp_ = value; + } + } + + /// Field number for the "world_state" field. + public const int WorldStateFieldNumber = 3; + private global::WorldEngine.Proto.Cardinal.V1.WorldState worldState_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public global::WorldEngine.Proto.Cardinal.V1.WorldState WorldState { + get { return worldState_; } + set { + worldState_ = value; + } + } + + /// Field number for the "version" field. + public const int VersionFieldNumber = 4; + private uint version_; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public uint Version { + get { return version_; } + set { + version_ = value; + } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override bool Equals(object other) { + return Equals(other as Snapshot); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public bool Equals(Snapshot other) { + if (ReferenceEquals(other, null)) { + return false; + } + if (ReferenceEquals(other, this)) { + return true; + } + if (TickHeight != other.TickHeight) return false; + if (!object.Equals(Timestamp, other.Timestamp)) return false; + if (!object.Equals(WorldState, other.WorldState)) return false; + if (Version != other.Version) return false; + return Equals(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override int GetHashCode() { + int hash = 1; + if (TickHeight != 0UL) hash ^= TickHeight.GetHashCode(); + if (timestamp_ != null) hash ^= Timestamp.GetHashCode(); + if (worldState_ != null) hash ^= WorldState.GetHashCode(); + if (Version != 0) hash ^= Version.GetHashCode(); + if (_unknownFields != null) { + hash ^= _unknownFields.GetHashCode(); + } + return hash; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public override string ToString() { + return pb::JsonFormatter.ToDiagnosticString(this); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void WriteTo(pb::CodedOutputStream output) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + output.WriteRawMessage(this); + #else + if (TickHeight != 0UL) { + output.WriteRawTag(8); + output.WriteUInt64(TickHeight); + } + if (timestamp_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Timestamp); + } + if (worldState_ != null) { + output.WriteRawTag(26); + output.WriteMessage(WorldState); + } + if (Version != 0) { + output.WriteRawTag(32); + output.WriteUInt32(Version); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(output); + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { + if (TickHeight != 0UL) { + output.WriteRawTag(8); + output.WriteUInt64(TickHeight); + } + if (timestamp_ != null) { + output.WriteRawTag(18); + output.WriteMessage(Timestamp); + } + if (worldState_ != null) { + output.WriteRawTag(26); + output.WriteMessage(WorldState); + } + if (Version != 0) { + output.WriteRawTag(32); + output.WriteUInt32(Version); + } + if (_unknownFields != null) { + _unknownFields.WriteTo(ref output); + } + } + #endif + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public int CalculateSize() { + int size = 0; + if (TickHeight != 0UL) { + size += 1 + pb::CodedOutputStream.ComputeUInt64Size(TickHeight); + } + if (timestamp_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(Timestamp); + } + if (worldState_ != null) { + size += 1 + pb::CodedOutputStream.ComputeMessageSize(WorldState); + } + if (Version != 0) { + size += 1 + pb::CodedOutputStream.ComputeUInt32Size(Version); + } + if (_unknownFields != null) { + size += _unknownFields.CalculateSize(); + } + return size; + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(Snapshot other) { + if (other == null) { + return; + } + if (other.TickHeight != 0UL) { + TickHeight = other.TickHeight; + } + if (other.timestamp_ != null) { + if (timestamp_ == null) { + Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + Timestamp.MergeFrom(other.Timestamp); + } + if (other.worldState_ != null) { + if (worldState_ == null) { + WorldState = new global::WorldEngine.Proto.Cardinal.V1.WorldState(); + } + WorldState.MergeFrom(other.WorldState); + } + if (other.Version != 0) { + Version = other.Version; + } + _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public void MergeFrom(pb::CodedInputStream input) { + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + input.ReadRawMessage(this); + #else + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); + break; + case 8: { + TickHeight = input.ReadUInt64(); + break; + } + case 18: { + if (timestamp_ == null) { + Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(Timestamp); + break; + } + case 26: { + if (worldState_ == null) { + WorldState = new global::WorldEngine.Proto.Cardinal.V1.WorldState(); + } + input.ReadMessage(WorldState); + break; + } + case 32: { + Version = input.ReadUInt32(); + break; + } + } + } + #endif + } + + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { + uint tag; + while ((tag = input.ReadTag()) != 0) { + if ((tag & 7) == 4) { + // Abort on any end group tag. + return; + } + switch(tag) { + default: + _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); + break; + case 8: { + TickHeight = input.ReadUInt64(); + break; + } + case 18: { + if (timestamp_ == null) { + Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); + } + input.ReadMessage(Timestamp); + break; + } + case 26: { + if (worldState_ == null) { + WorldState = new global::WorldEngine.Proto.Cardinal.V1.WorldState(); + } + input.ReadMessage(WorldState); + break; + } + case 32: { + Version = input.ReadUInt32(); + break; + } + } + } + } + #endif + + } + + /// + /// WorldState represents the ECS world state. + /// + [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] + public sealed partial class WorldState : pb::IMessage + #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE + , pb::IBufferMessage + #endif + { + private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new WorldState()); + private pb::UnknownFieldSet _unknownFields; + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pb::MessageParser Parser { get { return _parser; } } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public static pbr::MessageDescriptor Descriptor { + get { return global::WorldEngine.Proto.Cardinal.V1.SnapshotReflection.Descriptor.MessageTypes[1]; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + pbr::MessageDescriptor pb::IMessage.Descriptor { + get { return Descriptor; } + } + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WorldState() { + OnConstruction(); + } + + partial void OnConstruction(); + + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public WorldState(WorldState other) : this() { nextId_ = other.nextId_; freeIds_ = other.freeIds_.Clone(); entityArch_ = other.entityArch_.Clone(); @@ -98,8 +434,8 @@ public CardinalSnapshot(CardinalSnapshot other) : this() { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public CardinalSnapshot Clone() { - return new CardinalSnapshot(this); + public WorldState Clone() { + return new WorldState(this); } /// Field number for the "next_id" field. @@ -159,12 +495,12 @@ public uint NextId { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { - return Equals(other as CardinalSnapshot); + return Equals(other as WorldState); } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public bool Equals(CardinalSnapshot other) { + public bool Equals(WorldState other) { if (ReferenceEquals(other, null)) { return false; } @@ -252,7 +588,7 @@ public int CalculateSize() { [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public void MergeFrom(CardinalSnapshot other) { + public void MergeFrom(WorldState other) { if (other == null) { return; } @@ -361,7 +697,7 @@ public sealed partial class Archetype : pb::IMessage [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::WorldEngine.Proto.Cardinal.V1.SnapshotReflection.Descriptor.MessageTypes[1]; } + get { return global::WorldEngine.Proto.Cardinal.V1.SnapshotReflection.Descriptor.MessageTypes[2]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] @@ -696,7 +1032,7 @@ public sealed partial class Column : pb::IMessage [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public static pbr::MessageDescriptor Descriptor { - get { return global::WorldEngine.Proto.Cardinal.V1.SnapshotReflection.Descriptor.MessageTypes[2]; } + get { return global::WorldEngine.Proto.Cardinal.V1.SnapshotReflection.Descriptor.MessageTypes[3]; } } [global::System.Diagnostics.DebuggerNonUserCodeAttribute] diff --git a/proto/gen/csharp/WorldEngine/Proto/Micro/V1/Snapshot.cs b/proto/gen/csharp/WorldEngine/Proto/Micro/V1/Snapshot.cs deleted file mode 100644 index 9b0de44c7..000000000 --- a/proto/gen/csharp/WorldEngine/Proto/Micro/V1/Snapshot.cs +++ /dev/null @@ -1,408 +0,0 @@ -// -// Generated by the protocol buffer compiler. DO NOT EDIT! -// source: worldengine/micro/v1/snapshot.proto -// -#pragma warning disable 1591, 0612, 3021, 8981 -#region Designer generated code - -using pb = global::Google.Protobuf; -using pbc = global::Google.Protobuf.Collections; -using pbr = global::Google.Protobuf.Reflection; -using scg = global::System.Collections.Generic; -namespace WorldEngine.Proto.Micro.V1 { - - /// Holder for reflection information generated from worldengine/micro/v1/snapshot.proto - public static partial class SnapshotReflection { - - #region Descriptor - /// File descriptor for worldengine/micro/v1/snapshot.proto - public static pbr::FileDescriptor Descriptor { - get { return descriptor; } - } - private static pbr::FileDescriptor descriptor; - - static SnapshotReflection() { - byte[] descriptorData = global::System.Convert.FromBase64String( - string.Concat( - "CiN3b3JsZGVuZ2luZS9taWNyby92MS9zbmFwc2hvdC5wcm90bxIUd29ybGRl", - "bmdpbmUubWljcm8udjEaH2dvb2dsZS9wcm90b2J1Zi90aW1lc3RhbXAucHJv", - "dG8iuwEKCFNuYXBzaG90EiEKDGVwb2NoX2hlaWdodBgBIAEoBFILZXBvY2hI", - "ZWlnaHQSHwoLdGlja19oZWlnaHQYAiABKARSCnRpY2tIZWlnaHQSOAoJdGlt", - "ZXN0YW1wGAMgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcFIJdGlt", - "ZXN0YW1wEh0KCnN0YXRlX2hhc2gYBCABKAxSCXN0YXRlSGFzaBISCgRkYXRh", - "GAYgASgMUgRkYXRhQmtaTGdpdGh1Yi5jb20vYXJndXMtbGFicy93b3JsZC1l", - "bmdpbmUvcHJvdG8vZ2VuL2dvL3dvcmxkZW5naW5lL21pY3JvL3YxO21pY3Jv", - "djGqAhpXb3JsZEVuZ2luZS5Qcm90by5NaWNyby5WMWIGcHJvdG8z")); - descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, - new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, - new pbr::GeneratedClrTypeInfo(null, null, new pbr::GeneratedClrTypeInfo[] { - new pbr::GeneratedClrTypeInfo(typeof(global::WorldEngine.Proto.Micro.V1.Snapshot), global::WorldEngine.Proto.Micro.V1.Snapshot.Parser, new[]{ "EpochHeight", "TickHeight", "Timestamp", "StateHash", "Data" }, null, null, null, null) - })); - } - #endregion - - } - #region Messages - /// - /// Snapshot represents a point-in-time capture of shard state. - /// - [global::System.Diagnostics.DebuggerDisplayAttribute("{ToString(),nq}")] - public sealed partial class Snapshot : pb::IMessage - #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE - , pb::IBufferMessage - #endif - { - private static readonly pb::MessageParser _parser = new pb::MessageParser(() => new Snapshot()); - private pb::UnknownFieldSet _unknownFields; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public static pb::MessageParser Parser { get { return _parser; } } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public static pbr::MessageDescriptor Descriptor { - get { return global::WorldEngine.Proto.Micro.V1.SnapshotReflection.Descriptor.MessageTypes[0]; } - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - pbr::MessageDescriptor pb::IMessage.Descriptor { - get { return Descriptor; } - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public Snapshot() { - OnConstruction(); - } - - partial void OnConstruction(); - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public Snapshot(Snapshot other) : this() { - epochHeight_ = other.epochHeight_; - tickHeight_ = other.tickHeight_; - timestamp_ = other.timestamp_ != null ? other.timestamp_.Clone() : null; - stateHash_ = other.stateHash_; - data_ = other.data_; - _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public Snapshot Clone() { - return new Snapshot(this); - } - - /// Field number for the "epoch_height" field. - public const int EpochHeightFieldNumber = 1; - private ulong epochHeight_; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public ulong EpochHeight { - get { return epochHeight_; } - set { - epochHeight_ = value; - } - } - - /// Field number for the "tick_height" field. - public const int TickHeightFieldNumber = 2; - private ulong tickHeight_; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public ulong TickHeight { - get { return tickHeight_; } - set { - tickHeight_ = value; - } - } - - /// Field number for the "timestamp" field. - public const int TimestampFieldNumber = 3; - private global::Google.Protobuf.WellKnownTypes.Timestamp timestamp_; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public global::Google.Protobuf.WellKnownTypes.Timestamp Timestamp { - get { return timestamp_; } - set { - timestamp_ = value; - } - } - - /// Field number for the "state_hash" field. - public const int StateHashFieldNumber = 4; - private pb::ByteString stateHash_ = pb::ByteString.Empty; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pb::ByteString StateHash { - get { return stateHash_; } - set { - stateHash_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } - } - - /// Field number for the "data" field. - public const int DataFieldNumber = 6; - private pb::ByteString data_ = pb::ByteString.Empty; - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public pb::ByteString Data { - get { return data_; } - set { - data_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); - } - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public override bool Equals(object other) { - return Equals(other as Snapshot); - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public bool Equals(Snapshot other) { - if (ReferenceEquals(other, null)) { - return false; - } - if (ReferenceEquals(other, this)) { - return true; - } - if (EpochHeight != other.EpochHeight) return false; - if (TickHeight != other.TickHeight) return false; - if (!object.Equals(Timestamp, other.Timestamp)) return false; - if (StateHash != other.StateHash) return false; - if (Data != other.Data) return false; - return Equals(_unknownFields, other._unknownFields); - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public override int GetHashCode() { - int hash = 1; - if (EpochHeight != 0UL) hash ^= EpochHeight.GetHashCode(); - if (TickHeight != 0UL) hash ^= TickHeight.GetHashCode(); - if (timestamp_ != null) hash ^= Timestamp.GetHashCode(); - if (StateHash.Length != 0) hash ^= StateHash.GetHashCode(); - if (Data.Length != 0) hash ^= Data.GetHashCode(); - if (_unknownFields != null) { - hash ^= _unknownFields.GetHashCode(); - } - return hash; - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public override string ToString() { - return pb::JsonFormatter.ToDiagnosticString(this); - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public void WriteTo(pb::CodedOutputStream output) { - #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE - output.WriteRawMessage(this); - #else - if (EpochHeight != 0UL) { - output.WriteRawTag(8); - output.WriteUInt64(EpochHeight); - } - if (TickHeight != 0UL) { - output.WriteRawTag(16); - output.WriteUInt64(TickHeight); - } - if (timestamp_ != null) { - output.WriteRawTag(26); - output.WriteMessage(Timestamp); - } - if (StateHash.Length != 0) { - output.WriteRawTag(34); - output.WriteBytes(StateHash); - } - if (Data.Length != 0) { - output.WriteRawTag(50); - output.WriteBytes(Data); - } - if (_unknownFields != null) { - _unknownFields.WriteTo(output); - } - #endif - } - - #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { - if (EpochHeight != 0UL) { - output.WriteRawTag(8); - output.WriteUInt64(EpochHeight); - } - if (TickHeight != 0UL) { - output.WriteRawTag(16); - output.WriteUInt64(TickHeight); - } - if (timestamp_ != null) { - output.WriteRawTag(26); - output.WriteMessage(Timestamp); - } - if (StateHash.Length != 0) { - output.WriteRawTag(34); - output.WriteBytes(StateHash); - } - if (Data.Length != 0) { - output.WriteRawTag(50); - output.WriteBytes(Data); - } - if (_unknownFields != null) { - _unknownFields.WriteTo(ref output); - } - } - #endif - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public int CalculateSize() { - int size = 0; - if (EpochHeight != 0UL) { - size += 1 + pb::CodedOutputStream.ComputeUInt64Size(EpochHeight); - } - if (TickHeight != 0UL) { - size += 1 + pb::CodedOutputStream.ComputeUInt64Size(TickHeight); - } - if (timestamp_ != null) { - size += 1 + pb::CodedOutputStream.ComputeMessageSize(Timestamp); - } - if (StateHash.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeBytesSize(StateHash); - } - if (Data.Length != 0) { - size += 1 + pb::CodedOutputStream.ComputeBytesSize(Data); - } - if (_unknownFields != null) { - size += _unknownFields.CalculateSize(); - } - return size; - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public void MergeFrom(Snapshot other) { - if (other == null) { - return; - } - if (other.EpochHeight != 0UL) { - EpochHeight = other.EpochHeight; - } - if (other.TickHeight != 0UL) { - TickHeight = other.TickHeight; - } - if (other.timestamp_ != null) { - if (timestamp_ == null) { - Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); - } - Timestamp.MergeFrom(other.Timestamp); - } - if (other.StateHash.Length != 0) { - StateHash = other.StateHash; - } - if (other.Data.Length != 0) { - Data = other.Data; - } - _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); - } - - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - public void MergeFrom(pb::CodedInputStream input) { - #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE - input.ReadRawMessage(this); - #else - uint tag; - while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { - default: - _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, input); - break; - case 8: { - EpochHeight = input.ReadUInt64(); - break; - } - case 16: { - TickHeight = input.ReadUInt64(); - break; - } - case 26: { - if (timestamp_ == null) { - Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); - } - input.ReadMessage(Timestamp); - break; - } - case 34: { - StateHash = input.ReadBytes(); - break; - } - case 50: { - Data = input.ReadBytes(); - break; - } - } - } - #endif - } - - #if !GOOGLE_PROTOBUF_REFSTRUCT_COMPATIBILITY_MODE - [global::System.Diagnostics.DebuggerNonUserCodeAttribute] - [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] - void pb::IBufferMessage.InternalMergeFrom(ref pb::ParseContext input) { - uint tag; - while ((tag = input.ReadTag()) != 0) { - if ((tag & 7) == 4) { - // Abort on any end group tag. - return; - } - switch(tag) { - default: - _unknownFields = pb::UnknownFieldSet.MergeFieldFrom(_unknownFields, ref input); - break; - case 8: { - EpochHeight = input.ReadUInt64(); - break; - } - case 16: { - TickHeight = input.ReadUInt64(); - break; - } - case 26: { - if (timestamp_ == null) { - Timestamp = new global::Google.Protobuf.WellKnownTypes.Timestamp(); - } - input.ReadMessage(Timestamp); - break; - } - case 34: { - StateHash = input.ReadBytes(); - break; - } - case 50: { - Data = input.ReadBytes(); - break; - } - } - } - } - #endif - - } - - #endregion - -} - -#endregion Designer generated code diff --git a/proto/gen/go/worldengine/cardinal/v1/cardinalv1connect/debug.connect.go b/proto/gen/go/worldengine/cardinal/v1/cardinalv1connect/debug.connect.go new file mode 100644 index 000000000..4c83eaf8c --- /dev/null +++ b/proto/gen/go/worldengine/cardinal/v1/cardinalv1connect/debug.connect.go @@ -0,0 +1,112 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: worldengine/cardinal/v1/debug.proto + +package cardinalv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // DebugServiceName is the fully-qualified name of the DebugService service. + DebugServiceName = "worldengine.cardinal.v1.DebugService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // DebugServiceIntrospectProcedure is the fully-qualified name of the DebugService's Introspect RPC. + DebugServiceIntrospectProcedure = "/worldengine.cardinal.v1.DebugService/Introspect" +) + +// DebugServiceClient is a client for the worldengine.cardinal.v1.DebugService service. +type DebugServiceClient interface { + // Introspect returns metadata about the registered types in the world. + // The result includes JSON schemas for commands, components, and events. + Introspect(context.Context, *connect.Request[v1.IntrospectRequest]) (*connect.Response[v1.IntrospectResponse], error) +} + +// NewDebugServiceClient constructs a client for the worldengine.cardinal.v1.DebugService service. +// By default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped +// responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewDebugServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) DebugServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + debugServiceMethods := v1.File_worldengine_cardinal_v1_debug_proto.Services().ByName("DebugService").Methods() + return &debugServiceClient{ + introspect: connect.NewClient[v1.IntrospectRequest, v1.IntrospectResponse]( + httpClient, + baseURL+DebugServiceIntrospectProcedure, + connect.WithSchema(debugServiceMethods.ByName("Introspect")), + connect.WithClientOptions(opts...), + ), + } +} + +// debugServiceClient implements DebugServiceClient. +type debugServiceClient struct { + introspect *connect.Client[v1.IntrospectRequest, v1.IntrospectResponse] +} + +// Introspect calls worldengine.cardinal.v1.DebugService.Introspect. +func (c *debugServiceClient) Introspect(ctx context.Context, req *connect.Request[v1.IntrospectRequest]) (*connect.Response[v1.IntrospectResponse], error) { + return c.introspect.CallUnary(ctx, req) +} + +// DebugServiceHandler is an implementation of the worldengine.cardinal.v1.DebugService service. +type DebugServiceHandler interface { + // Introspect returns metadata about the registered types in the world. + // The result includes JSON schemas for commands, components, and events. + Introspect(context.Context, *connect.Request[v1.IntrospectRequest]) (*connect.Response[v1.IntrospectResponse], error) +} + +// NewDebugServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewDebugServiceHandler(svc DebugServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + debugServiceMethods := v1.File_worldengine_cardinal_v1_debug_proto.Services().ByName("DebugService").Methods() + debugServiceIntrospectHandler := connect.NewUnaryHandler( + DebugServiceIntrospectProcedure, + svc.Introspect, + connect.WithSchema(debugServiceMethods.ByName("Introspect")), + connect.WithHandlerOptions(opts...), + ) + return "/worldengine.cardinal.v1.DebugService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case DebugServiceIntrospectProcedure: + debugServiceIntrospectHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedDebugServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedDebugServiceHandler struct{} + +func (UnimplementedDebugServiceHandler) Introspect(context.Context, *connect.Request[v1.IntrospectRequest]) (*connect.Response[v1.IntrospectResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("worldengine.cardinal.v1.DebugService.Introspect is not implemented")) +} diff --git a/proto/gen/go/worldengine/cardinal/v1/debug.pb.go b/proto/gen/go/worldengine/cardinal/v1/debug.pb.go new file mode 100644 index 000000000..f40f1409b --- /dev/null +++ b/proto/gen/go/worldengine/cardinal/v1/debug.pb.go @@ -0,0 +1,256 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: worldengine/cardinal/v1/debug.proto + +package cardinalv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// IntrospectRequest is the request message for the Introspect RPC. +type IntrospectRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IntrospectRequest) Reset() { + *x = IntrospectRequest{} + mi := &file_worldengine_cardinal_v1_debug_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IntrospectRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IntrospectRequest) ProtoMessage() {} + +func (x *IntrospectRequest) ProtoReflect() protoreflect.Message { + mi := &file_worldengine_cardinal_v1_debug_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IntrospectRequest.ProtoReflect.Descriptor instead. +func (*IntrospectRequest) Descriptor() ([]byte, []int) { + return file_worldengine_cardinal_v1_debug_proto_rawDescGZIP(), []int{0} +} + +// IntrospectResponse contains introspection metadata about the world. +type IntrospectResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // JSON schemas for registered commands. + Commands []*TypeSchema `protobuf:"bytes,1,rep,name=commands,proto3" json:"commands,omitempty"` + // JSON schemas for registered components. + Components []*TypeSchema `protobuf:"bytes,2,rep,name=components,proto3" json:"components,omitempty"` + // JSON schemas for registered events. + Events []*TypeSchema `protobuf:"bytes,3,rep,name=events,proto3" json:"events,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IntrospectResponse) Reset() { + *x = IntrospectResponse{} + mi := &file_worldengine_cardinal_v1_debug_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IntrospectResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IntrospectResponse) ProtoMessage() {} + +func (x *IntrospectResponse) ProtoReflect() protoreflect.Message { + mi := &file_worldengine_cardinal_v1_debug_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IntrospectResponse.ProtoReflect.Descriptor instead. +func (*IntrospectResponse) Descriptor() ([]byte, []int) { + return file_worldengine_cardinal_v1_debug_proto_rawDescGZIP(), []int{1} +} + +func (x *IntrospectResponse) GetCommands() []*TypeSchema { + if x != nil { + return x.Commands + } + return nil +} + +func (x *IntrospectResponse) GetComponents() []*TypeSchema { + if x != nil { + return x.Components + } + return nil +} + +func (x *IntrospectResponse) GetEvents() []*TypeSchema { + if x != nil { + return x.Events + } + return nil +} + +// TypeSchema represents the JSON schema for a registered type. +type TypeSchema struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Name of the type. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // JSON schema for the type. + Schema *structpb.Struct `protobuf:"bytes,2,opt,name=schema,proto3" json:"schema,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TypeSchema) Reset() { + *x = TypeSchema{} + mi := &file_worldengine_cardinal_v1_debug_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TypeSchema) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TypeSchema) ProtoMessage() {} + +func (x *TypeSchema) ProtoReflect() protoreflect.Message { + mi := &file_worldengine_cardinal_v1_debug_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TypeSchema.ProtoReflect.Descriptor instead. +func (*TypeSchema) Descriptor() ([]byte, []int) { + return file_worldengine_cardinal_v1_debug_proto_rawDescGZIP(), []int{2} +} + +func (x *TypeSchema) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *TypeSchema) GetSchema() *structpb.Struct { + if x != nil { + return x.Schema + } + return nil +} + +var File_worldengine_cardinal_v1_debug_proto protoreflect.FileDescriptor + +const file_worldengine_cardinal_v1_debug_proto_rawDesc = "" + + "\n" + + "#worldengine/cardinal/v1/debug.proto\x12\x17worldengine.cardinal.v1\x1a\x1cgoogle/protobuf/struct.proto\"\x13\n" + + "\x11IntrospectRequest\"\xd7\x01\n" + + "\x12IntrospectResponse\x12?\n" + + "\bcommands\x18\x01 \x03(\v2#.worldengine.cardinal.v1.TypeSchemaR\bcommands\x12C\n" + + "\n" + + "components\x18\x02 \x03(\v2#.worldengine.cardinal.v1.TypeSchemaR\n" + + "components\x12;\n" + + "\x06events\x18\x03 \x03(\v2#.worldengine.cardinal.v1.TypeSchemaR\x06events\"Q\n" + + "\n" + + "TypeSchema\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12/\n" + + "\x06schema\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x06schema2u\n" + + "\fDebugService\x12e\n" + + "\n" + + "Introspect\x12*.worldengine.cardinal.v1.IntrospectRequest\x1a+.worldengine.cardinal.v1.IntrospectResponseBtZRgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1;cardinalv1\xaa\x02\x1dWorldEngine.Proto.Cardinal.V1b\x06proto3" + +var ( + file_worldengine_cardinal_v1_debug_proto_rawDescOnce sync.Once + file_worldengine_cardinal_v1_debug_proto_rawDescData []byte +) + +func file_worldengine_cardinal_v1_debug_proto_rawDescGZIP() []byte { + file_worldengine_cardinal_v1_debug_proto_rawDescOnce.Do(func() { + file_worldengine_cardinal_v1_debug_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_worldengine_cardinal_v1_debug_proto_rawDesc), len(file_worldengine_cardinal_v1_debug_proto_rawDesc))) + }) + return file_worldengine_cardinal_v1_debug_proto_rawDescData +} + +var file_worldengine_cardinal_v1_debug_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_worldengine_cardinal_v1_debug_proto_goTypes = []any{ + (*IntrospectRequest)(nil), // 0: worldengine.cardinal.v1.IntrospectRequest + (*IntrospectResponse)(nil), // 1: worldengine.cardinal.v1.IntrospectResponse + (*TypeSchema)(nil), // 2: worldengine.cardinal.v1.TypeSchema + (*structpb.Struct)(nil), // 3: google.protobuf.Struct +} +var file_worldengine_cardinal_v1_debug_proto_depIdxs = []int32{ + 2, // 0: worldengine.cardinal.v1.IntrospectResponse.commands:type_name -> worldengine.cardinal.v1.TypeSchema + 2, // 1: worldengine.cardinal.v1.IntrospectResponse.components:type_name -> worldengine.cardinal.v1.TypeSchema + 2, // 2: worldengine.cardinal.v1.IntrospectResponse.events:type_name -> worldengine.cardinal.v1.TypeSchema + 3, // 3: worldengine.cardinal.v1.TypeSchema.schema:type_name -> google.protobuf.Struct + 0, // 4: worldengine.cardinal.v1.DebugService.Introspect:input_type -> worldengine.cardinal.v1.IntrospectRequest + 1, // 5: worldengine.cardinal.v1.DebugService.Introspect:output_type -> worldengine.cardinal.v1.IntrospectResponse + 5, // [5:6] is the sub-list for method output_type + 4, // [4:5] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_worldengine_cardinal_v1_debug_proto_init() } +func file_worldengine_cardinal_v1_debug_proto_init() { + if File_worldengine_cardinal_v1_debug_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_worldengine_cardinal_v1_debug_proto_rawDesc), len(file_worldengine_cardinal_v1_debug_proto_rawDesc)), + NumEnums: 0, + NumMessages: 3, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_worldengine_cardinal_v1_debug_proto_goTypes, + DependencyIndexes: file_worldengine_cardinal_v1_debug_proto_depIdxs, + MessageInfos: file_worldengine_cardinal_v1_debug_proto_msgTypes, + }.Build() + File_worldengine_cardinal_v1_debug_proto = out.File + file_worldengine_cardinal_v1_debug_proto_goTypes = nil + file_worldengine_cardinal_v1_debug_proto_depIdxs = nil +} diff --git a/proto/gen/go/worldengine/cardinal/v1/snapshot.pb.go b/proto/gen/go/worldengine/cardinal/v1/snapshot.pb.go index 0717a1c2c..f5e96218d 100644 --- a/proto/gen/go/worldengine/cardinal/v1/snapshot.pb.go +++ b/proto/gen/go/worldengine/cardinal/v1/snapshot.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/cardinal/v1/snapshot.proto @@ -10,6 +10,7 @@ import ( _ "buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go/buf/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -22,8 +23,77 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) -// CardinalSnapshot represents a complete snapshot of the world state. -type CardinalSnapshot struct { +// Snapshot represents a point-in-time capture of shard state. +type Snapshot struct { + state protoimpl.MessageState `protogen:"open.v1"` + TickHeight uint64 `protobuf:"varint,1,opt,name=tick_height,json=tickHeight,proto3" json:"tick_height,omitempty"` + Timestamp *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + WorldState *WorldState `protobuf:"bytes,3,opt,name=world_state,json=worldState,proto3" json:"world_state,omitempty"` + Version uint32 `protobuf:"varint,4,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Snapshot) Reset() { + *x = Snapshot{} + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Snapshot) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Snapshot) ProtoMessage() {} + +func (x *Snapshot) ProtoReflect() protoreflect.Message { + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Snapshot.ProtoReflect.Descriptor instead. +func (*Snapshot) Descriptor() ([]byte, []int) { + return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{0} +} + +func (x *Snapshot) GetTickHeight() uint64 { + if x != nil { + return x.TickHeight + } + return 0 +} + +func (x *Snapshot) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +func (x *Snapshot) GetWorldState() *WorldState { + if x != nil { + return x.WorldState + } + return nil +} + +func (x *Snapshot) GetVersion() uint32 { + if x != nil { + return x.Version + } + return 0 +} + +// WorldState represents the ECS world state. +type WorldState struct { state protoimpl.MessageState `protogen:"open.v1"` // Entity manager state NextId uint32 `protobuf:"varint,1,opt,name=next_id,json=nextId,proto3" json:"next_id,omitempty"` @@ -36,21 +106,21 @@ type CardinalSnapshot struct { sizeCache protoimpl.SizeCache } -func (x *CardinalSnapshot) Reset() { - *x = CardinalSnapshot{} - mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[0] +func (x *WorldState) Reset() { + *x = WorldState{} + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *CardinalSnapshot) String() string { +func (x *WorldState) String() string { return protoimpl.X.MessageStringOf(x) } -func (*CardinalSnapshot) ProtoMessage() {} +func (*WorldState) ProtoMessage() {} -func (x *CardinalSnapshot) ProtoReflect() protoreflect.Message { - mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[0] +func (x *WorldState) ProtoReflect() protoreflect.Message { + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -61,33 +131,33 @@ func (x *CardinalSnapshot) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use CardinalSnapshot.ProtoReflect.Descriptor instead. -func (*CardinalSnapshot) Descriptor() ([]byte, []int) { - return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{0} +// Deprecated: Use WorldState.ProtoReflect.Descriptor instead. +func (*WorldState) Descriptor() ([]byte, []int) { + return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{1} } -func (x *CardinalSnapshot) GetNextId() uint32 { +func (x *WorldState) GetNextId() uint32 { if x != nil { return x.NextId } return 0 } -func (x *CardinalSnapshot) GetFreeIds() []uint32 { +func (x *WorldState) GetFreeIds() []uint32 { if x != nil { return x.FreeIds } return nil } -func (x *CardinalSnapshot) GetEntityArch() []int64 { +func (x *WorldState) GetEntityArch() []int64 { if x != nil { return x.EntityArch } return nil } -func (x *CardinalSnapshot) GetArchetypes() []*Archetype { +func (x *WorldState) GetArchetypes() []*Archetype { if x != nil { return x.Archetypes } @@ -113,7 +183,7 @@ type Archetype struct { func (x *Archetype) Reset() { *x = Archetype{} - mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[1] + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[2] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -125,7 +195,7 @@ func (x *Archetype) String() string { func (*Archetype) ProtoMessage() {} func (x *Archetype) ProtoReflect() protoreflect.Message { - mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[1] + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[2] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -138,7 +208,7 @@ func (x *Archetype) ProtoReflect() protoreflect.Message { // Deprecated: Use Archetype.ProtoReflect.Descriptor instead. func (*Archetype) Descriptor() ([]byte, []int) { - return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{1} + return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{2} } func (x *Archetype) GetId() int32 { @@ -189,7 +259,7 @@ type Column struct { func (x *Column) Reset() { *x = Column{} - mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[2] + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[3] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -201,7 +271,7 @@ func (x *Column) String() string { func (*Column) ProtoMessage() {} func (x *Column) ProtoReflect() protoreflect.Message { - mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[2] + mi := &file_worldengine_cardinal_v1_snapshot_proto_msgTypes[3] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -214,7 +284,7 @@ func (x *Column) ProtoReflect() protoreflect.Message { // Deprecated: Use Column.ProtoReflect.Descriptor instead. func (*Column) Descriptor() ([]byte, []int) { - return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{2} + return file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP(), []int{3} } func (x *Column) GetComponentName() string { @@ -233,50 +303,36 @@ func (x *Column) GetComponents() [][]byte { var File_worldengine_cardinal_v1_snapshot_proto protoreflect.FileDescriptor -var file_worldengine_cardinal_v1_snapshot_proto_rawDesc = string([]byte{ - 0x0a, 0x26, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x63, 0x61, - 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, - 0x6f, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x17, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, - 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x63, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x2e, 0x76, - 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, - 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xab, - 0x01, 0x0a, 0x10, 0x43, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x53, 0x6e, 0x61, 0x70, 0x73, - 0x68, 0x6f, 0x74, 0x12, 0x17, 0x0a, 0x07, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x0d, 0x52, 0x06, 0x6e, 0x65, 0x78, 0x74, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, - 0x66, 0x72, 0x65, 0x65, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x07, - 0x66, 0x72, 0x65, 0x65, 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x65, 0x6e, 0x74, 0x69, 0x74, - 0x79, 0x5f, 0x61, 0x72, 0x63, 0x68, 0x18, 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x0a, 0x65, 0x6e, - 0x74, 0x69, 0x74, 0x79, 0x41, 0x72, 0x63, 0x68, 0x12, 0x42, 0x0a, 0x0a, 0x61, 0x72, 0x63, 0x68, - 0x65, 0x74, 0x79, 0x70, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x77, - 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x63, 0x61, 0x72, 0x64, 0x69, - 0x6e, 0x61, 0x6c, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x72, 0x63, 0x68, 0x65, 0x74, 0x79, 0x70, 0x65, - 0x52, 0x0a, 0x61, 0x72, 0x63, 0x68, 0x65, 0x74, 0x79, 0x70, 0x65, 0x73, 0x22, 0xb3, 0x01, 0x0a, - 0x09, 0x41, 0x72, 0x63, 0x68, 0x65, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x02, 0x69, 0x64, 0x12, 0x2b, 0x0a, 0x11, 0x63, 0x6f, - 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x5f, 0x62, 0x69, 0x74, 0x6d, 0x61, 0x70, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x10, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, - 0x73, 0x42, 0x69, 0x74, 0x6d, 0x61, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x03, 0x52, 0x04, 0x72, 0x6f, 0x77, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x08, 0x65, - 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x39, 0x0a, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, - 0x6e, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, - 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x63, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x2e, - 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x52, 0x07, 0x63, 0x6f, 0x6c, 0x75, 0x6d, - 0x6e, 0x73, 0x22, 0x58, 0x0a, 0x06, 0x43, 0x6f, 0x6c, 0x75, 0x6d, 0x6e, 0x12, 0x2e, 0x0a, 0x0e, - 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x09, 0x42, 0x07, 0xba, 0x48, 0x04, 0x72, 0x02, 0x10, 0x01, 0x52, 0x0d, 0x63, - 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1e, 0x0a, 0x0a, - 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0c, - 0x52, 0x0a, 0x63, 0x6f, 0x6d, 0x70, 0x6f, 0x6e, 0x65, 0x6e, 0x74, 0x73, 0x42, 0x74, 0x5a, 0x52, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, - 0x2d, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, - 0x6e, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, - 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x63, 0x61, 0x72, 0x64, - 0x69, 0x6e, 0x61, 0x6c, 0x2f, 0x76, 0x31, 0x3b, 0x63, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, - 0x76, 0x31, 0xaa, 0x02, 0x1d, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, - 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x43, 0x61, 0x72, 0x64, 0x69, 0x6e, 0x61, 0x6c, 0x2e, - 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_worldengine_cardinal_v1_snapshot_proto_rawDesc = "" + + "\n" + + "&worldengine/cardinal/v1/snapshot.proto\x12\x17worldengine.cardinal.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc5\x01\n" + + "\bSnapshot\x12\x1f\n" + + "\vtick_height\x18\x01 \x01(\x04R\n" + + "tickHeight\x128\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampR\ttimestamp\x12D\n" + + "\vworld_state\x18\x03 \x01(\v2#.worldengine.cardinal.v1.WorldStateR\n" + + "worldState\x12\x18\n" + + "\aversion\x18\x04 \x01(\rR\aversion\"\xa5\x01\n" + + "\n" + + "WorldState\x12\x17\n" + + "\anext_id\x18\x01 \x01(\rR\x06nextId\x12\x19\n" + + "\bfree_ids\x18\x02 \x03(\rR\afreeIds\x12\x1f\n" + + "\ventity_arch\x18\x03 \x03(\x03R\n" + + "entityArch\x12B\n" + + "\n" + + "archetypes\x18\x04 \x03(\v2\".worldengine.cardinal.v1.ArchetypeR\n" + + "archetypes\"\xb3\x01\n" + + "\tArchetype\x12\x0e\n" + + "\x02id\x18\x01 \x01(\x05R\x02id\x12+\n" + + "\x11components_bitmap\x18\x02 \x01(\fR\x10componentsBitmap\x12\x12\n" + + "\x04rows\x18\x03 \x03(\x03R\x04rows\x12\x1a\n" + + "\bentities\x18\x04 \x03(\rR\bentities\x129\n" + + "\acolumns\x18\x05 \x03(\v2\x1f.worldengine.cardinal.v1.ColumnR\acolumns\"X\n" + + "\x06Column\x12.\n" + + "\x0ecomponent_name\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01R\rcomponentName\x12\x1e\n" + + "\n" + + "components\x18\x02 \x03(\fR\n" + + "componentsBtZRgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1;cardinalv1\xaa\x02\x1dWorldEngine.Proto.Cardinal.V1b\x06proto3" var ( file_worldengine_cardinal_v1_snapshot_proto_rawDescOnce sync.Once @@ -290,20 +346,24 @@ func file_worldengine_cardinal_v1_snapshot_proto_rawDescGZIP() []byte { return file_worldengine_cardinal_v1_snapshot_proto_rawDescData } -var file_worldengine_cardinal_v1_snapshot_proto_msgTypes = make([]protoimpl.MessageInfo, 3) +var file_worldengine_cardinal_v1_snapshot_proto_msgTypes = make([]protoimpl.MessageInfo, 4) var file_worldengine_cardinal_v1_snapshot_proto_goTypes = []any{ - (*CardinalSnapshot)(nil), // 0: worldengine.cardinal.v1.CardinalSnapshot - (*Archetype)(nil), // 1: worldengine.cardinal.v1.Archetype - (*Column)(nil), // 2: worldengine.cardinal.v1.Column + (*Snapshot)(nil), // 0: worldengine.cardinal.v1.Snapshot + (*WorldState)(nil), // 1: worldengine.cardinal.v1.WorldState + (*Archetype)(nil), // 2: worldengine.cardinal.v1.Archetype + (*Column)(nil), // 3: worldengine.cardinal.v1.Column + (*timestamppb.Timestamp)(nil), // 4: google.protobuf.Timestamp } var file_worldengine_cardinal_v1_snapshot_proto_depIdxs = []int32{ - 1, // 0: worldengine.cardinal.v1.CardinalSnapshot.archetypes:type_name -> worldengine.cardinal.v1.Archetype - 2, // 1: worldengine.cardinal.v1.Archetype.columns:type_name -> worldengine.cardinal.v1.Column - 2, // [2:2] is the sub-list for method output_type - 2, // [2:2] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 4, // 0: worldengine.cardinal.v1.Snapshot.timestamp:type_name -> google.protobuf.Timestamp + 1, // 1: worldengine.cardinal.v1.Snapshot.world_state:type_name -> worldengine.cardinal.v1.WorldState + 2, // 2: worldengine.cardinal.v1.WorldState.archetypes:type_name -> worldengine.cardinal.v1.Archetype + 3, // 3: worldengine.cardinal.v1.Archetype.columns:type_name -> worldengine.cardinal.v1.Column + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_worldengine_cardinal_v1_snapshot_proto_init() } @@ -317,7 +377,7 @@ func file_worldengine_cardinal_v1_snapshot_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_worldengine_cardinal_v1_snapshot_proto_rawDesc), len(file_worldengine_cardinal_v1_snapshot_proto_rawDesc)), NumEnums: 0, - NumMessages: 3, + NumMessages: 4, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/gen/go/worldengine/isc/v1/command.pb.go b/proto/gen/go/worldengine/isc/v1/command.pb.go index 05030ef25..49af85da0 100644 --- a/proto/gen/go/worldengine/isc/v1/command.pb.go +++ b/proto/gen/go/worldengine/isc/v1/command.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/isc/v1/command.proto @@ -99,43 +99,14 @@ func (x *Command) GetPayload() *structpb.Struct { var File_worldengine_isc_v1_command_proto protoreflect.FileDescriptor -var file_worldengine_isc_v1_command_proto_rawDesc = string([]byte{ - 0x0a, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, - 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x12, 0x12, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, - 0x69, 0x73, 0x63, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, - 0x73, 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x61, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x1a, 0x22, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, - 0x2f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x80, 0x02, 0x0a, 0x07, 0x43, 0x6f, 0x6d, 0x6d, - 0x61, 0x6e, 0x64, 0x12, 0x33, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x42, 0x1f, 0xba, 0x48, 0x1c, 0xc8, 0x01, 0x01, 0x72, 0x17, 0x10, 0x01, 0x18, 0x80, 0x01, - 0x32, 0x10, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x41, 0x2d, 0x5a, 0x30, 0x2d, 0x39, 0x5f, 0x2d, 0x5d, - 0x2b, 0x24, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x46, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, - 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x77, 0x6f, 0x72, 0x6c, - 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x76, 0x31, - 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x42, - 0x06, 0xba, 0x48, 0x03, 0xc8, 0x01, 0x01, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, - 0x12, 0x3d, 0x0a, 0x07, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x1b, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, - 0x69, 0x73, 0x63, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x61, 0x42, 0x06, - 0xba, 0x48, 0x03, 0xc8, 0x01, 0x01, 0x52, 0x07, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x61, 0x12, - 0x39, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x42, 0x06, 0xba, 0x48, 0x03, 0xc8, 0x01, - 0x01, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x65, 0x5a, 0x48, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, 0x2d, 0x6c, - 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x77, 0x6f, - 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, 0x63, 0x2f, 0x76, 0x31, - 0x3b, 0x69, 0x73, 0x63, 0x76, 0x31, 0xaa, 0x02, 0x18, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, - 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x63, 0x2e, 0x56, - 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_worldengine_isc_v1_command_proto_rawDesc = "" + + "\n" + + " worldengine/isc/v1/command.proto\x12\x12worldengine.isc.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/protobuf/struct.proto\x1a worldengine/isc/v1/persona.proto\x1a\"worldengine/micro/v1/service.proto\"\x80\x02\n" + + "\aCommand\x123\n" + + "\x04name\x18\x01 \x01(\tB\x1f\xbaH\x1c\xc8\x01\x01r\x17\x10\x01\x18\x80\x012\x10^[a-zA-Z0-9_-]+$R\x04name\x12F\n" + + "\aaddress\x18\x02 \x01(\v2$.worldengine.micro.v1.ServiceAddressB\x06\xbaH\x03\xc8\x01\x01R\aaddress\x12=\n" + + "\apersona\x18\x03 \x01(\v2\x1b.worldengine.isc.v1.PersonaB\x06\xbaH\x03\xc8\x01\x01R\apersona\x129\n" + + "\apayload\x18\x04 \x01(\v2\x17.google.protobuf.StructB\x06\xbaH\x03\xc8\x01\x01R\apayloadBeZHgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1;iscv1\xaa\x02\x18WorldEngine.Proto.Isc.V1b\x06proto3" var ( file_worldengine_isc_v1_command_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/isc/v1/epoch.pb.go b/proto/gen/go/worldengine/isc/v1/epoch.pb.go index 73f8ad2db..96a6b9d6f 100644 --- a/proto/gen/go/worldengine/isc/v1/epoch.pb.go +++ b/proto/gen/go/worldengine/isc/v1/epoch.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/isc/v1/epoch.proto @@ -241,50 +241,23 @@ func (x *TickData) GetCommands() []*Command { var File_worldengine_isc_v1_epoch_proto protoreflect.FileDescriptor -var file_worldengine_isc_v1_epoch_proto_rawDesc = string([]byte{ - 0x0a, 0x1e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, - 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x12, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x69, 0x73, - 0x63, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x1a, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, - 0x69, 0x73, 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x6e, 0x0a, 0x05, 0x45, 0x70, 0x6f, 0x63, 0x68, 0x12, 0x21, 0x0a, - 0x0c, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x04, 0x52, 0x0b, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, - 0x12, 0x12, 0x0a, 0x04, 0x68, 0x61, 0x73, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, - 0x68, 0x61, 0x73, 0x68, 0x12, 0x2e, 0x0a, 0x05, 0x74, 0x69, 0x63, 0x6b, 0x73, 0x18, 0x03, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, - 0x65, 0x2e, 0x69, 0x73, 0x63, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x52, 0x05, 0x74, - 0x69, 0x63, 0x6b, 0x73, 0x22, 0x70, 0x0a, 0x04, 0x54, 0x69, 0x63, 0x6b, 0x12, 0x36, 0x0a, 0x06, - 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x77, - 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x69, 0x73, 0x63, 0x2e, 0x76, - 0x31, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, 0x06, 0x68, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x12, 0x30, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, - 0x2e, 0x69, 0x73, 0x63, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x69, 0x63, 0x6b, 0x44, 0x61, 0x74, 0x61, - 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x6f, 0x0a, 0x0a, 0x54, 0x69, 0x63, 0x6b, 0x48, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x12, 0x1f, 0x0a, 0x0b, 0x74, 0x69, 0x63, 0x6b, 0x5f, 0x68, 0x65, 0x69, - 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x74, 0x69, 0x63, 0x6b, 0x48, - 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x40, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, - 0x6d, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x42, 0x06, 0xba, 0x48, 0x03, 0xc8, 0x01, 0x01, 0x52, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x22, 0x43, 0x0a, 0x08, 0x54, 0x69, 0x63, 0x6b, 0x44, - 0x61, 0x74, 0x61, 0x12, 0x37, 0x0a, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, - 0x69, 0x6e, 0x65, 0x2e, 0x69, 0x73, 0x63, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6f, 0x6d, 0x6d, 0x61, - 0x6e, 0x64, 0x52, 0x08, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x73, 0x42, 0x65, 0x5a, 0x48, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, - 0x2d, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, - 0x6e, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, - 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, 0x63, 0x2f, - 0x76, 0x31, 0x3b, 0x69, 0x73, 0x63, 0x76, 0x31, 0xaa, 0x02, 0x18, 0x57, 0x6f, 0x72, 0x6c, 0x64, - 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x63, - 0x2e, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_worldengine_isc_v1_epoch_proto_rawDesc = "" + + "\n" + + "\x1eworldengine/isc/v1/epoch.proto\x12\x12worldengine.isc.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a worldengine/isc/v1/command.proto\"n\n" + + "\x05Epoch\x12!\n" + + "\fepoch_height\x18\x01 \x01(\x04R\vepochHeight\x12\x12\n" + + "\x04hash\x18\x02 \x01(\fR\x04hash\x12.\n" + + "\x05ticks\x18\x03 \x03(\v2\x18.worldengine.isc.v1.TickR\x05ticks\"p\n" + + "\x04Tick\x126\n" + + "\x06header\x18\x01 \x01(\v2\x1e.worldengine.isc.v1.TickHeaderR\x06header\x120\n" + + "\x04data\x18\x02 \x01(\v2\x1c.worldengine.isc.v1.TickDataR\x04data\"o\n" + + "\n" + + "TickHeader\x12\x1f\n" + + "\vtick_height\x18\x01 \x01(\x04R\n" + + "tickHeight\x12@\n" + + "\ttimestamp\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampB\x06\xbaH\x03\xc8\x01\x01R\ttimestamp\"C\n" + + "\bTickData\x127\n" + + "\bcommands\x18\x01 \x03(\v2\x1b.worldengine.isc.v1.CommandR\bcommandsBeZHgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1;iscv1\xaa\x02\x18WorldEngine.Proto.Isc.V1b\x06proto3" var ( file_worldengine_isc_v1_epoch_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/isc/v1/event.pb.go b/proto/gen/go/worldengine/isc/v1/event.pb.go index 14662c7ef..4c6dadeed 100644 --- a/proto/gen/go/worldengine/isc/v1/event.pb.go +++ b/proto/gen/go/worldengine/isc/v1/event.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/isc/v1/event.proto @@ -80,30 +80,12 @@ func (x *Event) GetPayload() *structpb.Struct { var File_worldengine_isc_v1_event_proto protoreflect.FileDescriptor -var file_worldengine_isc_v1_event_proto_rawDesc = string([]byte{ - 0x0a, 0x1e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, - 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x65, 0x76, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x12, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x69, 0x73, - 0x63, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0x77, 0x0a, 0x05, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x33, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x1f, 0xba, 0x48, 0x1c, 0xc8, 0x01, 0x01, 0x72, 0x17, - 0x10, 0x01, 0x18, 0x80, 0x01, 0x32, 0x10, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x41, 0x2d, 0x5a, 0x30, - 0x2d, 0x39, 0x5f, 0x2d, 0x5d, 0x2b, 0x24, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x39, 0x0a, - 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x42, 0x06, 0xba, 0x48, 0x03, 0xc8, 0x01, 0x01, 0x52, - 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x65, 0x5a, 0x48, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, 0x2d, 0x6c, 0x61, 0x62, - 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x77, 0x6f, 0x72, 0x6c, - 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, 0x63, 0x2f, 0x76, 0x31, 0x3b, 0x69, - 0x73, 0x63, 0x76, 0x31, 0xaa, 0x02, 0x18, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, - 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x63, 0x2e, 0x56, 0x31, 0x62, - 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_worldengine_isc_v1_event_proto_rawDesc = "" + + "\n" + + "\x1eworldengine/isc/v1/event.proto\x12\x12worldengine.isc.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/protobuf/struct.proto\"w\n" + + "\x05Event\x123\n" + + "\x04name\x18\x01 \x01(\tB\x1f\xbaH\x1c\xc8\x01\x01r\x17\x10\x01\x18\x80\x012\x10^[a-zA-Z0-9_-]+$R\x04name\x129\n" + + "\apayload\x18\x02 \x01(\v2\x17.google.protobuf.StructB\x06\xbaH\x03\xc8\x01\x01R\apayloadBeZHgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1;iscv1\xaa\x02\x18WorldEngine.Proto.Isc.V1b\x06proto3" var ( file_worldengine_isc_v1_event_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/isc/v1/persona.pb.go b/proto/gen/go/worldengine/isc/v1/persona.pb.go index 5eca8b88c..7e78b025b 100644 --- a/proto/gen/go/worldengine/isc/v1/persona.pb.go +++ b/proto/gen/go/worldengine/isc/v1/persona.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/isc/v1/persona.proto @@ -69,24 +69,11 @@ func (x *Persona) GetId() string { var File_worldengine_isc_v1_persona_proto protoreflect.FileDescriptor -var file_worldengine_isc_v1_persona_proto_rawDesc = string([]byte{ - 0x0a, 0x20, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, - 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x70, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x61, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x12, 0x12, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, - 0x69, 0x73, 0x63, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0x3b, 0x0a, 0x07, 0x50, 0x65, 0x72, 0x73, 0x6f, 0x6e, 0x61, 0x12, 0x30, - 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x20, 0xba, 0x48, 0x1d, 0xc8, - 0x01, 0x01, 0x72, 0x18, 0x10, 0x01, 0x18, 0x80, 0x01, 0x32, 0x11, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, - 0x41, 0x2d, 0x5a, 0x30, 0x2d, 0x39, 0x2e, 0x5f, 0x2d, 0x5d, 0x2b, 0x24, 0x52, 0x02, 0x69, 0x64, - 0x42, 0x65, 0x5a, 0x48, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, - 0x72, 0x67, 0x75, 0x73, 0x2d, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, - 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, - 0x2f, 0x67, 0x6f, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, - 0x69, 0x73, 0x63, 0x2f, 0x76, 0x31, 0x3b, 0x69, 0x73, 0x63, 0x76, 0x31, 0xaa, 0x02, 0x18, 0x57, - 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x49, 0x73, 0x63, 0x2e, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_worldengine_isc_v1_persona_proto_rawDesc = "" + + "\n" + + " worldengine/isc/v1/persona.proto\x12\x12worldengine.isc.v1\x1a\x1bbuf/validate/validate.proto\";\n" + + "\aPersona\x120\n" + + "\x02id\x18\x01 \x01(\tB \xbaH\x1d\xc8\x01\x01r\x18\x10\x01\x18\x80\x012\x11^[a-zA-Z0-9._-]+$R\x02idBeZHgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1;iscv1\xaa\x02\x18WorldEngine.Proto.Isc.V1b\x06proto3" var ( file_worldengine_isc_v1_persona_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/isc/v1/query.pb.go b/proto/gen/go/worldengine/isc/v1/query.pb.go index 4672c7fb7..7d9dbcf56 100644 --- a/proto/gen/go/worldengine/isc/v1/query.pb.go +++ b/proto/gen/go/worldengine/isc/v1/query.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/isc/v1/query.proto @@ -191,41 +191,20 @@ func (x *QueryResult) GetEntities() []*structpb.Struct { var File_worldengine_isc_v1_query_proto protoreflect.FileDescriptor -var file_worldengine_isc_v1_query_proto_rawDesc = string([]byte{ - 0x0a, 0x1e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x69, 0x73, - 0x63, 0x2f, 0x76, 0x31, 0x2f, 0x71, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x12, 0x12, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x69, 0x73, - 0x63, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, - 0xdc, 0x01, 0x0a, 0x05, 0x51, 0x75, 0x65, 0x72, 0x79, 0x12, 0x35, 0x0a, 0x04, 0x66, 0x69, 0x6e, - 0x64, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x42, 0x21, 0xba, 0x48, 0x1e, 0x92, 0x01, 0x1b, 0x22, - 0x19, 0x72, 0x17, 0x10, 0x01, 0x18, 0x80, 0x01, 0x32, 0x10, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x41, - 0x2d, 0x5a, 0x30, 0x2d, 0x39, 0x5f, 0x2d, 0x5d, 0x2b, 0x24, 0x52, 0x04, 0x66, 0x69, 0x6e, 0x64, - 0x12, 0x41, 0x0a, 0x05, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, - 0x1f, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x69, 0x73, - 0x63, 0x2e, 0x76, 0x31, 0x2e, 0x51, 0x75, 0x65, 0x72, 0x79, 0x2e, 0x4d, 0x61, 0x74, 0x63, 0x68, - 0x42, 0x0a, 0xba, 0x48, 0x07, 0x82, 0x01, 0x04, 0x10, 0x01, 0x20, 0x00, 0x52, 0x05, 0x6d, 0x61, - 0x74, 0x63, 0x68, 0x12, 0x14, 0x0a, 0x05, 0x77, 0x68, 0x65, 0x72, 0x65, 0x18, 0x03, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x05, 0x77, 0x68, 0x65, 0x72, 0x65, 0x22, 0x43, 0x0a, 0x05, 0x4d, 0x61, 0x74, - 0x63, 0x68, 0x12, 0x15, 0x0a, 0x11, 0x4d, 0x41, 0x54, 0x43, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, - 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, 0x4d, 0x41, 0x54, - 0x43, 0x48, 0x5f, 0x45, 0x58, 0x41, 0x43, 0x54, 0x10, 0x01, 0x12, 0x12, 0x0a, 0x0e, 0x4d, 0x41, - 0x54, 0x43, 0x48, 0x5f, 0x43, 0x4f, 0x4e, 0x54, 0x41, 0x49, 0x4e, 0x53, 0x10, 0x02, 0x22, 0x42, - 0x0a, 0x0b, 0x51, 0x75, 0x65, 0x72, 0x79, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x33, 0x0a, - 0x08, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, - 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x08, 0x65, 0x6e, 0x74, 0x69, 0x74, 0x69, - 0x65, 0x73, 0x42, 0x65, 0x5a, 0x48, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, - 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, 0x2d, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, - 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, - 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, - 0x65, 0x2f, 0x69, 0x73, 0x63, 0x2f, 0x76, 0x31, 0x3b, 0x69, 0x73, 0x63, 0x76, 0x31, 0xaa, 0x02, - 0x18, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, - 0x74, 0x6f, 0x2e, 0x49, 0x73, 0x63, 0x2e, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, -}) +const file_worldengine_isc_v1_query_proto_rawDesc = "" + + "\n" + + "\x1eworldengine/isc/v1/query.proto\x12\x12worldengine.isc.v1\x1a\x1bbuf/validate/validate.proto\x1a\x1cgoogle/protobuf/struct.proto\"\xdc\x01\n" + + "\x05Query\x125\n" + + "\x04find\x18\x01 \x03(\tB!\xbaH\x1e\x92\x01\x1b\"\x19r\x17\x10\x01\x18\x80\x012\x10^[a-zA-Z0-9_-]+$R\x04find\x12A\n" + + "\x05match\x18\x02 \x01(\x0e2\x1f.worldengine.isc.v1.Query.MatchB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x05match\x12\x14\n" + + "\x05where\x18\x03 \x01(\tR\x05where\"C\n" + + "\x05Match\x12\x15\n" + + "\x11MATCH_UNSPECIFIED\x10\x00\x12\x0f\n" + + "\vMATCH_EXACT\x10\x01\x12\x12\n" + + "\x0eMATCH_CONTAINS\x10\x02\"B\n" + + "\vQueryResult\x123\n" + + "\bentities\x18\x01 \x03(\v2\x17.google.protobuf.StructR\bentitiesBeZHgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/isc/v1;iscv1\xaa\x02\x18WorldEngine.Proto.Isc.V1b\x06proto3" var ( file_worldengine_isc_v1_query_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/micro/v1/response.pb.go b/proto/gen/go/worldengine/micro/v1/response.pb.go index 55dd795d1..927c5e0c3 100644 --- a/proto/gen/go/worldengine/micro/v1/response.pb.go +++ b/proto/gen/go/worldengine/micro/v1/response.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/micro/v1/response.proto @@ -165,52 +165,22 @@ func (x *Response) GetPayload() *anypb.Any { var File_worldengine_micro_v1_response_proto protoreflect.FileDescriptor -var file_worldengine_micro_v1_response_proto_rawDesc = string([]byte{ - 0x0a, 0x23, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x6d, 0x69, - 0x63, 0x72, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, - 0x6e, 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x76, 0x31, 0x1a, 0x19, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x61, 0x6e, 0x79, - 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x72, - 0x70, 0x63, 0x2f, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, - 0x22, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x6d, 0x69, 0x63, - 0x72, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, 0x72, - 0x6f, 0x74, 0x6f, 0x22, 0xbb, 0x01, 0x0a, 0x07, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x22, 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, - 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, - 0x88, 0x01, 0x01, 0x12, 0x4d, 0x0a, 0x0f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x61, - 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x77, - 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, - 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x52, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x12, 0x2e, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x14, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, - 0x61, 0x64, 0x42, 0x0d, 0x0a, 0x0b, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, - 0x64, 0x22, 0xe8, 0x01, 0x0a, 0x08, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x22, - 0x0a, 0x0a, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x09, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x49, 0x64, 0x88, - 0x01, 0x01, 0x12, 0x4d, 0x0a, 0x0f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x61, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x24, 0x2e, 0x77, 0x6f, - 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2e, - 0x76, 0x31, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x52, 0x0e, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, - 0x73, 0x12, 0x2a, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x0b, 0x32, 0x12, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x72, 0x70, 0x63, 0x2e, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2e, 0x0a, - 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x14, - 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, - 0x2e, 0x41, 0x6e, 0x79, 0x52, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x42, 0x0d, 0x0a, - 0x0b, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x5f, 0x69, 0x64, 0x42, 0x6b, 0x5a, 0x4c, - 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, - 0x2d, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, - 0x6e, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, - 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x6d, 0x69, 0x63, 0x72, - 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x76, 0x31, 0xaa, 0x02, 0x1a, 0x57, - 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, - 0x2e, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, - 0x33, -}) +const file_worldengine_micro_v1_response_proto_rawDesc = "" + + "\n" + + "#worldengine/micro/v1/response.proto\x12\x14worldengine.micro.v1\x1a\x19google/protobuf/any.proto\x1a\x17google/rpc/status.proto\x1a\"worldengine/micro/v1/service.proto\"\xbb\x01\n" + + "\aRequest\x12\"\n" + + "\n" + + "request_id\x18\x01 \x01(\tH\x00R\trequestId\x88\x01\x01\x12M\n" + + "\x0fservice_address\x18\x02 \x01(\v2$.worldengine.micro.v1.ServiceAddressR\x0eserviceAddress\x12.\n" + + "\apayload\x18\x03 \x01(\v2\x14.google.protobuf.AnyR\apayloadB\r\n" + + "\v_request_id\"\xe8\x01\n" + + "\bResponse\x12\"\n" + + "\n" + + "request_id\x18\x01 \x01(\tH\x00R\trequestId\x88\x01\x01\x12M\n" + + "\x0fservice_address\x18\x02 \x01(\v2$.worldengine.micro.v1.ServiceAddressR\x0eserviceAddress\x12*\n" + + "\x06status\x18\x03 \x01(\v2\x12.google.rpc.StatusR\x06status\x12.\n" + + "\apayload\x18\x04 \x01(\v2\x14.google.protobuf.AnyR\apayloadB\r\n" + + "\v_request_idBkZLgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1;microv1\xaa\x02\x1aWorldEngine.Proto.Micro.V1b\x06proto3" var ( file_worldengine_micro_v1_response_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/micro/v1/service.pb.go b/proto/gen/go/worldengine/micro/v1/service.pb.go index 3ac9c9b8a..4e7e7e616 100644 --- a/proto/gen/go/worldengine/micro/v1/service.pb.go +++ b/proto/gen/go/worldengine/micro/v1/service.pb.go @@ -1,6 +1,6 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.5 +// protoc-gen-go v1.36.11 // protoc (unknown) // source: worldengine/micro/v1/service.proto @@ -193,45 +193,21 @@ func (x *ServiceAddress) GetServiceId() string { var File_worldengine_micro_v1_service_proto protoreflect.FileDescriptor -var file_worldengine_micro_v1_service_proto_rawDesc = string([]byte{ - 0x0a, 0x22, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x6d, 0x69, - 0x63, 0x72, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x70, - 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, - 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x76, 0x31, 0x1a, 0x1b, 0x62, 0x75, 0x66, 0x2f, - 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x2f, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xf7, 0x02, 0x0a, 0x0e, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x2d, 0x0a, 0x06, 0x72, 0x65, - 0x67, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x15, 0xba, 0x48, 0x12, 0x72, - 0x10, 0x10, 0x01, 0x32, 0x0c, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x2d, 0x5d, 0x2b, - 0x24, 0x52, 0x06, 0x72, 0x65, 0x67, 0x69, 0x6f, 0x6e, 0x12, 0x4c, 0x0a, 0x05, 0x72, 0x65, 0x61, - 0x6c, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2a, 0x2e, 0x77, 0x6f, 0x72, 0x6c, 0x64, - 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x76, 0x31, 0x2e, - 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x2e, 0x52, - 0x65, 0x61, 0x6c, 0x6d, 0x42, 0x0a, 0xba, 0x48, 0x07, 0x82, 0x01, 0x04, 0x10, 0x01, 0x20, 0x00, - 0x52, 0x05, 0x72, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x3a, 0x0a, 0x0c, 0x6f, 0x72, 0x67, 0x61, 0x6e, - 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x16, 0xba, - 0x48, 0x13, 0x72, 0x11, 0x10, 0x01, 0x32, 0x0d, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, - 0x5f, 0x2d, 0x5d, 0x2b, 0x24, 0x52, 0x0c, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, - 0x69, 0x6f, 0x6e, 0x12, 0x30, 0x0a, 0x07, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x42, 0x16, 0xba, 0x48, 0x13, 0x72, 0x11, 0x10, 0x01, 0x32, 0x0d, 0x5e, - 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5f, 0x2d, 0x5d, 0x2b, 0x24, 0x52, 0x07, 0x70, 0x72, - 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x12, 0x35, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, - 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x42, 0x16, 0xba, 0x48, 0x13, 0x72, 0x11, - 0x10, 0x01, 0x32, 0x0d, 0x5e, 0x5b, 0x61, 0x2d, 0x7a, 0x30, 0x2d, 0x39, 0x5f, 0x2d, 0x5d, 0x2b, - 0x24, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x22, 0x43, 0x0a, 0x05, - 0x52, 0x65, 0x61, 0x6c, 0x6d, 0x12, 0x15, 0x0a, 0x11, 0x52, 0x45, 0x41, 0x4c, 0x4d, 0x5f, 0x55, - 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x12, 0x0a, 0x0e, - 0x52, 0x45, 0x41, 0x4c, 0x4d, 0x5f, 0x49, 0x4e, 0x54, 0x45, 0x52, 0x4e, 0x41, 0x4c, 0x10, 0x01, - 0x12, 0x0f, 0x0a, 0x0b, 0x52, 0x45, 0x41, 0x4c, 0x4d, 0x5f, 0x57, 0x4f, 0x52, 0x4c, 0x44, 0x10, - 0x02, 0x42, 0x6b, 0x5a, 0x4c, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x61, 0x72, 0x67, 0x75, 0x73, 0x2d, 0x6c, 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, - 0x2d, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, - 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, - 0x2f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2f, 0x76, 0x31, 0x3b, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x76, - 0x31, 0xaa, 0x02, 0x1a, 0x57, 0x6f, 0x72, 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, - 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x56, 0x31, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_worldengine_micro_v1_service_proto_rawDesc = "" + + "\n" + + "\"worldengine/micro/v1/service.proto\x12\x14worldengine.micro.v1\x1a\x1bbuf/validate/validate.proto\"\xf7\x02\n" + + "\x0eServiceAddress\x12-\n" + + "\x06region\x18\x01 \x01(\tB\x15\xbaH\x12r\x10\x10\x012\f^[a-z0-9-]+$R\x06region\x12L\n" + + "\x05realm\x18\x02 \x01(\x0e2*.worldengine.micro.v1.ServiceAddress.RealmB\n" + + "\xbaH\a\x82\x01\x04\x10\x01 \x00R\x05realm\x12:\n" + + "\forganization\x18\x03 \x01(\tB\x16\xbaH\x13r\x11\x10\x012\r^[a-z0-9_-]+$R\forganization\x120\n" + + "\aproject\x18\x04 \x01(\tB\x16\xbaH\x13r\x11\x10\x012\r^[a-z0-9_-]+$R\aproject\x125\n" + + "\n" + + "service_id\x18\x05 \x01(\tB\x16\xbaH\x13r\x11\x10\x012\r^[a-z0-9_-]+$R\tserviceId\"C\n" + + "\x05Realm\x12\x15\n" + + "\x11REALM_UNSPECIFIED\x10\x00\x12\x12\n" + + "\x0eREALM_INTERNAL\x10\x01\x12\x0f\n" + + "\vREALM_WORLD\x10\x02BkZLgithub.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1;microv1\xaa\x02\x1aWorldEngine.Proto.Micro.V1b\x06proto3" var ( file_worldengine_micro_v1_service_proto_rawDescOnce sync.Once diff --git a/proto/gen/go/worldengine/micro/v1/snapshot.pb.go b/proto/gen/go/worldengine/micro/v1/snapshot.pb.go deleted file mode 100644 index b222d924c..000000000 --- a/proto/gen/go/worldengine/micro/v1/snapshot.pb.go +++ /dev/null @@ -1,180 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.5 -// protoc (unknown) -// source: worldengine/micro/v1/snapshot.proto - -package microv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - timestamppb "google.golang.org/protobuf/types/known/timestamppb" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// Snapshot represents a point-in-time capture of shard state. -type Snapshot struct { - state protoimpl.MessageState `protogen:"open.v1"` - EpochHeight uint64 `protobuf:"varint,1,opt,name=epoch_height,json=epochHeight,proto3" json:"epoch_height,omitempty"` - TickHeight uint64 `protobuf:"varint,2,opt,name=tick_height,json=tickHeight,proto3" json:"tick_height,omitempty"` - Timestamp *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` - StateHash []byte `protobuf:"bytes,4,opt,name=state_hash,json=stateHash,proto3" json:"state_hash,omitempty"` - Data []byte `protobuf:"bytes,6,opt,name=data,proto3" json:"data,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *Snapshot) Reset() { - *x = Snapshot{} - mi := &file_worldengine_micro_v1_snapshot_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *Snapshot) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*Snapshot) ProtoMessage() {} - -func (x *Snapshot) ProtoReflect() protoreflect.Message { - mi := &file_worldengine_micro_v1_snapshot_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use Snapshot.ProtoReflect.Descriptor instead. -func (*Snapshot) Descriptor() ([]byte, []int) { - return file_worldengine_micro_v1_snapshot_proto_rawDescGZIP(), []int{0} -} - -func (x *Snapshot) GetEpochHeight() uint64 { - if x != nil { - return x.EpochHeight - } - return 0 -} - -func (x *Snapshot) GetTickHeight() uint64 { - if x != nil { - return x.TickHeight - } - return 0 -} - -func (x *Snapshot) GetTimestamp() *timestamppb.Timestamp { - if x != nil { - return x.Timestamp - } - return nil -} - -func (x *Snapshot) GetStateHash() []byte { - if x != nil { - return x.StateHash - } - return nil -} - -func (x *Snapshot) GetData() []byte { - if x != nil { - return x.Data - } - return nil -} - -var File_worldengine_micro_v1_snapshot_proto protoreflect.FileDescriptor - -var file_worldengine_micro_v1_snapshot_proto_rawDesc = string([]byte{ - 0x0a, 0x23, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x6d, 0x69, - 0x63, 0x72, 0x6f, 0x2f, 0x76, 0x31, 0x2f, 0x73, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x2e, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x14, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, - 0x6e, 0x65, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x76, 0x31, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, - 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xbb, 0x01, 0x0a, - 0x08, 0x53, 0x6e, 0x61, 0x70, 0x73, 0x68, 0x6f, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x65, 0x70, 0x6f, - 0x63, 0x68, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, - 0x0b, 0x65, 0x70, 0x6f, 0x63, 0x68, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x1f, 0x0a, 0x0b, - 0x74, 0x69, 0x63, 0x6b, 0x5f, 0x68, 0x65, 0x69, 0x67, 0x68, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, - 0x04, 0x52, 0x0a, 0x74, 0x69, 0x63, 0x6b, 0x48, 0x65, 0x69, 0x67, 0x68, 0x74, 0x12, 0x38, 0x0a, - 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, - 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, - 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x74, 0x65, - 0x5f, 0x68, 0x61, 0x73, 0x68, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x73, 0x74, 0x61, - 0x74, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x06, - 0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x42, 0x6b, 0x5a, 0x4c, 0x67, 0x69, - 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x61, 0x72, 0x67, 0x75, 0x73, 0x2d, 0x6c, - 0x61, 0x62, 0x73, 0x2f, 0x77, 0x6f, 0x72, 0x6c, 0x64, 0x2d, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, - 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x65, 0x6e, 0x2f, 0x67, 0x6f, 0x2f, 0x77, 0x6f, - 0x72, 0x6c, 0x64, 0x65, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2f, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x2f, - 0x76, 0x31, 0x3b, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x76, 0x31, 0xaa, 0x02, 0x1a, 0x57, 0x6f, 0x72, - 0x6c, 0x64, 0x45, 0x6e, 0x67, 0x69, 0x6e, 0x65, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x4d, - 0x69, 0x63, 0x72, 0x6f, 0x2e, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) - -var ( - file_worldengine_micro_v1_snapshot_proto_rawDescOnce sync.Once - file_worldengine_micro_v1_snapshot_proto_rawDescData []byte -) - -func file_worldengine_micro_v1_snapshot_proto_rawDescGZIP() []byte { - file_worldengine_micro_v1_snapshot_proto_rawDescOnce.Do(func() { - file_worldengine_micro_v1_snapshot_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_worldengine_micro_v1_snapshot_proto_rawDesc), len(file_worldengine_micro_v1_snapshot_proto_rawDesc))) - }) - return file_worldengine_micro_v1_snapshot_proto_rawDescData -} - -var file_worldengine_micro_v1_snapshot_proto_msgTypes = make([]protoimpl.MessageInfo, 1) -var file_worldengine_micro_v1_snapshot_proto_goTypes = []any{ - (*Snapshot)(nil), // 0: worldengine.micro.v1.Snapshot - (*timestamppb.Timestamp)(nil), // 1: google.protobuf.Timestamp -} -var file_worldengine_micro_v1_snapshot_proto_depIdxs = []int32{ - 1, // 0: worldengine.micro.v1.Snapshot.timestamp:type_name -> google.protobuf.Timestamp - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name -} - -func init() { file_worldengine_micro_v1_snapshot_proto_init() } -func file_worldengine_micro_v1_snapshot_proto_init() { - if File_worldengine_micro_v1_snapshot_proto != nil { - return - } - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_worldengine_micro_v1_snapshot_proto_rawDesc), len(file_worldengine_micro_v1_snapshot_proto_rawDesc)), - NumEnums: 0, - NumMessages: 1, - NumExtensions: 0, - NumServices: 0, - }, - GoTypes: file_worldengine_micro_v1_snapshot_proto_goTypes, - DependencyIndexes: file_worldengine_micro_v1_snapshot_proto_depIdxs, - MessageInfos: file_worldengine_micro_v1_snapshot_proto_msgTypes, - }.Build() - File_worldengine_micro_v1_snapshot_proto = out.File - file_worldengine_micro_v1_snapshot_proto_goTypes = nil - file_worldengine_micro_v1_snapshot_proto_depIdxs = nil -} diff --git a/proto/gen/ts/worldengine/cardinal/v1/debug_pb.ts b/proto/gen/ts/worldengine/cardinal/v1/debug_pb.ts new file mode 100644 index 000000000..0b1fbcd70 --- /dev/null +++ b/proto/gen/ts/worldengine/cardinal/v1/debug_pb.ts @@ -0,0 +1,114 @@ +// @generated by protoc-gen-es v2.2.3 with parameter "target=ts" +// @generated from file worldengine/cardinal/v1/debug.proto (package worldengine.cardinal.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv1"; +import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv1"; +import { file_google_protobuf_struct } from "@bufbuild/protobuf/wkt"; +import type { JsonObject, Message } from "@bufbuild/protobuf"; + +/** + * Describes the file worldengine/cardinal/v1/debug.proto. + */ +export const file_worldengine_cardinal_v1_debug: GenFile = /*@__PURE__*/ + fileDesc("CiN3b3JsZGVuZ2luZS9jYXJkaW5hbC92MS9kZWJ1Zy5wcm90bxIXd29ybGRlbmdpbmUuY2FyZGluYWwudjEiEwoRSW50cm9zcGVjdFJlcXVlc3QiuQEKEkludHJvc3BlY3RSZXNwb25zZRI1Cghjb21tYW5kcxgBIAMoCzIjLndvcmxkZW5naW5lLmNhcmRpbmFsLnYxLlR5cGVTY2hlbWESNwoKY29tcG9uZW50cxgCIAMoCzIjLndvcmxkZW5naW5lLmNhcmRpbmFsLnYxLlR5cGVTY2hlbWESMwoGZXZlbnRzGAMgAygLMiMud29ybGRlbmdpbmUuY2FyZGluYWwudjEuVHlwZVNjaGVtYSJDCgpUeXBlU2NoZW1hEgwKBG5hbWUYASABKAkSJwoGc2NoZW1hGAIgASgLMhcuZ29vZ2xlLnByb3RvYnVmLlN0cnVjdDJ1CgxEZWJ1Z1NlcnZpY2USZQoKSW50cm9zcGVjdBIqLndvcmxkZW5naW5lLmNhcmRpbmFsLnYxLkludHJvc3BlY3RSZXF1ZXN0Gisud29ybGRlbmdpbmUuY2FyZGluYWwudjEuSW50cm9zcGVjdFJlc3BvbnNlQnRaUmdpdGh1Yi5jb20vYXJndXMtbGFicy93b3JsZC1lbmdpbmUvcHJvdG8vZ2VuL2dvL3dvcmxkZW5naW5lL2NhcmRpbmFsL3YxO2NhcmRpbmFsdjGqAh1Xb3JsZEVuZ2luZS5Qcm90by5DYXJkaW5hbC5WMWIGcHJvdG8z", [file_google_protobuf_struct]); + +/** + * IntrospectRequest is the request message for the Introspect RPC. + * + * @generated from message worldengine.cardinal.v1.IntrospectRequest + */ +export type IntrospectRequest = Message<"worldengine.cardinal.v1.IntrospectRequest"> & { +}; + +/** + * Describes the message worldengine.cardinal.v1.IntrospectRequest. + * Use `create(IntrospectRequestSchema)` to create a new message. + */ +export const IntrospectRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_worldengine_cardinal_v1_debug, 0); + +/** + * IntrospectResponse contains introspection metadata about the world. + * + * @generated from message worldengine.cardinal.v1.IntrospectResponse + */ +export type IntrospectResponse = Message<"worldengine.cardinal.v1.IntrospectResponse"> & { + /** + * JSON schemas for registered commands. + * + * @generated from field: repeated worldengine.cardinal.v1.TypeSchema commands = 1; + */ + commands: TypeSchema[]; + + /** + * JSON schemas for registered components. + * + * @generated from field: repeated worldengine.cardinal.v1.TypeSchema components = 2; + */ + components: TypeSchema[]; + + /** + * JSON schemas for registered events. + * + * @generated from field: repeated worldengine.cardinal.v1.TypeSchema events = 3; + */ + events: TypeSchema[]; +}; + +/** + * Describes the message worldengine.cardinal.v1.IntrospectResponse. + * Use `create(IntrospectResponseSchema)` to create a new message. + */ +export const IntrospectResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_worldengine_cardinal_v1_debug, 1); + +/** + * TypeSchema represents the JSON schema for a registered type. + * + * @generated from message worldengine.cardinal.v1.TypeSchema + */ +export type TypeSchema = Message<"worldengine.cardinal.v1.TypeSchema"> & { + /** + * Name of the type. + * + * @generated from field: string name = 1; + */ + name: string; + + /** + * JSON schema for the type. + * + * @generated from field: google.protobuf.Struct schema = 2; + */ + schema?: JsonObject; +}; + +/** + * Describes the message worldengine.cardinal.v1.TypeSchema. + * Use `create(TypeSchemaSchema)` to create a new message. + */ +export const TypeSchemaSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_worldengine_cardinal_v1_debug, 2); + +/** + * DebugService provides debugging and introspection endpoints for Cardinal. + * This service is intended for dev tooling (e.g., AI agents, debugging tools). + * + * @generated from service worldengine.cardinal.v1.DebugService + */ +export const DebugService: GenService<{ + /** + * Introspect returns metadata about the registered types in the world. + * The result includes JSON schemas for commands, components, and events. + * + * @generated from rpc worldengine.cardinal.v1.DebugService.Introspect + */ + introspect: { + methodKind: "unary"; + input: typeof IntrospectRequestSchema; + output: typeof IntrospectResponseSchema; + }, +}> = /*@__PURE__*/ + serviceDesc(file_worldengine_cardinal_v1_debug, 0); + diff --git a/proto/gen/ts/worldengine/cardinal/v1/snapshot_pb.ts b/proto/gen/ts/worldengine/cardinal/v1/snapshot_pb.ts index 657fdf4ee..cf599671e 100644 --- a/proto/gen/ts/worldengine/cardinal/v1/snapshot_pb.ts +++ b/proto/gen/ts/worldengine/cardinal/v1/snapshot_pb.ts @@ -5,20 +5,56 @@ import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1"; import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1"; import { file_buf_validate_validate } from "../../../buf/validate/validate_pb"; +import type { Timestamp } from "@bufbuild/protobuf/wkt"; +import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; import type { Message } from "@bufbuild/protobuf"; /** * Describes the file worldengine/cardinal/v1/snapshot.proto. */ export const file_worldengine_cardinal_v1_snapshot: GenFile = /*@__PURE__*/ - fileDesc("CiZ3b3JsZGVuZ2luZS9jYXJkaW5hbC92MS9zbmFwc2hvdC5wcm90bxIXd29ybGRlbmdpbmUuY2FyZGluYWwudjEiggEKEENhcmRpbmFsU25hcHNob3QSDwoHbmV4dF9pZBgBIAEoDRIQCghmcmVlX2lkcxgCIAMoDRITCgtlbnRpdHlfYXJjaBgDIAMoAxI2CgphcmNoZXR5cGVzGAQgAygLMiIud29ybGRlbmdpbmUuY2FyZGluYWwudjEuQXJjaGV0eXBlIoQBCglBcmNoZXR5cGUSCgoCaWQYASABKAUSGQoRY29tcG9uZW50c19iaXRtYXAYAiABKAwSDAoEcm93cxgDIAMoAxIQCghlbnRpdGllcxgEIAMoDRIwCgdjb2x1bW5zGAUgAygLMh8ud29ybGRlbmdpbmUuY2FyZGluYWwudjEuQ29sdW1uIj0KBkNvbHVtbhIfCg5jb21wb25lbnRfbmFtZRgBIAEoCUIHukgEcgIQARISCgpjb21wb25lbnRzGAIgAygMQnRaUmdpdGh1Yi5jb20vYXJndXMtbGFicy93b3JsZC1lbmdpbmUvcHJvdG8vZ2VuL2dvL3dvcmxkZW5naW5lL2NhcmRpbmFsL3YxO2NhcmRpbmFsdjGqAh1Xb3JsZEVuZ2luZS5Qcm90by5DYXJkaW5hbC5WMWIGcHJvdG8z", [file_buf_validate_validate]); + fileDesc("CiZ3b3JsZGVuZ2luZS9jYXJkaW5hbC92MS9zbmFwc2hvdC5wcm90bxIXd29ybGRlbmdpbmUuY2FyZGluYWwudjEimQEKCFNuYXBzaG90EhMKC3RpY2tfaGVpZ2h0GAEgASgEEi0KCXRpbWVzdGFtcBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoLd29ybGRfc3RhdGUYAyABKAsyIy53b3JsZGVuZ2luZS5jYXJkaW5hbC52MS5Xb3JsZFN0YXRlEg8KB3ZlcnNpb24YBCABKA0ifAoKV29ybGRTdGF0ZRIPCgduZXh0X2lkGAEgASgNEhAKCGZyZWVfaWRzGAIgAygNEhMKC2VudGl0eV9hcmNoGAMgAygDEjYKCmFyY2hldHlwZXMYBCADKAsyIi53b3JsZGVuZ2luZS5jYXJkaW5hbC52MS5BcmNoZXR5cGUihAEKCUFyY2hldHlwZRIKCgJpZBgBIAEoBRIZChFjb21wb25lbnRzX2JpdG1hcBgCIAEoDBIMCgRyb3dzGAMgAygDEhAKCGVudGl0aWVzGAQgAygNEjAKB2NvbHVtbnMYBSADKAsyHy53b3JsZGVuZ2luZS5jYXJkaW5hbC52MS5Db2x1bW4iPQoGQ29sdW1uEh8KDmNvbXBvbmVudF9uYW1lGAEgASgJQge6SARyAhABEhIKCmNvbXBvbmVudHMYAiADKAxCdFpSZ2l0aHViLmNvbS9hcmd1cy1sYWJzL3dvcmxkLWVuZ2luZS9wcm90by9nZW4vZ28vd29ybGRlbmdpbmUvY2FyZGluYWwvdjE7Y2FyZGluYWx2MaoCHVdvcmxkRW5naW5lLlByb3RvLkNhcmRpbmFsLlYxYgZwcm90bzM", [file_buf_validate_validate, file_google_protobuf_timestamp]); /** - * CardinalSnapshot represents a complete snapshot of the world state. + * Snapshot represents a point-in-time capture of shard state. * - * @generated from message worldengine.cardinal.v1.CardinalSnapshot + * @generated from message worldengine.cardinal.v1.Snapshot */ -export type CardinalSnapshot = Message<"worldengine.cardinal.v1.CardinalSnapshot"> & { +export type Snapshot = Message<"worldengine.cardinal.v1.Snapshot"> & { + /** + * @generated from field: uint64 tick_height = 1; + */ + tickHeight: bigint; + + /** + * @generated from field: google.protobuf.Timestamp timestamp = 2; + */ + timestamp?: Timestamp; + + /** + * @generated from field: worldengine.cardinal.v1.WorldState world_state = 3; + */ + worldState?: WorldState; + + /** + * @generated from field: uint32 version = 4; + */ + version: number; +}; + +/** + * Describes the message worldengine.cardinal.v1.Snapshot. + * Use `create(SnapshotSchema)` to create a new message. + */ +export const SnapshotSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_worldengine_cardinal_v1_snapshot, 0); + +/** + * WorldState represents the ECS world state. + * + * @generated from message worldengine.cardinal.v1.WorldState + */ +export type WorldState = Message<"worldengine.cardinal.v1.WorldState"> & { /** * Entity manager state * @@ -47,11 +83,11 @@ export type CardinalSnapshot = Message<"worldengine.cardinal.v1.CardinalSnapshot }; /** - * Describes the message worldengine.cardinal.v1.CardinalSnapshot. - * Use `create(CardinalSnapshotSchema)` to create a new message. + * Describes the message worldengine.cardinal.v1.WorldState. + * Use `create(WorldStateSchema)` to create a new message. */ -export const CardinalSnapshotSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_worldengine_cardinal_v1_snapshot, 0); +export const WorldStateSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_worldengine_cardinal_v1_snapshot, 1); /** * Archetype represents a collection of entities with the same component types. @@ -100,7 +136,7 @@ export type Archetype = Message<"worldengine.cardinal.v1.Archetype"> & { * Use `create(ArchetypeSchema)` to create a new message. */ export const ArchetypeSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_worldengine_cardinal_v1_snapshot, 1); + messageDesc(file_worldengine_cardinal_v1_snapshot, 2); /** * Column represents a sparse set data structure for storing component data. @@ -128,5 +164,5 @@ export type Column = Message<"worldengine.cardinal.v1.Column"> & { * Use `create(ColumnSchema)` to create a new message. */ export const ColumnSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_worldengine_cardinal_v1_snapshot, 2); + messageDesc(file_worldengine_cardinal_v1_snapshot, 3); diff --git a/proto/gen/ts/worldengine/micro/v1/snapshot_pb.ts b/proto/gen/ts/worldengine/micro/v1/snapshot_pb.ts deleted file mode 100644 index d4377cddd..000000000 --- a/proto/gen/ts/worldengine/micro/v1/snapshot_pb.ts +++ /dev/null @@ -1,55 +0,0 @@ -// @generated by protoc-gen-es v2.2.3 with parameter "target=ts" -// @generated from file worldengine/micro/v1/snapshot.proto (package worldengine.micro.v1, syntax proto3) -/* eslint-disable */ - -import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv1"; -import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv1"; -import type { Timestamp } from "@bufbuild/protobuf/wkt"; -import { file_google_protobuf_timestamp } from "@bufbuild/protobuf/wkt"; -import type { Message } from "@bufbuild/protobuf"; - -/** - * Describes the file worldengine/micro/v1/snapshot.proto. - */ -export const file_worldengine_micro_v1_snapshot: GenFile = /*@__PURE__*/ - fileDesc("CiN3b3JsZGVuZ2luZS9taWNyby92MS9zbmFwc2hvdC5wcm90bxIUd29ybGRlbmdpbmUubWljcm8udjEihgEKCFNuYXBzaG90EhQKDGVwb2NoX2hlaWdodBgBIAEoBBITCgt0aWNrX2hlaWdodBgCIAEoBBItCgl0aW1lc3RhbXAYAyABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhIKCnN0YXRlX2hhc2gYBCABKAwSDAoEZGF0YRgGIAEoDEJrWkxnaXRodWIuY29tL2FyZ3VzLWxhYnMvd29ybGQtZW5naW5lL3Byb3RvL2dlbi9nby93b3JsZGVuZ2luZS9taWNyby92MTttaWNyb3YxqgIaV29ybGRFbmdpbmUuUHJvdG8uTWljcm8uVjFiBnByb3RvMw", [file_google_protobuf_timestamp]); - -/** - * Snapshot represents a point-in-time capture of shard state. - * - * @generated from message worldengine.micro.v1.Snapshot - */ -export type Snapshot = Message<"worldengine.micro.v1.Snapshot"> & { - /** - * @generated from field: uint64 epoch_height = 1; - */ - epochHeight: bigint; - - /** - * @generated from field: uint64 tick_height = 2; - */ - tickHeight: bigint; - - /** - * @generated from field: google.protobuf.Timestamp timestamp = 3; - */ - timestamp?: Timestamp; - - /** - * @generated from field: bytes state_hash = 4; - */ - stateHash: Uint8Array; - - /** - * @generated from field: bytes data = 6; - */ - data: Uint8Array; -}; - -/** - * Describes the message worldengine.micro.v1.Snapshot. - * Use `create(SnapshotSchema)` to create a new message. - */ -export const SnapshotSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_worldengine_micro_v1_snapshot, 0); - diff --git a/proto/worldengine/cardinal/v1/debug.proto b/proto/worldengine/cardinal/v1/debug.proto new file mode 100644 index 000000000..533d9df84 --- /dev/null +++ b/proto/worldengine/cardinal/v1/debug.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package worldengine.cardinal.v1; + +import "google/protobuf/struct.proto"; + +option csharp_namespace = "WorldEngine.Proto.Cardinal.V1"; +option go_package = "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1;cardinalv1"; + +// DebugService provides debugging and introspection endpoints for Cardinal. +// This service is intended for dev tooling (e.g., AI agents, debugging tools). +service DebugService { + // Introspect returns metadata about the registered types in the world. + // The result includes JSON schemas for commands, components, and events. + rpc Introspect(IntrospectRequest) returns (IntrospectResponse); +} + +// IntrospectRequest is the request message for the Introspect RPC. +message IntrospectRequest {} + +// IntrospectResponse contains introspection metadata about the world. +message IntrospectResponse { + // JSON schemas for registered commands. + repeated TypeSchema commands = 1; + + // JSON schemas for registered components. + repeated TypeSchema components = 2; + + // JSON schemas for registered events. + repeated TypeSchema events = 3; +} + +// TypeSchema represents the JSON schema for a registered type. +message TypeSchema { + // Name of the type. + string name = 1; + + // JSON schema for the type. + google.protobuf.Struct schema = 2; +} diff --git a/proto/worldengine/cardinal/v1/snapshot.proto b/proto/worldengine/cardinal/v1/snapshot.proto index 694733bd5..1f74e8e8f 100644 --- a/proto/worldengine/cardinal/v1/snapshot.proto +++ b/proto/worldengine/cardinal/v1/snapshot.proto @@ -3,12 +3,24 @@ syntax = "proto3"; package worldengine.cardinal.v1; import "buf/validate/validate.proto"; +import "google/protobuf/timestamp.proto"; option csharp_namespace = "WorldEngine.Proto.Cardinal.V1"; option go_package = "github.com/argus-labs/world-engine/proto/gen/go/worldengine/cardinal/v1;cardinalv1"; -// CardinalSnapshot represents a complete snapshot of the world state. -message CardinalSnapshot { +// Snapshot represents a point-in-time capture of shard state. +message Snapshot { + uint64 tick_height = 1; + + google.protobuf.Timestamp timestamp = 2; + + WorldState world_state = 3; + + uint32 version = 4; +} + +// WorldState represents the ECS world state. +message WorldState { // Entity manager state uint32 next_id = 1; diff --git a/proto/worldengine/micro/v1/snapshot.proto b/proto/worldengine/micro/v1/snapshot.proto deleted file mode 100644 index 5f3369283..000000000 --- a/proto/worldengine/micro/v1/snapshot.proto +++ /dev/null @@ -1,21 +0,0 @@ -syntax = "proto3"; - -package worldengine.micro.v1; - -import "google/protobuf/timestamp.proto"; - -option csharp_namespace = "WorldEngine.Proto.Micro.V1"; -option go_package = "github.com/argus-labs/world-engine/proto/gen/go/worldengine/micro/v1;microv1"; - -// Snapshot represents a point-in-time capture of shard state. -message Snapshot { - uint64 epoch_height = 1; - - uint64 tick_height = 2; - - google.protobuf.Timestamp timestamp = 3; - - bytes state_hash = 4; - - bytes data = 6; -}