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:
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):
Each character name in a @PU line is resolved to a canonical Postać via
name resolution — with 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
@PUthat 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 + RawMonthlyuntil the reserve empties — the legacy overflow ledger showsVelrose (Kłak)carrying4.10then4.40across 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:
- passes the single write gate (
Assert-NerthusWriteAllowed, see API); - writes the new
pu_suma/pu_nadmiar/pu_zdobytesnapshots for every awarded Postać intonerthus.entities.md; - writes one audit line per mutated Postać (
op: pu.award, entity, fieldpu_suma, old/new, the counted session headers, the batch month) — this is what the per-entity history view (Get-NerthusEntityHistory) reads; - 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 echonerthus.ledger.md(- PU ### <header> (rozliczono <date>)— file inventory in Architecture); - fires the
AfterPuAwardedhook: one batch-level audit line (batch month + character count), thepu:awardedevent, and — whenintegrations.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)
Σ ZDOBYTEsums the storedpu_zdobytesnapshots over all Postać entities whose@należy_dois the player — no further qualifier.- The divisor, bonus, and floor are the
pu.new_character.{divisor,bonus,floor}constants. The20floor is why so many archive rows readSTARTOWE: 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 carryingAssignmentDate,Character,Granted,CountedSessions, and the exactSessionsheaders:
{ "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ć. +1per 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
@PUname 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 elections — decided: the inherited live rule above; window and threshold are tunable, the formula is not re-opened.
- Eligibility date basis — decided: session dates, not assignment dates — late awards never disenfranchise a player.
- NADMIAR cap — decided: unbounded; there is no cap and no config key for one.
- Rounding — decided: grants as-is; award snapshots rounded to two decimals, fixed.
BRAKbaselines — deferred to import: left uninitialized; a Narrator setsSTARTOWEexplicitly (see Adoption).
See also¶
- PU overview — the plain operator introduction
- Sessions model — the session header key, session metadata, distribution
- Players & Characters — Gracz/Postać ownership, the durable roster sections
- Roles & permissions — capabilities,
WYBORYgovernance context - Logs model — audit streams, the PU Discord notification
- Configuration — the
puconfig block, keys and defaults - Adoption — recovering the legacy PU ledger
- Architecture — the daemon, hooks, write gate
- API — routes, envelopes, jobs, dry-run