Locations & maps¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
Locations (Lokacja) and maps (Mapa) are entities too, but they carry a geography the
plain entity routes ignore: a containment tree (@lokacja), authored passages (@drzwi),
exterior/interior links (@outerior), and the Margonem map catalogue. This page covers the
location tree, the connectivity graph, the map-traversal edges inferred from sessions and
logs, the claim-ledger lint, and creating or editing locations and maps. Names are
URL-encoded in the path; the reads use the fixture geography around Thuzal — its child
Gildia Teologów, plus Ithan, Nithal, Karka-han.
Reads need entity.read, writes need entity.write (a reindex needs location.override).
Every example runs against a live daemon — see how the reference is tested.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /locations |
Find-NerthusLocation |
entity.read |
— |
| GET | /locations/graph |
Get-NerthusLocationGraph |
entity.read |
— |
| GET | /claims/lint |
Test-NerthusClaims |
entity.read |
— |
| POST | /locations/reindex |
Invoke-NerthusReindex |
location.override |
✓ |
| POST | /locations |
New-NerthusLocationEntity |
entity.write |
✓ |
| PATCH | /locations/{name} |
Set-NerthusLocationEntity |
entity.write |
✓ |
| GET | /locations/{name} |
Get-NerthusLocation |
entity.read |
— |
| GET | /maps/traversal |
Get-NerthusMapTraversalGraph |
entity.read |
— |
| POST | /maps/traversal |
Set-NerthusTraversalEntities |
entity.write |
✓ |
| POST | /maps/dimensions |
— | location.override |
✓ |
| POST | /maps |
New-NerthusMapEntity |
entity.write |
✓ |
| PATCH | /maps/{name} |
Set-NerthusMapEntity |
entity.write |
✓ |
Cap is the required capability (— = public, no token); Write (✓) marks routes that pass the write gate; a — in Cmdlet is reached directly, no wrapper. Paths are relative to /v1/api; the cross-cutting contract — middleware, envelopes, status codes — is on the API reference index.
List locations¶
GET /v1/api/locations returns the { count, items } envelope of every Lokacja
projection. Filter with ?parent= (@lokacja), ?outerior=, or ?region= (@region
membership — see Regions). Here, the sixteen fixtures — the twelve
world-tree directories plus the four interiors the fixture sessions mint.
A location's name is its block heading, and a heading is qualified by its parent whenever
two directories in the world tree share a leaf name — Karczma (Torneg) and
Karczma (Werbin) are two places, not one. Address the qualified form: the bare Karczma
is ambiguous and write paths refuse it rather than guess.
Response 200 (trimmed):
{ "count": 16, "items": [
{ "Name": "Thuzal", "Type": "Lokacja", "Status": "Aktywny" },
{ "Name": "Gildia Teologów", "Type": "Lokacja", "Location": "Thuzal" },
{ "Name": "Karczma (Torneg)", "Type": "Lokacja", "Location": "Torneg" },
{ "Name": "Karczma (Werbin)", "Type": "Lokacja", "Location": "Werbin" }
] }
Fetch one location¶
GET /v1/api/locations/{name} resolves the name as a Lokacja and enriches the projection
with its child locations. An unknown name is 404.
Response 200 (trimmed):
The location graph¶
GET /v1/api/locations/graph returns the containment (@lokacja) plus door (@drzwi)
edges across every Lokacja, with the node and edge counts. The twelve fixtures give
twelve nodes.
Response 200 (trimmed):
{ "NodeCount": 16, "EdgeCount": 6, "Edges": [
{ "Source": "Thuzal", "Target": "Gildia Teologów", "Type": "Containment" },
{ "Source": "Torneg", "Target": "Karczma (Torneg)", "Type": "Containment" }
] }
Lint the claim ledger¶
GET /v1/api/claims/lint checks the @forma_sesyjna routing ledger against the live
session model — duplicate claims, claims on soft-deleted blocks, machine claims contradicting
a block of the same name, duplicate headings within one type, and operator claims matching no
observed session form. It returns { file, findingCount, findings, stats, convergeLint };
stats carries the ledger tallies (claims, blocks, splitGroups, operator, auto).
convergeLint carries the post-converge semantic lint beside the ledger findings, because
both answer what a hand edit broke. A plain read serves the recording the last converge left
behind: at, sha, stats, findings, and fresh: false. A host with no recording yet
answers at: null there, with an empty finding list.
?fresh=true runs the lint now and answers fresh: true with no sha, since the result
belongs to no converged commit. The run walks every committed index file and the model's ref
diagnostics, so a dashboard polling this route should leave fresh off. Neither form notifies
anybody — only the converge's own pass posts to Discord, and only on a transition
(Sync).
Each finding carries kind, severity, entity, and a Polish reason, plus tag, files
or values where the kind has one:
kind |
Fires on | severity |
|---|---|---|
zdublowany-nagłówek |
one ### heading of one type in two committed index files |
error |
podwójnie-aktywny-wiersz |
two dated rows of one scalar tag both covering today | error |
wiszący-ref |
an @ref target that is gone, or holds no block of that name |
error |
łańcuch-ref |
an @ref target that itself carries @ref |
error |
wielokrotny-ref |
more than one active @ref on one entity |
error |
odrzucona-ścieżka-ref |
an @ref path outside the files a reference may name |
error |
nieczytelny-ref |
an @ref target with neither a leading property list nor an index shape |
error |
rejestr-w-indeksie |
a Gracz block standing in both the index and nerthus.contributors.md |
error |
obca-zmiana-pliku-robota |
a converged change to a robot-owned file outside the settlement window | warning |
konflikt-ref |
a key stated where the replacement split does not read it | warning |
The ledger's own kinds reach convergeLint.findings too, always at error, because the
converge pass re-runs the ledger lint with the (auto) claims the ordinary pass exempts
(lint and apply). zdublowany-nagłówek
is the row above and is not reported a second time.
Response 200 (trimmed):
{ "findingCount": 0, "findings": [],
"stats": { "claims": 5, "blocks": 28, "splitGroups": 1, "operator": 0, "auto": 5 },
"convergeLint": { "at": null, "sha": null, "stats": null, "findings": [], "fresh": false } }
The fresh run reports its own tallies, errors, warnings and total:
Response 200 (trimmed):
{ "findingCount": 0, "findings": [],
"convergeLint": { "at": "2026-07-30T05:56:52.6013490Z", "fresh": true, "findings": [],
"stats": { "errors": 0, "warnings": 0, "total": 0 } } }
Measuring a map¶
POST /v1/api/maps/dimensions records each active Mapa's size as @wymiary, in tiles. The
size comes from the render: a PNG's IHDR sits at a fixed offset, so a Range: bytes=0-33
request answers the question without moving the image — 34 bytes out of a 2.2 MB file.
Two ways to choose the maps¶
| Body | Selects | Capability |
|---|---|---|
{ "names": ["Torneg", "Nithal"] } |
exactly these, at most 50 | location.override |
{ "maxMaps": 25, "cursor": 400 } |
a slice of the ordered catalogue | location.override and workflow.map_checkup |
The two are mutually exclusive — sending both is a 400 (MapDimensionsSelectionConflict), and a
fifty-first name is a 400 (MapDimensionsTooManyNames). A named request is not a slice of
anything, so its answer's cursor is always 0; the sliced form's cursor is the resume point,
0 once the list is done.
They also differ in one behaviour, deliberately: a named map is re-measured even if it already
carries @wymiary, and a swept one is skipped. The skip is what makes a catalogue pass
idempotent and resumable — a re-run over a measured catalogue costs nothing, because the skip is
checked before the politeness delay and the probe. Naming a map is asking about that map now.
A name the corpus does not hold as an active Mapa, or one whose @url host is off the
allow-list, lands in failed[] with NotAMapa / NoUrl / HostNotAllowed rather than failing
the request: a status code cannot say which of fifty names it was.
The catalogue-wide pass belongs on the fleet tick rather than in a browser — see
?dimensions= on POST /workflows/map-checkup, which is
where a sweep already has a cursor that survives restarts and a time budget.
Bounding a call¶
maxMaps bounds the number of probes; maxMillis bounds the clock, and it is the bound that
survives the failure that matters. The CDN's front tarpits over-limit probes into ten-second
timeouts, so a 25-map slice that normally takes seven seconds can take minutes — long enough for
an edge proxy to cut the request. maxMillis: 0 (the default) leaves the call unbounded, which is
what the CI driver relies on. A bounded call returns within maxMillis plus one probe.
?dryRun=true is not free here¶
It reports without writing, and it still pays every probe and every politeness delay, then
discards the measurements. A preview of a write that touches local files (POST /maps/layout) and
a preview of a write whose whole cost is outbound HTTP are not the same kind of preview: here a
dry run is a way to pay twice. Measure, or do not.
Watching a run¶
The sweep publishes mapdimensions:progress ({ done, total, detail }, detail being the map
being probed) on the event stream. The daemon serves on one thread, so a sweep holds
the accept loop for its whole run and no in-flight request can report on it — open /events
first, then start the run, or the subscription arrives after the work it wanted to watch.
What stays true, and for how long¶
A measured size describes one render, and the render is content-addressed by the version suffix in
its own filename — so @wymiary stays true until @url moves. @url moving is
POST /workflows/map-checkup's business, and its size stage re-measures exactly the maps whose
@url it just moved (?dimensions=both, the default). That stage is also what measures the maps
nothing has measured yet, on the fleet tick.
A render whose pixel size is not an exact multiple of 32 is refused, not rounded, and lands in
failed[] with NotTileMultiple:<W>x<H>. It does not spend the failure budget: the CDN answered,
and it is the daemon declining to describe the map. A transport failure does spend it, and twenty
consecutive ones stop the sweep with budgetExhausted: true.
Politeness is shared with map-checkup (integrations.margonem.map_checkup) — the same host
allow-list, delay and failure budget, because it is the same CDN and the same fleet-wide budget.
Map traversal edges¶
GET /v1/api/maps/traversal merges movement edges from session @Lokacje route sequences
and cached log transcripts. Each edge carries a Weight and an Origins set; a move
between two non-adjacent locations is typed Teleport — the cue for an undocumented
passage.
Response 200 (trimmed):
{ "EdgeCount": 1, "Edges": [
{ "Source": "Thuzal", "Target": "Rezydencja Tussal", "Type": "Containment", "Weight": 1, "Origins": ["session"] }
] }
Preview a reindex¶
POST /v1/api/locations/reindex re-runs the full import so edited @forma_sesyjna claims
re-route. With ?dryRun=true it writes nothing and returns { wouldReindex, findingCount,
findings, stats } — the claim lint, so a conflict never takes effect silently. An applied
run returns { reindexed, findingCount, findings, stats, speakerProposals }, where
speakerProposals counts the review-flagged NPC blocks the transcripts support
(the character harvest).
Response 200:
Preview a location create¶
POST /v1/api/locations forces type = Lokacja, so the body needs only a name.
?dryRun=true returns 200 { wouldCreate, type } and touches no disk — proven by the
follow-up 404. The scratch location is a tavern in Thuzal, drawn from the session's world.
Response 200:
Create a location¶
POST /v1/api/locations for real writes the Lokacja block and returns 201 { created,
type }. The tavern in Thuzal is scene-derived.
Response 201:
Update a location tag¶
PATCH /v1/api/locations/{name} sets one tag from the closed schema. Here the tavern gets
its @lokacja parent — placing it under Thuzal in the containment tree. An unknown tag is
422.
Response 200:
Preview a map create¶
POST /v1/api/maps forces type = Mapa and accepts the map metadata keys (margonemid,
url, wymiary, koordynaty, slug, …). ?dryRun=true returns 200 { wouldCreate, type
} and writes nothing — proven by the follow-up 404. The scratch map is the ground-floor
map of that Thuzal tavern, scene-derived.
Response 200:
Create a map¶
POST /v1/api/maps for real writes the Mapa block (seeded @status: Aktywny plus any
metadata keys supplied) and returns 201 { created, type }.
Response 201:
Update a map tag¶
PATCH /v1/api/maps/{name} sets one schema tag on a Mapa — here the Margonem @margonemid.
Response 200:
Promote an inferred passage¶
POST /v1/api/maps/traversal turns an inferred movement into an authored @drzwi passage
on the source location, returning { source, target, linked }. Here Thuzal gains a door to
Ithan. Both must be existing locations; ?dryRun=true returns { wouldLink } instead.
Response 200: