Metadata-Version: 2.4
Name: shamash-core
Version: 2026.5.1
Summary: A Toolbox for creating a physically modelled world for worldbuilding purposes, based on Artifexian's Worldsmith.
License-Expression: MIT
Project-URL: Source, https://codeberg.org/schobernoise/shamash-core
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml
Requires-Dist: rich
Requires-Dist: naturedynamics>=1.3
Requires-Dist: shamash-orogen>=0.2
Provides-Extra: dev
Requires-Dist: black; extra == "dev"
Requires-Dist: pyproj; extra == "dev"
Dynamic: license-file

# Shamash

> This aims to be a physically accurate worldbuilder. A lot of these calculations are rather educated guesses than well-founded scientific principals, but they all have their foundation in recent scientific discoveries and laws of nature.

Based on the great [Artifexian](https://www.artifexian.com/)'s Work "The Worldsmith" I wanted to continue his journey. Intruiged by building on the very idea of quantifying reality - but at the same time horrified by looking at a spreadsheet - the only logical next step was to build a python application. :)

> [!important] AI Disclaimer
> Yeah, I know, this sucks. Until version 2026.0.3 I wrote everything myself, except some exporting functionality. The architecture, the Design Patterns, I came up with that myself. But with time being the only thing that does not accumulate over time (weird, right?) I simply couldn't stand another project wasting away over the years, and me not having the energy to finish it. Claude assists me heavily in writing the API and UI.

So, before the whole thing became a webfrontend with an API I designed it to be a CLI-Application. I like to work from the terminal, but after having hundreds of parameters scrolling past your tired eyes this was just not feasable any more. So I turned the CLI into an API (which still can be used as CLI as well) and created a React-Frontend.

Worldbuilding is an incredibly complex process. This application will never aim at calculating EVERYTHING, you know. I know some of you (including me) have those thoughts and it is only natural to desire a feeling of absolute control over reality. Understanding, caluclating, quantifiying everything until its smallest detail. But look into yourself. The motivation will most likely be fear, or some derivation of it. You are afraid to not be able to hold the world you are building in the palm of your hand.

But the very thing you are seeking to do - describing a reality by natural laws - is something we humans perfected since 300.000 years by immitating our natural surroundings (impressions), using them to express our feelings, pain and pleasure (expressions). This enables you to describe your newly formed reality in a way far more compley than any table of figures can. **Put your world into writing, not into a table, so other humans can comprehend what you are creating.**

## The shamash repositories

shamash is split by audience:

- **shamash-core** (this repo) — the physics engine and the `shamash` CLI. Published on PyPI.
- **[shamash-server](https://codeberg.org/schobernoise/shamash-server)** — the HTTP API around the engine, dockerized.
- **[shamash-web](https://codeberg.org/schobernoise/shamash-web)** — the React frontend, dockerized, talking to a shamash-server.

## Getting started

```bash
pip install shamash-core
shamash -p
```

The distribution is `shamash-core`; the command and the import name are both `shamash`.

For development, a Makefile is provided:

```bash
make venv
uv pip install --python venv/bin/python -e ../naturedynamics   # the physics backend, as a sibling checkout
make install
make run
make test
```

Without `-w`, shamash reads the seed world packaged at `src/shamash/data/default.yml`, our own solar system. `make run` runs `shamash -p`, which prints it with everything computed.

### The `shamash` command

```
shamash [-w WORLD] [-p | -e PATH] [-f yaml|json|csv] [-l]
shamash new PATH [-y]
shamash create TYPE [PARENT] [--set KEY=VALUE]... [--answer NAME=VALUE]... [--id ID] [-y] -w WORLD
shamash crs BODY [--projection NAME] [--central-meridian DEG] [-w WORLD]
shamash map init BODY DIR [-w WORLD] [--width PX] [--moon-width PX] [--major DEG] [--minor DEG]
shamash map ACTION [DIR]
```

shamash computes a world from a world file (stars, planets, moons, orbits, atmospheres) and builds things from it: a map project per planet, with Inkscape templates to paint, a QGIS project, a Blender scene, and from drawn plate tectonics a terrain and a climate. Without `-w` it uses the seed world: Sol, Earth and the Moon. A bare `shamash` prints this usage.

**Options** (work before or after a command)

| | |
|---|---|
| `-w, --worldfile PATH` | the world file to read; with `create`, also the one it changes. Alone, it just loads and checks the world |
| `-p, --print` | print the computed world to stdout |
| `-e, --export PATH` | write it to PATH instead. `-p` and `-e` write the same text, which loads back with `-w` |
| `-f, --format yaml\|json\|csv` | notation for `-p`/`-e`. csv is one row per value, for spreadsheets, and does not load back |
| `-l, --log` | also write the log (always on stderr) to `settings.log_file` |

**Commands**

| | |
|---|---|
| `new PATH` | copy the seed world to PATH, to edit into your own. Asks before overwriting unless `-y` |
| `create TYPE [PARENT]` | add a Star, Planet, Moon, Orbit, Atmosphere, Biosphere or Magnetosphere to the world in `-w` (or write the result to `-e`). Asks for required fields; `--set mass=1.0:MSol` gives one (values are SI unless a unit follows the colon), `--answer strategy=add` settles a choice. With no terminal and something left to ask, it fails and names what is missing |
| `crs BODY` | print the body's coordinate reference system (WKT2, a sphere of its radius) for QGIS, GDAL or PROJ. Projections: geographic, equirectangular, north-polar, south-polar, sinusoidal, mollweide, transverse-mercator. PROJ takes any sphere of 6,330–6,423 km for Earth |
| `map init BODY DIR` | start a map project in DIR for BODY and its moons: one equirectangular Inkscape template per body (a sample Earth and Moon from public data with the seed world), `map.yml`, `export.sh` and `setup.sh` |
| `map ACTION [DIR]` | one step on an existing map project, DIR or the current directory; see below. The project's `map.yml` names its world, so `-w` is refused |

**Map actions**, in the order a project uses them:

| Action | Reads | Writes |
|---|---|---|
| `refresh` | the world, map.yml | shamash's locked layers in every `map.svg` (grid, tropics, moon tracks, tectonics guide), missing channel layers, `export.sh`, `setup.sh`; `map.svg.bak` first. Run after changing the world or updating shamash |
| `georeference` | the PNGs `export.sh` wrote | `.pgw` + `.aux.xml` beside each, so GDAL and QGIS place them on the body's sphere; the height map in metres |
| `qgis` | | `qgis/project.json` + `build_project.py`; run that with QGIS's Python for one `.qgz` per body |
| `blender` | | `blender/scene.json` + `build_scene.py`; run that with `blender -b --python-exit-code 1 -P` for `scene.blend` |
| `tectonics` | `<body>/tectonics/phase-N/plates.gpkg`, drawn in QGIS | the latest phase reconstructed every `tectonics.step` Myr |
| `next-phase` | the latest phase's end | `phase-N+1/seed/`, which QGIS turns into the next drawing |
| `terrain` | the latest reconstructed phase | `<body>/terrain/dem.png`, linked under the height channel |
| `climate` | `dem.png`, the world | `<body>/climate/`: temperatures, precipitation, Köppen-Geiger classes, winds, ocean currents |

`setup.sh` runs them all, plus `export.sh` and the QGIS and Blender scripts. [TUTORIAL.md](TUTORIAL.md) builds a planet step by step.

**Files** in a map project

```
map.yml               the world, the bodies, their template sizes, elevation ranges and settings
export.sh, setup.sh   written by shamash; export.sh needs Inkscape
<body>/map.svg        the template you paint; map.png and one PNG per channel beside it after export
<body>/tectonics/     phase-1, phase-2, ...: plates.gpkg, reconstructed/, next/
<body>/terrain/       dem.png
<body>/climate/       *.asc grids, koppen.json, winds_*.geojson, currents.geojson, planet.json
qgis/, blender/       the data and scripts for QGIS and Blender
```

**Environment**: `QGIS_PYTHON` and `BLENDER` tell `setup.sh` where QGIS's Python and Blender are (default `python3`, `blender`).

**Exit status**: 0 on success, 1 on any error, with the reason on stderr.

**Examples**

```bash
shamash -p                                    # the seed world, computed
shamash -f csv -p | awk -F, '$3 == "habitability"'
shamash new my-world.yml
shamash create Planet sol -w my-world.yml --answer strategy=add
shamash crs earth --projection north-polar > earth-north.wkt
shamash map init earth earth-map -w my-world.yml
cd earth-map && ./setup.sh
```

### As a library

Everything the CLI does is reachable from Python. A world loads into a registry, every value on it is computed on access, and `Worldloader` turns it back into text:

```python
import shamash as sh

with sh.god.scope():
    loader = sh.services.Worldloader()
    loader.load(sh.services.BaseService.default_world_path())
    print(sh.god.summons("earth").habitability)
    print(loader.dumps(fmt="json"))
```

`god.scope()` gives the world a registry of its own, so two worlds can be computed at once without seeing each other — that is what shamash-server does per request.

`sh.utils.describe_entities()` is the per-type schema — every field and computed property with its unit, whether it is required, and its description — derived from the models rather than written down anywhere.

### Property documentation

Every field and computed property carries a `description`, and each has a long-form page with formulas and sources under `src/shamash/docs/properties/<Type>/<name>.md`. The pages ship inside the package, so a server always hands out the pages for exactly the engine version it runs; shamash-web renders them. `make test` fails if a description has no page or a page documents nothing.

## Releases

Development happens on `origin` (`dev.fs.or.at`). A release is an annotated
tag on `main`, and `codeberg` only ever sees tagged states:

```bash
git push origin main                          # every day
git tag -a v2026.1.1 -m "CLI verbs and output formats"
git push origin --tags                        # keep the markers on dev too
git push codeberg main v2026.1.1              # publish
```

Tags go to **both** remotes; commits go to `origin` until they are released.
Then the same commit goes to PyPI:

```bash
make publish-test   # TestPyPI, with $PYPI_TEST_TOKEN; check it installs
make publish        # PyPI, with $PYPI_TOKEN
```
That way the release history survives on the remote that has everything,
rather than only on the one it is published to.

So `git tag` is the list of releases, each heading below names one, and the
version in `pyproject.toml` is bumped in the commit the tag points at.
`__version__.py` reads it from there — see the note in `CLAUDE.md` about why
the checkout's `pyproject.toml` wins over `importlib.metadata`.

### 2026.5.1

- [x] The tectonics, terrain and climate code carries no comments and no
  docstrings, like the rest of shamash-core since 2026-10-09; behaviour is
  that of 2026.5.0 (`shamash -p` byte-identical in every format)

### 2026.5.0

Tectonics, terrain and climate, and the 2026.4.1 work that was never tagged on its own.

- [x] `shamash map tectonics`: plates, motion arrows and continents drawn in
  QGIS (`<id>/tectonics/plates.gpkg`, created by `build_project.py`) become
  Euler rotations (and a GPlates rotation file); shamash-orogen reconstructs
  them every `step` Myr
- [x] The reconstruction comes back as a QGIS layer group and a locked
  `shamash: tectonics` guide layer in each Inkscape template
- [x] One command: `map tectonics` runs the reconstruction in-process;
  neither GPlates, pygplates nor a second Python is needed (pygplates did
  the reconstruction until 2026-10-16)
- [x] Chainable phases: `map next-phase` starts `phase-N+1` from the latest
  phase's present (overlaps resolved, continents merged)
- [x] Valid lon/lat output for plates crossing the antimeridian or covering
  a pole; plate areas are conserved to 0.002% over ea's 200 Myr, where
  pygplates lost up to 0.1% at the antimeridian
- [x] Requires naturedynamics 1.3 (`geophysics/plate_kinematics.py`,
  `astrophysics/insolation.py`) and shamash-orogen 0.2.0, both on PyPI
- [x] Heuristic DEM from plate interactions (convergent/divergent/transform
  boundaries, age-depth seafloor): `shamash map terrain`, through
  shamash-orogen, 0.1 deg by default, into QGIS and the height channel
- [x] Commands renamed to say what they do: `shamash new PATH` replaces
  `-i`, `map refresh` replaces `map grid` and `map layers`, `map georef` is
  `map georeference`; map actions default to the current directory, and a
  bare `shamash` prints its usage
- [x] Climate from that DEM: `shamash map climate`, shamash-orogen's seasonal
  energy balance model with Hadley cells scaled by rotation and heuristic
  rain, Köppen-Geiger classes into QGIS; seconds per body, any OS. On Earth
  it gets 65% of land cells' main class right against Beck et al. 2018
  (ExoPlaSim was tried first and needs gfortran, Linux, and models no CH4)
- [x] Climate: wind-driven ocean currents (Stommel gyres, western boundary
  currents, coastal upwelling) carry the sea's heat; London's class is right now

- [x] The seed world's map project starts from a sample Earth and Moon (NASA,
  NOAA, Natural Earth), linked into the templates and credited in
  `ATTRIBUTION.md`; `scripts/build_sample_data.sh` rebuilds it
- [x] `setup.sh` beside `map.yml` regenerates every derived file in one go, through
  the same shamash that wrote it
- [x] A planet's template shows each moon's ground track, the path of the
  point directly beneath it over the Blender loop, on a locked layer
- [x] Every body gets a Blender camera that turns with it, placed per body in
  `map.yml` (`camera: {lat, lon, distance}`)
- [x] An observer scene per body: someone standing on it (`map.yml`
  `observer: {lat, lon, azimuth}`, eye height from `height_observer`, new
  and optional on `Moon`) watching one of its days, under a physical sky,
  sunlight reddened through the air and a pale daytime moon
- [x] Also from naturedynamics 1.3: `air_mass`, `DRY_AIR_COMPOSITION`, and
  `beer_lambert_transmission` fixed for air masses other than 1
- [x] `shamash map georef` warns about a `map.png` with nothing on it: an
  unpainted template, or a different copy of `map.svg` painted than the one
  `export.sh` reads
- [x] Oceans come from height: a body with `ocean: true` in `map.yml` (by
  default, one with an atmosphere) is sea in Blender wherever its height is
  at or below sea level, so unpainted ground is ocean and land is what you
  raise. The water channel now only adds lakes above sea level.

### 2026.4.0

Maps: everything from a paintable template to an animated Blender scene.

- [x] `services/terrestrial/` is now `services/cartography/`:
  `TerrestrialService` became `CartographyService`, `sh.services.terrestrial`
  became `sh.services.cartography`. No alias is kept; `crs` output is
  byte-identical. shamash-server needs the same rename when it moves its pin.
- [x] `sh.services.cartography` always names the module's exports. It used
  to come back as the bare subpackage once anything had touched
  `CartographyService` first (a latent bug since 2026.3.0, when it was
  `terrestrial`).
- [x] `Planet.axial_tilt` (optional), and the `tropic_latitude` and
  `polar_circle_latitude` it gives; `Orbit.raan` is an optional field (it was
  a `NotImplementedError` stub); `Atmosphere.scale_height` is computed
- [x] `shamash map init|grid`: a map project directory with an
  equirectangular Inkscape template per body, whose locked grid layer
  shamash owns and redraws while leaving every other layer alone
- [x] Channel layers (height, water, night, clouds), `shamash map layers`,
  and an emitted `export.sh` that exports the colour map without the grid
  and one PNG per channel through Inkscape; height in metres, 16-bit, sea
  level snapped to a grey step
- [x] `shamash map georef`: world file + `.aux.xml` for every exported PNG,
  sized from the PNG itself, height with Scale/Offset for GDAL/QGIS
- [x] `shamash map qgis`: the data and a PyQGIS script that build one QGIS
  project per body on its own CRS, registering every projection as a
  user CRS
- [x] `shamash map blender`: the planet system at its vernal equinox, true
  scale, physically lit and eye-adapted, with atmosphere, bump, ocean glint,
  night lights and clouds, animated over one orbit of the outermost moon in
  240 frames keyed from Kepler's equation
- [x] Requires naturedynamics 1.2 (`astrophysics/orbital.py`,
  `scale_height`, `srgb_to_linear`)

### 2026.3.1

- [x] `crs` argument mistakes are typed: an unknown projection, or a polar
  projection given a central meridian, raises `InvalidProjectionError`
  instead of a bare `ValueError`, so shamash-server can answer them with a
  422 rather than a 500. The CLI behaves as before.

### 2026.3.0

- [x] A terrestrial service and the `crs` verb
  - `services/terrestrial/` is the home for anything on a body's surface.
    First in: WKT2 coordinate reference systems for any entity with a
    radius — geographic, equirectangular, polar stereographic, sinusoidal,
    Mollweide and transverse Mercator — mirroring the IAU 2015 definitions
    PROJ ships.
  - A sphere for now: a rotational ellipsoid needs a flattening model, and
    the simple homogeneous one gets Earth wrong (1/232 against 1/298).
  - Tested against PROJ itself through pyproj (a dev-only dependency):
    every CRS parses, and known points land where the closed-form
    projection says they must.

### 2026.2.0

- [x] Split the project into repositories
  - This repo becomes **shamash-core**: the engine and the CLI, published
    on PyPI as `shamash-core`. The import name and the command stay
    `shamash`.
  - The HTTP layer moved to **shamash-server** and the frontend to
    **shamash-web**, both dockerized; the marimo notebooks to a private
    experiments repo. `shamash-api` is gone from this package.
  - The property docs moved *into* the package
    (`src/shamash/docs/properties/`), next to the descriptions they expand
    on, so a page and its description change in one commit. The coverage
    test moved with them and now checks both directions, the Settings
    pages and the sidebar — it used to check only that entity descriptions
    had a page, and skipped itself in any install without the frontend.
  - `describe_entities`/`describe_config` moved from `api/schemas.py` to
    `sh.utils`: they read only the models.
  - MIT licensed, in all three public repos.
  - Dependencies cut to what the engine imports: `pyyaml`, `rich`,
    `naturedynamics`. `numpy`/`scipy`/`matplotlib` arrive through
    `naturedynamics`, which declares them itself.

### 2026.1.2

- [x] Bring back a test suite — `make test`, stdlib `unittest`, ~1 s
  - It loads the real seed world and asserts what comes out, rather than
    mocking collaborators: the physics is a pure function of `default.yml`,
    so running it *is* the test. Most of it is a transcription of numbers
    `CLAUDE.md` already presented as regression baselines — Sol at 1.000
    RSol / 5770.8 K / G2.9, Earth habitability 1.000, `#B3F49A`,
    magnetopause 9.52 R⊕ — which described a suite nobody had written down.
  - 70 tests over the physics anchors, the loader's round-trips and error
    taxonomy, `god.scope()` isolation under 8 threads, the HTTP contracts
    (since moved to shamash-server's suite), the CLI, and the `create`
    verb's parent rules.
  - Mutation-probed while being written, which is the only reason to trust
    it: feeding `Star.radius` kilograms fails 6 tests, dropping
    `FACTOR_FLOOR` fails the hostile-world test, removing the
    `Planet.moons` backref fails the tidal one.
  - `make test-packaging` is separate (~15 s): it builds a wheel and runs
    the CLI and the API from it with no source tree in sight.
- [x] Two bugs the suite found before it was finished
  - `create Moon sol` ran the orbit cascade *before* discovering a Star
    cannot host a moon, so it created an orbit and then refused — leaving
    it behind in a world the caller was told nothing had been added to.
    Legality is now decided first, so a refusal costs nothing.
  - `create` asked its questions before validating `--set` names and the
    `--id`, so a typo was reported only after you had answered three
    prompts. Both are checked up front now.
- [x] Corrected numbers the docs stated but the code did not produce
  - `magnetosphere.py` documented Earth's magnetopause at 9.6 planetary
    radii and the bow shock at 12.5, in five places including user-facing
    text mirrored into the docs site. The code computes 9.52 and 12.37.

### 2026.1.1

- [x] Output formats for the CLI — `-f yaml|json|csv`
  - Notation moved out of both the codec and the HTTP layer into
    `services/world/formats.py`, so there is one YAML dumper call and one
    JSON dumper call in the codebase and the CLI and API cannot drift.
    `-p` and `-e` are byte-identical per format; `shamash -f json -p` is
    byte-identical to `GET /v1/world/default`.
  - csv is the flat view — one row per scalar, the same shape shamash-web's
    matrix tab shows — and is one-way.
  - `Worldloader.loads` now parses a root-level `{` as JSON. PyYAML follows
    YAML 1.1, where `5e-05` without a dot is a *string*, so the app could not
    read back a file it had just written with `-f json -e`.
- [x] CLI verbs — `shamash create <Type> <parent_id>`
  - `cli.py` grows subparsers and a `VERBS` map; flags work on either side of
    the verb. `-w` names the file the verb modifies.
  - What can host what is declared per relationship as `parent_needs` — what
    the child reads off its parent — never as a table of types. The refusal
    message is assembled from the models, so it follows them: the day a Moon
    can compute a `surface_temperature`, moon atmospheres start working with
    nothing to update.
  - Services never prompt. They call `self.ask(questions)`; the CLI injects
    `InteractionService.ask` and everything else gets `refuse_to_ask`, which
    raises a 422 carrying the questions — a service reading stdin would block
    a uvicorn worker forever, and `world:create-orbit` runs the same code.
  - `--set` and `--answer` make a create fully scriptable; with nothing left
    to ask it needs no terminal at all.

### 2026.1.0

- [x] Docker, and a React web frontend — both now live in their own repos,
  [shamash-server](https://codeberg.org/schobernoise/shamash-server) and
  [shamash-web](https://codeberg.org/schobernoise/shamash-web), together with
  their design notes.
- [x] Atmospheric `sky_color` — the colour of the sky from the ground
  - Single-scattered starlight through the Rayleigh column, von Kries adapted
    to the local star. Earth reads `#788CC4`; a 90 atm column washes out to
    near-white, which is what a saturated column should do.

### 2026.0.4

- [x] Stateless HTTP API (FastAPI), so a web frontend can do what `-w` does —
  now [shamash-server](https://codeberg.org/schobernoise/shamash-server). What
  it needed from the engine stayed here:
  - [x] Phase 0: make the package survive outside its source checkout
    - [x] Resolve version/name/description without reading `pyproject.toml` at import
    - [x] Ship `data/default.yml` as declared package data
    - [x] Stop the exporter crashing on optional fields a world leaves out
    - [x] Validate a world file's units against the dimension the field declares
    - [x] Log normal conditions at DEBUG instead of ERROR
  - [x] Request-scoped `god` registry (`contextvars`)
  - [x] `Worldloader.loads`/`load_data`/`to_dict` seams, JSON and YAML in/out
  - [x] Console entry point `shamash`

### v2026.0.3

- [x] Refactor and Introduce Atmospheric model
  - Maybe even in naturedynamics, taking atmospheric models from omniastro
- [x] Introduce Plant Color and Biosphere Module
  - Based on Star and Atmosphere
- [x] Geomagnetic Dynamics/ Solarmagnetic Dynamics
- [x] Make Habitability not true or false but a probability scale, taking into account:
  - Class of the Star and Brightness
  - Calculation Method relative habitability to how far the planet is away

### v2026.0.2

Settled on name for project: shamash.

- [x] Rename Project
- [x] Refactor export/import handling
  - do not use pprint, but format in cli as well
  - use flag for cli dump
- [x] Unify Units handling, refactor bad claude code
  - Every function should have their units as metadata with them - decorators?
  - Distances in space are AU/Parsecs/Lightyears
- [x] Fix main_star_apparent_size=<error: division by zero>
- [x] Improve logging
  - [x] Add a flag to write to a log file
- [x] Star Class Calculation
- [x] Add Star Classes as Enums, maybe directly into naturedynamics
- [x] Fix Hillsphere Calculation and Inner/Outer Moon Zone Error
- [x] Also push naturedynamics when pushing worldcreator
  - As of 2026-09-01 naturedynamics is no longer a submodule; it lives at
    `~/Dev/projects/naturedynamics` and is installed into the venv.
  - Release Versioning and Cycle ?
- [x] Add Horizon Distance
- [x] Add proper error handling
- [x] Add a flag to init a new world yaml file
  - `shamash init new_world`
- [x] Add Flag to point to yaml world file
- [x] Make color calculation refined and add Hexcolor support

### v2026.0.1

Finally, first working version finished, shamash.

### Backlog

- [ ] Relief and ocean floor carried from one tectonics phase to the next
- [ ] Climate: monsoons
- [ ] A Windows-native counterpart to `setup.sh`/`export.sh`
- [ ] Add Habitable Zone Shift Factor for planets, to shift habitable zone bonds,
  - regarding how many greenhouse gases exist
  - Axial Tilt
  - Orbital Period
- [ ] Make Apparent Brightness, etc a function that can be calculated at any given point of the orbit.
- [ ] Add LSol as Unit for Luminosity
- [ ] Change ASol to AU
- [ ] Decide what log level a _caller's_ mistake deserves
  - `AppError` logs at ERROR on construction. Over HTTP every 422 is the
    client's bad world, not a server incident, so a busy API fills the error
    log with things no operator can act on — but the same exception from the
    CLI, against a world file you just edited, is worth an ERROR. One class,
    two contexts; Phase 0 already established `log_level=` as the mechanism.
- [x] Containerise — done under 2026.1.0 above, once unblocked on
      2026-09-01 by the package no longer reading `pyproject.toml` at import,
      shipping its seed world as package data, and running (CLI and API both)
      from a wheel with no source tree.
- [x] `make venv` pins `--python 3.13` while the venv in use is 3.14.6
  - Pinned to 3.14, what everything is verified on. It also gained `--seed`:
    without pip in the venv, `make install` could not run on a fresh one.
    And its `uv pip install -r requirements.txt` now names `venv/bin/python`:
    uv looks for `.venv` or an activated env, so it failed unless `venv` was
    activated.
