Skip to content

The roster model

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

The roster subsystem defines the two people-facing entity types: Gracz — the real person, keyed by their Margonem profile ID — and Postać — an in-fiction character owned by exactly one Gracz. This page specifies the exact contracts: where a player record lives and what its tags mean, the retired Gracze.md adoption source and its seed parse rules, the roster invariants, and the API-managed Dodatkowe informacje entries. For the plain introduction, start with People and roles and Player characters.

Concepts

  • Gracz — the real person. Identity is the Margonem profile ID (an integer like 9826541, stable for as long as the person keeps that account); nicks are fungible, and each Margonem account caps at six game characters. The friendly block heading (e.g. ### Eraster) is a display label.
  • Postać — an in-fiction character (e.g. Eraster, Losso Minewit, Felkissian). Linked to its owner by @należy_do; has its own entity record, currency holdings, session participation, and PU accounting.
  • Active character — each Gracz designates one "current" Postać: @status: Aktywny on that Postać, @status: Nieaktywny on the others. In Gracze.md this was encoded by bold-linking exactly one character.
  • Discord channel — the player's personal Discord channel, carried as @discord on their block in the contributor store; PU and Intel notifications for any of that player's characters are posted there by the bot, resolving the name to a channel id. Those notifications ask for the channel purposed gracz — a person who also narrates carries a second, differently purposed line on the same block (the contributor store). See Logs & Discord.
  • Tematy zastrzeżone — content triggers the player does not wish to see in sessions they take part in (e.g. Banany), carried as the typed scalar @tematy_zastrzeżone on the person's block and projected as RestrictedTopics. Narrators consult it when planning a session.

The split is deliberate: PU, the Discord channel, triggers, and identity attach to the person; currency, reputation, sessions, and the character sheet attach to the character. One person, several characters, one active. That split is now also a storage split: the person's facts live in the hand-authored contributor store, the character's in the generated entity index.

The roster record: a person in the store, a character in the index

A player is a ### <nick> block in the ## Osoby section of nerthus.contributors.md — the same block that carries their roles, their capabilities and, if they narrate, their narrator channel. What makes them a player is @rola: gracz: the daemon projects one Gracz record per such block into its entity model, so @należy_do resolution, .own scoping, @Intel fan-out, transfers and name resolution all behave exactly as before. The storage moved; the model did not.

Their characters stay in the ## Postacie Graczy section of nerthus.entities.md, seeded from Gracze.md at adoption (Adoption) and daemon-written thereafter.

The store is hand-authored and never regenerated, so the durable/machine section split no longer applies to people at all — that distinction existed only because a hand-authored thing was parked in a generated file. It still governs the index and is specified in the entity model, along with the block format (### heading + flat @tag bullets) both files share.

# nerthus.contributors.md

## Osoby

### Eraster
- @margonemid: 9826541
- @status: Aktywny
- @rola: gracz
- @discord: gracz-eraster, gracze, gracz
- @tematy_zastrzeżone: Banany
# nerthus.entities.md

## Postacie Graczy

### Eraster
- @typ: Postać
- @należy_do: Eraster
- @status: Aktywny
- @plik: /Postaci/Gracze/Eraster.md
- @pu_startowe: 20
- @pu_suma: 127.90
- @pu_zdobyte: 107.90
- @pu_nadmiar: 0.00

The person block carries no @typ — the section states it — and gains @rola: gracz, without which they are somebody in the file who is not in the game. The full ## Osoby block shape, with multiple roles and purposed channels, is the contributor store's: it owns the block's authorization tags — @rola, @dostęp, @klucz_api — while this model owns the person's player-facing tags — @status, @tematy_zastrzeżone, @alias. The @discord line is shared by facet: the contributor store owns its purpose grammar and delivery resolution, this model owns it as the player's channel.

Eraster's other three Postacie (Losso Minewit, Felkissian, Klusp) follow the same shape with @status: Nieaktywny; legacy decimal scale is preserved as written (42.1, 0.00), and @pu_zdobyte is derived as SUMA − STARTOWE.

Field reference (Gracz / Postać entities; the full tag schema is in the tag reference):

Tag On Authored in Meaning
@typ Postać index Postać. A person's block carries none — the ## Osoby section states it
@rola: gracz person store what makes the person a player; the projection predicate
@margonemid person store authoritative profile ID (identity key); multi + temporal — a retired account keeps its line with the range closed
@należy_do Postać index owning Gracz (links character → person)
@status both store / index Aktywny / Nieaktywny / Usunięty — soft-delete only (entity model)
@alias both store / index alternate names (Lord Tussal known as Tussal); nick history on a person
@plik Postać index the character source file — the distribution target for sessions
@discord person store the player's Discord channel as kanał, kategoria[, cel] — the bot posts to it by name. Hand-authored; the importer seeds none, so a freshly seeded roster has no channels until the Rada fills them in
@tematy_zastrzeżone person store the player's content triggers as a typed scalar; the block carries no freeform prose (projected as RestrictedTopics)
@pu_startowe @pu_suma @pu_zdobyte @pu_nadmiar Postać index the four PU accounting fields — semantics owned by the PU model

Two rows carry a design decision:

  • A Gracz carries no @plik, so session distribution skips a Gracz participant. The legacy Gracze.md roster carried @plik: Gracze.md — distribution would then recreate the deleted Gracze.md on every settle run. The import drops the tag.
  • @discord resolution. Delivery asks for a channel by purposegracz for PU and @Intel. The chain is the entity's own @discord, then the person's channel for that purpose, then their unpurposed default slot, then the ## Role block for that purpose, else it records a skip. The channel name is committed lore, but posting to it needs the out-of-band Discord bot token — not a committed webhook URL (Logs & Discord).

Why the person left the index — and why one block

Four reasons, all of them about where a statement belongs:

  • One store for people. The contributor store was already keyed by @margonemid, already parsed by the one scanner, already self-healed per request, and already carried a person's roles, capabilities and channel. The roster belonged there, in the same block — not beside it.
  • The durable-section rule existed only to protect a hand-authored thing parked in a generated file. With the roster in a file nothing regenerates, the rule has nothing left to do for people.
  • A person's @discord was stored twice, in two files, with a three-step fallback chain whose middle step existed only because of the split.
  • A human who narrates and plays was two blocks pretending to be two people. One block with two @rola lines says it plainly, and lets each capacity have its own channel.

Identity over nick

The Gracz heading and @należy_do carry the friendly nick for human readability, but identity always reconciles to @margonemid; a nick change updates only the label and @alias. See identity & permissions.

And when the account itself changes

A profile id is stable for an account. Somebody who moves to a new Margonem account closes the range on their old @margonemid and adds a line for the new one, in the same block. Live decisions resolve as of now, so the old account stops authenticating on its date; history — grants, links, audit lines — still resolves through the old id. The operator steps are people and roles.

The retired seed source: Gracze.md

Gracze.md was the hand-authored player database and the roster's adoption source. The import's bootstrap path reads it once and appends the ## Osoby section to nerthus.contributors.md; once that section exists it is the roster record, and a re-import preserves it by presence (the seed no longer runs). The tool never writes to or deletes Gracze.md — retiring the file is the operator's own git commit after adoption, and git history preserves the archive. Adoption aborts with a structured RosterSourceMissing error only when there is neither a ## Osoby section nor a Gracze.md (Adoption).

Seeded blocks carry no @discord. The initializer cannot know a channel name, and guessing one would route a real person's private notifications to the wrong place — so every seeded block ships without one and the Rada fills them in by hand afterwards. The tool keeps the list of who still needs one.

A campaign whose nerthus.entities.md still carries a ## Gracze section is refused rather than migrated: two rosters cannot be merged by guessing, and the manual recipe is People and roles's.

The seed parses a fixed per-player block shape:

### Eraster
- Postaci:
    - **[Eraster](/Postaci/Gracze/Eraster.md)**:
        - PU: NADMIAR: 0.00, STARTOWE: 20, SUMA: 127.90
        - Wręczył Tussalowi łapówkę w postaci dziesięciu koron
    - [Losso Minewit](/Postaci/Gracze/Losso Minewit.md):
        - PU: NADMIAR: 0.00, STARTOWE: 40, SUMA: 42.1
    - [Felkissian](/Postaci/Gracze/Felkissian.md):
        - PU: NADMIAR: 0.00, STARTOWE: 41, SUMA: 52.95
    - [Klusp](/Postaci/Gracze/Klusp.md):
        - PU: NADMIAR: 0.00, STARTOWE: 64.00, SUMA: 65.40
- Tematy zastrzeżone: Banany
- ID Margonem: 9826541

Parse rules the seed applies:

Source line Meaning Maps to
### Eraster friendly label for the player block Gracz heading (display name)
**[Char](…)** (bold link) the active character Postać @status: Aktywny
[Char](…) (plain link) an inactive character Postać @status: Nieaktywny
(/Postaci/Gracze/Char.md) the character's source file Postać @plik
PU: NADMIAR: …, STARTOWE: …, SUMA: … per-character PU baseline @pu_nadmiar / @pu_startowe / @pu_suma (+ derived @pu_zdobyte)
Aliasy: Tussal alternate names Postać @alias
free bullet under a character (Wręczył Tussalowi łapówkę…) narrator note moved into the charfile's **Dodatkowe informacje:**
Tematy zastrzeżone: Banany content triggers (player-level) the person's @tematy_zastrzeżone (typed scalar)
other player-level bullets (e.g. Tussal's - Inne:) roster notes moved into the player's active-character **Dodatkowe informacje:**
ID Margonem: 9826541 authoritative identity the person's @margonemid (a #char profile URL is not carried onto the block)

Every seeded block also gets @status: Aktywny and @rola: gracz.

Reading notes:

  • The seed parses only the ## Lista section and skips fenced example blocks and unrecognized lines. The narrator-note bullets under each character are moved into that character's file, deduplicated against notes already there; the move mechanics (fail-early, per-bullet logging) belong to the import model.
  • The PU line accepts ZDOBYTE: in place of SUMA: — the seed reconstructs SUMA = STARTOWE + ZDOBYTE. Decimals accept dots or commas (42,142.1).
  • BRAK is the uninitialized sentinel (this page owns its semantics): STARTOWE: BRAK — or a missing PU line — emits no PU tags, except that a numeric SUMA beside STARTOWE: BRAK still emits @pu_suma; NADMIAR: BRAK seeds @pu_nadmiar: 0. A ripple worth knowing: on such a block the first monthly award computes @pu_zdobyte against a missing STARTOWE (treated as 0), so it lands as the full legacy total — set @pu_startowe by hand or API first when the baseline matters for a new-character STARTOWE preview.
  • ID Margonem may be a bare id or a profile URL (…/profile/view,9826541#…) — the numeric id is extracted either way and kept as @margonemid. The full URL's #char character-sheet anchor is not carried onto the person's block: the character's own **Karta Postaci:** link is that sheet's home, and git history keeps the retired Gracze.md verbatim.
  • The PU values seeded here are the adoption baseline only; going forward PU is computed and authoritative from session @PU (PU model).
  • A player-character file with no roster entry still seeds as a Postać — @typ/@status/@plik only, no @należy_do. Files under Postaci/Gracze/Martwi/ seed as Nieaktywny.

The character file: Postaci/Gracze/<Char>.md

Each Postać has a hand-authored Markdown file. It is a human-authored Polish source — the daemon reads it, distributes sessions into it (resolved via @plik), and manages the Dodatkowe informacje entries in place — but it never rewrites the freeform prose. Bold-label fields:

Label Contents
**Karta Postaci:** link to the freeform character sheet on the Margonem profile (e.g. https://www.margonem.pl/profile/view,9826541#char_22788,nerthus)
**Tematy zastrzeżone:** optional character-level trigger restatement
**Stan:** current condition (Zdrowy., Brak., injury notes)
**Przedmioty specjalne:** narratively significant items (everyday holdings live in entity currency/items)
**Reputacja:** three buckets — Pozytywna / Neutralna / Negatywna, each a per-town list (Eder, Torneg, Ithan, …)
**Dodatkowe informacje:** standing facts and hooks (blessings, debts, owed items) — API-managed entries, below
**Opisane sesje:** a [[_TOC_]] followed by one block per session

The Karta Postaci is freeform and external (the Margonem profile); the tool tracks only the link — modeling statistics or the diceless ruleset is a deliberate non-goal (Architecture).

Under **Opisane sesje:**, session distribution appends one block per session the character took part in, keyed by the universal session header and copied verbatim from the session record — the character file is a projection, not an authority. Distribution mechanics: the session model.

Dodatkowe informacje — the API-managed entries

Each bullet under **Dodatkowe informacje:** is one entry the daemon lists, adds, edits, and removes in place (the reversed legacy label **Informacje dodatkowe:** is accepted on read — one legacy charfile uses it), through Get-NerthusCharacterInfo, Add-NerthusCharacterInfo, Set-NerthusCharacterInfo, and Remove-NerthusCharacterInfo. An entry may carry the standard temporal (from:to) range, filtered by the all-active rule (entity model). An edit or removal must restate the entry's current text and is refused on a mismatch, so concurrent operators never silently overwrite each other. Entry text is a single line by contract — the API refuses embedded newlines, so an entry can never smuggle a fake session heading into the sheet. The listed text is authoritative; the range-stripped value also drops a trailing colon-bearing parenthetical, while matching and filtering stay exact. The import moves the Gracze.md narrator notes into this section during adoption — a character's own notes into that character's file, and each player's free roster notes (- Inne: …) into their active character's file (Adoption). Routes, bodies, and capabilities: the API reference.

Behavior & invariants

  • One owner, one active. Every Postać has exactly one @należy_do. Setting a Postać's @status to Aktywny demotes the owner's previously active Postać to Nieaktywny in the same write; both changes are audited. Creation is the one path that does not demote — a new Postać seeds the entity-create default @status: Aktywny without touching siblings, so promote/demote explicitly after creating an additional Postać. Status is a temporal tag — each flip is a dated append, never an overwrite (entity model).
  • Soft-delete only. Removing a Gracz or Postać is a @status: Usunięty transition (entity model); the record and its file persist.
  • The store is the roster record, and it is hand-authored. A person is changed by editing their block in nerthus.contributors.md and committing — no route writes it. Character mutations still land in ## Postacie Graczy; the tool never touches Gracze.md.
  • PU is computed, not edited. Roster cmdlets do not set PU; it flows from the monthly batch (PU model). Creating a Postać seeds no PU tags — Get-NerthusNewPlayerCharacterPUCount previews the policy-derived STARTOWE, which the operator passes explicitly (e.g. -Tags @{ pu_startowe = '41' }).
  • Margonem id at creation. Creating a Gracz does not require @margonemid, but a Gracz without one cannot be identity-linked (the Margonem/Discord login, identity & permissions) — pass it in -Tags at creation.
  • Player writes do not exist. Every route that mutated a Gracz — create, edit, soft-delete, set the Discord channel — now refuses and names nerthus.contributors.md as the file to edit (API reference). Reads are unchanged. The capability consequence, stated plainly: player.write.own no longer lets a player set their own Discord channel — they ask the Rada, who edits the store and commits. player.write and player.write.own keep their ids and still gate every character route, where ownership scoping applies exactly as before (identity & permissions; capability reference).
  • Single write gate. Every character mutation passes the write gate and fires the write hooks — see the API reference. A read-only host still answers at that gate first, before any roster refusal.

Cmdlet surface

Players: Get-NerthusPlayer reads. New-NerthusPlayer, Set-NerthusPlayer, Remove-NerthusPlayer and Set-NerthusPlayerDiscordChannel still exist and now surface a refusal naming the file to edit (API reference) — kept deliberately, so an existing script fails with the explanation rather than with a missing command. Player Discord channel: Get-NerthusPlayerDiscordChannel reads (and answers null for a block the Rada has not filled in yet — that is normal, not an error). Characters: Get-NerthusCharacter fetches one by its own name, Get-NerthusPlayerCharacter -Player lists everyone a Gracz owns, and New-NerthusPlayerCharacter, Set-NerthusPlayerCharacter, Remove-NerthusPlayerCharacter write. A Gracz and their Postać routinely share a name, so the two reads are worth telling apart. Character info entries: Get-NerthusCharacterInfo, Add-NerthusCharacterInfo, Set-NerthusCharacterInfo, Remove-NerthusCharacterInfo. Starting-PU preview: Get-NerthusNewPlayerCharacterPUCount. A character's owning Gracz is also resolvable through the API's entity surface. Routes, parameters, envelopes, and capabilities: the API reference.

Examples

Eraster is one Gracz (@margonemid: 9826541) owning four characters, with Eraster active:

PS> Get-NerthusPlayerCharacter -Player Eraster | Format-Table Name, Status, PuSuma

Name          Status     PuSuma
----          ------     ------
Eraster       Aktywny    127.90
Losso Minewit Nieaktywny 42.1
Felkissian    Nieaktywny 52.95
Klusp         Nieaktywny 65.40

The Gracz and their active Postać share the name here, which is the case the parameter name guards: -Player Eraster lists all four, Get-NerthusCharacter -Name Eraster returns the one character.

PS> Get-NerthusCharacter -Name Eraster | Format-Table Name, Type, Owner, PuSuma

Name    Type   Owner   PuSuma
----    ----   -----   ------
Eraster Postać Eraster 127.90

Switching the active character (demotes Eraster, promotes Felkissian in one write):

PS> Set-NerthusPlayerCharacter -Name Felkissian -Tag status -Value Aktywny

Previewing the starting PU for a new character of Eraster's, then passing it at creation:

PS> Get-NerthusNewPlayerCharacterPUCount -Player Eraster
PS> New-NerthusPlayerCharacter -Name 'Felkissian' -Tags @{ 'należy_do' = 'Eraster'; plik = '/Postaci/Gracze/Felkissian.md'; pu_startowe = '41' }

Reading Eraster's Discord channel and adding a Dodatkowe informacje entry:

PS> Get-NerthusPlayerDiscordChannel -Name Eraster
PS> Add-NerthusCharacterInfo -Name Eraster -Text 'Wręczył Tussalowi łapówkę w postaci dziesięciu koron'

Setting that channel is an edit and a commit, not a call — add the line to Eraster's block in nerthus.contributors.md:

### Eraster
- @margonemid: 9826541
- @status: Aktywny
- @rola: gracz
- @discord: gracz-eraster, gracze, gracz

Why it works this way

  • Nick-change history. Old nicks are retained as @alias on the person's block; the live index always answers with the current nick.
  • Player-level vs character-level triggers. The Gracz value is authoritative; character-file restatements are informational.
  • Creation vs the one-active flag. The one-active-character rule is enforced on update, not creation — see "One owner, one active" above.
  • The roster record. Adoption retires Gracze.md; the store's ## Osoby section is the roster record, and deleting the file is the operator's commit.
  • Discord channel storage. @discord on the person's block in the committed store names the player's channel, purposed for the capacity it serves; the bot posts to it by name, and the bot token — not any committed URL — is the one out-of-band secret (Logs & Discord).

See also