---
id: building-the-game/004
title: Publish: the editors on the site, the database canonical, snapshots always written, a staging stack
epic: building-the-game
state: review
priority: 2
blocked_by: ["building-the-game/003"]
estimate: L
created: 2026-09-03
updated: 2026-09-03
claimed_by: null
claimed_at: null
delivers: []
review_artifact: builder/README.md
---

## What to do

- **Staging stack** on the droplet: `lor_game_staging` (restored from a live dump),
  `lor-director-staging.service` (its own ports, `WORLD_SERVER_DIR` shared), Caddy
  `staging.spellgrove.com/director/`; the game client's hidden dev option (title screen: tap
  the version line seven times → a server picker, remembered) to target it.
- **Builder writes** `PUT /builder/authoring/world | areas/<slug> | areas/<slug>/ground |
  elements`, validated with the game's own Zod schemas (`game/director/src/worldContent.ts`),
  `ground_rules.mjs`, and a `checkGround` lifted from `tools/hub-ui/server.mjs`.
- **Publish** `{files: always, staging, live}`: write the snapshot files in the content
  checkout (`spellgrove.world.json`, `areas/<slug>.ground.json`, `elements.js` +
  `sync-elements.sh`), commit, push over the deploy key; then `loadWorldContent` against the
  staging pool and/or the live pool. A `publish_log` row per publish. **Elements are the honest
  special case**: they are compiled into the game, so "Live" for an element edit means a
  build, and the dialog says so.
- **DB→file exporter** `npm run export-world` (the inverse of `loadWorldContent`); round-trip
  equality with the committed snapshot is its acceptance test.
- **Editors on the site**: `tools/hub-ui/` → `tools/hub-ui/` as ES modules with a backend
  adapter (the local `server.mjs`, or the Builder + bearer); the site copies the folder in at
  build. The publish dialog: two tick boxes, Staging and Live, both on by default, remembered
  in `lor:publish:<editor>`, and plain words about what goes where. `server.mjs` stays as the
  offline shim of the same routes.

## Definition of done

Write → export → equals the committed file; a Staging-only publish changes staging and not
live; `verify-world-map` and `verify-home-door` 10/10 on live after a live publish; a Godot
client lands in a re-saved Village; the snapshot commit appears on GitHub.

## Built (2026-09-03)

- **Staging stack** live: `lor_game_staging` (a copy of live), `lor-director-staging.service`
  on 8091/8092, `staging.spellgrove.com/director/`; the client's hidden picker (seven taps on
  the title footer → Live / Staging / this build's default, remembered per device) shipped in
  the web build on `/play` and APK **v22**; `/play?director=` rides through to the game.
- **Exporter** `npm run export-world` in `game/director` (DB → world file + ground files,
  `--check` for drift; round-trip tests, ground files byte-identical).
- **The Builder's writes**: `GET/PUT /builder/authoring/world`, `…/areas/<slug>/ground`,
  `…/elements`, `GET /authoring/publishes`; every publish writes the snapshot files in
  `/opt/lor/content`, commits as `Builder <builder@spellgrove.com>` naming the signed-in
  account, pushes over the deploy key, then loads into each ticked database; serialised;
  14 tests. **Proven live** with a throwaway admin: a world and a ground publish to Staging
  only landed as commits `036cb67` and `097ce02` on GitHub (the one-time re-sort of the world
  file by slug, and a comment line), applied to staging and not live; a bad ground → 422.
- **Editors on the site**: `tools/hub-ui/` (one codebase, two backends — the local
  `server.mjs`, or the Builder with the site's sign-in) copied to `spellgrove.com/hub/` at
  build; the publish dialog with the two remembered ticks; the card editor's Publish button
  (files + sync + push; "Live" for elements means a build, and it says so). Admin-only
  "Edit the world →", "Paint the ground →", "Edit the element cards →" links on the Building pages.

Not yet done by a human: Kris publishing something himself through the site's editors.