ZOA Quantities
One named ledger for every long-typed value a game tracks: renown, reputation, karma, insight, notoriety.
Most games end up with half a dozen systems that all do the same thing: store a number per entity, clamp it, raise an event when it moves, and serialise it. Reputation is one, renown is another, and karma, insight, notoriety and faction standing are all the same shape. Quantities extracts that shape once. A quantity is a `long` attached to an entity id, described by a definition that supplies an initial value, optional clamp bounds, and display metadata.
The relationship to `com.zoa.economy` is the question worth answering first, because the two look similar and are not the same thing. Economy is commerce: currencies, vendors, prices, stock, barter, and the rules of a transaction. Quantities is narrative and social state: values the world reacts to rather than values the player spends. Either can drive faction reactions, vendor stock, quest gating, or a HUD readout, and a game can adopt one without the other.
There is one connection between them, and it runs in a single direction. Since the F107 consolidation, `CurrencyService` stores its balances in an `IQuantityLedger` rather than a dictionary of its own, namespaced under `currency.<id>`. Quantities does not know that economy exists. It is the lower primitive, and economy is one of its consumers, which is why installing a shared ledger in a scene collapses currency, narrative values, and anything else on the ledger into a single storage model and a single save section.
Depends on (2)
Depended on by (1)
How it works
Concepts
Registration first, then reads and writes
A quantity has to be registered before any entity can hold a value for it. `Register(IQuantityDefinition)` is idempotent for the same instance and replaces the schema when a different definition arrives under the same id, in which case the new clamps apply from the next mutation onward and stored values are not retroactively re-clamped.
`Get`, `Add`, and `Set` throw `ArgumentException` on an unregistered id. A typo'd quantity id is an authoring bug, and silently writing into a phantom slot is how that bug survives to ship. `TryGet` is the non-throwing variant for binders that poll for a quantity which may legitimately be absent.
Reads on an untouched slot are not zero by default: they return the definition's `InitialValue`. Starting reputation therefore falls out of authoring with no initialisation pass, and an entity that has never interacted with a faction already reads at the authored neutral point.
Why the value type is long
Reputation is small and signed, often somewhere in a band like minus one thousand to one thousand. Renown is unsigned and accumulates over a long session. Currency-like proxy values can get very large. One type has to cover all three.
`int` overflows for the accumulating cases in a long session. `float` introduces rounding drift when you sum thousands of small deltas, which is exactly the access pattern a reputation system has. `long` is exact, wide enough for every case in view, and it serialises cleanly.
Clamping is per definition rather than global. `Min` and `Max` apply only when `Clamp` is true, so a bounded reputation and an unbounded score can live in the same ledger without one imposing its rules on the other. `Add` and `Set` both return the post-clamp value, so a caller learns what actually landed rather than what it asked for.
Change events are the integration point
`Changed` fires after every successful mutation with `(entityId, id, oldValue, newValue)`. Both endpoints are carried: a HUD animates the transition between them, and a faction system can detect a threshold crossing without keeping its own shadow copy of the previous value.
No-op mutations stay silent. `Add` with a delta of zero, or an `Add` that clamps back to the same value, returns the current value without writing and without raising, so subscribers never see spurious callbacks and a threshold handler cannot fire twice on a value that did not move.
The ledger is not thread-safe, matching the rest of the ZOA runtime services: all gameplay mutation happens on the main thread. It is a pure data layer with no per-frame work and no pooling, so the cost is one dictionary lookup per access.
Touched versus registered, and why save cares
`Registered` lists every quantity the ledger knows about. `GetTouched(entityId)` lists only the ones that entity has actually mutated. The save format is built on that distinction: an untouched slot's value is already recoverable from its definition, so writing it would be redundant.
`GetAllTouched` returns every `(entityId, id, value)` triple across the ledger in one allocation, so a save adapter never has to walk every known entity. Both enumerations snapshot into a fresh list rather than exposing internal storage, because a subscriber calling `Add` inside a `Changed` handler while a save walks the ledger does happen.
Ordering is stable within one snapshot but otherwise implementation-defined, so adapters should not diff on position.
One save section for every scalar system
`QuantityLedgerSaveAdapter` implements the persistence package's package-owner contract under the id `com.zoa.quantities` and writes a schema-versioned payload. Capture snapshots `GetAllTouched` into a flat list of entries. Restore parses that payload and calls `Set` on each one, so clamping and `Changed` events fire exactly as they would for a live mutation.
The payload is a flat `List<Entry>` rather than a dictionary because it is serialised with Unity's `JsonUtility`, which handles neither dictionaries nor tuples, and which matches the string payload shape the persistence package already uses for every other section.
Restore is forgiving in one direction. An entry whose quantity id is no longer registered, because the definition asset was deleted since the save was written, is logged and skipped rather than aborting the restore, so one removed quantity cannot cost the player everything else in the section.
Because economy, progression, and objective progress all sit on the ledger after F107, they all flow through this one adapter instead of three parallel ones.
Installation order matters
`QuantityLedgerComponent` runs at execution order -9500, which is ahead of the -9300 tier the economy and other scene installers use. The ledger has to exist and be registered before any consumer's Awake tries to resolve it; otherwise that consumer allocates a private ledger and the storage never gets shared.
On Awake the component constructs a ledger, registers every definition wired into its inspector list, optionally scans a Resources path for more, registers the ledger against `FoundryServiceRegistry` as `IQuantityLedger`, and, when a save ownership registry is present, attaches the save adapter. When no persistence registry is registered the save step is a silent no-op, so a scene without saving works unchanged.
The registry-facing lifecycle is asymmetric. The ledger registration is left in place on destroy, because a scene rebuild registers a fresh instance and the old one simply becomes unreferenced. Save ownership is reversible and is unregistered, so the next scene's component can claim the same package id without the registry throwing on a duplicate key.
In the editor
Screens
Screenshot pending
/screenshots/quantities-definition-inspector.png
The Inspector for a single quantity asset with a dotted id such as rep.faction.cult_seekers, a display name, a category of reputation, an initial value of zero, Clamp on with symmetrical Min and Max bounds, and an icon glyph filled in.
Screenshot pending
/screenshots/quantities-ledger-component.png
The Inspector for the component showing three or four QuantityDefinitionAssets in the definitions array, an empty Resources scan path, and Auto Register Save Adapter ticked, with the GameObject visible in the hierarchy alongside other ZOA scene installers.
Setup
Workflow
- 01
Author the quantities you track
Create a QuantityDefinitionAsset per named value from Assets > Create > ZOA > Quantities > Quantity Definition. Give each a stable dotted id such as renown.cosmichorror or rep.faction.cult_seekers, an initial value, and clamp bounds if the value is bounded. Set Category to something conventional (renown, reputation, karma, insight, notoriety) so a HUD can group or filter on it.
- 02
Install the ledger early
Add a QuantityLedgerComponent to a scene-bootstrap object and drop the definitions into its inspector list; the list order is the order the ledger's Registered list preserves, which a per-quantity HUD row iterates. Its -9500 execution order puts it ahead of the -9300 installers, so consumers like the economy installer can find and share it.
- 03
Ship demo content without wiring
Set the Resources scan path to auto-register every QuantityDefinitionAsset under a Resources folder. That is the route sample content takes when there is no scene to wire it into. Leave it empty to disable the scan.
- 04
Read and write from gameplay
Resolve IQuantityLedger from FoundryServiceRegistry and call Add with a signed delta, or Set for an absolute value. Both return the post-clamp result. Use the entity id your project already uses elsewhere, such as player.local, so currency, quantities, and save all key the same way.
- 05
React to change rather than polling
Subscribe to Changed and branch on the old and new values together. Threshold crossings, faction reactions, and HUD animations all want that pair, and the event does not fire for mutations that did not move the value.
- 06
Let persistence handle itself
Leave Auto Register Save Adapter on. With a save ownership registry present, the ledger lands in the save blob under com.zoa.quantities alongside every other package's section; with none, the step is a silent no-op.
Surface
Key types
IQuantityLedger
interface
The service: register schemas, read and mutate per-entity values, enumerate what has been touched, and observe every change. The whole package is this interface plus one implementation.
- void Register(IQuantityDefinition definition)
- bool IsRegistered(QuantityId id)
- IReadOnlyList<QuantityId> Registered { get; }
- long Get(string entityId, QuantityId id)
- bool TryGet(string entityId, QuantityId id, out long value)
- long Add(string entityId, QuantityId id, long delta)
- long Set(string entityId, QuantityId id, long value)
- IReadOnlyList<QuantityId> GetTouched(string entityId)
- IReadOnlyList<(string EntityId, QuantityId Id, long Value)> GetAllTouched()
- event Action<string, QuantityId, long, long> Changed
QuantityLedger
class
The in-memory implementation: a nested dictionary keyed by entity id then quantity id. Plain C# with no engine reference, so it constructs in a NUnit test. Not thread-safe.
IQuantityDefinition
interface
The schema for one quantity. The ledger reads this to seed initial values, apply clamps, and expose display metadata, and never imports the Unity-layer concrete type.
- QuantityId Id { get; }
- string DisplayName { get; }
- string Category { get; }
- long InitialValue { get; }
- long Min { get; }
- long Max { get; }
- bool Clamp { get; }
- string IconGlyph { get; }
QuantityId
struct
Trimmed, case-insensitive handle for a named quantity, with implicit conversion to and from string. Follows the same value-type pattern as SkillId and AbilityId elsewhere in the stack.
- string Value
- bool IsEmpty
- static implicit operator QuantityId(string value)
- static implicit operator string(QuantityId id)
QuantityLedgerComponent
component
The scene installer, at execution order -9500 so it lands before every consumer. Builds a ledger, registers the definitions wired to it, publishes it to the service registry, and attaches the save adapter when persistence is present.
- IQuantityLedger Ledger { get; }
- void RegisterAdditional(IReadOnlyList<QuantityDefinitionAsset> additionalDefinitions)
QuantityLedgerSaveAdapter
class
The persistence boundary. Captures every touched triple into a JsonUtility-friendly payload and restores through Set so clamping and change events behave identically to live play.
- const string CanonicalPackageId = "com.zoa.quantities"
- const int Schema = 1
- string CaptureState()
- bool RestoreState(string payloadJson)
- static QuantityLedgerSaveAdapter RegisterWith(ISaveOwnershipRegistry registry, IQuantityLedger ledger)
QuantityLedgerSavePayload
class
The serialised shape: a schema version plus a flat list of entity id, quantity id, and value entries. Flat because JsonUtility handles neither dictionaries nor tuples.
- int schemaVersion
- List<Entry> entries
Surface
Authoring assets
QuantityDefinitionAsset
asset
The authoring asset, created from Assets > Create > ZOA > Quantities > Quantity Definition. One per named quantity the game tracks. Implements IQuantityDefinition directly, so the asset itself is what gets registered with the ledger, with no conversion step.
- QuantityId Id
- string DisplayName
- string Description
- string Category
- long InitialValue
- long Min
- long Max
- bool Clamp
- string IconGlyph
Usage
Examples
using ZOA.Messaging;
using ZOA.Quantities.Core.Contracts;
using ZOA.Quantities.Core.Models;
var ledger = FoundryServiceRegistry.Get<IQuantityLedger>();
var standing = new QuantityId("rep.faction.cult_seekers");
// Untouched slots read the definition's InitialValue, so a
// neutral starting standing needs no init pass.
long before = ledger.Get("player.local", standing);
// Add returns the value after clamping, not the raw sum.
long after = ledger.Add("player.local", standing, -150);
if (before >= 0 && after < 0)
Debug.Log("The Seekers have turned hostile.");using System;
using UnityEngine;
using ZOA.Messaging;
using ZOA.Quantities.Core.Contracts;
using ZOA.Quantities.Core.Models;
public sealed class FactionReactionWatcher : MonoBehaviour
{
private static readonly QuantityId Renown = new QuantityId("renown.cosmichorror");
private const long AwakenedThreshold = 500;
private IQuantityLedger _ledger;
private void OnEnable()
{
if (FoundryServiceRegistry.TryResolve<IQuantityLedger>(out _ledger))
_ledger.Changed += OnQuantityChanged;
}
private void OnDisable()
{
if (_ledger != null) _ledger.Changed -= OnQuantityChanged;
}
// Both endpoints arrive together, so a crossing is detectable
// without keeping a shadow copy of the previous value.
private void OnQuantityChanged(string entityId, QuantityId id, long oldValue, long newValue)
{
if (id != Renown) return;
if (oldValue < AwakenedThreshold && newValue >= AwakenedThreshold)
Debug.Log(entityId + " has drawn something's attention.");
}
}using ZOA.Quantities.Core.Contracts;
using ZOA.Quantities.Core.Models;
using ZOA.Quantities.Core.Runtime;
// The core layer has no engine reference, so this runs under
// plain NUnit with no scene and no play mode.
// Any IQuantityDefinition will do; the asset is only the
// authored flavour of the same interface.
public sealed class KarmaSchema : IQuantityDefinition
{
public QuantityId Id => new QuantityId("karma");
public string DisplayName => "Karma";
public string Description => "";
public string Category => "karma";
public long InitialValue => 0;
public long Min => -100;
public long Max => 100;
public bool Clamp => true;
public string IconGlyph => "";
}
var ledger = new QuantityLedger();
ledger.Register(new KarmaSchema());
var karma = new QuantityId("karma");
ledger.Set("npc.merchant", karma, 90);
// Clamped definitions return what actually landed.
long landed = ledger.Add("npc.merchant", karma, 50); // clamps to 100
// TryGet never throws, which is what a binder polling a
// possibly-absent quantity wants.
if (ledger.TryGet("npc.merchant", new QuantityId("insight"), out long insight))
Debug.Log("Insight: " + insight);using ZOA.Quantities.Core.Contracts;
using ZOA.Quantities.Core.Models;
// One call rather than walking every known entity. Only touched
// slots come back: untouched values are already recoverable
// from their definitions.
foreach (var (entityId, id, value) in ledger.GetAllTouched())
Debug.Log(entityId + " / " + id.Value + " = " + value);
// What the shipped adapter does on load: Set rather than a raw
// write, so clamps apply and Changed fires as it would in play.
ledger.Set("player.local", new QuantityId("karma"), 42);Read this
Notes and caveats
See also