From dcbff80e7a986a69ecfa55889d5f16f8cc9fe678 Mon Sep 17 00:00:00 2001 From: Scott Sunarto Date: Sat, 9 Nov 2024 17:57:37 -0800 Subject: [PATCH] refactor: v2 --- cardinal/cardinal_test.go | 2 +- cardinal/go.mod | 43 +- cardinal/go.sum | 70 +- cardinal/server/server_test.go | 2 +- e2e/testgames/go.mod | 32 +- e2e/testgames/go.sum | 58 +- e2e/tests/go.mod | 16 +- e2e/tests/go.sum | 39 +- evm/go.mod | 47 +- evm/go.sum | 77 +- go.work | 3 +- go.work.sum | 7 +- relay/nakama/go.mod | 4 +- relay/nakama/go.sum | 6 +- relay/nakama/signer/kms_test.go | 4 +- relay/nakama/signer/nakama.go | 6 +- rift/go.mod | 19 +- rift/go.sum | 31 +- sign/go.mod | 4 +- sign/go.sum | 6 +- sign/sign.go | 29 +- sign/sign_test.go | 26 +- v2/cardinal.go | 329 +++++++ v2/cardinal_test.go | 445 +++++++++ v2/codec/codec.go | 23 + v2/codec/codec_test.go | 49 + v2/config/config.go | 232 +++++ v2/config/config_test.go | 243 +++++ v2/gamestate/active.go | 36 + v2/gamestate/component.go | 133 +++ v2/gamestate/component_utils.go | 55 ++ v2/gamestate/doc.go | 78 ++ v2/gamestate/ecb.go | 1032 +++++++++++++++++++++ v2/gamestate/ecb_test.go | 951 +++++++++++++++++++ v2/gamestate/errors.go | 17 + v2/gamestate/finalized_state.go | 310 +++++++ v2/gamestate/finalized_state_test.go | 210 +++++ v2/gamestate/iterators.go | 36 + v2/gamestate/keys.go | 44 + v2/gamestate/map.go | 54 ++ v2/gamestate/primitivestorage.go | 27 + v2/gamestate/redis.go | 145 +++ v2/gamestate/schema_storage.go | 6 + v2/gamestate/search/filter/all.go | 16 + v2/gamestate/search/filter/and.go | 22 + v2/gamestate/search/filter/contains.go | 24 + v2/gamestate/search/filter/exact.go | 27 + v2/gamestate/search/filter/filter.go | 40 + v2/gamestate/search/filter/filter_test.go | 186 ++++ v2/gamestate/search/filter/helper.go | 19 + v2/gamestate/search/filter/not.go | 17 + v2/gamestate/search/filter/or.go | 22 + v2/gamestate/search/search.go | 228 +++++ v2/gamestate/search/search_filter.go | 45 + v2/gamestate/search/search_test.go | 370 ++++++++ v2/gamestate/state.go | 147 +++ v2/gamestate/volatilestorage.go | 11 + v2/go.mod | 126 +++ v2/go.sum | 415 +++++++++ v2/option.go | 60 ++ v2/plugin/task/plugin.go | 165 ++++ v2/plugin/task/plugin_test.go | 516 +++++++++++ v2/server/docs/docs.go | 482 ++++++++++ v2/server/docs/swagger.json | 461 +++++++++ v2/server/docs/swagger.yaml | 304 ++++++ v2/server/error.go | 28 + v2/server/handler/debug.go | 50 + v2/server/handler/events.go | 31 + v2/server/handler/health.go | 27 + v2/server/handler/query.go | 34 + v2/server/handler/receipts.go | 47 + v2/server/handler/tx.go | 88 ++ v2/server/handler/world.go | 41 + v2/server/server.go | 143 +++ v2/server/server_test.go | 440 +++++++++ v2/server/utils/utils.go | 19 + v2/storage/nonce_test.go | 138 +++ v2/storage/redis/keys.go | 16 + v2/storage/redis/nonce.go | 143 +++ v2/storage/redis/schema.go | 39 + v2/storage/redis/storage.go | 53 ++ v2/storage/schema_test.go | 48 + v2/storage/storage.go | 16 + v2/telemetry/telemetry.go | 95 ++ v2/test_fixture.go | 323 +++++++ v2/testutils/testutils.go | 25 + v2/tick/receipt.go | 14 + v2/tick/tick.go | 67 ++ v2/types/archetype.go | 3 + v2/types/component.go | 72 ++ v2/types/entity.go | 10 + v2/types/errors.go | 5 + v2/types/info.go | 21 + v2/types/message/message.go | 198 ++++ v2/types/message/tx.go | 64 ++ v2/types/namespace.go | 27 + v2/types/util.go | 30 + v2/types/util_test.go | 57 ++ v2/world/entity.go | 199 ++++ v2/world/errors_test.go | 381 ++++++++ v2/world/option.go | 14 + v2/world/persona.go | 368 ++++++++ v2/world/persona_test.go | 283 ++++++ v2/world/query.go | 156 ++++ v2/world/query_test.go | 159 ++++ v2/world/register.go | 63 ++ v2/world/system_test.go | 138 +++ v2/world/world.go | 219 +++++ v2/world/world_context.go | 165 ++++ v2/world/world_query.go | 47 + v2/world/world_system.go | 94 ++ v2/world/world_tick.go | 97 ++ v2/world/world_tx.go | 135 +++ 113 files changed, 13297 insertions(+), 322 deletions(-) create mode 100644 v2/cardinal.go create mode 100644 v2/cardinal_test.go create mode 100644 v2/codec/codec.go create mode 100644 v2/codec/codec_test.go create mode 100644 v2/config/config.go create mode 100644 v2/config/config_test.go create mode 100644 v2/gamestate/active.go create mode 100644 v2/gamestate/component.go create mode 100644 v2/gamestate/component_utils.go create mode 100644 v2/gamestate/doc.go create mode 100644 v2/gamestate/ecb.go create mode 100644 v2/gamestate/ecb_test.go create mode 100644 v2/gamestate/errors.go create mode 100644 v2/gamestate/finalized_state.go create mode 100644 v2/gamestate/finalized_state_test.go create mode 100644 v2/gamestate/iterators.go create mode 100644 v2/gamestate/keys.go create mode 100644 v2/gamestate/map.go create mode 100644 v2/gamestate/primitivestorage.go create mode 100644 v2/gamestate/redis.go create mode 100644 v2/gamestate/schema_storage.go create mode 100644 v2/gamestate/search/filter/all.go create mode 100644 v2/gamestate/search/filter/and.go create mode 100644 v2/gamestate/search/filter/contains.go create mode 100644 v2/gamestate/search/filter/exact.go create mode 100644 v2/gamestate/search/filter/filter.go create mode 100644 v2/gamestate/search/filter/filter_test.go create mode 100644 v2/gamestate/search/filter/helper.go create mode 100644 v2/gamestate/search/filter/not.go create mode 100644 v2/gamestate/search/filter/or.go create mode 100644 v2/gamestate/search/search.go create mode 100644 v2/gamestate/search/search_filter.go create mode 100644 v2/gamestate/search/search_test.go create mode 100644 v2/gamestate/state.go create mode 100644 v2/gamestate/volatilestorage.go create mode 100644 v2/go.mod create mode 100644 v2/go.sum create mode 100644 v2/option.go create mode 100644 v2/plugin/task/plugin.go create mode 100644 v2/plugin/task/plugin_test.go create mode 100644 v2/server/docs/docs.go create mode 100644 v2/server/docs/swagger.json create mode 100644 v2/server/docs/swagger.yaml create mode 100644 v2/server/error.go create mode 100644 v2/server/handler/debug.go create mode 100644 v2/server/handler/events.go create mode 100644 v2/server/handler/health.go create mode 100644 v2/server/handler/query.go create mode 100644 v2/server/handler/receipts.go create mode 100644 v2/server/handler/tx.go create mode 100644 v2/server/handler/world.go create mode 100644 v2/server/server.go create mode 100644 v2/server/server_test.go create mode 100644 v2/server/utils/utils.go create mode 100644 v2/storage/nonce_test.go create mode 100644 v2/storage/redis/keys.go create mode 100644 v2/storage/redis/nonce.go create mode 100644 v2/storage/redis/schema.go create mode 100644 v2/storage/redis/storage.go create mode 100644 v2/storage/schema_test.go create mode 100644 v2/storage/storage.go create mode 100644 v2/telemetry/telemetry.go create mode 100644 v2/test_fixture.go create mode 100644 v2/testutils/testutils.go create mode 100644 v2/tick/receipt.go create mode 100644 v2/tick/tick.go create mode 100644 v2/types/archetype.go create mode 100644 v2/types/component.go create mode 100644 v2/types/entity.go create mode 100644 v2/types/errors.go create mode 100644 v2/types/info.go create mode 100644 v2/types/message/message.go create mode 100644 v2/types/message/tx.go create mode 100644 v2/types/namespace.go create mode 100644 v2/types/util.go create mode 100644 v2/types/util_test.go create mode 100644 v2/world/entity.go create mode 100644 v2/world/errors_test.go create mode 100644 v2/world/option.go create mode 100644 v2/world/persona.go create mode 100644 v2/world/persona_test.go create mode 100644 v2/world/query.go create mode 100644 v2/world/query_test.go create mode 100644 v2/world/register.go create mode 100644 v2/world/system_test.go create mode 100644 v2/world/world.go create mode 100644 v2/world/world_context.go create mode 100644 v2/world/world_query.go create mode 100644 v2/world/world_system.go create mode 100644 v2/world/world_tick.go create mode 100644 v2/world/world_tx.go diff --git a/cardinal/cardinal_test.go b/cardinal/cardinal_test.go index bdb92823a..2efc839b3 100644 --- a/cardinal/cardinal_test.go +++ b/cardinal/cardinal_test.go @@ -702,7 +702,7 @@ func TestCreatePersona(t *testing.T) { } wantBody, err := json.Marshal(body) assert.NilError(t, err) - sp, err := sign.NewSystemTransaction(goodKey, namespace, wantBody) + sp, err := sign.NewTransaction(goodKey, "a", namespace, wantBody) assert.NilError(t, err) bodyBytes, err := json.Marshal(sp) assert.NilError(t, err) diff --git a/cardinal/go.mod b/cardinal/go.mod index 6034f1feb..1a87d35bb 100644 --- a/cardinal/go.mod +++ b/cardinal/go.mod @@ -27,10 +27,10 @@ require ( github.com/stretchr/testify v1.9.0 github.com/swaggo/swag v1.16.3 github.com/wI2L/jsondiff v0.5.0 - go.opentelemetry.io/otel v1.26.0 - go.opentelemetry.io/otel/trace v1.26.0 - golang.org/x/sync v0.6.0 - google.golang.org/grpc v1.63.2 + go.opentelemetry.io/otel v1.29.0 + go.opentelemetry.io/otel/trace v1.29.0 + golang.org/x/sync v0.7.0 + google.golang.org/grpc v1.64.0 google.golang.org/protobuf v1.35.1 gopkg.in/DataDog/dd-trace-go.v1 v1.63.1 gotest.tools/v3 v3.5.1 @@ -53,16 +53,17 @@ require ( github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect github.com/andybalholm/brotli v1.1.0 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect - github.com/dgraph-io/badger/v4 v4.2.0 // indirect + github.com/dgraph-io/badger/v4 v4.2.1-0.20231013074411-fb1b00959581 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.6.0-alpha.5 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect @@ -72,10 +73,9 @@ require ( github.com/golang/glog v1.2.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect - github.com/google/flatbuffers v1.12.1 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect + github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.1-vault-5 // indirect @@ -98,7 +98,7 @@ require ( github.com/puzpuzpuz/xsync/v3 v3.2.0 // indirect github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.11.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect @@ -119,19 +119,20 @@ require ( github.com/valyala/tcplisten v1.0.0 // indirect github.com/yuin/gopher-lua v1.1.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/otel/metric v1.26.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect - golang.org/x/mod v0.14.0 // indirect - golang.org/x/net v0.23.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.16.1 // indirect - golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cardinal/go.sum b/cardinal/go.sum index f2645bd25..38d80e8c4 100644 --- a/cardinal/go.sum +++ b/cardinal/go.sum @@ -49,8 +49,7 @@ github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtyd github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -69,12 +68,11 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= -github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/badger/v4 v4.2.1-0.20231013074411-fb1b00959581 h1:yy45brf1ktmnkTCZlHynP1gRlVwZ9g19oz5D9wG81v4= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -90,15 +88,13 @@ github.com/ethereum/go-ethereum v1.13.10 h1:Ppdil79nN+Vc+mXfge0AuUgmKWuVv4eMqzoI github.com/ethereum/go-ethereum v1.13.10/go.mod h1:sc48XYQxCzH3fG9BcrXCOOgQk2JfZzNAmIKnceogzsA= github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -151,10 +147,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= -github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -167,8 +160,7 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b h1:h9U78+dx9a4BKdQkBBos92HalKpaGKHrp+3Uo6yTodo= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 h1:E/LAvt58di64hlYjx7AsNS6C/ysHWYo+2qPCZKTQhRo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -252,8 +244,7 @@ github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052/go.mod h1 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -337,31 +328,24 @@ github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= -go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= -go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= -go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= -go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= -go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= -go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -372,8 +356,7 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91 golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0= -golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -390,8 +373,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -401,8 +383,7 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -439,8 +420,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -456,28 +436,24 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.16.1 h1:TLyB3WofjdOEepBHAU20JdNC1Zbg87elYofWYAY5oZA= -golang.org/x/tools v0.16.1/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3jS9O0/s90v0rJh3X/OLHEUk= -golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/cardinal/server/server_test.go b/cardinal/server/server_test.go index 0ec132cb7..c8a106466 100644 --- a/cardinal/server/server_test.go +++ b/cardinal/server/server_test.go @@ -351,7 +351,7 @@ func (s *ServerTestSuite) createPersona(personaTag string) { PersonaTag: personaTag, SignerAddress: s.signerAddr, } - tx, err := sign.NewSystemTransaction(s.privateKey, s.world.Namespace(), createPersonaTx) + tx, err := sign.NewTransaction(s.privateKey, personaTag, s.world.Namespace(), createPersonaTx) s.Require().NoError(err) res := s.fixture.Post(utils.GetTxURL("persona", "create-persona"), tx) s.Require().Equal(fiber.StatusOK, res.StatusCode, s.readBody(res.Body)) diff --git a/e2e/testgames/go.mod b/e2e/testgames/go.mod index 8e0c831ef..3921da99f 100644 --- a/e2e/testgames/go.mod +++ b/e2e/testgames/go.mod @@ -29,15 +29,16 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/coocood/freecache v1.2.4 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect - github.com/dgraph-io/badger/v4 v4.2.0 // indirect + github.com/dgraph-io/badger/v4 v4.2.1-0.20231013074411-fb1b00959581 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.6.0-alpha.5 // indirect github.com/ethereum/go-ethereum v1.13.10 // indirect github.com/fasthttp/websocket v1.5.8 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.20.0 // indirect github.com/go-openapi/jsonreference v0.20.0 // indirect @@ -52,10 +53,9 @@ require ( github.com/golang/glog v1.2.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb // indirect - github.com/google/flatbuffers v1.12.1 // indirect + github.com/google/flatbuffers v23.5.26+incompatible // indirect github.com/google/go-cmp v0.6.0 // indirect - github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b // indirect + github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -105,23 +105,23 @@ require ( github.com/wI2L/jsondiff v0.5.0 // indirect github.com/yuin/gopher-lua v1.1.0 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/otel v1.26.0 // indirect - go.opentelemetry.io/otel/metric v1.26.0 // indirect - go.opentelemetry.io/otel/trace v1.26.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/atomic v1.11.0 // indirect - go.uber.org/multierr v1.9.0 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.24.0 // indirect + golang.org/x/net v0.26.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect - golang.org/x/text v0.14.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.20.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect - google.golang.org/grpc v1.63.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/grpc v1.64.0 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/DataDog/dd-trace-go.v1 v1.63.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/e2e/testgames/go.sum b/e2e/testgames/go.sum index b74d45a7f..9e029e974 100644 --- a/e2e/testgames/go.sum +++ b/e2e/testgames/go.sum @@ -69,12 +69,11 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= -github.com/dgraph-io/badger/v4 v4.2.0 h1:kJrlajbXXL9DFTNuhhu9yCx7JJa4qpYWxtE8BzuWsEs= -github.com/dgraph-io/badger/v4 v4.2.0/go.mod h1:qfCqhPoWDFJRx1gp5QwwyGo8xk1lbHUxvK9nK0OGAak= +github.com/dgraph-io/badger/v4 v4.2.1-0.20231013074411-fb1b00959581 h1:yy45brf1ktmnkTCZlHynP1gRlVwZ9g19oz5D9wG81v4= github.com/dgraph-io/ristretto v0.1.1 h1:6CWw5tJNgpegArSHpNHJKldNeq03FQCwYvfMVWajOK8= github.com/dgraph-io/ristretto v0.1.1/go.mod h1:S1GPSBCYCIhmVNfcth17y2zZtQT6wzkzgwUve0VDWWA= -github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2 h1:tdlZCpZ/P9DhczCTSixgIKmwPv6+wP5DGjqLYw5SUiA= github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= @@ -90,15 +89,13 @@ github.com/ethereum/go-ethereum v1.13.10 h1:Ppdil79nN+Vc+mXfge0AuUgmKWuVv4eMqzoI github.com/ethereum/go-ethereum v1.13.10/go.mod h1:sc48XYQxCzH3fG9BcrXCOOgQk2JfZzNAmIKnceogzsA= github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= -github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk= -github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= @@ -151,10 +148,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb h1:PBC98N2aIaM3XXiurYmW7fx4GZkL8feAMVq7nEjURHk= -github.com/golang/snappy v0.0.5-0.20220116011046-fa5810519dcb/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= -github.com/google/flatbuffers v1.12.1 h1:MVlul7pQNoDzWRLTw5imwYsl+usrS1TXG2H4jg6ImGw= -github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/flatbuffers v23.5.26+incompatible h1:M9dgRyhJemaM4Sw8+66GHBu8ioaQmyPLg1b8VwK5WJg= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -167,8 +161,7 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b h1:h9U78+dx9a4BKdQkBBos92HalKpaGKHrp+3Uo6yTodo= -github.com/google/pprof v0.0.0-20230817174616-7a8ec2ada47b/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 h1:E/LAvt58di64hlYjx7AsNS6C/ysHWYo+2qPCZKTQhRo= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -250,8 +243,7 @@ github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052/go.mod h1 github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -335,31 +327,24 @@ github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0 h1:KfYpVmrjI7JuToy5k8XV3nkapjWx48k4E4JOtVstzQI= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.44.0/go.mod h1:SeQhzAEccGVZVEy7aH87Nh0km+utSpo1pTv6eMMop48= -go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= -go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= -go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= -go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= -go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= -go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI= -go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= -golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -388,8 +373,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= -golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -437,8 +421,7 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -454,8 +437,7 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -467,15 +449,13 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/e2e/tests/go.mod b/e2e/tests/go.mod index f31ef6e1c..ad386946e 100644 --- a/e2e/tests/go.mod +++ b/e2e/tests/go.mod @@ -26,7 +26,7 @@ require ( github.com/cosmos/cosmos-sdk v0.50.7-0.20240528102556-d180df817efc github.com/ethereum/go-ethereum v1.13.10 github.com/rotisserie/eris v0.5.4 - google.golang.org/grpc v1.63.2 + google.golang.org/grpc v1.64.0 gotest.tools/v3 v3.5.1 nhooyr.io/websocket v1.8.10 pkg.world.dev/world-engine/assert v1.0.0 @@ -55,7 +55,7 @@ require ( github.com/bgentry/speakeasy v0.1.1-0.20220910012023-760eaf8b6816 // indirect github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect github.com/bufbuild/protocompile v0.6.1-0.20231108163138-146b831231f7 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cockroachdb/datadriven v1.0.3-0.20230801171734-e384cf455877 // indirect @@ -172,16 +172,16 @@ require ( github.com/zondax/ledger-go v0.14.3 // indirect go.etcd.io/bbolt v1.3.8 // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.23.0 // indirect + golang.org/x/crypto v0.24.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect - golang.org/x/net v0.25.0 // indirect + golang.org/x/net v0.26.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect google.golang.org/protobuf v1.35.1 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/e2e/tests/go.sum b/e2e/tests/go.sum index 611993502..5df4d0da6 100644 --- a/e2e/tests/go.sum +++ b/e2e/tests/go.sum @@ -87,8 +87,7 @@ github.com/celestiaorg/utils v0.1.0 h1:WsP3O8jF7jKRgLNFmlDCwdThwOFMFxg0MnqhkLFVx github.com/celestiaorg/utils v0.1.0/go.mod h1:vQTh7MHnvpIeCQZ2/Ph+w7K1R2UerDheZbgJEJD2hSU= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= @@ -255,8 +254,7 @@ github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4= github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= @@ -867,12 +865,9 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk= go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= -go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= -go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= -go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= -go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= -go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -905,8 +900,7 @@ golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20200331195152-e8c3332aa8e5/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw= @@ -953,8 +947,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1023,16 +1016,14 @@ golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -1055,8 +1046,7 @@ golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapK golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1082,10 +1072,8 @@ google.golang.org/genproto v0.0.0-20210126160654-44e461bb6506/go.mod h1:FWY/as6D google.golang.org/genproto v0.0.0-20220314164441-57ef72a4c106/go.mod h1:hAL49I2IFola2sVEjAn7MEwsja0xp51I0tlGAf9hz4E= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= -google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= -google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.20.0/go.mod h1:chYK+tFQF0nDUGJgXMSgLCQk3phJEuONr2DCgLDdAQM= @@ -1103,8 +1091,7 @@ google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTp google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= google.golang.org/grpc v1.45.0/go.mod h1:lN7owxKUQEqMfSyQikvvk5tf/6zMPsrK+ONuO11+0rQ= google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/evm/go.mod b/evm/go.mod index aef78d318..7b6112074 100644 --- a/evm/go.mod +++ b/evm/go.mod @@ -49,9 +49,9 @@ require ( github.com/spf13/cobra v1.8.0 github.com/spf13/viper v1.18.2 github.com/stretchr/testify v1.9.0 - google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de - google.golang.org/grpc v1.63.2 - google.golang.org/protobuf v1.34.1 + google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 + google.golang.org/grpc v1.64.0 + google.golang.org/protobuf v1.35.1 gotest.tools v2.2.0+incompatible gotest.tools/v3 v3.5.1 pkg.world.dev/world-engine/assert v1.0.0 @@ -59,11 +59,10 @@ require ( ) require ( - cloud.google.com/go v0.112.0 // indirect - cloud.google.com/go/compute v1.24.0 // indirect - cloud.google.com/go/compute/metadata v0.2.3 // indirect + cloud.google.com/go v0.112.1 // indirect + cloud.google.com/go/compute/metadata v0.3.0 // indirect cloud.google.com/go/iam v1.1.6 // indirect - cloud.google.com/go/storage v1.36.0 // indirect + cloud.google.com/go/storage v1.38.0 // indirect cosmossdk.io/collections v0.4.0 // indirect cosmossdk.io/errors v1.0.1 // indirect cosmossdk.io/math v1.3.0 // indirect @@ -91,7 +90,7 @@ require ( github.com/celestiaorg/go-header v0.6.1 // indirect github.com/celestiaorg/go-libp2p-messenger v0.2.0 // indirect github.com/celestiaorg/utils v0.1.0 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect @@ -154,7 +153,7 @@ require ( github.com/go-kit/kit v0.13.0 // indirect github.com/go-kit/log v0.2.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect - github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-playground/locales v0.14.1 // indirect @@ -182,7 +181,7 @@ require ( github.com/google/s2a-go v0.1.7 // indirect github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect - github.com/googleapis/gax-go/v2 v2.12.0 // indirect + github.com/googleapis/gax-go/v2 v2.12.2 // indirect github.com/gorilla/handlers v1.5.1 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/gorilla/rpc v1.2.1 // indirect @@ -344,34 +343,34 @@ require ( github.com/zondax/ledger-go v0.14.3 // indirect go.etcd.io/bbolt v1.3.8 // indirect go.opencensus.io v0.24.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 // indirect - go.opentelemetry.io/otel v1.26.0 // indirect - go.opentelemetry.io/otel/metric v1.26.0 // indirect - go.opentelemetry.io/otel/trace v1.26.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel v1.29.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.opentelemetry.io/otel/sdk v1.28.0 // indirect + go.opentelemetry.io/otel/trace v1.29.0 // indirect go.uber.org/dig v1.17.1 // indirect go.uber.org/fx v1.20.1 // indirect go.uber.org/mock v0.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect golang.org/x/arch v0.4.0 // indirect - golang.org/x/crypto v0.23.0 // indirect + golang.org/x/crypto v0.24.0 // indirect golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect golang.org/x/mod v0.17.0 // indirect - golang.org/x/net v0.25.0 // indirect - golang.org/x/oauth2 v0.18.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/oauth2 v0.21.0 // indirect golang.org/x/sync v0.7.0 // indirect golang.org/x/sys v0.21.0 // indirect - golang.org/x/term v0.20.0 // indirect - golang.org/x/text v0.15.0 // indirect + golang.org/x/term v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect golang.org/x/time v0.5.0 // indirect - golang.org/x/tools v0.20.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect gonum.org/v1/gonum v0.12.0 // indirect - google.golang.org/api v0.162.0 // indirect - google.golang.org/appengine v1.6.8 // indirect + google.golang.org/api v0.169.0 // indirect google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/evm/go.sum b/evm/go.sum index 2c22c8762..d22d9c693 100644 --- a/evm/go.sum +++ b/evm/go.sum @@ -38,8 +38,7 @@ cloud.google.com/go v0.100.2/go.mod h1:4Xra9TjzAeYHrl5+oeLlzbM2k3mjVhZh4UqTZ//w9 cloud.google.com/go v0.102.0/go.mod h1:oWcCzKlqJ5zgHQt9YsaeTY9KzIvjyy0ArmiBUgpQ+nc= cloud.google.com/go v0.102.1/go.mod h1:XZ77E9qnTEnrgEOvr4xzfdX5TRo7fB4T2F4O6+34hIU= cloud.google.com/go v0.104.0/go.mod h1:OO6xxXdJyvuJPcEPBLN9BJPD+jep5G1+2U5B5gkRYtA= -cloud.google.com/go v0.112.0 h1:tpFCD7hpHFlQ8yPwT3x+QeXqc2T6+n6T+hmABHfDUSM= -cloud.google.com/go v0.112.0/go.mod h1:3jEEVwZ/MHU4djK5t5RHuKOA/GbLddgTdVubX1qnPD4= +cloud.google.com/go v0.112.1 h1:uJSeirPke5UNZHIb4SxfZklVSiWWVqW4oXlETwZziwM= cloud.google.com/go/aiplatform v1.22.0/go.mod h1:ig5Nct50bZlzV6NvKaTwmplLLddFx0YReh9WfTO5jKw= cloud.google.com/go/aiplatform v1.24.0/go.mod h1:67UUvRBKG6GTayHKV8DBv2RtR1t93YRu5B1P3x99mYY= cloud.google.com/go/analytics v0.11.0/go.mod h1:DjEWCu41bVbYcKyvlws9Er60YE4a//bK6mnhWvQeFNI= @@ -76,10 +75,7 @@ cloud.google.com/go/compute v1.6.0/go.mod h1:T29tfhtVbq1wvAPo0E3+7vhgmkOYeXjhFvz cloud.google.com/go/compute v1.6.1/go.mod h1:g85FgpzFvNULZ+S8AYq87axRKuf2Kh7deLqV/jJ3thU= cloud.google.com/go/compute v1.7.0/go.mod h1:435lt8av5oL9P3fv1OEzSbSUe+ybHXGMPQHHZWZxy9U= cloud.google.com/go/compute v1.10.0/go.mod h1:ER5CLbMxl90o2jtNbGSbtfOpQKR0t15FOtRsugnLrlU= -cloud.google.com/go/compute v1.24.0 h1:phWcR2eWzRJaL/kOiJwfFsPs4BaKq1j6vnpZrc1YlVg= -cloud.google.com/go/compute v1.24.0/go.mod h1:kw1/T+h/+tK2LJK0wiPPx1intgdAM3j/g3hFDlscY40= -cloud.google.com/go/compute/metadata v0.2.3 h1:mg4jlk7mCAj6xXp9UJ4fjI9VUI5rubuGBW5aJ7UnBMY= -cloud.google.com/go/compute/metadata v0.2.3/go.mod h1:VAV5nSsACxMJvgaAuX6Pk2AawlZn8kiOGuCv6gTkwuA= +cloud.google.com/go/compute/metadata v0.3.0 h1:Tz+eQXMEqDIKRsmY3cHTL6FVaynIjX2QxYC4trgAKZc= cloud.google.com/go/containeranalysis v0.5.1/go.mod h1:1D92jd8gRR/c0fGMlymRgxWD3Qw9C1ff6/T7mLgVL8I= cloud.google.com/go/containeranalysis v0.6.0/go.mod h1:HEJoiEIu+lEXM+k7+qLCci0h33lX3ZqoYFdmPcoO7s4= cloud.google.com/go/datacatalog v1.3.0/go.mod h1:g9svFY6tuR+j+hrTw3J2dNcmI0dzmSiyOzm8kpLq0a0= @@ -183,8 +179,7 @@ cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3f cloud.google.com/go/storage v1.22.1/go.mod h1:S8N1cAStu7BOeFfE8KAQzmyyLkK8p/vmRq6kuBTW58Y= cloud.google.com/go/storage v1.23.0/go.mod h1:vOEEDNFnciUMhBeT6hsJIn3ieU5cFRmzeLgDvXzfIXc= cloud.google.com/go/storage v1.27.0/go.mod h1:x9DOL8TK/ygDUMieqwfhdpQryTeEkhGKMi80i/iqR2s= -cloud.google.com/go/storage v1.36.0 h1:P0mOkAcaJxhCTvAkMhxMfrTKiNcub4YmmPBtlhAyTr8= -cloud.google.com/go/storage v1.36.0/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= +cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg= cloud.google.com/go/talent v1.1.0/go.mod h1:Vl4pt9jiHKvOgF9KoZo6Kob9oV4lwd/ZD5Cto54zDRw= cloud.google.com/go/talent v1.2.0/go.mod h1:MoNF9bhFQbiJ6eFD3uSsg0uBALw4n4gaCaEjBw9zo8g= cloud.google.com/go/videointelligence v1.6.0/go.mod h1:w0DIDlVRKtwPCn/C4iwZIJdvC69yInhW0cfi+p546uU= @@ -415,8 +410,7 @@ github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEe github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/cp v0.1.0 h1:SE+dxFebS7Iik5LK0tsi1k9ZCxEaFX4AjQmoyA+1dJk= @@ -469,8 +463,6 @@ github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211130200136-a8f946100490/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= -github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa h1:jQCWAUqqlij9Pgj2i/PB79y4KOPYVyFYdROxgaCwdTQ= -github.com/cncf/xds/go v0.0.0-20231128003011-0fa0005c9caa/go.mod h1:x/1Gn8zydmfq8dk6e9PdstVsDgu9RuyIIJqAaF//0IM= github.com/cockroachdb/apd/v2 v2.0.2 h1:weh8u7Cneje73dDh+2tEVLUvyBc89iwepWCD8b8034E= github.com/cockroachdb/apd/v2 v2.0.2/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8= @@ -649,8 +641,6 @@ github.com/envoyproxy/go-control-plane v0.10.2-0.20220325020618-49ff273808a1/go. github.com/envoyproxy/protoc-gen-validate v0.0.14/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E9/baC+qXE/TeeyBRzgJDws= -github.com/envoyproxy/protoc-gen-validate v1.0.4 h1:gVPz/FMfvh57HdSJQyvBtF00j8JU4zdyUgIUNhlgg0A= -github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= github.com/esimonov/ifshort v1.0.4/go.mod h1:Pe8zjlRrJ80+q2CxHLfEOfTwxCZ4O+MuhcHcfgNWTk0= github.com/ethereum/c-kzg-4844 v0.4.0 h1:3MS1s4JtA868KpJxroZoepdV0ZKBp3u/O5HcZ7R3nlY= github.com/ethereum/c-kzg-4844 v0.4.0/go.mod h1:VewdlzQmpT5QSrVhbBuGoCdFJkpaJlO1aQputP83wc0= @@ -739,8 +729,7 @@ github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= -github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= -github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= @@ -962,8 +951,7 @@ github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99 github.com/googleapis/gax-go/v2 v2.4.0/go.mod h1:XOTVJ59hdnfJLIP/dh8n5CGryZR2LxK9wbMD5+iXC6c= github.com/googleapis/gax-go/v2 v2.5.1/go.mod h1:h6B0KMMFNtI2ddbGJn3T3ZbwkeT6yqEF02fYlzkUCyo= github.com/googleapis/gax-go/v2 v2.6.0/go.mod h1:1mjbznJAPHFpesgE5ucqfYEscaz5kMdcIDwU/6+DDoY= -github.com/googleapis/gax-go/v2 v2.12.0 h1:A+gCJKdRfqXkr+BIRGtZLibNXf0m1f9E4HG56etFpas= -github.com/googleapis/gax-go/v2 v2.12.0/go.mod h1:y+aIqrI5eb1YGMVJfuV3185Ts/D7qKpsEkdD5+I6QGU= +github.com/googleapis/gax-go/v2 v2.12.2 h1:mhN09QQW1jEWeMF74zGR81R30z4VJzjZsfkUhuHF+DA= github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= github.com/gookit/color v1.5.1/go.mod h1:wZFzea4X8qN6vHOSP2apMb4/+w/orMznEzYsIHPaqKM= @@ -2165,20 +2153,14 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.opencensus.io v0.23.0/go.mod h1:XItmlyltB5F7CS4xOC1DcqMoFqwtC6OG2xF7mCv7P7E= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0 h1:UNQQKPfTDe1J81ViolILjTKPr9WetKW6uei2hFgJmFs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0/go.mod h1:r9vWsPS/3AQItv3OSlEJ/E4mbrhUbbw18meOjArPtKQ= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0 h1:sv9kVfal0MK0wBMCOGr+HeJm9v803BkJxGrk2au7j08= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.47.0/go.mod h1:SK2UL73Zy1quvRPonmOmRDiWk1KBV3LyIeeIxcEApWw= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= go.opentelemetry.io/otel v1.6.3/go.mod h1:7BgNga5fNlF/iZjG06hM3yofffp0ofKCDwSXx1GC4dI= -go.opentelemetry.io/otel v1.26.0 h1:LQwgL5s/1W7YiiRwxf03QGnWLb2HW4pLiAhaA5cZXBs= -go.opentelemetry.io/otel v1.26.0/go.mod h1:UmLkJHUAidDval2EICqBMbnAd0/m2vmpf/dAM+fvFs4= -go.opentelemetry.io/otel/metric v1.26.0 h1:7S39CLuY5Jgg9CrnA9HHiEjGMF/X2VHvoXGgSllRz30= -go.opentelemetry.io/otel/metric v1.26.0/go.mod h1:SY+rHOI4cEawI9a7N1A4nIg/nTQXe1ccCNWYOJUrpX4= -go.opentelemetry.io/otel/sdk v1.21.0 h1:FTt8qirL1EysG6sTQRZ5TokkU8d0ugCj8htOgThZXQ8= -go.opentelemetry.io/otel/sdk v1.21.0/go.mod h1:Nna6Yv7PWTdgJHVRD9hIYywQBRx7pbox6nwBnZIxl/E= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/sdk v1.28.0 h1:b9d7hIry8yZsgtbmM0DKyPWMMUMlK9NEKuIG4aBqWyE= go.opentelemetry.io/otel/trace v1.6.3/go.mod h1:GNJQusJlUgZl9/TQBPKU/Y/ty+0iVB5fjhKeJGZPGFs= -go.opentelemetry.io/otel/trace v1.26.0 h1:1ieeAUb4y0TE26jUFrCIXKpTuVK7uJGN9/Z/2LP5sQA= -go.opentelemetry.io/otel/trace v1.26.0/go.mod h1:4iDxvGDQuUkHve82hJJ8UqrwswHYsZuWCBllGV2U2y0= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE= @@ -2266,8 +2248,7 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220525230936-793ad666bf5e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -2401,8 +2382,7 @@ golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug golang.org/x/net v0.0.0-20220909164309-bea034e7d591/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.0.0-20221014081412-f15817d10f9b/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk= golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= -golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -2431,8 +2411,7 @@ golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094/go.mod h1:h4gKUeWbJ4rQPri golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.0.0-20221014153046-6fdb5e3db783/go.mod h1:h4gKUeWbJ4rQPri7E0u6Gs4e9Ri2zaLxzw5DI5XGrYg= golang.org/x/oauth2 v0.1.0/go.mod h1:G9FE4dLTsbXUu90h/Pf85g4w1D+SSAgR+q46nJZ8M4A= -golang.org/x/oauth2 v0.18.0 h1:09qnuIAgzdx1XplqJvW6CQqMCtGZykZWcXzPMPUusvI= -golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= +golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -2606,8 +2585,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20220526004731-065cf7ba2467/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -2619,8 +2597,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2739,8 +2716,7 @@ golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E golang.org/x/tools v0.1.11-0.20220513221640-090b14e8501f/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= golang.org/x/tools v0.1.11/go.mod h1:SgwaegtQh8clINPpECJMqnxLv9I09HLqnW3RMqW0CA4= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY= -golang.org/x/tools v0.20.0/go.mod h1:WvitBU7JJf6A4jOdg4S1tviW9bhUxkgeCui/0JHctQg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2813,8 +2789,7 @@ google.golang.org/api v0.96.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ google.golang.org/api v0.97.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= google.golang.org/api v0.98.0/go.mod h1:w7wJQLTM+wvQpNf5JyEcBoxK0RH7EDrh/L4qfsuJ13s= google.golang.org/api v0.100.0/go.mod h1:ZE3Z2+ZOr87Rx7dqFsdRQkRBk36kDtp/h+QpHbB7a70= -google.golang.org/api v0.162.0 h1:Vhs54HkaEpkMBdgGdOT2P6F0csGG/vxDS0hWHJzmmps= -google.golang.org/api v0.162.0/go.mod h1:6SulDkfoBIg4NFmCuZ39XeeAgSHCPecfSUuDyYlAHs0= +google.golang.org/api v0.169.0 h1:QwWPy71FgMWqJN/l6jVlFHUa29a7dcUy02I8o799nPY= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -2825,8 +2800,6 @@ google.golang.org/appengine v1.6.2/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= -google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20170818010345-ee236bd376b0/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= @@ -2951,10 +2924,8 @@ google.golang.org/genproto v0.0.0-20221014213838-99cd37c6964a/go.mod h1:1vXfmgAz google.golang.org/genproto v0.0.0-20221025140454-527a21cfbd71/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:VUhTRKeHn9wwcdrk73nvdC9gF178Tzhmt/qyaFcPLSo= -google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de h1:jFNzHPIeuzhdRwVhbZdiym9q0ory/xY3sA+v2wPg8I0= -google.golang.org/genproto/googleapis/api v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:5iCWqnniDlqZHrd3neWVTOwvh/v6s3232omMecelax8= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda h1:LI5DOvAxUPMv/50agcLLoo+AdWc1irS9Rzz4vPuD1V4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240401170217-c3f982113cda/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY= +google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= google.golang.org/grpc v1.8.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio= @@ -3003,8 +2974,7 @@ google.golang.org/grpc v1.48.0/go.mod h1:vN9eftEi1UMyUsIF80+uQXhHjbXYbm0uXoFCACu google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.50.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= google.golang.org/grpc v1.50.1/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= @@ -3022,8 +2992,7 @@ google.golang.org/protobuf v1.27.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/go.work b/go.work index f36e4dd61..d83900193 100644 --- a/go.work +++ b/go.work @@ -1,12 +1,13 @@ go 1.22.1 use ( + ./cardinal/v2 assert cardinal e2e/testgames e2e/tests evm relay/nakama - sign rift + sign ) diff --git a/go.work.sum b/go.work.sum index 13510bf01..b532ca959 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1110,7 +1110,6 @@ cloud.google.com/go/storage v1.35.1 h1:B59ahL//eDfx2IIKFBeT5Atm9wnNmj3+8xG/W4WB/ cloud.google.com/go/storage v1.35.1/go.mod h1:M6M/3V/D3KpzMTJyPOR/HU6n2Si5QdaXYEsng2xgOs8= cloud.google.com/go/storage v1.37.0 h1:WI8CsaFO8Q9KjPVtsZ5Cmi0dXV25zMoX0FklT7c3Jm4= cloud.google.com/go/storage v1.37.0/go.mod h1:i34TiT2IhiNDmcj65PqwCjcoUX7Z5pLzS8DEmoiFq1k= -cloud.google.com/go/storage v1.38.0 h1:Az68ZRGlnNTpIBbLjSMIV2BDcwwXYlRlQzis0llkpJg= cloud.google.com/go/storage v1.38.0/go.mod h1:tlUADB0mAb9BgYls9lq+8MGkfzOXuLrnHXlpHmvFJoY= cloud.google.com/go/storagetransfer v1.5.0/go.mod h1:dxNzUopWy7RQevYFHewchb29POFv3/AaBgnhqzqiK0w= cloud.google.com/go/storagetransfer v1.6.0/go.mod h1:y77xm4CQV/ZhFZH75PLEXY0ROiS7Gh6pSKrM8dJyg6I= @@ -1735,6 +1734,7 @@ github.com/envoyproxy/protoc-gen-validate v0.10.1/go.mod h1:DRjgyB0I43LtJapqN6Ni github.com/envoyproxy/protoc-gen-validate v1.0.1/go.mod h1:0vj8bNkYbSTNS2PIyH87KZaeN4x9zpL9Qt8fQC7d+vs= github.com/envoyproxy/protoc-gen-validate v1.0.2 h1:QkIBuU5k+x7/QXPvPPnWXWlCdaBFApVqftFV6k087DA= github.com/envoyproxy/protoc-gen-validate v1.0.2/go.mod h1:GpiZQP3dDbg4JouG/NNS7QWXpgx6x8QiMKdmN72jogE= +github.com/envoyproxy/protoc-gen-validate v1.0.4/go.mod h1:qys6tmnRsYrQqIhm2bvKZH4Blx/1gTIZ2UKVY1M+Yew= github.com/envoyproxy/protoc-gen-validate v1.1.0/go.mod h1:sXRDRVmzEbkM7CVcM06s9shE/m23dg3wzjl0UWqJ2q4= github.com/esimonov/ifshort v1.0.4 h1:6SID4yGWfRae/M7hkVDVVyppy8q/v9OuxNdmjLQStBA= github.com/etcd-io/bbolt v1.3.3 h1:gSJmxrs37LgTqR/oyJBWok6k6SvXEUerFTbltIhXkBM= @@ -3175,6 +3175,7 @@ golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200826173525-f9321e4c35a6/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201101102859-da207088b7d1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -3215,7 +3216,6 @@ golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= -golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA= golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0= golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM= golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8= @@ -3249,8 +3249,6 @@ golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk golang.org/x/tools v0.16.0/go.mod h1:kYVVN6I1mBNoB1OX+noeBjbRk4IUEPa7JJ+TJMEooJ0= golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps= golang.org/x/tools v0.18.0/go.mod h1:GL7B4CwcLLeo59yx/9UWWuNOW1n3VZ4f5axWfML7Lcg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= gonum.org/v1/gonum v0.9.3/go.mod h1:TZumC3NeyVQskjXqmyWt4S3bINhy7B4eYwW69EbyX+0= gonum.org/v1/gonum v0.11.0/go.mod h1:fSG4YDCxxUZQJ7rKsQrj0gMOg00Il0Z96/qMA4bVQhA= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= @@ -3283,6 +3281,7 @@ google.golang.org/api v0.157.0/go.mod h1:+z4v4ufbZ1WEpld6yMGHyggs+PmAHiaLNj5ytP3 google.golang.org/api v0.160.0/go.mod h1:0mu0TpK33qnydLvWqbImq2b1eQ5FHRSDCBzAxX9ZHyw= google.golang.org/api v0.164.0/go.mod h1:2OatzO7ZDQsoS7IFf3rvsE17/TldiU3F/zxFHeqUB5o= google.golang.org/api v0.166.0/go.mod h1:4FcBc686KFi7QI/U51/2GKKevfZMpM17sCdibqe/bSA= +google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20180518175338-11a468237815/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20220329172620-7be39ac1afc7/go.mod h1:8w6bsBMX6yCPbAVTeqQHvzxW0EIFigd5lZyahWgyfDo= google.golang.org/genproto v0.0.0-20221024153911-1573dae28c9c/go.mod h1:9qHF0xnpdSfF6knlcsnpzUu5y+rpwgbvsyGAZPBMg4s= diff --git a/relay/nakama/go.mod b/relay/nakama/go.mod index c872818e5..c1096755d 100644 --- a/relay/nakama/go.mod +++ b/relay/nakama/go.mod @@ -19,7 +19,7 @@ require ( go.opentelemetry.io/otel/trace v1.29.0 google.golang.org/api v0.169.0 google.golang.org/grpc v1.64.0 - google.golang.org/protobuf v1.34.1 + google.golang.org/protobuf v1.35.1 pkg.world.dev/world-engine/assert v1.0.0 pkg.world.dev/world-engine/sign v1.1.0 ) @@ -66,7 +66,7 @@ require ( golang.org/x/crypto v0.24.0 // indirect golang.org/x/net v0.26.0 // indirect golang.org/x/sys v0.21.0 // indirect - google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect + google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gotest.tools/v3 v3.5.1 // indirect ) diff --git a/relay/nakama/go.sum b/relay/nakama/go.sum index 7410c0e23..f154ee649 100644 --- a/relay/nakama/go.sum +++ b/relay/nakama/go.sum @@ -180,8 +180,7 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 h1:9+tzLLstTlPTRyJTh+ah5wIMsBW5c4tQwGTN3thOW9Y= -google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9/go.mod h1:mqHbVIp48Muh7Ywss/AD6I5kNVKZMmAa/QEW58Gxp2s= +google.golang.org/genproto v0.0.0-20240227224415-6ceb2ff114de h1:F6qOa9AZTYJXOUEr4jDysRDLrm4PHePlge4v4TGAlxY= google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117 h1:+rdxYoE3E5htTEWIe15GlN6IfvbURM//Jt0mmkmm6ZU= google.golang.org/genproto/googleapis/api v0.0.0-20240604185151-ef581f913117/go.mod h1:OimBR/bc1wPO9iV4NC2bpyjy3VnAwZh5EBPQdtaE5oo= google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= @@ -202,8 +201,7 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2 google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/relay/nakama/signer/kms_test.go b/relay/nakama/signer/kms_test.go index dbc9bded6..6c9b8c52f 100644 --- a/relay/nakama/signer/kms_test.go +++ b/relay/nakama/signer/kms_test.go @@ -89,7 +89,7 @@ func TestCanSignTxWithPrecomputedSignature(t *testing.T) { assert.Equal(t, string(tx.Body), string(wantTx.Body)) // Also make sure the resulting signature can be verified by the sign package. - assert.NilError(t, tx.Verify(precomputedSignerAddress)) + assert.NilError(t, tx.Verify(common.HexToAddress(precomputedSignerAddress))) } // TestQueryRealKMSService is a test that will query Google's actual KMS service to sign a transaction. It's left here @@ -116,7 +116,7 @@ func TestQueryRealKMSService(t *testing.T) { tx, err := txSigner.SignTx(ctx, personaTag, namespace, data) assert.NilError(t, err) - assert.NilError(t, tx.Verify(txSigner.SignerAddress())) + assert.NilError(t, tx.Verify(common.HexToAddress(txSigner.SignerAddress()))) } type fakeAsymmetricSigner struct { diff --git a/relay/nakama/signer/nakama.go b/relay/nakama/signer/nakama.go index 9103e0d57..d38133d9c 100644 --- a/relay/nakama/signer/nakama.go +++ b/relay/nakama/signer/nakama.go @@ -40,11 +40,7 @@ type nakamaSigner struct { func (n *nakamaSigner) SignTx(_ context.Context, personaTag string, namespace string, data any) ( tx *sign.Transaction, err error, ) { - if personaTag == "" { - tx, err = sign.NewSystemTransaction(n.privateKey, namespace, data) - } else { - tx, err = sign.NewTransaction(n.privateKey, personaTag, namespace, data) - } + tx, err = sign.NewTransaction(n.privateKey, personaTag, namespace, data) if err != nil { return nil, eris.Wrap(err, "failed to sign transaction") diff --git a/rift/go.mod b/rift/go.mod index 67f4f4e98..91ef4716b 100644 --- a/rift/go.mod +++ b/rift/go.mod @@ -5,16 +5,19 @@ go 1.22.1 require ( github.com/rotisserie/eris v0.5.4 github.com/stretchr/testify v1.9.0 - google.golang.org/grpc v1.63.2 - google.golang.org/protobuf v1.34.1 + google.golang.org/grpc v1.64.0 + google.golang.org/protobuf v1.35.1 ) require ( - github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.21.0 // indirect - golang.org/x/sys v0.17.0 // indirect - golang.org/x/text v0.14.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/rift/go.sum b/rift/go.sum index b58a36780..353a24b41 100644 --- a/rift/go.sum +++ b/rift/go.sum @@ -1,28 +1,21 @@ -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= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= -google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/sign/go.mod b/sign/go.mod index cd0354126..d07c8f6cf 100644 --- a/sign/go.mod +++ b/sign/go.mod @@ -16,6 +16,6 @@ require ( github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/holiman/uint256 v1.2.4 // indirect - golang.org/x/crypto v0.22.0 // indirect - golang.org/x/sys v0.19.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/sys v0.21.0 // indirect ) diff --git a/sign/go.sum b/sign/go.sum index 5c33ea0cb..13ec26c75 100644 --- a/sign/go.sum +++ b/sign/go.sum @@ -18,9 +18,7 @@ github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyua github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= -golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= -golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= -golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= -golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= diff --git a/sign/sign.go b/sign/sign.go index 72fcb71e7..886961919 100644 --- a/sign/sign.go +++ b/sign/sign.go @@ -206,11 +206,6 @@ func sign(pk *ecdsa.PrivateKey, personaTag, namespace string, data any) (*Transa return sp, nil } -// NewSystemTransaction signs a given body with the given private key using the SystemPersonaTag. -func NewSystemTransaction(pk *ecdsa.PrivateKey, namespace string, data any) (*Transaction, error) { - return sign(pk, SystemPersonaTag, namespace, data) -} - // NewTransaction signs a given body, tag, and nonce with the given private key. func NewTransaction( pk *ecdsa.PrivateKey, @@ -248,20 +243,14 @@ func (s *Transaction) HashHex() string { return s.Hash.Hex() } -// Verify verifies this Transaction has a valid signature. If nil is returned, the signature is valid. -// Signature verification follows the pattern in crypto.TestSign: -// https://github.com/ethereum/go-ethereum/blob/master/crypto/crypto_test.go#L94 -// TODO: Review this signature verification, and compare it to geth's sig verification -func (s *Transaction) Verify(hexAddress string) error { - addr := common.HexToAddress(hexAddress) - +func (s *Transaction) Signer() (common.Address, error) { if IsZeroHash(s.Hash) { s.populateHash() } sig := common.Hex2Bytes(s.Signature) if len(sig) < crypto.RecoveryIDOffset { - return eris.Wrap(ErrSignatureValidationFailed, "hex to bytes failed") + return common.Address{}, eris.Wrap(ErrSignatureValidationFailed, "hex to bytes failed") } if sig[crypto.RecoveryIDOffset] == 27 || sig[crypto.RecoveryIDOffset] == 28 { sig[crypto.RecoveryIDOffset] -= 27 // Transform yellow paper V from 27/28 to 0/1 @@ -269,10 +258,22 @@ func (s *Transaction) Verify(hexAddress string) error { signerPubKey, err := crypto.SigToPub(s.Hash.Bytes(), sig) err = eris.Wrap(err, "") + if err != nil { + return common.Address{}, err + } + + return crypto.PubkeyToAddress(*signerPubKey), nil +} + +// Verify verifies this Transaction has a valid signature. If nil is returned, the signature is valid. +// Signature verification follows the pattern in crypto.TestSign: +// https://github.com/ethereum/go-ethereum/blob/master/crypto/crypto_test.go#L94 +// TODO: Review this signature verification, and compare it to geth's sig verification +func (s *Transaction) Verify(addr common.Address) error { + signerAddr, err := s.Signer() if err != nil { return err } - signerAddr := crypto.PubkeyToAddress(*signerPubKey) if signerAddr != addr { return eris.Wrap(ErrSignatureValidationFailed, "") } diff --git a/sign/sign_test.go b/sign/sign_test.go index f29ea19b5..3b0f7b8fa 100644 --- a/sign/sign_test.go +++ b/sign/sign_test.go @@ -31,20 +31,20 @@ func TestCanSignAndVerifyPayload(t *testing.T) { toBeVerified, err := UnmarshalTransaction(buf) assert.NilError(t, err) - goodAddressHex := crypto.PubkeyToAddress(goodKey.PublicKey).Hex() - badAddressHex := crypto.PubkeyToAddress(badKey.PublicKey).Hex() + goodAddress := crypto.PubkeyToAddress(goodKey.PublicKey) + badAddress := crypto.PubkeyToAddress(badKey.PublicKey) assert.Equal(t, toBeVerified.PersonaTag, wantPersonaTag) assert.Equal(t, toBeVerified.Namespace, wantNamespace) assert.Assert(t, toBeVerified.Timestamp >= wantJustAMomentAgo) // make sure unix time stamp is reasonable assert.Assert(t, toBeVerified.Timestamp <= TimestampNow()) - assert.NilError(t, toBeVerified.Verify(goodAddressHex)) + assert.NilError(t, toBeVerified.Verify(goodAddress)) // Make sure an empty hash is regenerated toBeVerified.Hash = common.Hash{} - assert.NilError(t, toBeVerified.Verify(goodAddressHex)) + assert.NilError(t, toBeVerified.Verify(goodAddress)) // Verify signature verification can fail - errorWithStackTrace := toBeVerified.Verify(badAddressHex) + errorWithStackTrace := toBeVerified.Verify(badAddress) err = eris.Unwrap(errorWithStackTrace) assert.ErrorIs(t, err, ErrSignatureValidationFailed) } @@ -160,13 +160,6 @@ func TestFailsIfFieldsMissing(t *testing.T) { }, expErr: ErrInvalidNamespace, }, - { - name: "system transaction", - payload: func() (*Transaction, error) { - return NewSystemTransaction(goodKey, "some-namespace", "{}") - }, - expErr: nil, - }, { name: "signed payload with SystemPersonaTag", payload: func() (*Transaction, error) { @@ -174,13 +167,6 @@ func TestFailsIfFieldsMissing(t *testing.T) { }, expErr: ErrInvalidPersonaTag, }, - { - name: "empty body", - payload: func() (*Transaction, error) { - return NewSystemTransaction(goodKey, "some-namespace", "") - }, - expErr: ErrCannotSignEmptyBody, - }, } for _, tc := range testCases { @@ -349,7 +335,7 @@ func TestUnsortedJSONBlobsCanBeSignedAndVerified(t *testing.T) { } gotTx, err := MappedTransaction(dataAsMap) assert.NilError(t, err) - addr := crypto.PubkeyToAddress(key.PublicKey).Hex() + addr := crypto.PubkeyToAddress(key.PublicKey) assert.NilError(t, gotTx.Verify(addr)) } diff --git a/v2/cardinal.go b/v2/cardinal.go new file mode 100644 index 000000000..8dbcc92a6 --- /dev/null +++ b/v2/cardinal.go @@ -0,0 +1,329 @@ +package cardinal + +import ( + "context" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "golang.org/x/sync/errgroup" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "pkg.world.dev/world-engine/cardinal/v2/config" + "pkg.world.dev/world-engine/cardinal/v2/plugin/task" + "pkg.world.dev/world-engine/cardinal/v2/server" + "pkg.world.dev/world-engine/cardinal/v2/storage/redis" + "pkg.world.dev/world-engine/cardinal/v2/telemetry" + "pkg.world.dev/world-engine/cardinal/v2/tick" + "pkg.world.dev/world-engine/cardinal/v2/types/message" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +const ( + RedisDialTimeOut = 150 +) + +type Cardinal struct { + cancel context.CancelFunc + tickChannel <-chan time.Time + isReplica bool + config config.Config + + world *world.World + server *server.Server + + telemetry *telemetry.Manager + tracer trace.Tracer // Tracer for Cardinal + + subscribers []chan *tick.Tick + mu *sync.RWMutex + closed bool + + startHook func() error +} + +func New(opts ...CardinalOption) (*Cardinal, *world.World, error) { + cfg, err := config.Load() + if err != nil { + return nil, nil, eris.Wrap(err, "Failed to load config to start world") + } + cardinalOpts, worldOpts := separateOptions(opts) + + // Initialize telemetry + var tm *telemetry.Manager + if cfg.TelemetryTraceEnabled || cfg.TelemetryProfilerEnabled { + tm, err = telemetry.New(cfg.TelemetryTraceEnabled, cfg.TelemetryProfilerEnabled) + if err != nil { + return nil, nil, eris.Wrap(err, "failed to create telemetry manager") + } + } + + rs := redis.NewRedisStorage(redis.Options{ + Addr: cfg.RedisAddress, + Password: cfg.RedisPassword, + DB: 0, // use default DB + DialTimeout: RedisDialTimeOut * time.Second, // Increase startup dial timeout + }, cfg.CardinalNamespace) + + w, err := world.New(&rs, worldOpts...) + if err != nil { + return nil, nil, eris.Wrap(err, "failed to create world") + } + + s, err := server.New(w) + if err != nil { + return nil, nil, eris.Wrap(err, "failed to create server") + } + + c := &Cardinal{ + world: w, + server: s, + telemetry: tm, + tracer: otel.Tracer("cardinal"), + mu: &sync.RWMutex{}, + isReplica: false, + config: *cfg, + } + + // Apply options + for _, opt := range cardinalOpts { + opt(c) + } + + // Register plugins + world.RegisterPlugin(w, task.NewPlugin()) + + return c, w, nil +} + +func (c *Cardinal) Start() error { + var ctx context.Context + ctx, c.cancel = context.WithCancel(context.Background()) + + err := c.world.Init() + if err != nil { + return eris.Wrap(err, "failed to init world") + } + + // Handles SIGINT and SIGTERM signals and starts the shutdown process. + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigCh + c.Stop() + }() + + // It is possible to inject a custom tick channel that provides manual control over when ticks are executed + // by passing in the WithTickChannel option on the cardinal.New function. + // If the tick channel is not set, an auto-ticker will be used that ticks every second. + // TODO: this should be configurable via an environnment variable or config file. + if c.tickChannel == nil { + c.tickChannel = time.Tick(time.Second) + } + + if c.config.CardinalRollupEnabled { + err = c.syncLoop(ctx) + if err != nil { + return eris.Wrap(err, "failed to sync loop") + } + } + + go c.server.Serve(ctx) + + if c.startHook != nil { + err := c.startHook() + if err != nil { + return eris.Wrap(err, "failed to run start hook") + } + } + + if !c.isReplica { + err := c.tickLoop(ctx) + if err != nil { + return eris.Wrap(err, "failed to tick loop") + } + } + + return nil +} + +func (c *Cardinal) syncLoop(ctx context.Context) error { + syncChannel := make(chan tick.Proposal) + + eg, ctx := errgroup.WithContext(ctx) + eg.Go(func() error { + // TODO: Implement syncing logic. + + // Once we finish syncing, syncChannel should be closed. + // This signals to the syncLoop that it should exit. + // TODO: Handle continuous syncing where there are new transactions batches coming in. + close(syncChannel) + + return nil + }) + + eg.Go(func() error { + for { + select { + case <-ctx.Done(): + return nil + case proposal, ok := <-syncChannel: + // When we finish syncing, syncChannel will be closed. + // If that's the case, we should exit the loop. + if !ok { + return nil + } + + // Currently, since we do not post batches for ticks without transactions, we would need to fast forward + // the tick if we encounter any gaps. + // We want to tick forward until the last finalized tick is exactly one tick behind the tick we are + // sychronizing to. + if c.world.LastFinalizedTick() < proposal.ID-1 { + // TODO: Non-deterministic behavior here. We need to know the historical timestamp to be able to + // do deterministic fast forwarding of the tick. + ffProposal := c.world.PrepareSyncTick(c.world.LastFinalizedTick()+1, + proposal.Timestamp, make(message.TxMap)) + + err := c.nextTick(ctx, &ffProposal) + if err != nil { + return eris.Wrap(err, "failed to fast forward tick") + } + + return nil + } + + err := c.nextTick(ctx, &proposal) + if err != nil { + return eris.Wrap(err, "failed to apply tick") + } + } + } + }) + + return eg.Wait() +} + +func (c *Cardinal) tickLoop(ctx context.Context) error { + for { + select { + case <-ctx.Done(): + return nil + case <-c.tickChannel: + proposal := c.world.PrepareTick(c.world.CopyTransactions(ctx)) + err := c.nextTick(ctx, &proposal) + if err != nil { + return eris.Wrap(err, "failed to apply tick") + } + } + } +} + +// isSyncMode will return true if the world is not fully synchronized with the EVM shard. +// In a replica shard, this should always return true because we want to continuously listen for new transactions from +// the leader shard. +func (c *Cardinal) isSyncMode() bool { + if c.isReplica { + return true + } + // TODO: check whether we are the tip tick of the leader shard. + + return false +} + +func (c *Cardinal) nextTick(ctx context.Context, proposal *tick.Proposal) error { + ctx, span := c.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "world.tick") + defer span.End() + + startTime := time.Now() + + t, err := c.world.ApplyTick(ctx, proposal) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to apply tick") + } + + err = c.world.CommitTick(t) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to commit tick") + } + + if !c.isSyncMode() && !c.isReplica && c.config.CardinalRollupEnabled { + // Broadcast tick + err = c.server.BroadcastEvent(t) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to broadcast tick") + } + + // TODO: Submit tick to router + } + + c.publishTick(t) + + log.Info(). + Int64("tick", t.ID). + Dur("duration", time.Since(startTime)). + Int("tx_count", len(t.Receipts)). + Msg("Tick completed") + + return nil +} + +func (c *Cardinal) Subscribe() <-chan *tick.Tick { + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return nil + } + + r := make(chan *tick.Tick) + + c.subscribers = append(c.subscribers, r) + + return r +} + +func (c *Cardinal) publishTick(t *tick.Tick) { + c.mu.RLock() + defer c.mu.RUnlock() + + if c.closed { + return + } + + for _, ch := range c.subscribers { + ch <- t + } +} + +func (c *Cardinal) Stop() { + c.cancel() + + c.mu.Lock() + defer c.mu.Unlock() + + if c.closed { + return + } + + for _, ch := range c.subscribers { + close(ch) + } +} + +func (c *Cardinal) World() *world.World { + return c.world +} diff --git a/v2/cardinal_test.go b/v2/cardinal_test.go new file mode 100644 index 000000000..83377029e --- /dev/null +++ b/v2/cardinal_test.go @@ -0,0 +1,445 @@ +package cardinal_test + +import ( + "encoding/json" + "errors" + "fmt" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type AddHealthToEntityTx struct { + TargetID types.EntityID + Amount int +} + +type AddHealthToEntityResult struct{} + +type Rawbodytx struct { + PersonaTag string `json:"personaTag"` + SignerAddress string `json:"signerAddress"` +} + +type Foo struct{} + +func (Foo) Name() string { return "foo" } + +type Bar struct{} + +func (Bar) Name() string { return "bar" } + +type Health struct { + Value int +} + +func (Health) Name() string { return "health" } + +type SomeMsg struct { + GenerateError bool +} + +func (SomeMsg) Name() string { return "some-msg" } + +type SomeMsgResponse struct { + Successful bool +} + +func TestForEachTransaction(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterMessage[SomeMsg](tf.World())) + assert.NilError(t, world.RegisterSystems(tf.World(), + func(wCtx world.WorldContext) error { + return world.EachMessage(wCtx, func(t message.TxType[SomeMsg]) (any, error) { + if t.Msg().GenerateError { + return nil, errors.New("some error") + } + return SomeMsgResponse{ + Successful: true, + }, nil + }, + ) + }, + )) + + tf.StartWorld() + + // Add 10 transactions to the tx pool and keep track of the hashes that we just world.Created + knownTxHashes := make(map[common.Hash]SomeMsg) + for i := 0; i < 10; i++ { + msg := SomeMsg{GenerateError: i%2 == 0} + txHash := tf.AddTransaction(SomeMsg{}.Name(), &msg) + knownTxHashes[txHash] = msg + } + + // Perform a engine tick + tf.DoTick() + + // Verify the receipts for the previous tick are what we expect + for txHash, msg := range knownTxHashes { + receipt, err := tf.World().GetReceipt(txHash) + assert.NilError(t, err) + + if msg.GenerateError { + assert.NotEmpty(t, receipt.Error) + } else { + assert.Empty(t, receipt.Error) + + var result SomeMsgResponse + err = json.Unmarshal(receipt.Result, &result) + assert.NilError(t, err) + + assert.Equal(t, result, SomeMsgResponse{Successful: true}) + } + } +} + +type CounterComponent struct { + Count int +} + +func (CounterComponent) Name() string { + return "count" +} + +type ScoreComponent struct { + Score int +} + +func (ScoreComponent) Name() string { + return "score" +} + +type ModifyScoreMsg struct { + PlayerID types.EntityID + Amount int +} + +func (ModifyScoreMsg) Name() string { + return "modify-score" +} + +type EmptyMsgResult struct{} + +func TestSystemsAreExecutedDuringGameTick(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[CounterComponent](tf.World())) + + err := world.RegisterSystems(tf.World(), func(wCtx world.WorldContext) error { + id := wCtx.Search(filter.Exact(CounterComponent{})).MustFirst() + return world.UpdateComponent[CounterComponent]( + wCtx, id, func(c *CounterComponent) *CounterComponent { + c.Count++ + return c + }, + ) + }) + assert.NilError(t, err) + + err = world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + var err error + _, err = world.Create(wCtx, CounterComponent{}) + assert.NilError(t, err) + return nil + }) + assert.NilError(t, err) + + tf.StartWorld() + + for i := 0; i < 10; i++ { + tf.DoTick() + } +} + +func TestTransactionAreAppliedToSomeEntities(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[ScoreComponent](tf.World())) + assert.NilError(t, world.RegisterMessage[ModifyScoreMsg](tf.World())) + + var ids []types.EntityID + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + var err error + ids, err = world.CreateMany(wCtx, 100, ScoreComponent{}) + assert.NilError(t, err) + return nil + })) + + assert.NilError(t, world.RegisterSystems(tf.World(), + func(wCtx world.WorldContext) error { + return world.EachMessage[ModifyScoreMsg](wCtx, + func(msData message.TxType[ModifyScoreMsg]) (any, error) { + ms := msData.Msg() + err := world.UpdateComponent[ScoreComponent]( + wCtx, ms.PlayerID, func(s *ScoreComponent) *ScoreComponent { + s.Score += ms.Amount + return s + }, + ) + assert.NilError(t, err) + return &EmptyMsgResult{}, nil + }, + ) + }, + )) + + tf.StartWorld() + tf.DoTick() + + // Entities at index 5, 10 and 50 will be updated with some values + tf.AddTransaction( + ModifyScoreMsg{}.Name(), + &ModifyScoreMsg{ + PlayerID: ids[5], + Amount: 105, + }, + ) + tf.AddTransaction( + ModifyScoreMsg{}.Name(), + &ModifyScoreMsg{ + PlayerID: ids[10], + Amount: 110, + }, + ) + tf.AddTransaction( + ModifyScoreMsg{}.Name(), + &ModifyScoreMsg{ + PlayerID: ids[50], + Amount: 150, + }, + ) + + tf.DoTick() + + for i, id := range ids { + wantScore := 0 + switch i { + case 5: + wantScore = 105 + case 10: + wantScore = 110 + case 50: + wantScore = 150 + } + tf.World().View(func(wCtx world.WorldContextReadOnly) error { + s, err := world.GetComponent[ScoreComponent](wCtx, id) + assert.NilError(t, err) + assert.Equal(t, wantScore, s.Score) + return nil + }) + } +} + +// TestAddToPoolDuringTickDoesNotTimeout verifies that we can add a transaction to the transaction +// pool during a game tick, and the call does not block. +func TestAddToPoolDuringTickDoesNotTimeout(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterMessage[ModifyScoreMsg](tf.World())) + + shouldBlock := false + inSystemCh := make(chan struct{}) + defer func() { close(inSystemCh) }() + + // This system will block forever. This will give us a never-ending game tick that we can use + // to verify that the addition of more transactions doesn't block. + err := world.RegisterSystems(tf.World(), func(world.WorldContext) error { + if shouldBlock { + <-inSystemCh + <-inSystemCh + } + return nil + }) + assert.NilError(t, err) + + tf.StartWorld() + + tf.AddTransaction(ModifyScoreMsg{}.Name(), &ModifyScoreMsg{}) + + // Start a tick in the background. + go func() { + shouldBlock = true + tf.DoTick() + }() + + // Make sure we're actually in the system. + inSystemCh <- struct{}{} + + // Make sure we can call addTransaction again in a reasonable amount of time + timeout := time.After(500 * time.Millisecond) + doneWithAddTx := make(chan struct{}) + + go func() { + tf.AddTransaction(ModifyScoreMsg{}.Name(), &ModifyScoreMsg{}) + doneWithAddTx <- struct{}{} + }() + + select { + case <-doneWithAddTx: + // happy path + case <-timeout: + t.Fatal("timeout while trying to addTransaction") + } + // release the system + inSystemCh <- struct{}{} + + // Second tick to make sure all transaction processed before shutdown + tickDone := make(chan struct{}) + go func() { + tf.DoTick() + tickDone <- struct{}{} + }() + inSystemCh <- struct{}{} + inSystemCh <- struct{}{} + + // wait for tick done to prevent panic on shutdown + <-tickDone +} + +func TestCannotRegisterDuplicateTransaction(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterMessage[ModifyScoreMsg](tf.World())) + assert.IsError(t, world.RegisterMessage[ModifyScoreMsg](tf.World())) +} + +type MoveMsg struct { + DeltaX, DeltaY int +} + +func (MoveMsg) Name() string { + return "move" +} + +type MoveMsgResult struct { + EndX, EndY int +} + +func TestTransaction_Result(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + // Each transaction now needs an input and an output + assert.NilError(t, world.RegisterMessage[MoveMsg](tf.World())) + + wantDeltaX, wantDeltaY := 99, 100 + + isReady := false + err := world.RegisterSystems(tf.World(), func(wCtx world.WorldContext) error { + if isReady { + // This new In function returns a triplet of information: + // 1) The transaction input + // 2) An EntityID that uniquely identifies this specific transaction + // 3) The signature + // This function would replace both "In" and "TxsAndSigsIn" + txData := make([]message.TxType[MoveMsg], 0) + err := world.EachMessage[MoveMsg](wCtx, func(tx message.TxType[MoveMsg]) (any, error) { + // The input for the transaction is found at tx.Val + txData = append(txData, tx) + return MoveMsgResult{EndX: 42, EndY: 42}, nil + }) + assert.NilError(t, err) + fmt.Println(txData) + assert.Equal(t, 1, len(txData), "expected 1 move transaction") + tx := txData[0] + + // The input for the transaction is found at tx.Val + assert.Equal(t, wantDeltaX, tx.Msg().DeltaX) + assert.Equal(t, wantDeltaY, tx.Msg().DeltaY) + } + return nil + }) + assert.NilError(t, err) + tf.StartWorld() + + txHash := tf.AddTransaction(MoveMsg{}.Name(), MoveMsg{99, 100}) + + // Tick the game so the transaction is processed + isReady = true + tf.DoTick() + + receipt, err := tf.World().GetReceipt(txHash) + assert.NilError(t, err) + + var got MoveMsgResult + err = json.Unmarshal(receipt.Result, &got) + assert.NilError(t, err) + + assert.Equal(t, MoveMsgResult{42, 42}, got) +} + +func TestTransaction_Error(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + // Each transaction now needs an input and an output + assert.NilError(t, world.RegisterMessage[MoveMsg](tf.World())) + + wantFirstError := errors.New("this is a transaction error") + wantDeltaX, wantDeltaY := 99, 100 + + isReady := false + err := world.RegisterSystems(tf.World(), func(wCtx world.WorldContext) error { + if isReady { + // This new In function returns a triplet of information: + // 1) The transaction input + // 2) An EntityID that uniquely identifies this specific transaction + // 3) The signature + // This function would replace both "In" and "TxsAndSigsIn" + txData := make([]message.TxType[MoveMsg], 0) + err := world.EachMessage[MoveMsg](wCtx, + func(tx message.TxType[MoveMsg]) (any, error) { + // The input for the transaction is found at tx.Val + txData = append(txData, tx) + return nil, wantFirstError + }, + ) + assert.NilError(t, err) + + assert.Equal(t, 1, len(txData), "expected 1 move transaction") + tx := txData[0] + + // The input for the transaction is found at tx.Val + assert.Equal(t, wantDeltaX, tx.Msg().DeltaX) + assert.Equal(t, wantDeltaY, tx.Msg().DeltaY) + } + return nil + }) + assert.NilError(t, err) + tf.StartWorld() + + txHash := tf.AddTransaction(MoveMsg{}.Name(), MoveMsg{99, 100}) + + // Tick the game so the transaction is processed + isReady = true + tf.DoTick() + + receipt, err := tf.World().GetReceipt(txHash) + assert.NilError(t, err) + assert.Equal(t, wantFirstError.Error(), receipt.Error) +} + +func TestCanGetTimestampFromWorldContext(t *testing.T) { + var ts int64 + tf := cardinal.NewTestCardinal(t, nil) + err := world.RegisterSystems(tf.World(), func(context world.WorldContext) error { + ts = context.Timestamp() + return nil + }) + assert.NilError(t, err) + tf.StartWorld() + tf.DoTick() + lastTS := ts + time.Sleep(time.Second) + tf.DoTick() + assert.Check(t, ts > lastTS) +} + +func wsURL(addr, path string) string { + return fmt.Sprintf("ws://%s/%s", addr, path) +} diff --git a/v2/codec/codec.go b/v2/codec/codec.go new file mode 100644 index 000000000..2bbe2a7e0 --- /dev/null +++ b/v2/codec/codec.go @@ -0,0 +1,23 @@ +package codec + +import ( + "github.com/goccy/go-json" + "github.com/rotisserie/eris" +) + +func Decode[T any](bz []byte) (T, error) { + comp := new(T) + err := json.Unmarshal(bz, comp) + if err != nil { + return *comp, eris.Wrap(err, "") + } + return *comp, nil +} + +func Encode(comp any) ([]byte, error) { + bz, err := json.Marshal(comp) + if err != nil { + return nil, eris.Wrap(err, "") + } + return bz, nil +} diff --git a/v2/codec/codec_test.go b/v2/codec/codec_test.go new file mode 100644 index 000000000..f789447d7 --- /dev/null +++ b/v2/codec/codec_test.go @@ -0,0 +1,49 @@ +package codec_test + +import ( + "testing" + + "pkg.world.dev/world-engine/cardinal/v2/codec" +) + +// Define a dummy struct for benchmarking. +type ExampleStruct struct { + ID int + Name string +} + +// Benchmark the Decode function. +func BenchmarkDecode(b *testing.B) { + // Prepare a byte slice to decode + data := []byte(`{"ID": 1, "Name": "Example"}`) + + b.ResetTimer() // Reset the timer + + // Run the benchmark + for i := 0; i < b.N; i++ { + _, err := codec.Decode[ExampleStruct](data) + if err != nil { + b.Fatal(err) + } + } +} + +// Benchmark the Encode function. +func BenchmarkEncode(b *testing.B) { + // Prepare an example struct to encode + example := ExampleStruct{ + ID: 1, + Name: "Example", + } + + // Reset the timer + b.ResetTimer() + + // Run the benchmark + for i := 0; i < b.N; i++ { + _, err := codec.Encode(example) + if err != nil { + b.Fatal(err) + } + } +} diff --git a/v2/config/config.go b/v2/config/config.go new file mode 100644 index 000000000..aae47003d --- /dev/null +++ b/v2/config/config.go @@ -0,0 +1,232 @@ +package config + +import ( + "net" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + + "github.com/rotisserie/eris" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "github.com/spf13/pflag" + "github.com/spf13/viper" + + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/rift/credentials" +) + +const ( + DefaultCardinalNamespace = "world-1" + DefaultCardinalLogLevel = "info" + DefaultRedisAddress = "localhost:6379" + DefaultBaseShardSequencerAddress = "localhost:9601" + + // Toml config file related + configFilePathEnvVariable = "CARDINAL_CONFIG" + defaultConfigFileName = "world.toml" +) + +var ( + validLogLevels = []string{ + zerolog.DebugLevel.String(), + zerolog.InfoLevel.String(), + zerolog.WarnLevel.String(), + zerolog.ErrorLevel.String(), + zerolog.Disabled.String(), + } + + defaultConfig = Config{ + CardinalNamespace: DefaultCardinalNamespace, + CardinalRollupEnabled: false, + CardinalVerifySignature: true, + CardinalLogPretty: false, + CardinalLogLevel: DefaultCardinalLogLevel, + RedisAddress: DefaultRedisAddress, + RedisPassword: "", + BaseShardSequencerAddress: DefaultBaseShardSequencerAddress, + BaseShardRouterKey: "", + TelemetryTraceEnabled: false, + TelemetryProfilerEnabled: false, + } +) + +type Config struct { + // CardinalNamespace The shard namespace for Cardinal. This needs to be unique to prevent signature replay attacks. + CardinalNamespace string `mapstructure:"CARDINAL_NAMESPACE"` + + // CardinalRollupEnabled When true, Cardinal will sequence and recover to/from base shard. + CardinalRollupEnabled bool `mapstructure:"CARDINAL_ROLLUP_ENABLED"` + + // CardinalVerifySignature When false, Cardinal will not verify signatures. + CardinalVerifySignature bool `mapstructure:"CARDINAL_SIGNATURE_VERIFICATION"` + + // CardinalLogLevel Determines the log level for Cardinal. + CardinalLogLevel string `mapstructure:"CARDINAL_LOG_LEVEL"` + + // CardinalLogPretty Pretty logging, disable by default due to performance impact. + CardinalLogPretty bool `mapstructure:"CARDINAL_LOG_PRETTY"` + + // RedisAddress The address of the redis server, supports unix sockets. + RedisAddress string `mapstructure:"REDIS_ADDRESS"` + + // RedisPassword The password for the redis server. Make sure to use a password in production. + RedisPassword string `mapstructure:"REDIS_PASSWORD"` + + // BaseShardSequencerAddress This is the address that Cardinal will use to sequence and recover to/from base shard. + BaseShardSequencerAddress string `mapstructure:"BASE_SHARD_SEQUENCER_ADDRESS"` + + // BaseShardRouterKey is a token used to secure communications between the game shard and the base shard. + BaseShardRouterKey string `mapstructure:"BASE_SHARD_ROUTER_KEY"` + + // TelemetryTraceEnabled When true, Cardinal will collect OpenTelemetry traces + TelemetryTraceEnabled bool `mapstructure:"TELEMETRY_TRACE_ENABLED"` + + // TelemetryProfilerEnabled When true, Cardinal will run Datadog continuous profiling + TelemetryProfilerEnabled bool `mapstructure:"TELEMETRY_PROFILER_ENABLED"` +} + +func Load() (*Config, error) { + // Set default config + cfg := defaultConfig + + // Setup Viper for world toml config file + setupViper() + + // Read the config file + // Unmarshal the [cardinal] section from config file into the WorldConfig struct + if err := viper.ReadInConfig(); err != nil { + log.Warn().Err(err).Msg("No config file found") + } else { + if err := viper.Sub("cardinal").Unmarshal(&cfg); err != nil { + log.Warn().Err(err).Msg("Failed to unmarshal config file") + } + } + + // Override config values with environment variables + // This is done after reading the config file to allow for environment variable overrides + if err := viper.Unmarshal(&cfg); err != nil { + log.Warn().Err(err).Msg("Failed to load config from environment variables") + } else { + log.Debug().Msg("Loaded config from environment variables") + } + + if err := cfg.Validate(); err != nil { + return nil, eris.Wrap(err, "Invalid config") + } + + if err := cfg.setLogger(); err != nil { + return nil, eris.Wrap(err, "Failed to set log level") + } + + return &cfg, nil +} + +// Validate validates the config values. +// If CARDINAL_ROLLUP=true, the BASE_SHARD_SEQUENCER_ADDRESS and BASE_SHARD_ROUTER_KEY are required. +func (w *Config) Validate() error { + // Validate Cardinal configs + if err := types.Namespace(w.CardinalNamespace).Validate(); err != nil { + return eris.Wrap(err, "CARDINAL_NAMESPACE is not a valid namespace") + } + if w.CardinalLogLevel == "" || !slices.Contains(validLogLevels, w.CardinalLogLevel) { + return eris.New("CARDINAL_LOG_LEVEL must be one of the following: " + strings.Join(validLogLevels, ", ")) + } + + // Validate base shard configs (only required when rollup mode is enabled) + if w.CardinalRollupEnabled { + if _, _, err := net.SplitHostPort(w.BaseShardSequencerAddress); err != nil { + return eris.Wrap(err, "BASE_SHARD_SEQUENCER_ADDRESS must follow the format :") + } + if w.BaseShardRouterKey == "" { + return eris.New("BASE_SHARD_ROUTER_KEY must be when rollup mode is enabled") + } + if err := credentials.ValidateKey(w.BaseShardRouterKey); err != nil { + return err + } + } + + return nil +} + +func (w *Config) setLogger() error { + // Set global logger level + level, err := zerolog.ParseLevel(w.CardinalLogLevel) + if err != nil { + return eris.Wrap(err, "CARDINAL_LOG_LEVEL is not a valid log level") + } + zerolog.SetGlobalLevel(level) + + // Override global logger to console writer if pretty logging is enabled + if w.CardinalLogPretty { + log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr}) + } + + return nil +} + +func setupViper() { + if pflag.Lookup(configFilePathEnvVariable) == nil { + pflag.String(configFilePathEnvVariable, "", "Path to the TOML config file") + } + + pflag.Parse() + + // Bind the command-line flags to Viper + if err := viper.BindPFlags(pflag.CommandLine); err != nil { + log.Debug().Err(err).Msg("Failed to bind command-line flags to Viper") + // Continue even if the binding fails + } + + // Bind env for CARDINAL_CONFIG + if err := viper.BindEnv(configFilePathEnvVariable); err != nil { + log.Warn().Err(err).Str("env", configFilePathEnvVariable).Msg("Failed to bind env variable") + } + + // Set default toml config file name and type + viper.SetConfigName("world") // name of config file (without extension) + viper.SetConfigType("toml") // REQUIRED if the config file does not have the extension in the name + + // Find the toml config file from the flag and env variable + // viper precedence: flag > env > default + configFilePath := viper.GetString(configFilePathEnvVariable) + if configFilePath != "" { //nolint:nestif // better consistency and readability + // Use Specified config file + fileName := filepath.Base(configFilePath) + + viper.SetConfigName(strings.TrimSuffix(fileName, filepath.Ext(fileName))) + viper.SetConfigType(strings.TrimPrefix(filepath.Ext(fileName), ".")) + + viper.AddConfigPath(filepath.Dir(configFilePath)) + } else { + // Search for toml file in the current directory and parent directory + viper.AddConfigPath(".") // look for config in the working directory + + // If the config file is not found in the current directory, search in the parent directory + if _, err := os.Stat(defaultConfigFileName); err != nil { + parentDir, err := os.Getwd() + if err != nil { + log.Warn().Err(err).Msg("Failed to get current directory for TOML file search") + } else { + parentDir = filepath.Dir(parentDir) // get parent directory + viper.AddConfigPath(parentDir) + } + } + } + + // Bind env from struct tags + val := reflect.ValueOf(&defaultConfig).Elem() + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + tag := field.Tag.Get("mapstructure") + if tag != "" { + if err := viper.BindEnv(tag); err != nil { + log.Warn().Err(err).Str("field", field.Name).Msg("Failed to bind env variable") + } + } + } +} diff --git a/v2/config/config_test.go b/v2/config/config_test.go new file mode 100644 index 000000000..61679b9ee --- /dev/null +++ b/v2/config/config_test.go @@ -0,0 +1,243 @@ +package config + +import ( + "os" + "reflect" + "strconv" + "testing" + + "github.com/naoina/toml" + "github.com/spf13/viper" + + "pkg.world.dev/world-engine/assert" +) + +func TestWorldConfig_loadWorldConfig(t *testing.T) { + defer CleanupViper(t) + + // Test that loading config prorammatically works + cfg, err := Load() + assert.NilError(t, err) + assert.Equal(t, defaultConfig, *cfg) +} + +func TestWorldConfig_LoadFromEnv(t *testing.T) { + defer CleanupViper(t) + + // This target config intentionally does not use the default config values + // to make sure that all custom config is properly loaded from env vars. + wantCfg := Config{ + CardinalNamespace: "baz", + CardinalRollupEnabled: false, + CardinalVerifySignature: true, + CardinalLogLevel: "error", + CardinalLogPretty: true, + RedisAddress: "localhost:7070", + RedisPassword: "bar", + BaseShardSequencerAddress: "localhost:8080", + BaseShardRouterKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ01", + } + + // Set env vars to target config values + t.Setenv("CARDINAL_NAMESPACE", wantCfg.CardinalNamespace) + t.Setenv("CARDINAL_ROLLUP_ENABLED", strconv.FormatBool(wantCfg.CardinalRollupEnabled)) + t.Setenv("CARDINAL_LOG_LEVEL", wantCfg.CardinalLogLevel) + t.Setenv("CARDINAL_LOG_PRETTY", strconv.FormatBool(wantCfg.CardinalLogPretty)) + t.Setenv("REDIS_ADDRESS", wantCfg.RedisAddress) + t.Setenv("REDIS_PASSWORD", wantCfg.RedisPassword) + t.Setenv("BASE_SHARD_SEQUENCER_ADDRESS", wantCfg.BaseShardSequencerAddress) + t.Setenv("BASE_SHARD_ROUTER_KEY", wantCfg.BaseShardRouterKey) + + gotCfg, err := Load() + assert.NilError(t, err) + + assert.Equal(t, wantCfg, *gotCfg) +} + +func TestWorldConfig_Validate_DefaultConfigIsValid(t *testing.T) { + // Validates the default config + assert.NilError(t, defaultConfig.Validate()) +} + +func TestWorldConfig_Validate_Namespace(t *testing.T) { + testCases := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "If Namespace is valid, no errors", + cfg: defaultConfigWithOverrides(Config{CardinalNamespace: "world-1"}), + wantErr: false, + }, + { + name: "If namespace contains anything other than alphanumeric and -, error", + cfg: defaultConfigWithOverrides(Config{ + CardinalNamespace: "&1235%^^", + }), + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + if tc.wantErr { + assert.IsError(t, err) + } else { + assert.NilError(t, err) + } + }) + } +} + +func TestWorldConfig_Validate_LogLevel(t *testing.T) { + for _, logLevel := range validLogLevels { + t.Run("If log level is set to "+logLevel+", no errors", func(t *testing.T) { + cfg := defaultConfigWithOverrides(Config{CardinalLogLevel: logLevel}) + assert.NilError(t, cfg.Validate()) + }) + } + + t.Run("If log level is invalid, error", func(t *testing.T) { + cfg := defaultConfigWithOverrides(Config{CardinalLogLevel: "foo"}) + assert.IsError(t, cfg.Validate()) + }) +} + +func TestWorldConfig_Validate_RollupMode(t *testing.T) { + testCases := []struct { + name string + cfg Config + wantErr bool + }{ + { + name: "Without setting base shard configs fails", + cfg: defaultConfigWithOverrides(Config{CardinalRollupEnabled: true}), + wantErr: true, + }, + { + name: "With base shard config, but bad token", + cfg: defaultConfigWithOverrides(Config{ + CardinalRollupEnabled: true, + BaseShardSequencerAddress: DefaultBaseShardSequencerAddress, + BaseShardRouterKey: "not a good token!", + }), + wantErr: true, + }, + { + name: "With valid base shard config", + cfg: defaultConfigWithOverrides(Config{ + CardinalRollupEnabled: true, + BaseShardSequencerAddress: "localhost:8080", + BaseShardRouterKey: "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ01", + }), + wantErr: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + err := tc.cfg.Validate() + if tc.wantErr { + assert.IsError(t, err) + } else { + assert.NilError(t, err) + } + }) + } +} + +func defaultConfigWithOverrides(overrideCfg Config) Config { + // Iterate over all the fields in the default config and override the ones that are set in the overrideCfg + // with the values from the overrideCfg. + cfg := defaultConfig + + for i := range reflect.TypeOf(overrideCfg).NumField() { + // Get the field name and value from the overrideCfg + overrideFieldValue := reflect.ValueOf(overrideCfg).Field(i) + + if overrideFieldValue.Kind() == reflect.Ptr { + // Dereference before checking zero value if it is a pointer + if !overrideFieldValue.Elem().IsZero() { + reflect.ValueOf(&cfg).Elem().Field(i).Set(overrideFieldValue) + } + } else { + // If the field is set in the overrideCfg, set it in the default config + if !overrideFieldValue.IsZero() { + reflect.ValueOf(&cfg).Elem().Field(i).Set(overrideFieldValue) + } + } + } + + return cfg +} + +func makeConfigAtPath(t *testing.T, path, namespace string) { + file, err := os.Create(path) + assert.NilError(t, err) + defer file.Close() + makeConfigAtFile(t, file, namespace) +} + +func makeConfigAtFile(t *testing.T, file *os.File, namespace string) { + data := map[string]any{ + "cardinal": map[string]any{ + "CARDINAL_NAMESPACE": namespace, + }, + } + assert.NilError(t, toml.NewEncoder(file).Encode(data)) +} + +func TestWorldConfig_loadWorldConfigUsingFromCurDir(t *testing.T) { + defer CleanupViper(t) + + makeConfigAtPath(t, "world.toml", "my-world-current-dir") + t.Cleanup(func() { + os.Remove("world.toml") + }) + + cfg, err := Load() + assert.NilError(t, err) + assert.Equal(t, "my-world-current-dir", cfg.CardinalNamespace) +} + +func TestWorldConfig_loadWorldConfigUsingFromParDir(t *testing.T) { + defer CleanupViper(t) + + makeConfigAtPath(t, "../world.toml", "my-world-parrent-dir") + t.Cleanup(func() { + os.Remove("../world.toml") + }) + + cfg, err := Load() + assert.NilError(t, err) + assert.Equal(t, "my-world-parrent-dir", cfg.CardinalNamespace) +} + +func TestWorldConfig_loadWorldConfigUsingOverrideByenv(t *testing.T) { + defer CleanupViper(t) + + makeConfigAtPath(t, "../world.toml", "my-world-parrent-dir") + t.Cleanup(func() { + os.Remove("../world.toml") + }) + t.Setenv("CARDINAL_NAMESPACE", "my-world-env") + + cfg, err := Load() + assert.NilError(t, err) + assert.Equal(t, "my-world-env", cfg.CardinalNamespace) +} + +// CleanupViper resets Viper configuration +func CleanupViper(t *testing.T) { + viper.Reset() + + // Optionally, you can also clear environment variables if needed + for _, key := range viper.AllKeys() { + err := os.Unsetenv(key) + if err != nil { + t.Errorf("failed to unset env var %s: %v", key, err) + } + } +} diff --git a/v2/gamestate/active.go b/v2/gamestate/active.go new file mode 100644 index 000000000..db3be6929 --- /dev/null +++ b/v2/gamestate/active.go @@ -0,0 +1,36 @@ +package gamestate + +import ( + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// activeEntities represents a group of entities. +type activeEntities struct { + ids []types.EntityID + modified bool +} + +// swapRemove removes the given entity EntityID from this list of active entities. This is used when moving +// an entity from one archetype to another, and then deleting an entity altogether. +func (a *activeEntities) swapRemove(idToRemove types.EntityID) error { + // TODO: The finding and removing of these entity ids can be sped up. We're going with a simple implementation + // here to get to an MVP + indexOfID := -1 + for i, id := range a.ids { + if idToRemove == id { + indexOfID = i + break + } + } + if indexOfID == -1 { + return eris.Errorf("cannot find entity id %d", idToRemove) + } + lastIndex := len(a.ids) - 1 + if indexOfID < lastIndex { + a.ids[indexOfID] = a.ids[lastIndex] + } + a.ids = a.ids[:len(a.ids)-1] + return nil +} diff --git a/v2/gamestate/component.go b/v2/gamestate/component.go new file mode 100644 index 000000000..ad3cd5a93 --- /dev/null +++ b/v2/gamestate/component.go @@ -0,0 +1,133 @@ +package gamestate + +import ( + "fmt" + "reflect" + + "github.com/invopop/jsonschema" + "github.com/rotisserie/eris" + "github.com/wI2L/jsondiff" + + "pkg.world.dev/world-engine/cardinal/v2/codec" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// Interface guard +var _ types.ComponentMetadata = (*componentMetadata[types.Component])(nil) + +// Option is a type that can be passed to NewComponentMetadata to augment the creation +// of the component type. +type Option[T types.Component] func(c *componentMetadata[T]) + +// componentMetadata represents a type of component. It is used to identify +// a component when getting or setting the component of an entity. +type componentMetadata[T types.Component] struct { + isIDSet bool + id types.ComponentID + compType reflect.Type + name string + schema []byte + defaultVal types.Component +} + +// NewComponentMetadata creates a new component type. +// The function is used to create a new component of the type. +func NewComponentMetadata[T types.Component](opts ...Option[T]) ( + types.ComponentMetadata, error, +) { + var t T + compType := reflect.TypeOf(t) + + schema, err := jsonschema.ReflectFromType(compType).MarshalJSON() + if err != nil { + return nil, eris.Wrap(err, "component must be json serializable") + } + + compMetadata := &componentMetadata[T]{ + compType: compType, + name: t.Name(), + schema: schema, + } + for _, opt := range opts { + opt(compMetadata) + } + + return compMetadata, nil +} + +func (c *componentMetadata[T]) GetSchema() []byte { + return c.schema +} + +// SetID set's this component's ID. It must be unique across the world object. +func (c *componentMetadata[T]) SetID(id types.ComponentID) error { + if c.isIDSet { + // In games implemented with Cardinal, components will only be initialized one time (on startup). + // In tests, it's often useful to use the same component in multiple worlds. This check will allow for the + // re-initialization of components, as long as the ID doesn't change. + if id == c.id { + return nil + } + return eris.Errorf("id for component %v is already set to %v, cannot change to %v", c, c.id, id) + } + c.id = id + c.isIDSet = true + return nil +} + +// String returns the component type name. +func (c *componentMetadata[T]) String() string { + return c.name +} + +// Name returns the component type name. +func (c *componentMetadata[T]) Name() string { + return c.name +} + +// ID returns the component type id. +func (c *componentMetadata[T]) ID() types.ComponentID { + return c.id +} + +func (c *componentMetadata[T]) New() ([]byte, error) { + if c.defaultVal != nil { + return codec.Encode(c.defaultVal) + } + return codec.Encode(c.compType) +} + +func (c *componentMetadata[T]) Encode(v any) ([]byte, error) { + return codec.Encode(v) +} + +func (c *componentMetadata[T]) Decode(bz []byte) (types.Component, error) { + return codec.Decode[T](bz) +} + +func (c *componentMetadata[T]) ValidateAgainstSchema(targetSchema []byte) error { + diff, err := jsondiff.CompareJSON(c.schema, targetSchema) + if err != nil { + return eris.Wrap(err, "failed to compare component schema") + } + + if diff.String() != "" { + return eris.Wrap(types.ErrComponentSchemaMismatch, diff.String()) + } + + return nil +} + +func (c *componentMetadata[T]) validateDefaultVal() { + if !reflect.TypeOf(c.defaultVal).AssignableTo(c.compType) { + panic(fmt.Sprintf("default value is not assignable to component type: %s", c.name)) + } +} + +// WithDefault updated the created componentMetadata with a default value. +func WithDefault[T types.Component](defaultVal T) Option[T] { + return func(c *componentMetadata[T]) { + c.defaultVal = defaultVal + c.validateDefaultVal() + } +} diff --git a/v2/gamestate/component_utils.go b/v2/gamestate/component_utils.go new file mode 100644 index 000000000..c31c217f2 --- /dev/null +++ b/v2/gamestate/component_utils.go @@ -0,0 +1,55 @@ +package gamestate + +import ( + "sort" + + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +var errComponentMismatch = eris.New("component mismatched") + +// compKey is a tuple of a component ComponentID and an entity EntityID. It used as a map key to keep +// track of component data in-memory. +type compKey struct { + compName types.ComponentName + entityID types.EntityID +} + +// sortComponentSet sorts component names lexicographically. +func sortComponentSet(components []types.ComponentName) error { + sort.Slice( + components, func(i, j int) bool { + return components[i] < components[j] + }, + ) + for i := 1; i < len(components); i++ { + if components[i] == components[i-1] { + return eris.New("duplicate components is not allowed") + } + } + + return nil +} + +func isComponentSetMatch(a, b []types.ComponentName) error { + if len(a) != len(b) { + return errComponentMismatch + } + + if err := sortComponentSet(a); err != nil { + return err + } + if err := sortComponentSet(b); err != nil { + return err + } + + for i := range a { + if a[i] != b[i] { + return errComponentMismatch + } + } + + return nil +} diff --git a/v2/gamestate/doc.go b/v2/gamestate/doc.go new file mode 100644 index 000000000..c9b1cca1a --- /dev/null +++ b/v2/gamestate/doc.go @@ -0,0 +1,78 @@ +/* +Package gamestate allows for buffering of state changes to the ECS dbStorage layer, and either committing those changes +in an atomic Redis transaction, or discarding the changes. In either case, the underlying Redis DB is never in an +intermediate state. + +# Atomic options + +There are two ways a batch of state changes can be grouped and applied/discarded. + +EntityCommandBuffer.AtomicFn takes in a function that returns an error. The passed in function will be executed, and +any state made during that function call will be stored as pending state changes. During this time, reads using the +EntityCommandBuffer will report the pending values. Conversely, reading data directly from Redis the original value +(before AtomicFn was called). + +If the passed in function returns an error, all pending state changes will be discarded. + +If the passed in function returns no error, all pending state changes will be committed to Redis in an atomic +transaction. + +Alternatively, EntityCommandBuffer can be used outside an AtomicFn context. State changes are stored as pending +operations. Read operations will report the pending state. Note, no changes to Redis are applied while pending +operations are accumulated. + +Pending changes can be discarded with EntityCommandBuffer.DiscardPending. A subsequent read will return identical +data to the data stored in Redis. + +Pending changes can be committed to redis with EntityCommandBuffer.FinalizeTick. All pending changes will +be packaged into a single redis [multi/exec pipeline](https://redis.io/docs/interact/transactions/) and applied +atomically. Reads to redis during this time will never return any pending state. For example, if a series of 100 +commands increments some value from 0 to 100, and then FinalizeTick is called, reading this value from the DB will +only ever return 0 or 100 (depending on the exact timing of the call). + +# Redis PrimitiveStorage Model + +The Redis keys that store data in redis are defined in keys.go. All keys are prefixed with "ECB". + +key: "ECB:NEXT-ENTITY-ID" +value: An integer that represents the next available entity ID that can be assigned to some entity. It can be assumed +that entity IDs smaller than this value have already been assigned. + +key: fmt.Sprintf("ECB:COMPONENT-VALUE:TYPE-ID-%d:ENTITY-ID-%d", componentTypeID, entityID) +value: JSON serialized bytes that can be deserialized to the component with the matching componentTypeID. This +component data has been assigned to the entity matching the entityID. + +key: fmt.Sprintf("ECB:ARCHETYPE-ID:ENTITY-ID-%d", entityID) +value: An integer that represents the archetype ID that the matching entityID has been assigned to. + +key: fmt.Sprintf("ECB:ACTIVE-ENTITY-IDS:ARCHETYPE-ID-%d", archetypeID) +value: JSON serialized bytes that can be deserialized to a slice of integers. The integers represent the entity IDs +that currently belong to the matching archetypeID. Note, this is a reverse mapping of the previous key. + +key: "ECB:ARCHETYPE-ID-TO-COMPONENT-TYPES" +value: JSON serialized bytes that can be deserialized to a map of archetype.ID to []component.ID. This field represents +what archetype IDs have already been assigned and what groups of components each archetype ID corresponds to. This field +must be loaded into memory before any entity creation or component addition/removals take place. + +key: "ECB:START-TICK" +value: An integer that represents the last tick that was started. + +key: "ECB:END-TICK" +value: An integer that represents the last tick that was successfully completed. + +# In-memory storage model + +The in-memory data model roughly matches the model that is stored in redis, but there are some differences: + +Components are stored as generic interfaces and not as serialized JSON. + +# Potential Improvements + +In redis, the ECB:ACTIVE-ENTITY-IDS and ECB:ARCHETYPE-ID:ENTITY-ID keys contains the same data, but are just reversed +mapping of one another. The amount of data in redis, and the data written can likely be reduced if we abandon one of +these keys and rebuild the other mapping in memory. + +In memory, compValues are written to redis during a FinalizeTick cycle. Components that were not actually changed (e.g. +only read operations were performed) are still written to the DB. +*/ +package gamestate diff --git a/v2/gamestate/ecb.go b/v2/gamestate/ecb.go new file mode 100644 index 000000000..13c4c50eb --- /dev/null +++ b/v2/gamestate/ecb.go @@ -0,0 +1,1032 @@ +package gamestate + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + + "github.com/redis/go-redis/v9" + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "pkg.world.dev/world-engine/cardinal/v2/codec" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +var ( + ErrNotInitialized = errors.New("ecb is not ready to perform entity operations") + ErrArchetypeNotFound = errors.New("archetype for components not found") + doesNotExistArchetypeID = types.ArchetypeID(-1) +) + +type EntityCommandBuffer struct { + locked bool + + dbStorage PrimitiveStorage[string] + + compValues VolatileStorage[compKey, types.Component] + compValuesToDelete []compKey + compNameToComponent map[types.ComponentName]types.ComponentMetadata + + activeEntities VolatileStorage[types.ArchetypeID, activeEntities] + + // Fields that track the next valid entity EntityID that can be assigned + nextEntityIDSaved uint64 + pendingEntityIDs uint64 + isEntityIDLoaded bool + + // Archetype EntityID management. + entityIDToArchID map[types.EntityID]types.ArchetypeID + entityIDToOriginArchID map[types.EntityID]types.ArchetypeID + + archIDToComps map[types.ArchetypeID][]types.ComponentName + pendingArchIDs []types.ArchetypeID + + // OpenTelemetry tracer + tracer trace.Tracer +} + +var _ Reader = &EntityCommandBuffer{} + +// NewEntityCommandBuffer creates a new command buffer manager that is able to queue up a series of states changes and +// atomically commit them to the underlying redis dbStorage layer. +func NewEntityCommandBuffer(storage PrimitiveStorage[string]) (*EntityCommandBuffer, error) { + m := &EntityCommandBuffer{ + locked: false, + + dbStorage: storage, + + compValues: NewMapStorage[compKey, types.Component](), + compValuesToDelete: make([]compKey, 0), + compNameToComponent: map[types.ComponentName]types.ComponentMetadata{}, + + activeEntities: NewMapStorage[types.ArchetypeID, activeEntities](), + + nextEntityIDSaved: 0, + pendingEntityIDs: 0, + isEntityIDLoaded: false, + + entityIDToArchID: map[types.EntityID]types.ArchetypeID{}, + entityIDToOriginArchID: map[types.EntityID]types.ArchetypeID{}, + + archIDToComps: map[types.ArchetypeID][]types.ComponentName{}, + pendingArchIDs: []types.ArchetypeID{}, + + tracer: otel.Tracer("ecb"), + } + + return m, nil +} + +// init performs the initial load from Redis and locks ECB such that no new components can be registered. +func (m *EntityCommandBuffer) init() error { + if m.locked { + return eris.New("ecb is already initialized") + } + + err := m.loadArchetype() + if err != nil { + return err + } + + // Lock ECB to prevent new components from being registered + m.locked = true + + return nil +} + +func (m *EntityCommandBuffer) isComponentRegistered(compName types.ComponentName) bool { + _, ok := m.compNameToComponent[compName] + return ok +} + +func (m *EntityCommandBuffer) registerComponent(comp types.ComponentMetadata) error { + if m.locked { + return eris.New("unable to register components after ecb is initialized") + } + + if _, ok := m.compNameToComponent[comp.Name()]; ok { + return eris.New("component already registered") + } + m.compNameToComponent[comp.Name()] = comp + + return nil +} + +// DiscardPending discards any pending state changes. +func (m *EntityCommandBuffer) DiscardPending() error { + if !m.locked { + return ErrNotInitialized + } + + // Clears the in-memory cache of component values; this will force the next fetch of the component value to be + // fetch from Redis, which contains the latest finalized state. + if err := m.compValues.Clear(); err != nil { + return err + } + + // Any entity archetypes movements need to be undone + if err := m.activeEntities.Clear(); err != nil { + return err + } + + // m.entityIDToOriginArchID tracks the mapping of entity ID to its origin archetype ID prior to an archetype move. + for movedEntityID := range m.entityIDToOriginArchID { + delete(m.entityIDToArchID, movedEntityID) + } + + // Clear the entityIDToOriginArchID map + m.entityIDToOriginArchID = map[types.EntityID]types.ArchetypeID{} + + // Zeroes out the entity ID counter since we want to refetch the last finalized entity ID counter from Redis. + m.isEntityIDLoaded = false + m.pendingEntityIDs = 0 + + // Rollback all the archetypes that was created and was not finalized + for _, archID := range m.pendingArchIDs { + delete(m.archIDToComps, archID) + } + + // Clear the pending archetypes entity operation queue + m.pendingArchIDs = make([]types.ArchetypeID, 0) + + return nil +} + +// RemoveEntity removes the given entity from the ECS data model. +func (m *EntityCommandBuffer) RemoveEntity(idToRemove types.EntityID) error { + if !m.locked { + return ErrNotInitialized + } + + archID, err := m.getArchetypeForEntity(idToRemove) + if err != nil { + return err + } + + active, err := m.getActiveEntities(archID) + if err != nil { + return err + } + + if err := active.swapRemove(idToRemove); err != nil { + return err + } + + if err := m.setActiveEntities(archID, active); err != nil { + return err + } + + // See whether the origin archetype ID is already set (from a previous archetype move). If not, set it. + // We don't want to set it again becuse archID would not be pointing to the actual origin archetype as it has been + // changed. + if _, ok := m.entityIDToOriginArchID[idToRemove]; !ok { + m.entityIDToOriginArchID[idToRemove] = archID + } + + delete(m.entityIDToArchID, idToRemove) + + for _, compName := range m.archIDToComps[archID] { + key := compKey{compName, idToRemove} + + if err := m.compValues.Delete(key); err != nil { + return err + } + + m.compValuesToDelete = append(m.compValuesToDelete, key) + } + + return nil +} + +// CreateEntity creates a single entity with the given set of components. +func (m *EntityCommandBuffer) CreateEntity(comps ...types.Component) (types.EntityID, error) { + if !m.locked { + return 0, ErrNotInitialized + } + + ids, err := m.CreateManyEntities(1, comps...) + if err != nil { + return 0, err + } + + return ids[0], nil +} + +// CreateManyEntities creates many entities with the given set of components. +func (m *EntityCommandBuffer) CreateManyEntities(num int, comps ...types.Component) ([]types.EntityID, error) { + if !m.locked { + return nil, ErrNotInitialized + } + + // Check component is registered + for _, comp := range comps { + err := m.checkComponentRegistered(comp) + if err != nil { + return nil, err + } + } + + // Check for duplicate components + seenComps := make([]types.ComponentName, 0, len(comps)) + for _, comp := range comps { + if slices.Contains(seenComps, comp.Name()) { + return nil, eris.New("duplicate component") + } + seenComps = append(seenComps, comp.Name()) + } + + archID, err := m.getOrMakeArchIDForComponents(seenComps) + if err != nil { + return nil, err + } + + ids := make([]types.EntityID, num) + active, err := m.getActiveEntities(archID) + if err != nil { + return nil, err + } + + for i := range ids { + currID, err := m.nextEntityID() + if err != nil { + return nil, err + } + ids[i] = currID + + m.entityIDToArchID[currID] = archID + m.entityIDToOriginArchID[currID] = doesNotExistArchetypeID + + active.ids = append(active.ids, currID) + active.modified = true + } + + if err := m.setActiveEntities(archID, active); err != nil { + return nil, err + } + + for _, id := range ids { + for _, comp := range comps { + err := m.SetComponentForEntity(id, comp) + if err != nil { + return nil, err + } + } + } + + return ids, nil +} + +// SetComponentForEntity sets the given entity's component data to the given value. +func (m *EntityCommandBuffer) SetComponentForEntity(id types.EntityID, compValue types.Component) error { + if !m.locked { + return ErrNotInitialized + } + + err := m.checkComponentRegistered(compValue) + if err != nil { + return err + } + + comps, err := m.getComponentTypesForEntity(id) + if err != nil { + return err + } + + if !containsComponent(comps, compValue.Name()) { + return ErrComponentNotOnEntity + } + + err = m.compValues.Set(compKey{compValue.Name(), id}, compValue) + if err != nil { + return err + } + + return nil +} + +// GetComponentForEntity returns the saved component data for the given entity. +// In ECB, we store the component value after the first fetch of the component from Redis. +// If it is, we return it immmediately. +// If not, we will proceed to fetch the component value from Redis. +func (m *EntityCommandBuffer) GetComponentForEntity(comp types.Component, id types.EntityID) (any, error) { + if !m.locked { + return nil, ErrNotInitialized + } + + err := m.checkComponentRegistered(comp) + if err != nil { + return nil, err + } + + ctx := context.Background() + key := compKey{comp.Name(), id} + + // Case 1: The component value is already in the in-memory cache. + compValue, err := m.compValues.Get(key) + if err == nil { + return compValue, nil + } + + // Case 2: The component value is not in the in-memory cache. + + // Check if the component has the target component type. + + comps, err := m.getComponentTypesForEntity(id) + if err != nil { + return nil, err + } + + if !containsComponent(comps, comp.Name()) { + return nil, ErrComponentNotOnEntity + } + + cType, ok := m.compNameToComponent[comp.Name()] + if !ok { + return nil, ErrComponentNotRegistered + } + + // Fetch the value from Redis + redisKey := storageComponentKey(comp.Name(), id) + + bz, err := m.dbStorage.GetBytes(ctx, redisKey) + if err != nil { + // todo: this is redis specific, should be changed to a general error on storage + // todo: RedisStorage needs to be modified to return this general error when a redis.Nil is detected. + if !errors.Is(err, redis.Nil) { + return nil, err + } + + // This value has never been set. Make a default value. + bz, err = cType.New() + if err != nil { + return nil, err + } + } + + compValue, err = cType.Decode(bz) + if err != nil { + return nil, err + } + + // Save the value to the in-memory cache + if err := m.compValues.Set(key, compValue); err != nil { + return nil, err + } + + return compValue, nil +} + +// GetComponentForEntityInRawJSON returns the saved component data as JSON encoded bytes for the given entity. +func (m *EntityCommandBuffer) GetComponentForEntityInRawJSON(comp types.Component, id types.EntityID) ( + json.RawMessage, error, +) { + if !m.locked { + return nil, ErrNotInitialized + } + + value, err := m.GetComponentForEntity(comp, id) + if err != nil { + return nil, err + } + + cType, ok := m.compNameToComponent[comp.Name()] + if !ok { + return nil, ErrComponentNotRegistered + } + + return cType.Encode(value) +} + +// GetAllComponentsForEntityInRawJSON returns all components for the given entity in JSON format. +func (m *EntityCommandBuffer) GetAllComponentsForEntityInRawJSON(id types.EntityID) ( + map[string]json.RawMessage, error, +) { + if !m.locked { + return nil, ErrNotInitialized + } + + comps, err := m.getComponentTypesForEntity(id) + if err != nil { + return nil, err + } + + result := map[string]json.RawMessage{} + + for _, compNames := range comps { + comp := m.compNameToComponent[compNames] + value, err := m.GetComponentForEntityInRawJSON(comp, id) + if err != nil { + return nil, err + } + result[compNames] = value + } + + return result, nil +} + +// AddComponentToEntity adds the given component to the given entity. An error is returned if the entity +// already has this component. +func (m *EntityCommandBuffer) AddComponentToEntity(comp types.Component, id types.EntityID) error { + if !m.locked { + return ErrNotInitialized + } + + err := m.checkComponentRegistered(comp) + if err != nil { + return err + } + + currentComps, err := m.getComponentTypesForEntity(id) + if err != nil { + return err + } + + if containsComponent(currentComps, comp.Name()) { + return ErrComponentAlreadyOnEntity + } + + currentArch, err := m.getOrMakeArchIDForComponents(currentComps) + if err != nil { + return err + } + + newArch, err := m.getOrMakeArchIDForComponents(append(currentComps, comp.Name())) + if err != nil { + return err + } + + return m.moveEntityByArchetype(currentArch, newArch, id) +} + +// RemoveComponentFromEntity removes the given component from the given entity. An error is returned if the entity +// does not have the component. +func (m *EntityCommandBuffer) RemoveComponentFromEntity(comp types.Component, id types.EntityID) error { + if !m.locked { + return ErrNotInitialized + } + + err := m.checkComponentRegistered(comp) + if err != nil { + return err + } + + entityComps, err := m.getComponentTypesForEntity(id) + if err != nil { + return err + } + + isTargetCompOnEntity := false + newCompSet := make([]types.ComponentName, 0, len(entityComps)-1) + for _, entityComp := range entityComps { + if entityComp == comp.Name() { + isTargetCompOnEntity = true + continue + } + newCompSet = append(newCompSet, entityComp) + } + + if !isTargetCompOnEntity { + return ErrComponentNotOnEntity + } + + if len(newCompSet) == 0 { + return ErrEntityMustHaveAtLeastOneComponent + } + + key := compKey{comp.Name(), id} + if err := m.compValues.Delete(key); err != nil { + return err + } + m.compValuesToDelete = append(m.compValuesToDelete, key) + + fromArchID, err := m.getOrMakeArchIDForComponents(entityComps) + if err != nil { + return err + } + + toArchID, err := m.getOrMakeArchIDForComponents(newCompSet) + if err != nil { + return err + } + + return m.moveEntityByArchetype(fromArchID, toArchID, id) +} + +// getComponentTypesForEntity returns all the component types that are currently on the given entity. Only types +// are returned. To get the actual component data, use GetComponentForEntity. +func (m *EntityCommandBuffer) getComponentTypesForEntity(id types.EntityID) ([]types.ComponentName, error) { + archID, err := m.getArchetypeForEntity(id) + if err != nil { + return nil, err + } + return m.archIDToComps[archID], nil +} + +// getArchIDForComponents returns the archetype ID that has been assigned to this set of components. +// If this set of components does not have an archetype ID assigned to it, an error is returned. +func (m *EntityCommandBuffer) getArchIDForComponents(components []types.ComponentName) (types.ArchetypeID, error) { + if len(components) == 0 { + return 0, eris.New("must provide at least 1 component") + } + + for archID, comps := range m.archIDToComps { + if err := isComponentSetMatch(comps, components); err == nil { + return archID, nil + } + } + + return 0, ErrArchetypeNotFound +} + +// GetEntitiesForArchID returns all the entities that currently belong to the given archetype EntityID. +func (m *EntityCommandBuffer) GetEntitiesForArchID(archID types.ArchetypeID) ([]types.EntityID, error) { + active, err := m.getActiveEntities(archID) + if err != nil { + return nil, err + } + return active.ids, nil +} + +// FindArchetypes returns a list of archetype IDs that fulfill the given component filter. +func (m *EntityCommandBuffer) FindArchetypes(f filter.ComponentFilter) ([]types.ArchetypeID, error) { + archetypes := make([]types.ArchetypeID, 0) + + for archID, compNames := range m.archIDToComps { + comps := make([]types.Component, 0, len(compNames)) + for _, compName := range compNames { + comps = append(comps, m.compNameToComponent[compName]) + } + + if !f.MatchesComponents(comps) { + continue + } + archetypes = append(archetypes, archID) + } + + return archetypes, nil +} + +// ArchetypeCount returns the number of archetypes that have been generated. +func (m *EntityCommandBuffer) ArchetypeCount() (int, error) { + if !m.locked { + return 0, ErrNotInitialized + } + return len(m.archIDToComps), nil +} + +// Close closes the manager. +func (m *EntityCommandBuffer) Close() error { + ctx := context.Background() + err := eris.Wrap(m.dbStorage.Close(ctx), "") + // todo: make error general to storage and not redis specific + // todo: adjust redis client to be return a general storage error when redis.ErrClosed is detected + if eris.Is(eris.Cause(err), redis.ErrClosed) { + // if redis is already closed that means another shutdown pathway got to it first. + // There are multiple modules that will try to shutdown redis, if it is already shutdown it is not an error. + return nil + } + return err +} + +// getArchetypeForEntity returns the archetype EntityID for the given entity EntityID. +func (m *EntityCommandBuffer) getArchetypeForEntity(id types.EntityID) (types.ArchetypeID, error) { + // Check if the entity ID is already in the in-memory cache. If so, return the archetype ID. + archID, ok := m.entityIDToArchID[id] + if ok { + return archID, nil + } + + // If the entity ID is not in the in-memory cache, fetch the archetype ID from Redis. + num, err := m.dbStorage.GetInt(context.Background(), storageArchetypeIDForEntityID(id)) + if err != nil { + // todo: Make redis.Nil a general error on storage + if errors.Is(err, redis.Nil) { + return 0, eris.Wrap(redis.Nil, ErrEntityDoesNotExist.Error()) + } + return 0, err + } + + archID = types.ArchetypeID(num) + + // Save the archetype ID to in-memory cache + m.entityIDToArchID[id] = archID + + return archID, nil +} + +// nextEntityID returns the next available entity EntityID. +func (m *EntityCommandBuffer) nextEntityID() (types.EntityID, error) { + if !m.isEntityIDLoaded { + // The next valid entity EntityID needs to be loaded from dbStorage. + ctx := context.Background() + nextID, err := m.dbStorage.GetUInt64(ctx, storageNextEntityIDKey()) + err = eris.Wrap(err, "") + if err != nil { + // todo: make redis.Nil a general error on storage. + if !eris.Is(eris.Cause(err), redis.Nil) { + return 0, err + } + // redis.Nil means there's no value at this key. Start with an EntityID of 0 + nextID = 0 + } + m.nextEntityIDSaved = nextID + m.pendingEntityIDs = 0 + m.isEntityIDLoaded = true + } + + id := m.nextEntityIDSaved + m.pendingEntityIDs + m.pendingEntityIDs++ + return types.EntityID(id), nil +} + +// getOrMakeArchIDForComponents converts the given set of components into an archetype EntityID. +// If the set of components has already been assigned an archetype EntityID, that EntityID is returned. +// If this is a new set of components, an archetype EntityID is generated. +func (m *EntityCommandBuffer) getOrMakeArchIDForComponents(comps []types.ComponentName) ( + types.ArchetypeID, error, +) { + archID, err := m.getArchIDForComponents(comps) + if err == nil { + return archID, nil + } + if !eris.Is(eris.Cause(err), ErrArchetypeNotFound) { + return 0, err + } + + // An archetype EntityID was not found. Create a pending arch ID + id := types.ArchetypeID(len(m.archIDToComps)) + m.pendingArchIDs = append(m.pendingArchIDs, id) + m.archIDToComps[id] = comps + log.Debug().Int("archetype_id", int(id)).Msg("New archetype created") + + return id, nil +} + +// getActiveEntities returns the entities that are currently assigned to the given archetype EntityID. +func (m *EntityCommandBuffer) getActiveEntities(archID types.ArchetypeID) (activeEntities, error) { + active, err := m.activeEntities.Get(archID) + // The active entities for this archetype EntityID has not yet been loaded from dbStorage + if err == nil { + return active, nil + } + + var ids []types.EntityID + + bz, err := m.dbStorage.GetBytes(context.Background(), storageActiveEntityIDKey(archID)) + if err != nil { + // todo: this is redis specific, should be changed to a general error on storage + // todo: RedisStorage needs to be modified to return this general error when a redis.Nil is detected. + if !eris.Is(eris.Cause(err), redis.Nil) { + return active, err + } + } else { + ids, err = codec.Decode[[]types.EntityID](bz) + if err != nil { + return active, err + } + } + + result := activeEntities{ + ids: ids, + modified: false, + } + + if err = m.activeEntities.Set(archID, result); err != nil { + return activeEntities{}, err + } + + return result, nil +} + +// setActiveEntities sets the entities that are associated with the given archetype EntityID and marks +// the information as modified so it can later be pushed to the dbStorage layer. +func (m *EntityCommandBuffer) setActiveEntities(archID types.ArchetypeID, active activeEntities) error { + active.modified = true + return m.activeEntities.Set(archID, active) +} + +// moveEntityByArchetype moves an entity EntityID from one archetype to another archetype. +func (m *EntityCommandBuffer) moveEntityByArchetype( + currentArchID, newArchID types.ArchetypeID, id types.EntityID, +) error { + if _, ok := m.entityIDToOriginArchID[id]; !ok { + m.entityIDToOriginArchID[id] = currentArchID + } + + m.entityIDToArchID[id] = newArchID + + active, err := m.getActiveEntities(currentArchID) + if err != nil { + return err + } + + if err := active.swapRemove(id); err != nil { + return err + } + + if err := m.setActiveEntities(currentArchID, active); err != nil { + return err + } + + active, err = m.getActiveEntities(newArchID) + if err != nil { + return err + } + active.ids = append(active.ids, id) + + if err := m.setActiveEntities(newArchID, active); err != nil { + return err + } + + return nil +} + +// FinalizeTick combines all pending state changes into a single multi/exec redis transactions and commits them +// to the DB. +func (m *EntityCommandBuffer) FinalizeTick(ctx context.Context) error { + if !m.locked { + return ErrNotInitialized + } + + ctx, span := m.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "ecb.tick.finalize") + defer span.End() + + pipe, err := m.makePipeOfRedisCommands(ctx) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to make redis commands pipe") + } + + if err := pipe.Incr(ctx, storageLastFinalizedTickKey()); err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to increment latest finalized tick") + } + + if err := pipe.EndTransaction(ctx); err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to end transaction") + } + + m.pendingArchIDs = nil + + if err := m.DiscardPending(); err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return eris.Wrap(err, "failed to discard pending state changes") + } + + return nil +} + +// makePipeOfRedisCommands return a pipeliner with all pending state changes to redis ready to be committed in an atomic +// transaction. If an error is returned, no redis changes will have been made. +func (m *EntityCommandBuffer) makePipeOfRedisCommands(ctx context.Context) (PrimitiveStorage[string], error) { + ctx, span := m.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "ecb.tick.finalize.pipe_make") + defer span.End() + + pipe, err := m.dbStorage.StartTransaction(ctx) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return nil, err + } + + if m.compNameToComponent == nil { + err := eris.New("must call registerComponents before flushing to DB") + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return nil, err + } + + operations := []struct { + name string + method func(ctx context.Context, pipe PrimitiveStorage[string]) error + }{ + {"component_changes", m.addComponentChangesToPipe}, + {"next_entity_id", m.addNextEntityIDToPipe}, + {"pending_arch_ids", m.addPendingArchIDsToPipe}, + {"entity_id_to_arch_id", m.addEntityIDToArchIDToPipe}, + {"active_entity_ids", m.addActiveEntityIDsToPipe}, + } + + for _, operation := range operations { + ctx, pipeSpan := m.tracer.Start(ddotel.ContextWithStartOptions(ctx, //nolint:spancheck // false positive + ddtracer.Measured()), + "tick.span.finalize.pipe_make."+operation.name) + + // Perform the entity operation + if err := operation.method(ctx, pipe); err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + pipeSpan.SetStatus(codes.Error, eris.ToString(err, true)) + pipeSpan.RecordError(err) + return nil, eris.Wrapf(err, "failed to run step %q", operation.name) //nolint:spancheck // false positive + } + + pipeSpan.End() + } + + return pipe, nil +} + +// addEntityIDToArchIDToPipe adds the information related to mapping an EntityID to its assigned archetype ArchetypeID. +func (m *EntityCommandBuffer) addEntityIDToArchIDToPipe(ctx context.Context, pipe PrimitiveStorage[string]) error { + for id, originArchID := range m.entityIDToOriginArchID { + key := storageArchetypeIDForEntityID(id) + + // Check whether the entity is still attached to an archetype + archID, ok := m.entityIDToArchID[id] + + // Case 1: If an entity is not attached to an archetype, that means it hs been removed. + if !ok { + if err := pipe.Delete(ctx, key); err != nil { + return err + } + continue + } + + // Case 2: If an entity ID is the same as its origin archetype ID, that means it has not moved. Nothing to do. + if archID == originArchID { + continue + } + + // Case 3: The current archetype ID is different from the origin archetype ID. Update the archetype ID. + if err := pipe.Set(ctx, key, int(archID)); err != nil { + return err + } + } + + return nil +} + +// addNextEntityIDToPipe adds any changes to the next available entity ArchetypeID to the given redis pipe. +func (m *EntityCommandBuffer) addNextEntityIDToPipe(ctx context.Context, pipe PrimitiveStorage[string]) error { + // There are no pending entity id creations, so there's nothing to commit + if m.pendingEntityIDs == 0 { + return nil + } + + key := storageNextEntityIDKey() + nextID := m.nextEntityIDSaved + m.pendingEntityIDs + + return pipe.Set(ctx, key, nextID) +} + +// addComponentChangesToPipe adds updated component values for entities to the redis pipe. +func (m *EntityCommandBuffer) addComponentChangesToPipe(ctx context.Context, pipe PrimitiveStorage[string]) error { + for _, key := range m.compValuesToDelete { + if err := pipe.Delete(ctx, storageComponentKey(key.compName, key.entityID)); err != nil { + return err + } + } + + keys, err := m.compValues.Keys() + if err != nil { + return err + } + + for _, key := range keys { + value, err := m.compValues.Get(key) + if err != nil { + return err + } + + bz, err := codec.Encode(value) + if err != nil { + return err + } + + redisKey := storageComponentKey(key.compName, key.entityID) + if err = pipe.Set(ctx, redisKey, bz); err != nil { + return eris.Wrap(err, "") + } + } + + m.compValuesToDelete = make([]compKey, 0) + + return nil +} + +// addPendingArchIDsToPipe adds any newly created archetype IDs (as well as the associated sets of components) to the +// redis pipe. +func (m *EntityCommandBuffer) addPendingArchIDsToPipe(ctx context.Context, pipe PrimitiveStorage[string]) error { + if len(m.pendingArchIDs) == 0 { + return nil + } + + archetypes := map[types.ArchetypeID][]types.ComponentName{} + for archID, comps := range m.archIDToComps { + var compNames []types.ComponentName + for _, compName := range comps { + compNames = append(compNames, compName) + } + archetypes[archID] = compNames + } + + bz, err := codec.Encode(archetypes) + if err != nil { + return err + } + + return pipe.Set(ctx, storageArchIDsToCompTypesKey(), bz) +} + +// addActiveEntityIDsToPipe adds information about which entities are assigned to which archetype IDs to the reids pipe. +func (m *EntityCommandBuffer) addActiveEntityIDsToPipe(ctx context.Context, pipe PrimitiveStorage[string]) error { + archIDs, err := m.activeEntities.Keys() + if err != nil { + return err + } + for _, archID := range archIDs { + active, err := m.activeEntities.Get(archID) + if err != nil { + return err + } + if !active.modified { + continue + } + bz, err := codec.Encode(active.ids) + if err != nil { + return err + } + key := storageActiveEntityIDKey(archID) + err = pipe.Set(ctx, key, bz) + if err != nil { + return eris.Wrap(err, "") + } + } + return nil +} + +// loadArchetype returns a mapping that contains the corresponding components for a given archetype ID. +func (m *EntityCommandBuffer) loadArchetype() error { + // In ECB, it's not allowed to reload the archetype cache like this since it will overwrite the working state. + if m.locked { + return eris.New("archetype already loaded") + } + + bz, err := m.dbStorage.GetBytes(context.Background(), storageArchIDsToCompTypesKey()) + if err != nil { + // If no archetypes have been set, just terminate early. + if eris.Is(eris.Cause(err), redis.Nil) { + return nil + } + return err + } + + archetypes, err := codec.Decode[map[types.ArchetypeID][]types.ComponentName](bz) + if err != nil { + return err + } + + for archID, compNames := range archetypes { + var comps []types.ComponentName + + // Validate component schemas + for _, compName := range compNames { + _, ok := m.compNameToComponent[compName] + if !ok { + return ErrComponentMismatchWithSavedState + } + comps = append(comps, compName) + } + + m.archIDToComps[archID] = comps + } + + return nil +} + +// containsComponent returns true if the given slice of components contains the target component. +// Components are the same if they have the same Name. +func containsComponent( + components []types.ComponentName, + target types.ComponentName, +) bool { + for _, c := range components { + if target == c { + return true + } + } + return false +} + +func (m *EntityCommandBuffer) checkComponentRegistered(comp types.Component) error { + _, ok := m.compNameToComponent[comp.Name()] + if !ok { + return eris.Wrap(ErrComponentNotRegistered, fmt.Sprintf("component %q is not registered", comp.Name())) + } + return nil +} diff --git a/v2/gamestate/ecb_test.go b/v2/gamestate/ecb_test.go new file mode 100644 index 000000000..af8e4f767 --- /dev/null +++ b/v2/gamestate/ecb_test.go @@ -0,0 +1,951 @@ +package gamestate_test + +import ( + "bytes" + "context" + "runtime" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + redisstorage "pkg.world.dev/world-engine/cardinal/v2/storage/redis" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type Foo struct{ Value int } + +func (Foo) Name() string { + return "foo" +} + +type Bar struct{ Value int } + +func (Bar) Name() string { + return "bar" +} + +func newRedisClient(t *testing.T) *redis.Client { + s := miniredis.RunT(t) + options := redis.Options{ + Addr: s.Addr(), + Password: "", // no password set + DB: 0, // use default DB + } + return redis.NewClient(&options) +} + +// newECBForTest world.Creates a gamestate.EntityCommandBuffer using the given +// redis dbStorage. If the passed in redis +// dbStorage is nil, a redis dbStorage is world.Created. +func newECBForTest(t *testing.T, client *redis.Client) *gamestate.EntityCommandBuffer { + if client == nil { + client = newRedisClient(t) + } + + rs := redisstorage.NewRedisStorageWithClient(client, "test") + state, err := gamestate.New(&rs) + assert.NilError(t, err) + + fooComp, err := gamestate.NewComponentMetadata[Foo]() + assert.NilError(t, err) + barComp, err := gamestate.NewComponentMetadata[Bar]() + assert.NilError(t, err) + + assert.NilError(t, state.RegisterComponent(fooComp)) + assert.NilError(t, state.RegisterComponent(barComp)) + + assert.NilError(t, state.Init()) + + return state.ECB() +} + +func TestCanCreateEntityAndSetComponent(t *testing.T) { + ecb := newECBForTest(t, nil) + ctx := context.Background() + wantValue := Foo{99} + + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + _, err = ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.NilError(t, ecb.SetComponentForEntity(id, wantValue)) + gotValue, err := ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, wantValue, gotValue) + + // Commit the pending changes + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Data should not change after a successful commit + gotValue, err = ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, wantValue, gotValue) +} + +func TestDiscardedComponentChangeRevertsToOriginalValue(t *testing.T) { + ecb := newECBForTest(t, nil) + ctx := context.Background() + wantValue := Foo{99} + + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.NilError(t, ecb.SetComponentForEntity(id, wantValue)) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Verify the component is what we expect + gotValue, err := ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, wantValue, gotValue) + + badValue := Foo{666} + assert.NilError(t, ecb.SetComponentForEntity(id, badValue)) + // The (pending) value should be in the 'bad' state + gotValue, err = ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, badValue, gotValue) + + // Calling LayerDiscard will discard all changes since the last Layer* call + err = ecb.DiscardPending() + assert.NilError(t, err) + // The value should not be the original 'wantValue' + gotValue, err = ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, wantValue, gotValue) +} + +func TestDiscardedEntityIDsWillBeAssignedAgain(t *testing.T) { + ecb := newECBForTest(t, nil) + ctx := context.Background() + + ids, err := ecb.CreateManyEntities(10, Foo{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + // This is the next EntityID we should expect to be assigned + nextID := ids[len(ids)-1] + 1 + + // world.Create a new entity. It should have nextID as the EntityID + gotID, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.Equal(t, nextID, gotID) + // But uhoh, there's a problem. Returning an error here means the entity creation + // will be undone + err = ecb.DiscardPending() + assert.NilError(t, err) + + // world.Create an entity again. We should get nextID assigned again. + // world.Create a new entity. It should have nextID as the EntityID + gotID, err = ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.Equal(t, nextID, gotID) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Now that nextID has been assigned, creating a new entity should give us a new entity EntityID + gotID, err = ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.Equal(t, gotID, nextID+1) + assert.NilError(t, ecb.FinalizeTick(ctx)) +} + +func TestCanGetComponentsForEntity(t *testing.T) { + ecb := newECBForTest(t, nil) + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + + comp, err := ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Foo{}.Name()) +} + +func TestGettingInvalidEntityResultsInAnError(t *testing.T) { + ecb := newECBForTest(t, nil) + _, err := ecb.GetComponentForEntity(Foo{}, types.EntityID(1034134)) + assert.Check(t, err != nil) +} + +func TestComponentSetsCanBeDiscarded(t *testing.T) { + ecb := newECBForTest(t, nil) + + firstID, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + comp, err := ecb.GetComponentForEntity(Foo{}, firstID) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Foo{}.Name()) + + // Discard the above changes + err = ecb.DiscardPending() + assert.NilError(t, err) + + // Repeat the above operation. We should end up with the same entity EntityID, and it should + // end up containing a different set of components + gotID, err := ecb.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + + // The assigned entity EntityID should be reused + assert.Equal(t, gotID, firstID) + comps, err := ecb.GetComponentForEntity(Foo{}, gotID) + assert.NilError(t, err) + assert.Equal(t, comps.(types.Component).Name(), Foo{}.Name()) +} + +func TestCannotGetComponentOnEntityThatIsMissingTheComponent(t *testing.T) { + ecb := newECBForTest(t, nil) + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + // Bar{} has not been assigned to this entity + _, err = ecb.GetComponentForEntity(Bar{}, id) + assert.ErrorIs(t, err, gamestate.ErrComponentNotOnEntity) +} + +func TestCannotSetComponentOnEntityThatIsMissingTheComponent(t *testing.T) { + manager := newECBForTest(t, nil) + id, err := manager.CreateEntity(Foo{}) + assert.NilError(t, err) + // Bar{} has not been assigned to this entity + err = manager.SetComponentForEntity(id, Bar{100}) + assert.ErrorIs(t, err, gamestate.ErrComponentNotOnEntity) +} + +func TestCannotRemoveAComponentFromAnEntityThatDoesNotHaveThatComponent(t *testing.T) { + ecb := newECBForTest(t, nil) + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + err = ecb.RemoveComponentFromEntity(Bar{}, id) + assert.ErrorIs(t, err, gamestate.ErrComponentNotOnEntity) +} + +func TestCanAddAComponentToAnEntity(t *testing.T) { + manager := newECBForTest(t, nil) + ctx := context.Background() + + // Create an entity with the Foo component + id, err := manager.CreateEntity(Foo{}) + assert.NilError(t, err) + + // Check that the Foo component is on the entity + comp, err := manager.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Foo{}.Name()) + + // Add a Bar component to the entity + assert.NilError(t, manager.AddComponentToEntity(Bar{}, id)) + + // Commit this entity creation + assert.NilError(t, manager.FinalizeTick(ctx)) + + // Check that the Foo component is still on the entity + comp, err = manager.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Foo{}.Name()) + + // Check that the Bar component is on the entity + comp, err = manager.GetComponentForEntity(Bar{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Bar{}.Name()) +} + +func TestCanRemoveAComponentFromAnEntity(t *testing.T) { + manager := newECBForTest(t, nil) + + // Create an entity with both the Foo and Bar components + id, err := manager.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + + // Check that the Foo component is on the entity + comp, err := manager.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Foo{}.Name()) + + // Check that the Bar component is on the entity + comp, err = manager.GetComponentForEntity(Bar{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Bar{}.Name()) + + // Remove the Foo component from the entity + assert.NilError(t, manager.RemoveComponentFromEntity(Foo{}, id)) + + // Only the Bar component should be left + comp, err = manager.GetComponentForEntity(Bar{}, id) + assert.NilError(t, err) + assert.Equal(t, comp.(types.Component).Name(), Bar{}.Name()) +} + +func TestCannotAddComponentToEntityThatAlreadyHasTheComponent(t *testing.T) { + manager := newECBForTest(t, nil) + id, err := manager.CreateEntity(Foo{}) + assert.NilError(t, err) + + err = manager.AddComponentToEntity(Foo{}, id) + assert.ErrorIs(t, err, gamestate.ErrComponentAlreadyOnEntity) +} + +type Health struct { + Value int +} + +func (Health) Name() string { + return "health" +} + +type Power struct { + Value int +} + +func (Power) Name() string { + return "power" +} + +func TestStorageCanBeUsedInQueries(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[Health](tf.World())) + assert.NilError(t, world.RegisterComponent[Power](tf.World())) + + var justHealthIDs []types.EntityID + var justPowerIDs []types.EntityID + var healthAndPowerIDs []types.EntityID + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + var err error + justHealthIDs, err = world.CreateMany(wCtx, 8, Health{}) + assert.NilError(t, err) + justPowerIDs, err = world.CreateMany(wCtx, 9, Power{}) + assert.NilError(t, err) + healthAndPowerIDs, err = world.CreateMany(wCtx, 10, Health{}, Power{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + testCases := []struct { + search func() map[types.EntityID]bool + wantIDs []types.EntityID + }{ + { + search: func() map[types.EntityID]bool { + found := map[types.EntityID]bool{} + err := tf.Cardinal.World().Search(filter.Contains(Health{})).Each( + func(id types.EntityID) bool { + found[id] = true + return true + }, + ) + assert.NilError(t, err) + return found + }, + wantIDs: append(justHealthIDs, healthAndPowerIDs...), + }, + { + search: func() map[types.EntityID]bool { + found := map[types.EntityID]bool{} + err := tf.Cardinal.World().Search(filter.Contains(Power{})).Each( + func(id types.EntityID) bool { + found[id] = true + return true + }, + ) + assert.NilError(t, err) + return found + }, + wantIDs: append(justPowerIDs, healthAndPowerIDs...), + }, + { + search: func() map[types.EntityID]bool { + found := map[types.EntityID]bool{} + err := tf.Cardinal.World().Search(filter.Exact(Power{}, Health{})).Each( + func(id types.EntityID) bool { + found[id] = true + return true + }, + ) + assert.NilError(t, err) + return found + }, + wantIDs: healthAndPowerIDs, + }, + { + search: func() map[types.EntityID]bool { + found := map[types.EntityID]bool{} + err := tf.Cardinal.World().Search(filter.Exact(Health{})).Each( + func(id types.EntityID) bool { + found[id] = true + return true + }, + ) + assert.NilError(t, err) + return found + }, + wantIDs: justHealthIDs, + }, + { + search: func() map[types.EntityID]bool { + found := map[types.EntityID]bool{} + err := tf.Cardinal.World().Search(filter.Exact(Power{})).Each( + func(id types.EntityID) bool { + found[id] = true + return true + }, + ) + assert.NilError(t, err) + return found + }, + wantIDs: justPowerIDs, + }, + } + + for _, tc := range testCases { + found := tc.search() + assert.Equal(t, len(tc.wantIDs), len(found)) + for _, id := range tc.wantIDs { + assert.Check(t, found[id], "id is missing from query result") + } + } +} + +func TestEntityCanBeRemoved(t *testing.T) { + manager := newECBForTest(t, nil) + + ids, err := manager.CreateManyEntities(10, Foo{}, Bar{}) + assert.NilError(t, err) + assert.Equal(t, 10, len(ids)) + for i := range ids { + if i%2 == 0 { + assert.NilError(t, manager.RemoveEntity(ids[i])) + } + } + + for i, id := range ids { + valid := i%2 == 1 + _, err = manager.GetComponentForEntity(Foo{}, id) + if valid { + assert.NilError(t, err) + } else { + assert.Check(t, err != nil) + } + } +} + +func TestMovedEntitiesCanBeFoundInNewArchetype(t *testing.T) { + manager := newECBForTest(t, nil) + + id, err := manager.CreateEntity(Foo{}) + assert.NilError(t, err) + + startEntityCount := 10 + _, err = manager.CreateManyEntities(startEntityCount, Foo{}, Bar{}) + assert.NilError(t, err) + + fooArchIDs, err := manager.FindArchetypes(filter.Exact(Foo{})) + assert.NilError(t, err) + fooArchID := fooArchIDs[0] + + bothArchIDs, err := manager.FindArchetypes(filter.Exact(Bar{}, Foo{})) + assert.NilError(t, err) + bothArchID := bothArchIDs[0] + + // Make sure there are the correct number of ids in each archetype to start + ids, err := manager.GetEntitiesForArchID(fooArchID) + assert.NilError(t, err) + assert.Equal(t, 1, len(ids)) + + ids, err = manager.GetEntitiesForArchID(bothArchID) + assert.NilError(t, err) + assert.Equal(t, startEntityCount, len(ids)) + + assert.NilError(t, manager.AddComponentToEntity(Bar{}, id)) + + ids, err = manager.GetEntitiesForArchID(fooArchID) + assert.NilError(t, err) + assert.Equal(t, 0, len(ids)) + + ids, err = manager.GetEntitiesForArchID(bothArchID) + assert.NilError(t, err) + assert.Equal(t, startEntityCount+1, len(ids)) + + // make sure the target id is in the new list of ids. + found := false + for _, currID := range ids { + if currID == id { + found = true + break + } + } + assert.Check(t, found) + + // Also make sure we can remove the archetype + assert.NilError(t, manager.RemoveComponentFromEntity(Bar{}, id)) + + ids, err = manager.GetEntitiesForArchID(fooArchID) + assert.NilError(t, err) + assert.Equal(t, 1, len(ids)) + + ids, err = manager.GetEntitiesForArchID(bothArchID) + assert.NilError(t, err) + assert.Equal(t, startEntityCount, len(ids)) + + // Make sure the target id is NOT in the 'both' group + found = false + for _, currID := range ids { + if currID == id { + found = true + } + } + assert.Check(t, !found) +} + +func TestCanGetArchetypeCount(t *testing.T) { + manager := newECBForTest(t, nil) + _, err := manager.CreateEntity(Foo{}) + assert.NilError(t, err) + archCount, err := manager.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 1, archCount) + + // This archetype has already been world.Created, so it shouldn't change the count + _, err = manager.CreateEntity(Foo{}) + assert.NilError(t, err) + archCount, err = manager.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 1, archCount) + + _, err = manager.CreateEntity(Bar{}) + assert.NilError(t, err) + archCount, err = manager.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 2, archCount) + + _, err = manager.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + archCount, err = manager.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 3, archCount) +} + +func TestClearComponentWhenAnEntityMovesAwayFromAnArchetypeThenBackToTheArchetype(t *testing.T) { + manager := newECBForTest(t, nil) + id, err := manager.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + + startValue := Foo{100} + + assert.NilError(t, manager.SetComponentForEntity(id, startValue)) + gotValue, err := manager.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, startValue, gotValue.(Foo)) + + // Removing Foo{}, then re-adding it should zero out the component. + assert.NilError(t, manager.RemoveComponentFromEntity(Foo{}, id)) + assert.NilError(t, manager.AddComponentToEntity(Foo{}, id)) + + gotValue, err = manager.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, Foo{}, gotValue.(Foo)) +} + +func TestCannotCreateEntityWithDuplicateComponents(t *testing.T) { + manager := newECBForTest(t, nil) + _, err := manager.CreateEntity(Foo{}, Bar{}, Foo{}) + assert.Check(t, err != nil) +} + +func TestCannotSaveStateBeforeRegisteringComponents(t *testing.T) { + // Don't use newCmdBufferForTest because that automatically registers some components. + s := miniredis.RunT(t) + options := redis.Options{ + Addr: s.Addr(), + Password: "", // no password set + DB: 0, // use default DB + } + ctx := context.Background() + + client := redis.NewClient(&options) + rs := redisstorage.NewRedisStorageWithClient(client, "") + state, err := gamestate.New(&rs) + assert.NilError(t, err) + + // registerComponents must be called before attempting to save the state + err = state.ECB().FinalizeTick(ctx) + assert.IsError(t, err) + + fooComp, err := gamestate.NewComponentMetadata[Foo]() + assert.NilError(t, err) + barComp, err := gamestate.NewComponentMetadata[Bar]() + assert.NilError(t, err) + + assert.NilError(t, state.RegisterComponent(fooComp)) + assert.NilError(t, state.RegisterComponent(barComp)) + + assert.NilError(t, state.Init()) + assert.NilError(t, state.ECB().FinalizeTick(ctx)) +} + +// TestFinalizeTickPerformanceIsConsistent ensures calls to FinalizeTick takes roughly the same amount of time and +// resources when processing the same amount of data. +func TestFinalizeTickPerformanceIsConsistent(t *testing.T) { + manager := newECBForTest(t, nil) + ctx := context.Background() + + // CreateAndFinalizeEntities world.Creates some entities and then calls FinalizeTick. It returns the amount + // of time it took to execute FinalizeTick and how many bytes of memory were allocated during the call. + createAndFinalizeEntities := func() (duration time.Duration, allocations uint64) { + _, err := manager.CreateManyEntities(100, Foo{}, Bar{}) + assert.NilError(t, err) + + var memStats runtime.MemStats + runtime.ReadMemStats(&memStats) + startAlloc := memStats.TotalAlloc + + startTime := time.Now() + err = manager.FinalizeTick(ctx) + deltaTime := time.Since(startTime) + + runtime.ReadMemStats(&memStats) + deltaAlloc := memStats.TotalAlloc - startAlloc + + // Make sure FinalizeTick didn't produce an error. + assert.NilError(t, err) + return deltaTime, deltaAlloc + } + + // Collect a baseline for how much time FinalizeTick should take and how much memory it should allocate. + baselineDuration, baselineAlloc := createAndFinalizeEntities() + + // Run FinalizeTick a bunch of times to exacerbate any memory leaks. + for i := 0; i < 100; i++ { + _, _ = createAndFinalizeEntities() + } + + // Run FinalizeTick a final handful of times. We'll take the average of these final runs and compare them to + // the baseline. Averaging these runs is required to avoid any GC spikes that will cause a single run of + // FinalizeTick to be slow, or some background process that is allocating memory in bursts. + var totalDuration time.Duration + var totalAlloc uint64 + const count = 10 + for i := 0; i < count; i++ { + currDuration, currAlloc := createAndFinalizeEntities() + totalDuration += currDuration + totalAlloc += currAlloc + } + + averageDuration := totalDuration / count + averageAlloc := totalAlloc / count + + const maxFactor = 5 + maxDuration := maxFactor * baselineDuration + maxAlloc := maxFactor * baselineAlloc + + assert.Assert(t, averageDuration < maxDuration, + "FinalizeTick took an average of %v but must be less than %v", averageDuration, maxDuration) + assert.Assert(t, averageAlloc < maxAlloc, + "FinalizeTick allocated an average of %v but must be less than %v", averageAlloc, maxAlloc) +} + +func TestLoadingFromRedisShouldNotRepeatEntityIDs(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + ids, err := ecb.CreateManyEntities(50, Foo{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + nextID := ids[len(ids)-1] + 1 + + // Make a new manager using the same redis dbStorage. Newly assigned ids should start off where + // the previous manager left off + ecb = newECBForTest(t, client) + gotID, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.Equal(t, nextID, gotID) +} + +func TestComponentSetsCanBeRecovered(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + firstID, err := ecb.CreateEntity(Bar{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + ecb = newECBForTest(t, client) + assert.NilError(t, err) + + secondID, err := ecb.CreateEntity(Bar{}) + assert.NilError(t, err) + + firstComps, err := ecb.GetAllComponentsForEntityInRawJSON(firstID) + assert.NilError(t, err) + + secondComps, err := ecb.GetAllComponentsForEntityInRawJSON(secondID) + assert.NilError(t, err) + + assert.Equal(t, len(firstComps), len(secondComps)) + for compName := range firstComps { + assert.True(t, bytes.Equal(firstComps[compName], secondComps[compName])) + } +} + +func TestAddedComponentsCanBeDiscarded(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + + comps, err := ecb.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + assert.Equal(t, 1, len(comps)) + + // Commit this entity creation + assert.NilError(t, ecb.FinalizeTick(ctx)) + + assert.NilError(t, ecb.AddComponentToEntity(Bar{}, id)) + comps, err = ecb.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + assert.Equal(t, 2, len(comps)) + + // Discard this added component + err = ecb.DiscardPending() + assert.NilError(t, err) + + comps, _ = ecb.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + assert.Equal(t, 1, len(comps)) +} + +func TestCanGetComponentTypesAfterReload(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + var id types.EntityID + _, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + + id, err = ecb.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + ecb = newECBForTest(t, client) + + comps, err := ecb.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + assert.Equal(t, 2, len(comps)) +} + +func TestCanDiscardPreviouslyAddedComponent(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + assert.NilError(t, ecb.AddComponentToEntity(Bar{}, id)) + err = ecb.DiscardPending() + assert.NilError(t, err) + + comps, err := ecb.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + // We should only have the foo component + assert.Equal(t, 1, len(comps)) +} + +func TestEntitiesCanBeFetchedAfterReload(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + ids, err := ecb.CreateManyEntities(10, Foo{}, Bar{}) + assert.NilError(t, err) + assert.Equal(t, 10, len(ids)) + + ids, err = search.New(ecb, filter.Exact(Foo{}, Bar{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 10, len(ids)) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Create a new EntityCommandBuffer instances and make sure the previously world.Created entities can be found + ecb = newECBForTest(t, client) + ids, err = search.New(ecb, filter.Exact(Foo{}, Bar{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 10, len(ids)) +} + +func TestTheRemovalOfEntitiesCanBeDiscarded(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + ids, err := ecb.CreateManyEntities(10, Foo{}) + assert.NilError(t, err) + + gotIDs, err := search.New(ecb, filter.Exact(Foo{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 10, len(gotIDs)) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Discard 3 entities + assert.NilError(t, ecb.RemoveEntity(ids[0])) + assert.NilError(t, ecb.RemoveEntity(ids[4])) + assert.NilError(t, ecb.RemoveEntity(ids[7])) + + gotIDs, err = search.New(ecb, filter.Exact(Foo{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 7, len(gotIDs)) + + // Discard these changes (this should bring the entities back) + err = ecb.DiscardPending() + assert.NilError(t, err) + + gotIDs, err = search.New(ecb, filter.Exact(Foo{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 10, len(gotIDs)) +} + +func TestTheRemovalOfEntitiesIsRememberedAfterReload(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + startingIDs, err := ecb.CreateManyEntities(10, Foo{}, Bar{}) + assert.NilError(t, err) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + idToRemove := startingIDs[5] + + assert.NilError(t, ecb.RemoveEntity(idToRemove)) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Start a brand-new manager + ecb = newECBForTest(t, client) + assert.NilError(t, err) + + for _, id := range startingIDs { + _, err = ecb.GetComponentForEntity(Foo{}, id) + if id == idToRemove { + // Make sure the entity EntityID we removed cannot be found + assert.Check(t, err != nil) + } else { + assert.NilError(t, err) + } + } +} + +func TestRemovedComponentDataCanBeRecovered(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + id, err := ecb.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + wantFoo := Foo{99} + assert.NilError(t, ecb.SetComponentForEntity(id, wantFoo)) + gotFoo, err := ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, wantFoo, gotFoo.(Foo)) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + assert.NilError(t, ecb.RemoveComponentFromEntity(Foo{}, id)) + + // Make sure we can no longer get the foo component + _, err = ecb.GetComponentForEntity(Foo{}, id) + assert.ErrorIs(t, err, gamestate.ErrComponentNotOnEntity) + // But uhoh, there was a problem. This means the removal of the Foo component + // will be undone, and the original value can be found + err = ecb.DiscardPending() + assert.NilError(t, err) + + gotFoo, err = ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.Equal(t, wantFoo, gotFoo.(Foo)) +} + +func TestArchetypeCountTracksDiscardedChanges(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + _, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + archCount, err := ecb.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 1, archCount) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + _, err = ecb.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + archCount, err = ecb.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 2, archCount) + err = ecb.DiscardPending() + assert.NilError(t, err) + + // The previously world.Created archetype EntityID was discarded, so the count should be back to 1 + _, err = ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + archCount, err = ecb.ArchetypeCount() + assert.NilError(t, err) +} + +func TestCannotFetchComponentOnRemovedEntityAfterCommit(t *testing.T) { + client := newRedisClient(t) + ecb := newECBForTest(t, client) + ctx := context.Background() + + id, err := ecb.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + _, err = ecb.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) + assert.NilError(t, ecb.RemoveEntity(id)) + + // The entity has been removed. Trying to get a component for the entity should fail. + _, err = ecb.GetComponentForEntity(Foo{}, id) + assert.Check(t, err != nil) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Trying to get the same component after committing to the DB should also fail. + _, err = ecb.GetComponentForEntity(Foo{}, id) + assert.Check(t, err != nil) +} + +func TestArchetypeIDIsConsistentAfterSaveAndLoad(t *testing.T) { + client := newRedisClient(t) + state1 := newStateForTest(t, client) + ecb, _ := state1.ECB(), state1.FinalizedState() + + _, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + + wantIDs, err := ecb.FindArchetypes(filter.Exact(Foo{})) + assert.NilError(t, err) + wantID := wantIDs[0] + + assert.NilError(t, ecb.FinalizeTick(context.Background())) + + // Make a second instance of the engine using the same storage. + state2 := newStateForTest(t, client) + ecb, _ = state2.ECB(), state2.FinalizedState() + gotIDs, err := ecb.FindArchetypes(filter.Exact(Foo{})) + assert.NilError(t, err) + + gotID := gotIDs[0] + + // Archetype indices should be the same across save/load cycles + assert.Equal(t, wantID, gotID) +} diff --git a/v2/gamestate/errors.go b/v2/gamestate/errors.go new file mode 100644 index 000000000..227195a40 --- /dev/null +++ b/v2/gamestate/errors.go @@ -0,0 +1,17 @@ +package gamestate + +import ( + "github.com/rotisserie/eris" +) + +var ( + ErrEntityDoesNotExist = eris.New("entity does not exist") + ErrComponentAlreadyOnEntity = eris.New("component already on entity") + ErrComponentNotOnEntity = eris.New("component not on entity") + ErrEntityMustHaveAtLeastOneComponent = eris.New("entities must have at least 1 component") + ErrComponentNotRegistered = eris.New("must register component") + + // ErrComponentMismatchWithSavedState is an error that is returned when a ComponentID from + // the saved state is not found in the passed in list of components. + ErrComponentMismatchWithSavedState = eris.New("registered components do not match with the saved state") +) diff --git a/v2/gamestate/finalized_state.go b/v2/gamestate/finalized_state.go new file mode 100644 index 000000000..abe97b497 --- /dev/null +++ b/v2/gamestate/finalized_state.go @@ -0,0 +1,310 @@ +package gamestate + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/redis/go-redis/v9" + "github.com/rotisserie/eris" + "pkg.world.dev/world-engine/cardinal/v2/codec" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type FinalizedState struct { + locked bool + + storage PrimitiveStorage[string] + compNameToComponent map[types.ComponentName]types.ComponentMetadata + // archetype component set is never modified, therefore we can safely cache it. + archIDToComps map[types.ArchetypeID][]types.ComponentMetadata // archID -> []comps +} + +var _ Reader = &FinalizedState{} + +func NewFinalizedState(storage PrimitiveStorage[string]) (*FinalizedState, error) { + r := &FinalizedState{ + locked: false, + + storage: storage, + compNameToComponent: map[types.ComponentName]types.ComponentMetadata{}, + archIDToComps: map[types.ArchetypeID][]types.ComponentMetadata{}, + } + return r, nil +} + +// init performs the initial load from Redis and locks ECB such that no new components can be registered. +func (m *FinalizedState) init() error { + if m.locked { + return eris.New("finalized state is already initialized") + } + + if err := m.loadArchetype(); err != nil { + return err + } + + // Lock FinalizedState to prevent new components from being registered + m.locked = true + + return nil +} + +func (m *FinalizedState) isComponentRegistered(compName types.ComponentName) bool { + _, ok := m.compNameToComponent[compName] + return ok +} + +func (m *FinalizedState) registerComponent(comp types.ComponentMetadata) error { + if m.locked { + return eris.New("unable to register components after FinalizedState is initialized") + } + + if _, ok := m.compNameToComponent[comp.Name()]; ok { + return eris.New("component already registered") + } + m.compNameToComponent[comp.Name()] = comp + + return nil +} + +func (m *FinalizedState) GetComponentForEntity(comp types.Component, id types.EntityID) (any, error) { + if err := m.checkInitialized(); err != nil { + return nil, err + } + + bz, err := m.GetComponentForEntityInRawJSON(comp, id) + if err != nil { + return nil, err + } + + cType, ok := m.compNameToComponent[comp.Name()] + if !ok { + return nil, ErrComponentNotRegistered + } + + return cType.Decode(bz) +} + +func (m *FinalizedState) GetComponentForEntityInRawJSON(comp types.Component, id types.EntityID) ( + json.RawMessage, error, +) { + if err := m.checkInitialized(); err != nil { + return nil, err + } + + err := m.checkComponentRegistered(comp) + if err != nil { + return nil, err + } + + return m.storage.GetBytes(context.Background(), storageComponentKey(comp.Name(), id)) +} + +// GetEntitiesForArchID returns all the entities that currently belong to the given archetype EntityID. +func (m *FinalizedState) GetEntitiesForArchID(archID types.ArchetypeID) ([]types.EntityID, error) { + if err := m.checkInitialized(); err != nil { + return nil, err + } + + active, err := m.getActiveEntities(archID) + if err != nil { + return nil, err + } + return active.ids, nil +} + +// FindArchetypes returns a list of archetype IDs that fulfill the given component filter. +func (m *FinalizedState) FindArchetypes(filter filter.ComponentFilter) ([]types.ArchetypeID, error) { + if err := m.checkInitialized(); err != nil { + return nil, err + } + + archetypes := make([]types.ArchetypeID, 0) + + err := m.loadArchetype() + if err != nil { + return nil, err + } + + for archID, comps := range m.archIDToComps { + if !filter.MatchesComponents(types.ConvertComponentMetadatasToComponents(comps)) { + continue + } + archetypes = append(archetypes, archID) + } + + return archetypes, nil +} + +// GetAllComponentsForEntityInRawJSON returns all components for the given entity in JSON format. +func (m *FinalizedState) GetAllComponentsForEntityInRawJSON(id types.EntityID) (map[string]json.RawMessage, error) { + if err := m.checkInitialized(); err != nil { + return nil, err + } + + comps, err := m.getComponentTypesForEntity(id) + if err != nil { + return nil, err + } + + result := map[string]json.RawMessage{} + + for _, comp := range comps { + value, err := m.GetComponentForEntityInRawJSON(comp, id) + if err != nil { + return nil, err + } + result[comp.Name()] = value + } + + return result, nil +} + +// getActiveEntities returns the entities that are currently assigned to the given archetype EntityID. +func (m *FinalizedState) getActiveEntities(archID types.ArchetypeID) (*activeEntities, error) { + bz, err := m.storage.GetBytes(context.Background(), storageActiveEntityIDKey(archID)) + if err != nil { + return nil, err + } + + var ids []types.EntityID + if err != nil { + // todo: this is redis specific, should be changed to a general error on storage + // todo: RedisStorage needs to be modified to return this general error when a redis.Nil is detected. + if !eris.Is(eris.Cause(err), redis.Nil) { + return nil, err + } + } else { + ids, err = codec.Decode[[]types.EntityID](bz) + if err != nil { + return nil, err + } + } + + entities := activeEntities{ + ids: ids, + modified: false, + } + + return &entities, nil +} + +// GetLastFinalizedTick returns the last tick that was successfully finalized. +// If the latest finalized tick is 0, it means that no tick has been finalized yet. +func (m *FinalizedState) GetLastFinalizedTick() (int64, error) { + if err := m.checkInitialized(); err != nil { + return 0, err + } + + tick, err := m.storage.GetInt64(context.Background(), storageLastFinalizedTickKey()) + if err != nil { + // If the returned error is redis.Nil, it means that the key does not exist yet. In this case, we can infer + // that the latest finalized tick is 0. If the return is not redis.Nil, it means that an actual error occurred. + if eris.Is(err, redis.Nil) { + tick = -1 + } else { + return 0, eris.Wrap(err, "failed to get latest finalized tick") + } + } + + return tick, nil +} + +func (m *FinalizedState) ArchetypeCount() (int, error) { + if err := m.checkInitialized(); err != nil { + return 0, err + } + + if err := m.loadArchetype(); err != nil { + return 0, err + } + + return len(m.archIDToComps), nil +} + +// getArchetypeForEntity returns the archetype EntityID for the given entity EntityID. +func (m *FinalizedState) getArchetypeForEntity(id types.EntityID) (types.ArchetypeID, error) { + // If the entity ID is not in the in-memory cache, fetch the archetype ID from Redis. + num, err := m.storage.GetInt(context.Background(), storageArchetypeIDForEntityID(id)) + if err != nil { + // todo: Make redis.Nil a general error on storage + if eris.Is(err, redis.Nil) { + return 0, eris.Wrap(redis.Nil, ErrEntityDoesNotExist.Error()) + } + return 0, err + } + + return types.ArchetypeID(num), nil +} + +// getComponentTypesForEntity returns all the component types that are currently on the given entity. Only types +// are returned. To get the actual component data, use GetComponentForEntity. +func (m *FinalizedState) getComponentTypesForEntity(id types.EntityID) ([]types.ComponentMetadata, error) { + archID, err := m.getArchetypeForEntity(id) + if err != nil { + return nil, nil + } + + err = m.loadArchetype() + if err != nil { + return nil, err + } + + return m.archIDToComps[archID], nil +} + +// loadArchetype returns a mapping that contains the corresponding components for a given archetype ID. +// In contrast to ECB, it's perfectly fine to reload the archetype cache since we are not tracking the working here. +func (m *FinalizedState) loadArchetype() error { + bz, err := m.storage.GetBytes(context.Background(), storageArchIDsToCompTypesKey()) + if err != nil { + // If no archetypes have been set, just terminate early. + if eris.Is(eris.Cause(err), redis.Nil) { + return nil + } + return err + } + + archetypes, err := codec.Decode[map[types.ArchetypeID][]types.ComponentName](bz) + if err != nil { + return err + } + + result := map[types.ArchetypeID][]types.ComponentMetadata{} + for archID, compNames := range archetypes { + var currComps []types.ComponentMetadata + + // Validate component schemas + for _, compName := range compNames { + fmt.Println(compName) + fmt.Println(m.compNameToComponent) + currComp, ok := m.compNameToComponent[compName] + if !ok { + return ErrComponentMismatchWithSavedState + } + currComps = append(currComps, currComp) + } + + result[archID] = currComps + } + + m.archIDToComps = result + + return nil +} + +func (m *FinalizedState) checkInitialized() error { + if !m.locked { + return eris.New("finalized state is not initialized") + } + return nil +} + +func (m *FinalizedState) checkComponentRegistered(comp types.Component) error { + _, ok := m.compNameToComponent[comp.Name()] + if !ok { + return eris.Wrap(ErrComponentNotRegistered, fmt.Sprintf("component %q is not registered", comp.Name())) + } + return nil +} diff --git a/v2/gamestate/finalized_state_test.go b/v2/gamestate/finalized_state_test.go new file mode 100644 index 000000000..deb6640d3 --- /dev/null +++ b/v2/gamestate/finalized_state_test.go @@ -0,0 +1,210 @@ +package gamestate_test + +import ( + "context" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/redis/go-redis/v9" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + redisstorage "pkg.world.dev/world-engine/cardinal/v2/storage/redis" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// newStateForTest creates a gamestate.EntityCommandBuffer using the given +// redis dbStorage. If the passed in redis +// dbStorage is nil, a redis dbStorage is world.Created. +func newStateForTest(t *testing.T, client *redis.Client) *gamestate.State { + if client == nil { + s := miniredis.RunT(t) + options := redis.Options{ + Addr: s.Addr(), + Password: "", // no password set + DB: 0, // use default DB + } + + client = redis.NewClient(&options) + } + + rs := redisstorage.NewRedisStorageWithClient(client, "test") + state, err := gamestate.New(&rs) + assert.NilError(t, err) + + fooComp, err := gamestate.NewComponentMetadata[Foo]() + assert.NilError(t, err) + barComp, err := gamestate.NewComponentMetadata[Bar]() + assert.NilError(t, err) + + assert.NilError(t, state.RegisterComponent(fooComp)) + assert.NilError(t, state.RegisterComponent(barComp)) + + assert.NilError(t, state.Init()) + + return state +} + +func TestFinalizedState_CanGetComponent(t *testing.T) { + state := newStateForTest(t, nil) + ecb, fs := state.ECB(), state.FinalizedState() + ctx := context.Background() + + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + + // A read-only operation here should NOT find the entity (because it hasn't been committed yet) + _, err = fs.GetComponentForEntity(Foo{}, id) + assert.Check(t, err != nil) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + _, err = fs.GetComponentForEntity(Foo{}, id) + assert.NilError(t, err) +} + +func TestFinalizedState_CanGetComponentTypesForEntityAndArchID(t *testing.T) { + state := newStateForTest(t, nil) + ecb, fs := state.ECB(), state.FinalizedState() + ctx := context.Background() + + testCases := []struct { + name string + comps []types.Component + }{ + { + "just foo", + []types.Component{Foo{}}, + }, + { + "just bar", + []types.Component{Bar{}}, + }, + { + "foo and bar", + []types.Component{Foo{}, Bar{}}, + }, + } + + for _, tc := range testCases { + id, err := ecb.CreateEntity(tc.comps...) + assert.NilError(t, err) + + gotComps, err := ecb.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + assert.Equal(t, len(gotComps), len(tc.comps)) + + for _, comp := range tc.comps { + compName := comp.Name() + _, ok := gotComps[compName] + assert.Check(t, ok, "component %q not found in entity %q", compName, id) + } + + assert.NilError(t, ecb.FinalizeTick(ctx)) + + gotComps, err = fs.GetAllComponentsForEntityInRawJSON(id) + assert.NilError(t, err) + assert.Equal(t, len(gotComps), len(tc.comps)) + + for _, comp := range tc.comps { + compName := comp.Name() + _, ok := gotComps[compName] + assert.Check(t, ok, "component %q not found in entity %q", compName, id) + } + } +} + +func TestFinalizedState_CanFindEntityIDAfterChangingArchetypes(t *testing.T) { + state := newStateForTest(t, nil) + ecb, fs := state.ECB(), state.FinalizedState() + ctx := context.Background() + + id, err := ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + gotIDs, err := search.New(fs, filter.Exact(Foo{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 1, len(gotIDs)) + assert.Equal(t, gotIDs[0], id) + + assert.NilError(t, ecb.AddComponentToEntity(Bar{}, id)) + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // There should be no more entities with JUST the foo componnet + gotIDs, err = search.New(fs, filter.Exact(Foo{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 0, len(gotIDs)) + + // There should be exactly one entity with both foo and bar + gotIDs, err = search.New(fs, filter.Exact(Foo{}, Bar{})).Collect() + assert.NilError(t, err) + assert.Equal(t, 1, len(gotIDs)) + assert.Equal(t, gotIDs[0], id) +} + +func TestFinalizedState_ArchetypeCount(t *testing.T) { + state := newStateForTest(t, nil) + ecb, fs := state.ECB(), state.FinalizedState() + ctx := context.Background() + + // No archetypes have been world.Created yet + archCount, err := fs.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 0, archCount) + + _, err = ecb.CreateEntity(Foo{}) + assert.NilError(t, err) + + // The manager knows about the new archetype + archCount, err = ecb.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 1, archCount) + // but the read-only manager is not aware of it yet + archCount, err = fs.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 0, archCount) + + assert.NilError(t, ecb.FinalizeTick(ctx)) + archCount, err = fs.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 1, archCount) + + _, err = ecb.CreateEntity(Foo{}, Bar{}) + assert.NilError(t, err) + assert.NilError(t, ecb.FinalizeTick(ctx)) + archCount, err = fs.ArchetypeCount() + assert.NilError(t, err) + assert.Equal(t, 2, archCount) +} + +func TestFinalizedState_FindArchetypes(t *testing.T) { + state := newStateForTest(t, nil) + ecb, fs := state.ECB(), state.FinalizedState() + ctx := context.Background() + + fs.FindArchetypes(filter.Contains(filter.Component[Health]())) + + _, err := ecb.CreateManyEntities(8, Foo{}) + assert.NilError(t, err) + _, err = ecb.CreateManyEntities(9, Bar{}) + assert.NilError(t, err) + _, err = ecb.CreateManyEntities(10, Foo{}, Bar{}) + assert.NilError(t, err) + + componentFilter := filter.Contains(filter.Component[Bar]()) + + // Before FinalizeTick is called, there should be no archetypes available to the read-only + // manager + archetypes, err := fs.FindArchetypes(componentFilter) + assert.Equal(t, 0, len(archetypes)) + + // Commit the archetypes to the DB + assert.NilError(t, ecb.FinalizeTick(ctx)) + + // Exactly 2 archetypes contain the Barcomponent + archetypes, err = fs.FindArchetypes(componentFilter) + assert.Equal(t, 2, len(archetypes)) +} diff --git a/v2/gamestate/iterators.go b/v2/gamestate/iterators.go new file mode 100644 index 000000000..b7243decd --- /dev/null +++ b/v2/gamestate/iterators.go @@ -0,0 +1,36 @@ +package gamestate + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type EntityIterator struct { + // current is the index of the current archetype id being iterated over + current int + // archIDs is the list of archetype ids that we want to iterate over + archIDs []types.ArchetypeID + // stateReader is an interface that allows us to read the current entity state + stateReader Reader +} + +// NewEntityIterator returns an iterator that iterates through a list of entities for the given archetype iterators. +func NewEntityIterator(stateReader Reader, archIDs []types.ArchetypeID) EntityIterator { + iterator := EntityIterator{ + current: 0, + archIDs: archIDs, + stateReader: stateReader, + } + return iterator +} + +// HasNext evaluates to true if there are still archetypes to iterate over. +func (it *EntityIterator) HasNext() bool { + return it.current < len(it.archIDs) +} + +// Next returns the next entity list based on the list of archetypes in archIds. +func (it *EntityIterator) Next() ([]types.EntityID, error) { + archID := it.archIDs[it.current] + it.current++ + return it.stateReader.GetEntitiesForArchID(archID) +} diff --git a/v2/gamestate/keys.go b/v2/gamestate/keys.go new file mode 100644 index 000000000..2fcf4c1b3 --- /dev/null +++ b/v2/gamestate/keys.go @@ -0,0 +1,44 @@ +package gamestate + +import ( + "fmt" + + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// storageComponentKey is the key that maps an entity ID and a specific component ID to the value of that component. +func storageComponentKey(compName types.ComponentName, id types.EntityID) string { + return fmt.Sprintf("ECB:COMPONENT-VALUE:TYPE-ID-%s:ENTITY-ID-%d", compName, id) +} + +// storageNextEntityIDKey is the key that stores the next available entity ID that can be assigned to a newly created +// entity. +func storageNextEntityIDKey() string { + return "ECB:NEXT-ENTITY-ID" +} + +// storageArchetypeIDForEntityID is the key that maps a specific entity ID to its archetype ID. +// Note, this key and storageActiveEntityIDKey represent the same information. +// This maps entity.ID -> archetype.ID. +func storageArchetypeIDForEntityID(id types.EntityID) string { + return fmt.Sprintf("ECB:ARCHETYPE-ID:ENTITY-ID-%d", id) +} + +// storageActiveEntityIDKey is the key that maps an archetype ID to all the entities that currently belong +// to the archetype ID. +// Note, this key and storageArchetypeIDForEntityID represent the same information. +// This maps archetype.ID -> []entity.ID. +func storageActiveEntityIDKey(archID types.ArchetypeID) string { + return fmt.Sprintf("ECB:ACTIVE-ENTITY-IDS:ARCHETYPE-ID-%d", archID) +} + +// storageArchIDsToCompTypesKey is the key that stores the map of archetype IDs to its relevant set of component types +// (in the form of []component.ID). To recover the actual ComponentMetadata information, a slice of active +// ComponentMetadata must be used. +func storageArchIDsToCompTypesKey() string { + return "ECB:ARCHETYPE-ID-TO-COMPONENT-TYPES" +} + +func storageLastFinalizedTickKey() string { + return "ECB:LAST-FINALIZED-TICK" +} diff --git a/v2/gamestate/map.go b/v2/gamestate/map.go new file mode 100644 index 000000000..97cd6e0c5 --- /dev/null +++ b/v2/gamestate/map.go @@ -0,0 +1,54 @@ +package gamestate + +import ( + "github.com/rotisserie/eris" +) + +var _ VolatileStorage[string, any] = &MapStorage[string, any]{} + +var ErrNotFound = eris.New("key not found in map") + +type MapStorage[K comparable, V any] struct { + internalMap map[K]V +} + +func NewMapStorage[K comparable, V any]() *MapStorage[K, V] { + return &MapStorage[K, V]{ + internalMap: make(map[K]V), + } +} + +func (m *MapStorage[K, V]) Keys() ([]K, error) { + acc := make([]K, 0, len(m.internalMap)) + for k := range m.internalMap { + acc = append(acc, k) + } + return acc, nil +} + +func (m *MapStorage[K, V]) Delete(key K) error { + delete(m.internalMap, key) + return nil +} + +func (m *MapStorage[K, V]) Get(key K) (V, error) { + v, ok := m.internalMap[key] + if !ok { + return v, eris.Wrap(ErrNotFound, "") + } + return v, nil +} + +func (m *MapStorage[K, V]) Set(key K, value V) error { + m.internalMap[key] = value + return nil +} + +func (m *MapStorage[K, V]) Clear() error { + m.internalMap = make(map[K]V) + return nil +} + +func (m *MapStorage[K, V]) Len() int { + return len(m.internalMap) +} diff --git a/v2/gamestate/primitivestorage.go b/v2/gamestate/primitivestorage.go new file mode 100644 index 000000000..3be385018 --- /dev/null +++ b/v2/gamestate/primitivestorage.go @@ -0,0 +1,27 @@ +package gamestate + +import ( + "context" +) + +// PrimitiveStorage is the interface for all available stores related to the game loop +// there is another store like interface for other logistical values located in `ecs.storage` +type PrimitiveStorage[K comparable] interface { + GetFloat64(ctx context.Context, key K) (float64, error) + GetFloat32(ctx context.Context, key K) (float32, error) + GetUInt64(ctx context.Context, key K) (uint64, error) + GetInt64(ctx context.Context, key K) (int64, error) + GetInt(ctx context.Context, key K) (int, error) + GetBool(ctx context.Context, key K) (bool, error) + GetBytes(ctx context.Context, key K) ([]byte, error) + Get(ctx context.Context, key K) (any, error) + Set(ctx context.Context, key K, value any) error + Incr(ctx context.Context, key K) error + Decr(ctx context.Context, key K) error + Delete(ctx context.Context, key K) error + StartTransaction(ctx context.Context) (PrimitiveStorage[K], error) + EndTransaction(ctx context.Context) error + Close(ctx context.Context) error + Clear(ctx context.Context) error + Keys(ctx context.Context) ([]K, error) +} diff --git a/v2/gamestate/redis.go b/v2/gamestate/redis.go new file mode 100644 index 000000000..63830b870 --- /dev/null +++ b/v2/gamestate/redis.go @@ -0,0 +1,145 @@ +package gamestate + +import ( + "context" + + "github.com/redis/go-redis/v9" + "github.com/rotisserie/eris" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" +) + +var _ PrimitiveStorage[string] = &RedisStorage{} + +type RedisStorage struct { + currentClient redis.Cmdable + tracer trace.Tracer +} + +func NewRedisPrimitiveStorage(client redis.Cmdable) PrimitiveStorage[string] { + return &RedisStorage{ + currentClient: client, + tracer: otel.Tracer("redis"), + } +} + +func (r *RedisStorage) GetFloat64(ctx context.Context, key string) (float64, error) { + res, err := r.currentClient.Get(ctx, key).Float64() + if err != nil { + return 0, eris.Wrap(err, "") + } + return res, nil +} +func (r *RedisStorage) GetFloat32(ctx context.Context, key string) (float32, error) { + res, err := r.currentClient.Get(ctx, key).Float32() + if err != nil { + return 0, eris.Wrap(err, "") + } + return res, nil +} +func (r *RedisStorage) GetUInt64(ctx context.Context, key string) (uint64, error) { + res, err := r.currentClient.Get(ctx, key).Uint64() + if err != nil { + return 0, eris.Wrap(err, "") + } + return res, nil +} + +func (r *RedisStorage) GetInt64(ctx context.Context, key string) (int64, error) { + res, err := r.currentClient.Get(ctx, key).Int64() + if err != nil { + return 0, eris.Wrap(err, "") + } + return res, nil +} + +func (r *RedisStorage) GetInt(ctx context.Context, key string) (int, error) { + res, err := r.currentClient.Get(ctx, key).Int() + if err != nil { + return 0, eris.Wrap(err, "") + } + return res, nil +} + +func (r *RedisStorage) GetBool(ctx context.Context, key string) (bool, error) { + res, err := r.currentClient.Get(ctx, key).Bool() + if err != nil { + return false, eris.Wrap(err, "") + } + return res, nil +} + +func (r *RedisStorage) GetBytes(ctx context.Context, key string) ([]byte, error) { + bz, err := r.currentClient.Get(ctx, key).Bytes() + if err != nil { + return nil, eris.Wrap(err, "") + } + return bz, nil +} + +func (r *RedisStorage) Set(ctx context.Context, key string, value any) error { + return eris.Wrap(r.currentClient.Set(ctx, key, value, 0).Err(), "") +} + +// Underlying type is a string. Unfortunately this is the way redis works and this is the most generic return value. +func (r *RedisStorage) Get(ctx context.Context, key string) (any, error) { + var res any + var err error + res, err = r.currentClient.Get(ctx, key).Result() + return res, eris.Wrap(err, "") +} + +func (r *RedisStorage) Incr(ctx context.Context, key string) error { + return eris.Wrap(r.currentClient.Incr(ctx, key).Err(), "") +} + +func (r *RedisStorage) Decr(ctx context.Context, key string) error { + return eris.Wrap(r.currentClient.Decr(ctx, key).Err(), "") +} + +func (r *RedisStorage) Delete(ctx context.Context, key string) error { + return eris.Wrap(r.currentClient.Del(ctx, key).Err(), "") +} + +func (r *RedisStorage) Close(ctx context.Context) error { + return eris.Wrap(r.currentClient.Shutdown(ctx).Err(), "") +} + +func (r *RedisStorage) Keys(ctx context.Context) ([]string, error) { + return r.currentClient.Keys(ctx, "*").Result() +} + +func (r *RedisStorage) Clear(ctx context.Context) error { + return eris.Wrap(r.currentClient.FlushAll(ctx).Err(), "") +} + +func (r *RedisStorage) StartTransaction(_ context.Context) (PrimitiveStorage[string], error) { + pipeline := r.currentClient.TxPipeline() + redisTransaction := NewRedisPrimitiveStorage(pipeline) + return redisTransaction, nil +} + +func (r *RedisStorage) EndTransaction(ctx context.Context) error { + ctx, span := r.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "redis.transaction.end") + defer span.End() + + pipeline, ok := r.currentClient.(redis.Pipeliner) + if !ok { + err := eris.New("current redis dbStorage is not a pipeline/transaction") + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return err + } + + _, err := pipeline.Exec(ctx) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return err + } + + return nil +} diff --git a/v2/gamestate/schema_storage.go b/v2/gamestate/schema_storage.go new file mode 100644 index 000000000..a7761f2e8 --- /dev/null +++ b/v2/gamestate/schema_storage.go @@ -0,0 +1,6 @@ +package gamestate + +type SchemaStorage interface { + GetSchema(componentName string) ([]byte, error) + SetSchema(componentName string, schemaData []byte) error +} diff --git a/v2/gamestate/search/filter/all.go b/v2/gamestate/search/filter/all.go new file mode 100644 index 000000000..5c1e6d0d9 --- /dev/null +++ b/v2/gamestate/search/filter/all.go @@ -0,0 +1,16 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type all struct { +} + +func All() ComponentFilter { + return &all{} +} + +func (f *all) MatchesComponents(_ []types.Component) bool { + return true +} diff --git a/v2/gamestate/search/filter/and.go b/v2/gamestate/search/filter/and.go new file mode 100644 index 000000000..7e1f31655 --- /dev/null +++ b/v2/gamestate/search/filter/and.go @@ -0,0 +1,22 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type and struct { + filters []ComponentFilter +} + +func And(filters ...ComponentFilter) ComponentFilter { + return &and{filters: filters} +} + +func (f *and) MatchesComponents(components []types.Component) bool { + for _, filter := range f.filters { + if !filter.MatchesComponents(components) { + return false + } + } + return true +} diff --git a/v2/gamestate/search/filter/contains.go b/v2/gamestate/search/filter/contains.go new file mode 100644 index 000000000..952fe6384 --- /dev/null +++ b/v2/gamestate/search/filter/contains.go @@ -0,0 +1,24 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type contains struct { + components []types.Component +} + +// Contains matches archetypes that contain all the components specified. +func Contains(components ...types.Component) ComponentFilter { + return &contains{components: components} +} + +func (f *contains) MatchesComponents(components []types.Component) bool { + matchComponent := CreateComponentMatcher(components) + for _, componentType := range f.components { + if !matchComponent(componentType) { + return false + } + } + return true +} diff --git a/v2/gamestate/search/filter/exact.go b/v2/gamestate/search/filter/exact.go new file mode 100644 index 000000000..6ce3c704c --- /dev/null +++ b/v2/gamestate/search/filter/exact.go @@ -0,0 +1,27 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type exact struct { + components []types.Component +} + +// Exact matches archetypes that contain exactly the same components specified. +func Exact(components ...types.Component) ComponentFilter { + return exact{components: components} +} + +func (f exact) MatchesComponents(components []types.Component) bool { + if len(components) != len(f.components) { + return false + } + matchComponent := CreateComponentMatcher(f.components) + for _, componentType := range components { + if !matchComponent(componentType) { + return false + } + } + return true +} diff --git a/v2/gamestate/search/filter/filter.go b/v2/gamestate/search/filter/filter.go new file mode 100644 index 000000000..73ebb8372 --- /dev/null +++ b/v2/gamestate/search/filter/filter.go @@ -0,0 +1,40 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// ComponentFilter is a filter that filters entities based on their components. +type ComponentFilter interface { + // MatchesComponents returns true if the entity matches the filter. + MatchesComponents(components []types.Component) bool +} + +type componentWrapper struct { + types.Component + name string +} + +var _ types.Component = componentWrapper{} + +func (c componentWrapper) Name() string { + return c.name +} + +// Component is public but contains an unexported return type +// this is done with intent as the user should never use componentWrapper +// explicitly. +// +//revive:disable-next-line:unexported-return +func Component[T types.Component]() componentWrapper { + var t T + return componentWrapper{ + name: t.Name(), + } +} + +func ComponentWithName(name string) componentWrapper { + return componentWrapper{ + name: name, + } +} diff --git a/v2/gamestate/search/filter/filter_test.go b/v2/gamestate/search/filter/filter_test.go new file mode 100644 index 000000000..8f91141d8 --- /dev/null +++ b/v2/gamestate/search/filter/filter_test.go @@ -0,0 +1,186 @@ +package filter_test + +import ( + "fmt" + "testing" + + "github.com/rs/zerolog" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type Alpha struct{} + +func (Alpha) Name() string { return "alpha" } + +type Beta struct{} + +func (Beta) Name() string { return "beta" } + +type Gamma struct{} + +func (Gamma) Name() string { return "gamma" } + +func TestGetEverythingFilter(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[Alpha](tf.World())) + assert.NilError(t, world.RegisterComponent[Beta](tf.World())) + assert.NilError(t, world.RegisterComponent[Gamma](tf.World())) + + subsetCount := 50 + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + _, err := world.CreateMany(wCtx, subsetCount, Alpha{}, Beta{}) + assert.NilError(t, err) + _, err = world.CreateMany(wCtx, 20, Alpha{}, Beta{}, Gamma{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + // Loop over every entity. There should + // only be 50 + 20 entities. + count, err := tf.Cardinal.World().Search(filter.All()).Count() + assert.NilError(t, err) + assert.Equal(t, count, subsetCount+20) +} + +func TestCanFilterByArchetype(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[Alpha](tf.World())) + assert.NilError(t, world.RegisterComponent[Beta](tf.World())) + assert.NilError(t, world.RegisterComponent[Gamma](tf.World())) + + subsetCount := 50 + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + _, err := world.CreateMany(wCtx, subsetCount, Alpha{}, Beta{}) + assert.NilError(t, err) + // Make some entities that have all 3 component. + _, err = world.CreateMany(wCtx, 20, Alpha{}, Beta{}, Gamma{}) + assert.NilError(t, err) + + return nil + })) + + tf.StartWorld() + tf.DoTick() + + // Loop over every entity that has exactly the alpha and beta components. There should + // only be subsetCount entities. + count, err := tf.Cardinal.World().Search(filter.Exact(Alpha{}, Beta{})).Count() + assert.NilError(t, err) + assert.Equal(t, count, subsetCount) +} + +func TestExactVsContains(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[Alpha](tf.World())) + assert.NilError(t, world.RegisterComponent[Beta](tf.World())) + + alphaCount := 75 + alphaBetaCount := 100 + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + _, err := world.CreateMany(wCtx, alphaCount, Alpha{}) + assert.NilError(t, err) + _, err = world.CreateMany(wCtx, alphaBetaCount, Alpha{}, Beta{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + // Contains(alpha) should return all entities + count, err := tf.Cardinal.World().Search(filter.Contains(Alpha{})).Count() + assert.NilError(t, err) + assert.Equal(t, count, alphaCount+alphaBetaCount) + + // Exact(alpha, beta) should not return the entities that only have alpha + count2, err := tf.Cardinal.World().Search(filter.Exact(Alpha{}, Beta{})).Count() + assert.NilError(t, err) + assert.Equal(t, count2, alphaBetaCount) + + // Contains(beta) should only return the entities that have both components + count3, err := tf.Cardinal.World().Search(filter.Contains(Beta{})).Count() + assert.NilError(t, err) + assert.Equal(t, count3, alphaBetaCount) + + // Exact(alpha) should not return the entities that have both alpha and beta + count4, err := tf.Cardinal.World().Search(filter.Exact(Alpha{})).Count() + assert.NilError(t, err) + assert.Equal(t, count4, alphaCount) +} + +func TestCanGetArchetypeFromEntity(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[Alpha](tf.World())) + assert.NilError(t, world.RegisterComponent[Beta](tf.World())) + + alphaBetaCount := 50 + alphaCount := 20 + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + _, err := world.CreateMany(wCtx, alphaBetaCount, Alpha{}, Beta{}) + assert.NilError(t, err) + _, err = world.CreateMany(wCtx, alphaCount, Alpha{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + count, err := tf.Cardinal.World().Search(filter.Exact(Alpha{}, Beta{})).Count() + assert.NilError(t, err) + assert.Equal(t, count, alphaBetaCount) + + count2, err := tf.Cardinal.World().Search(filter.Exact(Alpha{})).Count() + assert.NilError(t, err) + assert.Equal(t, count2, alphaCount) +} + +// BenchmarkFilterByArchetypeIsNotImpactedByTotalEntityCount verifies that the time it takes to filter +// by a specific archetype depends on the number of entities that have that archetype and NOT the +// total number of entities that have been world.Created. +func BenchmarkFilterByArchetypeIsNotImpactedByTotalEntityCount(b *testing.B) { + relevantCount := 100 + zerolog.SetGlobalLevel(zerolog.Disabled) + for i := 10; i <= 10000; i *= 10 { + ignoreCount := i + b.Run( + fmt.Sprintf("IgnoreCount:%d", ignoreCount), func(b *testing.B) { + helperArchetypeFilter(b, relevantCount, ignoreCount) + }, + ) + } +} + +func helperArchetypeFilter(b *testing.B, relevantCount, ignoreCount int) { + b.StopTimer() + tf := cardinal.NewTestCardinal(b, nil) + assert.NilError(b, world.RegisterComponent[Alpha](tf.World())) + assert.NilError(b, world.RegisterComponent[Beta](tf.World())) + + assert.NilError(b, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + _, err := world.CreateMany(wCtx, relevantCount, Alpha{}, Beta{}) + assert.NilError(b, err) + _, err = world.CreateMany(wCtx, ignoreCount, Alpha{}) + assert.NilError(b, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + b.StartTimer() + for i := 0; i < b.N; i++ { + count, err := tf.Cardinal.World().Search(filter.Exact(Alpha{}, Beta{})).Count() + assert.NilError(b, err) + assert.Equal(b, count, relevantCount) + } +} diff --git a/v2/gamestate/search/filter/helper.go b/v2/gamestate/search/filter/helper.go new file mode 100644 index 000000000..c936e0bb3 --- /dev/null +++ b/v2/gamestate/search/filter/helper.go @@ -0,0 +1,19 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// CreateComponentMatcher creates a function given a slice of components. This function will +// take a parameter that is a single component and return true if it is in the slice of components +// or false otherwise +func CreateComponentMatcher(components []types.Component) func(types.Component) bool { + mapStringToComponent := make(map[string]types.Component, len(components)) + for _, component := range components { + mapStringToComponent[component.Name()] = component + } + return func(cType types.Component) bool { + _, ok := mapStringToComponent[cType.Name()] + return ok + } +} diff --git a/v2/gamestate/search/filter/not.go b/v2/gamestate/search/filter/not.go new file mode 100644 index 000000000..8554eb345 --- /dev/null +++ b/v2/gamestate/search/filter/not.go @@ -0,0 +1,17 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type not struct { + filter ComponentFilter +} + +func (f *not) MatchesComponents(components []types.Component) bool { + return !f.filter.MatchesComponents(components) +} + +func Not(filter ComponentFilter) ComponentFilter { + return ¬{filter: filter} +} diff --git a/v2/gamestate/search/filter/or.go b/v2/gamestate/search/filter/or.go new file mode 100644 index 000000000..60d03b311 --- /dev/null +++ b/v2/gamestate/search/filter/or.go @@ -0,0 +1,22 @@ +package filter + +import ( + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type or struct { + filters []ComponentFilter +} + +func Or(filters ...ComponentFilter) ComponentFilter { + return &or{filters: filters} +} + +func (f *or) MatchesComponents(components []types.Component) bool { + for _, filter := range f.filters { + if filter.MatchesComponents(components) { + return true + } + } + return false +} diff --git a/v2/gamestate/search/search.go b/v2/gamestate/search/search.go new file mode 100644 index 000000000..8fd098a04 --- /dev/null +++ b/v2/gamestate/search/search.go @@ -0,0 +1,228 @@ +package search + +import ( + "math" + "slices" + + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +var nonFatalError = []error{ + gamestate.ErrEntityDoesNotExist, + gamestate.ErrComponentNotOnEntity, + gamestate.ErrComponentAlreadyOnEntity, + gamestate.ErrEntityMustHaveAtLeastOneComponent, +} + +const badEntityID types.EntityID = math.MaxUint64 + +type CallbackFn func(types.EntityID) bool + +// Search allows you to find a set of entities that match a given criteria. Entities are first filtered by their +// components defined in componentFilter, and then filtered by an arbitrary user-defined filter defined in whereFilter. +type Search struct { + stateReader gamestate.Reader + + // componentFilter defines our entitity component criteria. + componentFilter filter.ComponentFilter + + // whereFilter is an arbitrary user-defined filter that can be evaluated to filter entities. + whereFilter FilterFn +} + +// New allows users to create a Search object with a filter already provided +// as a property. +func New(stateReader gamestate.Reader, compFilter filter.ComponentFilter) *Search { + return &Search{ + stateReader: stateReader, + componentFilter: compFilter, + whereFilter: nil, + } +} + +// Where Once the where clause method is activated the search will ONLY return results +// if a where clause returns true and no error. +func (s *Search) Where(whereFn func(id types.EntityID) (bool, error)) *Search { + var whereFilter FilterFn + + // A where clause can be chained with another where clause. If the where clause is not nil, we need to chain it. + if s.whereFilter != nil { + whereFilter = andFilter(s.whereFilter, whereFn) + } else { + whereFilter = whereFn + } + + return &Search{ + stateReader: s.stateReader, + componentFilter: s.componentFilter, + whereFilter: whereFilter, + } +} + +// Each iterates over all entities that match the search. +// If you would like to stop the iteration, return false to the callback. To continue iterating, return true. +func (s *Search) Each(callback CallbackFn) (err error) { + defer panicOnFatalError(err) + + archetypes, err := s.findArchetypes() + if err != nil { + return err + } + + entities := gamestate.NewEntityIterator(s.stateReader, archetypes) + + for entities.HasNext() { + entities, err := entities.Next() + if err != nil { + return err + } + + for _, id := range entities { + // Entity is eligible until proven otherwise + entityEligible := true + + if s.whereFilter != nil { + entityEligible, err = s.whereFilter(id) + if err != nil { + continue + } + } + + if entityEligible { + if cont := callback(id); !cont { + return nil + } + } + } + } + + return nil +} + +// First returns the first entity that matches the search. +func (s *Search) First() (id types.EntityID, err error) { + defer panicOnFatalError(err) + + archetypes, err := s.findArchetypes() + if err != nil { + return badEntityID, err + } + + entities := gamestate.NewEntityIterator(s.stateReader, archetypes) + if !entities.HasNext() { + return badEntityID, eris.New("no entities for the given criteria found") + } + + for entities.HasNext() { + entities, err := entities.Next() + if err != nil { + return 0, err + } + + for _, id := range entities { + // Entity is eligible until proven otherwise + entityEligible := true + + if s.whereFilter != nil { + entityEligible, err = s.whereFilter(id) + if err != nil { + continue + } + } + + if entityEligible { + return id, nil + } + } + } + + return badEntityID, nil +} + +func (s *Search) MustFirst() types.EntityID { + id, err := s.First() + if err != nil { + panic("no entity matches the search") + } + return id +} + +// Count returns the number of entities that match the search. +func (s *Search) Count() (ret int, err error) { + defer panicOnFatalError(err) + + archetypes, err := s.findArchetypes() + if err != nil { + return 0, err + } + + entities := gamestate.NewEntityIterator(s.stateReader, archetypes) + for entities.HasNext() { + entities, err := entities.Next() + if err != nil { + return 0, err + } + + for _, id := range entities { + // Entity is eligible until proven otherwise + entityEligible := true + + if s.whereFilter != nil { + entityEligible, err = s.whereFilter(id) + if err != nil { + continue + } + } + + if entityEligible { + ret++ + } + } + } + return ret, nil +} + +func (s *Search) Collect() ([]types.EntityID, error) { + acc := make([]types.EntityID, 0) + + err := s.Each(func(id types.EntityID) bool { + acc = append(acc, id) + return true + }) + if err != nil { + return nil, err + } + + fastSortIDs(acc) + return acc, nil +} + +func (s *Search) findArchetypes() ([]types.ArchetypeID, error) { + return s.stateReader.FindArchetypes(s.componentFilter) +} + +func fastSortIDs(ids []types.EntityID) { + slices.Sort(ids) +} + +// panicOnFatalError is a helper function to panic on non-deterministic errors (i.e. Redis error). +func panicOnFatalError(err error) { + if err != nil && isFatalError(err) { + log.Logger.Panic().Err(err).Msgf("fatal error: %v", eris.ToString(err, true)) + panic(err) + } +} + +func isFatalError(err error) bool { + for _, e := range nonFatalError { + if eris.Is(err, e) { + return false + } + } + return true +} diff --git a/v2/gamestate/search/search_filter.go b/v2/gamestate/search/search_filter.go new file mode 100644 index 000000000..5096b0399 --- /dev/null +++ b/v2/gamestate/search/search_filter.go @@ -0,0 +1,45 @@ +package search + +import ( + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type FilterFn func(id types.EntityID) (bool, error) + +func andFilter(fns ...FilterFn) FilterFn { + return func(id types.EntityID) (bool, error) { + for _, fn := range fns { + passedFilter, err := fn(id) + if err != nil { + return false, eris.Wrap(err, "an error occured while evaluating a filter") + } + + // In an andFilter, if any of the filters return false, the whole filter returns false. + if !passedFilter { + return false, nil + } + } + + return true, nil + } +} + +func orFilter(fns ...FilterFn) FilterFn { + return func(id types.EntityID) (bool, error) { + for _, fn := range fns { + passedFilter, err := fn(id) + if err != nil { + return false, eris.Wrap(err, "an error occured while evaluating a filter") + } + + // In an orFilter, if any of the filters return true, the whole filter returns true. + if passedFilter { + return true, nil + } + } + + return false, nil + } +} diff --git a/v2/gamestate/search/search_test.go b/v2/gamestate/search/search_test.go new file mode 100644 index 000000000..f83b9df71 --- /dev/null +++ b/v2/gamestate/search/search_test.go @@ -0,0 +1,370 @@ +package search_test + +import ( + "testing" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +var _ types.Component + +type AlphaTest struct { + Name1 string +} +type BetaTest struct { + Name1 string +} +type GammaTest struct { + Name1 string +} + +type Player struct { + Player string +} + +type Vampire struct { + Vampire bool +} + +type HP struct { + Amount int +} + +func (p Player) Name() string { + return "Player" +} + +func (a HP) Name() string { + return "HP" +} + +func (v Vampire) Name() string { + return "Vampire" +} + +func (AlphaTest) Name() string { + return "alpha" +} + +func (BetaTest) Name() string { + return "beta" +} + +func (GammaTest) Name() string { + return "gamma" +} + +func TestSearchUsingAllMethods(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[AlphaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[BetaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[GammaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[HP](tf.World())) + + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(ctx world.WorldContext) error { + _, err := world.CreateMany(ctx, 10, AlphaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, GammaTest{}) + assert.NilError(t, err) + + hpids, err := world.CreateMany(ctx, 10, HP{}) + assert.NilError(t, err) + for i, id := range hpids { + err = world.SetComponent[HP](ctx, id, &HP{Amount: i}) + assert.NilError(t, err) + } + return nil + })) + + tf.StartWorld() + tf.DoTick() + + err := tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + amt, err := wCtx.Search(filter.Not(filter.Or( + filter.Contains(filter.Component[AlphaTest]()), + filter.Contains(filter.Component[BetaTest]()), + filter.Contains(filter.Component[GammaTest]())), + )).Where(func(_ types.EntityID) (bool, error) { + return true, nil + }).Count() + assert.NilError(t, err) + assert.Equal(t, amt, 10) + + q := wCtx.Search(filter.Not(filter.Or( + filter.Contains(filter.Component[AlphaTest]()), + filter.Contains(filter.Component[BetaTest]()), + filter.Contains(filter.Component[GammaTest]())), + )).Where(func(id types.EntityID) (bool, error) { + c, err := world.GetComponent[HP](wCtx, id) + if err != nil { + return false, err + } + if c.Amount < 3 { + return true, nil + } + return false, nil + }) + + amt, err = q.Count() + assert.NilError(t, err) + assert.Equal(t, amt, 3) + + ids, err := q.Collect() + assert.NilError(t, err) + assert.True(t, areIDsSorted(ids)) + return nil + }) + assert.NilError(t, err) +} + +func areIDsSorted(ids []types.EntityID) bool { + for index, id := range ids { + if index < len(ids)-1 { + if id <= ids[index+1] { + continue + } + return false + } + } + return true +} + +func TestSearch_Integration(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[AlphaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[BetaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[GammaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[Player](tf.World())) + assert.NilError(t, world.RegisterComponent[Vampire](tf.World())) + assert.NilError(t, world.RegisterComponent[HP](tf.World())) + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(ctx world.WorldContext) error { + _, err := world.CreateMany(ctx, 10, AlphaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 1, &Player{Player: "AnyName"}, &Vampire{Vampire: true}, &HP{Amount: 0}) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + testCases := []struct { + name string + search *search.Search + want int + }{ + { + "does not have alpha", + wCtx.Search(filter.Not(filter.Contains(AlphaTest{}))). + Where(func(_ types.EntityID) (bool, error) { + return true, nil + }), + 31, + }, + { + "exactly alpha", + wCtx.Search(filter.Exact(AlphaTest{})), + 10, + }, + { + "contains alpha", + wCtx.Search(filter.Contains(AlphaTest{})), + 30, + }, + { + "all", + wCtx.Search(filter.All()), + 61, + }, + } + for _, tc := range testCases { + msg := "problem with " + tc.name + var count int + count, err := tc.search.Count() + assert.NilError(t, err, msg) + assert.Equal(t, tc.want, count, msg) + } + + amount, err := wCtx.Search(filter.Exact( + filter.Component[AlphaTest](), + filter.Component[BetaTest]())). + Where(func(_ types.EntityID) (bool, error) { + return false, nil + }).Count() + assert.NilError(t, err) + assert.Equal(t, amount, 0) + return nil + }) +} + +func TestSearch_Exact_ReturnsExactComponentMatch(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[AlphaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[BetaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[GammaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[Player](tf.World())) + assert.NilError(t, world.RegisterComponent[Vampire](tf.World())) + assert.NilError(t, world.RegisterComponent[HP](tf.World())) + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(ctx world.WorldContext) error { + _, err := world.CreateMany(ctx, 10, AlphaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 12, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + amt, err := wCtx.Search(filter.Exact(filter.Component[BetaTest]())).Count() + assert.NilError(t, err) + assert.Equal(t, amt, 12) + return nil + }) +} + +func TestSearch_Contains_ReturnsEntityThatContainsComponents(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[AlphaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[BetaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[GammaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[Player](tf.World())) + assert.NilError(t, world.RegisterComponent[Vampire](tf.World())) + assert.NilError(t, world.RegisterComponent[HP](tf.World())) + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(ctx world.WorldContext) error { + _, err := world.CreateMany(ctx, 10, AlphaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 12, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + amt, err := wCtx.Search(filter.Contains(filter.Component[BetaTest]())).Count() + assert.NilError(t, err) + assert.Equal(t, amt, 42) + return nil + }) +} + +func TestSearch_ComponentNotRegistered_ReturnsZeroEntityWithNoError(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[AlphaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[BetaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[GammaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[Player](tf.World())) + assert.NilError(t, world.RegisterComponent[Vampire](tf.World())) + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(ctx world.WorldContext) error { + _, err := world.CreateMany(ctx, 10, AlphaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 12, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + amt, err := wCtx.Search(filter.Contains(HP{})).Count() + assert.NilError(t, err) + assert.Equal(t, amt, 0) + return nil + }) +} + +func TestWhereClauseOnSearch(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[AlphaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[BetaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[GammaTest](tf.World())) + assert.NilError(t, world.RegisterComponent[Player](tf.World())) + assert.NilError(t, world.RegisterComponent[Vampire](tf.World())) + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(ctx world.WorldContext) error { + _, err := world.CreateMany(ctx, 10, AlphaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 12, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + _, err = world.CreateMany(ctx, 10, AlphaTest{}, BetaTest{}, GammaTest{}) + assert.NilError(t, err) + return nil + })) + + tf.StartWorld() + tf.DoTick() + + err := tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + amt, err := wCtx.Search(filter.All()).Where(func(id types.EntityID) (bool, error) { + _, err := world.GetComponent[AlphaTest](wCtx, id) + if err != nil { + return false, err + } + return true, nil + }).Count() + assert.NilError(t, err) + assert.Equal(t, amt, 40) + return nil + }) + assert.NilError(t, err) +} diff --git a/v2/gamestate/state.go b/v2/gamestate/state.go new file mode 100644 index 000000000..6a37c255b --- /dev/null +++ b/v2/gamestate/state.go @@ -0,0 +1,147 @@ +package gamestate + +import ( + "fmt" + "reflect" + + "github.com/goccy/go-json" + "github.com/rotisserie/eris" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/storage/redis" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type Reader interface { + GetComponentForEntity(comp types.Component, id types.EntityID) (any, error) + GetComponentForEntityInRawJSON(comp types.Component, id types.EntityID) (json.RawMessage, error) + GetAllComponentsForEntityInRawJSON(id types.EntityID) (map[string]json.RawMessage, error) + GetEntitiesForArchID(archID types.ArchetypeID) ([]types.EntityID, error) + FindArchetypes(filter filter.ComponentFilter) ([]types.ArchetypeID, error) + ArchetypeCount() (int, error) +} + +type State struct { + storage *redis.Storage + + ecb EntityCommandBuffer + finalizedState FinalizedState + + nextComponentID types.ComponentID +} + +func New(storage *redis.Storage) (*State, error) { + kv := NewRedisPrimitiveStorage(storage.Client) + + ecb, err := NewEntityCommandBuffer(kv) + if err != nil { + return nil, err + } + + finalizedState, err := NewFinalizedState(kv) + if err != nil { + return nil, err + } + + return &State{ + storage: storage, + + ecb: *ecb, + finalizedState: *finalizedState, + + nextComponentID: 1, + }, nil +} + +// RegisteredComponents returns the metadata of all registered components. +func (s *State) RegisteredComponents() []types.ComponentInfo { + comps := make([]types.ComponentInfo, 0, len(s.ecb.compNameToComponent)) + for _, comp := range s.ecb.compNameToComponent { + comps = append(comps, types.ComponentInfo{ + Name: comp.Name(), + Fields: types.GetFieldInformation(reflect.TypeOf(comp)), + }) + } + return comps +} + +// RegisterComponent registers component with the component manager. +// There can only be one component with a given name, which is declared by the user by implementing the Name() method. +// If there is a duplicate component name, an error will be returned and the component will not be registered. +func (s *State) RegisterComponent(compMetadata types.ComponentMetadata) error { + // Check that the component is not already registered + // Technically, you only need to check one since we always register components together in both ECB and + // FinalizedState, but we're being extra cautious here. + if s.ecb.isComponentRegistered(compMetadata.Name()) { + return eris.Errorf("message %q is already registered", compMetadata.Name()) + } + if s.finalizedState.isComponentRegistered(compMetadata.Name()) { + return eris.Errorf("message %q is already registered", compMetadata.Name()) + } + + // Try getting the schema from storage + // If the error is simply the schema not existing yet in storage, we can safely proceed. + // However, if it is a different error, we need to terminate and return the error. + storedSchema, err := s.storage.SchemaStorage.GetSchema(compMetadata.Name()) + if err != nil && !eris.Is(err, redis.ErrNoSchemaFound) { + return err + } + + //nolint:nestif // Comments for nested if statements provided for clarity + if storedSchema != nil { + // If there is a schema stored in storage, check if it matches the current schema of the component. + // If it does not match or schema validation failed, return an error. + // If it does match, our job here is done. + if err := compMetadata.ValidateAgainstSchema(storedSchema); err != nil { + if eris.Is(err, types.ErrComponentSchemaMismatch) { + return eris.Wrap(err, + fmt.Sprintf("component %q does not match the schema stored in storage", compMetadata.Name()), + ) + } + return eris.Wrap(err, "error when validating component schema against stored schema in storage") + } + } else { + // If there is no schema stored in storage, store the schema of the component in storage. + if err := s.storage.SchemaStorage.SetSchema(compMetadata.Name(), compMetadata.GetSchema()); err != nil { + return err + } + } + + // Set the component ID and register the component. + // We do this after the schema validation and storage operations to ensure that the component is only registered + // if the schema validation and storage operations are successful. + if err := compMetadata.SetID(s.nextComponentID); err != nil { + return err + } + + if err := s.ecb.registerComponent(compMetadata); err != nil { + return err + } + if err := s.finalizedState.registerComponent(compMetadata); err != nil { + return err + } + + s.nextComponentID++ + + return nil +} + +// Init marks the state as ready for use. This prevents any new components from being registered. +func (s *State) Init() error { + if err := s.ecb.init(); err != nil { + return err + } + + if err := s.finalizedState.init(); err != nil { + return err + } + + return nil +} + +func (s *State) ECB() *EntityCommandBuffer { + return &s.ecb +} + +func (s *State) FinalizedState() *FinalizedState { + return &s.finalizedState +} diff --git a/v2/gamestate/volatilestorage.go b/v2/gamestate/volatilestorage.go new file mode 100644 index 000000000..a29ffa281 --- /dev/null +++ b/v2/gamestate/volatilestorage.go @@ -0,0 +1,11 @@ +package gamestate + +// this interface is meant for in memory storage +type VolatileStorage[K comparable, V any] interface { + Get(key K) (V, error) + Set(key K, value V) error + Delete(key K) error + Keys() ([]K, error) + Clear() error + Len() int +} diff --git a/v2/go.mod b/v2/go.mod new file mode 100644 index 000000000..d4b582f26 --- /dev/null +++ b/v2/go.mod @@ -0,0 +1,126 @@ +module pkg.world.dev/world-engine/cardinal/v2 + +go 1.22.1 + +require ( + github.com/alicebob/miniredis/v2 v2.30.5 + github.com/coocood/freecache v1.2.4 + github.com/ethereum/go-ethereum v1.13.10 + github.com/goccy/go-json v0.10.3 + github.com/gofiber/contrib/socketio v1.0.0 + github.com/gofiber/contrib/websocket v1.3.0 + github.com/gofiber/fiber/v2 v2.52.2 + github.com/gofiber/swagger v0.1.14 + github.com/invopop/jsonschema v0.7.0 + github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416 + github.com/redis/go-redis/v9 v9.1.0 + github.com/rotisserie/eris v0.5.4 + github.com/rs/zerolog v1.33.0 + github.com/spf13/pflag v1.0.5 + github.com/spf13/viper v1.18.2 + github.com/stretchr/testify v1.9.0 + github.com/swaggo/swag v1.16.3 + github.com/wI2L/jsondiff v0.5.0 + go.opentelemetry.io/otel v1.29.0 + go.opentelemetry.io/otel/trace v1.29.0 + golang.org/x/sync v0.7.0 + gopkg.in/DataDog/dd-trace-go.v1 v1.63.1 + gotest.tools/v3 v3.5.1 + pkg.world.dev/world-engine/assert v1.0.0 + pkg.world.dev/world-engine/rift v1.2.0 + pkg.world.dev/world-engine/sign v1.1.0 +) + +require ( + github.com/DataDog/appsec-internal-go v1.5.0 // indirect + github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 // indirect + github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1 // indirect + github.com/DataDog/datadog-go/v5 v5.3.0 // indirect + github.com/DataDog/go-libddwaf/v2 v2.4.2 // indirect + github.com/DataDog/go-tuf v1.0.2-0.5.2 // indirect + github.com/DataDog/gostackparse v0.7.0 // indirect + github.com/DataDog/sketches-go v1.4.2 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect + github.com/Microsoft/go-winio v0.6.1 // indirect + github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a // indirect + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/btcsuite/btcd/btcec/v2 v2.3.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect + github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.6.0-alpha.5 // indirect + github.com/fasthttp/websocket v1.5.8 // indirect + github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-logr/logr v1.4.2 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.20.0 // indirect + github.com/go-openapi/jsonreference v0.20.0 // indirect + github.com/go-openapi/spec v0.20.8 // indirect + github.com/go-openapi/swag v0.22.4 // indirect + github.com/golang/protobuf v1.5.4 // indirect + github.com/google/go-cmp v0.6.0 // indirect + github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/hcl v1.0.1-vault-5 // indirect + github.com/holiman/uint256 v1.2.4 // indirect + github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/klauspost/compress v1.17.7 // indirect + github.com/magiconair/properties v1.8.7 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/naoina/go-stringutil v0.1.0 // indirect + github.com/outcaste-io/ristretto v0.2.3 // indirect + github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/philhofer/fwd v1.1.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/sagikazarmark/locafero v0.4.0 // indirect + github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 // indirect + github.com/secure-systems-lab/go-securesystemslib v0.7.0 // indirect + github.com/sourcegraph/conc v0.3.0 // indirect + github.com/spaolacci/murmur3 v1.1.0 // indirect + github.com/spf13/afero v1.11.0 // indirect + github.com/spf13/cast v1.6.0 // indirect + github.com/subosito/gotenv v1.6.0 // indirect + github.com/swaggo/files/v2 v2.0.0 // indirect + github.com/tidwall/gjson v1.17.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect + github.com/tinylib/msgp v1.1.8 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.52.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + github.com/yuin/gopher-lua v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect + go.opentelemetry.io/otel/metric v1.29.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.24.0 // indirect + golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 // indirect + golang.org/x/mod v0.17.0 // indirect + golang.org/x/net v0.26.0 // indirect + golang.org/x/sys v0.21.0 // indirect + golang.org/x/text v0.16.0 // indirect + golang.org/x/time v0.5.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect + golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 // indirect + google.golang.org/grpc v1.64.0 // indirect + google.golang.org/protobuf v1.35.1 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/v2/go.sum b/v2/go.sum new file mode 100644 index 000000000..f2437f337 --- /dev/null +++ b/v2/go.sum @@ -0,0 +1,415 @@ +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DataDog/appsec-internal-go v1.5.0 h1:8kS5zSx5T49uZ8dZTdT19QVAvC/B8ByyZdhQKYQWHno= +github.com/DataDog/appsec-internal-go v1.5.0/go.mod h1:pEp8gjfNLtEOmz+iZqC8bXhu0h4k7NUsW/qiQb34k1U= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0 h1:bUMSNsw1iofWiju9yc1f+kBd33E3hMJtq9GuU602Iy8= +github.com/DataDog/datadog-agent/pkg/obfuscate v0.48.0/go.mod h1:HzySONXnAgSmIQfL6gOv9hWprKJkx8CicuXuUbmgWfo= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1 h1:5nE6N3JSs2IG3xzMthNFhXfOaXlrsdgqmJ73lndFf8c= +github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.48.1/go.mod h1:Vc+snp0Bey4MrrJyiV2tVxxJb6BmLomPvN1RgAvjGaQ= +github.com/DataDog/datadog-go/v5 v5.3.0 h1:2q2qjFOb3RwAZNU+ez27ZVDwErJv5/VpbBPprz7Z+s8= +github.com/DataDog/datadog-go/v5 v5.3.0/go.mod h1:XRDJk1pTc00gm+ZDiBKsjh7oOOtJfYfglVCmFb8C2+Q= +github.com/DataDog/go-libddwaf/v2 v2.4.2 h1:ilquGKUmN9/Ty0sIxiEyznVRxP3hKfmH15Y1SMq5gjA= +github.com/DataDog/go-libddwaf/v2 v2.4.2/go.mod h1:gsCdoijYQfj8ce/T2bEDNPZFIYnmHluAgVDpuQOWMZE= +github.com/DataDog/go-tuf v1.0.2-0.5.2 h1:EeZr937eKAWPxJ26IykAdWA4A0jQXJgkhUjqEI/w7+I= +github.com/DataDog/go-tuf v1.0.2-0.5.2/go.mod h1:zBcq6f654iVqmkk8n2Cx81E1JnNTMOAx1UEO/wZR+P0= +github.com/DataDog/gostackparse v0.7.0 h1:i7dLkXHvYzHV308hnkvVGDL3BR4FWl7IsXNPz/IGQh4= +github.com/DataDog/gostackparse v0.7.0/go.mod h1:lTfqcJKqS9KnXQGnyQMCugq3u1FP6UZMfWR0aitKFMM= +github.com/DataDog/sketches-go v1.4.2 h1:gppNudE9d19cQ98RYABOetxIhpTCl4m7CnbRZjvVA/o= +github.com/DataDog/sketches-go v1.4.2/go.mod h1:xJIXldczJyyjnbDop7ZZcLxJdV3+7Kra7H1KMgpgkLk= +github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= +github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= +github.com/Microsoft/go-winio v0.5.0/go.mod h1:JPGBdM1cNvN/6ISo+n8V5iA4v8pBzdOpzfwIujj1a84= +github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= +github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= +github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0= +github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= +github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= +github.com/alicebob/miniredis/v2 v2.30.5 h1:3r6kTHdKnuP4fkS8k2IrvSfxpxUTcW1SOL0wN7b7Dt0= +github.com/alicebob/miniredis/v2 v2.30.5/go.mod h1:b25qWj4fCEsBeAAR2mlb0ufImGC6uH3VlUfb/HS5zKg= +github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/bsm/ginkgo/v2 v2.9.5 h1:rtVBYPs3+TC5iLUVOis1B9tjLTup7Cj5IfzosKtvTJ0= +github.com/bsm/ginkgo/v2 v2.9.5/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.26.0 h1:LhQm+AFcgV2M0WyKroMASzAzCAJVpAxQXv4SaI9a69Y= +github.com/bsm/gomega v1.26.0/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/btcsuite/btcd/btcec/v2 v2.3.2 h1:5n0X6hX0Zk+6omWcihdYvdAlGf2DfasC0GMf7DClJ3U= +github.com/btcsuite/btcd/btcec/v2 v2.3.2/go.mod h1:zYzJ8etWJQIv1Ogk7OzpWjowwOdXY1W/17j2MW85J04= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2 h1:KdUfX2zKommPRa+PD0sWZUyXe9w277ABlgELO7H04IM= +github.com/btcsuite/btcd/chaincfg/chainhash v1.0.2/go.mod h1:7SFka0XMvUgj3hfZtydOrQY2mwhPclbT2snogU7SQQc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +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/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +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/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= +github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y= +github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.6.0-alpha.5 h1:EYID3JOAdmQ4SNZYJHu9V6IqOeRQDBYxqKAg9PyoHFY= +github.com/ebitengine/purego v0.6.0-alpha.5/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/ethereum/go-ethereum v1.13.10 h1:Ppdil79nN+Vc+mXfge0AuUgmKWuVv4eMqzoIVSdqZek= +github.com/ethereum/go-ethereum v1.13.10/go.mod h1:sc48XYQxCzH3fG9BcrXCOOgQk2JfZzNAmIKnceogzsA= +github.com/fasthttp/websocket v1.5.8 h1:k5DpirKkftIF/w1R8ZzjSgARJrs54Je9YJK37DL/Ah8= +github.com/fasthttp/websocket v1.5.8/go.mod h1:d08g8WaT6nnyvg9uMm8K9zMYyDjfKyj3170AtPRuVU0= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= +github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= +github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= +github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= +github.com/go-openapi/jsonreference v0.19.6/go.mod h1:diGHMEHg2IqXZGKxqyvWdfWU/aim5Dprw5bqpKkTvns= +github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA= +github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo= +github.com/go-openapi/spec v0.20.4/go.mod h1:faYFR1CvsJZ0mNsmsphTMSoRrNV3TEDoAM7FOEWeq8I= +github.com/go-openapi/spec v0.20.8 h1:ubHmXNY3FCIOinT8RNrrPfGc9t7I1qhPtdOGoG2AxRU= +github.com/go-openapi/spec v0.20.8/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA= +github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ= +github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= +github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofiber/contrib/socketio v1.0.0 h1:Wanb9hsiuBg69IIDkes0mmKv23TsW+PxZQ/gBBqFwko= +github.com/gofiber/contrib/socketio v1.0.0/go.mod h1:I8TtbDgnvysM+VJckHbuMAZGFO8qDokuHysXgcx/JzI= +github.com/gofiber/contrib/websocket v1.3.0 h1:XADFAGorer1VJ1bqC4UkCjqS37kwRTV0415+050NrMk= +github.com/gofiber/contrib/websocket v1.3.0/go.mod h1:xguaOzn2ZZ759LavtosEP+rcxIgBEE/rdumPINhR+Xo= +github.com/gofiber/fiber/v2 v2.50.0/go.mod h1:21eytvay9Is7S6z+OgPi7c7n4++tnClWmhpimVHMimw= +github.com/gofiber/fiber/v2 v2.52.2 h1:b0rYH6b06Df+4NyrbdptQL8ifuxw/Tf2DgfkZkDaxEo= +github.com/gofiber/fiber/v2 v2.52.2/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= +github.com/gofiber/swagger v0.1.14 h1:o524wh4QaS4eKhUCpj7M0Qhn8hvtzcyxDsfZLXuQcRI= +github.com/gofiber/swagger v0.1.14/go.mod h1:DCk1fUPsj+P07CKaZttBbV1WzTZSQcSxfub8y9/BFr8= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5 h1:E/LAvt58di64hlYjx7AsNS6C/ysHWYo+2qPCZKTQhRo= +github.com/google/pprof v0.0.0-20240207164012-fb44976bdcd5/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik= +github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/hcl v1.0.1-vault-5 h1:kI3hhbbyzr4dldA8UdTb7ZlVVlI2DACdCfz31RPDgJM= +github.com/hashicorp/hcl v1.0.1-vault-5/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/holiman/uint256 v1.2.4 h1:jUc4Nk8fm9jZabQuqr2JzednajVmBpC+oiTiXZJEApU= +github.com/holiman/uint256 v1.2.4/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 h1:i462o439ZjprVSFSZLZxcsoAe592sZB1rci2Z8j4wdk= +github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0/go.mod h1:N0Wam8K1arqPXNWjMo21EXnBPOPp36vB07FNRdD2geA= +github.com/invopop/jsonschema v0.7.0 h1:2vgQcBz1n256N+FpX3Jq7Y17AjYt46Ig3zIWyy770So= +github.com/invopop/jsonschema v0.7.0/go.mod h1:O9uiLokuu0+MGFlyiaqtWxwqJm41/+8Nj0lD7A36YH0= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= +github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/naoina/go-stringutil v0.1.0 h1:rCUeRUHjBjGTSHl0VC00jUPLz8/F9dDzYI70Hzifhks= +github.com/naoina/go-stringutil v0.1.0/go.mod h1:XJ2SJL9jCtBh+P9q5btrd/Ylo8XwT/h1USek5+NqSA0= +github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416 h1:shk/vn9oCoOTmwcouEdwIeOtOGA/ELRUw/GwvxwfT+0= +github.com/naoina/toml v0.1.2-0.20170918210437-9fafd6967416/go.mod h1:NBIhNtsFMo3G2szEBne+bO4gS192HuIYRqfvOWb4i1E= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= +github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= +github.com/outcaste-io/ristretto v0.2.3 h1:AK4zt/fJ76kjlYObOeNwh4T3asEuaCmp26pOvUOL9w0= +github.com/outcaste-io/ristretto v0.2.3/go.mod h1:W8HywhmtlopSB1jeMg3JtdIhf+DYkLAr0VN/s4+MHac= +github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= +github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= +github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.1.0 h1:137FnGdk+EQdCbye1FW+qOEcY5S+SpY9T0NiuqvtfMY= +github.com/redis/go-redis/v9 v9.1.0/go.mod h1:urWj3He21Dj5k4TK1y59xH8Uj6ATueP8AH1cY3lZl4c= +github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052 h1:Qp27Idfgi6ACvFQat5+VJvlYToylpM/hcyLBI3WaKPA= +github.com/richardartoul/molecule v1.0.1-0.20221107223329-32cfee06a052/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+uGV8BrTv8W/SIwk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/rotisserie/eris v0.5.4 h1:Il6IvLdAapsMhvuOahHWiBnl1G++Q0/L5UIkI5mARSk= +github.com/rotisserie/eris v0.5.4/go.mod h1:Z/kgYTJiJtocxCbFfvRmO+QejApzG6zpyky9G1A4g9s= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= +github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= +github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= +github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511 h1:KanIMPX0QdEdB4R3CiimCAbxFrhB3j7h0/OvpYGVQa8= +github.com/savsgio/gotils v0.0.0-20240303185622-093b76447511/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/secure-systems-lab/go-securesystemslib v0.7.0 h1:OwvJ5jQf9LnIAS83waAjPbcMsODrTQUpJ02eNLUoxBg= +github.com/secure-systems-lab/go-securesystemslib v0.7.0/go.mod h1:/2gYnlnHVQ6xeGtfIqFy7Do03K4cdCY0A/GlJLDKLHI= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= +github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI= +github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= +github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= +github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= +github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= +github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.3.1-0.20190311161405-34c6fa2dc709/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= +github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= +github.com/swaggo/swag v1.16.2/go.mod h1:6YzXnDcpr0767iOejs318CwYkCQqyGer6BizOg03f+E= +github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= +github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= +github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM= +github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= +github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= +github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.50.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= +github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= +github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/wI2L/jsondiff v0.5.0 h1:RRMTi/mH+R2aXcPe1VYyvGINJqQfC3R+KSEakuU1Ikw= +github.com/wI2L/jsondiff v0.5.0/go.mod h1:qqG6hnK0Lsrz2BpIVCxWiK9ItsBCpIZQiv0izJjOZ9s= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/gopher-lua v1.1.0 h1:BojcDhfyDWgU2f2TOzYK/g5p2gxMrku8oupLDqlnSqE= +github.com/yuin/gopher-lua v1.1.0/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw= +go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw= +go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= +go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc= +go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= +go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4= +go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= +go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI= +golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0 h1:985EYyeCOxTpcgOTJpflJUwOeEz0CQOdPt73OzpE9F8= +golang.org/x/exp v0.0.0-20240404231335-c0f41cb1a7a0/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= +golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190204203706-41f3e6584952/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220627191245-f75cf1eec38b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= +golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= +golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= +golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117 h1:1GBuWVLM/KMVUv1t1En5Gs+gFZCNd360GGb4sSxtrhU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240604185151-ef581f913117/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= +google.golang.org/grpc v1.64.0 h1:KH3VH9y/MgNQg1dE7b3XfVK0GsPSIzJwdF617gUSbvY= +google.golang.org/grpc v1.64.0/go.mod h1:oxjF8E3FBnjp+/gVFYdWacaLDx9na1aqy9oovLpxQYg= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +gopkg.in/DataDog/dd-trace-go.v1 v1.63.1 h1:POnTNQLAJHnuywfk48N+l/EiwQJ6Kdaa7nwV5dbfdUY= +gopkg.in/DataDog/dd-trace-go.v1 v1.63.1/go.mod h1:pv2V0h4+skvObjdi3pWV4k6JHsdQk+flbjdC25mmTfU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= +gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= +gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +honnef.co/go/gotraceui v0.2.0 h1:dmNsfQ9Vl3GwbiVD7Z8d/osC6WtGGrasyrC2suc4ZIQ= +honnef.co/go/gotraceui v0.2.0/go.mod h1:qHo4/W75cA3bX0QQoSvDjbJa4R8mAyyFjbWAj63XElc= +pkg.world.dev/world-engine/assert v1.0.0 h1:vD6+QLT1pvQa86FPFi+wGcVA7PEwTGndXr+PkTY/Fpw= +pkg.world.dev/world-engine/assert v1.0.0/go.mod h1:bwA9YZ40+Tte6GUKibfqByxBLLt+54zjjFako8cpSuU= +pkg.world.dev/world-engine/rift v1.2.0 h1:789ymNG9HdLubkXB9NLZ93kRu5gTAvlXcxY9vjuQxuM= +pkg.world.dev/world-engine/rift v1.2.0/go.mod h1:vimnFo4VCFYHzixIv5ZtqnaRQM5+8a0J+V4AAOKrKk0= +pkg.world.dev/world-engine/sign v1.1.0 h1:hotNGChaT+HCOfB3NMlqjISi1jX/UWjOPGlvuq9ws5s= +pkg.world.dev/world-engine/sign v1.1.0/go.mod h1:pHHRuZ7P3HkZQbch0++xgy66iq0MwBuIeKymLdw0IMg= +sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= diff --git a/v2/option.go b/v2/option.go new file mode 100644 index 000000000..f4ab2d3a0 --- /dev/null +++ b/v2/option.go @@ -0,0 +1,60 @@ +package cardinal + +import ( + "time" + + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type CardinalOption struct { + worldOption world.Option + cardinalOption Option +} + +type Option func(*Cardinal) + +// WithDisableSignatureVerification disables signature verification for the HTTP server. This should only be +// used for local development. +func WithDisableSignatureVerification() CardinalOption { + return CardinalOption{ + worldOption: world.WithVerifySignature(false), + } +} + +// WithTickChannel sets the channel that will be used to decide when world.doTick is executed. If unset, a loop interval +// of 1 second will be set. To set some other time, use: WithTickChannel(time.Tick()). Tests can pass +// in a channel controlled by the test for fine-grained control over when ticks are executed. +func WithTickChannel(ch <-chan time.Time) CardinalOption { + return CardinalOption{ + cardinalOption: func(cardinal *Cardinal) { + cardinal.tickChannel = ch + }, + } +} + +func WithStartHook(hook func() error) CardinalOption { + return CardinalOption{ + cardinalOption: func(c *Cardinal) { + c.startHook = hook + }, + } +} + +// 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. +func separateOptions(opts []CardinalOption) ([]Option, []world.Option) { + cardinalOpts := make([]Option, 0) + worldOpts := make([]world.Option, 0) + + for _, opt := range opts { + if opt.cardinalOption != nil { + cardinalOpts = append(cardinalOpts, opt.cardinalOption) + } + if opt.worldOption != nil { + worldOpts = append(worldOpts, opt.worldOption) + } + } + + return cardinalOpts, worldOpts +} diff --git a/v2/plugin/task/plugin.go b/v2/plugin/task/plugin.go new file mode 100644 index 000000000..6fd411036 --- /dev/null +++ b/v2/plugin/task/plugin.go @@ -0,0 +1,165 @@ +package task + +import ( + "time" + + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +// ----------------------------------------------------------------------------- +// Public API accessible via task. +// ----------------------------------------------------------------------------- + +// RegisterTask registers a Task definition with the World. A Task definition is a special type of component that +// has a Handle(WorldContext) method that is called when the task is triggered. The Handle method is responsible +// for executing the task and returning an error if any occurred. +func RegisterTask[T Task](w *world.World) error { + if err := world.RegisterComponent[T](w); err != nil { + return eris.Wrap(err, "failed to register timestamp task component") + } + + if err := world.RegisterSystems(w, taskSystem[T]); err != nil { + return eris.Wrap(err, "failed to register timestamp task system") + } + + return nil +} + +func ScheduleTickTask(w world.WorldContext, tickDelay int64, task Task) error { + triggerAtTick := w.CurrentTick() + tickDelay + return createTickTask(w, triggerAtTick, task) +} + +func ScheduleTimeTask(w world.WorldContext, duration time.Duration, task Task) error { + if duration.Milliseconds() < 0 { + return eris.New("duration value must be positive") + } + + triggerAtTimestamp := w.Timestamp() + duration.Milliseconds() + return createTimestampTask(w, triggerAtTimestamp, task) +} + +// ----------------------------------------------------------------------------- +// Components +// ----------------------------------------------------------------------------- + +// Task is a user-facing special component interface that is used to define a task that can be scheduled to be executed. +// It implements the types.Component interface along with a Handle method that is called when the task is triggered. +// This method is not to be confused with taskMetadata, which is an internal component type used to store the trigger +// condition for a task. +type Task interface { + types.Component + Handle(world.WorldContext) error +} + +// taskMetadata is an internal component that is used to store the trigger condition for a task. +// It implements the types.Component interface along with an isTriggered method that returns true if the task +// should be triggered based on the current tick or timestamp. +type taskMetadata struct { + TriggerAtTick *int64 + TriggerAtTimestamp *int64 +} + +func (taskMetadata) Name() string { + return "taskMetadata" +} + +// Task will be triggered when the current tick is greater than designated trigger tick OR when the current timestamp +// is greater than designated trigger timestamp. A task can only have one trigger condition, either tick or timestamp. +// The task should have been trigger at exactly the designated trigger tick, but we make it >= to be safe. +func (t taskMetadata) isTriggered(tick int64, timestamp int64) bool { + if t.TriggerAtTick != nil { + return tick >= *t.TriggerAtTick + } + return timestamp >= *t.TriggerAtTimestamp +} + +// ----------------------------------------------------------------------------- +// Systems +// ----------------------------------------------------------------------------- + +// taskSystem is a system that is registered when RegisterTask is called. It is responsible for iterating through all +// entities with the Task type T and executing the task if the trigger condition is met. +func taskSystem[T Task](wCtx world.WorldContext) error { + var t T + var internalErr error + err := wCtx.Search(filter.Contains(t, filter.Component[taskMetadata]())).Each( + func(id types.EntityID) bool { + taskMetadata, err := world.GetComponent[taskMetadata](wCtx, id) + if err != nil { + internalErr = err + return false + } + + if taskMetadata.isTriggered(wCtx.CurrentTick(), wCtx.Timestamp()) { + task, err := world.GetComponent[T](wCtx, id) + if err != nil { + internalErr = err + return false + } + + if err = (*task).Handle(wCtx); err != nil { + internalErr = err + return false + } + + if err = world.Remove(wCtx, id); err != nil { + internalErr = err + return false + } + } + return true + }, + ) + if internalErr != nil { + return eris.Wrap(internalErr, "encountered an error while executing a task") + } + if err != nil { + return eris.Wrap(err, "encountered an error while iterating over tasks") + } + + return nil +} + +// ----------------------------------------------------------------------------- +// Internal functions used by WorldContext to schedule tasks +// ----------------------------------------------------------------------------- + +// createTickTask creates a task entity that will be executed by taskSystem at the designated tick. +func createTickTask(wCtx world.WorldContext, tick int64, task Task) error { + _, err := world.Create(wCtx, task, taskMetadata{TriggerAtTick: &tick}) + if err != nil { + return eris.Wrap(err, "failed to create tick task entity") + } + return nil +} + +// createTimestampTask creates a task entity that will be executed by taskSystem at the designated timestamp. +func createTimestampTask(wCtx world.WorldContext, timestamp int64, task Task) error { + _, err := world.Create(wCtx, task, taskMetadata{TriggerAtTimestamp: ×tamp}) + if err != nil { + return eris.Wrap(err, "failed to create timestamp task entity") + } + return nil +} + +// ----------------------------------------------------------------------------- +// Plugin Definition +// ----------------------------------------------------------------------------- + +// plugin defines a plugin that handles task scheduling and execution. +type plugin struct{} + +var _ world.Plugin = (*plugin)(nil) + +func NewPlugin() *plugin { + return &plugin{} +} + +func (*plugin) Register(w *world.World) error { + return world.RegisterComponent[taskMetadata](w) +} diff --git a/v2/plugin/task/plugin_test.go b/v2/plugin/task/plugin_test.go new file mode 100644 index 000000000..a02cfe407 --- /dev/null +++ b/v2/plugin/task/plugin_test.go @@ -0,0 +1,516 @@ +package task_test + +import ( + "testing" + "time" + + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/plugin/task" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +// ----------------------------------------------------------------------------- +// Test components +// ----------------------------------------------------------------------------- + +// Storage is a test component that stores a string and a counter. +type Storage struct { + Storage string +} + +func (Storage) Name() string { + return "Storage" +} + +type Counter struct { + Count int +} + +func (Counter) Name() string { + return "Counter" +} + +// ----------------------------------------------------------------------------- +// Test tasks +// ----------------------------------------------------------------------------- + +// StorageSetterTask is a task that sets the storage value to the corresponding payload. +type StorageSetterTask struct { + Payload string +} + +var _ task.Task = (*StorageSetterTask)(nil) + +func (StorageSetterTask) Name() string { + return "StorageSetterTask" +} + +func (t StorageSetterTask) Handle(w world.WorldContext) error { + w.Logger().Info().Msgf("Executing task %v", t) + + id, err := w.Search(filter.Contains(Storage{})).First() + if err != nil { + return eris.Wrap(err, "failed to get Storage entity") + } + + err = world.UpdateComponent[Storage](w, id, func(c *Storage) *Storage { + c.Storage = t.Payload + return c + }) + if err != nil { + return eris.Wrap(err, "failed to update Storage entity") + } + + return nil +} + +type CounterTask struct{} + +var _ task.Task = (*CounterTask)(nil) + +func (CounterTask) Name() string { + return "CounterTask" +} + +func (CounterTask) Handle(w world.WorldContext) error { + w.Logger().Info().Msgf("Executing task %v", CounterTask{}) + + id, err := w.Search(filter.Contains(Counter{})).First() + if err != nil { + return eris.Wrap(err, "failed to get Counter entity") + } + + err = world.UpdateComponent[Counter](w, id, func(c *Counter) *Counter { + c.Count++ + return c + }) + if err != nil { + return eris.Wrap(err, "failed to update Counter entity") + } + + return nil +} + +// ----------------------------------------------------------------------------- +// ScheduleTimeTask tests +// ----------------------------------------------------------------------------- + +func TestPluginTask_ScheduleTimeTask(t *testing.T) { + type testTask struct { + delay time.Duration + task task.Task + } + + type testExpected struct { + wait time.Duration + storage string + count int + } + + tests := []struct { + name string + testTasks []testTask + expected []testExpected + }{ + { + name: "Task executed after the specified duration", + testTasks: []testTask{ + { + delay: 1 * time.Millisecond, + task: StorageSetterTask{Payload: "test"}, + }, + }, + expected: []testExpected{ + { + wait: 10 * time.Millisecond, + storage: "test", + count: 0, + }, + }, + }, + { + name: "Task executed in the correct order", + testTasks: []testTask{ + { + delay: 1 * time.Millisecond, + task: StorageSetterTask{Payload: "test"}, + }, + { + delay: 2 * time.Millisecond, + task: StorageSetterTask{Payload: "test2"}, + }, + }, + expected: []testExpected{ + { + wait: 10 * time.Millisecond, + storage: "test2", + count: 0, + }, + }, + }, + { + name: "Task not prematurely executed", + testTasks: []testTask{ + { + delay: 10 * time.Second, + task: StorageSetterTask{Payload: "test"}, + }, + }, + expected: []testExpected{ + { + wait: 10 * time.Millisecond, + storage: "", + count: 0, + }, + }, + }, + { + name: "Task executed only once", + testTasks: []testTask{ + { + delay: 1 * time.Millisecond, + task: CounterTask{}, + }, + }, + expected: []testExpected{ + { + wait: 10 * time.Millisecond, + storage: "", + count: 1, + }, + { + wait: 10 * time.Millisecond, + storage: "", + count: 1, + }, + { + wait: 10 * time.Millisecond, + storage: "", + count: 1, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[Storage](tf.World())) + assert.NilError(t, world.RegisterComponent[Counter](tf.World())) + assert.NilError(t, task.RegisterTask[StorageSetterTask](tf.World())) + assert.NilError(t, task.RegisterTask[CounterTask](tf.World())) + + // Register an init system that creates a Storage and Counter entity and schedules tasks + err := world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + // Create a Storage entity + _, err := world.Create(wCtx, Storage{}) + assert.NilError(t, err) + + // Create a Counter entity + _, err = world.Create(wCtx, Counter{}) + assert.NilError(t, err) + + // Schedule tasks + for _, testTask := range tc.testTasks { + assert.NilError(t, task.ScheduleTimeTask(wCtx, testTask.delay, testTask.task)) + } + + return nil + }) + assert.NilError(t, err) + + // Execute the init system + tf.DoTick() + + tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + for _, expected := range tc.expected { + // Wait for the task to be executed + time.Sleep(expected.wait) + // Execute the task + tf.DoTick() + + // Fetch the storage and counter entities + storageID, err := wCtx.Search(filter.Contains(Storage{})).First() + assert.NilError(t, err) + counterID, err := wCtx.Search(filter.Contains(Counter{})).First() + assert.NilError(t, err) + + // Assert the storage value + gotStorage, err := world.GetComponent[Storage](wCtx, storageID) + assert.NilError(t, err) + assert.Equal(t, gotStorage.Storage, expected.storage) + + // Assert the counter value + gotCounter, err := world.GetComponent[Counter](wCtx, counterID) + assert.NilError(t, err) + assert.Equal(t, gotCounter.Count, expected.count) + } + return nil + }) + }) + } +} + +// TestPluginTask_ScheduleTimeTask_Recovery tests that the task is recovered after a world restart +func TestPluginTask_ScheduleTimeTask_Recovery(t *testing.T) { + tf1 := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[Storage](tf1.World())) + assert.NilError(t, task.RegisterTask[StorageSetterTask](tf1.World())) + + // Register an init system that creates a Storage and Counter entity and schedules tasks + err := world.RegisterInitSystems(tf1.World(), func(wCtx world.WorldContext) error { + // Create a Storage entity + _, err := world.Create(wCtx, Storage{}) + assert.NilError(t, err) + + // Schedule tasks + err = task.ScheduleTimeTask(wCtx, 10*time.Millisecond, StorageSetterTask{Payload: "test"}) + assert.NilError(t, err) + + return nil + }) + assert.NilError(t, err) + + // Execute the init system + tf1.DoTick() + + // Create a new test fixture with the same redis DB + tf2 := cardinal.NewTestCardinal(t, tf1.Redis) + + assert.NilError(t, world.RegisterComponent[Storage](tf2.World())) + assert.NilError(t, task.RegisterTask[StorageSetterTask](tf2.World())) + + // Wait until the task is ready to be executed + time.Sleep(20 * time.Millisecond) + + // Execute the task + tf2.DoTick() + + // Fetch the storage and counter entities + tf2.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + storageID, err := wCtx.Search(filter.Contains(Storage{})).First() + assert.NilError(t, err) + + // Assert the storage value + gotStorage, err := world.GetComponent[Storage](wCtx, storageID) + assert.NilError(t, err) + assert.Equal(t, gotStorage.Storage, "test") + return nil + }) +} + +// ----------------------------------------------------------------------------- +// ScheduleTickTask tests +// ----------------------------------------------------------------------------- + +func TestPluginTask_ScheduleTickTask(t *testing.T) { + type testTask struct { + delay int64 + task task.Task + } + + type testExpected struct { + tickToRun int // how many ticks to run before test is asserted + storage string + count int + } + + tests := []struct { + name string + testTasks []testTask + expected []testExpected + }{ + { + name: "Task executed after the specified duration", + testTasks: []testTask{ + { + delay: 1, + task: StorageSetterTask{Payload: "test"}, + }, + }, + expected: []testExpected{ + { + tickToRun: 1, + storage: "test", + count: 0, + }, + }, + }, + { + name: "Task executed in the correct order", + testTasks: []testTask{ + { + delay: 1, + task: StorageSetterTask{Payload: "test"}, + }, + { + delay: 2, + task: StorageSetterTask{Payload: "test2"}, + }, + }, + expected: []testExpected{ + { + tickToRun: 2, + storage: "test2", + count: 0, + }, + }, + }, + { + name: "Task not prematurely executed", + testTasks: []testTask{ + { + delay: 10, + task: StorageSetterTask{Payload: "test"}, + }, + }, + expected: []testExpected{ + { + tickToRun: 1, + storage: "", + count: 0, + }, + }, + }, + { + name: "Task executed only once", + testTasks: []testTask{ + { + delay: 1, + task: CounterTask{}, + }, + }, + expected: []testExpected{ + { + tickToRun: 1, + storage: "", + count: 1, + }, + { + tickToRun: 1, + storage: "", + count: 1, + }, + { + tickToRun: 1, + storage: "", + count: 1, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[Storage](tf.World())) + assert.NilError(t, world.RegisterComponent[Counter](tf.World())) + assert.NilError(t, task.RegisterTask[StorageSetterTask](tf.World())) + assert.NilError(t, task.RegisterTask[CounterTask](tf.World())) + + // Register an init system that creates a Storage and Counter entity and schedules tasks + err := world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + // Create a Storage entity + _, err := world.Create(wCtx, Storage{}) + assert.NilError(t, err) + + // Create a Counter entity + _, err = world.Create(wCtx, Counter{}) + assert.NilError(t, err) + + // Schedule tasks + for _, testTask := range tc.testTasks { + assert.NilError(t, task.ScheduleTickTask(wCtx, testTask.delay, testTask.task)) + } + + return nil + }) + assert.NilError(t, err) + + // Execute the init system + tf.DoTick() + + err = tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + for _, expected := range tc.expected { + for i := 0; i < expected.tickToRun; i++ { + // Fast forward the tick + tf.DoTick() + } + + // Fetch the storage and counter entities + storageID, err := wCtx.Search(filter.Contains(Storage{})).First() + assert.NilError(t, err) + counterID, err := wCtx.Search(filter.Contains(Counter{})).First() + assert.NilError(t, err) + + // Assert the storage value + gotStorage, err := world.GetComponent[Storage](wCtx, storageID) + assert.NilError(t, err) + assert.Equal(t, gotStorage.Storage, expected.storage) + + // Assert the counter value + gotCounter, err := world.GetComponent[Counter](wCtx, counterID) + assert.NilError(t, err) + assert.Equal(t, gotCounter.Count, expected.count) + } + return nil + }) + assert.NilError(t, err) + }) + } +} + +// TestPluginTask_ScheduleTickTask_Recovery tests that the task is recovered after a world restart +func TestPluginTask_ScheduleTickTask_Recovery(t *testing.T) { + tf1 := cardinal.NewTestCardinal(t, nil) + + assert.NilError(t, world.RegisterComponent[Storage](tf1.World())) + assert.NilError(t, task.RegisterTask[StorageSetterTask](tf1.World())) + + // Register an init system that creates a Storage and Counter entity and schedules tasks + err := world.RegisterInitSystems(tf1.World(), func(wCtx world.WorldContext) error { + // Create a Storage entity + _, err := world.Create(wCtx, Storage{}) + assert.NilError(t, err) + + // Schedule tasks + err = task.ScheduleTickTask(wCtx, 2, StorageSetterTask{Payload: "test"}) + assert.NilError(t, err) + + return nil + }) + assert.NilError(t, err) + + // Execute the init system + tf1.DoTick() + + // Create a new test fixture with the same redis DB + tf2 := cardinal.NewTestCardinal(t, tf1.Redis) + + assert.NilError(t, world.RegisterComponent[Storage](tf2.World())) + assert.NilError(t, task.RegisterTask[StorageSetterTask](tf2.World())) + + // Execute the task + tf2.DoTick() + tf2.DoTick() + + // Fetch the storage and counter entities + err = tf2.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + storageID, err := wCtx.Search(filter.Contains(Storage{})).First() + assert.NilError(t, err) + + // Assert the storage value + gotStorage, err := world.GetComponent[Storage](wCtx, storageID) + assert.NilError(t, err) + assert.Equal(t, gotStorage.Storage, "test") + return nil + }) + assert.NilError(t, err) +} diff --git a/v2/server/docs/docs.go b/v2/server/docs/docs.go new file mode 100644 index 000000000..c8566dcdf --- /dev/null +++ b/v2/server/docs/docs.go @@ -0,0 +1,482 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "contact": {}, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/debug/state": { + "post": { + "description": "Retrieves a list of all entities in the game state", + "produces": [ + "application/json" + ], + "summary": "Retrieves a list of all entities in the game state", + "responses": { + "200": { + "description": "List of all entities", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.EntityData" + } + } + } + } + } + }, + "/events": { + "get": { + "description": "Establishes a new websocket connection to retrieve system events", + "produces": [ + "application/json" + ], + "summary": "Establishes a new websocket connection to retrieve system events", + "responses": { + "101": { + "description": "Switch protocol to ws", + "schema": { + "type": "string" + } + } + } + } + }, + "/health": { + "get": { + "description": "Retrieves the status of the server and game loop", + "produces": [ + "application/json" + ], + "summary": "Retrieves the status of the server and game loop", + "responses": { + "200": { + "description": "Server and game loop status", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.GetHealthResponse" + } + } + } + } + }, + "/query/receipts/list": { + "post": { + "description": "Retrieves all transaction receipts", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Retrieves all transaction receipts", + "parameters": [ + { + "description": "Query body", + "name": "GetReceiptsRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cardinal_server_handler.GetReceiptsRequest" + } + } + ], + "responses": { + "200": { + "description": "List of receipts", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.GetReceiptsResponse" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + } + } + } + }, + "/query/{queryGroup}/{queryName}": { + "post": { + "description": "Executes a query", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Executes a query", + "parameters": [ + { + "type": "string", + "description": "Query group", + "name": "queryGroup", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name of a registered query", + "name": "queryName", + "in": "path", + "required": true + }, + { + "description": "Query to be executed", + "name": "queryBody", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "Results of the executed query", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "type": "string" + } + } + } + } + }, + "/tx/game/{txName}": { + "post": { + "description": "Submits a transaction", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Submits a transaction", + "parameters": [ + { + "type": "string", + "description": "Name of a registered message", + "name": "txName", + "in": "path", + "required": true + }, + { + "description": "Transaction details \u0026 message to be submitted", + "name": "txBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/sign.Transaction" + } + } + ], + "responses": { + "200": { + "description": "Transaction hash and tick", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.PostTransactionResponse" + } + }, + "400": { + "description": "Invalid request parameter", + "schema": { + "type": "string" + } + } + } + } + }, + "/tx/persona/create-persona": { + "post": { + "description": "Creates a persona", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Creates a persona", + "parameters": [ + { + "description": "Transaction details \u0026 message to be submitted", + "name": "txBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/sign.Transaction" + } + } + ], + "responses": { + "200": { + "description": "Transaction hash and tick", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.PostTransactionResponse" + } + }, + "400": { + "description": "Invalid request parameter", + "schema": { + "type": "string" + } + } + } + } + }, + "/tx/{group}/{name}": { + "post": { + "description": "Submits a transaction", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Submits a transaction", + "parameters": [ + { + "type": "string", + "description": "Message group", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name of a registered message", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Transaction details \u0026 message to be submitted", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/sign.Transaction" + } + } + ], + "responses": { + "200": { + "description": "Transaction hash and tick", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.PostTransactionResponse" + } + }, + "400": { + "description": "Invalid request parameter", + "schema": { + "type": "string" + } + } + } + } + }, + "/world": { + "get": { + "description": "Contains the registered components, messages, queries, and namespace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Retrieves details of the game world", + "responses": { + "200": { + "description": "Details of the game world", + "schema": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.WorldInfo" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "cardinal_server_handler.GetHealthResponse": { + "type": "object", + "properties": { + "isGameLoopRunning": { + "type": "boolean" + }, + "isServerRunning": { + "type": "boolean" + } + } + }, + "cardinal_server_handler.GetReceiptsRequest": { + "type": "object", + "properties": { + "txHashes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "cardinal_server_handler.GetReceiptsResponse": { + "type": "object", + "properties": { + "receipts": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "cardinal_server_handler.PostTransactionResponse": { + "type": "object", + "properties": { + "txHash": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.ComponentInfo": { + "type": "object", + "properties": { + "fields": { + "description": "property name and type", + "type": "object", + "additionalProperties": {} + }, + "name": { + "description": "name of the component", + "type": "string" + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.EndpointInfo": { + "type": "object", + "properties": { + "fields": { + "description": "property name and type", + "type": "object", + "additionalProperties": {} + }, + "name": { + "description": "name of the message or query", + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.EntityData": { + "type": "object", + "properties": { + "components": { + "type": "object" + }, + "id": { + "type": "integer" + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.WorldInfo": { + "type": "object", + "properties": { + "components": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.ComponentInfo" + } + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.EndpointInfo" + } + }, + "namespace": { + "type": "string" + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.EndpointInfo" + } + } + } + }, + "sign.Transaction": { + "type": "object", + "properties": { + "body": { + "description": "json string", + "type": "object" + }, + "hash": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "nonce": { + "type": "integer" + }, + "personaTag": { + "type": "string" + }, + "signature": { + "description": "hex encoded string", + "type": "string" + } + } + } + } +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "0.0.1", + Host: "", + BasePath: "/", + Schemes: []string{"http", "ws"}, + Title: "Cardinal", + Description: "Backend server for World Engine", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/v2/server/docs/swagger.json b/v2/server/docs/swagger.json new file mode 100644 index 000000000..a08b89e28 --- /dev/null +++ b/v2/server/docs/swagger.json @@ -0,0 +1,461 @@ +{ + "schemes": [ + "http", + "ws" + ], + "swagger": "2.0", + "info": { + "description": "Backend server for World Engine", + "title": "Cardinal", + "contact": {}, + "version": "0.0.1" + }, + "basePath": "/", + "paths": { + "/debug/state": { + "post": { + "description": "Retrieves a list of all entities in the game state", + "produces": [ + "application/json" + ], + "summary": "Retrieves a list of all entities in the game state", + "responses": { + "200": { + "description": "List of all entities", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.EntityData" + } + } + } + } + } + }, + "/events": { + "get": { + "description": "Establishes a new websocket connection to retrieve system events", + "produces": [ + "application/json" + ], + "summary": "Establishes a new websocket connection to retrieve system events", + "responses": { + "101": { + "description": "Switch protocol to ws", + "schema": { + "type": "string" + } + } + } + } + }, + "/health": { + "get": { + "description": "Retrieves the status of the server and game loop", + "produces": [ + "application/json" + ], + "summary": "Retrieves the status of the server and game loop", + "responses": { + "200": { + "description": "Server and game loop status", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.GetHealthResponse" + } + } + } + } + }, + "/query/receipts/list": { + "post": { + "description": "Retrieves all transaction receipts", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Retrieves all transaction receipts", + "parameters": [ + { + "description": "Query body", + "name": "GetReceiptsRequest", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/cardinal_server_handler.GetReceiptsRequest" + } + } + ], + "responses": { + "200": { + "description": "List of receipts", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.GetReceiptsResponse" + } + }, + "400": { + "description": "Invalid request body", + "schema": { + "type": "string" + } + } + } + } + }, + "/query/{queryGroup}/{queryName}": { + "post": { + "description": "Executes a query", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Executes a query", + "parameters": [ + { + "type": "string", + "description": "Query group", + "name": "queryGroup", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name of a registered query", + "name": "queryName", + "in": "path", + "required": true + }, + { + "description": "Query to be executed", + "name": "queryBody", + "in": "body", + "required": true, + "schema": { + "type": "object" + } + } + ], + "responses": { + "200": { + "description": "Results of the executed query", + "schema": { + "type": "object" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "type": "string" + } + } + } + } + }, + "/tx/game/{txName}": { + "post": { + "description": "Submits a transaction", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Submits a transaction", + "parameters": [ + { + "type": "string", + "description": "Name of a registered message", + "name": "txName", + "in": "path", + "required": true + }, + { + "description": "Transaction details \u0026 message to be submitted", + "name": "txBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/sign.Transaction" + } + } + ], + "responses": { + "200": { + "description": "Transaction hash and tick", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.PostTransactionResponse" + } + }, + "400": { + "description": "Invalid request parameter", + "schema": { + "type": "string" + } + } + } + } + }, + "/tx/persona/create-persona": { + "post": { + "description": "Creates a persona", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Creates a persona", + "parameters": [ + { + "description": "Transaction details \u0026 message to be submitted", + "name": "txBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/sign.Transaction" + } + } + ], + "responses": { + "200": { + "description": "Transaction hash and tick", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.PostTransactionResponse" + } + }, + "400": { + "description": "Invalid request parameter", + "schema": { + "type": "string" + } + } + } + } + }, + "/tx/{group}/{name}": { + "post": { + "description": "Submits a transaction", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Submits a transaction", + "parameters": [ + { + "type": "string", + "description": "Message group", + "name": "group", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name of a registered message", + "name": "name", + "in": "path", + "required": true + }, + { + "description": "Transaction details \u0026 message to be submitted", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/sign.Transaction" + } + } + ], + "responses": { + "200": { + "description": "Transaction hash and tick", + "schema": { + "$ref": "#/definitions/cardinal_server_handler.PostTransactionResponse" + } + }, + "400": { + "description": "Invalid request parameter", + "schema": { + "type": "string" + } + } + } + } + }, + "/world": { + "get": { + "description": "Contains the registered components, messages, queries, and namespace", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "summary": "Retrieves details of the game world", + "responses": { + "200": { + "description": "Details of the game world", + "schema": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.WorldInfo" + } + }, + "400": { + "description": "Invalid request parameters", + "schema": { + "type": "string" + } + } + } + } + } + }, + "definitions": { + "cardinal_server_handler.GetHealthResponse": { + "type": "object", + "properties": { + "isGameLoopRunning": { + "type": "boolean" + }, + "isServerRunning": { + "type": "boolean" + } + } + }, + "cardinal_server_handler.GetReceiptsRequest": { + "type": "object", + "properties": { + "txHashes": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "cardinal_server_handler.GetReceiptsResponse": { + "type": "object", + "properties": { + "receipts": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "integer" + } + } + } + } + }, + "cardinal_server_handler.PostTransactionResponse": { + "type": "object", + "properties": { + "txHash": { + "type": "array", + "items": { + "type": "integer" + } + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.ComponentInfo": { + "type": "object", + "properties": { + "fields": { + "description": "property name and type", + "type": "object", + "additionalProperties": {} + }, + "name": { + "description": "name of the component", + "type": "string" + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.EndpointInfo": { + "type": "object", + "properties": { + "fields": { + "description": "property name and type", + "type": "object", + "additionalProperties": {} + }, + "name": { + "description": "name of the message or query", + "type": "string" + }, + "url": { + "type": "string" + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.EntityData": { + "type": "object", + "properties": { + "components": { + "type": "object" + }, + "id": { + "type": "integer" + } + } + }, + "pkg_world_dev_world-engine_cardinal_types.WorldInfo": { + "type": "object", + "properties": { + "components": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.ComponentInfo" + } + }, + "messages": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.EndpointInfo" + } + }, + "namespace": { + "type": "string" + }, + "queries": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_world_dev_world-engine_cardinal_types.EndpointInfo" + } + } + } + }, + "sign.Transaction": { + "type": "object", + "properties": { + "body": { + "description": "json string", + "type": "object" + }, + "hash": { + "type": "string" + }, + "namespace": { + "type": "string" + }, + "nonce": { + "type": "integer" + }, + "personaTag": { + "type": "string" + }, + "signature": { + "description": "hex encoded string", + "type": "string" + } + } + } + } +} \ No newline at end of file diff --git a/v2/server/docs/swagger.yaml b/v2/server/docs/swagger.yaml new file mode 100644 index 000000000..7b073cedf --- /dev/null +++ b/v2/server/docs/swagger.yaml @@ -0,0 +1,304 @@ +basePath: / +definitions: + cardinal_server_handler.GetHealthResponse: + properties: + isGameLoopRunning: + type: boolean + isServerRunning: + type: boolean + type: object + cardinal_server_handler.GetReceiptsRequest: + properties: + txHashes: + items: + items: + type: integer + type: array + type: array + type: object + cardinal_server_handler.GetReceiptsResponse: + properties: + receipts: + additionalProperties: + items: + type: integer + type: array + type: object + type: object + cardinal_server_handler.PostTransactionResponse: + properties: + txHash: + items: + type: integer + type: array + type: object + pkg_world_dev_world-engine_cardinal_types.ComponentInfo: + properties: + fields: + additionalProperties: {} + description: property name and type + type: object + name: + description: name of the component + type: string + type: object + pkg_world_dev_world-engine_cardinal_types.EndpointInfo: + properties: + fields: + additionalProperties: {} + description: property name and type + type: object + name: + description: name of the message or query + type: string + url: + type: string + type: object + pkg_world_dev_world-engine_cardinal_types.EntityData: + properties: + components: + type: object + id: + type: integer + type: object + pkg_world_dev_world-engine_cardinal_types.WorldInfo: + properties: + components: + items: + $ref: '#/definitions/pkg_world_dev_world-engine_cardinal_types.ComponentInfo' + type: array + messages: + items: + $ref: '#/definitions/pkg_world_dev_world-engine_cardinal_types.EndpointInfo' + type: array + namespace: + type: string + queries: + items: + $ref: '#/definitions/pkg_world_dev_world-engine_cardinal_types.EndpointInfo' + type: array + type: object + sign.Transaction: + properties: + body: + description: json string + type: object + hash: + type: string + namespace: + type: string + nonce: + type: integer + personaTag: + type: string + signature: + description: hex encoded string + type: string + type: object +info: + contact: {} + description: Backend server for World Engine + title: Cardinal + version: 0.0.1 +paths: + /debug/state: + post: + description: Retrieves a list of all entities in the game state + produces: + - application/json + responses: + "200": + description: List of all entities + schema: + items: + $ref: '#/definitions/pkg_world_dev_world-engine_cardinal_types.EntityData' + type: array + summary: Retrieves a list of all entities in the game state + /events: + get: + description: Establishes a new websocket connection to retrieve system events + produces: + - application/json + responses: + "101": + description: Switch protocol to ws + schema: + type: string + summary: Establishes a new websocket connection to retrieve system events + /health: + get: + description: Retrieves the status of the server and game loop + produces: + - application/json + responses: + "200": + description: Server and game loop status + schema: + $ref: '#/definitions/cardinal_server_handler.GetHealthResponse' + summary: Retrieves the status of the server and game loop + /query/{queryGroup}/{queryName}: + post: + consumes: + - application/json + description: Executes a query + parameters: + - description: Query group + in: path + name: queryGroup + required: true + type: string + - description: Name of a registered query + in: path + name: queryName + required: true + type: string + - description: Query to be executed + in: body + name: queryBody + required: true + schema: + type: object + produces: + - application/json + responses: + "200": + description: Results of the executed query + schema: + type: object + "400": + description: Invalid request parameters + schema: + type: string + summary: Executes a query + /query/receipts/list: + post: + consumes: + - application/json + description: Retrieves all transaction receipts + parameters: + - description: Query body + in: body + name: GetReceiptsRequest + required: true + schema: + $ref: '#/definitions/cardinal_server_handler.GetReceiptsRequest' + produces: + - application/json + responses: + "200": + description: List of receipts + schema: + $ref: '#/definitions/cardinal_server_handler.GetReceiptsResponse' + "400": + description: Invalid request body + schema: + type: string + summary: Retrieves all transaction receipts + /tx/{group}/{name}: + post: + consumes: + - application/json + description: Submits a transaction + parameters: + - description: Message group + in: path + name: group + required: true + type: string + - description: Name of a registered message + in: path + name: name + required: true + type: string + - description: Transaction details & message to be submitted + in: body + name: body + required: true + schema: + $ref: '#/definitions/sign.Transaction' + produces: + - application/json + responses: + "200": + description: Transaction hash and tick + schema: + $ref: '#/definitions/cardinal_server_handler.PostTransactionResponse' + "400": + description: Invalid request parameter + schema: + type: string + summary: Submits a transaction + /tx/game/{txName}: + post: + consumes: + - application/json + description: Submits a transaction + parameters: + - description: Name of a registered message + in: path + name: txName + required: true + type: string + - description: Transaction details & message to be submitted + in: body + name: txBody + required: true + schema: + $ref: '#/definitions/sign.Transaction' + produces: + - application/json + responses: + "200": + description: Transaction hash and tick + schema: + $ref: '#/definitions/cardinal_server_handler.PostTransactionResponse' + "400": + description: Invalid request parameter + schema: + type: string + summary: Submits a transaction + /tx/persona/create-persona: + post: + consumes: + - application/json + description: Creates a persona + parameters: + - description: Transaction details & message to be submitted + in: body + name: txBody + required: true + schema: + $ref: '#/definitions/sign.Transaction' + produces: + - application/json + responses: + "200": + description: Transaction hash and tick + schema: + $ref: '#/definitions/cardinal_server_handler.PostTransactionResponse' + "400": + description: Invalid request parameter + schema: + type: string + summary: Creates a persona + /world: + get: + consumes: + - application/json + description: Contains the registered components, messages, queries, and namespace + produces: + - application/json + responses: + "200": + description: Details of the game world + schema: + $ref: '#/definitions/pkg_world_dev_world-engine_cardinal_types.WorldInfo' + "400": + description: Invalid request parameters + schema: + type: string + summary: Retrieves details of the game world +schemes: +- http +- ws +swagger: "2.0" diff --git a/v2/server/error.go b/v2/server/error.go new file mode 100644 index 000000000..91689794c --- /dev/null +++ b/v2/server/error.go @@ -0,0 +1,28 @@ +package server + +import ( + "errors" + + "github.com/gofiber/fiber/v2" +) + +type ErrorResponse struct { + Error Error `json:"error"` +} + +type Error struct { + Message string `json:"message"` +} + +var ErrorHandler = func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + + var e *fiber.Error + if errors.As(err, &e) { + code = e.Code + } + + c.Set(fiber.HeaderContentType, "application/json") + + return c.Status(code).JSON(ErrorResponse{Error: Error{Message: err.Error()}}) +} diff --git a/v2/server/handler/debug.go b/v2/server/handler/debug.go new file mode 100644 index 000000000..4173c33dc --- /dev/null +++ b/v2/server/handler/debug.go @@ -0,0 +1,50 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type DebugStateRequest struct{} + +type DebugStateResponse = []types.EntityData + +// GetState godoc +// +// @Summary Retrieves a list of all entities in the game state +// @Description Retrieves a list of all entities in the game state +// @Produce application/json +// @Success 200 {object} DebugStateResponse "List of all entities" +// @Router /debug/state [post] +func GetState(w *world.World) func(*fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + entities := make([]types.EntityData, 0) + + var eachErr error + err := w.Search(filter.All()).Each(func(id types.EntityID) bool { + components, err := w.State().FinalizedState().GetAllComponentsForEntityInRawJSON(id) + if err != nil { + eachErr = err + return false + } + + entities = append(entities, types.EntityData{ + ID: id, + Components: components, + }) + + return true + }) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + if eachErr != nil { + return fiber.NewError(fiber.StatusInternalServerError, eachErr.Error()) + } + + return ctx.JSON(entities) + } +} diff --git a/v2/server/handler/events.go b/v2/server/handler/events.go new file mode 100644 index 000000000..d6750c8f1 --- /dev/null +++ b/v2/server/handler/events.go @@ -0,0 +1,31 @@ +package handler + +import ( + "github.com/gofiber/contrib/socketio" + "github.com/gofiber/contrib/websocket" + "github.com/gofiber/fiber/v2" + "github.com/rs/zerolog/log" +) + +// WebSocketEvents godoc +// +// @Summary Establishes a new websocket connection to retrieve system events +// @Description Establishes a new websocket connection to retrieve system events +// @Produce application/json +// @Success 101 {string} string "Switch protocol to ws" +// @Router /events [get] +func WebSocketEvents() func(c *fiber.Ctx) error { + return socketio.New(func(_ *socketio.Websocket) { + log.Debug().Msg("new websocket connection established") + }) +} + +func WebSocketUpgrader(c *fiber.Ctx) error { + // IsWebSocketUpgrade returns true if the client + // requested upgrade to the WebSocket protocol. + if websocket.IsWebSocketUpgrade(c) { + c.Locals("allowed", true) + return c.Next() + } + return fiber.ErrUpgradeRequired +} diff --git a/v2/server/handler/health.go b/v2/server/handler/health.go new file mode 100644 index 000000000..4b52f8810 --- /dev/null +++ b/v2/server/handler/health.go @@ -0,0 +1,27 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" +) + +type GetHealthResponse struct { + IsServerRunning bool `json:"isServerRunning"` + IsGameLoopRunning bool `json:"isGameLoopRunning"` +} + +// GetHealth godoc +// +// @Summary Retrieves the status of the server and game loop +// @Description Retrieves the status of the server and game loop +// @Produce application/json +// @Success 200 {object} GetHealthResponse "Server and game loop status" +// @Router /health [get] +func GetHealth() func(c *fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + return ctx.JSON(GetHealthResponse{ + IsServerRunning: true, + // TODO(scott): reconsider whether we need this. Intuitively server running implies game loop running. + IsGameLoopRunning: true, + }) + } +} diff --git a/v2/server/handler/query.go b/v2/server/handler/query.go new file mode 100644 index 000000000..eac3eeffc --- /dev/null +++ b/v2/server/handler/query.go @@ -0,0 +1,34 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +// PostQuery godoc +// +// @Summary Executes a query +// @Description Executes a query +// @Accept application/json +// @Produce application/json +// @Param queryGroup path string true "Query group" +// @Param queryName path string true "Name of a registered query" +// @Param queryBody body object true "Query to be executed" +// @Success 200 {object} object "Results of the executed query" +// @Failure 400 {string} string "Invalid request parameters" +// @Router /query/{queryGroup}/{queryName} [post] +func PostQuery(w *world.World) func(*fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + ctx.Set("Content-Type", "application/json") + resBz, err := w.HandleQuery(ctx.Params("group"), ctx.Params("name"), ctx.Body()) + if eris.Is(err, types.ErrQueryNotFound) { + return fiber.NewError(fiber.StatusNotFound, "query not found") + } else if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "encountered an error in query: "+err.Error()) + } + return ctx.Send(resBz) + } +} diff --git a/v2/server/handler/receipts.go b/v2/server/handler/receipts.go new file mode 100644 index 000000000..0faf8a803 --- /dev/null +++ b/v2/server/handler/receipts.go @@ -0,0 +1,47 @@ +package handler + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" + "github.com/gofiber/fiber/v2" + + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type GetReceiptsRequest struct { + TxHashes []common.Hash `json:"txHashes"` +} + +// GetReceiptsResponse returns the transaction receipts for the given range of ticks. The interval is closed on +// StartTick and open on EndTick: i.e. [StartTick, EndTick) +// Meaning StartTick is included and EndTick is not. To iterate over all ticks in the future, use the returned +// EndTick as the StartTick in the next request. If StartTick == EndTick, the receipts list will be empty. +type GetReceiptsResponse struct { + Receipts map[common.Hash]json.RawMessage `json:"receipts"` +} + +// GetReceipts godoc +// +// @Summary Retrieves all transaction receipts +// @Description Retrieves all transaction receipts +// @Accept application/json +// @Produce application/json +// @Param GetReceiptsRequest body GetReceiptsRequest true "Query body" +// @Success 200 {object} GetReceiptsResponse "List of receipts" +// @Failure 400 {string} string "Invalid request body" +// @Router /query/receipts/list [post] +func GetReceipts(w *world.World) func(*fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + req := new(GetReceiptsRequest) + if err := ctx.BodyParser(req); err != nil { + return err + } + + receipts, err := w.GetReceiptsBytes(req.TxHashes) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "failed to get receipts: "+err.Error()) + } + + return ctx.JSON(&GetReceiptsResponse{Receipts: receipts}) + } +} diff --git a/v2/server/handler/tx.go b/v2/server/handler/tx.go new file mode 100644 index 000000000..dbdaa4b4c --- /dev/null +++ b/v2/server/handler/tx.go @@ -0,0 +1,88 @@ +package handler + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/gofiber/fiber/v2" + + "pkg.world.dev/world-engine/cardinal/v2/types/message" + "pkg.world.dev/world-engine/cardinal/v2/world" + "pkg.world.dev/world-engine/sign" +) + +// PostTransactionResponse is the HTTP response for a successful transaction submission +type PostTransactionResponse struct { + TxHash common.Hash `json:"txHash"` +} + +// PostTransaction godoc +// +// @Summary Submits a transaction +// @Description Submits a transaction +// @Accept application/json +// @Produce application/json +// @Param group path string true "Message group" +// @Param name path string true "Name of a registered message" +// @Param body body sign.Transaction true "Transaction details & message to be submitted" +// @Success 200 {object} PostTransactionResponse "Transaction hash and tick" +// @Failure 400 {string} string "Invalid request parameter" +// @Router /tx/{group}/{name} [post] +func PostTransaction(w *world.World) func(*fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + tx := new(sign.Transaction) + if err := ctx.BodyParser(tx); err != nil { + return fiber.NewError(fiber.StatusBadRequest, "failed to parse request body: "+err.Error()) + } + + group := ctx.Params("group") + name := ctx.Params("name") + + var msgName string + if group == message.DefaultGroup { + msgName = name + } else { + msgName = group + "." + name + } + + txHash, err := w.AddTransaction(msgName, tx) + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "failed to submit transaction: "+err.Error()) + } + + return ctx.JSON(PostTransactionResponse{ + TxHash: txHash, + }) + } +} + +// ----------------------------------------------------------------------------- +// For Swagger Docs +// ----------------------------------------------------------------------------- + +// PostGameTransaction godoc +// +// @Summary Submits a transaction +// @Description Submits a transaction +// @Accept application/json +// @Produce application/json +// @Param txName path string true "Name of a registered message" +// @Param txBody body sign.Transaction true "Transaction details & message to be submitted" +// @Success 200 {object} PostTransactionResponse "Transaction hash and tick" +// @Failure 400 {string} string "Invalid request parameter" +// @Router /tx/game/{txName} [post] +func PostGameTransaction(w *world.World) func(*fiber.Ctx) error { + return PostTransaction(w) +} + +// PostPersonaTransaction godoc +// +// @Summary Creates a persona +// @Description Creates a persona +// @Accept application/json +// @Produce application/json +// @Param txBody body sign.Transaction true "Transaction details & message to be submitted" +// @Success 200 {object} PostTransactionResponse "Transaction hash and tick" +// @Failure 400 {string} string "Invalid request parameter" +// @Router /tx/persona/create-persona [post] +func PostPersonaTransaction(w *world.World) func(*fiber.Ctx) error { + return PostTransaction(w) +} diff --git a/v2/server/handler/world.go b/v2/server/handler/world.go new file mode 100644 index 000000000..ed1e25e60 --- /dev/null +++ b/v2/server/handler/world.go @@ -0,0 +1,41 @@ +package handler + +import ( + "github.com/gofiber/fiber/v2" + + "pkg.world.dev/world-engine/cardinal/v2/server/utils" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type GetWorldResponse = types.WorldInfo + +// GetWorld godoc +// +// @Summary Retrieves details of the game world +// @Description Contains the registered components, messages, queries, and namespace +// @Accept application/json +// @Produce application/json +// @Success 200 {object} types.WorldInfo "Details of the game world" +// @Failure 400 {string} string "Invalid request parameters" +// @Router /world [get] +func GetWorld(w *world.World) func(*fiber.Ctx) error { + return func(ctx *fiber.Ctx) error { + queries := w.RegisteredQuries() + queryInfo := make([]types.EndpointInfo, 0, len(queries)) + for _, q := range queries { + queryInfo = append(queryInfo, types.EndpointInfo{ + Name: q.Name(), + Fields: q.GetRequestFieldInformation(), + URL: utils.GetQueryURL(q.Group(), q.Name()), + }) + } + + return ctx.JSON(&types.WorldInfo{ + Namespace: w.Namespace(), + Components: w.State().RegisteredComponents(), + Messages: w.RegisteredMessages(), + Queries: queryInfo, + }) + } +} diff --git a/v2/server/server.go b/v2/server/server.go new file mode 100644 index 000000000..f040dc583 --- /dev/null +++ b/v2/server/server.go @@ -0,0 +1,143 @@ +package server + +import ( + "context" + "encoding/json" + "os" + "time" + + "github.com/gofiber/contrib/socketio" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/swagger" + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + + "pkg.world.dev/world-engine/cardinal/v2/server/handler" + "pkg.world.dev/world-engine/cardinal/v2/world" + + _ "pkg.world.dev/world-engine/cardinal/v2/server/docs" // for swagger. +) + +const ( + defaultPort = "4040" + shutdownTimeout = 5 * time.Second +) + +type Server struct { + app *fiber.App + w *world.World +} + +// New returns an HTTP server with handlers for all QueryTypes and MessageTypes. +func New(w *world.World) (*Server, error) { + if w == nil { + return nil, eris.New("server requires an non-nil world and tick manager") + } + + app := fiber.New(fiber.Config{ + Network: "tcp", // Enable server listening on both ipv4 & ipv6 (default: ipv4 only) + DisableStartupMessage: true, + ErrorHandler: ErrorHandler, + }) + app.Use(cors.New()) + + s := &Server{ + app: app, + w: w, + } + s.setupRoutes() + + return s, nil +} + +// Serve serves the application, blocking the calling thread. +// Call this in a new go routine to prevent blocking. +func (s *Server) Serve(ctx context.Context) error { + serverErr := make(chan error, 1) + + // Starts the server in a new goroutine + go func() { + port := os.Getenv("CARDINAL_PORT") + if port == "" { + port = defaultPort + } + + log.Info().Msgf("Starting HTTP server at port %s", port) + if err := s.app.Listen(":" + port); err != nil { + serverErr <- eris.Wrap(err, "error starting http server") + } + }() + + // This function will block until the server is shutdown or the context is canceled. + select { + case err := <-serverErr: + return eris.Wrap(err, "server encountered an error") + case <-ctx.Done(): + if err := s.shutdown(); err != nil { + return eris.Wrap(err, "error shutting down server") + } + } + + return nil +} + +func (s *Server) BroadcastEvent(event any) error { + eventBz, err := json.Marshal(event) + if err != nil { + return err + } + socketio.Broadcast(eventBz) + return nil +} + +// Shutdown gracefully shuts down the server and closes all active websocket connections. +func (s *Server) shutdown() error { + log.Info().Msg("Shutting down server") + + // Close websocket connections + socketio.Broadcast([]byte(""), socketio.CloseMessage) + socketio.Fire(socketio.EventClose, nil) + + // Gracefully shutdown Fiber server + if err := s.app.ShutdownWithTimeout(shutdownTimeout); err != nil { + return eris.Wrap(err, "error shutting down server") + } + + log.Info().Msg("Successfully shut down server") + return nil +} + +// @title Cardinal +// @description Backend server for World Engine +// @version 0.0.1 +// @schemes http ws +// @BasePath / +// @consumes application/json +// @produces application/json +func (s *Server) setupRoutes() { + // Route: /swagger/ + s.app.Get("/swagger/*", swagger.HandlerDefault) + + // Route: /events/ + s.app.Use("/events", handler.WebSocketUpgrader) + s.app.Get("/events", handler.WebSocketEvents()) + + // Route: /world + s.app.Get("/world", handler.GetWorld(s.w)) + + // Route: /... + s.app.Get("/health", handler.GetHealth()) + + // Route: /query/... + q := s.app.Group("/query") + q.Post("/receipts/list", handler.GetReceipts(s.w)) + q.Post("/:group/:name", handler.PostQuery(s.w)) + + // Route: /tx/... + tx := s.app.Group("/tx") + tx.Post("/:group/:name", handler.PostTransaction(s.w)) + + // Route: /debug/state + s.app.Post("/debug/state", handler.GetState(s.w)) +} diff --git a/v2/server/server_test.go b/v2/server/server_test.go new file mode 100644 index 000000000..6e516bf3a --- /dev/null +++ b/v2/server/server_test.go @@ -0,0 +1,440 @@ +package server_test + +import ( + "crypto/ecdsa" + "encoding/json" + "fmt" + "io" + "math/rand" + "net/http" + "slices" + "strings" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/suite" + "github.com/swaggo/swag" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/server/handler" + "pkg.world.dev/world-engine/cardinal/v2/server/utils" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" + "pkg.world.dev/world-engine/cardinal/v2/world" + "pkg.world.dev/world-engine/sign" +) + +// Used for Registering message +type MoveMsgInput struct { + Direction string +} + +func (MoveMsgInput) Name() string { return "move" } + +// Used for Registering message +type MoveMessageOutput struct { + Location LocationComponent +} + +type QueryLocationRequest struct { + Persona string +} + +type QueryLocationResponse struct { + LocationComponent +} + +type ServerTestSuite struct { + suite.Suite + + fixture *cardinal.TestCardinal + + privateKey *ecdsa.PrivateKey + signerAddr string + nonce uint64 +} + +var moveMsgName = "move" + +func TestServer(t *testing.T) { + suite.Run(t, new(ServerTestSuite)) +} + +// SetupSuite runs before each test in the suite. +func (s *ServerTestSuite) SetupTest() { + var err error + s.privateKey, err = crypto.GenerateKey() + s.Require().NoError(err) + s.signerAddr = crypto.PubkeyToAddress(s.privateKey.PublicKey).Hex() +} + +// TestCanClaimPersonaSendGameTxAndQueryGame tests that you can claim a persona, send a tx, and then query. +func (s *ServerTestSuite) TestCanClaimPersonaSendGameTxAndQueryGame() { + s.setupWorld() + s.fixture.DoTick() + + personaTag := s.CreateRandomPersona() + + s.submitTx(moveMsgName, personaTag, MoveMsgInput{Direction: "up"}) + + res := s.fixture.Post("query/game/location", QueryLocationRequest{Persona: personaTag}) + + var loc LocationComponent + err := json.Unmarshal([]byte(s.readBody(res.Body)), &loc) + s.Require().NoError(err) + s.Require().Equal(LocationComponent{0, 1}, loc) +} + +// TestGetFieldInformation tests the fields endpoint. +func (s *ServerTestSuite) TestGetWorld() { + s.setupWorld() + s.fixture.DoTick() + res := s.fixture.Get("/world") + + var result handler.GetWorldResponse + err := json.Unmarshal([]byte(s.readBody(res.Body)), &result) + s.Require().NoError(err) + + comps := s.fixture.World().State().RegisteredComponents() + msgs := s.fixture.World().RegisteredMessages() + queries := s.fixture.World().RegisteredQuries() + s.Require().Len(comps, len(result.Components)) + s.Require().Len(msgs, len(result.Messages)) + s.Require().Len(queries, len(result.Queries)) + + // check that the component, message, query name are in the list + for _, comp := range comps { + assert.True(s.T(), slices.ContainsFunc(result.Components, func(field types.ComponentInfo) bool { + return comp.Name == field.Name + })) + } + for _, msg := range msgs { + assert.True(s.T(), slices.ContainsFunc(result.Messages, func(field types.EndpointInfo) bool { + return msg.Name == field.Name + })) + } + for _, query := range queries { + assert.True(s.T(), slices.ContainsFunc(result.Queries, func(field types.EndpointInfo) bool { + return query.Name() == field.Name + })) + } + + assert.Equal(s.T(), s.fixture.World().Namespace(), result.Namespace) +} + +// TestSwaggerEndpointsAreActuallyCreated verifies the non-variable endpoints that are declared in the swagger.yml file +// actually have endpoints when the cardinal server starts. +func (s *ServerTestSuite) TestSwaggerEndpointsAreActuallyCreated() { + s.setupWorld() + s.fixture.DoTick() + m := map[string]any{} + s.Require().NoError(json.Unmarshal([]byte(swag.GetSwagger("swagger").ReadDoc()), &m)) + paths, ok := m["paths"].(map[string]any) + s.Require().True(ok) + + for path, iface := range paths { + info, ok := iface.(map[string]any) + s.Require().True(ok) + if strings.ContainsAny(path, "{}") { + // Don't bother verifying paths that contain variables. + continue + } + if _, ok := info["get"]; ok { + res := s.fixture.Get(path) + // This test is only checking to make sure the endpoint can be found. + s.NotEqualf(404, res.StatusCode, + "swagger defines GET %q, but that endpoint was not found", path) + s.NotEqualf(405, res.StatusCode, + "swagger defines GET %q, but GET is not allowed on that endpoint", path) + } + if _, ok := info["post"]; ok { + emptyPayload := struct{}{} + res := s.fixture.Post(path, emptyPayload) + // This test is only checking to make sure the endpoint can be found. + s.NotEqualf(404, res.StatusCode, + "swagger defines POST %q, but that endpoint was not found", path) + s.NotEqualf(405, res.StatusCode, + "swagger defines GET %q, but POST is not allowed on that endpoint", path) + } + } +} + +// TestCanSendTxWithoutSigVerification tests that you can submit a tx with just a persona and body when sig verification +// is disabled. +func (s *ServerTestSuite) TestCanSendTxWithoutSigVerification() { + s.setupWorld(cardinal.WithDisableSignatureVerification()) + s.fixture.DoTick() + persona := s.CreateRandomPersona() + s.createPersona(persona) + msg := MoveMsgInput{Direction: "up"} + + s.submitTxWithoutSig(message.DefaultGroup, moveMsgName, persona, msg) + + s.fixture.DoTick() + s.nonce++ + + // check the component was successfully updated, despite not using any signature data. + res := s.fixture.Post("query/game/location", QueryLocationRequest{Persona: persona}) + + var loc LocationComponent + err := json.Unmarshal([]byte(s.readBody(res.Body)), &loc) + s.Require().NoError(err) + + s.Require().Equal(LocationComponent{0, 1}, loc) +} + +func (s *ServerTestSuite) TestQueryCustomGroup() { + type SomeRequest struct{} + type SomeResponse struct{} + s.setupWorld() + name := "foo" + group := "bar" + called := false + err := world.RegisterQuery[SomeRequest, SomeResponse]( + s.fixture.World(), + name, + func(_ world.WorldContextReadOnly, _ *SomeRequest) (*SomeResponse, error) { + called = true + return &SomeResponse{}, nil + }, + world.WithGroup[SomeRequest, SomeResponse](group), + ) + s.Require().NoError(err) + s.fixture.DoTick() + res := s.fixture.Post(utils.GetQueryURL(group, name), SomeRequest{}) + s.Require().Equal(fiber.StatusOK, res.StatusCode) + s.Require().True(called) +} + +func (s *ServerTestSuite) TestMissingSignerAddressIsOKWhenSigVerificationIsDisabled() { + s.setupWorld(cardinal.WithDisableSignatureVerification()) + s.fixture.DoTick() + unclaimedPersona := "some-persona" + // This persona tag does not have a signer address, but since signature verification is disabled it should + // encounter no errors + s.submitTx(moveMsgName, unclaimedPersona, MoveMsgInput{Direction: "up"}) +} + +func (s *ServerTestSuite) TestSignerAddressIsRequiredWhenSigVerificationIsEnabled() { + t := s.T() + // Signature verification is enabled + s.setupWorld() + s.fixture.DoTick() + unclaimedPersona := "some-persona" + payload := MoveMsgInput{Direction: "up"} + tx, err := sign.NewTransaction(s.privateKey, unclaimedPersona, s.fixture.World().Namespace(), payload) + assert.NilError(t, err) + + // This request should fail because signature verification is enabled, and we have not yet + // registered the given personaTag + res := s.fixture.Post(utils.GetTxURL(moveMsgName), tx) + assert.Equal(t, http.StatusBadRequest, res.StatusCode) +} + +// Creates a transaction with the given message, and runs it in a tick. +func (s *ServerTestSuite) submitTx(name string, personaTag string, payload any) common.Hash { + tx, err := sign.NewTransaction(s.privateKey, personaTag, s.fixture.World().Namespace(), payload) + s.Require().NoError(err) + + res := s.fixture.Post(utils.GetTxURL(name), tx) + resBody := s.readBody(res.Body) + s.Require().Equal(fiber.StatusOK, res.StatusCode, resBody) + + var txResp handler.PostTransactionResponse + err = json.Unmarshal([]byte(resBody), &txResp) + s.Require().NoError(err) + + s.fixture.DoTick() + s.nonce++ + + return txResp.TxHash +} + +func (s *ServerTestSuite) submitTxWithoutSig(group string, name string, personaTag string, payload any) { + body, err := json.Marshal(payload) + s.Require().NoError(err) + + tx := &sign.Transaction{ + PersonaTag: personaTag, + Body: body, + } + + res := s.fixture.Post(utils.GetTxURL(name), tx) + s.Require().Equal(fiber.StatusOK, res.StatusCode, s.readBody(res.Body)) + + s.fixture.DoTick() + s.nonce++ +} + +// Creates a persona with the specified tag. +func (s *ServerTestSuite) createPersona(personaTag string) { + createPersonaTx := world.CreatePersona{ + PersonaTag: personaTag, + } + + tx, err := sign.NewTransaction(s.privateKey, "foo", s.fixture.World().Namespace(), createPersonaTx) + s.Require().NoError(err) + + res := s.fixture.Post(utils.GetTxURL("persona.create-persona"), tx) + s.Require().Equal(fiber.StatusOK, res.StatusCode, s.readBody(res.Body)) + s.fixture.DoTick() + s.nonce++ +} + +// setupWorld sets up a world with a simple movement system, message, and query. +func (s *ServerTestSuite) setupWorld(opts ...cardinal.CardinalOption) { + s.fixture = cardinal.NewTestCardinal(s.T(), nil, opts...) + + err := world.RegisterComponent[LocationComponent](s.fixture.World()) + s.Require().NoError(err) + + err = world.RegisterMessage[MoveMsgInput](s.fixture.World()) + s.Require().NoError(err) + + personaToPosition := make(map[string]types.EntityID) + err = world.RegisterSystems(s.fixture.World(), func(context world.WorldContext) error { + return world.EachMessage[MoveMsgInput](context, + func(tx message.TxType[MoveMsgInput]) (any, error) { + posID, exists := personaToPosition[tx.PersonaTag()] + if !exists { + id, err := world.Create(context, LocationComponent{}) + s.Require().NoError(err) + personaToPosition[tx.PersonaTag()] = id + posID = id + } + var resultLocation LocationComponent + err = world.UpdateComponent[LocationComponent](context, posID, + func(loc *LocationComponent) *LocationComponent { + switch tx.Msg().Direction { + case "up": + loc.Y++ + case "down": + loc.Y-- + case "right": + loc.X++ + case "left": + loc.X-- + } + resultLocation = *loc + return loc + }) + s.Require().NoError(err) + return MoveMessageOutput{resultLocation}, nil + }) + }) + assert.NilError(s.T(), err) + + err = world.RegisterQuery[QueryLocationRequest, QueryLocationResponse]( + s.fixture.World(), + "location", + func(wCtx world.WorldContextReadOnly, req *QueryLocationRequest) (*QueryLocationResponse, error) { + locID, exists := personaToPosition[req.Persona] + if !exists { + return nil, fmt.Errorf("location for %q does not exists", req.Persona) + } + loc, err := world.GetComponent[LocationComponent](wCtx, locID) + s.Require().NoError(err) + + return &QueryLocationResponse{*loc}, nil + }, + ) + s.Require().NoError(err) +} + +// returns the body of an http response as string. +func (s *ServerTestSuite) readBody(body io.ReadCloser) string { + buf, err := io.ReadAll(body) + s.Require().NoError(err) + return string(buf) +} + +// CreateRandomPersona Creates a random persona and returns it as a string +func (s *ServerTestSuite) CreateRandomPersona() string { + letterRunes := []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ") + r := rand.New(rand.NewSource(time.Now().UnixNano())) + + length := 5 + result := make([]byte, length) + for i := 0; i < length; i++ { + result[i] = byte(letterRunes[r.Intn(len(letterRunes))]) + } + + personaTag := string(result) + s.createPersona(personaTag) + + return personaTag +} + +type LocationComponent struct { + X, Y uint64 +} + +func (LocationComponent) Name() string { + return "location" +} + +func (s *ServerTestSuite) TestDebugStateQuery() { + s.setupWorld() + + const wantNumOfZeroLocation = 5 + err := world.RegisterInitSystems(s.fixture.World(), func(wCtx world.WorldContext) error { + _, err := world.CreateMany(wCtx, wantNumOfZeroLocation, LocationComponent{}) + return err + }) + assert.NilError(s.T(), err) + + s.fixture.DoTick() + + personaTag := s.CreateRandomPersona() + + // This will create 1 additional location for this particular persona tag + s.submitTx(moveMsgName, personaTag, MoveMsgInput{Direction: "up"}) + + res := s.fixture.Post("debug/state", handler.DebugStateRequest{}) + s.Require().Equal(res.StatusCode, 200) + + var results []types.EntityData + s.Require().NoError(json.NewDecoder(res.Body).Decode(&results)) + + numOfZeroLocation := 0 + numOfNonZeroLocation := 0 + for _, result := range results { + comp := result.Components["location"] + if comp == nil { + continue + } + var loc LocationComponent + s.Require().NoError(json.Unmarshal(comp, &loc)) + + if loc.Y == 0 { + numOfZeroLocation++ + } else { + numOfNonZeroLocation++ + } + } + s.Require().Equal(numOfZeroLocation, wantNumOfZeroLocation) + s.Require().Equal(numOfNonZeroLocation, 1) +} + +func (s *ServerTestSuite) TestDebugStateQuery_NoState() { + s.setupWorld() + s.fixture.DoTick() + + res := s.fixture.Post("debug/state", handler.DebugStateRequest{}) + s.Require().Equal(res.StatusCode, 200) + + var results []types.EntityData + s.Require().NoError(json.NewDecoder(res.Body).Decode(&results)) + + s.Require().Equal(len(results), 0) +} + +type fooIn struct{} + +func (fooIn) Name() string { return "foo" } + +type fooOut struct{ Y int } diff --git a/v2/server/utils/utils.go b/v2/server/utils/utils.go new file mode 100644 index 000000000..a45ee47be --- /dev/null +++ b/v2/server/utils/utils.go @@ -0,0 +1,19 @@ +package utils + +import ( + "strings" + + "pkg.world.dev/world-engine/cardinal/v2/types/message" +) + +func GetQueryURL(group string, name string) string { + return "/query/" + group + "/" + name +} + +func GetTxURL(name string) string { + nameParts := strings.Split(name, ".") + if len(nameParts) == 1 { + return "/tx/" + message.DefaultGroup + "/" + nameParts[0] + } + return "/tx/" + nameParts[0] + "/" + nameParts[1] +} diff --git a/v2/storage/nonce_test.go b/v2/storage/nonce_test.go new file mode 100644 index 000000000..053f4bdb8 --- /dev/null +++ b/v2/storage/nonce_test.go @@ -0,0 +1,138 @@ +package storage_test + +import ( + "context" + "fmt" + "math/rand" + "testing" + + "github.com/alicebob/miniredis/v2" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2/storage/redis" +) + +const Namespace string = "world" + +func GetRedisStorage(t *testing.T) redis.Storage { + s := miniredis.RunT(t) + return redis.NewRedisStorage(redis.Options{ + Addr: s.Addr(), + Password: "", // no password set + DB: 0, // use default DB + }, Namespace) +} +func TestUseNonce(t *testing.T) { + rs := GetRedisStorage(t) + address := "some-address" + nonce := uint64(100) + assert.NilError(t, rs.UseNonce(address, nonce)) +} + +func TestCanStoreManyNonces(t *testing.T) { + rs := GetRedisStorage(t) + for i := uint64(10); i < 100; i++ { + addr := fmt.Sprintf("%d", i) + assert.NilError(t, rs.UseNonce(addr, i)) + } + + // These nonces can no longer be used + for i := uint64(10); i < 100; i++ { + addr := fmt.Sprintf("%d", i) + err := rs.UseNonce(addr, i) + assert.ErrorIs(t, redis.ErrNonceHasAlreadyBeenUsed, err) + } +} + +func TestNonceStorageIsBounded(t *testing.T) { + rs := GetRedisStorage(t) + addr := "some-address" + // totalNonceCount is the total number of nonces we want to "use" + totalNonceCount := 3 * redis.NonceSlidingWindowSize + // maxNonceSaved is the maximum number of nonces we should expect to see at the end of this test. If this value is + // exceeded it likely means the storage for nonces is unbounded. We don't want to set this limit to *exactly* + // NonceSlidingWindowSize because we want the nonce manager to have some leeway as to when exactly it will prune + // old nonces. + maxNonceSaved := 2 * redis.NonceSlidingWindowSize + for i := 0; i < totalNonceCount; i++ { + assert.NilError(t, rs.UseNonce(addr, uint64(i)), "using nonce %v failed", i) + } + + // Find the redis key that contains the used nonces for the above address + client := rs.Client + ctx := context.Background() + keys, err := client.Keys(ctx, "*"+addr+"*").Result() + assert.NilError(t, err) + assert.Equal(t, 1, len(keys)) + + // This test assumes we're using Redis to keep track of used nonces. If/when our storage layer changes, this test + // should be kept, but the mechanism for counting the number of saved nonces will need to be updated. + count, err := client.ZCard(ctx, keys[0]).Result() + assert.NilError(t, err) + assert.Check(t, count < int64(maxNonceSaved), "nonce tracking is unbounded") +} + +func TestOutOfOrderNoncesAreOK(t *testing.T) { + count := 100 + nonces := make([]uint64, count) + for i := range nonces { + nonces[i] = uint64(i) + } + r := rand.New(rand.NewSource(0)) + r.Shuffle(count, func(i, j int) { + nonces[i], nonces[j] = nonces[j], nonces[i] + }) + + rs := GetRedisStorage(t) + for _, n := range nonces { + assert.NilError(t, rs.UseNonce("some-addr", n)) + } + m := map[int]int{} + clear(m) +} + +// TestCannotReuseNonceAfterPrune ensures off-by-one errors related to pruning nonces outside the NonceSlidingWindowSize +// do not result in the ability to reuse an already-used nonce. +func TestCannotReuseNonceAfterPrune(t *testing.T) { + rs := GetRedisStorage(t) + total := 3 * redis.NonceSlidingWindowSize + addr := "some-addr" + for i := 0; i < total; i++ { + assert.NilError(t, rs.UseNonce(addr, uint64(i))) + if i > 10 { + err := rs.UseNonce(addr, uint64(i-10)) + assert.ErrorIs(t, redis.ErrNonceHasAlreadyBeenUsed, err) + } + if i > redis.NonceSlidingWindowSize+1 { + alreadyUsed := uint64(i - redis.NonceSlidingWindowSize) + before := alreadyUsed - 1 + after := alreadyUsed + 1 + + // Make sure the nonces around the sliding window size always return an error + assert.IsError(t, rs.UseNonce(addr, before), "%d was already used", before) + assert.IsError(t, rs.UseNonce(addr, alreadyUsed), "%d was already used", alreadyUsed) + assert.IsError(t, rs.UseNonce(addr, after), "%d was already used", after) + } + } +} + +func TestUsedNoncesAreRememberedAcrossRestart(t *testing.T) { + s := miniredis.RunT(t) + opts := redis.Options{ + Addr: s.Addr(), + Password: "", // no password set + DB: 0, // use default DB + } + rsOne := redis.NewRedisStorage(opts, Namespace) + + addr := "some-addr" + for i := 0; i < 10; i++ { + assert.NilError(t, rsOne.UseNonce(addr, uint64(i))) + } + + rsTwo := redis.NewRedisStorage(opts, Namespace) + for i := 0; i < 10; i++ { + err := rsTwo.UseNonce(addr, uint64(i)) + assert.ErrorIs(t, redis.ErrNonceHasAlreadyBeenUsed, err) + } +} diff --git a/v2/storage/redis/keys.go b/v2/storage/redis/keys.go new file mode 100644 index 000000000..0ea5ace69 --- /dev/null +++ b/v2/storage/redis/keys.go @@ -0,0 +1,16 @@ +package redis + +import "fmt" + +/* + NONCE STORAGE: ADDRESS_TO_NONCE -> Nonce used for verifying signatures. + Hash set of signature address to uint64 nonce +*/ + +func (r *NonceStorage) nonceSetKey(str string) string { + return fmt.Sprintf("USED_NONCES_%s", str) +} + +func (r *SchemaStorage) schemaStorageKey() string { + return "COMPONENT_NAME_TO_SCHEMA_DATA" +} diff --git a/v2/storage/redis/nonce.go b/v2/storage/redis/nonce.go new file mode 100644 index 000000000..a5cedffd4 --- /dev/null +++ b/v2/storage/redis/nonce.go @@ -0,0 +1,143 @@ +package redis + +import ( + "context" + "strconv" + "sync" + + "github.com/redis/go-redis/v9" + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" +) + +const ( + // NonceSlidingWindowSize is the maximum distance a new nonce can be from the max nonce before it is rejected + // outright. + NonceSlidingWindowSize = 1000 + + // numOfNoncesToTriggerCleanup is the number of nonces in redis required for a cleanup pass to be initiated. + // A cleanup consists of removing all nonces that are beyond the NonceSlidingWindowSize from the maximum seen nonce. + // Each cleanup operation costs O(log(N)+M) where N is the number of items in the set and M is the number of items + // to be removed. If this number is close to NonceSlidingWindowSize, we will spend more time removing old nonces + // from redis, but the total number of nonces saved will be smaller. The larger this number gets, the less time we + // will spend removing old nonces, but the total number of nonces saved will be larger. + numOfNoncesToTriggerCleanup = NonceSlidingWindowSize * 1.5 + + // maxValidNonce is the largest nonce that is guaranteed to have a unique ZSet score from all smaller nonces. + // A ZSet in redis is used to track unique nonces. Each item in a ZSet has a score, which is stored as a float64. + // Due to the precision loss when converting large integers to floating point numbers, at some point 2 distinct + // nonces will map to the same score in the Redis ZSet. + maxValidNonce = (1 << (float64MantissaSize + 1)) - 1 + float64MantissaSize = 52 +) + +var ErrNonceHasAlreadyBeenUsed = eris.New("nonce has already been used") + +type NonceStorage struct { + Client *redis.Client + // mutex locks the UseNonce function to make it safe for concurrent access. This is a single lock for all signer + // addresses. An improvement on NonceStorage would have a different lock for each signer addresses. + mutex *sync.Mutex + // maxNonce tracks the highest nonce seen for a particular signer address + maxNonce map[string]uint64 + // countNonce tracks the number of nonces stored in redis for each signer address. This count will increase as + // nonces are used and decrease as out-of-window nonces are removed from redis. + countNonce map[string]int +} + +func NewNonceStorage(client *redis.Client) NonceStorage { + return NonceStorage{ + Client: client, + mutex: &sync.Mutex{}, + maxNonce: map[string]uint64{}, + countNonce: map[string]int{}, + } +} + +// UseNonce atomically marks the given nonce as used. The nonce is valid if nil is returned. A non-nil error means +// there was an error verifying the nonce, or the nonce was already used. +func (r *NonceStorage) UseNonce(signerAddress string, nonce uint64) error { + if nonce > maxValidNonce { + return eris.New("nonce is too large") + } + ctx := context.Background() + signerAddressKey := r.nonceSetKey(signerAddress) + + // All redis and in-memory map operations happen inside a lock. This could be improved by creating a separate lock + // for each signer address. + r.mutex.Lock() + defer r.mutex.Unlock() + + maxNonce, err := r.getMaxNonceForKey(ctx, signerAddressKey) + if err != nil { + return eris.Wrap(err, "failed to get max nonce for signer address") + } + + // Nonces beyond the sliding window are invalid and can be rejected outright. + if nonce < maxNonce && maxNonce-nonce >= NonceSlidingWindowSize { + return eris.New("nonce is too old") + } + + zItem := redis.Z{ + Score: float64(nonce), + Member: nonce, + } + + added, err := r.Client.ZAdd(ctx, signerAddressKey, zItem).Result() + if err != nil { + return eris.Wrap(err, "failed to add nonce") + } + // A result of 0 from ZAdd means no new items were actually added to the Zset. This means the nonce was already + // used. + if added == 0 { + return eris.Wrapf(ErrNonceHasAlreadyBeenUsed, "signer %q has already used nonce %d", signerAddress, nonce) + } + + r.maxNonce[signerAddressKey] = max(r.maxNonce[signerAddressKey], nonce) + r.countNonce[signerAddressKey]++ + + if r.countNonce[signerAddressKey] > numOfNoncesToTriggerCleanup { + r.cleanupOldNonces(ctx, signerAddressKey, r.maxNonce[signerAddressKey]) + } + + return nil +} + +// cleanupOldNonces removes the record of all nonces that are older than NonceSlidingWindowSize. Nonces in that range +// can be rejected without checking storage. ZRemRangeByScore has a performance of O(log(N)+M) where N is the number +// of items in the set and M is the number of items to remove. +func (r *NonceStorage) cleanupOldNonces(ctx context.Context, signerAddressKey string, currMax uint64) { + minScore := "-inf" + maxScore := strconv.FormatUint(currMax-NonceSlidingWindowSize, 10) + removed, err := r.Client.ZRemRangeByScore(ctx, signerAddressKey, minScore, maxScore).Result() + if err != nil { + log.Err(err).Msg("failed to remove old nonces") + return + } + r.countNonce[signerAddressKey] -= int(removed) +} + +// getMaxNonceForKey returns the highest used nonce for the given key. +func (r *NonceStorage) getMaxNonceForKey(ctx context.Context, signerAddressKey string) (uint64, error) { + maxNonce, ok := r.maxNonce[signerAddressKey] + if ok { + return maxNonce, nil + } + // There isn't a max nonce in memory. Fetch it from redis. + values, err := r.Client.ZRange(ctx, signerAddressKey, -1, 0).Result() + if err != nil { + return 0, eris.Wrap(err, "failed to get range of nonce values") + } + if len(values) == 0 { + // No nonces have been used for this key + maxNonce = 0 + } else { + // At least 1 value was returned. + maxNonce, err = strconv.ParseUint(values[0], 10, 64) + if err != nil { + return 0, eris.Wrapf(err, "failed to convert %q to uint64", values[0]) + } + } + r.maxNonce[signerAddressKey] = maxNonce + return maxNonce, nil +} diff --git a/v2/storage/redis/schema.go b/v2/storage/redis/schema.go new file mode 100644 index 000000000..b5a026bec --- /dev/null +++ b/v2/storage/redis/schema.go @@ -0,0 +1,39 @@ +package redis + +import ( + "context" + "errors" + + "github.com/redis/go-redis/v9" + "github.com/rotisserie/eris" +) + +var ( + ErrNoSchemaFound = errors.New("no schema found") +) + +type SchemaStorage struct { + Client *redis.Client +} + +func NewSchemaStorage(client *redis.Client) SchemaStorage { + return SchemaStorage{ + Client: client, + } +} + +func (r *SchemaStorage) GetSchema(componentName string) ([]byte, error) { + ctx := context.Background() + schemaBytes, err := r.Client.HGet(ctx, r.schemaStorageKey(), componentName).Bytes() + if eris.Is(err, redis.Nil) { + return nil, eris.Wrap(err, ErrNoSchemaFound.Error()) + } else if err != nil { + return nil, eris.Wrap(err, "") + } + return schemaBytes, nil +} + +func (r *SchemaStorage) SetSchema(componentName string, schemaData []byte) error { + ctx := context.Background() + return eris.Wrap(r.Client.HSet(ctx, r.schemaStorageKey(), componentName, schemaData).Err(), "") +} diff --git a/v2/storage/redis/storage.go b/v2/storage/redis/storage.go new file mode 100644 index 000000000..f4e8abd49 --- /dev/null +++ b/v2/storage/redis/storage.go @@ -0,0 +1,53 @@ +package redis + +import ( + "os" + + "github.com/redis/go-redis/v9" + "github.com/rotisserie/eris" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" +) + +type Storage struct { + Namespace string + Client *redis.Client + Log zerolog.Logger + NonceStorage + SchemaStorage +} + +type Options = redis.Options + +func NewRedisStorage(options Options, namespace string) Storage { + client := redis.NewClient(&options) + return Storage{ + Namespace: namespace, + Client: client, + Log: zerolog.New(os.Stdout), + NonceStorage: NewNonceStorage(client), + SchemaStorage: NewSchemaStorage(client), + } +} + +func NewRedisStorageWithClient(client *redis.Client, namespace string) Storage { + return Storage{ + Namespace: namespace, + Client: client, + Log: zerolog.New(os.Stdout), + NonceStorage: NewNonceStorage(client), + SchemaStorage: NewSchemaStorage(client), + } +} + +func (r *Storage) Close() error { + log.Debug().Msg("Closing storage connection") + + err := r.Client.Close() + if err != nil { + return eris.Wrap(err, "") + } + + log.Debug().Msg("Successfully closed storage connection") + return nil +} diff --git a/v2/storage/schema_test.go b/v2/storage/schema_test.go new file mode 100644 index 000000000..c583be712 --- /dev/null +++ b/v2/storage/schema_test.go @@ -0,0 +1,48 @@ +package storage_test + +import ( + "testing" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +type TestComponent1 struct { + number int +} + +func (TestComponent1) Name() string { + return "test_component1" +} + +type TestComponent struct { + word string +} + +func (TestComponent) Name() string { + return "test_component" +} + +func TestSetAndGetSchema(t *testing.T) { + testComponent1 := TestComponent1{number: 2} + testComponent := TestComponent{word: "hello"} + schema1, err := types.SerializeComponentSchema(testComponent1) + assert.NilError(t, err) + schema, err := types.SerializeComponentSchema(testComponent) + assert.NilError(t, err) + rs := GetRedisStorage(t) + err = rs.SetSchema(testComponent1.Name(), schema1) + assert.NilError(t, err) + err = rs.SetSchema(testComponent.Name(), schema) + assert.NilError(t, err) + otherSchema1, err := rs.GetSchema(testComponent1.Name()) + assert.NilError(t, err) + valid, err := types.IsComponentValid(testComponent1, otherSchema1) + assert.NilError(t, err) + assert.Assert(t, valid) + otherSchema, err := rs.GetSchema(testComponent.Name()) + assert.NilError(t, err) + valid, err = types.IsComponentValid(testComponent1, otherSchema) + assert.NilError(t, err) + assert.Assert(t, !valid) +} diff --git a/v2/storage/storage.go b/v2/storage/storage.go new file mode 100644 index 000000000..35a0c1776 --- /dev/null +++ b/v2/storage/storage.go @@ -0,0 +1,16 @@ +package storage + +type NonceStorage interface { + UseNonce(signerAddress string, nonce uint64) error +} + +type SchemaStorage interface { + GetSchema(componentName string) ([]byte, error) + SetSchema(componentName string, schemaData []byte) error +} + +type Storage interface { + NonceStorage + SchemaStorage + Close() error +} diff --git a/v2/telemetry/telemetry.go b/v2/telemetry/telemetry.go new file mode 100644 index 000000000..9975a46c1 --- /dev/null +++ b/v2/telemetry/telemetry.go @@ -0,0 +1,95 @@ +package telemetry + +import ( + "errors" + + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + "gopkg.in/DataDog/dd-trace-go.v1/profiler" +) + +type Manager struct { + tracerShutdownFunc func() error + profilerShutdownFunc func() + tracerProvider *ddotel.TracerProvider +} + +func New(enableTrace bool, enableProfiler bool) (*Manager, error) { + tm := Manager{ + tracerShutdownFunc: nil, + tracerProvider: nil, + } + + // Set up propagator + tm.setupPropagator() + + // Set up trace provider used for creating spans + if enableTrace { + tm.setupTrace() + } + + // Set up profiler + if enableProfiler { + if err := tm.setupProfiler(); err != nil { + return nil, errors.Join(err, tm.Shutdown()) + } + } + + return &tm, nil +} + +// Shutdown calls cleanup functions registered in the telemetry manager. +// Each registered cleanup will be invoked once and the errors from the calls are joined. +func (tm *Manager) Shutdown() error { + log.Debug().Msg("Shutting down telemetry") + + if tm.tracerShutdownFunc != nil { + err := tm.tracerShutdownFunc() + return err + } + + if tm.profilerShutdownFunc != nil { + tm.profilerShutdownFunc() + } + + log.Debug().Msg("Successfully shutdown telemetry") + return nil +} + +func (tm *Manager) setupPropagator() { + prop := propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + ) + otel.SetTextMapPropagator(prop) +} + +func (tm *Manager) setupTrace() { + tm.tracerProvider = ddotel.NewTracerProvider(tracer.WithRuntimeMetrics()) + tm.tracerShutdownFunc = tm.tracerProvider.Shutdown + otel.SetTracerProvider(tm.tracerProvider) +} + +func (tm *Manager) setupProfiler() error { + err := profiler.Start( + profiler.WithProfileTypes( + profiler.CPUProfile, + profiler.HeapProfile, + // The profiles below are disabled by default to keep overhead + // low, but can be enabled as needed. + // profiler.BlockProfile, + // profiler.MutexProfile, + // profiler.GoroutineProfile, + ), + ) + if err != nil { + return err + } + + tm.profilerShutdownFunc = profiler.Stop + + return nil +} diff --git a/v2/test_fixture.go b/v2/test_fixture.go new file mode 100644 index 000000000..e2799050d --- /dev/null +++ b/v2/test_fixture.go @@ -0,0 +1,323 @@ +package cardinal + +import ( + "bytes" + "context" + "crypto/ecdsa" + "encoding/json" + "fmt" + "net" + "net/http" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/rotisserie/eris" + "github.com/spf13/viper" + "gotest.tools/v3/assert" + + "pkg.world.dev/world-engine/cardinal/v2/tick" + "pkg.world.dev/world-engine/cardinal/v2/world" + "pkg.world.dev/world-engine/sign" +) + +const ( + DefaultTestPersonaTag = "testpersona" +) + +// TestCardinal is a helper struct that manages a cardinal.World instance. It will automatically clean up its resources +// at the end of the test. +type TestCardinal struct { + testing.TB + *Cardinal + + signer ecdsa.PrivateKey + nonce uint64 + + // Base url is something like "localhost:5050". You must attach http:// or ws:// as well as a resource path + BaseURL string + Redis *miniredis.Miniredis + + TickTrigger chan time.Time + TickSubscription <-chan *tick.Tick + startSubscription <-chan bool + + doCleanup func() + startOnce *sync.Once +} + +// NewTestCardinal creates a test fixture with user defined port for Cardinal integration tests. +func NewTestCardinal(t testing.TB, redis *miniredis.Miniredis, opts ...CardinalOption) *TestCardinal { + if redis == nil { + redis = miniredis.RunT(t) + } + + ports, err := findOpenPorts(2) //nolint:gomnd + assert.NilError(t, err) + + cardinalPort := ports[0] + evmPort := ports[1] + + t.Setenv("BASE_SHARD_SEQUENCER_ADDRESS", "localhost:"+evmPort) + t.Setenv("CARDINAL_LOG_PRETTY", "true") + t.Setenv("CARDINAL_PORT", cardinalPort) + t.Setenv("REDIS_ADDRESS", redis.Addr()) + + tickTrigger, doneTickCh := make(chan time.Time), make(chan uint64) + + startSubscription := make(chan bool) + defaultOpts := []CardinalOption{ + WithTickChannel(tickTrigger), + WithStartHook(func() error { + startSubscription <- true + close(startSubscription) + return nil + }), + } + + // Default options go first so that any user supplied options overwrite the defaults. + c, _, err := New(append(defaultOpts, opts...)...) + assert.NilError(t, err) + + signer, err := crypto.GenerateKey() + assert.NilError(t, err) + + return &TestCardinal{ + TB: t, + Cardinal: c, + + signer: *signer, + nonce: 0, + + BaseURL: "localhost:" + cardinalPort, + Redis: redis, + + TickTrigger: tickTrigger, + TickSubscription: c.Subscribe(), + startSubscription: startSubscription, + + startOnce: &sync.Once{}, + // Only register this method with t.Cleanup if the game server is actually started + doCleanup: func() { + viper.Reset() + + // Optionally, you can also clear environment variables if needed + for _, key := range viper.AllKeys() { + err := os.Unsetenv(key) + if err != nil { + t.Errorf("failed to unset env var %s: %v", key, err) + } + } + + // First, make sure completed ticks will never be blocked + go func() { + for range doneTickCh { //nolint:revive // This pattern drains the channel until closed + } + }() + + // Next, shut down the world + c.Stop() + + // The world is shut down; No more ticks will be started + close(tickTrigger) + }, + } +} + +// StartWorld starts the game world and registers a cleanup function that will shut down +// the cardinal World at the end of the test. Components/Systems/Queries, etc should +// be registered before calling this function. +func (c *TestCardinal) StartWorld() { + c.startOnce.Do(func() { + startupError := make(chan error) + go func() { + // StartGame is meant to block forever, so any return value will be non-nil and cause for concern. + // Also, calling t.Fatal from a non-main thread only reports a failure once the test on the main thread has + // completed. By sending this error out on a channel we can fail the test right away (assuming doTick + // has been called from the main thread). + startupError <- c.Cardinal.Start() + }() + + // Wait for the start hook to trigger and mark the world is ready + <-c.startSubscription + + c.Cleanup(c.doCleanup) + }) +} + +// DoTick executes one game tick and blocks until the tick is complete. StartWorld is automatically called if it was +// not called before the first tick. +func (c *TestCardinal) DoTick() { + c.StartWorld() + c.TickTrigger <- time.Now() + <-c.TickSubscription +} + +func (c *TestCardinal) httpURL(path string) string { + return fmt.Sprintf("http://%s/%s", c.BaseURL, path) +} + +// Post executes a http POST request to this TextFixture's cardinal server. +func (c *TestCardinal) Post(path string, payload any) *http.Response { + bz, err := json.Marshal(payload) + assert.NilError(c, err) + req, err := http.NewRequestWithContext( + context.Background(), + http.MethodPost, + c.httpURL(strings.Trim(path, "/")), + bytes.NewReader(bz), + ) + assert.NilError(c, err) + req.Header.Add("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + assert.NilError(c, err) + return resp +} + +// Get executes a http GET request to this TestCardinal's cardinal server. +func (c *TestCardinal) Get(path string) *http.Response { + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, c.httpURL(strings.Trim(path, "/")), + nil) + assert.NilError(c, err) + resp, err := http.DefaultClient.Do(req) + assert.NilError(c, err) + return resp +} + +func (c *TestCardinal) AddTransactionWithPersona(msgName string, personaTag string, msg any) common.Hash { + tx, err := c.newTx(personaTag, msg) + assert.NilError(c, err) + + txHash, err := c.world.AddTransaction(msgName, tx) + assert.NilError(c, err) + + return txHash +} + +// AddTransaction adds a transaction with a default persona tag to the transaction pool. +func (c *TestCardinal) AddTransaction(msgName string, msg any) common.Hash { + wCtx := world.NewWorldContextReadOnly(c.World().State(), c.World().Persona()) + + _, _, err := wCtx.GetPersona(DefaultTestPersonaTag) + if err != nil { + if eris.Is(err, world.ErrPersonaNotRegistered) { + c.CreatePersona(DefaultTestPersonaTag) + } else { + assert.NilError(c, err) + } + } + + tx, err := c.newTx(DefaultTestPersonaTag, msg) + assert.NilError(c, err) + + txHash, err := c.world.AddTransaction(msgName, tx) + assert.NilError(c, err) + + return txHash +} + +func (c *TestCardinal) CreatePersona(personaTag string) { + c.AddTransactionWithPersona("persona.create-persona", "test", world.CreatePersona{PersonaTag: personaTag}) + c.DoTick() +} + +func (c *TestCardinal) HandleQuery(group string, name string, request any) ([]byte, error) { + // Marshal request payload to JSON bytes + reqBz, err := json.Marshal(request) + assert.NilError(c, err) + + // Call the HandleQuery method on the QueryManager + return c.Cardinal.World().HandleQuery(group, name, reqBz) +} + +// findOpenPorts finds a set of open ports and returns them as a slice of strings. +// It is guaranteed that the returned slice will have the amount of ports requested and that there is no duplicate +// ports in the slice. +func findOpenPorts(amount int) ([]string, error) { + ports := make([]string, 0, amount) + + // Try to find open ports until we find the target amount or we run out of retries + for i := 0; i < amount; i++ { + var found bool + + // Try to find a random port, retying if it turns out to be a duplicate in list of ports up to 10 times + for retries := 10; retries > 0; retries-- { + port, err := findOpenPort() + if err != nil { + continue + } + + // Check for duplicate ports + for _, existingPort := range ports { + if port == existingPort { + continue + } + } + + // Add the port to the list and break out of the inner loop + ports = append(ports, port) + found = true + break + } + + if !found { + return nil, eris.New("failed to find open ports after retries") + } + } + + return ports, nil +} + +// findOpenPort finds an open port and returns it as a string. +// If you need to find multiple ports, use findOpenPorts to make sure that the ports are unique. +func findOpenPort() (string, error) { + findFn := func() (string, error) { + // Try to get a random port using the wildcard 0 port + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return "", eris.Wrap(err, "failed to initialize listener") + } + + // Get the autoamtically assigned port number from the listener + tcpAddr, err := net.ResolveTCPAddr(l.Addr().Network(), l.Addr().String()) + if err != nil { + return "", eris.Wrap(err, "failed to resolve address") + } + + // Close the listener when the function returns + err = l.Close() + if err != nil { + return "", eris.Wrap(err, "failed to close listener") + } + return strconv.Itoa(tcpAddr.Port), nil + } + + for retries := 10; retries > 0; retries-- { + port, err := findFn() + if err == nil { + return port, nil + } + time.Sleep(10 * time.Millisecond) //nolint:gomnd // it's fine. + } + + return "", eris.New("failed to find an open port") +} + +func (c *TestCardinal) newTx(personaTag string, msg any) (*sign.Transaction, error) { + c.nonce++ + sig, err := sign.NewTransaction(&c.signer, personaTag, c.world.Namespace(), msg) + if err != nil { + return nil, err + } + return sig, nil +} + +func (c *TestCardinal) SignerAddress() string { + return crypto.PubkeyToAddress(c.signer.PublicKey).Hex() +} diff --git a/v2/testutils/testutils.go b/v2/testutils/testutils.go new file mode 100644 index 000000000..b3b66c4cd --- /dev/null +++ b/v2/testutils/testutils.go @@ -0,0 +1,25 @@ +package testutils + +import ( + "testing" + "time" +) + +func SetTestTimeout(t *testing.T, timeout time.Duration) { + if _, ok := t.Deadline(); ok { + // A deadline has already been set. Don't add an additional deadline. + return + } + success := make(chan bool) + t.Cleanup(func() { + success <- true + }) + go func() { + select { + case <-success: + // test was successful. Do nothing + case <-time.After(timeout): + panic("test timed out") + } + }() +} diff --git a/v2/tick/receipt.go b/v2/tick/receipt.go new file mode 100644 index 000000000..194555bd6 --- /dev/null +++ b/v2/tick/receipt.go @@ -0,0 +1,14 @@ +package tick + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" +) + +// Receipt contains a transaction hash, an arbitrary result, and a list of errors. +type Receipt struct { + TxHash common.Hash `json:"txHash"` + EVMTxHash string `json:"-"` + Result json.RawMessage `json:"result"` + Error string `json:"error"` +} diff --git a/v2/tick/tick.go b/v2/tick/tick.go new file mode 100644 index 000000000..f58fb1f67 --- /dev/null +++ b/v2/tick/tick.go @@ -0,0 +1,67 @@ +package tick + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/goccy/go-json" + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" +) + +type Proposal struct { + ID int64 + Timestamp int64 + Namespace types.Namespace + Txs message.TxMap +} + +type Tick struct { + *Proposal + Receipts map[common.Hash]Receipt + Events map[string][]any +} + +func New(proposal *Proposal) (*Tick, error) { + if proposal == nil { + return nil, eris.New("proposal cannot be nil") + } + return &Tick{ + Proposal: proposal, + Receipts: make(map[common.Hash]Receipt), + Events: make(map[string][]any), + }, nil +} + +// SetReceipts sets the given transaction hash to the given result. +// Calling this multiple times will replace any previous results. +func (t *Tick) SetReceipts(hash common.Hash, result any, txErr error) error { + rec, ok := t.Receipts[hash] + if !ok { + rec = Receipt{} + } + + resultBz, err := json.Marshal(result) + if err != nil { + return err + } + + rec.TxHash = hash + rec.Result = resultBz + + if txErr != nil { + rec.Error = txErr.Error() + } else { + rec.Error = "" + } + + t.Receipts[hash] = rec + + log.Info().Str("tx_hash", hash.Hex()).RawJSON("result", resultBz).Msg("Set receipt") + return nil +} + +func (t *Tick) RecordEvent(systemName string, event any) { + t.Events[systemName] = append(t.Events[systemName], event) +} diff --git a/v2/types/archetype.go b/v2/types/archetype.go new file mode 100644 index 000000000..0b529aaeb --- /dev/null +++ b/v2/types/archetype.go @@ -0,0 +1,3 @@ +package types + +type ArchetypeID int diff --git a/v2/types/component.go b/v2/types/component.go new file mode 100644 index 000000000..f4485b8ac --- /dev/null +++ b/v2/types/component.go @@ -0,0 +1,72 @@ +package types + +import ( + "errors" + + "github.com/invopop/jsonschema" + "github.com/rotisserie/eris" + "github.com/wI2L/jsondiff" +) + +var ErrComponentSchemaMismatch = errors.New("component schema does not match target schema") + +type ComponentID int +type ComponentName = string + +// Component is the interface that the user needs to implement to create a new component type. +type Component interface { + // Name returns the name of the component. + Name() string +} + +// ComponentMetadata wraps the user-defined Component struct and provides functionalities that is used internally +// in the engine. +type ComponentMetadata interface { //revive:disable-line:exported + // SetID sets the ArchetypeID of this component. It must only be set once + SetID(ComponentID) error + // ArchetypeID returns the ArchetypeID of the component. + ID() ComponentID + // New returns the marshaled bytes of the default value for the component struct. + New() ([]byte, error) + Encode(any) ([]byte, error) + Decode([]byte) (Component, error) + GetSchema() []byte + ValidateAgainstSchema(targetSchema []byte) error + + Component +} + +func SerializeComponentSchema(component Component) ([]byte, error) { + componentSchema := jsonschema.Reflect(component) + schema, err := componentSchema.MarshalJSON() + if err != nil { + return nil, eris.Wrap(err, "component must be json serializable") + } + return schema, nil +} + +func IsComponentValid(component Component, jsonSchemaBytes []byte) (bool, error) { + componentSchema := jsonschema.Reflect(component) + componentSchemaBytes, err := componentSchema.MarshalJSON() + if err != nil { + return false, eris.Wrap(err, "") + } + return isSchemaValid(componentSchemaBytes, jsonSchemaBytes) +} + +func isSchemaValid(jsonSchemaBytes1 []byte, jsonSchemaBytes2 []byte) (bool, error) { + patch, err := jsondiff.CompareJSON(jsonSchemaBytes1, jsonSchemaBytes2) + if err != nil { + return false, eris.Wrap(err, "") + } + return patch.String() == "", nil +} + +// ConvertComponentMetadatasToComponents Cast an array of ComponentMetadata into an array of Component +func ConvertComponentMetadatasToComponents(comps []ComponentMetadata) []Component { + ret := make([]Component, len(comps)) + for i, comp := range comps { + ret[i] = comp + } + return ret +} diff --git a/v2/types/entity.go b/v2/types/entity.go new file mode 100644 index 000000000..bfee7757c --- /dev/null +++ b/v2/types/entity.go @@ -0,0 +1,10 @@ +package types + +import "encoding/json" + +type EntityID uint64 + +type EntityData struct { + ID EntityID `json:"id"` + Components map[string]json.RawMessage `json:"components" swaggertype:"object"` +} diff --git a/v2/types/errors.go b/v2/types/errors.go new file mode 100644 index 000000000..ce28d9d80 --- /dev/null +++ b/v2/types/errors.go @@ -0,0 +1,5 @@ +package types + +import "github.com/rotisserie/eris" + +var ErrQueryNotFound = eris.New("query not found") diff --git a/v2/types/info.go b/v2/types/info.go new file mode 100644 index 000000000..ae50a0144 --- /dev/null +++ b/v2/types/info.go @@ -0,0 +1,21 @@ +package types + +type WorldInfo struct { + Namespace string + Components []ComponentInfo + Messages []EndpointInfo + Queries []EndpointInfo +} + +// EndpointInfo provides metadata information about a message or query. +type EndpointInfo struct { + Name string `json:"name"` // name of the message or query + Fields map[string]any `json:"fields"` // property name and type + URL string `json:"url,omitempty"` +} + +// ComponentInfo provides metadata information about a component. +type ComponentInfo struct { + Name string `json:"name"` // name of the component + Fields map[string]any `json:"fields"` // property name and type +} diff --git a/v2/types/message/message.go b/v2/types/message/message.go new file mode 100644 index 000000000..7f52125c2 --- /dev/null +++ b/v2/types/message/message.go @@ -0,0 +1,198 @@ +package message + +import ( + "errors" + "fmt" + "reflect" + "regexp" + "strings" + + "pkg.world.dev/world-engine/cardinal/v2/codec" + "pkg.world.dev/world-engine/sign" +) + +var ErrEVMTypeNotSet = errors.New("EVM type is not set") +var DefaultGroup = "game" + +// enforces first/last (or single) alphanumeric character, can contain dash/slash in between. +// does not allow spaces or special characters. +var messageRegexp = regexp.MustCompile(`^[a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)?$`) + +type Message interface { + Name() string +} + +type MessageInternal interface { + Message + Group() string + GetSchema() map[string]any + Encode(Tx) ([]byte, error) + Decode(*sign.Transaction) (Tx, error) +} + +// MessageType manages a user defined state transition message struct. +type MessageType[Msg Message] struct { + name string + group string +} + +var _ Message = &MessageType[Message]{} +var _ MessageInternal = &MessageType[Message]{} + +// NewMessageType creates a new message type. It accepts two generic type parameters: the first for the message input, +// which defines the data needed to make a state transition, and the second for the message output, commonly used +// for the results of a state transition. By default, messages will be grouped under the "game" group, however an option +// may be passed in to change this. +func NewMessageType[Msg Message](opts ...Option[Msg]) *MessageType[Msg] { + var msg Msg + + if !isValidMsg[Msg]() { + panic(fmt.Sprintf("Invalid MessageType: %q: The In and Out must be both structs", msg.Name())) + } + + // If Msg.Name() is ``, use the default "game" group. + var group string + msgNameParts := strings.Split(msg.Name(), ".") + + if len(msgNameParts) == 1 { + // If Msg.Name() is ``, use the default "game" group. + group = DefaultGroup + } else if len(msgNameParts) == 2 { + // If Msg.Name() is `.`, use the custom group. + group = msgNameParts[0] + } else { + // If Msg.Name() is `..<...>`, panic. + // Technically, this should never happen because we check for this in isValidMsg, but just to be safe. + panic(fmt.Sprintf("Invalid message name: %q", msg.Name())) + } + + msgType := MessageType[Msg]{ + name: msg.Name(), + group: group, + } + for _, opt := range opts { + opt(&msgType) + } + + return &msgType +} + +func (t *MessageType[Msg]) Name() string { + return t.name +} + +func (t *MessageType[Msg]) Group() string { + return t.group +} + +// Encode encodes the transaction to its JSON representation. +func (t *MessageType[Msg]) Encode(tx Tx) ([]byte, error) { + return codec.Encode(tx) +} + +// Decode decodes the message from the transaction's body. +func (t *MessageType[Msg]) Decode(tx *sign.Transaction) (Tx, error) { + msg, err := codec.Decode[Msg](tx.Body) + if err != nil { + return nil, err + } + return txType[Msg]{ + Transaction: tx, + msg: msg, + }, nil +} + +// GetSchema returns the schema of the message Msg type. +func (t *MessageType[Msg]) GetSchema() map[string]any { + var msg Msg + return getStructSchema(reflect.TypeOf(msg)) +} + +// -------------------------- Options -------------------------- + +type Option[Msg Message] func(mt *MessageType[Msg]) + +// -------------------------- Helpers -------------------------- + +// isValidMsg checks that Msg.Name() is not empty and adheres to the message name regex. +func isValidMsg[Msg Message]() bool { + var msg Msg + msgName := msg.Name() + if msgName == "" { + return false + } + return messageRegexp.MatchString(msgName) +} + +// getStructSchema returns a struct's schema map (key: field name, value: field type OR nested schema map). +// If the field has a json tag, it will be used as the key, otherwise the struct field name will be used. +// It returns nil if the type is not a struct. +func getStructSchema(t reflect.Type) map[string]any { + // If t is a pointer, dereference it + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // Terminate if the type is not a struct (after dereferencing) + if t.Kind() != reflect.Struct { + return nil + } + + schema := make(map[string]any) + + // Iterate over all fields in the struct + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldType := field.Type + + // If the field type is a pointer, obtain the type it points to + if fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + + // Get field name from json tag or struct field name + var fieldName string + if tag := field.Tag.Get("json"); tag != "" { + fieldName = strings.Split(tag, ",")[0] // Handle cases like `json:"name,omitempty"` + } else { + fieldName = field.Name + } + + switch fieldType.Kind() { + // If the field is a struct, recursively obtain its schema + case reflect.Struct: + schema[fieldName] = getStructSchema(fieldType) + + // If the field is an interface, try to resolve it to a concrete type + case reflect.Interface: + // Obtain the backing value of the interface + // If its backed by nil, treat it as "interface{}" and move on + concreteValue := reflect.ValueOf(t) + if concreteValue.IsNil() { + schema[fieldName] = "interface{}" + continue + } + + // Otherwise, figure out the concrete type of the interface + concreteValueType := concreteValue.Type() + if concreteValueType.Kind() == reflect.Struct { + // If the interface is backed by a struct, recursively obtain its schema + schema[fieldName] = getStructSchema(concreteValueType) + } else if concreteValueType.Kind() == reflect.Interface || concreteValueType.Kind() == reflect.Pointer { + // While technically possible (unlikely) for the interface to be backed by another interface/pointer, + // we don't support recursively dereferencing it since it can lead to infinite loops when we have + // circular dependencies. Therefore, we will just fallback to "interface{}". + schema[fieldName] = "interface{}" + } else { + // Otherwise, the interface is backed by a primitive type, set value to the type name's string representation + schema[fieldName] = concreteValueType.String() + } + + // Otherwise, the field is a primitive type, set value to the type name's string representation + default: + schema[fieldName] = fieldType.String() + } + } + + return schema +} diff --git a/v2/types/message/tx.go b/v2/types/message/tx.go new file mode 100644 index 000000000..50da39bef --- /dev/null +++ b/v2/types/message/tx.go @@ -0,0 +1,64 @@ +package message + +import ( + "github.com/ethereum/go-ethereum/common" + "github.com/rotisserie/eris" + "pkg.world.dev/world-engine/sign" +) + +type TxMap = map[string][]Tx + +type Tx interface { + Hash() common.Hash + Signer() (common.Address, error) + Verify(common.Address) error + Namespace() string + PersonaTag() string +} + +type TxType[Msg Message] interface { + Tx + Msg() Msg +} + +type txType[Msg Message] struct { + *sign.Transaction + msg Msg +} + +var _ Tx = (*txType[Message])(nil) +var _ TxType[Message] = (*txType[Message])(nil) + +func NewTx[Msg Message](tx *sign.Transaction, msg Msg) (Tx, error) { + if tx == nil { + return nil, eris.New("transaction is nil") + } + return txType[Msg]{ + Transaction: tx, + msg: msg, + }, nil +} + +func (t txType[Msg]) Hash() common.Hash { + return t.Transaction.Hash +} + +func (t txType[Msg]) Signer() (common.Address, error) { + return t.Transaction.Signer() +} + +func (t txType[Msg]) Verify(expectedSigner common.Address) error { + return t.Transaction.Verify(expectedSigner) +} + +func (t txType[Msg]) Namespace() string { + return t.Transaction.Namespace +} + +func (t txType[Msg]) PersonaTag() string { + return t.Transaction.PersonaTag +} + +func (t txType[Msg]) Msg() Msg { + return t.msg +} diff --git a/v2/types/namespace.go b/v2/types/namespace.go new file mode 100644 index 000000000..328caceb6 --- /dev/null +++ b/v2/types/namespace.go @@ -0,0 +1,27 @@ +package types + +import ( + "regexp" + + "github.com/rotisserie/eris" +) + +var ( + regexAlphanumeric = regexp.MustCompile(`^[a-zA-Z0-9-]+$`) +) + +// Namespace is a unique identifier for a world used for posting to the data availability layer and to prevent +// signature replay attacks across multiple worlds. +type Namespace string + +func (n Namespace) String() string { + return string(n) +} + +// Validate validates that the namespace is alphanumeric or - (hyphen). +func (n Namespace) Validate() error { + if !regexAlphanumeric.MatchString(n.String()) { + return eris.New("Invalid namespace. A namespace must be alphanumeric.") + } + return nil +} diff --git a/v2/types/util.go b/v2/types/util.go new file mode 100644 index 000000000..e9fc935b1 --- /dev/null +++ b/v2/types/util.go @@ -0,0 +1,30 @@ +package types + +import "reflect" + +// GetFieldInformation returns a map of the fields of a struct and their types. +func GetFieldInformation(t reflect.Type) map[string]any { + if t.Kind() != reflect.Struct { + return nil + } + + fieldMap := make(map[string]any) + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldName := field.Name + + // Check if the field has a json tag + if tag := field.Tag.Get("json"); tag != "" { + fieldName = tag + } + + if field.Type.Kind() == reflect.Struct { + fieldMap[fieldName] = GetFieldInformation(field.Type) + } else { + fieldMap[fieldName] = field.Type.String() + } + } + + return fieldMap +} diff --git a/v2/types/util_test.go b/v2/types/util_test.go new file mode 100644 index 000000000..cb189b976 --- /dev/null +++ b/v2/types/util_test.go @@ -0,0 +1,57 @@ +package types + +import ( + "reflect" + "testing" + + "pkg.world.dev/world-engine/assert" +) + +func TestGetFieldInformation(t *testing.T) { + testCases := []struct { + name string + value any + want map[string]any + }{ + { + name: "default field names", + value: struct { + Alpha int + Beta string + Gamma float64 + }{}, + want: map[string]any{"Alpha": "int", "Beta": "string", "Gamma": "float64"}, + }, + { + name: "json tagged fields", + value: struct { + Alpha int `json:"aaaaa"` + Beta string `json:"bbbbb"` + Gamma float64 `json:"ggggg"` + }{}, + want: map[string]any{"aaaaa": "int", "bbbbb": "string", "ggggg": "float64"}, + }, + { + name: "nested fields", + value: struct { + Alpha struct { + Beta struct { + Gamma string + } `json:"bbbbb"` + } + }{}, + want: map[string]any{ + "Alpha": map[string]any{ + "bbbbb": map[string]any{ + "Gamma": "string", + }, + }, + }, + }, + } + + for _, tc := range testCases { + fields := GetFieldInformation(reflect.TypeOf(tc.value)) + assert.DeepEqual(t, fields, tc.want) + } +} diff --git a/v2/world/entity.go b/v2/world/entity.go new file mode 100644 index 000000000..04c9d4854 --- /dev/null +++ b/v2/world/entity.go @@ -0,0 +1,199 @@ +package world + +import ( + "fmt" + "strconv" + + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" +) + +// 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 ...types.Component) (_ types.EntityID, err error) { + // We don't handle panics here because we let CreateMany handle it for us + entityIDs, err := CreateMany(wCtx, 1, components...) + if err != nil { + return 0, err + } + return entityIDs[0], nil +} + +// 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 ...types.Component) (entityIDs []types.EntityID, err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + // Create the entities + sm, err := wCtx.stateWriter() + if err != nil { + return nil, err + } + + entityIDs, err = sm.CreateManyEntities(num, components...) + if err != nil { + return nil, err + } + + // Store the components for the entities + for _, id := range entityIDs { + for _, comp := range components { + if err := sm.SetComponentForEntity(id, comp); err != nil { + return nil, err + } + } + } + + return entityIDs, nil +} + +// SetComponent sets component data to the entity. +func SetComponent[T types.Component](wCtx WorldContext, id types.EntityID, component *T) (err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + sm, err := wCtx.stateWriter() + if err != nil { + return err + } + + err = sm.SetComponentForEntity(id, *component) + if err != nil { + return err + } + + wCtx.Logger().Debug(). + Str("entity_id", strconv.FormatUint(uint64(id), 10)). + Str("component_name", (*component).Name()). + Msg("entity updated") + + return nil +} + +// GetComponent returns component data from the entity. +func GetComponent[T types.Component](wCtx WorldContextReadOnly, id types.EntityID) (comp *T, err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + var t T + + // Get current component value + compValue, err := wCtx.stateReader().GetComponentForEntity(t, id) + if err != nil { + return nil, err + } + + // Type assert the component value to the component type + t, ok := compValue.(T) + if !ok { + comp, ok = compValue.(*T) + if !ok { + return nil, err + } + } else { + comp = &t + } + + return comp, nil +} + +func UpdateComponent[T types.Component](wCtx WorldContext, id types.EntityID, fn func(*T) *T) (err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + // Get current component value + val, err := GetComponent[T](wCtx, id) + if err != nil { + return err + } + + // Get the new component value + updatedVal := fn(val) + + // Store the new component value + err = SetComponent[T](wCtx, id, updatedVal) + if err != nil { + return err + } + + return nil +} + +func AddComponentTo[T types.Component](wCtx WorldContext, id types.EntityID) (err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + sm, err := wCtx.stateWriter() + if err != nil { + return err + } + + var t T + if err := sm.AddComponentToEntity(t, id); err != nil { + return err + } + + return nil +} + +// RemoveComponentFrom removes a component from an entity. +func RemoveComponentFrom[T types.Component](wCtx WorldContext, id types.EntityID) (err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + sm, err := wCtx.stateWriter() + if err != nil { + return err + } + + var t T + return sm.RemoveComponentFromEntity(t, id) +} + +// Remove removes the given Entity from the world. +func Remove(wCtx WorldContext, id types.EntityID) (err error) { + defer func() { panicOnFatalError(wCtx, err) }() + + sm, err := wCtx.stateWriter() + if err != nil { + return err + } + + err = sm.RemoveEntity(id) + if err != nil { + return err + } + + return nil +} + +type MsgHandler[Msg message.Message] func(message.TxType[Msg]) (any, error) + +func EachMessage[Msg message.Message](wCtx WorldContext, fn MsgHandler[Msg]) error { + var msg Msg + + txs, ok := wCtx.getTick().Txs[msg.Name()] + if !ok { + return nil + } + + for _, tx := range txs { + tx, ok := tx.(message.TxType[Msg]) + if !ok { + fmt.Printf("expected message type %s, got %s", msg.Name(), tx.Msg().Name()) + panic(fmt.Sprintf("expected message type %s, got %s", msg.Name(), tx.Msg().Name())) + } + + result, err := fn(tx) + if err != nil { + wCtx.Logger().Err(eris.New(err.Error())). + Str("tx_hash", tx.Hash().Hex()). + Str("persona_tag", tx.PersonaTag()). + Interface("message", tx.Msg()). + Msg("tx encountered an error") + } + + err = wCtx.getTick().SetReceipts(tx.Hash(), result, err) + if err != nil { + return eris.Wrap(err, "failed to set receipt") + } + } + return nil +} diff --git a/v2/world/errors_test.go b/v2/world/errors_test.go new file mode 100644 index 000000000..1ef80bcf1 --- /dev/null +++ b/v2/world/errors_test.go @@ -0,0 +1,381 @@ +package world_test + +import ( + "errors" + "fmt" + "strings" + "testing" + + "github.com/goccy/go-json" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type ScalarComponentStatic struct { + Val int +} + +func (ScalarComponentStatic) Name() string { return "scalar_component_static" } + +type ScalarComponentDynamic struct { + Val int +} + +func (ScalarComponentDynamic) Name() string { return "scalar_component_dynamic" } + +type Foo struct{} + +func (Foo) Name() string { return "foo" } + +type Bar struct{} + +func (Bar) Name() string { return "bar" } + +// TestSystemsReturnNonFatalErrors ensures system will surface non-fatal read and write errors to the user. +func TestSystemsReturnNonFatalErrors(t *testing.T) { + const nonExistentEntityID = 999 + testCases := []struct { + name string + testFn func(world.WorldContext) error + wantErr error + }{ + { + name: "AddComponentTo_BadEntity", + testFn: func(wCtx world.WorldContext) error { + return world.AddComponentTo[Foo](wCtx, nonExistentEntityID) + }, + wantErr: gamestate.ErrEntityDoesNotExist, + }, + { + name: "AddComponentTo_ComponentAlreadyOnEntity", + testFn: func(wCtx world.WorldContext) error { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return world.AddComponentTo[Foo](wCtx, id) + }, + wantErr: gamestate.ErrComponentAlreadyOnEntity, + }, + { + name: "RemoveComponentFrom_BadEntity", + testFn: func(wCtx world.WorldContext) error { + return world.RemoveComponentFrom[Foo](wCtx, nonExistentEntityID) + }, + wantErr: gamestate.ErrEntityDoesNotExist, + }, + { + name: "RemoveComponentFrom_ComponentNotOnEntity", + testFn: func(wCtx world.WorldContext) error { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return world.RemoveComponentFrom[Bar](wCtx, id) + }, + wantErr: gamestate.ErrComponentNotOnEntity, + }, + { + name: "RemoveComponentFrom_EntityMustHaveAtLeastOneComponent", + testFn: func(wCtx world.WorldContext) error { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return world.RemoveComponentFrom[Foo](wCtx, id) + }, + wantErr: gamestate.ErrEntityMustHaveAtLeastOneComponent, + }, + { + name: "GetComponent_BadEntity", + testFn: func(wCtx world.WorldContext) error { + _, err := world.GetComponent[Foo](wCtx, nonExistentEntityID) + return err + }, + wantErr: gamestate.ErrEntityDoesNotExist, + }, + { + name: "GetComponent_ComponentNotOnEntity", + testFn: func(wCtx world.WorldContext) error { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + _, err = world.GetComponent[Bar](wCtx, id) + return err + }, + wantErr: gamestate.ErrComponentNotOnEntity, + }, + { + name: "SetComponent_BadEntity", + testFn: func(wCtx world.WorldContext) error { + return world.SetComponent[Foo](wCtx, nonExistentEntityID, &Foo{}) + }, + wantErr: gamestate.ErrEntityDoesNotExist, + }, + { + name: "SetComponent_ComponentNotOnEntity", + testFn: func(wCtx world.WorldContext) error { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return world.SetComponent[Bar](wCtx, id, &Bar{}) + }, + wantErr: gamestate.ErrComponentNotOnEntity, + }, + { + name: "UpdateComponent_BadEntity", + testFn: func(wCtx world.WorldContext) error { + return world.UpdateComponent[Foo](wCtx, nonExistentEntityID, func(f *Foo) *Foo { + return f + }) + }, + wantErr: gamestate.ErrEntityDoesNotExist, + }, + { + name: "UpdateComponent_ComponentNotOnEntity", + testFn: func(wCtx world.WorldContext) error { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return world.UpdateComponent[Bar](wCtx, id, func(b *Bar) *Bar { + return b + }) + }, + wantErr: gamestate.ErrComponentNotOnEntity, + }, + { + name: "Remove_EntityDoesNotExist", + testFn: func(wCtx world.WorldContext) error { + return world.Remove(wCtx, nonExistentEntityID) + }, + wantErr: gamestate.ErrEntityDoesNotExist, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + tick := tf.DoTick + assert.NilError(t, world.RegisterComponent[Foo](tf.World())) + assert.NilError(t, world.RegisterComponent[Bar](tf.World())) + err := world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + defer func() { + // In systems, Cardinal is designed to panic when a fatal error is encountered. + // This test is not supposed to panic, but if it does panic it happens in a non-main thread which + // makes it hard to track down where the panic actually came from. + // Recover here and complain about any non-nil panics to allow the remaining tests in this + // function to be executed and so the maintainer will know exactly which test failed. + err := recover() + assert.Check(t, err == nil, "got fatal error \"%v\"", err) + }() + + err := tc.testFn(wCtx) + isWantError := errors.Is(err, tc.wantErr) + assert.Check(t, isWantError, "expected %v but got %v", tc.wantErr, err) + return nil + }) + assert.NilError(t, err) + tick() + }) + } +} + +type UnregisteredComp struct{} + +func (UnregisteredComp) Name() string { return "unregistered_comp" } + +// TestSystemPanicOnComponentHasNotBeenRegistered ensures Systems that encounter a component that has not been +// registered will panic. +func TestSystemsPanicOnComponentHasNotBeenRegistered(t *testing.T) { + testCases := []struct { + name string + // Every test is expected to panic, so no return error is needed + panicFn func(world.WorldContext) + }{ + { + name: "AddComponentTo", + panicFn: func(wCtx world.WorldContext) { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + _ = world.AddComponentTo[UnregisteredComp](wCtx, id) + }, + }, + { + name: "RemoveComponentFrom", + panicFn: func(wCtx world.WorldContext) { + id, err := world.Create(wCtx, Foo{}, Bar{}) + assert.Check(t, err == nil) + _ = world.RemoveComponentFrom[UnregisteredComp](wCtx, id) + }, + }, + { + name: "GetComponent", + panicFn: func(wCtx world.WorldContext) { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + _, _ = world.GetComponent[UnregisteredComp](wCtx, id) + }, + }, + { + name: "SetComponent", + panicFn: func(wCtx world.WorldContext) { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + _ = world.SetComponent[UnregisteredComp](wCtx, id, &UnregisteredComp{}) + }, + }, + { + name: "UpdateComponent", + panicFn: func(wCtx world.WorldContext) { + id, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + _ = world.UpdateComponent[UnregisteredComp](wCtx, id, + func(u *UnregisteredComp) *UnregisteredComp { + return u + }) + }, + }, + { + name: "Create", + panicFn: func(wCtx world.WorldContext) { + _, _ = world.Create(wCtx, Foo{}, UnregisteredComp{}) + }, + }, + { + name: "CreateMany", + panicFn: func(wCtx world.WorldContext) { + _, _ = world.CreateMany(wCtx, 10, Foo{}, UnregisteredComp{}) + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + tick := tf.DoTick + assert.NilError(t, world.RegisterComponent[Foo](tf.World())) + err := world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + defer func() { + err := recover() + // assert.Check is required here because this is happening in a non-main thread. + assert.Check(t, err != nil, "expected the ECB mutation to panic") + errStr, ok := err.(string) + assert.Check(t, ok, "expected the panic to be of type string") + isErrComponentNotRegistered := strings.Contains(errStr, gamestate.ErrComponentNotRegistered.Error()) + assert.Check(t, isErrComponentNotRegistered, + fmt.Sprintf("expected error %q to contain %q", + errStr, + gamestate.ErrComponentNotRegistered.Error())) + }() + // This should panic every time + tc.panicFn(wCtx) + assert.Check(t, false, "should not reach this line") + return nil + }) + assert.NilError(t, err) + tick() + }) + } +} + +type QueryRequest struct{} +type QueryResponse struct{} + +// TestQueriesDoNotPanicOnComponentHasNotBeenRegistered ensures queries do not panic when a non-registered component +// is encountered. Instead, the error should be returned to the user. +func TestQueriesDoNotPanicOnComponentHasNotBeenRegistered(t *testing.T) { + testCases := []struct { + name string + testFn func(world.WorldContextReadOnly) error + }{ + { + name: "GetComponent", + testFn: func(w world.WorldContextReadOnly) error { + // Get a valid entity to ensure the error we find is related to the component and NOT + // due to an invalid entity. + id, err := w.Search(filter.Exact(Foo{})).First() + assert.Check(t, err == nil) + _, err = world.GetComponent[UnregisteredComp](w, id) + return err + }, + }, + } + + const queryName = "some_query" + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // These queries shouldn't ever panic, but this recover is included here so that if we DO panic, only the + // failing test will be displayed in the failure logs. + defer func() { + err := recover() + assert.Check(t, err == nil, "expected no panic but got %q", err) + }() + + tf := cardinal.NewTestCardinal(t, nil) + tick := tf.DoTick + assert.NilError(t, world.RegisterComponent[Foo](tf.World())) + err := world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + // Make an entity so the test functions are operating on a valid entity. + _, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return nil + }) + assert.NilError(t, err) + err = world.RegisterQuery[QueryRequest, QueryResponse]( + tf.World(), + queryName, + func(wCtx world.WorldContextReadOnly, _ *QueryRequest) (*QueryResponse, error) { + return nil, tc.testFn(wCtx) + }) + assert.Check(t, err == nil) + + // Do an initial tick so that the single entity can be Created. + tick() + + reqBz, err := json.Marshal(QueryRequest{}) + assert.NilError(t, err) + + _, err = tf.Cardinal.World().HandleQuery("game", queryName, reqBz) + // Each test case is meant to generate a "ErrComponentNotRegistered" error + assert.Check(t, errors.Is(err, gamestate.ErrComponentNotRegistered), + "expected a component not registered error, got %v", err) + }) + } +} + +func TestGetComponentInQueryDoesNotPanicOnRedisError(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + tick := tf.DoTick + assert.NilError(t, world.RegisterComponent[Foo](tf.World())) + + err := world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + _, err := world.Create(wCtx, Foo{}) + assert.Check(t, err == nil) + return nil + }) + assert.NilError(t, err) + + const queryName = "some_query" + assert.NilError(t, world.RegisterQuery[QueryRequest, QueryResponse]( + tf.World(), + queryName, + func(wCtx world.WorldContextReadOnly, req *QueryRequest) (*QueryResponse, error) { + id, err := wCtx.Search(filter.Exact(Foo{})).First() + assert.Check(t, err != nil) + _, err = world.GetComponent[Foo](wCtx, id) + return nil, err + })) + + // Tick so the entity can be Created + tick() + + // Uhoh, redis is now broken. + tf.Redis.Close() + + // This will fail with a redis connection error, and since we're in a query, we should NOT panic + defer func() { + assert.Check(t, recover() == nil, "expected no panic in a query") + }() + + reqBz, err := json.Marshal(QueryRequest{}) + assert.NilError(t, err) + + _, err = tf.Cardinal.World().HandleQuery("game", queryName, reqBz) + assert.IsError(t, err) + assert.ErrorContains(t, err, "connection refused", "expected a connection error") +} diff --git a/v2/world/option.go b/v2/world/option.go new file mode 100644 index 000000000..d59025ad8 --- /dev/null +++ b/v2/world/option.go @@ -0,0 +1,14 @@ +package world + +import "github.com/rs/zerolog/log" + +type Option func(*World) + +func WithVerifySignature(verifySignature bool) Option { + return func(w *World) { + if !verifySignature { + log.Warn().Msg("Signature verification is disabled. This is not recommended for production.") + } + w.config.CardinalVerifySignature = verifySignature + } +} diff --git a/v2/world/persona.go b/v2/world/persona.go new file mode 100644 index 000000000..4db712f9d --- /dev/null +++ b/v2/world/persona.go @@ -0,0 +1,368 @@ +package world + +import ( + "errors" + "regexp" + "strings" + + "github.com/ethereum/go-ethereum/common" + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" + "pkg.world.dev/world-engine/sign" +) + +var ( + ErrPersonaNotRegistered = eris.New("persona is not registered") + ErrCreatePersonaTxsNotProcessed = eris.New("create persona txs have not been processed for the given tick") +) + +const ( + MinimumPersonaTagLength = 3 + MaximumPersonaTagLength = 16 +) + +var ( + // Regexp syntax is described here: https://github.com/google/re2/wiki/Syntax + personaTagRegexp = regexp.MustCompile("^[a-zA-Z0-9_]+$") +) + +const ( + PersonaStatusAvailable = "available" + PersonaStatusAssigned = "assigned" +) + +type PersonaManager struct { + index *personaIndex +} + +func newPersonaManager(w *World) (*PersonaManager, error) { + pm := &PersonaManager{ + index: nil, + } + + err := RegisterComponent[Persona](w) + if err != nil { + return nil, err + } + + err = RegisterQuery[PersonaQueryReq, PersonaQueryResp](w, "info", personaQuery, + WithGroup[PersonaQueryReq, PersonaQueryResp]("persona")) + if err != nil { + return nil, err + } + + err = RegisterMessage[CreatePersona](w) + if err != nil { + return nil, err + } + + err = RegisterMessage[AuthorizePersonaAddress](w) + if err != nil { + return nil, err + } + + err = RegisterSystems(w, createPersonaSystem, authorizePersonaAddressSystem) + if err != nil { + return nil, err + } + + return pm, nil +} + +func (pm *PersonaManager) Init(wCtx WorldContextReadOnly) error { + personaIndex, err := newPersonaIndex(wCtx) + if err != nil { + return err + } + pm.index = personaIndex + return nil +} + +func (pm *PersonaManager) Get(wCtx WorldContextReadOnly, personaTag string) (*Persona, types.EntityID, error) { + if pm.index == nil { + return nil, 0, eris.New("persona index is not initialized") + } + + entry, err := pm.index.get(personaTag) + if err == nil { + persona, err := GetComponent[Persona](wCtx, entry.EntityID) + if err != nil { + return nil, 0, err + } + return persona, entry.EntityID, nil + } + + return nil, 0, ErrPersonaNotRegistered +} + +// --------------------------------------------------------------------------------------------------------------------- +// Components +// --------------------------------------------------------------------------------------------------------------------- + +type Persona struct { + PersonaTag string + SignerAddress string + AuthorizedAddresses []string +} + +func (Persona) Name() string { + return "Persona" +} + +// --------------------------------------------------------------------------------------------------------------------- +// Messages +// --------------------------------------------------------------------------------------------------------------------- + +type AuthorizePersonaAddress struct { + Address string `json:"address"` +} + +func (AuthorizePersonaAddress) Name() string { + return "persona.authorize-persona-address" +} + +// CreatePersona allows for the associating of a persona tag with a signer address. +type CreatePersona struct { + PersonaTag string `json:"personaTag"` +} + +func (CreatePersona) Name() string { + return "persona.create-persona" +} + +// --------------------------------------------------------------------------------------------------------------------- +// Queries +// --------------------------------------------------------------------------------------------------------------------- + +// PersonaQueryReq is the desired request body for the query-persona-info endpoint. +type PersonaQueryReq struct { + PersonaTag string `json:"personaTag"` +} + +// PersonaQueryResp is used as the response body for the query-persona-signer endpoint. Status can be: +// "assigned": The requested persona tag has been assigned the returned SignerAddress +// "unknown": The game tick has not advanced far enough to know what the signer address. SignerAddress will be empty. +// "available": The game tick has advanced, and no signer address has been assigned. SignerAddress will be empty. +type PersonaQueryResp struct { + Status string `json:"status"` + Persona Persona `json:"persona"` +} + +func personaQuery(w WorldContextReadOnly, req *PersonaQueryReq) (*PersonaQueryResp, error) { + persona, _, err := w.GetPersona(req.PersonaTag) + if err != nil { + // Handles if the persona tag is not claimed by anyone yet. + if eris.Is(err, ErrPersonaNotRegistered) { + return &PersonaQueryResp{ + Status: PersonaStatusAvailable, + Persona: Persona{}, + }, nil + } + return nil, eris.Wrap(err, "error when fetching persona") + } + + // Handles if the persona tag has been claimed. + return &PersonaQueryResp{ + Status: PersonaStatusAssigned, + Persona: *persona, + }, nil +} + +// --------------------------------------------------------------------------------------------------------------------- +// Systems +// --------------------------------------------------------------------------------------------------------------------- + +type createPersonaResult struct { + PersonaTag string `json:"personaTag"` +} + +// createPersonaSystem is a system that will associate persona tags with signature addresses. Each persona tag +// may have at most 1 signer, so additional attempts to register a signer with a persona tag will be ignored. +func createPersonaSystem(wCtx WorldContext) error { + return EachMessage[CreatePersona](wCtx, func(tx message.TxType[CreatePersona]) (any, error) { + if !IsValidPersonaTag(tx.Msg().PersonaTag) { + err := eris.Errorf( + "persona tag %q invalid: must be between %d-%d characters & contain only alphanumeric characters and underscores", + tx.Msg().PersonaTag, + MinimumPersonaTagLength, + MaximumPersonaTagLength) + return nil, err + } + + // Normalize the persona tag to lowercase to check against mapping of lowercase tags + if _, _, err := wCtx.GetPersona(tx.Msg().PersonaTag); err == nil { + // This PersonaTag has already been registered. Don't do anything + return nil, eris.Errorf("persona tag %s has already been registered", tx.Msg().PersonaTag) + } + + id, err := Create(wCtx, Persona{}) + if err != nil { + return nil, err + } + + signer, err := tx.Signer() + if err != nil { + if eris.Is(err, sign.ErrSignatureValidationFailed) { + // If the transaction is not signed, set the persona signer to an empty string + // This should only happen when Cardinal is running with transaction verification disabled + wCtx.Logger().Warn().Msg("Transaction not signed: 0x0 address will be used") + signer = common.Address{} + } else { + // If the transaction is signed but the signer address cannot be recovered, return an error + return nil, err + } + } + + if err := SetComponent[Persona]( + wCtx, id, &Persona{ + PersonaTag: tx.Msg().PersonaTag, + SignerAddress: signer.Hex(), + AuthorizedAddresses: make([]string, 0), + }, + ); err != nil { + return nil, err + } + + // Update the index with the new persona + // TODO: This needs to be reverted when a tick fails to finalize + err = wCtx.personaManager().index.update(tx.Msg().PersonaTag, signer.Hex(), id) + if err != nil { + return nil, err + } + + return createPersonaResult{PersonaTag: tx.Msg().PersonaTag}, nil + }) +} + +type authorizedPersonaAddressResult struct { + PersonaTag string `json:"personaTag"` + AuthorizedAddress string `json:"authorizedAddress"` +} + +// authorizePersonaAddressSystem enables users to authorize an address to a persona tag. This is mostly used so that +// users who want to interact with the game via smart contract can link their EVM address to their persona tag, enabling +// them to mutate their owned state from the context of the EVM. +func authorizePersonaAddressSystem(wCtx WorldContext) error { + return EachMessage[AuthorizePersonaAddress](wCtx, func(tx message.TxType[AuthorizePersonaAddress]) (any, error) { + // Check if the Persona Tag exists + persona, id, err := wCtx.GetPersona(tx.PersonaTag()) + if err != nil { + return nil, eris.Errorf("persona %s does not exist", tx.PersonaTag()) + } + + // Check that the authorizer is the owner of the persona + signer, err := tx.Signer() + if err != nil { + return nil, eris.Wrap(err, "failed to get signer") + } + if persona.SignerAddress != signer.Hex() { + return nil, eris.Errorf("persona %s is not owned by the authorizing address", tx.PersonaTag()) + } + + // Normalize address + address := strings.ToLower(tx.Msg().Address) + address = strings.ReplaceAll(address, " ", "") + + // Check that address is valid + valid := common.IsHexAddress(address) + if !valid { + return nil, eris.Errorf("address %s is invalid", address) + } + + err = UpdateComponent[Persona](wCtx, id, func(s *Persona) *Persona { + for _, addr := range s.AuthorizedAddresses { + if addr == address { + return s + } + } + s.AuthorizedAddresses = append(s.AuthorizedAddresses, address) + return s + }) + if err != nil { + return nil, eris.Wrap(err, "unable to update persona component") + } + + return authorizedPersonaAddressResult{ + PersonaTag: tx.PersonaTag(), + AuthorizedAddress: address, + }, nil + }) +} + +// --------------------------------------------------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------------------------------------------------- + +// IsValidPersonaTag checks that string is a valid persona tag: alphanumeric + underscore +func IsValidPersonaTag(s string) bool { + if length := len(s); length < MinimumPersonaTagLength || length > MaximumPersonaTagLength { + return false + } + return personaTagRegexp.MatchString(s) +} + +// --------------------------------------------------------------------------------------------------------------------- +// Index +// --------------------------------------------------------------------------------------------------------------------- + +type personaIndexEntry struct { + SignerAddress string + EntityID types.EntityID +} + +type personaIndex struct { + index map[string]personaIndexEntry +} + +func newPersonaIndex(wCtx WorldContextReadOnly) (*personaIndex, error) { + index := map[string]personaIndexEntry{} + + var errs []error + err := wCtx.Search(filter.Exact(filter.Component[Persona]())).Each(func(id types.EntityID) bool { + sc, err := GetComponent[Persona](wCtx, id) + if err != nil { + errs = append(errs, err) + // Terminate the iteration + return false + } + + // Normalize the persona tag to lowercase + personaTag := strings.ToLower(sc.PersonaTag) + index[personaTag] = personaIndexEntry{ + SignerAddress: sc.SignerAddress, + EntityID: id, + } + + // Continue the iteration + return true + }) + if err != nil { + return nil, err + } + if len(errs) != 0 { + return nil, errors.Join(errs...) + } + + return &personaIndex{index: index}, nil +} + +func (p *personaIndex) update(personaTag string, signer string, entityID types.EntityID) error { + personaTag = strings.ToLower(personaTag) + p.index[personaTag] = personaIndexEntry{ + SignerAddress: signer, + EntityID: entityID, + } + return nil +} + +func (p *personaIndex) get(personaTag string) (*personaIndexEntry, error) { + personaTag = strings.ToLower(personaTag) + entry, ok := p.index[personaTag] + if !ok { + return nil, eris.Errorf("persona tag %q not found in index", personaTag) + } + return &entry, nil +} diff --git a/v2/world/persona_test.go b/v2/world/persona_test.go new file mode 100644 index 000000000..fa7711c7f --- /dev/null +++ b/v2/world/persona_test.go @@ -0,0 +1,283 @@ +package world_test + +import ( + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +func TestGetPersonaComponent(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + tf.CreatePersona("hello") + + tf.DoTick() + + err := tf.Cardinal.World().View(func(wCtx world.WorldContextReadOnly) error { + err := wCtx.Search(filter.Exact(world.Persona{})).Each( + func(id types.EntityID) bool { + p, err := world.GetComponent[world.Persona](wCtx, id) + assert.NilError(t, err) + assert.Equal(t, p.PersonaTag, "hello") + assert.Equal(t, p.SignerAddress, tf.SignerAddress()) + return true + }, + ) + assert.NilError(t, err) + return nil + }) + assert.NilError(t, err) +} + +func TestCreatePersonaSystem_WithNoPersonaTagCreateTxs_TickShouldBeFast(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + + const trials = 100 + startTime := time.Now() + // Collect a baseline average tick duration when there are no persona tags and nothing is going on + for i := 0; i < trials; i++ { + tf.DoTick() + } + baselineDuration := time.Since(startTime) / trials + + // Create 100 persona tags and make sure they exist + for i := 0; i < 100; i++ { + msg := world.CreatePersona{ + PersonaTag: fmt.Sprintf("personatag%d", i), + } + + txHash := tf.AddTransaction(world.CreatePersona{}.Name(), msg) + + tf.DoTick() + + // Make sure the persona tag was actually added + receipt, err := tf.Cardinal.World().GetReceipt(txHash) + assert.NilError(t, err) + assert.Empty(t, receipt.Error) + } + + startTime = time.Now() + // Collect another average for ticks that have no persona tag registrations. These ticks should be similar + // in duration to the baseline. + for i := 0; i < trials; i++ { + tf.DoTick() + } + saturatedDuration := time.Since(startTime) / trials + slowdownRatio := float64(saturatedDuration) / float64(baselineDuration) + // Fail this test if the second batch of ticks is more than 5 times slower than the original + // batch of ticks. The previously registered persona tags should have no performance impact on these systems. + assert.True(t, slowdownRatio < 5, "ticks are much slower when many persona tags have been registered") +} + +func TestPersonaTagIsValid(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"abc123", true}, + {"ABC_123", true}, + {"123", true}, + {"abc 123", false}, // contains a space + {"abc123 ", false}, // contains a trailing space + {"abc@123", false}, // contains a special character + {"snow☃man", false}, // contains a unicode character + {"", false}, // empty string + {"a", false}, // too short + {"aa", false}, // too short + {strings.Repeat("a", 17), false}, // too long, + } + + for _, test := range tests { + t.Run(test.input, func(t *testing.T) { + result := world.IsValidPersonaTag(test.input) + assert.Equal(t, result, test.expected) + }) + } +} + +func TestCreatePersonaTransactionAutomaticallyCreated(t *testing.T) { + // Verify that the world.CreatePersona is automatically world.Created and registered with a engine. + tf := cardinal.NewTestCardinal(t, nil) + tf.StartWorld() + + wantTag := "CoolMage" + tf.AddTransactionWithPersona( + world.CreatePersona{}.Name(), + wantTag, + world.CreatePersona{ + PersonaTag: wantTag, + }, + ) + // This world.CreatePersona has the same persona tag, but it shouldn't be registered because + // it comes second. + tf.AddTransactionWithPersona( + world.CreatePersona{}.Name(), + wantTag, + world.CreatePersona{ + PersonaTag: wantTag, + }, + ) + + // PersonaTag registration doesn't take place until the relevant system is run during a game tick. + tf.DoTick() + + signers := getPersona(t, tf.Cardinal.World()) + count := len(signers) + assert.Equal(t, signers[0].PersonaTag, wantTag) + assert.Equal(t, signers[0].SignerAddress, tf.SignerAddress()) + assert.Equal(t, 1, count) +} + +func TestCreatePersonaFailsIfTagIsInvalid(t *testing.T) { + // Verify that the world.CreatePersona is automatically world.Created and registered with a engine. + tf := cardinal.NewTestCardinal(t, nil) + tf.StartWorld() + tf.CreatePersona("INVALID PERSONA TAG WITH SPACES") + + // PersonaTag registration doesn't take place until the relevant system is run during a game tick. + tf.DoTick() + + signers := getPersona(t, tf.Cardinal.World()) + count := len(signers) + assert.Equal(t, count, 0) // Assert that no signer components were found +} + +func TestSamePersonaWithDifferentCaseCannotBeClaimed(t *testing.T) { + // Verify that the world.CreatePersona is automatically world.Created and registered with a engine. + tf := cardinal.NewTestCardinal(t, nil) + tf.StartWorld() + tf.CreatePersona("WowTag") + tf.CreatePersona("wowtag") + + signers := getPersona(t, tf.Cardinal.World()) + count := len(signers) + assert.Equal(t, count, 1) // Assert that only one signer component was found and it was the first one + assert.Equal(t, signers[0].PersonaTag, "WowTag") +} + +func TestCanAuthorizeAddress(t *testing.T) { + // Verify that the world.CreatePersona is automatically world.Created and registered with a engine. + tf := cardinal.NewTestCardinal(t, nil) + tf.StartWorld() + + wantTag := "CoolMage" + tf.CreatePersona(wantTag) + + authAddr := "0xd5e099c71b797516c10ed0f0d895f429c2781142" + tf.AddTransactionWithPersona( + world.AuthorizePersonaAddress{}.Name(), + wantTag, + world.AuthorizePersonaAddress{ + Address: authAddr, + }, + ) + // PersonaTag registration doesn't take place until the relevant system is run during a game tick. + tf.DoTick() + + signers := getPersona(t, tf.Cardinal.World()) + ourSigner := signers[0] + count := len(signers) + assert.Equal(t, ourSigner.PersonaTag, wantTag) + assert.Equal(t, len(ourSigner.AuthorizedAddresses), 1) + assert.Equal(t, ourSigner.AuthorizedAddresses[0], authAddr) + + // verify that the query was even ran. if for some reason there were no SignerComponents in the state, + // this test would still pass (false positive). + assert.Equal(t, count, 1) +} + +func TestAuthorizeAddressFailsOnInvalidAddress(t *testing.T) { + // Verify that the world.CreatePersona is automatically world.Created and registered with a engine. + tf := cardinal.NewTestCardinal(t, nil) + tf.StartWorld() + + personaTag := "CoolMage" + tf.CreatePersona(personaTag) + + invalidAuthAddress := "INVALID ADDRESS" + tf.AddTransactionWithPersona( + world.AuthorizePersonaAddress{}.Name(), + personaTag, + world.AuthorizePersonaAddress{ + Address: invalidAuthAddress, + }, + ) + // PersonaTag registration doesn't take place until the relevant system is run during a game tick. + tf.DoTick() + + signers := getPersona(t, tf.Cardinal.World()) + ourSigner := signers[0] + count := len(signers) + assert.Equal(t, ourSigner.PersonaTag, personaTag) + assert.Equal(t, ourSigner.SignerAddress, tf.SignerAddress()) + assert.Len(t, ourSigner.AuthorizedAddresses, 0) // Assert that no authorized address was added + + // verify that the query was even ran. if for some reason there were no SignerComponents in the state, + // this test would still pass (false positive). + assert.Equal(t, count, 1) +} + +func TestQuerySigner(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + personaTag := "CoolMage" + tf.CreatePersona(personaTag) + + reqBz, err := json.Marshal(&world.PersonaQueryReq{ + PersonaTag: personaTag, + }) + + resBz, err := tf.Cardinal.World().HandleQuery("persona", "info", reqBz) + assert.NilError(t, err) + + var res world.PersonaQueryResp + err = json.Unmarshal(resBz, &res) + assert.NilError(t, err) + + assert.Equal(t, res.Persona.SignerAddress, tf.SignerAddress()) + assert.Equal(t, res.Status, world.PersonaStatusAssigned) +} + +func TestQuerySignerAvailable(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + tf.DoTick() + + reqBz, err := json.Marshal(&world.PersonaQueryReq{ + PersonaTag: "some-random-nonexistent-persona-tag", + }) + assert.NilError(t, err) + + resBz, err := tf.Cardinal.World().HandleQuery("persona", "info", reqBz) + assert.NilError(t, err) + + var res world.PersonaQueryResp + err = json.Unmarshal(resBz, &res) + assert.NilError(t, err) + + assert.Equal(t, res.Status, world.PersonaStatusAvailable) +} + +func getPersona(t *testing.T, w *world.World) []world.Persona { + var signers = make([]world.Persona, 0) + err := w.View(func(wCtx world.WorldContextReadOnly) error { + err := wCtx.Search(filter.Exact(filter.Component[world.Persona]())).Each( + func(id types.EntityID) bool { + sc, err := world.GetComponent[world.Persona](wCtx, id) + assert.NilError(t, err) + signers = append(signers, *sc) + return true + }, + ) + assert.NilError(t, err) + return nil + }) + assert.NilError(t, err) + return signers +} diff --git a/v2/world/query.go b/v2/world/query.go new file mode 100644 index 000000000..91ea55b32 --- /dev/null +++ b/v2/world/query.go @@ -0,0 +1,156 @@ +package world + +import ( + "encoding/json" + "reflect" + + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +var _ Query = &queryType[struct{}, struct{}]{} + +var DefaultGroup = "game" + +type Query interface { + // Name returns the name of the query. + Name() string + // Group returns the group of the query. + Group() string + // GetRequestFieldInformation returns a map of the fields of the query's request type and their types. + GetRequestFieldInformation() map[string]any + + // handleQuery handles queries with concrete struct types, rather than encoded bytes. + handleQuery(WorldContextReadOnly, any) (any, error) + // HandleQueryJSON handles json-encoded query request and return a json-encoded response. + HandleQueryJSON(WorldContextReadOnly, []byte) ([]byte, error) +} + +type QueryOption[Request, Reply any] func(qt *queryType[Request, Reply]) + +type queryType[Request any, Reply any] struct { + name string + group string + handler func(wCtx WorldContextReadOnly, req *Request) (*Reply, error) +} + +// WithGroup sets a custom group for the query. +// By default, queries are registered under the "game" group which maps it to the /query/game/:queryType route. +// This option allows you to set a custom group, which allow you to register the query +// under /query//:queryType. +func WithGroup[Request, Reply any](group string) QueryOption[Request, Reply] { + return func(qt *queryType[Request, Reply]) { + qt.group = group + } +} + +func NewQueryType[Request any, Reply any]( + name string, + handler func(wCtx WorldContextReadOnly, req *Request) (*Reply, error), + opts ...QueryOption[Request, Reply], +) (Query, error) { + err := validateQuery[Request, Reply](name, handler) + if err != nil { + return nil, err + } + r := &queryType[Request, Reply]{ + name: name, + group: DefaultGroup, + handler: handler, + } + for _, opt := range opts { + opt(r) + } + + return r, nil +} + +func (r *queryType[Request, Reply]) Name() string { + return r.name +} + +func (r *queryType[Request, Reply]) Group() string { + return r.group +} + +func (r *queryType[Request, Reply]) handleQuery(wCtx WorldContextReadOnly, a any) (any, error) { + var request *Request + if reflect.TypeOf(a).Kind() == reflect.Pointer { + ptrRequest, ok := a.(*Request) + if !ok { + return nil, eris.Errorf("cannot cast %T to this query request type %T", a, new(Request)) + } + request = ptrRequest + } else { + valueReq, ok := a.(Request) + if !ok { + return nil, eris.Errorf("cannot cast %T to this query request type %T", a, new(Request)) + } + request = &valueReq + } + reply, err := r.handler(wCtx, request) + return reply, err +} + +func (r *queryType[Request, Reply]) HandleQueryJSON(wCtx WorldContextReadOnly, bz []byte) ([]byte, error) { + request := new(Request) + err := json.Unmarshal(bz, request) + if err != nil { + return nil, eris.Wrapf(err, "unable to unmarshal query request into type %T", *request) + } + + res, err := r.handler(wCtx, request) + if err != nil { + return nil, err + } + + bz, err = json.Marshal(res) + if err != nil { + return nil, eris.Wrapf(err, "unable to marshal response %T", res) + } + + return bz, nil +} + +// GetRequestFieldInformation returns the field information for the request struct. +func (r *queryType[Request, Reply]) GetRequestFieldInformation() map[string]any { + return types.GetFieldInformation(reflect.TypeOf(new(Request)).Elem()) +} + +func validateQuery[Request any, Reply any]( + name string, + handler func(wCtx WorldContextReadOnly, req *Request) (*Reply, error), +) error { + if name == "" { + return eris.New("cannot create query without name") + } + if handler == nil { + return eris.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 eris.Errorf( + "invalid query: %s: the Request and Reply generics must be both structs", + name, + ) + } + return nil +} diff --git a/v2/world/query_test.go b/v2/world/query_test.go new file mode 100644 index 000000000..df468efef --- /dev/null +++ b/v2/world/query_test.go @@ -0,0 +1,159 @@ +package world_test + +import ( + "fmt" + "testing" + + "github.com/goccy/go-json" + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +type Health struct { + Value int +} + +func (h Health) Name() string { + return "health" +} + +type QueryHealthRequest struct { + Min int +} + +type QueryHealthResponse struct { + IDs []types.EntityID +} + +func handleQueryHealth(wCtx world.WorldContextReadOnly, request *QueryHealthRequest) (*QueryHealthResponse, error) { + resp := &QueryHealthResponse{} + fmt.Println(request.Min) + err := wCtx.Search(filter.Contains(Health{})).Each( + func(id types.EntityID) bool { + fmt.Println(id) + health, err := world.GetComponent[Health](wCtx, id) + if err != nil { + return true + } + fmt.Println(health.Value) + if health.Value < request.Min { + return true + } + resp.IDs = append(resp.IDs, id) + return true + }) + if err != nil { + return nil, err + } + return resp, nil +} + +func TestQueryExample(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterQuery[QueryHealthRequest, QueryHealthResponse]( + tf.World(), + "query_health", + handleQueryHealth, + )) + + assert.NilError(t, world.RegisterComponent[Health](tf.World())) + + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + ids, err := world.CreateMany(wCtx, 100, Health{}) + if err != nil { + return err + } + for i, id := range ids { + assert.NilError(t, world.UpdateComponent[Health](wCtx, id, func(h *Health) *Health { + h.Value = i + return h + })) + } + return nil + })) + + tf.StartWorld() + tf.DoTick() + + // Give each new entity health based on the ever-increasing index + // No entities should have health over a million. + respBz, err := tf.HandleQuery(world.DefaultGroup, "query_health", QueryHealthRequest{1_000_000}) + assert.NilError(t, err) + + var resp QueryHealthResponse + err = json.Unmarshal(respBz, &resp) + assert.NilError(t, err) + assert.Equal(t, 0, len(resp.IDs)) + + // All entities should have health over -100 + respBz, err = tf.HandleQuery(world.DefaultGroup, "query_health", QueryHealthRequest{-100}) + assert.NilError(t, err) + + err = json.Unmarshal(respBz, &resp) + assert.NilError(t, err) + assert.Equal(t, 100, len(resp.IDs)) + + // Exactly 10 entities should have health at or above 90 + respBz, err = tf.HandleQuery(world.DefaultGroup, "query_health", QueryHealthRequest{90}) + assert.NilError(t, err) + + err = json.Unmarshal(respBz, &resp) + assert.NilError(t, err) + assert.Equal(t, 10, len(resp.IDs)) +} + +func TestQueryTypeNotStructs(t *testing.T) { + str := "blah" + err := world.RegisterQuery[string, string]( + cardinal.NewTestCardinal(t, nil).World(), + "foo", + func(world.WorldContextReadOnly, *string) (*string, error) { + return &str, nil + }, + ) + assert.ErrorContains(t, err, "the Request and Reply generics must be both structs") +} + +func TestErrOnNoNameOrHandler(t *testing.T) { + type foo struct{} + testCases := []struct { + name string + CreateQuery func() error + shouldErr bool + }{ + { + name: "error on no name", + CreateQuery: func() error { + return world.RegisterQuery[foo, foo]( + cardinal.NewTestCardinal(t, nil).World(), + "", + nil) + }, + shouldErr: true, + }, + { + name: "error on no handler", + CreateQuery: func() error { + return world.RegisterQuery[foo, foo]( + cardinal.NewTestCardinal(t, nil).World(), + "foo", + nil) + }, + shouldErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + if tc.shouldErr { + err := tc.CreateQuery() + assert.Assert(t, err != nil) + } else { + assert.NilError(t, tc.CreateQuery()) + } + }) + } +} diff --git a/v2/world/register.go b/v2/world/register.go new file mode 100644 index 000000000..d93a4e87b --- /dev/null +++ b/v2/world/register.go @@ -0,0 +1,63 @@ +package world + +import ( + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" +) + +type Plugin interface { + Register(w *World) error +} + +func RegisterPlugin(w *World, plugin Plugin) { + if err := plugin.Register(w); err != nil { + log.Fatal().Err(err).Msgf("failed to register plugin: %v", err) + } +} + +func RegisterSystems(w *World, sys ...System) error { + return w.RegisterSystems(false, sys...) +} + +func RegisterInitSystems(w *World, sys ...System) error { + return w.RegisterSystems(true, sys...) +} + +func RegisterComponent[T types.Component](w *World) error { + compMetadata, err := gamestate.NewComponentMetadata[T]() + if err != nil { + return err + } + return w.State().RegisterComponent(compMetadata) +} + +// RegisterMessage registers a message to the world. Cardinal will automatically set up HTTP routes that map to each +// registered message. Message URLs are take the form of "group.name". A default group, "game", is used +// unless the WithCustomGroup option is used. Example: game.throw-rock +func RegisterMessage[Msg message.Message](w *World, opts ...message.Option[Msg]) error { + var msg Msg + _, exists := w.registeredMessages[msg.Name()] + if exists { + return eris.Errorf("message %q is already registered", msg.Name()) + } else { + w.registeredMessages[msg.Name()] = message.NewMessageType[Msg](opts...) + } + return nil +} + +func RegisterQuery[Request any, Reply any]( + w *World, + name string, + handler func(wCtx WorldContextReadOnly, req *Request) (*Reply, error), + opts ...QueryOption[Request, Reply], +) (err error) { + q, err := NewQueryType[Request, Reply](name, handler, opts...) + if err != nil { + return err + } + return w.RegisterQuery(q) +} diff --git a/v2/world/system_test.go b/v2/world/system_test.go new file mode 100644 index 000000000..98cf92472 --- /dev/null +++ b/v2/world/system_test.go @@ -0,0 +1,138 @@ +package world_test + +import ( + "errors" + "testing" + + "pkg.world.dev/world-engine/assert" + "pkg.world.dev/world-engine/cardinal/v2" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/world" +) + +func IncrementSystem(w world.WorldContext) error { + var errs []error + errs = append(errs, w.Search(filter.Exact(ScalarComponentStatic{})).Each(func(id types.EntityID) bool { + errs = append(errs, world.UpdateComponent[ScalarComponentStatic](w, id, + func(h *ScalarComponentStatic) *ScalarComponentStatic { + h.Val++ + return h + })) + return true + })) + err := errors.Join(errs...) + return err +} + +func TestSystemExample(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + assert.NilError(t, world.RegisterComponent[ScalarComponentStatic](tf.World())) + assert.NilError(t, world.RegisterSystems(tf.World(), IncrementSystem)) + + var ids []types.EntityID + assert.NilError(t, world.RegisterInitSystems(tf.World(), func(wCtx world.WorldContext) error { + var err error + ids, err = world.CreateMany(wCtx, 100, ScalarComponentStatic{}) + assert.NilError(t, err) + return nil + })) + + tf.DoTick() + + // Make sure we have 100 entities all with a health of 0 + err := tf.World().View(func(wCtx world.WorldContextReadOnly) error { + for _, id := range ids { + c, err := world.GetComponent[ScalarComponentStatic](wCtx, id) + assert.NilError(t, err) + assert.Equal(t, 1, c.Val) + } + return nil + }) + assert.NilError(t, err) + + // do 5 ticks + for i := 0; i < 5; i++ { + tf.DoTick() + } + + err = tf.World().View(func(wCtx world.WorldContextReadOnly) error { + // Health should be 5 for everyone + for _, id := range ids { + var c *ScalarComponentStatic + c, err := world.GetComponent[ScalarComponentStatic](wCtx, id) + assert.NilError(t, err) + assert.Equal(t, 6, c.Val) + } + return nil + }) + assert.NilError(t, err) +} + +func TestCanRegisterMultipleSystem(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + var firstSystemCalled bool + var secondSystemCalled bool + + firstSystem := func(world.WorldContext) error { + firstSystemCalled = true + return nil + } + secondSystem := func(world.WorldContext) error { + secondSystemCalled = true + return nil + } + + err := world.RegisterSystems(tf.World(), firstSystem, secondSystem) + assert.NilError(t, err) + + tf.DoTick() + + assert.Check(t, firstSystemCalled) + assert.Check(t, secondSystemCalled) +} + +func TestInitSystemRunsOnce(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + count := 0 + count2 := 0 + err := world.RegisterInitSystems(tf.World(), func(_ world.WorldContext) error { + count++ + return nil + }, func(_ world.WorldContext) error { + count2 += 2 + return nil + }) + assert.NilError(t, err) + tf.DoTick() + tf.DoTick() + + assert.Equal(t, count, 1) + assert.Equal(t, count2, 2) +} + +func TestSystemExecutionOrder(t *testing.T) { + tf := cardinal.NewTestCardinal(t, nil) + order := make([]int, 0, 3) + err := world.RegisterSystems( + tf.World(), + func(world.WorldContext) error { + order = append(order, 1) + return nil + }, func(world.WorldContext) error { + order = append(order, 2) + return nil + }, func(world.WorldContext) error { + order = append(order, 3) + return nil + }, + ) + assert.NilError(t, err) + tf.StartWorld() + assert.NilError(t, err) + tf.DoTick() + expectedOrder := []int{1, 2, 3} + for i, elem := range order { + assert.Equal(t, elem, expectedOrder[i]) + } +} diff --git a/v2/world/world.go b/v2/world/world.go new file mode 100644 index 000000000..d6a8b823c --- /dev/null +++ b/v2/world/world.go @@ -0,0 +1,219 @@ +package world + +import ( + "encoding/json" + "sync" + + "github.com/coocood/freecache" + "github.com/ethereum/go-ethereum/common" + "github.com/rotisserie/eris" + "github.com/rs/zerolog/log" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" + + "pkg.world.dev/world-engine/cardinal/v2/config" + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/storage/redis" + "pkg.world.dev/world-engine/cardinal/v2/tick" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" +) + +const ( + ReceiptCacheSize = 10000 +) + +type World struct { + state *gamestate.State + pm *PersonaManager + + // System + // Registered systems in the order that they were registered. + // This is represented as a list as maps in Go are unordered. + registeredSystems []systemType + registeredInitSystems []systemType + + // Tx + txMap message.TxMap + txsInPool int + mux *sync.Mutex + registeredMessages map[string]message.MessageInternal + + // Query + registeredQueriesByGroup map[string]map[string]Query // group:name:query + + // Storage + rs *redis.Storage + + // Config + config *config.Config + + // Telemetry + tracer trace.Tracer // Tracer for World + + lastFinalizedTickID int64 + namespace types.Namespace + receipts *freecache.Cache +} + +// New creates a new World object using Redis as the storage layer +func New(rs *redis.Storage, opts ...Option) (*World, error) { + if rs == nil { + return nil, eris.New("redis storage is required") + } + + cfg, err := config.Load() + if err != nil { + return nil, eris.Wrap(err, "Failed to load config to start world") + } + + if cfg.CardinalRollupEnabled { + log.Info().Msgf("Creating a new Cardinal world in rollup mode") + } else { + log.Warn().Msg("Cardinal is running in development mode without rollup sequencing. " + + "If you intended to run this for production use, set CARDINAL_ROLLUP=true") + } + + s, err := gamestate.New(rs) + if err != nil { + return nil, err + } + + w := &World{ + state: s, + pm: nil, + + // System + registeredSystems: make([]systemType, 0), + registeredInitSystems: make([]systemType, 0), + + // Tx + txMap: make(message.TxMap), + txsInPool: 0, + mux: new(sync.Mutex), + registeredMessages: make(map[string]message.MessageInternal), + + // Query + registeredQueriesByGroup: make(map[string]map[string]Query), + + // Storage + rs: rs, + + // Config + config: cfg, + + // Telemetry + tracer: otel.Tracer("cardinal"), + + lastFinalizedTickID: -1, + namespace: types.Namespace(cfg.CardinalNamespace), + receipts: freecache.NewCache(ReceiptCacheSize), + } + for _, opt := range opts { + opt(w) + } + + w.pm, err = newPersonaManager(w) + if err != nil { + return nil, err + } + + return w, nil +} + +// Init marks the world as ready for use. +func (w *World) Init() error { + var err error + + if err = w.state.Init(); err != nil { + return err + } + + w.lastFinalizedTickID, err = w.state.FinalizedState().GetLastFinalizedTick() + if err != nil { + return err + } + + if err = w.pm.Init(NewWorldContextReadOnly(w.state, w.pm)); err != nil { + return err + } + + return nil +} + +func (w *World) Persona() *PersonaManager { + return w.pm +} + +func (w *World) State() *gamestate.State { + return w.state +} + +func (w *World) Namespace() string { + return string(w.namespace) +} + +func (w *World) Search(compFilter filter.ComponentFilter) *search.Search { + return search.New(w.state.FinalizedState(), compFilter) +} + +func (w *World) View(viewFn func(wCtx WorldContextReadOnly) error) error { + return viewFn(NewWorldContextReadOnly(w.State(), w.pm)) +} + +func (w *World) GetReceiptBytes(hash common.Hash) (json.RawMessage, error) { + receiptBz, err := w.receipts.Get(hash.Bytes()) + if err != nil { + return nil, ErrInvalidReceiptTxHash + } + return receiptBz, nil +} + +func (w *World) GetReceiptsBytes(txHashes []common.Hash) (map[common.Hash]json.RawMessage, error) { + receipts := make(map[common.Hash]json.RawMessage) + for _, txHash := range txHashes { + receipt, err := w.GetReceiptBytes(txHash) + if err != nil { + if eris.Is(err, ErrInvalidReceiptTxHash) { + receipts[txHash] = nil + continue + } + return nil, eris.Wrap(err, "failed to get receipts") + } + receipts[txHash] = receipt + } + return receipts, nil +} + +func (w *World) GetReceipt(hash common.Hash) (*tick.Receipt, error) { + receiptBz, err := w.GetReceiptBytes(hash) + if err != nil { + return nil, eris.Wrap(err, "failed to get receipt") + } + + var receipt tick.Receipt + err = json.Unmarshal(receiptBz, &receipt) + if err != nil { + return nil, eris.Wrap(err, "failed to unmarshal receipt") + } + + return &receipt, nil +} + +func (w *World) GetReceipts(txHashes []common.Hash) (map[common.Hash]*tick.Receipt, error) { + receipts := make(map[common.Hash]*tick.Receipt) + for _, txHash := range txHashes { + receipt, err := w.GetReceipt(txHash) + if err != nil { + if eris.Is(err, ErrInvalidReceiptTxHash) { + receipts[txHash] = nil + continue + } + return nil, eris.Wrap(err, "failed to get receipt") + } + receipts[txHash] = receipt + } + return receipts, nil +} diff --git a/v2/world/world_context.go b/v2/world/world_context.go new file mode 100644 index 000000000..cbe0a300b --- /dev/null +++ b/v2/world/world_context.go @@ -0,0 +1,165 @@ +package world + +import ( + "fmt" + + "github.com/rotisserie/eris" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "pkg.world.dev/world-engine/cardinal/v2/gamestate" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search" + "pkg.world.dev/world-engine/cardinal/v2/gamestate/search/filter" + "pkg.world.dev/world-engine/cardinal/v2/tick" + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +var nonFatalError = []error{ + gamestate.ErrEntityDoesNotExist, + gamestate.ErrComponentNotOnEntity, + gamestate.ErrComponentAlreadyOnEntity, + gamestate.ErrEntityMustHaveAtLeastOneComponent, +} + +type worldContext struct { + systemName string + writer *gamestate.EntityCommandBuffer + reader gamestate.Reader + events []map[string]any + tick *tick.Tick + pm *PersonaManager +} + +type WorldContext interface { + WorldContextReadOnly + EmitEvent(event map[string]any) + EmitStringEvent(eventMsg string) + + setSystemName(systemName string) + stateWriter() (*gamestate.EntityCommandBuffer, error) + getTick() *tick.Tick +} + +type WorldContextReadOnly interface { + Timestamp() int64 + CurrentTick() int64 + Logger() *zerolog.Logger + Search(compFilter filter.ComponentFilter) *search.Search + Namespace() string + GetPersona(personaTag string) (*Persona, types.EntityID, error) + + stateReader() gamestate.Reader + personaManager() *PersonaManager + inSystem() bool +} + +var _ WorldContext = (*worldContext)(nil) +var _ WorldContextReadOnly = (*worldContext)(nil) + +func NewWorldContext(state *gamestate.State, pm *PersonaManager, tick *tick.Tick) WorldContext { + return &worldContext{ + systemName: "", + writer: state.ECB(), + reader: state.ECB(), + tick: tick, + pm: pm, + } +} + +func NewWorldContextReadOnly(state *gamestate.State, pm *PersonaManager) WorldContextReadOnly { + return &worldContext{ + systemName: "", + writer: nil, + reader: state.FinalizedState(), + events: make([]map[string]any, 0), + tick: nil, + pm: pm, + } +} + +func (ctx *worldContext) EmitEvent(event map[string]any) { + ctx.tick.RecordEvent(ctx.systemName, event) +} + +func (ctx *worldContext) EmitStringEvent(eventMsg string) { + ctx.tick.RecordEvent(ctx.systemName, map[string]any{"message": eventMsg}) +} + +func (ctx *worldContext) Timestamp() int64 { + return ctx.tick.Timestamp +} + +func (ctx *worldContext) CurrentTick() int64 { + return ctx.tick.ID +} + +func (ctx *worldContext) Search(compFilter filter.ComponentFilter) *search.Search { + return search.New(ctx.stateReader(), compFilter) +} + +func (ctx *worldContext) Logger() *zerolog.Logger { + if ctx.systemName == "" { + return &log.Logger + } + sysLogger := log.Logger.With().Int64("tick", ctx.tick.ID).Str("system", ctx.systemName).Logger() + return &sysLogger +} + +func (ctx *worldContext) Namespace() string { + return string(ctx.tick.Namespace) +} + +func (ctx *worldContext) GetPersona(personaTag string) (*Persona, types.EntityID, error) { + return ctx.pm.Get(ctx, personaTag) +} + +func (ctx *worldContext) setSystemName(systemName string) { + ctx.systemName = systemName +} + +func (ctx *worldContext) stateWriter() (*gamestate.EntityCommandBuffer, error) { + if ctx.writer == nil { + return nil, eris.New("world context does not have a state writer") + } + return ctx.writer, nil +} + +func (ctx *worldContext) stateReader() gamestate.Reader { + return ctx.reader +} + +func (ctx *worldContext) personaManager() *PersonaManager { + return ctx.pm +} + +func (ctx *worldContext) getTick() *tick.Tick { + return ctx.tick +} + +func (ctx *worldContext) inSystem() bool { + return ctx.writer != nil +} + +// ----------------------------------------------------------------------------- +// Private methods +// ----------------------------------------------------------------------------- + +// panicOnFatalError is a helper function to panic on non-deterministic errors (i.e. Redis error). +func panicOnFatalError(wCtx WorldContextReadOnly, err error) { + fmt.Println(err) + fmt.Println(isFatalError(err)) + fmt.Println(wCtx.inSystem()) + if err != nil && isFatalError(err) && wCtx.inSystem() { + log.Logger.Panic().Err(err).Msgf("fatal error: %v", eris.ToString(err, true)) + panic(err) + } +} + +func isFatalError(err error) bool { + for _, e := range nonFatalError { + if eris.Is(err, e) { + return false + } + } + return true +} diff --git a/v2/world/world_query.go b/v2/world/world_query.go new file mode 100644 index 000000000..c99eb9111 --- /dev/null +++ b/v2/world/world_query.go @@ -0,0 +1,47 @@ +package world + +import ( + "github.com/rotisserie/eris" + + "pkg.world.dev/world-engine/cardinal/v2/types" +) + +// RegisterQuery registers a query with the query World. +// There can only be one query with a given name. +func (w *World) RegisterQuery(queryInput Query) error { + _, ok := w.registeredQueriesByGroup[queryInput.Group()] + if !ok { + w.registeredQueriesByGroup[queryInput.Group()] = make(map[string]Query) + } + + w.registeredQueriesByGroup[queryInput.Group()][queryInput.Name()] = queryInput + return nil +} + +// RegisteredQuries returns all the registered queries. +func (w *World) RegisteredQuries() []Query { + registeredQueries := make([]Query, 0, len(w.registeredQueriesByGroup)) + for _, queryGroup := range w.registeredQueriesByGroup { + for _, q := range queryGroup { + registeredQueries = append(registeredQueries, q) + } + } + return registeredQueries +} + +func (w *World) HandleQuery(group string, name string, bz []byte) ([]byte, error) { + q, err := w.getQuery(group, name) + if err != nil { + return nil, eris.Wrapf(err, "unable to find query %s/%s", group, name) + } + return q.HandleQueryJSON(NewWorldContextReadOnly(w.State(), w.pm), bz) +} + +// getQuery returns a query corresponding to the identifier with the format /. +func (w *World) getQuery(group string, name string) (Query, error) { + q, ok := w.registeredQueriesByGroup[group][name] + if !ok { + return nil, types.ErrQueryNotFound + } + return q, nil +} diff --git a/v2/world/world_system.go b/v2/world/world_system.go new file mode 100644 index 000000000..9b78637de --- /dev/null +++ b/v2/world/world_system.go @@ -0,0 +1,94 @@ +package world + +import ( + "context" + "reflect" + "slices" + + "github.com/rotisserie/eris" + "go.opentelemetry.io/otel/codes" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "pkg.world.dev/world-engine/cardinal/v2/tick" +) + +// System is a user-defined function that is executed at every tick. +type System func(ctx WorldContext) error + +// systemType is an internal entry used to track registered systems. +type systemType struct { + Name string + Fn System +} + +func (w *World) GetRegisteredSystems() []string { + sys := slices.Concat(w.registeredInitSystems, w.registeredSystems) + sysNames := make([]string, len(sys)) + for i, sys := range sys { + sysNames[i] = sys.Name + } + return sysNames +} + +// RegisterSystems registers multiple systems with the system manager. +// If isInit is true, the system will only be executed once at tick 0. +func (w *World) RegisterSystems(isInit bool, systemFuncs ...System) error { + for _, systemFunc := range systemFuncs { + if err := w.registerSystem(isInit, systemFunc); err != nil { + return eris.Wrap(err, "failed to register system") + } + } + return nil +} + +// registerSystem is an internal function that allows us to register a system with a custom system name. +func (w *World) registerSystem(isInit bool, systemFunc System) error { + sysName := reflect.TypeOf(systemFunc).Name() + sys := systemType{Name: sysName, Fn: systemFunc} + if isInit { + w.registeredInitSystems = append(w.registeredInitSystems, sys) + } else { + w.registeredSystems = append(w.registeredSystems, sys) + } + return nil +} + +// RunSystems runs all the registered system in the order that they were registered. +func (w *World) runSystems(ctx context.Context, proposal *tick.Proposal) (*tick.Tick, error) { + ctx, span := w.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "system.run") + defer span.End() + + t, err := tick.New(proposal) + if err != nil { + return nil, eris.Wrap(err, "failed to initialize tick") + } + + wCtx := NewWorldContext(w.state, w.pm, t) + + var systemsToRun []systemType + if t.ID == 0 { + systemsToRun = slices.Concat(w.registeredInitSystems, w.registeredSystems) + } else { + systemsToRun = w.registeredSystems + } + + for _, sys := range systemsToRun { + wCtx.setSystemName(sys.Name) + + // Executes the system function that the user registered + _, systemFnSpan := w.tracer.Start(ddotel.ContextWithStartOptions(ctx, + ddtracer.Measured()), + "system.run."+sys.Name) + if err := sys.Fn(wCtx); err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + systemFnSpan.SetStatus(codes.Error, eris.ToString(err, true)) + systemFnSpan.RecordError(err) + return nil, eris.Wrapf(err, "System %s generated an error", sys.Name) + } + systemFnSpan.End() + } + + return t, nil +} diff --git a/v2/world/world_tick.go b/v2/world/world_tick.go new file mode 100644 index 000000000..9df32c3c5 --- /dev/null +++ b/v2/world/world_tick.go @@ -0,0 +1,97 @@ +package world + +import ( + "context" + "time" + + "github.com/goccy/go-json" + "github.com/rotisserie/eris" + "go.opentelemetry.io/otel/codes" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "pkg.world.dev/world-engine/cardinal/v2/tick" + "pkg.world.dev/world-engine/cardinal/v2/types/message" +) + +const ( + cacheSize = 10 * 1024 * 1024 + cacheTTL = 5 * 60 // 5 minutes +) + +var ErrInvalidReceiptTxHash = eris.New("invalid receipt tx hash") + +func (w *World) LastFinalizedTick() int64 { + return w.lastFinalizedTickID +} + +// PrepareTick creates a new proposal for the next tick. +func (w *World) PrepareTick(txs message.TxMap) tick.Proposal { + return tick.Proposal{ + ID: w.lastFinalizedTickID + 1, + Timestamp: time.Now().UnixMilli(), + Namespace: w.namespace, + Txs: txs, + } +} + +// PrepareSyncTick creates a new proposal for the next tick based on historical tick data obtained from syncing. +func (w *World) PrepareSyncTick(id int64, timestamp int64, txs message.TxMap) tick.Proposal { + return tick.Proposal{ + ID: id, + Timestamp: timestamp, + Namespace: w.namespace, + Txs: txs, + } +} + +func (w *World) ApplyTick(ctx context.Context, proposal *tick.Proposal) (*tick.Tick, error) { + ctx, span := w.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "world.tick") + defer span.End() + + // This defer is here to catch any panics that occur during the tick. It will log the current tick and the + // current system that is running. + defer func() { + if r := recover(); r != nil { + panic(r) + } + }() + + // Run all registered systems. + // This will run the registered init systems if the current tick is 0 + t, err := w.runSystems(ctx, proposal) + if err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return nil, err + } + + if err := w.state.ECB().FinalizeTick(ctx); err != nil { + span.SetStatus(codes.Error, eris.ToString(err, true)) + span.RecordError(err) + return nil, err + } + + return t, nil +} + +func (w *World) CommitTick(tick *tick.Tick) error { + if tick.ID != w.lastFinalizedTickID+1 { + return eris.New("tick ID must be increment by 1") + } + + for txHash, receipt := range tick.Receipts { + receiptBz, err := json.Marshal(receipt) + if err != nil { + return eris.Wrap(err, "failed to marshal receipt") + } + err = w.receipts.Set(txHash.Bytes(), receiptBz, cacheTTL) + if err != nil { + return eris.Wrap(err, "failed to set receipt") + } + } + + w.lastFinalizedTickID = tick.ID + + return nil +} diff --git a/v2/world/world_tx.go b/v2/world/world_tx.go new file mode 100644 index 000000000..87f96060f --- /dev/null +++ b/v2/world/world_tx.go @@ -0,0 +1,135 @@ +package world + +import ( + "context" + "fmt" + "slices" + + "github.com/ethereum/go-ethereum/common" + "github.com/rotisserie/eris" + ddotel "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/opentelemetry" + ddtracer "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" + + "pkg.world.dev/world-engine/cardinal/v2/server/utils" + "pkg.world.dev/world-engine/cardinal/v2/types" + "pkg.world.dev/world-engine/cardinal/v2/types/message" + "pkg.world.dev/world-engine/sign" +) + +var ( + ErrWrongNamespace = eris.New("incorrect namespace") + ErrSystemTransactionRequired = eris.New("system transaction required") + ErrSystemTransactionForbidden = eris.New("system transaction forbidden") + ErrNoPersonaTag = eris.New("persona tag is required") +) + +// RegisteredMessages returns the list of all registered messages +func (w *World) RegisteredMessages() []types.EndpointInfo { + messageInfo := make([]types.EndpointInfo, 0, len(w.registeredMessages)) + for _, msg := range w.registeredMessages { + messageInfo = append(messageInfo, types.EndpointInfo{ + Name: msg.Name(), + Fields: msg.GetSchema(), + URL: utils.GetTxURL(msg.Name()), + }) + } + return messageInfo +} + +func (w *World) AddTransaction(msgName string, rawTx *sign.Transaction) (common.Hash, error) { + msgType, ok := w.registeredMessages[msgName] + if !ok { + return common.Hash{}, eris.Errorf("message %q not registered", msgName) + } + + tx, err := msgType.Decode(rawTx) + if err != nil { + return common.Hash{}, eris.Wrap(err, "failed to decode transaction's message") + } + + if w.config.CardinalVerifySignature { + if err := w.checkTx(msgName, tx); err != nil { + return common.Hash{}, eris.Wrap(err, "failed to verify transaction's signature") + } + } + + w.mux.Lock() + defer w.mux.Unlock() + + w.txMap[msgName] = append(w.txMap[msgName], tx) + w.txsInPool++ + + // TODO: Migrate Ed's TTL-based signature verification here + // .... + + return tx.Hash(), nil +} + +func (w *World) CopyTransactions(ctx context.Context) message.TxMap { + _, span := w.tracer.Start(ddotel.ContextWithStartOptions(ctx, ddtracer.Measured()), "world.copy-transactions") + defer span.End() + + w.mux.Lock() + defer w.mux.Unlock() + + // Save a copy of the txMap object + txMapCopy := w.txMap + + // Zero out the txMap object + w.txMap = message.TxMap{} + w.txsInPool = 0 + + // Return a pointer to the copied txMap object + return txMapCopy +} + +func (w *World) checkTx(msgName string, tx message.Tx) error { + return w.View(func(wCtx WorldContextReadOnly) error { + if tx.Namespace() != w.Namespace() { + return eris.Wrap(ErrWrongNamespace, fmt.Sprintf("expected %q got %q", w.Namespace(), tx.Namespace())) + } + + signer, err := tx.Signer() + if err != nil { + return err + } + + if err := tx.Verify(signer); err != nil { + return err + } + + // TODO: Consider making persona creation automatic. + var cpMsg CreatePersona + if msgName != cpMsg.Name() { + // Start persona validation. Only check persona tag if the message is not a CreatePersona message. + if tx.PersonaTag() == "" { + return ErrNoPersonaTag + } + + personaComp, _, err := w.pm.Get(wCtx, tx.PersonaTag()) + if err != nil { + return eris.Wrap(err, "failed to get persona component") + } + + switch { + // The signer is the persona's owner. + case signer.Hex() == personaComp.SignerAddress: + return nil + + // The signer is in the authorized address list. + case slices.Contains(personaComp.AuthorizedAddresses, signer.Hex()): + return nil + + // The signer is not authorized to sign on behalf of the persona. + default: + return eris.Errorf( + "%q is not authorized to sign transactions on behalf of persona %q", + signer.Hex(), + personaComp.PersonaTag, + ) + } + } + + return nil + }) +}