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
/peopleand/charactersroutes are the Gracz/Postać surface (player.write), distinct from generic/entities+entity.write. The gracz role holdsplayer.write.own, scoped to the caller's own Gracz and Postacie — it still gates everyPostaćroute, but no longer lets a player set their own Discord channel (capabilities). - The four
/peoplewrite rows keep theirCapand theirWriteflag and answer422 RosterStoreProperty. Keeping them is deliberate: the write gate still runs first, so a read-only host answers its503before the422— a property of the host outranks a property of the resource — and?dryRun=truereturns the identical refusal, so preview and apply agree. The same422answersPATCH/DELETE /entities/{name}andPOST /entitieswithtype: Gracz, so the generic path is closed too. - Refusal body:
{ "error": "RosterStoreProperty", "entity", "tag", "file": "nerthus.contributors.md", "detail" }— the same shape asRefTargetPropertyon a referenced property. - The own-scope
403is checked before the422, so an own-scoped caller poking at a foreign player learns nothing about whether that player exists. - The
/characters/{name}/inforoutes manage the charfile's**Dodatkowe informacje:**entries;PUT/DELETErequirematchto equal the entry's current text, so a stalematchis refused409.
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.
Response 200 (trimmed):
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.
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": [] }.
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.
Response 200 (trimmed):
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.
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).
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.
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.
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.
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).
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.
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.
Response 200 (trimmed):
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.
Response 201:
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.
Response 200:
Retire a character (soft delete)¶
DELETE /v1/api/characters/{name} marks the Postać block @status: Usunięty. Here the
scratch courier.
Response 200:
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.
Response 201:
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.
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.
Response 400:
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.
Response 400: