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/*.itemassets (Assets/Items/Drinks/) were, before this milestone,Ediblebehaviours restoringhunger— a content-migration artifact from when Thirst didn't exist yet (legacy itself did this:Legacy/08_NEEDS_AND_STATE.md§6, "Drinks derive fromfoodand restore hunger"). Retargeted toNeedId: "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.CreateUseInteractionfires 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 singleEdiblelist 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 restorediseasewould 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.OnCompletecould 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
NeedDefinitionnow 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.