diff --git a/cardinal/ecs/query.go b/cardinal/ecs/query.go index 92f1536d3..691ac5669 100644 --- a/cardinal/ecs/query.go +++ b/cardinal/ecs/query.go @@ -33,15 +33,15 @@ type IQuery interface { IsEVMCompatible() bool } -type QueryType[Request any, Reply any] struct { +type Query[Request any, Reply any] struct { name string - handler func(wCtx WorldContext, req Request) (Reply, error) + handler func(wCtx WorldContext, req *Request) (*Reply, error) requestABI *ethereumAbi.Type replyABI *ethereumAbi.Type } -func WithQueryEVMSupport[Request, Reply any]() func(transactionType *QueryType[Request, Reply]) { - return func(query *QueryType[Request, Reply]) { +func WithQueryEVMSupport[Request, Reply any]() func(transactionType *Query[Request, Reply]) { + return func(query *Query[Request, Reply]) { err := query.generateABIBindings() if err != nil { panic(err) @@ -49,52 +49,32 @@ func WithQueryEVMSupport[Request, Reply any]() func(transactionType *QueryType[R } } -var _ IQuery = &QueryType[struct{}, struct{}]{} - func NewQueryType[Request any, Reply any]( name string, - handler func(wCtx WorldContext, req Request) (Reply, error), - opts ...func() func(queryType *QueryType[Request, Reply]), -) *QueryType[Request, Reply] { - if name == "" { - panic("cannot create query without name") - } - if handler == nil { - panic("cannot create query without handler") - } - var req Request - var rep Reply - reqType := reflect.TypeOf(req) - reqKind := reqType.Kind() - reqValid := false - if (reqKind == reflect.Pointer && reqType.Elem().Kind() == reflect.Struct) || reqKind == reflect.Struct { - reqValid = true - } - repType := reflect.TypeOf(rep) - repKind := reqType.Kind() - repValid := false - if (repKind == reflect.Pointer && repType.Elem().Kind() == reflect.Struct) || repKind == reflect.Struct { - repValid = true + handler func(wCtx WorldContext, req *Request) (*Reply, error), + opts ...func() func(queryType *Query[Request, Reply]), +) (IQuery, error) { + err := validateQuery[Request, Reply](name, handler) + if err != nil { + return nil, err } - if !repValid || !reqValid { - panic(fmt.Sprintf("Invalid QueryType: %s: The Request and Reply must be both structs", name)) - } - r := &QueryType[Request, Reply]{ + r := &Query[Request, Reply]{ name: name, handler: handler, } for _, opt := range opts { opt()(r) } - return r + + return r, nil } -func (r *QueryType[Request, Reply]) IsEVMCompatible() bool { +func (r *Query[Request, Reply]) IsEVMCompatible() bool { return r.requestABI != nil && r.replyABI != nil } -func (r *QueryType[Request, Reply]) generateABIBindings() error { +func (r *Query[Request, Reply]) generateABIBindings() error { var req Request reqABI, err := abi.GenerateABIType(req) if err != nil { @@ -110,30 +90,30 @@ func (r *QueryType[Request, Reply]) generateABIBindings() error { return nil } -func (r *QueryType[req, rep]) Name() string { +func (r *Query[req, rep]) Name() string { return r.name } -func (r *QueryType[req, rep]) Schema() (request, reply *jsonschema.Schema) { +func (r *Query[req, rep]) Schema() (request, reply *jsonschema.Schema) { return jsonschema.Reflect(new(req)), jsonschema.Reflect(new(rep)) } -func (r *QueryType[req, rep]) HandleQuery(wCtx WorldContext, a any) (any, error) { +func (r *Query[req, rep]) HandleQuery(wCtx WorldContext, a any) (any, error) { request, ok := a.(req) if !ok { return nil, fmt.Errorf("cannot cast %T to this query request type %T", a, new(req)) } - reply, err := r.handler(wCtx, request) + reply, err := r.handler(wCtx, &request) return reply, err } -func (r *QueryType[req, rep]) HandleQueryRaw(wCtx WorldContext, bz []byte) ([]byte, error) { +func (r *Query[req, rep]) HandleQueryRaw(wCtx WorldContext, bz []byte) ([]byte, error) { request := new(req) err := json.Unmarshal(bz, request) if err != nil { return nil, fmt.Errorf("unable to unmarshal query request into type %T: %w", *request, err) } - res, err := r.handler(wCtx, *request) + res, err := r.handler(wCtx, request) if err != nil { return nil, err } @@ -144,7 +124,7 @@ func (r *QueryType[req, rep]) HandleQueryRaw(wCtx WorldContext, bz []byte) ([]by return bz, nil } -func (r *QueryType[req, rep]) DecodeEVMRequest(bz []byte) (any, error) { +func (r *Query[req, rep]) DecodeEVMRequest(bz []byte) (any, error) { if r.requestABI == nil { return nil, ErrEVMTypeNotSet } @@ -163,7 +143,7 @@ func (r *QueryType[req, rep]) DecodeEVMRequest(bz []byte) (any, error) { return request, nil } -func (r *QueryType[req, rep]) DecodeEVMReply(bz []byte) (any, error) { +func (r *Query[req, rep]) DecodeEVMReply(bz []byte) (any, error) { if r.replyABI == nil { return nil, ErrEVMTypeNotSet } @@ -182,7 +162,7 @@ func (r *QueryType[req, rep]) DecodeEVMReply(bz []byte) (any, error) { return reply, nil } -func (r *QueryType[req, rep]) EncodeEVMReply(a any) ([]byte, error) { +func (r *Query[req, rep]) EncodeEVMReply(a any) ([]byte, error) { if r.replyABI == nil { return nil, ErrEVMTypeNotSet } @@ -191,7 +171,7 @@ func (r *QueryType[req, rep]) EncodeEVMReply(a any) ([]byte, error) { return bz, err } -func (r *QueryType[Request, Reply]) EncodeAsABI(input any) ([]byte, error) { +func (r *Query[Request, Reply]) EncodeAsABI(input any) ([]byte, error) { if r.requestABI == nil || r.replyABI == nil { return nil, ErrEVMTypeNotSet } @@ -217,3 +197,32 @@ func (r *QueryType[Request, Reply]) EncodeAsABI(input any) ([]byte, error) { } return bz, nil } + +func validateQuery[Request any, Reply any](name string, handler func(wCtx WorldContext, req *Request) (*Reply, error)) error { + if name == "" { + return errors.New("cannot create query without name") + } + if handler == nil { + return errors.New("cannot create query without handler") + } + + var req Request + var rep Reply + reqType := reflect.TypeOf(req) + reqKind := reqType.Kind() + reqValid := false + if (reqKind == reflect.Pointer && reqType.Elem().Kind() == reflect.Struct) || reqKind == reflect.Struct { + reqValid = true + } + repType := reflect.TypeOf(rep) + repKind := reqType.Kind() + repValid := false + if (repKind == reflect.Pointer && repType.Elem().Kind() == reflect.Struct) || repKind == reflect.Struct { + repValid = true + } + + if !repValid || !reqValid { + return errors.New(fmt.Sprintf("Invalid Query: %s: The Request and Reply must be both structs", name)) + } + return nil +} diff --git a/cardinal/ecs/query_test.go b/cardinal/ecs/query_test.go index 6ac3d79d2..f39daf7dc 100644 --- a/cardinal/ecs/query_test.go +++ b/cardinal/ecs/query_test.go @@ -2,7 +2,7 @@ package ecs_test import ( "context" - "github.com/stretchr/testify/require" + "pkg.world.dev/world-engine/cardinal/ecs" "pkg.world.dev/world-engine/cardinal/testutils" "testing" @@ -11,41 +11,14 @@ import ( "gotest.tools/v3/assert" routerv1 "pkg.world.dev/world-engine/rift/router/v1" - - "pkg.world.dev/world-engine/cardinal/ecs" ) func TestQueryTypeNotStructs(t *testing.T) { - type FooRequest struct { - ID string - } - type FooReply struct { - Name string - Age uint64 - } - - expectedReply := FooReply{ - Name: "Chad", - Age: 22, - } - - defer func() { - // test should trigger a panic. - panicValue := recover() - assert.Assert(t, panicValue != nil) - ecs.NewQueryType[FooRequest, FooReply]("foo", func(wCtx ecs.WorldContext, req FooRequest) (FooReply, error) { - return expectedReply, nil - }) - defer func() { - // deferred function should not fail - panicValue = recover() - assert.Assert(t, panicValue == nil) - }() - }() - - ecs.NewQueryType[string, string]("foo", func(wCtx ecs.WorldContext, req string) (string, error) { - return "blah", nil + str := "blah" + err := ecs.RegisterQuery[string, string](testutils.NewTestWorld(t).Instance(), "foo", func(wCtx ecs.WorldContext, req *string) (*string, error) { + return &str, nil }) + assert.Assert(t, err != nil) } func TestQueryEVM(t *testing.T) { @@ -62,13 +35,13 @@ func TestQueryEVM(t *testing.T) { Name: "Chad", Age: 22, } - fooQuery := ecs.NewQueryType[FooRequest, FooReply]("foo", func(wCtx ecs.WorldContext, req FooRequest, - ) (FooReply, error) { - return expectedReply, nil - }, ecs.WithQueryEVMSupport[FooRequest, FooReply]) w := testutils.NewTestWorld(t).Instance() - err := w.RegisterQueries(fooQuery) + err := ecs.RegisterQuery[FooRequest, FooReply](w, "foo", func(wCtx ecs.WorldContext, req *FooRequest, + ) (*FooReply, error) { + return &expectedReply, nil + }, ecs.WithQueryEVMSupport[FooRequest, FooReply]) + assert.NilError(t, err) err = w.RegisterMessages(ecs.NewMessageType[struct{}, struct{}]("blah")) assert.NilError(t, err) @@ -76,6 +49,8 @@ func TestQueryEVM(t *testing.T) { assert.NilError(t, err) // create the abi encoded bytes that the EVM would send. + fooQuery, err := w.GetQueryByName("foo") + assert.NilError(t, err) bz, err := fooQuery.EncodeAsABI(FooRequest{ID: "foo"}) assert.NilError(t, err) @@ -101,31 +76,32 @@ func TestPanicsOnNoNameOrHandler(t *testing.T) { type foo struct{} testCases := []struct { name string - createQuery func() - shouldPanic bool + createQuery func() error + shouldErr bool }{ { name: "panic on no name", - createQuery: func() { - ecs.NewQueryType[foo, foo]("", nil) + createQuery: func() error { + return ecs.RegisterQuery[foo, foo](testutils.NewTestWorld(t).Instance(), "", nil) }, - shouldPanic: true, + shouldErr: true, }, { name: "panic on no handler", - createQuery: func() { - ecs.NewQueryType[foo, foo]("foo", nil) + createQuery: func() error { + return ecs.RegisterQuery[foo, foo](testutils.NewTestWorld(t).Instance(), "foo", nil) }, - shouldPanic: true, + shouldErr: true, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - if tc.shouldPanic { - require.Panics(t, tc.createQuery) + if tc.shouldErr { + err := tc.createQuery() + assert.Assert(t, err != nil) } else { - require.NotPanics(t, tc.createQuery) + assert.NilError(t, tc.createQuery()) } }) } diff --git a/cardinal/ecs/world.go b/cardinal/ecs/world.go index 85e9b0876..52d2d9c12 100644 --- a/cardinal/ecs/world.go +++ b/cardinal/ecs/world.go @@ -50,6 +50,7 @@ type World struct { systemNames []string tick uint64 nameToComponent map[string]metadata.ComponentMetadata + nameToQuery map[string]IQuery registeredComponents []metadata.ComponentMetadata registeredMessages []message.Message registeredQueries []IQuery @@ -192,22 +193,39 @@ func (w *World) GetComponentByName(name string) (metadata.ComponentMetadata, err return componentType, nil } -func (w *World) RegisterQueries(queries ...IQuery) error { - if w.stateIsLoaded { +func RegisterQuery[Request any, Reply any]( + world *World, + name string, + handler func(wCtx WorldContext, req *Request) (*Reply, error), + opts ...func() func(queryType *Query[Request, Reply]), +) error { + if world.stateIsLoaded { panic("cannot register queries after loading game state") } - w.registeredQueries = append(w.registeredQueries, queries...) - seenQueryNames := map[string]struct{}{} - for _, t := range w.registeredQueries { - name := t.Name() - if _, ok := seenQueryNames[name]; ok { - return fmt.Errorf("duplicate query %q: %w", name, ErrDuplicateQueryName) - } - seenQueryNames[name] = struct{}{} + + if _, ok := world.nameToQuery[name]; ok { + return fmt.Errorf("query with name %s is already registered", name) } + + q, err := NewQueryType[Request, Reply](name, handler, opts...) + if err != nil { + return err + } + + world.registeredQueries = append(world.registeredQueries, q) + world.nameToQuery[q.Name()] = q + return nil } +func (w *World) GetQueryByName(name string) (IQuery, error) { + if q, ok := w.nameToQuery[name]; ok { + return q, nil + } else { + return nil, fmt.Errorf("query with name %s not found", name) + } +} + func (w *World) RegisterMessages(txs ...message.Message) error { if w.stateIsLoaded { panic("cannot register messages after loading game state") @@ -272,6 +290,7 @@ func NewWorld( systems: make([]System, 0), initSystem: func(_ WorldContext) error { return nil }, nameToComponent: make(map[string]metadata.ComponentMetadata), + nameToQuery: make(map[string]IQuery), txQueue: message.NewTxQueue(), Logger: logger, isGameLoopRunning: atomic.Bool{}, diff --git a/cardinal/evm/server_test.go b/cardinal/evm/server_test.go index 400fb610b..1a9e55fe5 100644 --- a/cardinal/evm/server_test.go +++ b/cardinal/evm/server_test.go @@ -2,6 +2,7 @@ package evm_test import ( "context" + "pkg.world.dev/world-engine/cardinal" "pkg.world.dev/world-engine/cardinal/evm" "pkg.world.dev/world-engine/cardinal/testutils" "strings" @@ -131,18 +132,21 @@ func TestServer_Query(t *testing.T) { Y uint64 } // set up a query that simply returns the FooReq.X - query := ecs.NewQueryType[FooReq, FooReply]("foo", func(wCtx ecs.WorldContext, req FooReq) (FooReply, error) { - return FooReply{Y: req.X}, nil - }, ecs.WithQueryEVMSupport[FooReq, FooReply]) - w := testutils.NewTestWorld(t).Instance() - err := w.RegisterQueries(query) + handleFooQuery := func(wCtx cardinal.WorldContext, req *FooReq) (*FooReply, error) { + return &FooReply{Y: req.X}, nil + } + w := testutils.NewTestWorld(t) + world := w.Instance() + err := cardinal.RegisterQueryWithEVMSupport[FooReq, FooReply](w, "foo", handleFooQuery) assert.NilError(t, err) - err = w.RegisterMessages(ecs.NewMessageType[struct{}, struct{}]("nothing")) + err = world.RegisterMessages(ecs.NewMessageType[struct{}, struct{}]("nothing")) assert.NilError(t, err) - s, err := evm.NewServer(w) + s, err := evm.NewServer(world) assert.NilError(t, err) request := FooReq{X: 3000} + query, err := world.GetQueryByName("foo") + assert.NilError(t, err) bz, err := query.EncodeAsABI(request) assert.NilError(t, err) diff --git a/cardinal/message.go b/cardinal/message.go index 5bd8160cf..0ae84d867 100644 --- a/cardinal/message.go +++ b/cardinal/message.go @@ -47,19 +47,19 @@ func (t *MessageType[Input, Result]) AddToQueue(world *World, data Input, sigs . // AddError adds the given error to the transaction identified by the given hash. Multiple errors can be // added to the same message hash. func (t *MessageType[Input, Result]) AddError(wCtx WorldContext, hash TxHash, err error) { - t.impl.AddError(wCtx.getECSWorldContext(), hash, err) + t.impl.AddError(wCtx.Instance(), hash, err) } // SetResult sets the result of the message identified by the given hash. Only one result may be associated // with a message hash, so calling this multiple times will clobber previously set results. func (t *MessageType[Input, Result]) SetResult(wCtx WorldContext, hash TxHash, result Result) { - t.impl.SetResult(wCtx.getECSWorldContext(), hash, result) + t.impl.SetResult(wCtx.Instance(), hash, result) } // GetReceipt returns the result (if any) and errors (if any) associated with the given hash. If false is returned, // the hash is not recognized, so the returned result and errors will be empty. func (t *MessageType[Input, Result]) GetReceipt(wCtx WorldContext, hash TxHash) (Result, []error, bool) { - return t.impl.GetReceipt(wCtx.getECSWorldContext(), hash) + return t.impl.GetReceipt(wCtx.Instance(), hash) } func (t *MessageType[Input, Result]) ForEach(wCtx WorldContext, fn func(TxData[Input]) (Result, error)) { @@ -67,12 +67,12 @@ func (t *MessageType[Input, Result]) ForEach(wCtx WorldContext, fn func(TxData[I adaptedTx := TxData[Input]{impl: ecsTxData} return fn(adaptedTx) } - t.impl.ForEach(wCtx.getECSWorldContext(), adapterFn) + t.impl.ForEach(wCtx.Instance(), adapterFn) } // In returns the TxData in the given transaction queue that match this message's type. func (t *MessageType[Input, Result]) In(wCtx WorldContext) []TxData[Input] { - ecsTxData := t.impl.In(wCtx.getECSWorldContext()) + ecsTxData := t.impl.In(wCtx.Instance()) out := make([]TxData[Input], 0, len(ecsTxData)) for _, tx := range ecsTxData { out = append(out, TxData[Input]{ diff --git a/cardinal/query.go b/cardinal/query.go deleted file mode 100644 index 521ec4e9b..000000000 --- a/cardinal/query.go +++ /dev/null @@ -1,61 +0,0 @@ -package cardinal - -import ( - "fmt" - "pkg.world.dev/world-engine/cardinal/ecs" -) - -// AnyQueryType is implemented by the return value of NewQueryType and is used in RegisterQueries; any -// query operation creates by NewQueryType can be registered with a World object via RegisterQueries. -type AnyQueryType interface { - Convert() ecs.IQuery -} - -// QueryType represents a query operation on a world object. The state of the world object must not be -// changed during the query operation. -type QueryType[Request, Reply any] struct { - impl *ecs.QueryType[Request, Reply] -} - -// NewQueryType creates a new instance of a QueryType. The World state must not be changed -// in the given handler function. -func NewQueryType[Request any, Reply any]( - name string, - handler func(WorldContext, Request) (Reply, error), -) *QueryType[Request, Reply] { - return &QueryType[Request, Reply]{ - impl: ecs.NewQueryType[Request, Reply](name, func(wCtx ecs.WorldContext, req Request) (Reply, error) { - return handler(&worldContext{implContext: wCtx}, req) - }), - } -} - -// NewQueryTypeWithEVMSupport creates a new instance of a QueryType with EVM support, allowing this query to be called -// from the EVM base shard. The World state must not be changed in the given handler function. -func NewQueryTypeWithEVMSupport[Request, Reply any](name string, handler func(WorldContext, Request) (Reply, error), -) *QueryType[Request, Reply] { - return &QueryType[Request, Reply]{ - impl: ecs.NewQueryType[Request, Reply](name, func(wCtx ecs.WorldContext, req Request) (Reply, error) { - return handler(&worldContext{implContext: wCtx}, req) - }, ecs.WithQueryEVMSupport[Request, Reply]), - } -} - -// Convert implements the AnyQueryType interface which allows a QueryType to be registered -// with a World via RegisterQueries. -func (q *QueryType[Request, Reply]) Convert() ecs.IQuery { - return q.impl -} - -func (q *QueryType[Request, Reply]) DoQuery(worldCtx WorldContext, req Request) (Reply, error) { - var reply Reply - iface, err := q.impl.HandleQuery(worldCtx.getECSWorldContext(), req) - if err != nil { - return reply, err - } - reply, ok := iface.(Reply) - if !ok { - return reply, fmt.Errorf("could not convert %T to %T", iface, reply) - } - return reply, nil -} diff --git a/cardinal/query_test.go b/cardinal/query_test.go index e7395da51..820c0ef78 100644 --- a/cardinal/query_test.go +++ b/cardinal/query_test.go @@ -43,7 +43,7 @@ func handleQueryHealth(worldCtx cardinal.WorldContext, request *QueryHealthReque return resp, nil } -func TestNewQueryTypeWithEVMSupport(_ *testing.T) { +func TestNewQueryTypeWithEVMSupport(t *testing.T) { // This test just makes sure that NeQueryTypeWithEVMSupport maintains api compatibility. // it is mainly here to check for compiler errors. type FooReq struct { @@ -52,21 +52,20 @@ func TestNewQueryTypeWithEVMSupport(_ *testing.T) { type FooReply struct { Y uint64 } - cardinal.NewQueryTypeWithEVMSupport[FooReq, FooReply]( + _ = cardinal.RegisterQueryWithEVMSupport[FooReq, FooReply]( + testutils.NewTestWorld(t), "query_health", func( _ cardinal.WorldContext, - _ FooReq) (FooReply, error) { - return FooReply{}, errors.New("this function should never get called") + _ *FooReq) (*FooReply, error) { + return &FooReply{}, errors.New("this function should never get called") }) } -var queryHealth = cardinal.NewQueryType[*QueryHealthRequest, *QueryHealthResponse]("query_health", handleQueryHealth) - func TestQueryExample(t *testing.T) { world, _ := testutils.MakeWorldAndTicker(t) assert.NilError(t, cardinal.RegisterComponent[Health](world)) - assert.NilError(t, cardinal.RegisterQueries(world, queryHealth)) + assert.NilError(t, cardinal.RegisterQuery[QueryHealthRequest, QueryHealthResponse](world, "query_health", handleQueryHealth)) worldCtx := testutils.WorldToWorldContext(world) ids, err := cardinal.CreateMany(worldCtx, 100, Health{}) @@ -80,17 +79,20 @@ func TestQueryExample(t *testing.T) { } // No entities should have health over a million. - resp, err := queryHealth.DoQuery(worldCtx, &QueryHealthRequest{1_000_000}) + q, err := world.Instance().GetQueryByName("query_health") + assert.NilError(t, err) + + resp, err := q.HandleQuery(worldCtx.Instance(), QueryHealthRequest{1_000_000}) assert.NilError(t, err) - assert.Equal(t, 0, len(resp.IDs)) + assert.Equal(t, 0, len(resp.(*QueryHealthResponse).IDs)) // All entities should have health over -100 - resp, err = queryHealth.DoQuery(worldCtx, &QueryHealthRequest{-100}) + resp, err = q.HandleQuery(worldCtx.Instance(), QueryHealthRequest{-100}) assert.NilError(t, err) - assert.Equal(t, 100, len(resp.IDs)) + assert.Equal(t, 100, len(resp.(*QueryHealthResponse).IDs)) // Exactly 10 entities should have health at or above 90 - resp, err = queryHealth.DoQuery(worldCtx, &QueryHealthRequest{90}) + resp, err = q.HandleQuery(worldCtx.Instance(), QueryHealthRequest{90}) assert.NilError(t, err) - assert.Equal(t, 10, len(resp.IDs)) + assert.Equal(t, 10, len(resp.(*QueryHealthResponse).IDs)) } diff --git a/cardinal/search.go b/cardinal/search.go index fced5ddac..088d14c6b 100644 --- a/cardinal/search.go +++ b/cardinal/search.go @@ -17,17 +17,17 @@ type SearchCallBackFn func(EntityID) bool // Each executes the given callback function on every EntityID that matches this search. If any call to callback returns // falls, no more entities will be processed. func (q *Search) Each(wCtx WorldContext, callback SearchCallBackFn) error { - return q.impl.Each(wCtx.getECSWorldContext(), func(eid entity.ID) bool { + return q.impl.Each(wCtx.Instance(), func(eid entity.ID) bool { return callback(eid) }) } // Count returns the number of entities that match this search. func (q *Search) Count(wCtx WorldContext) (int, error) { - return q.impl.Count(wCtx.getECSWorldContext()) + return q.impl.Count(wCtx.Instance()) } // First returns the first entity that matches this search. func (q *Search) First(wCtx WorldContext) (EntityID, error) { - return q.impl.First(wCtx.getECSWorldContext()) + return q.impl.First(wCtx.Instance()) } diff --git a/cardinal/server/query.go b/cardinal/server/query.go index ad903f5cf..8fb066c0c 100644 --- a/cardinal/server/query.go +++ b/cardinal/server/query.go @@ -17,11 +17,6 @@ import ( // //nolint:funlen,gocognit func (handler *Handler) registerQueryHandlerSwagger(api *untyped.API) error { - queryNameToQueryType := make(map[string]ecs.IQuery) - for _, query := range handler.w.ListQueries() { - queryNameToQueryType[query.Name()] = query - } - // query/game/{queryType} is a dynamic route that must dynamically handle things thus it can't use // the createSwaggerQueryHandler utility function below as the Request and Reply types are dynamic. queryHandler := runtime.OperationHandlerFunc(func(params interface{}) (interface{}, error) { @@ -37,10 +32,10 @@ func (handler *Handler) registerQueryHandlerSwagger(api *untyped.API) error { if !ok { return nil, fmt.Errorf("queryType was the wrong type, it should be a string from the path") } - outputType, ok := queryNameToQueryType[queryTypeString] - if !ok { - return middleware.Error(http.StatusNotFound, fmt.Errorf("queryType of type %s does not exist", - queryTypeString)), nil + + q, err := handler.w.GetQueryByName(queryTypeString) + if err != nil { + return middleware.Error(http.StatusNotFound, fmt.Errorf("query %s not found", queryTypeString)), nil } bodyData, ok := mapStruct["queryBody"] @@ -64,7 +59,7 @@ func (handler *Handler) registerQueryHandlerSwagger(api *untyped.API) error { return nil, err } wCtx := ecs.NewReadOnlyWorldContext(handler.w) - rawJSONReply, err := outputType.HandleQueryRaw(wCtx, rawJSONBody) + rawJSONReply, err := q.HandleQueryRaw(wCtx, rawJSONBody) if err != nil { return nil, err } diff --git a/cardinal/server/server_test.go b/cardinal/server/server_test.go index 0d24b6b93..b72f8cb11 100644 --- a/cardinal/server/server_test.go +++ b/cardinal/server/server_test.go @@ -302,17 +302,19 @@ type garbageStructBeta struct { func (garbageStructBeta) Name() string { return "beta" } func TestHandleSwaggerServer(t *testing.T) { - w := testutils.NewTestWorld(t).Instance() + w := testutils.NewTestWorld(t) + world := w.Instance() + sendTx := ecs.NewMessageType[SendEnergyTx, SendEnergyTxResult]("send-energy") - assert.NilError(t, w.RegisterMessages(sendTx)) - w.AddSystem(func(ecs.WorldContext) error { + assert.NilError(t, world.RegisterMessages(sendTx)) + world.AddSystem(func(ecs.WorldContext) error { return nil }) - assert.NilError(t, ecs.RegisterComponent[garbageStructAlpha](w)) - assert.NilError(t, ecs.RegisterComponent[garbageStructBeta](w)) + assert.NilError(t, ecs.RegisterComponent[garbageStructAlpha](world)) + assert.NilError(t, ecs.RegisterComponent[garbageStructBeta](world)) alphaCount := 75 - wCtx := ecs.NewWorldContext(w) + wCtx := ecs.NewWorldContext(world) _, err := component.CreateMany(wCtx, alphaCount, garbageStructAlpha{}) assert.NilError(t, err) bothCount := 100 @@ -322,14 +324,14 @@ func TestHandleSwaggerServer(t *testing.T) { // Queue up a CreatePersona personaTag := "foobar" signerAddress := "xyzzy" - ecs.CreatePersonaMsg.AddToQueue(w, ecs.CreatePersona{ + ecs.CreatePersonaMsg.AddToQueue(world, ecs.CreatePersona{ PersonaTag: personaTag, SignerAddress: signerAddress, }) authorizedPersonaAddress := ecs.AuthorizePersonaAddress{ Address: signerAddress, } - ecs.AuthorizePersonaAddressMsg.AddToQueue(w, authorizedPersonaAddress, &sign.Transaction{PersonaTag: personaTag}) + ecs.AuthorizePersonaAddressMsg.AddToQueue(world, authorizedPersonaAddress, &sign.Transaction{PersonaTag: personaTag}) // PersonaTag registration doesn't take place until the relevant system is run during a game tick. // create readers @@ -345,15 +347,13 @@ func TestHandleSwaggerServer(t *testing.T) { Name: "Chad", Age: 22, } - fooQuery := ecs.NewQueryType[FooRequest, FooReply]( - "foo", - func(wCtx ecs.WorldContext, req FooRequest, - ) (FooReply, error) { - return expectedReply, nil - }) - assert.NilError(t, w.RegisterQueries(fooQuery)) + fooQueryHandler := func(wCtx cardinal.WorldContext, req *FooRequest, + ) (*FooReply, error) { + return &expectedReply, nil + } + assert.NilError(t, cardinal.RegisterQuery[FooRequest, FooReply](w, "foo", fooQueryHandler)) - txh := testutils.MakeTestTransactionHandler(t, w, server.DisableSignatureVerification()) + txh := testutils.MakeTestTransactionHandler(t, world, server.DisableSignatureVerification()) // Test /query/http/endpoints expectedEndpointResult := server.EndpointsResult{ @@ -395,9 +395,9 @@ func TestHandleSwaggerServer(t *testing.T) { req.Header.Set("Accept", "application/json") client := http.Client{} ctx := context.Background() - err = w.LoadGameState() + err = world.LoadGameState() assert.NilError(t, err) - err = w.Tick(ctx) + err = world.Tick(ctx) assert.NilError(t, err) resp2, err := client.Do(req) assert.NilError(t, err) @@ -700,7 +700,8 @@ func TestSigVerificationChecksNonce(t *testing.T) { // TestCanListQueries tests that we can list the available queries in the handler. func TestCanListQueries(t *testing.T) { - world := testutils.NewTestWorld(t).Instance() + w := testutils.NewTestWorld(t) + world := w.Instance() type FooRequest struct { Foo int `json:"foo,omitempty"` Meow string `json:"bar,omitempty"` @@ -710,20 +711,22 @@ func TestCanListQueries(t *testing.T) { Meow string `json:"meow,omitempty"` } - fooQuery := ecs.NewQueryType[FooRequest, FooResponse]("foo", func(wCtx ecs.WorldContext, req FooRequest, - ) (FooResponse, error) { - return FooResponse{Meow: req.Meow}, nil - }) - barQuery := ecs.NewQueryType[FooRequest, FooResponse]("bar", func(wCtx ecs.WorldContext, req FooRequest, - ) (FooResponse, error) { - return FooResponse{Meow: req.Meow}, nil - }) - bazQuery := ecs.NewQueryType[FooRequest, FooResponse]("baz", func(wCtx ecs.WorldContext, req FooRequest, - ) (FooResponse, error) { - return FooResponse{Meow: req.Meow}, nil - }) + handleFooQuery := func(wCtx cardinal.WorldContext, req *FooRequest, + ) (*FooResponse, error) { + return &FooResponse{Meow: req.Meow}, nil + } + handleBarQuery := func(wCtx cardinal.WorldContext, req *FooRequest, + ) (*FooResponse, error) { + return &FooResponse{Meow: req.Meow}, nil + } + handleBazQuery := func(wCtx cardinal.WorldContext, req *FooRequest, + ) (*FooResponse, error) { + return &FooResponse{Meow: req.Meow}, nil + } - assert.NilError(t, world.RegisterQueries(fooQuery, barQuery, bazQuery)) + assert.NilError(t, cardinal.RegisterQuery[FooRequest, FooResponse](w, "foo", handleFooQuery)) + assert.NilError(t, cardinal.RegisterQuery[FooRequest, FooResponse](w, "bar", handleBarQuery)) + assert.NilError(t, cardinal.RegisterQuery[FooRequest, FooResponse](w, "baz", handleBazQuery)) assert.NilError(t, world.LoadGameState()) txh := testutils.MakeTestTransactionHandler(t, world, server.DisableSignatureVerification()) @@ -755,24 +758,23 @@ func TestCanListQueries(t *testing.T) { // work. func TestQueryEncodeDecode(t *testing.T) { // setup this read business stuff - endpoint := "foo" type FooRequest struct { Foo int `json:"foo,omitempty"` Meow string `json:"bar,omitempty"` } - type FooResponse struct { Meow string `json:"meow,omitempty"` } - fq := ecs.NewQueryType[FooRequest, FooResponse](endpoint, func(wCtx ecs.WorldContext, req FooRequest, - ) (FooResponse, error) { - return FooResponse{Meow: req.Meow}, nil - }) - url := "query/game/" + endpoint + handleFooQuery := func(wCtx cardinal.WorldContext, req *FooRequest) (*FooResponse, error) { + return &FooResponse{Meow: req.Meow}, nil + } + + url := "query/game/" + "foo" // set up the world, register the queries, load. - world := testutils.NewTestWorld(t).Instance() - assert.NilError(t, world.RegisterQueries(fq)) + w := testutils.NewTestWorld(t) + world := w.Instance() + assert.NilError(t, cardinal.RegisterQuery[FooRequest, FooResponse](w, "foo", handleFooQuery)) assert.NilError(t, world.LoadGameState()) // make our test tx handler diff --git a/cardinal/testing.go b/cardinal/testing.go index 0033c8c44..806fceb0c 100644 --- a/cardinal/testing.go +++ b/cardinal/testing.go @@ -6,11 +6,11 @@ import "pkg.world.dev/world-engine/cardinal/ecs" func TestingWorldToWorldContext(world *World) WorldContext { ecsWorldCtx := ecs.NewWorldContext(world.instance) - return &worldContext{implContext: ecsWorldCtx} + return &worldContext{instance: ecsWorldCtx} } func TestingWorldContextToECSWorld(worldCtx WorldContext) *ecs.World { - return worldCtx.getECSWorldContext().GetWorld() + return worldCtx.Instance().GetWorld() } func (w *World) TestingGetTransactionReceiptsForTick(tick uint64) ([]Receipt, error) { diff --git a/cardinal/util.go b/cardinal/util.go index aa43a4d27..2b068a8d2 100644 --- a/cardinal/util.go +++ b/cardinal/util.go @@ -14,14 +14,6 @@ func toMessageType(ins []AnyMessage) []message.Message { return out } -func toIQueryType(ins []AnyQueryType) []ecs.IQuery { - out := make([]ecs.IQuery, 0, len(ins)) - for _, r := range ins { - out = append(out, r.Convert()) - } - return out -} - // separateOptions separates the given options into ecs options, server options, and cardinal (this package) options. // The different options are all grouped together to simplify the end user's experience, but under the hood different // options are meant for different sub-systems. diff --git a/cardinal/world.go b/cardinal/world.go index f01098ef0..59caa9475 100644 --- a/cardinal/world.go +++ b/cardinal/world.go @@ -125,43 +125,43 @@ func NewMockWorld(opts ...WorldOption) (*World, error) { // CreateMany creates multiple entities in the world, and returns the slice of ids for the newly created // entities. At least 1 component must be provided. func CreateMany(wCtx WorldContext, num int, components ...metadata.Component) ([]EntityID, error) { - return component.CreateMany(wCtx.getECSWorldContext(), num, components...) + return component.CreateMany(wCtx.Instance(), num, components...) } // Create creates a single entity in the world, and returns the id of the newly created entity. // At least 1 component must be provided. func Create(wCtx WorldContext, components ...metadata.Component) (EntityID, error) { - return component.Create(wCtx.getECSWorldContext(), components...) + return component.Create(wCtx.Instance(), components...) } // SetComponent Set sets component data to the entity. func SetComponent[T metadata.Component](wCtx WorldContext, id entity.ID, comp *T) error { - return component.SetComponent[T](wCtx.getECSWorldContext(), id, comp) + return component.SetComponent[T](wCtx.Instance(), id, comp) } // GetComponent Get returns component data from the entity. func GetComponent[T metadata.Component](wCtx WorldContext, id entity.ID) (*T, error) { - return component.GetComponent[T](wCtx.getECSWorldContext(), id) + return component.GetComponent[T](wCtx.Instance(), id) } // UpdateComponent Updates a component on an entity. func UpdateComponent[T metadata.Component](wCtx WorldContext, id entity.ID, fn func(*T) *T) error { - return component.UpdateComponent[T](wCtx.getECSWorldContext(), id, fn) + return component.UpdateComponent[T](wCtx.Instance(), id, fn) } // AddComponentTo Adds a component on an entity. func AddComponentTo[T metadata.Component](wCtx WorldContext, id entity.ID) error { - return component.AddComponentTo[T](wCtx.getECSWorldContext(), id) + return component.AddComponentTo[T](wCtx.Instance(), id) } // RemoveComponentFrom Removes a component from an entity. func RemoveComponentFrom[T metadata.Component](wCtx WorldContext, id entity.ID) error { - return component.RemoveComponentFrom[T](wCtx.getECSWorldContext(), id) + return component.RemoveComponentFrom[T](wCtx.Instance(), id) } // Remove removes the given entity id from the world. func Remove(wCtx WorldContext, id EntityID) error { - return wCtx.getECSWorldContext().GetWorld().Remove(id) + return wCtx.Instance().GetWorld().Remove(id) } func (w *World) handleShutdown() { @@ -269,7 +269,7 @@ func RegisterSystems(w *World, systems ...System) { sys := system w.instance.AddSystemWithName(func(wCtx ecs.WorldContext) error { return sys(&worldContext{ - implContext: wCtx, + instance: wCtx, }) }, functionName) } @@ -285,10 +285,46 @@ func RegisterMessages(w *World, msgs ...AnyMessage) error { return w.instance.RegisterMessages(toMessageType(msgs)...) } -// RegisterQueries adds the given query capabilities to the game world. HTTP endpoints to use these queries +// RegisterQuery adds the given query to the game world. HTTP endpoints to use these queries +// will automatically be created when StartGame is called. This function does not add EVM support to the query. +func RegisterQuery[Request any, Reply any]( + world *World, + name string, + handler func(wCtx WorldContext, req *Request) (*Reply, error), +) error { + err := ecs.RegisterQuery[Request, Reply]( + world.instance, + name, + func(wCtx ecs.WorldContext, req *Request) (*Reply, error) { + return handler(&worldContext{instance: wCtx}, req) + }, + ) + if err != nil { + return err + } + return nil +} + +// RegisterQueryWithEVMSupport adds the given query to the game world. HTTP endpoints to use these queries // will automatically be created when StartGame is called. This Register method must only be called once. -func RegisterQueries(w *World, queries ...AnyQueryType) error { - return w.instance.RegisterQueries(toIQueryType(queries)...) +// This function also adds EVM support to the query. +func RegisterQueryWithEVMSupport[Request any, Reply any]( + world *World, + name string, + handler func(wCtx WorldContext, req *Request) (*Reply, error), +) error { + err := ecs.RegisterQuery[Request, Reply]( + world.instance, + name, + func(wCtx ecs.WorldContext, req *Request) (*Reply, error) { + return handler(&worldContext{instance: wCtx}, req) + }, + ecs.WithQueryEVMSupport[Request, Reply], + ) + if err != nil { + return err + } + return nil } func (w *World) Instance() *ecs.World { @@ -306,6 +342,6 @@ func (w *World) Tick(ctx context.Context) error { // Init Registers a system that only runs once on a new game before tick 0. func (w *World) Init(system System) { w.instance.AddInitSystem(func(ecsWctx ecs.WorldContext) error { - return system(&worldContext{implContext: ecsWctx}) + return system(&worldContext{instance: ecsWctx}) }) } diff --git a/cardinal/world_context.go b/cardinal/world_context.go index 5a887c03e..0c2f61440 100644 --- a/cardinal/world_context.go +++ b/cardinal/world_context.go @@ -11,33 +11,33 @@ type WorldContext interface { CurrentTick() uint64 EmitEvent(event string) Logger() *zerolog.Logger - getECSWorldContext() ecs.WorldContext + Instance() ecs.WorldContext } type worldContext struct { - implContext ecs.WorldContext + instance ecs.WorldContext } func (wCtx *worldContext) EmitEvent(event string) { - wCtx.getECSWorldContext().GetWorld().EmitEvent(&events.Event{Message: event}) + wCtx.instance.GetWorld().EmitEvent(&events.Event{Message: event}) } func (wCtx *worldContext) CurrentTick() uint64 { - return wCtx.implContext.CurrentTick() + return wCtx.instance.CurrentTick() } func (wCtx *worldContext) Logger() *zerolog.Logger { - return wCtx.implContext.Logger() + return wCtx.instance.Logger() } func (wCtx *worldContext) NewSearch(filter Filter) (*Search, error) { - ecsSearch, err := wCtx.implContext.NewSearch(filter.convertToFilterable()) + ecsSearch, err := wCtx.instance.NewSearch(filter.convertToFilterable()) if err != nil { return nil, err } return &Search{impl: ecsSearch}, nil } -func (wCtx *worldContext) getECSWorldContext() ecs.WorldContext { - return wCtx.implContext +func (wCtx *worldContext) Instance() ecs.WorldContext { + return wCtx.instance }