Skip to content

The PU model

This page defines the exact PU contracts: the four accounting fields, the monthly award batch fed by session @PU grants, the new-character baseline, the fail-early rule, the dated assignment timeline, and election eligibility (WYBORY → UPRAWNIONY). The daemon computes all of it from the per-session grants the Polish sources declare; how much a session is worth stays the Narrator's call. For the plain operator introduction, start at the PU overview.

Concepts

PU lives on Postać entities. A Gracz may own several characters, each with its own PU account (Players & Characters). PU state is persisted as four scalar tags on the Postać in the generated nerthus.entities.md index:

- @pu_startowe: 20
- @pu_suma: 127.90
- @pu_zdobyte: 107.90
- @pu_nadmiar: 0

These are tool-refreshed snapshots, replaced in place on each write (no temporal history on the tag — the history lives in the audit stream and the ledger, see the assignment timeline).

Field Polish meaning Definition
STARTOWE points the character started with The baseline granted at character creation. Immutable once set.
SUMA the total current pool The authoritative running total. It grows only through the monthly batch, by that month's GrantedPU.
ZDOBYTE points earned through play ZDOBYTE = SUMA − STARTOWE. The batch writes the snapshot alongside SUMA; the new-character baseline reads the stored value back.
NADMIAR the overflow reserve Unbounded pool of PU that exceeded the monthly cap, carried forward and drawn down in lean months.

The BRAK sentinel

A legacy STARTOWE: BRAK row leaves the account uninitialized — the sentinel's parse semantics are owned by Players & Characters.

Decimals — Polish comma normalized to dot

Per-session grants are small decimals. On disk a Narrator may write either the Polish comma form 0,15 or the dot form 0.15; the parser normalizes the comma to a dot and stores a canonical invariant-culture decimal. STARTOWE: 37.5 and SUMA: 127.90 are real archive values. Rounding is specified once, in the monthly batch.

Session @PU grants

A session declares its PU grants as the @PU block — a list of Character: amount lines under the session header, one per participant who earned points. The example below uses the bare form: bare PU: parses identically to @PU: (Sessions):

### 2026-07-01, Eraster rozmawia z Tussalem, Anward
- PU:
    - Eraster: 0.2
    - Lord Tussal: 0.2

Each character name in a @PU line is resolved to a canonical Postać via name resolutionwith the fuzzy stage disabled, per that page's structured-value rule. Declension and alternation still apply; a near-miss typo fails resolution and aborts the batch (see fail-early) instead of matching the wrong character.

A session is counted into the assignment ledger once — the ledger is keyed by the session header, the universal identity key (see Sessions) — so a grant is never double-applied even if the monthly batch is re-run.

The monthly batch

PU is awarded in a monthly batch — the only thing that moves SUMA and NADMIAR. For each character named in the month's uncounted @PU data it computes a GrantedPU value and applies it, with overflow routed to NADMIAR:

RawMonthly    = monthly_base + Σ(this month's @PU grants for the character)
              = 1            + Σ session PU                    (tunable)

Available     = RawMonthly + NADMIAR_before      (overflow can be spent down)

GrantedPU     = min(Available, monthly_cap)      (monthly_cap = 5, tunable)
NADMIAR_after = Available − GrantedPU            (unbounded; ≥ 0)

SUMA         += GrantedPU
  • The monthly base is credited once per character processed in the month; only characters named in a counted session's @PU that month are processed.
  • NADMIAR is unbounded and drawn down in lean months. A character who banked overflow keeps drawing up to the cap each month from NADMIAR + RawMonthly until the reserve empties — the legacy overflow ledger shows Velrose (Kłak) carrying 4.10 then 4.40 across consecutive months.
  • Rounding: grants are summed as authored; the award figures the batch writes and reports (Granted, SumaAfter, NadmiarAfter, Zdobyte) are rounded to two decimal places — fixed behavior, not a tunable.

The constants live in config.json under the pu block — pu.monthly_base, pu.monthly_cap, pu.election_window_months, pu.election_threshold, and pu.new_character.{divisor,bonus,floor}; defaults and file location in Configuration.

What a committed batch does

The result carries one award record per character (GrantThisMonth, Base, Granted, SumaBefore/After, NadmiarBefore/After, Zdobyte, counted session headers) plus the month's counted headers and a SkippedAlreadyCounted count. On apply, the batch:

  1. passes the single write gate (Assert-NerthusWriteAllowed, see API);
  2. writes the new pu_suma / pu_nadmiar / pu_zdobyte snapshots for every awarded Postać into nerthus.entities.md;
  3. writes one audit line per mutated Postać (op: pu.award, entity, field pu_suma, old/new, the counted session headers, the batch month) — this is what the per-entity history view (Get-NerthusEntityHistory) reads;
  4. appends the assignment (date + counted headers) to the dedup ledger, .nerthus/state/pu-ledger.json, and one line per counted session to the committed ledger echo nerthus.ledger.md (- PU ### <header> (rozliczono <date>) — file inventory in Architecture);
  5. fires the AfterPuAwarded hook: one batch-level audit line (batch month + character count), the pu:awarded event, and — when integrations.discord.enabled — one combined Polish Discord message per Gracz, decimals in the comma form, best-effort (see Logs & Discord).

Re-runs are incremental and idempotent: sessions already in the ledger are skipped, so running the same month again after new sessions land counts only the new ones.

The drift guard (no double-award across clones)

The private dedup ledger is machine-local; the committed echo travels with git. When a batch (dry-run or apply) counts a session that the echo already lists, this clone's private ledger is missing an award another instance committed — the sums in nerthus.entities.md already include it. The batch aborts with a structured 409 PULedgerDrift naming the sessions, before any write:

{ "error": "PULedgerDrift", "month": "2026-07",
  "sessions": ["### 2026-07-01, Eraster rozmawia z Tussalem, Anward"],
  "applied": false }

A session added late is unaffected — it appears in no echo line, so the late award proceeds normally. The echo starts empty at adoption: historical dedup comes from the recovered legacy ledger, which every clone re-imports identically (Adoption). The settlement workflow reads the same ledgers to skip settled months before invoking the batch, so this guard never fires during a scheduled settle (Settlement).

New-character PU baseline (STARTOWE)

When a Gracz creates a new Postać, its STARTOWE is seeded from the player's existing earned points so a veteran's second character does not start from zero:

STARTOWE_new = max( floor( Σ ZDOBYTE / divisor + bonus ), floor )
             = max( floor( Σ ZDOBYTE / 2       + 20    ), 20    )   (defaults)
  • Σ ZDOBYTE sums the stored pu_zdobyte snapshots over all Postać entities whose @należy_do is the player — no further qualifier.
  • The divisor, bonus, and floor are the pu.new_character.{divisor,bonus,floor} constants. The 20 floor is why so many archive rows read STARTOWE: 20 — it is the baseline for a player with no prior earned PU.

Get-NerthusNewPlayerCharacterPUCount computes this figure (without writing) so a Narrator can preview a new character's starting points before creation.

Fail-early: the whole batch or nothing

Invariant. Any unresolved character name in the month's @PU data aborts the entire monthly batch with no partial writes. This protects SUMA from a half-applied month that could never be cleanly re-run against the dedup ledger.

Invoke-NerthusPUAssignment (and the dry-run Test-NerthusPUAssignment) first resolves every name across every uncounted session, and only then applies. On failure it returns a structured 422 error with the unresolved tokens grouped per token, each carrying every session that used it — so one fix (an @alias or a typo correction) clears every listed occurrence at once:

{
  "error": "PUUnresolvedCharacters",
  "month": "2026-07",
  "unresolved": [
    { "token": "Tussal",   "sessions": ["### 2026-07-01, Eraster rozmawia z Tussalem, Anward"] },
    { "token": "Lorda T.", "sessions": ["### 2026-07-01, Eraster rozmawia z Tussalem, Anward"] }
  ],
  "applied": false
}

The operator fixes the source and re-runs; because nothing was written, the re-run is clean.

The PU assignment timeline (dated ledger)

PU is sometimes assigned late — a Narrator may award February's sessions on the 1st of March, or backfill months later. Therefore session date ≠ assignment date, and the ledger records both: the AssignmentDate (when the batch ran) and the exact set of counted session headers. On adoption import the ledger is seeded from the legacy .robot/res/pu-sessions.md file, and the imported pu_nadmiar balances come from the NADMIAR: field of Gracze.md rows. Adoption owns the recovery detail.

Get-NerthusPUTimeline reads the ledger:

  • Without -Character — one entry per assignment batch: AssignmentDate, CountedSessions (count), Sessions (the headers).
  • With -Character — only the batches whose counted sessions credit that Postać, each carrying AssignmentDate, Character, Granted, CountedSessions, and the exact Sessions headers:
{ "AssignmentDate": "2026-08-01 03:16", "Character": "Lord Tussal", "Granted": 0.2,
  "CountedSessions": 1, "Sessions": ["### 2026-07-01, Eraster rozmawia z Tussalem, Anward"] }

Granted here is the raw @PU sum credited in that batch; the base, the cap, and NADMIAR movement live only in the award records (Lord Tussal's 0.2 above became an applied GrantedPU of 5.00 in the worked example).

Get-NerthusPUAssignmentHistory returns the assignment-level view: one row per batch with its date and the count of counted sessions. The per-batch headers come from the timeline.

Election eligibility (WYBORY → UPRAWNIONY)

Council elections (WYBORY) restrict voting to active players. Eligibility is computed on demand over a rolling window, so it is always current.

window           = trailing pu.election_window_months (6) ending at the as-of date
activity(player) = Σ over window sessions of the player's characters' @PU grants
                 + 1 per distinct month with at least one such grant
UPRAWNIONY       = activity ≥ pu.election_threshold (3.0)
  • The window selects sessions by session date (when it was played), not by assignment date — a session awarded late, or not yet awarded at all, still counts toward the month it was played.
  • Activity aggregates across all of a player's characters (owner resolved via @należy_do), because the franchise belongs to the human, not the Postać.
  • +1 per distinct active month rewards consistent presence over a single bursty month, carrying the inherited 6-month rule forward.
  • Eligibility is a read: unlike the batch, an unresolved @PU name is skipped, not fatal.

Get-NerthusElectionEligibility returns the roll, sorted by score — per player: Gracz, Window (e.g. 2026-01:2026-07), PuScore, ActiveMonths, Total, Threshold, and the UPRAWNIONY/NIEUPRAWNIONY verdict:

{ "Gracz": "Tussal", "Window": "2026-01:2026-07", "PuScore": 4.2,
  "ActiveMonths": 3, "Total": 7.2, "Threshold": 3.0, "Status": "UPRAWNIONY" }

Eligibility is PU-derived data: the route requires the pu.read capability, and a Gracz holding only pu.read.own self-checks — the query is forced to their own Gracz, like the own-scoped timeline and baseline preview. Capability ids are listed in Capabilities; the WYBORY governance context lives in Roles & permissions.

Cmdlet surface

Invoke-NerthusPUAssignment (dry-run twin: Test-NerthusPUAssignment), Get-NerthusPUTimeline, Get-NerthusPUAssignmentHistory, Get-NerthusNewPlayerCharacterPUCount, Get-NerthusElectionEligibility.

Routes, parameters, envelopes, capabilities, the -WhatIf?dryRun mapping, and the -AsJob job model are specified once, in API.

Worked example

July 2026: the ### 2026-07-01, Eraster rozmawia z Tussalem, Anward session from the grants section is the month's only uncounted session, awarded on assignment date 2026-08-01.

For Lord Tussal (grant 0.2, a veteran with NADMIAR_before = 4.40 banked):

RawMonthly = 1 (base) + 0.2 = 1.2
Available  = 1.2 + 4.40 = 5.60
GrantedPU  = min(5.60, 5) = 5.00      → SUMA += 5.00
NADMIAR_after = 5.60 − 5.00 = 0.60    (carried forward)

For Eraster (grant 0.2, NADMIAR_before = 0):

RawMonthly = 1 + 0.2 = 1.2
Available  = 1.2
GrantedPU  = min(1.2, 5) = 1.20       → SUMA += 1.20
NADMIAR_after = 0

The ledger gains one assignment dated 2026-08-01 counting the session's header.

Design decisions

  • Activity definition for electionsdecided: the inherited live rule above; window and threshold are tunable, the formula is not re-opened.
  • Eligibility date basisdecided: session dates, not assignment dates — late awards never disenfranchise a player.
  • NADMIAR capdecided: unbounded; there is no cap and no config key for one.
  • Roundingdecided: grants as-is; award snapshots rounded to two decimals, fixed.
  • BRAK baselinesdeferred to import: left uninitialized; a Narrator sets STARTOWE explicitly (see Adoption).

See also