Skip to content

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.

GET /v1/api/locations
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/locations", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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.

GET /v1/api/locations/Thuzal
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/locations/Thuzal", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200 (trimmed):

{ "Name": "Thuzal", "Type": "Lokacja", "Status": "Aktywny", "Children": ["Gildia Teologów"] }

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.

GET /v1/api/locations/graph
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/locations/graph", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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.

GET /v1/api/claims/lint
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/claims/lint", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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:

GET /v1/api/claims/lint?fresh=true
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/claims/lint?fresh=true", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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.

GET /v1/api/maps/traversal
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/maps/traversal", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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).

POST /v1/api/locations/reindex?dryRun=true
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/locations/reindex?dryRun=true", {
  method: "POST",
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

Response 200:

{ "wouldReindex": true, "findingCount": 0, "findings": [], "stats": { "blocks": 26 } }

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.

POST /v1/api/locations?dryRun=true
Content-Type: application/json
Authorization: Bearer <token>

{ "name": "Karczma w Thuzal" }
await fetch("https://evocation.nerthus.pl/v1/api/locations?dryRun=true", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ name: "Karczma w Thuzal" }),
});

Response 200:

{ "wouldCreate": "Karczma w Thuzal", "type": "Lokacja" }

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.

POST /v1/api/locations
Content-Type: application/json
Authorization: Bearer <token>

{ "name": "Karczma w Thuzal" }
await fetch("https://evocation.nerthus.pl/v1/api/locations", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ name: "Karczma w Thuzal" }),
});

Response 201:

{ "created": "Karczma w Thuzal", "type": "Lokacja" }

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.

PATCH /v1/api/locations/Karczma%20w%20Thuzal
Content-Type: application/json
Authorization: Bearer <token>

{ "tag": "lokacja", "value": "Thuzal" }
await fetch("https://evocation.nerthus.pl/v1/api/locations/Karczma%20w%20Thuzal", {
  method: "PATCH",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ tag: "lokacja", value: "Thuzal" }),
});

Response 200:

{ "updated": "Karczma w Thuzal", "tag": "lokacja", "changed": true }

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.

POST /v1/api/maps?dryRun=true
Content-Type: application/json
Authorization: Bearer <token>

{ "name": "Karczma w Thuzal - parter" }
await fetch("https://evocation.nerthus.pl/v1/api/maps?dryRun=true", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ name: "Karczma w Thuzal - parter" }),
});

Response 200:

{ "wouldCreate": "Karczma w Thuzal - parter", "type": "Mapa" }

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 }.

POST /v1/api/maps
Content-Type: application/json
Authorization: Bearer <token>

{ "name": "Karczma w Thuzal - parter" }
await fetch("https://evocation.nerthus.pl/v1/api/maps", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ name: "Karczma w Thuzal - parter" }),
});

Response 201:

{ "created": "Karczma w Thuzal - parter", "type": "Mapa" }

Update a map tag

PATCH /v1/api/maps/{name} sets one schema tag on a Mapa — here the Margonem @margonemid.

PATCH /v1/api/maps/Karczma%20w%20Thuzal%20-%20parter
Content-Type: application/json
Authorization: Bearer <token>

{ "tag": "margonemid", "value": "5099" }
await fetch("https://evocation.nerthus.pl/v1/api/maps/Karczma%20w%20Thuzal%20-%20parter", {
  method: "PATCH",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ tag: "margonemid", value: "5099" }),
});

Response 200:

{ "updated": "Karczma w Thuzal - parter", "tag": "margonemid", "changed": true }

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.

POST /v1/api/maps/traversal
Content-Type: application/json
Authorization: Bearer <token>

{ "source": "Thuzal", "target": "Ithan" }
await fetch("https://evocation.nerthus.pl/v1/api/maps/traversal", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ source: "Thuzal", target: "Ithan" }),
});

Response 200:

{ "source": "Thuzal", "target": "Ithan", "linked": true }