Skip to content

Needs Framework

One generic model for every ambient per-character resource that drains passively and is restored by discrete events — hunger today, thirst/temperature/disease later. The original built each of its two real needs (hunger, stamina) as a separate plugin with its own timer and its own hand-rolled arithmetic; this document is the config-driven replacement for the half of that shape which actually generalises. See Legacy/08_NEEDS_AND_STATE.md for the full legacy trace.


1. What a need is

A need is a per-character value between 0 and a configured maximum that decays passively over time and is restored by discrete events.

Legacy/08_NEEDS_AND_STATE.md §6 names this shape directly: "a single value on a fixed tick" plus "consequences that scale continuously" — but the consequences (damage, speed scaling) belong to whichever system reacts to a need's value, not to the framework itself. This document is deliberately narrow: it owns the value and its decay/restore, nothing else.

Polarity is inverted from the legacy source, deliberately. The original's _Hunger.amount ran 0 (fine) → 100 (starving) — "worth flagging because every instinct says otherwise" (Legacy/08_NEEDS_AND_STATE.md §2, which itself recommends the fix). A need's value here runs Max (satisfied) → 0 (empty/starving), a satiation value, not a deprivation one.

namespace Applejack.Needs;

/// <summary>
/// One configured need -- content, not code. See Code/Needs/README.md.
/// </summary>
public sealed class NeedDefinition
{
    /// <summary>Stable id a caller names a need by, e.g. "hunger". Matched against
    /// Edible.NeedId (Code/Items/Edible.cs).</summary>
    [Property] public string Id { get; set; } = "";

    [Property] public string DisplayName { get; set; } = "";

    /// <summary>The satisfied value a character starts at and can be restored up to.</summary>
    [Property] public float Max { get; set; } = 100f;

    /// <summary>Starting value for a character with no saved value yet. Almost always
    /// <see cref="Max"/> -- a new character starts satisfied, not starving.</summary>
    [Property] public float Initial { get; set; } = 100f;

    /// <summary>Seconds to passively drain from <see cref="Max"/> to <c>0</c> with no
    /// restoration. Legacy: "Hunger Minutes" (`sh_config.lua`), scaled to seconds -- see
    /// Legacy/08_NEEDS_AND_STATE.md §2.</summary>
    [Property] public float DepletionSeconds { get; set; } = 1800f;
}

2. Configuration, not code

Per 05_CONFIGURATION.md, the operator-facing surface is a NeedsConfiguration GameResource holding List<NeedDefinition> Definitions. Adding a need means an operator (or this project) adds another NeedDefinition entry — no new C#, exactly the promise Legacy/08_NEEDS_AND_STATE.md §6 makes: "Thirst, temperature and disease should be configurations of it, not new code." Milestone 12 exercises that promise directly: NeedsConfiguration's seeded list grew from one entry to four with no change to NeedDefinition, INeedsService, NeedsStore, CharacterNeedsComponent, or NeedsBar.razor — see §2.1.

2.1 The four seeded needs

Unlike Hunger, none of Thirst/Temperature/Disease have a legacy implementation to preserve defaults from (Legacy/08_NEEDS_AND_STATE.md §6: all three listed "Absent"), so their DepletionSeconds are this project's own judgement call, reasoned out here rather than guessed silently:

Need Id DepletionSeconds Reasoning
Hunger hunger 1800 (30 min) Legacy Hunger Minutes default, preserved exactly (§1 above).
Thirst thirst 1200 (20 min) Faster than Hunger — real dehydration outpaces starvation by a much wider margin (days vs. weeks), but a literal ratio would make Thirst nag far more often than is fun on this game's already-compressed timescale. Two-thirds of Hunger's window keeps thirst reading as "the more urgent one" without dominating the HUD.
Temperature temperature 2400 (40 min) Slower than Hunger. There is no weather or indoor/outdoor detection yet (deliberately deferred, §6) to drive a real exposure rate, so this is a flat placeholder decay standing in for "ambient exposure in the absence of any actual environment simulation." Deliberately the slowest of the three new needs so an un-modelled mechanic doesn't out-pressure the two that are fully real (eating, drinking) — once weather exists, this rate should become an input the environment scales, not a fixed constant.
Disease disease 3600 (60 min) Slowest of all four, for the same reason as Temperature but more so: there is no transmission/infection mechanic yet (deferred to Milestone 15, per ROADMAP.md's own sequencing note that Medical lands "after Milestone 12 so Thirst/Disease exist as real needs axes to attach injury/illness severity to"). Passive decay here is a stand-in for "general wellness drifting without care," not a modelled illness — the hour-long window keeps it a near-non-issue until Medical's real infection-driven decay replaces this flat rate.

Max/Initial are 100 for all four, matching Hunger — no reasoning to diverge; every need starts satisfied and can be restored back up to the same ceiling.

Real consumers, landed vs. deferred. Per this milestone's own scope (ROADMAP.md Milestone 12), a NeedDefinition with zero real consumer is exactly the "dead configuration" this project avoids landing:

  • Thirst — the 8 Drinks/*.item assets (Assets/Items/Drinks/) were, before this milestone, Edible behaviours restoring hunger — a content-migration artifact from when Thirst didn't exist yet (legacy itself did this: Legacy/08_NEEDS_AND_STATE.md §6, "Drinks derive from food and restore hunger"). Retargeted to NeedId: "thirst" here — a drink quenching thirst rather than hunger is both the obviously correct semantics and a real, already-existing, zero-new-code consumer. ItemDefinition.CreateUseInteraction fires only the first behaviour that offers one (Code/Items/ItemDefinition.cs's own doc comment), so a drink cannot restore both Hunger and Thirst through a single Edible list without the second entry being genuinely dead config — this project chose real Thirst semantics over keeping the (already slightly fictional) Hunger restoration on drinks.
  • Temperature — no consumer wired. Nothing among the 116 migrated items (Assets/Items/) is clothing, a heat source, or anything else that plausibly restores warmth; inventing one purely to satisfy a checkbox would be new content with no legacy precedent and no real mechanic (no equip-driven warmth, no shelter/fire entity) behind it yet. Deferred, named here rather than silently skipped.
  • Disease — no consumer wired. Assets/Items/Drugs/ (health_kit, health_vial, paracetamol, steroids, nitrazepam, stimulants) look like an obvious fit at a glance, but every one of them is semantically Health, Stamina, or Crime's knockout/wake primitives (Assets/Items/README.md's own disposition table), not illness — forcing one to restore disease would be a category error, not a real consumer. Medical (Milestone 15) is where Disease's real restore-side content belongs, per the same sequencing note cited above.

Deviation from 05_CONFIGURATION.md's own illustrative sample, fixed here and there. That document's worked example for this exact module used HungerFillSeconds/ HungerDamagePerSecond as bespoke top-level fields under the legacy (non-inverted) polarity. Both are wrong for what actually got built: FillSeconds implied filling up toward starving; renamed to DepletionSeconds to match the inverted satiation polarity above. And HungerDamagePerSecond is dropped entirely — there is no Health service in this codebase yet to apply damage to, and a config field nothing reads is exactly the "magic number/dead configuration" smell Standards/00_CODING_STANDARDS.md argues against. Both are corrected in 05_CONFIGURATION.md's sample in the same commit that built the real thing, matching this project's standing practice for illustrative-doc drift.

NeedsConfiguration.PostLoad validates every NeedDefinition: non-empty Id, no duplicate Id across the list, Max > 0, DepletionSeconds > 0 — the same named-load-failure shape ItemDefinition.PostLoad already uses for its own list of behaviours.

3. The service

INeedsService mirrors IInteractionRunner (10_INTERACTION_FRAMEWORK.md §2), not IInventoryService: a need is ambient per-character state advanced every frame, exactly what IInteractionRunner already is — a single registered singleton, logically scoped per character by CharacterId, ticked once per frame per character by that character's own component so a shared service stays correct without double-ticking.

namespace Applejack.Needs;

public interface INeedsService
{
    /// <summary>Loads a character's need values into memory, seeding every configured
    /// NeedDefinition at its Initial value if none has ever been saved. Must be called
    /// before GetValue/Tick/RestoreAsync are trusted -- same requirement as
    /// IEconomyService.LoadOrCreateAsync.</summary>
    Task<Result> LoadOrCreateAsync(CharacterId characterId);

    /// <summary>The character's current value for needId, or 0 if unloaded or the id isn't
    /// configured.</summary>
    float GetValue(CharacterId characterId, string needId);

    /// <summary>Decays every configured need for characterId by its own
    /// DepletionSeconds-derived rate, clamped at 0. In-memory only -- see §4 for why this
    /// never persists.</summary>
    void Tick(CharacterId characterId, TimeSpan elapsed);

    /// <summary>Adds amount to needId, clamped at that need's Max, and persists immediately.
    /// A no-op, logged, for an unrecognised needId -- a content-author typo in
    /// Edible.NeedId should not crash the interaction it came from.</summary>
    Task<Result> RestoreAsync(CharacterId characterId, string needId, float amount);
}

NeedsStore, the one implementation, follows EconomyStore's exact in-memory-dictionary-plus- persist-on-write shape (Code/Economy/EconomyStore.cs) — a Dictionary<CharacterId, Dictionary<string, float>> is the source of truth while a character is active, since every read here (GetValue) is documented synchronous, matching GetBalance's own reasoning.

4. Tick persists nothing; only Restore does

Decaying every configured need every frame, for every active character, and writing that to the persistence backend on every single frame would be write-amplification no other ambient system in this codebase performs — InteractionComponent.Ticks Progress/IsBusy in memory only too. RestoreAsync is a discrete, meaningful event (eating) and persists immediately, exactly like EconomyStore.CreditAsync/DebitAsync.

The consequence: decay accumulated between the last RestoreAsync and a server stop is lost on restart. This is the same already-accepted class of gap Code/Core/Modules/ApplejackBootstrap.cs's own header comment flags for the still-missing shutdown/disconnect hook — nothing in this codebase saves on disconnect yet. Noted here, not solved speculatively; whichever milestone item adds a real shutdown hook fixes this framework's persistence gap along with everyone else's.

5. Replication

CharacterNeedsComponent follows CharacterEconomyComponent/CharacterInventoryComponent's poll-and-assign shape exactly (11_UI_ARCHITECTURE.md §1): resolves INeedsService in OnAwake; OnUpdate reads the sibling CharacterNetworkComponent for identity, lazily LoadOrCreateAsyncs once, then calls Tick(characterId, Time.Delta) every frame and assigns a [Sync(SyncFlags.FromHost)] List<NeedValueView> snapshot — NeedValueView(string NeedId, string DisplayName, float Value, float Max), a plain record struct, the same engine-agnostic-snapshot shape as InventorySlotView (Code/Inventories/InventorySlotView.cs). NeedsBar.razor binds to that component and is registered against IHudRegistry at HudSlot.TopRight (Ui/IHudRegistry.cs), one text line per entry — no bar graphics; the same "text stands in for a real widget, unverified without a compiler" caveat every HUD panel in this codebase already carries.

6. What this framework deliberately does not do

Named here so it doesn't quietly disappear the way the Interaction-framework's own RPC gap almost did (tasks.md #34):

  • Temperature's environmental-source mechanic (weather, indoor/outdoor detection) and Disease's transmission/infection mechanic (proximity-based spread). Both needs land with the same passive-decay-plus-discrete-restore shape every need gets (§2.1), but that decay is presently a flat, mechanic-less placeholder for each — the real drivers are separately-scoped features this codebase doesn't have yet. Landing the need itself now, ahead of the mechanic that should really drive it, is the same order Hunger itself already set: the value existed before Edible.OnComplete could restore it.
  • Damage while starving, the vehicle-double-damage penalty, and the suicide/respawn anti-exploit (Legacy/08_NEEDS_AND_STATE.md §2) all need a Health service that doesn't exist in this codebase yet.
  • Stamina does not fit this framework's model at all: its legacy drain is event-driven (sprinting) with a resting restore, not a passive decay toward empty, and its real consequence (speed interpolation) needs a Movement system that doesn't exist yet. Adding it as a NeedDefinition now would mean fields nothing reads — deferred until whatever builds Movement can give it real semantics.
  • The six physical states (Incapacitated, Exhausted, Knocked out, Tied, Arrested, Sleeping — Legacy/08_NEEDS_AND_STATE.md §5) belong to Crime/Law's mechanisms (Legacy/13_DEPENDENCY_GRAPH.md §4), not to this framework, and are out of scope here.

7. Testing

NeedDefinition/NeedsConfiguration are plain/GameResource types with no engine dependency in their validation logic, per Standards/03_TESTING_STANDARDS.md §1 — constructed and validated directly, no asset file needed. NeedsStore is tested against the shared InMemoryPersistenceBackend fixture, following EconomyStoreTests.cs's exact setup. CharacterNeedsComponent/NeedsBar.razor are not unit-tested, for the same reason no other Component/PanelComponent in this codebase is — see Standards/03_TESTING_STANDARDS.md §1.