dst-server 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. {dst_server-0.2.0 → dst_server-0.2.2}/PKG-INFO +34 -15
  2. {dst_server-0.2.0 → dst_server-0.2.2}/README.md +33 -14
  3. {dst_server-0.2.0 → dst_server-0.2.2}/pyproject.toml +1 -1
  4. {dst_server-0.2.0 → dst_server-0.2.2}/pyproject.toml.orig +1 -1
  5. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/archive.py +27 -4
  6. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/agent.py +7 -1
  7. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/controller.py +4 -2
  8. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/daemon.py +2 -5
  9. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/subscriptions.py +2 -1
  10. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/commands.py +0 -15
  11. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/errors.py +0 -1
  12. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/client.py +35 -26
  13. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/lobby.py +9 -6
  14. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/telemetry.lua +2 -2
  15. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/wire.lua +21 -8
  16. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/snapshot.py +8 -0
  17. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/steamcmd.py +19 -17
  18. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/workshop.py +4 -2
  19. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/netdata.py +2 -1
  20. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/__init__.py +1 -2
  21. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/client.py +18 -13
  22. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/schema/rpc.capnp +2 -2
  23. dst_server-0.2.2/src/dst_server/rpc/schema.py +12 -0
  24. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/servants.py +16 -31
  25. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/console.py +3 -2
  26. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/lifecycle.py +4 -3
  27. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/server.py +6 -0
  28. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/otel.py +9 -5
  29. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/outbox.py +24 -10
  30. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/recorder.py +58 -25
  31. dst_server-0.2.0/src/dst_server/rpc/schema.py +0 -43
  32. {dst_server-0.2.0 → dst_server-0.2.2}/LICENSE +0 -0
  33. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/__init__.py +0 -0
  34. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/__init__.py +0 -0
  35. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/cli.py +0 -0
  36. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/generator.py +0 -0
  37. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/values.py +0 -0
  38. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/annotations/visitors.py +0 -0
  39. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/api.py +0 -0
  40. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/__init__.py +0 -0
  41. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/cli.py +0 -0
  42. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/console.py +0 -0
  43. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/cluster/service.py +0 -0
  44. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/concurrency.py +0 -0
  45. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/__init__.py +0 -0
  46. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/files.py +0 -0
  47. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/ini.py +0 -0
  48. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/models.py +0 -0
  49. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/overrides.py +0 -0
  50. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/presets.py +0 -0
  51. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/store.py +0 -0
  52. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/configuration/world.py +0 -0
  53. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/__init__.py +0 -0
  54. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/application.py +0 -0
  55. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/models.py +0 -0
  56. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/deployment/quadlet.py +0 -0
  57. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/__init__.py +0 -0
  58. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/base.py +0 -0
  59. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/player.py +0 -0
  60. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/record.py +0 -0
  61. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/server.py +0 -0
  62. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/events/world.py +0 -0
  63. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/__init__.py +0 -0
  64. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/client.py +0 -0
  65. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/enums.py +0 -0
  66. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/game/rpc.py +0 -0
  67. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/__init__.py +0 -0
  68. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/enums.py +0 -0
  69. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/schema.py +0 -0
  70. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei/version.py +0 -0
  71. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/klei_id.py +0 -0
  72. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/actions.lua +0 -0
  73. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/commands.lua +0 -0
  74. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/player_events.lua +0 -0
  75. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/player_queries.lua +0 -0
  76. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/player_values.lua +0 -0
  77. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/state.lua +0 -0
  78. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/values.lua +0 -0
  79. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/world_events.lua +0 -0
  80. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server/world_queries.lua +0 -0
  81. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua/dst_server.lua +0 -0
  82. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/lua_codec.py +0 -0
  83. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/__init__.py +0 -0
  84. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/base.py +0 -0
  85. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/cluster.py +0 -0
  86. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/driver.py +0 -0
  87. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/entity.py +0 -0
  88. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/player.py +0 -0
  89. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/server.py +0 -0
  90. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/models/telemetry.py +0 -0
  91. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/__init__.py +0 -0
  92. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/files.py +0 -0
  93. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/native.py +0 -0
  94. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/mods/process.py +0 -0
  95. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/py.typed +0 -0
  96. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/codec.py +0 -0
  97. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/rpc/transport.py +0 -0
  98. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/__init__.py +0 -0
  99. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/config.py +0 -0
  100. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/driver.py +0 -0
  101. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/fds.py +0 -0
  102. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/operational.py +0 -0
  103. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/request.py +0 -0
  104. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/runtime/supervisor.py +0 -0
  105. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/__init__.py +0 -0
  106. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/config.py +0 -0
  107. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/exporter.py +0 -0
  108. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/telemetry/stream.py +0 -0
  109. {dst_server-0.2.0 → dst_server-0.2.2}/src/dst_server/timeouts.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dst-server
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Start and control a Don't Starve Together server process.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -383,7 +383,7 @@ The master Agent registers in-process; secondary Agents register through the Pod
383
383
 
384
384
  | Channel | Purpose |
385
385
  | --- | --- |
386
- | `/cluster/.dst-server.sock` | Public Cap'n Proto RPC; connections check a schema fingerprint, so the client and daemon should use matching versions. |
386
+ | `/cluster/.dst-server.sock` | Public Cap'n Proto RPC. |
387
387
  | Game FD 3 | Lua command input. |
388
388
  | Game FD 4 | Command text output; raw Lua must explicitly `print`. |
389
389
  | Game FD 5 | Native lifecycle events such as Ready, Session, Saved, and Stopping. |
@@ -582,8 +582,6 @@ Each endpoint accepts only its declared command scope.
582
582
  Game clients handle game operations; controllers own process lifecycle and cluster coordination.
583
583
 
584
584
  Cap'n Proto carries commands through `call` and observations through subscription capabilities.
585
- The handshake fingerprint covers the capability schema and Pydantic request, result, event, and error schemas.
586
- Use matching client and daemon versions.
587
585
  Configuration payloads preserve omitted fields, explicit `False`, world override types, and secrets needed for saving.
588
586
 
589
587
  Import cluster results, statuses, and observation cursors from [models.cluster](src/dst_server/models/cluster.py).
@@ -773,7 +771,9 @@ if catalog.has_more and catalog.snapshots:
773
771
  The Agent rejects path escapes, symlinks, invalid metadata, and session changes during a query.
774
772
  For standalone reads, use `WorldSnapshotMetadata.load(path)` / `PlayerSnapshotMetadata.load(path)` from [models.snapshot](src/dst_server/models/snapshot.py).
775
773
  The loaders parse only UTF-8 Lua literals and support native text headers and trailing NULs.
776
- They reject unknown fields, wrong types, and dynamic expressions.
774
+ Additional fields in `clock` and `seasons` written by Mods are ignored; known fields retain strict validation.
775
+ Unknown fields elsewhere, wrong types, and dynamic expressions are rejected.
776
+ The day uses the standard `clock.cycles + 1`, without interpreting Mod calendars.
777
777
  The world model covers `clock`, `seasons`, and nested fields.
778
778
  The player model exposes `character`, including Mod character identifiers.
779
779
 
@@ -838,22 +838,21 @@ Export detects file changes but cannot guarantee an atomic snapshot of an online
838
838
  The input must remain unchanged.
839
839
  Exports and uploads are available; there is no import API yet.
840
840
 
841
- Before uploading to R2, set [obstore's S3 environment variables](https://developmentseed.org/obstore/latest/api/store/aws/#obstore.store.S3Config) in the calling process:
842
-
843
- ```shell
844
- export AWS_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com'
845
- export AWS_BUCKET='your-bucket'
846
- export AWS_ACCESS_KEY_ID='your-access-key-id'
847
- export AWS_SECRET_ACCESS_KEY='your-secret-access-key'
848
- ```
841
+ Pass S3 connection settings and credentials directly when uploading to R2:
849
842
 
850
843
  ```python
851
844
  from pathlib import Path
852
845
 
846
+ from pydantic import SecretStr
847
+
853
848
  from dst_server.archive import export_cluster
854
849
 
855
850
  with export_cluster(Path("/srv/dst/000")) as archive:
856
851
  result = archive.upload(
852
+ endpoint="https://<account-id>.r2.cloudflarestorage.com",
853
+ bucket="your-bucket",
854
+ access_key_id=SecretStr("your-access-key-id"),
855
+ secret_access_key=SecretStr("your-secret-access-key"),
857
856
  object_prefix="rooms/exports/",
858
857
  url_prefix="https://downloads.example.com/",
859
858
  )
@@ -862,6 +861,25 @@ print(result.key)
862
861
  print(result.url)
863
862
  ```
864
863
 
864
+ | Upload argument | Type | Default | Environment fallback |
865
+ | --- | --- | --- | --- |
866
+ | `bucket` | `str \| None` | `None` | `AWS_BUCKET` |
867
+ | `endpoint` | `str \| None` | `None` | `AWS_ENDPOINT_URL_S3`, then `AWS_ENDPOINT` |
868
+ | `region` | `str` | `"auto"` | None; the argument overrides `AWS_REGION` |
869
+ | `access_key_id` | `SecretStr \| None` | `None` | `AWS_ACCESS_KEY_ID` |
870
+ | `secret_access_key` | `SecretStr \| None` | `None` | `AWS_SECRET_ACCESS_KEY` |
871
+ | `session_token` | `SecretStr \| None` | `None` | `AWS_SESSION_TOKEN` |
872
+ | `object_prefix` | `str` | `""` | None |
873
+ | `url_prefix` | `str \| None` | `None` | None |
874
+
875
+ The three credential arguments require `SecretStr` instances; plain strings are rejected.
876
+ Secrets are unwrapped only when creating `S3Store` and are never written to the archive.
877
+ Explicit values override the corresponding environment settings, including `AWS_ENDPOINT_URL_S3` for `endpoint`.
878
+ `None` leaves that field to [obstore's environment configuration](https://developmentseed.org/obstore/latest/api/store/aws/#obstore.store.S3Config).
879
+ This fallback applies per field: an omitted `session_token` can still come from the environment when both keys are explicit.
880
+ Other obstore options retain their environment behavior.
881
+ Calling `upload()` without connection or credential arguments continues to use AWS environment variables.
882
+
865
883
  `upload()` reads from the start of the stream and returns `ArchiveUploadResult` with `key` and `url` fields.
866
884
  The object key is `object_prefix + archive.filename`; `object_prefix` defaults to an empty string.
867
885
  The filename remains `DST-<room-id>-<UTC timestamp>.7z`, with the timestamp precise to seconds.
@@ -871,8 +889,9 @@ Both prefixes are explicit SDK arguments and are concatenated literally.
871
889
  `object_prefix` must not begin with `/`, which the storage backend would otherwise strip from the key.
872
890
  Supply any required separators, such as `/` or `?file=`, yourself.
873
891
  Query prefixes and trailing separators are preserved, and the URL is never inferred from the bucket or S3 endpoint.
874
- S3 configuration still uses the AWS environment variables above; there are no environment variables for these prefixes.
875
- `S3Store(region="auto")` uses [R2's `auto` region](https://developers.cloudflare.com/r2/api/s3/api/#bucket-region), so `AWS_REGION` is unnecessary.
892
+ The two prefixes are ordinary strings and have no corresponding environment variables.
893
+ The default `region="auto"` uses [R2's `auto` region](https://developers.cloudflare.com/r2/api/s3/api/#bucket-region).
894
+ Pass `region` explicitly when uploading to a different S3 region.
876
895
  [obstore handles multipart uploads](https://developmentseed.org/obstore/latest/api/put/); errors propagate to the caller, and local temporary files are still cleaned up.
877
896
  Remote parts from failed uploads may remain; R2 removes them after seven days by default.
878
897
  Configure this through [lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/).
@@ -360,7 +360,7 @@ The master Agent registers in-process; secondary Agents register through the Pod
360
360
 
361
361
  | Channel | Purpose |
362
362
  | --- | --- |
363
- | `/cluster/.dst-server.sock` | Public Cap'n Proto RPC; connections check a schema fingerprint, so the client and daemon should use matching versions. |
363
+ | `/cluster/.dst-server.sock` | Public Cap'n Proto RPC. |
364
364
  | Game FD 3 | Lua command input. |
365
365
  | Game FD 4 | Command text output; raw Lua must explicitly `print`. |
366
366
  | Game FD 5 | Native lifecycle events such as Ready, Session, Saved, and Stopping. |
@@ -559,8 +559,6 @@ Each endpoint accepts only its declared command scope.
559
559
  Game clients handle game operations; controllers own process lifecycle and cluster coordination.
560
560
 
561
561
  Cap'n Proto carries commands through `call` and observations through subscription capabilities.
562
- The handshake fingerprint covers the capability schema and Pydantic request, result, event, and error schemas.
563
- Use matching client and daemon versions.
564
562
  Configuration payloads preserve omitted fields, explicit `False`, world override types, and secrets needed for saving.
565
563
 
566
564
  Import cluster results, statuses, and observation cursors from [models.cluster](src/dst_server/models/cluster.py).
@@ -750,7 +748,9 @@ if catalog.has_more and catalog.snapshots:
750
748
  The Agent rejects path escapes, symlinks, invalid metadata, and session changes during a query.
751
749
  For standalone reads, use `WorldSnapshotMetadata.load(path)` / `PlayerSnapshotMetadata.load(path)` from [models.snapshot](src/dst_server/models/snapshot.py).
752
750
  The loaders parse only UTF-8 Lua literals and support native text headers and trailing NULs.
753
- They reject unknown fields, wrong types, and dynamic expressions.
751
+ Additional fields in `clock` and `seasons` written by Mods are ignored; known fields retain strict validation.
752
+ Unknown fields elsewhere, wrong types, and dynamic expressions are rejected.
753
+ The day uses the standard `clock.cycles + 1`, without interpreting Mod calendars.
754
754
  The world model covers `clock`, `seasons`, and nested fields.
755
755
  The player model exposes `character`, including Mod character identifiers.
756
756
 
@@ -815,22 +815,21 @@ Export detects file changes but cannot guarantee an atomic snapshot of an online
815
815
  The input must remain unchanged.
816
816
  Exports and uploads are available; there is no import API yet.
817
817
 
818
- Before uploading to R2, set [obstore's S3 environment variables](https://developmentseed.org/obstore/latest/api/store/aws/#obstore.store.S3Config) in the calling process:
819
-
820
- ```shell
821
- export AWS_ENDPOINT='https://<account-id>.r2.cloudflarestorage.com'
822
- export AWS_BUCKET='your-bucket'
823
- export AWS_ACCESS_KEY_ID='your-access-key-id'
824
- export AWS_SECRET_ACCESS_KEY='your-secret-access-key'
825
- ```
818
+ Pass S3 connection settings and credentials directly when uploading to R2:
826
819
 
827
820
  ```python
828
821
  from pathlib import Path
829
822
 
823
+ from pydantic import SecretStr
824
+
830
825
  from dst_server.archive import export_cluster
831
826
 
832
827
  with export_cluster(Path("/srv/dst/000")) as archive:
833
828
  result = archive.upload(
829
+ endpoint="https://<account-id>.r2.cloudflarestorage.com",
830
+ bucket="your-bucket",
831
+ access_key_id=SecretStr("your-access-key-id"),
832
+ secret_access_key=SecretStr("your-secret-access-key"),
834
833
  object_prefix="rooms/exports/",
835
834
  url_prefix="https://downloads.example.com/",
836
835
  )
@@ -839,6 +838,25 @@ print(result.key)
839
838
  print(result.url)
840
839
  ```
841
840
 
841
+ | Upload argument | Type | Default | Environment fallback |
842
+ | --- | --- | --- | --- |
843
+ | `bucket` | `str \| None` | `None` | `AWS_BUCKET` |
844
+ | `endpoint` | `str \| None` | `None` | `AWS_ENDPOINT_URL_S3`, then `AWS_ENDPOINT` |
845
+ | `region` | `str` | `"auto"` | None; the argument overrides `AWS_REGION` |
846
+ | `access_key_id` | `SecretStr \| None` | `None` | `AWS_ACCESS_KEY_ID` |
847
+ | `secret_access_key` | `SecretStr \| None` | `None` | `AWS_SECRET_ACCESS_KEY` |
848
+ | `session_token` | `SecretStr \| None` | `None` | `AWS_SESSION_TOKEN` |
849
+ | `object_prefix` | `str` | `""` | None |
850
+ | `url_prefix` | `str \| None` | `None` | None |
851
+
852
+ The three credential arguments require `SecretStr` instances; plain strings are rejected.
853
+ Secrets are unwrapped only when creating `S3Store` and are never written to the archive.
854
+ Explicit values override the corresponding environment settings, including `AWS_ENDPOINT_URL_S3` for `endpoint`.
855
+ `None` leaves that field to [obstore's environment configuration](https://developmentseed.org/obstore/latest/api/store/aws/#obstore.store.S3Config).
856
+ This fallback applies per field: an omitted `session_token` can still come from the environment when both keys are explicit.
857
+ Other obstore options retain their environment behavior.
858
+ Calling `upload()` without connection or credential arguments continues to use AWS environment variables.
859
+
842
860
  `upload()` reads from the start of the stream and returns `ArchiveUploadResult` with `key` and `url` fields.
843
861
  The object key is `object_prefix + archive.filename`; `object_prefix` defaults to an empty string.
844
862
  The filename remains `DST-<room-id>-<UTC timestamp>.7z`, with the timestamp precise to seconds.
@@ -848,8 +866,9 @@ Both prefixes are explicit SDK arguments and are concatenated literally.
848
866
  `object_prefix` must not begin with `/`, which the storage backend would otherwise strip from the key.
849
867
  Supply any required separators, such as `/` or `?file=`, yourself.
850
868
  Query prefixes and trailing separators are preserved, and the URL is never inferred from the bucket or S3 endpoint.
851
- S3 configuration still uses the AWS environment variables above; there are no environment variables for these prefixes.
852
- `S3Store(region="auto")` uses [R2's `auto` region](https://developers.cloudflare.com/r2/api/s3/api/#bucket-region), so `AWS_REGION` is unnecessary.
869
+ The two prefixes are ordinary strings and have no corresponding environment variables.
870
+ The default `region="auto"` uses [R2's `auto` region](https://developers.cloudflare.com/r2/api/s3/api/#bucket-region).
871
+ Pass `region` explicitly when uploading to a different S3 region.
853
872
  [obstore handles multipart uploads](https://developmentseed.org/obstore/latest/api/put/); errors propagate to the caller, and local temporary files are still cleaned up.
854
873
  Remote parts from failed uploads may remain; R2 removes them after seven days by default.
855
874
  Configure this through [lifecycle rules](https://developers.cloudflare.com/r2/buckets/object-lifecycles/).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dst-server"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Start and control a Don't Starve Together server process."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dst-server"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Start and control a Don't Starve Together server process."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -10,7 +10,7 @@ from pathlib import Path
10
10
  from tempfile import TemporaryFile
11
11
  from typing import Annotated, BinaryIO
12
12
 
13
- from pydantic import ConfigDict, Field, validate_call
13
+ from pydantic import ConfigDict, Field, InstanceOf, SecretStr, validate_call
14
14
 
15
15
  from dst_server.klei_id import encode_klei_id
16
16
 
@@ -33,17 +33,39 @@ class ClusterArchive:
33
33
  filename: str
34
34
  stream: BinaryIO
35
35
 
36
- @validate_call(config=ConfigDict(strict=True))
36
+ @validate_call(config=ConfigDict(strict=True, hide_input_in_errors=True))
37
37
  def upload(
38
38
  self,
39
39
  *,
40
+ bucket: str | None = None,
41
+ endpoint: str | None = None,
42
+ region: str = "auto",
43
+ access_key_id: InstanceOf[SecretStr] | None = None,
44
+ secret_access_key: InstanceOf[SecretStr] | None = None,
45
+ session_token: InstanceOf[SecretStr] | None = None,
40
46
  object_prefix: Annotated[str, Field(pattern=r"^(?:[^/]|$)")] = "",
41
47
  url_prefix: str | None = None,
42
48
  ) -> ArchiveUploadResult:
43
- """Upload using AWS environment variables and literal key/URL prefixes."""
49
+ """Upload with explicit S3 settings overriding AWS environment variables."""
44
50
  from obstore.store import S3Store
45
51
 
46
- store = S3Store(region="auto")
52
+ settings = {
53
+ "aws_endpoint_url_s3": endpoint,
54
+ "access_key_id": access_key_id,
55
+ "secret_access_key": secret_access_key,
56
+ "session_token": session_token,
57
+ }
58
+ store = S3Store(
59
+ bucket,
60
+ region=region,
61
+ **{
62
+ name: value.get_secret_value()
63
+ if isinstance(value, SecretStr)
64
+ else value
65
+ for name, value in settings.items()
66
+ if value is not None
67
+ },
68
+ )
47
69
  key = f"{object_prefix}{self.filename}"
48
70
  self.stream.seek(0)
49
71
  store.put(
@@ -154,6 +176,7 @@ def export_cluster( # ruff: ignore[complex-structure, too-many-branches]
154
176
  if saves != _save_files(directory, configuration, encode_user_path):
155
177
  msg = "saves changed during export; use a quiescent copy or stop the games"
156
178
  raise RuntimeError(msg)
179
+ del saves, files, exported, configuration
157
180
  stream.seek(0)
158
181
  yield ClusterArchive(filename, stream)
159
182
 
@@ -86,7 +86,7 @@ class ShardAgent:
86
86
  self._fatal = asyncio.Event()
87
87
  self._failure_id: ULID | None = None
88
88
  self._started_at_ns: int | None = None
89
- self.failures: asyncio.Queue[ShardSupervisorStatus] = asyncio.Queue()
89
+ self.failures: asyncio.Queue[ShardSupervisorStatus] = asyncio.Queue(maxsize=1)
90
90
  self.supervisor = ShardSupervisor(
91
91
  shard.name,
92
92
  self._new_server,
@@ -474,6 +474,9 @@ class ShardAgent:
474
474
 
475
475
  async def _failed(self, status: ShardSupervisorStatus) -> None:
476
476
  self._failure_id = ULID()
477
+ # Failure reports wake reconciliation, which reads the current status.
478
+ if self.failures.full():
479
+ self.failures.get_nowait()
477
480
  self.failures.put_nowait(status)
478
481
 
479
482
  def _background_done(
@@ -536,6 +539,7 @@ class ShardAgent:
536
539
  if isinstance(event, SessionEvent):
537
540
  self._generation_sequence += 1
538
541
  self._event_changed.notify_all()
542
+ del observed, event
539
543
 
540
544
  async def _drain_game_events(self, server: Server) -> None:
541
545
  attempt = ULID.from_str(server.game_events.nonce)
@@ -560,6 +564,7 @@ class ShardAgent:
560
564
  event=observed.record,
561
565
  )
562
566
  )
567
+ del observed
563
568
 
564
569
  async def _drain_operational(self, server: Server) -> None:
565
570
  while (record := await server.read_operational_event()) is not None:
@@ -582,3 +587,4 @@ class ShardAgent:
582
587
  event=record.event_name,
583
588
  body=record.body,
584
589
  )
590
+ del record
@@ -1141,8 +1141,9 @@ class ClusterController(ClusterAPI):
1141
1141
  )
1142
1142
  self._reconcile_task.add_done_callback(self._consume_task_result)
1143
1143
 
1144
- @staticmethod
1145
- def _consume_task_result(task: asyncio.Task[None]) -> None:
1144
+ def _consume_task_result(self, task: asyncio.Task[None]) -> None:
1145
+ if self._reconcile_task is task:
1146
+ self._reconcile_task = None
1146
1147
  if not task.cancelled():
1147
1148
  task.exception()
1148
1149
 
@@ -1175,6 +1176,7 @@ class ClusterController(ClusterAPI):
1175
1176
  for item in batch:
1176
1177
  for target in targets:
1177
1178
  target.publish(item)
1179
+ del batch, item
1178
1180
  except SubscriptionOverflowError:
1179
1181
  logger.warning(
1180
1182
  "internal shard relay overflowed; resubscribing: {shard}",
@@ -16,7 +16,7 @@ from dst_server.configuration.models import ShardName
16
16
  from dst_server.configuration.store import ConfigurationStore
17
17
  from dst_server.rpc.client import rpc_runtime
18
18
  from dst_server.rpc.codec import unwrap_outcome
19
- from dst_server.rpc.schema import SCHEMA_FINGERPRINT, load_schema
19
+ from dst_server.rpc.schema import load_schema
20
20
  from dst_server.rpc.servants import (
21
21
  AgentServant,
22
22
  BootstrapServant,
@@ -355,10 +355,7 @@ async def _registered_cycle(agent: ShardAgent, internal_address: str) -> None:
355
355
  stack.callback(client.close)
356
356
  registry = client.bootstrap().cast_as(load_schema().WorkerRegistry)
357
357
  async with asyncio.timeout(DEFAULT_LIFECYCLE_TIMEOUT):
358
- response = await registry.register(
359
- schemaFingerprint=SCHEMA_FINGERPRINT,
360
- agent=servant,
361
- )
358
+ response = await registry.register(agent=servant)
362
359
  unwrap_outcome(response.result)
363
360
  disconnected = asyncio.ensure_future(client.on_disconnect())
364
361
  while True:
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  from typing import Annotated
3
+ from weakref import WeakSet
3
4
 
4
5
  from pydantic import Field, TypeAdapter
5
6
 
@@ -11,7 +12,7 @@ BATCH_SIZE = TypeAdapter(Annotated[int, Field(ge=1, le=MAX_BATCH_SIZE)])
11
12
 
12
13
  class Broadcast[T]:
13
14
  def __init__(self) -> None:
14
- self._subscriptions: set[Subscription[T]] = set()
15
+ self._subscriptions: WeakSet[Subscription[T]] = WeakSet()
15
16
  self._closed = False
16
17
 
17
18
  def subscribe(self) -> Subscription[T]:
@@ -492,18 +492,3 @@ def parse_request(payload: bytes, *, scope: Scope) -> Request[Any]:
492
492
  if envelope.timeout is not None:
493
493
  arguments |= {"timeout": envelope.timeout}
494
494
  return spec.request.model_validate_json(json.dumps(arguments, allow_nan=False))
495
-
496
-
497
- def schema_contract() -> dict[str, dict[str, dict[str, Any]]]:
498
- return {
499
- scope: {
500
- method: {
501
- "request": spec.request.model_json_schema(),
502
- "result": spec.response.json_schema(),
503
- "mutation": spec.mutation,
504
- }
505
- for (entry_scope, method), spec in sorted(_OPERATIONS.items())
506
- if entry_scope == scope
507
- }
508
- for scope in _ALL
509
- }
@@ -16,7 +16,6 @@ class ErrorCode(StrEnum):
16
16
  TIMEOUT = "timeout"
17
17
  OVERFLOW = "overflow"
18
18
  TOPOLOGY_CHANGE_REQUIRED = "topologyChangeRequired"
19
- INCOMPATIBLE_SCHEMA = "incompatibleSchema"
20
19
  INTERNAL = "internal"
21
20
  INDETERMINATE = "indeterminate"
22
21
 
@@ -1,8 +1,8 @@
1
- from asyncio import Semaphore, TaskGroup
2
- from collections.abc import Iterable
1
+ from asyncio import TaskGroup
2
+ from collections.abc import Awaitable, Callable, Iterable
3
3
  from itertools import chain, product
4
4
  from types import TracebackType
5
- from typing import Self
5
+ from typing import Self, cast
6
6
 
7
7
  import httpx2
8
8
  from logbook import Logger
@@ -111,18 +111,12 @@ class KleiClient:
111
111
  regions: Iterable[Region | str] = Region,
112
112
  platforms: Iterable[Platform] = Platform,
113
113
  ) -> tuple[Lobby, ...]:
114
- semaphore = Semaphore(self.lobby_concurrency)
115
-
116
- async def load(region: Region | str, platform: Platform) -> tuple[Lobby, ...]:
117
- async with semaphore:
118
- return await self.lobby(region, platform)
119
-
120
- async with TaskGroup() as group:
121
- tasks = [
122
- group.create_task(load(region, platform))
123
- for region, platform in product(regions, platforms)
124
- ]
125
- return tuple(chain.from_iterable(task.result() for task in tasks))
114
+ batches = await _map(
115
+ product(regions, platforms),
116
+ lambda pair: self.lobby(*pair),
117
+ self.lobby_concurrency,
118
+ )
119
+ return tuple(chain.from_iterable(batches))
126
120
 
127
121
  async def get_rooms(
128
122
  self,
@@ -132,17 +126,13 @@ class KleiClient:
132
126
  msg = "a Klei access token is required to query room details"
133
127
  raise ValueError(msg)
134
128
  if rooms is None:
135
- lobbies = await self.get_lobbies()
136
- rooms = ((lobby.row_id, lobby.region) for lobby in lobbies)
137
- semaphore = Semaphore(self.room_concurrency)
138
-
139
- async def load(row_id: str, region: Region | str) -> Room | None:
140
- async with semaphore:
141
- return await self.room(row_id, region)
142
-
143
- async with TaskGroup() as group:
144
- tasks = [group.create_task(load(*room)) for room in rooms]
145
- return tuple(room for task in tasks if (room := task.result()) is not None)
129
+ rooms = tuple(
130
+ (lobby.row_id, lobby.region) for lobby in await self.get_lobbies()
131
+ )
132
+ results = await _map(
133
+ rooms, lambda pair: self.room(*pair), self.room_concurrency
134
+ )
135
+ return tuple(room for room in results if room is not None)
146
136
 
147
137
  async def lobby(
148
138
  self,
@@ -203,6 +193,25 @@ class KleiClient:
203
193
  return data.rows[0] if data.rows else None
204
194
 
205
195
 
196
+ async def _map[ItemT, ResultT](
197
+ items: Iterable[ItemT],
198
+ load: Callable[[ItemT], Awaitable[ResultT]],
199
+ concurrency: int,
200
+ ) -> tuple[ResultT, ...]:
201
+ pending = enumerate(items)
202
+ results: list[ResultT | None] = []
203
+
204
+ async def worker() -> None:
205
+ for index, item in pending:
206
+ results.append(None)
207
+ results[index] = await load(item)
208
+
209
+ async with TaskGroup() as group:
210
+ for _ in range(concurrency):
211
+ group.create_task(worker())
212
+ return cast("tuple[ResultT, ...]", tuple(results))
213
+
214
+
206
215
  def positive(name: str, value: int) -> int:
207
216
  if isinstance(value, bool) or not isinstance(value, int) or value < 1:
208
217
  msg = f"{name} must be a positive integer"
@@ -1,5 +1,6 @@
1
1
  from collections.abc import Mapping
2
2
  from ipaddress import IPv4Address
3
+ from re import sub
3
4
  from typing import Annotated
4
5
 
5
6
  from pydantic import (
@@ -33,12 +34,12 @@ class DataResponse[DataT](KleiModel):
33
34
 
34
35
  class Player(KleiModel):
35
36
  name: str
36
- kuid: str
37
- role: Role | str | None = None
38
- steam_id: int | None = None
39
- ip: IPv4Address | None = None
37
+ netid: str
38
+ prefab: Role | str
39
+ colour: str
40
+ eventlevel: int
40
41
 
41
- @field_validator("role", mode="before")
42
+ @field_validator("prefab", mode="before")
42
43
  @classmethod
43
44
  def preserve_custom_role(cls, value: object) -> object:
44
45
  return Role(value) if isinstance(value, str) and value in Role else value
@@ -118,7 +119,9 @@ class Room(Lobby):
118
119
  return ()
119
120
  if not isinstance(value, str):
120
121
  return value
121
- players = parse_literal(value, "Klei room players")
122
+ players = parse_literal(
123
+ sub(r"\A\s*return\b", "", value, count=1), "Klei room players"
124
+ )
122
125
  if players == {}:
123
126
  return ()
124
127
  if not isinstance(players, list):
@@ -30,7 +30,7 @@ local function write(event_name, data)
30
30
  monotonic_ms = math.floor(GetTimeReal()),
31
31
  cycle = cycle == nil and json.null or cycle,
32
32
  data = data,
33
- })
33
+ }, state.max_line_bytes - #state.prefix)
34
34
  if type(encoded) ~= "string" then
35
35
  error("encoding_failed", 0)
36
36
  end
@@ -65,7 +65,7 @@ end
65
65
  function telemetry.emit(event_name, data)
66
66
  local ok, emitted, failure = pcall(write, event_name, data)
67
67
  if not ok then
68
- telemetry.report(event_name, "encoding_failed")
68
+ telemetry.report(event_name, emitted == "response_too_large" and "event_too_large" or "encoding_failed")
69
69
  elseif not emitted then
70
70
  telemetry.report(event_name, failure)
71
71
  end
@@ -3,6 +3,7 @@ local wire = {}
3
3
  local object_marker = {}
4
4
  local indeterminate = {}
5
5
  local prefix = "DST_SERVER_RESULT|"
6
+ local success_prefix = '{"ok":true,"data":'
6
7
  local maximum_line_bytes = 64 * 1024
7
8
  local escapes = { ['"'] = '\\"', ["\\"] = "\\\\" }
8
9
 
@@ -23,7 +24,9 @@ function wire.indeterminate()
23
24
  error(indeterminate, 0)
24
25
  end
25
26
 
26
- local function quote(value)
27
+ local function quote(value, remaining)
28
+ local size = #value + 2
29
+ if size > remaining then error("response_too_large", 0) end
27
30
  local index = 1
28
31
  while true do
29
32
  index = string.find(value, "[\128-\255]", index)
@@ -59,13 +62,21 @@ local function quote(value)
59
62
  end
60
63
  index = index + width + 1
61
64
  end
62
- return '"' .. string.gsub(value, '[%z\1-\31\\"]', escapes) .. '"'
65
+ return '"' .. string.gsub(value, '[%z\1-\31\\"]', function(character)
66
+ local escaped = escapes[character]
67
+ size = size + #escaped - 1
68
+ if size > remaining then error("response_too_large", 0) end
69
+ return escaped
70
+ end) .. '"'
63
71
  end
64
72
 
65
- function wire.encode(value)
73
+ function wire.encode(value, limit)
66
74
  local parts, seen = {}, {}
75
+ local remaining = limit or maximum_line_bytes
67
76
 
68
77
  local function append(text)
78
+ remaining = remaining - #text
79
+ if remaining < 0 then error("response_too_large", 0) end
69
80
  parts[#parts + 1] = text
70
81
  end
71
82
 
@@ -76,7 +87,7 @@ function wire.encode(value)
76
87
  end
77
88
  local kind = type(item)
78
89
  if kind == "string" then
79
- append(quote(item))
90
+ append(quote(item, remaining))
80
91
  return
81
92
  elseif kind == "boolean" then
82
93
  append(item and "true" or "false")
@@ -127,7 +138,7 @@ function wire.encode(value)
127
138
  append(",")
128
139
  end
129
140
  first = false
130
- append(quote(key))
141
+ append(quote(key, remaining))
131
142
  append(":")
132
143
  encode(child)
133
144
  end
@@ -153,14 +164,15 @@ function wire.reply(callback)
153
164
  local ok, data = pcall(callback)
154
165
  local payload, failure
155
166
  if ok then
156
- ok, data = pcall(wire.encode, data)
167
+ ok, data = pcall(wire.encode, data, maximum_line_bytes - #prefix - #success_prefix - 1)
157
168
  if ok then
158
- payload = '{"ok":true,"data":' .. data .. "}"
169
+ payload = success_prefix .. data .. "}"
159
170
  if #prefix + #payload > maximum_line_bytes then
160
171
  failure = "response_too_large"
161
172
  end
162
173
  else
163
- failure = data == "invalid_utf8" and "invalid_utf8" or "invalid_json_value"
174
+ failure = (data == "invalid_utf8" or data == "response_too_large")
175
+ and data or "invalid_json_value"
164
176
  end
165
177
  else
166
178
  failure = data == indeterminate and "indeterminate" or "lua_error"
@@ -168,6 +180,7 @@ function wire.reply(callback)
168
180
  if failure ~= nil then
169
181
  payload = '{"ok":false,"error":"' .. failure .. '"}'
170
182
  end
183
+ data = nil
171
184
  print(prefix .. payload)
172
185
  end
173
186
 
@@ -78,6 +78,14 @@ class _Metadata(FrozenModel):
78
78
  msg = f"unsupported snapshot metadata format: {path}"
79
79
  raise ValueError(msg)
80
80
  value = parse_literal(source.removeprefix("return"), "snapshot metadata")
81
+ if isinstance(value, dict):
82
+ for name, model in (("clock", SnapshotClock), ("seasons", SnapshotSeasons)):
83
+ if isinstance(fields := value.get(name), dict):
84
+ value[name] = {
85
+ key: item
86
+ for key, item in fields.items()
87
+ if key in model.model_fields
88
+ }
81
89
  return cls.model_validate(value)
82
90
 
83
91