mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
162 lines
11 KiB
Markdown
162 lines
11 KiB
Markdown
# Edited zone files
|
|
|
|
Taking a zone's edited files from an external level editor, keeping them apart from the client's files, and loading
|
|
them by replacing the zone's instances ([WorldHotReload.md](WorldHotReload.md)). The server's client files are never
|
|
written.
|
|
|
|
Code: `dCommon/ZonePublish` (checking a set), `dCommon/ZoneOverrideStore` (the override folder: apply, backups,
|
|
restore, clear), `dCommon/ZoneEditLock.h` (who may: lock and reload rules), `dCommon/dClient/AssetManager` (reading
|
|
overrides), `dDatabase/.../IZoneEditing.h` (locks and editors), `dWeb/MultipartFile` and `Web::RegisterUploadRoute`
|
|
(large uploads), `dDashboardServer/auth/ZonePublishAuth.h` (the API's gate),
|
|
`dDashboardServer/routes/ZonePublishRoutes` (the API), `dCommon/ZoneFilePack` and `dGame/dUtilities/ZoneEditor` (the
|
|
game client's level editor, on a world server). The first four know nothing of how files arrive: the API and the
|
|
world server use the same checks, store, locks and reload.
|
|
|
|
## Override folder
|
|
|
|
`zone_override_dir` (sharedconfig.ini; empty: off; relative to the servers' folder unless absolute).
|
|
|
|
| Path | Holds |
|
|
| --- | --- |
|
|
| `maps/<zone folder>/...` | the files in use |
|
|
| `maps/<zone folder>/.zone-<id>.json` | which files are zone `<id>`'s set, who sent it, when, its note |
|
|
| `staging/<id>/` | a set while it is checked and built |
|
|
| `backups/<zone id>/<unix time>-<id>/` | the sets a zone had before (`files/`, `manifest.json`) |
|
|
|
|
For a name under `maps/` the asset manager reads `<zone_override_dir>/maps/...` when the file is there, else the
|
|
client's. Names are lowercase with forward slashes. Master watches the same file a world would read: an override that
|
|
appears, changes or goes away makes the instances that read another version stale.
|
|
|
|
A zone's set is replaced whole. The folder's new contents are built in `staging`, flushed to disk and swapped in with
|
|
one rename (an exchange where the system has one). The set replaced is copied to `backups` first; the newest
|
|
`zone_publish_max_backups` are kept. Restore and clear are the same swap. Files of another zone in the same folder
|
|
stay.
|
|
|
|
## Who may
|
|
|
|
- An API key (`Authorization: Bearer`) whose own permission list has `zones_publish` (also written `zones.publish`
|
|
when a key is made). A key with all of its owner's permissions does not count. Keys are stored as a SHA-256 hash.
|
|
- The key's owner at GM level `zone_publish_gm_level` or higher, read from the database on every request.
|
|
- HTTPS, or the server's own machine. The dashboard speaks plain HTTP: with `behind_proxy=1` the proxy's
|
|
`X-Forwarded-Proto: https` counts; without it only loopback does. Anything else is refused and audited.
|
|
- Ten refused requests from one address in ten minutes refuse the address for the rest of that time, on top of the
|
|
key's own rate limit.
|
|
- Every use and every refusal is in the audit log (action `zone_publish`: key ID, zone, action, result; the account is
|
|
the entry's actor). Reads are written at most once a minute per key, zone and action.
|
|
|
|
Locks: a zone has up to 10 assigned editors and one lock holder. Taking the lock needs an assigned editor, or GM level
|
|
`zone_lock_admin_gm_level`. Releasing: the holder, or an admin. Sending, restoring and clearing need the caller to
|
|
hold the lock. Locks do not expire. Results where a number is given: 0 ok, 1 level too low, 2 not assigned, 3 locked
|
|
by another account, 4 no change.
|
|
|
|
## Checks
|
|
|
|
All files are checked before any is written; one failure refuses the set.
|
|
|
|
| Code | Meaning |
|
|
| --- | --- |
|
|
| `name_not_allowed` | Not a path in the zone's folder the `.luz` lists: `..`, absolute, backslash, NUL, non-ASCII, another zone's folder, an unlisted file |
|
|
| `duplicate` | The same path twice (compared in lowercase) |
|
|
| `missing_luz` | The set lacks the zone's `.luz` (its path comes from the ZoneTable) |
|
|
| `too_large` | A file over `zone_publish_max_file_mb`, the set over `zone_publish_max_total_mb` or `zone_publish_max_files` |
|
|
| `parse_failed` | The server's own reader does not read the file to its end (`offset`: where it stopped) |
|
|
| `unsupported_version` | `.luz` outside 30 to 41, `.lvl` over 48 |
|
|
| `scene_mismatch` | The `.luz` lists a scene the zone does not have in the client files, or one that is in neither the set nor the client |
|
|
| `terrain_mismatch` | The `.raw` sent is not the one the `.luz` names, or the one named is nowhere |
|
|
| `trigger_missing_target` | A trigger command without a `target` |
|
|
| `unknown_trigger_event`, `unknown_trigger_command` | An ID the server does not know |
|
|
| `bad_object_config` | An object setting that does not parse |
|
|
| `too_many_objects` | Over `zone_publish_max_objects` objects in the set's scenes |
|
|
|
|
Allowed names: the `.luz`, each listed scene's `.lvl` and `.lutriggers`, the `.raw` the `.luz` names. A set cannot add
|
|
scenes. Before a file is written its path is resolved (`std::filesystem::weakly_canonical`) and must be inside the
|
|
override folder.
|
|
|
|
## API
|
|
|
|
Base `/api/v1`. Errors are `{"error", "message"}`. `zoneId` is the ZoneTable ID.
|
|
|
|
| Route | Does |
|
|
| --- | --- |
|
|
| `POST /zones/{zoneId}/publish` | multipart/form-data: one file part per file (filename: its path relative to `res/maps/`), fields `reload` (default true), `dryRun`, `note`, `ifPlayersPresent` (`hold` default, `move`) |
|
|
| `GET /zones/{zoneId}/publish/{publishId}` | `{publishId, zoneId, action, state, players, reload: {instances, playersMoved, errors}, finishedAt}`; states `validating`, `applying`, `held`, `reloading`, `done`, `failed` |
|
|
| `POST /zones/{zoneId}/publish/{publishId}/reload` | `{"ifPlayersPresent": "move"}` goes on with a held reload |
|
|
| `GET /zones/{zoneId}/overrides` | `{current: {files: [{path, bytes, sha256, publishedBy, publishedAt}]} or null, backups: [{backupId, createdAt, createdBy, note}]}` |
|
|
| `POST /zones/{zoneId}/overrides/restore` | `{backupId, reload, ifPlayersPresent}`; answers `{publishId, zoneId}` at once |
|
|
| `DELETE /zones/{zoneId}/overrides` | optional `{reload, ifPlayersPresent}`; answers `{publishId, zoneId}` at once |
|
|
| `GET /zones/{zoneId}/lock` | `{holder, since, holder_is_you, editors}` |
|
|
| `POST /zones/{zoneId}/lock` | take (200 again for the holder, `holder_is_you: true`); 409 `{error: zone_locked, holder, since, holder_is_you: false}` |
|
|
| `DELETE /zones/{zoneId}/lock` | release (holder, or an admin) |
|
|
|
|
A publish answers `{publishId, zoneId, backupId, files: [{path, bytes, sha256, version}], reload: {state}}` with
|
|
`state` `started`, `held` or `skipped`. A dry run answers `{zoneId, dryRun: true, files, reload}` and keeps nothing.
|
|
Restore and clear answer with their `publishId` and are followed with the status route like a publish. A `failed`
|
|
status has `error` and `message` beside its state; reload problems are also in `reload.errors`. Every file part's form
|
|
field name is `file`. Names are compared without regard to case and kept lowercase, as the servers read them. Times
|
|
are unix seconds.
|
|
|
|
| Status | When |
|
|
| --- | --- |
|
|
| 400 | `malformed` |
|
|
| 401 | `unauthorized`, `api_key_required` |
|
|
| 403 | `https_required`, `scope_required`, `read_only_key`, `gm_level`, `lock_required`, `not_an_editor` |
|
|
| 404 | `unknown_zone`, `unknown_publish`, `unknown_backup` |
|
|
| 409 | `publish_in_progress`, `zone_locked`, `not_held` |
|
|
| 411 | `length_required` (no Content-Length) |
|
|
| 413 | `too_large` |
|
|
| 422 | `validation_failed` with `files: [{path, ok: false, errors: [{code, message, offset?}]}]` |
|
|
| 429 | `rate_limited`, or the key's own limit |
|
|
| 503 | `disabled` (no `zone_override_dir`), `master_unavailable` |
|
|
|
|
The publish request's headers are checked (key, level, transport, zone, lock, size) before any of its body is taken.
|
|
The body is written to `staging` as it arrives, not held in memory.
|
|
|
|
## Reload
|
|
|
|
Only the zone's instances, through master's world reload. With players in the zone and `ifPlayersPresent=hold` the
|
|
state is `held` until `.../reload` says to move them, or nobody is left when it is asked again with `hold`. From
|
|
before the files change until a reload of the zone is asked for, the dashboard tells master to leave the zone's
|
|
instances alone (`WORLD_RELOAD_HOLD`), so the file watch does not reload a held zone by itself. `reload=false` leaves
|
|
the instances as they are; the files are read when an instance next starts, or on a later reload.
|
|
|
|
The dashboard's Worlds page lists each zone's editors and lock holder (permission `zone_editors_manage` and GM level
|
|
`zone_lock_admin_gm_level`): add and remove editors, take a lock away.
|
|
|
|
## The game client's level editor
|
|
|
|
A world server answers the client's own level editor packets with the same backend. World packets, IDs as the
|
|
1.10.64 client has them:
|
|
|
|
| From the client | Holds | Answer |
|
|
| --- | --- | --- |
|
|
| 8 editor mode | u8 on | none; the server remembers it per connection |
|
|
| 9 reload map | nothing | 15, then the zone's reload |
|
|
| 10 push request | u8 fileType, i32 levelFileId | 18: u8 result, u8 fileType, i32 levelFileId, UTF-16 lockedBy with its null |
|
|
| 11 push | u8 fileType, i32 levelFileId, packed files | none; when the set is applied 15 to every client of the instance, then the zone's reload |
|
|
| 12 pull | u8 fileType, i32 levelFileId | 19: u8 fileType, i32 levelFileId, packed files |
|
|
| 13 lock | u8 lock (1 take, 0 give up) | 20: u8 result, 33 UTF-16 characters lockedBy, u8 locked (the client holds it now) |
|
|
|
|
fileType: 0 the whole map, 1 a scene, 2 the terrain, 3 the zone file, 4 a scene's triggers. levelFileId is the
|
|
scene's ID in the zone file for 1 and 4 when pulling; a push is matched by its file's name. Results: 0 ok, 1 the
|
|
account's editor level is too low, 2 not one of the zone's editors, 3 locked by another account (lockedBy says who; a
|
|
push without any lock held is 3 with a hint to lock first), 4 no change (lock only).
|
|
|
|
Packed files (`ZoneFilePack`): an entry is u32 nameBytes (UTF-16LE with its null), u32 dataBytes, the name, the data.
|
|
A whole map is the zone entry, the terrain entry, u16 N and N scene entries, u16 M and M trigger entries; any other
|
|
type is one entry. Data is always sd0, each chunk inflating to at most 256 KiB; anything else is refused. Only a
|
|
name's base name is used, lowercased into the zone's folder and checked like any other name. There is no checksum.
|
|
|
|
Gates, looked up on the server for every packet: the account's editor level (its GM level when at
|
|
`editor_gm_level`, else 0) at `zone_publish_gm_level` or above; one of the zone's editors or an admin to lock, pull or
|
|
reload; the lock's holder to push. A push is only taken after a push request for the same file type and ID was
|
|
answered with 0. A single file replaces that file in the zone's set and keeps the rest; a whole map replaces the set.
|
|
A push that is refused changes nothing and tells the sender why in chat. Every request is in the audit log (action
|
|
`zone_publish`, "in game").
|
|
|
|
While a client's editor is on it is never sent a zone load. The level load complete it sends when its editor ends is
|
|
answered as any other: its player is here, so the world is sent to it again. The reload after a push moves normal
|
|
clients to the new instance as any world reload does; a client with its editor on loses its connection there.
|
|
|
|
A character's editor level is told to the client in the character's save (`edit`), with the character (the editor
|
|
flag and level after the GM level) and in answer to the GetEditorLevel game message.
|