11 KiB
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). 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 haszones_publish(also writtenzones.publishwhen 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_levelor higher, read from the database on every request. - HTTPS, or the server's own machine. The dashboard speaks plain HTTP: with
behind_proxy=1the proxy'sX-Forwarded-Proto: httpscounts; 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.