Skip to content

Players & characters

Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.

A Gracz (player) is a person authored in the ## Osoby section of nerthus.contributors.md; the Postać (character) blocks they own live in nerthus.entities.md. This page covers reading a player and their roster, the roster's read-only wire surface, creating and retiring characters, and the dated per-character info entries. The examples use the canonical session: the Gracz Stefan owns the character Eraster; Roman owns Lord Tussal.

The roster is hand-authored

Every write route under /people answers 422 RosterStoreProperty and names nerthus.contributors.md as the file to edit. A person's block is an editorial statement, so the daemon does not compose one on a request path — the roster is changed by editing the store and committing (the roster model). Reads are unchanged, and every /characters route is unaffected.

Reads need player.read, writes need player.write. Names are URL-encoded in the path (ł%C5%82, space → %20). Every example runs against a live daemon — see how the reference is tested. Anward is the session's Narrator — he holds @rola: narratorzy and not @rola: gracz, so he is in the store and is not a player: /people/Anward resolves to nothing at all. The character-create cases use a scene-derived courier (Posłaniec Stefana), so they never collide with fixture data.

Routes

Method Path Cmdlet Cap Write
GET /people Find-NerthusPlayer player.read
GET /people/channels player.read
GET /people/{name} Get-NerthusPlayer player.read
GET /people/{name}/characters Get-NerthusPlayerCharacter player.read
GET /players/resolve player.read
GET /people/{name}/discord-channel Get-NerthusPlayerDiscordChannel player.read
PUT /people/{name}/discord-channel Set-NerthusPlayerDiscordChannel player.write ✓ → 422
POST /people New-NerthusPlayer player.write ✓ → 422
PATCH /people/{name} Set-NerthusPlayer player.write ✓ → 422
DELETE /people/{name} Remove-NerthusPlayer player.write ✓ → 422
GET /characters/{name} Get-NerthusCharacter player.read
POST /characters New-NerthusPlayerCharacter player.write
PATCH /characters/{name} Set-NerthusPlayerCharacter player.write
DELETE /characters/{name} Remove-NerthusPlayerCharacter player.write
GET /characters/{name}/info Get-NerthusCharacterInfo player.read
POST /characters/{name}/info Add-NerthusCharacterInfo player.write
PUT /characters/{name}/info Set-NerthusCharacterInfo player.write
DELETE /characters/{name}/info Remove-NerthusCharacterInfo player.write
GET /contributors/integrity Test-NerthusContributorIntegrity player.read

GET /contributors/integrity is the store's own validation surface — sibling to GET /entities/integrity — reading the same nerthus.contributors.md the roster projects from; it sits here because the store is the roster.

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.

  • The /people and /characters routes are the Gracz/Postać surface (player.write), distinct from generic /entities + entity.write. The gracz role holds player.write.own, scoped to the caller's own Gracz and Postacie — it still gates every Postać route, but no longer lets a player set their own Discord channel (capabilities).
  • The four /people write rows keep their Cap and their Write flag and answer 422 RosterStoreProperty. Keeping them is deliberate: the write gate still runs first, so a read-only host answers its 503 before the 422 — a property of the host outranks a property of the resource — and ?dryRun=true returns the identical refusal, so preview and apply agree. The same 422 answers PATCH/DELETE /entities/{name} and POST /entities with type: Gracz, so the generic path is closed too.
  • Refusal body: { "error": "RosterStoreProperty", "entity", "tag", "file": "nerthus.contributors.md", "detail" } — the same shape as RefTargetProperty on a referenced property.
  • The own-scope 403 is checked before the 422, so an own-scoped caller poking at a foreign player learns nothing about whether that player exists.
  • The /characters/{name}/info routes manage the charfile's **Dodatkowe informacje:** entries; PUT/DELETE require match to equal the entry's current text, so a stale match is refused 409.

Who can be reached, and who cannot

GET /v1/api/people/channels is the notification-channel census. Three states over the people the contributor store holds — set (a parsed @discord ref), declined (@discord: BRAK, a decision, and not work) and missing (neither) — and two counts beside them for the ### blocks the store could not index at all: unlisted, a block dropped for a missing or overlapping @margonemid, and retired, whose every @margonemid range is closed.

blocks reconciles to the roster file: total + unlisted + retired. That is the reason the two extra counts are published rather than dropped — a census reporting only what the store holds answers a smaller, self-consistent number, and nothing in it says the rest exist.

A channel on one capacity outweighs a declination on another: somebody reachable as a narrator and declining as a player is reachable.

Fetch a player

GET /v1/api/people/{name} resolves a Gracz name and returns its projection.

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

Response 200 (trimmed):

{ "Name": "Stefan", "Type": "Gracz", "MargonemId": "100001" }

List the roster

GET /v1/api/people enumerates the store's ## Osoby people — the read half of the roster /people/{name} fetches one of. Each item is the store projection resolved as-of-now: { name, margonemId, poprzednieId, roles, status, discord, tematy, alias }. A retired @rola/@status range is already dropped, matching what /people/{name} answers. margonemId is the person's current id and poprzednieId lists the ids they retired by changing Margonem account (empty for almost everybody), so a person who changed account reads as one human rather than two. A person whose every id is retired has left and is not listed at all. The list never carries an @klucz_api hash or a resolved capability set — for one person's full detail use /people/{name}, for the caller's effective capabilities use /capabilities.

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

Response 200 (one item shown):

{ "count": 3, "items": [
  { "name": "Stefan", "margonemId": "100001", "poprzednieId": [], "roles": ["gracz"],
    "status": "Aktywny", "discord": [], "tematy": null, "alias": [] }
] }

Contributor store integrity

GET /v1/api/contributors/integrity reports the contributor store's validation findings, read from the model the daemon rebuilds on every store edit — the read mirror of the hand-authored nerthus.contributors.md. Each finding is { Kind, Severity, Subject, Detail }, returned verbatim (the Detail is authored Polish); the finding kinds are catalogued in the store reference. The response is always 200 — an Error-severity finding (a DuplicateApiKey, a RawApiKeyValue) is store content, not a request failure, so it rides in the body exactly as GET /entities/integrity's schema Errors do; a clean store returns { "count": 0, "items": [] }.

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

Response 200 (a MissingDiscord to-do item — the Rada fills the channel in over time):

{ "count": 1, "items": [
  { "Kind": "MissingDiscord", "Severity": "Warning", "Subject": "Roman",
    "Detail": "brak @discord dla roli 'gracz' — Rada uzupełnia kanał ręcznie" }
] }

A player's characters

GET /v1/api/people/{name}/characters returns the { count, items } envelope of every Postać that @należy_do the player. Stefan owns one: Eraster. The {name} here is the player, which is why the cmdlet takes -Player; to fetch one character by its own name use /characters/{name}. The distinction is easy to miss when a Gracz and their Postać share a name.

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

Response 200 (trimmed):

{ "count": 1, "items": [{ "Name": "Eraster", "Type": "Postać", "Owner": "Stefan" }] }

Resolving a subject

GET /v1/api/players/resolve?nick=… or ?margonemid=…. Exactly one of the two; both, or neither, is 400 — picking one silently would answer about somebody the caller did not ask about, on a surface whose output names a person to be sanctioned.

It reads Postaci/nerthus.players.tsv including old nicks, which is the point: a sanction names the character the violation was committed with, and that is usually not the nick the profile carries now. The answer gives nick, currentNick, the full history, and charIdChanges. A char_id change on a stable profile means the character was deleted and remade on the same account; the profile stays the subject, so a cumulative tally does not reset.

On a miss the daemon fetches the world's online/<world>.json once. That roster is a sample and not a census — about 1.2 % of the world at any instant — so a miss answers 404 with a reason (not_in_ledger_and_not_online or recent_miss_cached) rather than a bare absence.

429 resolve_fetch_budget_exhausted is not 404. player.read is carried by every gracz, so a sliding one-hour budget (integrations.margonem.resolve_fetches_per_hour, default 6) is what keeps this route from being a lever against somebody else's website — and a spent budget must never read as an absent player. Misses are negatively cached for resolve_miss_ttl_s (default 300), so a typo in a compose form costs nothing.

recorded is always false on an on-demand hit: this route does not write the register. The player_ladder CI workload owns that file and records the profile on its next run.

This route is not replica-safe, and that is why it carries no replica flag: the fetch budget and the negative cache are per-host runtime state, so a replica answering from its own empty budget would hand a caller a second allowance against Margonem.

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

Read a player's Discord channel

GET /v1/api/people/{name}/discord-channel returns the Gracz's @discord channel as { channel, category } (both null when unset or BRAK).

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

Response 200. null here is the normal state of a freshly seeded roster, not an error: adoption seeds no channels at all, and the Rada fills them in over time.

{ "name": "Stefan", "channel": null, "category": null }

Create a player — refused

POST /v1/api/people does not create a person: their block is authored in nerthus.contributors.md, under ## Osoby, and @rola: gracz is what makes them a player. The route answers 422 and names the file.

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

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

Response 422:

{
  "error": "RosterStoreProperty",
  "entity": "Anward",
  "tag": null,
  "file": "nerthus.contributors.md",
  "detail": "'Anward' is authored in 'nerthus.contributors.md' — edit their ### block in the ## Osoby section and commit"
}

Update a player tag — refused

PATCH /v1/api/people/{name} does not edit a person's tag. Here Stefan's @status: the refusal names the tag as well as the file, and ?dryRun=true returns exactly the same body.

PATCH /v1/api/people/Stefan
Content-Type: application/json
Authorization: Bearer <token>

{ "tag": "status", "value": "Nieaktywny" }
await fetch("https://evocation.nerthus.pl/v1/api/people/Stefan", {
  method: "PATCH",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ tag: "status", value: "Nieaktywny" }),
});

Response 422:

{
  "error": "RosterStoreProperty",
  "entity": "Stefan",
  "tag": "status",
  "file": "nerthus.contributors.md",
  "detail": "'@status' of 'Stefan' is authored in 'nerthus.contributors.md' — edit their ### block in the ## Osoby section and commit"
}

Set a player's Discord channel — refused

PUT /v1/api/people/{name}/discord-channel does not write the channel. A person's @discord lines live on their ## Osoby block, one per capacity they are reached in (kanał, kategoria, gracz for a player, …, narratorzy for a Narrator), so setting one is an edit and a commit. The bot still posts to the resolved name (Discord bot token required — see Discord).

PUT /v1/api/people/Stefan/discord-channel
Content-Type: application/json
Authorization: Bearer <token>

{ "channel": "gracz-stefan", "category": "gracze" }
await fetch("https://evocation.nerthus.pl/v1/api/people/Stefan/discord-channel", {
  method: "PUT",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ channel: "gracz-stefan", category: "gracze" }),
});

Response 422:

{
  "error": "RosterStoreProperty",
  "entity": "Stefan",
  "tag": "discord",
  "file": "nerthus.contributors.md",
  "detail": "'@discord' of 'Stefan' is authored in 'nerthus.contributors.md' — edit their ### block in the ## Osoby section and commit"
}

The edit it is asking for:

### Stefan
- @margonemid: 100001
- @status: Aktywny
- @rola: gracz
- @discord: gracz-stefan, gracze, gracz

Retire a player (soft delete) — refused

DELETE /v1/api/people/{name} does not retire a person. Retirement is still a @status: Usunięty transition and never a physical removal — but it is written by hand, on their block in the store. A person who stops narrating while continuing to play is not retired at all: close the range on that one @rola line instead.

DELETE /v1/api/people/Stefan
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/people/Stefan", {
  method: "DELETE",
  headers: { "Authorization": `Bearer ${token}` },
});

Response 422:

{
  "error": "RosterStoreProperty",
  "entity": "Stefan",
  "tag": null,
  "file": "nerthus.contributors.md",
  "detail": "'Stefan' is authored in 'nerthus.contributors.md' — edit their ### block in the ## Osoby section and commit"
}

Fetch a character

GET /v1/api/characters/{name} resolves the name against Postać blocks only and returns the projection. A name no character carries but a player does answers 404 naming the roster route, so /characters/Stefan points at /people/Stefan/characters rather than reporting that Stefan does not exist.

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

Response 200 (trimmed):

{ "Name": "Eraster", "Type": "Postać", "Status": "Aktywny", "Owner": "Stefan" }

The record is type-scoped, so a character answers with Owner, Groups, and Location and not with a place's Doors or Coordinates (the shape rule).

Create a character

POST /v1/api/characters mints a new Postać — the handler forces type: Postać. Pass the owning Gracz as the należy_do seed tag. The name is a scene-derived courier serving Stefan.

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

{ "name": "Posłaniec Stefana", "tags": { "należy_do": "Stefan" } }
await fetch("https://evocation.nerthus.pl/v1/api/characters", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ name: "Posłaniec Stefana", tags: { "należy_do": "Stefan" } }),
});

Response 201:

{ "created": "Posłaniec Stefana", "type": "Postać" }

Update a character tag

PATCH /v1/api/characters/{name} sets one tag. Promoting a character to Aktywny also demotes the owner's previously active one, so a Gracz never has two active characters. Here a benign location tag on the courier.

PATCH /v1/api/characters/Pos%C5%82aniec%20Stefana
Content-Type: application/json
Authorization: Bearer <token>

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

Response 200:

{ "updated": "Posłaniec Stefana", "tag": "lokacja", "changed": true }

Retire a character (soft delete)

DELETE /v1/api/characters/{name} marks the Postać block @status: Usunięty. Here the scratch courier.

DELETE /v1/api/characters/Pos%C5%82aniec%20Stefana
Authorization: Bearer <token>
await fetch("https://evocation.nerthus.pl/v1/api/characters/Pos%C5%82aniec%20Stefana", {
  method: "DELETE",
  headers: { "Authorization": `Bearer ${token}` },
});

Response 200:

{ "softDeleted": "Posłaniec Stefana", "status": "Usunięty" }

Add a character info entry

POST /v1/api/characters/{name}/info appends one dated entry to the character's info section; from/to are YYYY-MM bounds. The response added is the formatted bullet line.

POST /v1/api/characters/Eraster/info
Content-Type: application/json
Authorization: Bearer <token>

{ "text": "Gość w rezydencji Tussala", "from": "2026-07", "to": "2026-08" }
await fetch("https://evocation.nerthus.pl/v1/api/characters/Eraster/info", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({ text: "Gość w rezydencji Tussala", from: "2026-07", to: "2026-08" }),
});

Response 201:

{ "added": "- Gość w rezydencji Tussala (2026-07 - 2026-08)", "name": "Eraster" }

List character info entries

GET /v1/api/characters/{name}/info returns the { name, count, items } envelope; each item carries its index, text, and optional from/to. Add ?activeOn=YYYY-MM-DD to filter to the entries covering that date.

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

Response 200 (trimmed):

{ "name": "Eraster", "count": 1, "items": [{ "index": 0, "text": "Gość w rezydencji Tussala", "from": "2026-07", "to": "2026-08" }] }

Replace a character info entry

PUT /v1/api/characters/{name}/info replaces the entry at index; match (the current raw text) is the optimistic-concurrency guard. Both are required — an empty body is 400, nothing written. To edit for real, read the entry with the GET above, then send its index plus its exact text as match.

PUT /v1/api/characters/Eraster/info
Content-Type: application/json
Authorization: Bearer <token>

{}
await fetch("https://evocation.nerthus.pl/v1/api/characters/Eraster/info", {
  method: "PUT",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({}),
});

Response 400:

{ "error": "IndexAndMatchRequired", "detail": "index and match required" }

Remove a character info entry

DELETE /v1/api/characters/{name}/info removes the entry at index, guarded by the same match. Both are required — an empty body is 400.

DELETE /v1/api/characters/Eraster/info
Content-Type: application/json
Authorization: Bearer <token>

{}
await fetch("https://evocation.nerthus.pl/v1/api/characters/Eraster/info", {
  method: "DELETE",
  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}` },
  body: JSON.stringify({}),
});

Response 400:

{ "error": "IndexAndMatchRequired", "detail": "index and match required" }