Stamina economy: running drains stamina, fatigue forces walk #178

Closed
opened 2026-07-24 01:21:19 +02:00 by panda · 3 comments
Collaborator

Follow-up to the run/walk gait feature: make running cost stamina so it isn't free, the way UO does.

Background

The gait feature ships run/walk as two speeds with no cost (run is strictly faster, walk is a deliberate-slow choice). Today Mobile.Stamina => Dex is a read-only stub ("not yet wired"). This issue turns stamina into a real drainable pool and gates running on it.

Scope (proposed — needs its own design review)

  • A real current/max stamina pool on Mobile (max from DEX), draining per run step and regenerating over time / while walking or standing (server-tick paced, like mana regen).
  • When stamina is exhausted the server forces walk regardless of the client's Running flag (server-authoritative; a modified client can't run on empty).
  • A one-time / throttled SystemMessageId cue ("too winded to run") — new catalog entry, no loose string.
  • Persist current stamina with the other vitals (per-entity serialize, version bump on PlayerMobile).
  • Decide the numbers (drain per step, regen rate, DEX→max curve) — a balancing pass.

Definition of Done (delta)

  • Sustained running depletes stamina and the player drops to walk at zero, server-side (unit-tested against World).
  • Stamina regenerates when not running; a fast/modified client cannot run while empty.
  • Current stamina round-trips through a save/load.

Parent/prereq: #177 (run/walk gaits).

Follow-up to the run/walk gait feature: make **running cost stamina** so it isn't free, the way UO does. ## Background The gait feature ships run/walk as two speeds with **no cost** (run is strictly faster, walk is a deliberate-slow choice). Today `Mobile.Stamina => Dex` is a read-only stub ("not yet wired"). This issue turns stamina into a real drainable pool and gates running on it. ## Scope (proposed — needs its own design review) - A real current/max **stamina pool** on `Mobile` (max from DEX), draining per **run** step and regenerating over time / while walking or standing (server-tick paced, like mana regen). - When stamina is exhausted the server **forces walk** regardless of the client's `Running` flag (server-authoritative; a modified client can't run on empty). - A one-time / throttled **`SystemMessageId`** cue ("too winded to run") — new catalog entry, no loose string. - Persist current stamina with the other vitals (per-entity serialize, version bump on `PlayerMobile`). - Decide the numbers (drain per step, regen rate, DEX→max curve) — a balancing pass. ## Definition of Done (delta) - Sustained running depletes stamina and the player **drops to walk** at zero, server-side (unit-tested against `World`). - Stamina regenerates when not running; a fast/modified client cannot run while empty. - Current stamina round-trips through a save/load. Parent/prereq: #177 (run/walk gaits).
Author
Collaborator

Agreed design (reviewed with critical-design-review)

Motivation from the owner: running through marsh should cost stamina — otherwise marsh is merely "slower" instead of a real decision. Stamina is what turns the run gait (#177/#180) and the marsh move-cost (#138) into a trade-off.

Blocked on sequencing: this sits on top of two unmerged branches — the run gait (#180) and the marsh move-cost (#138, on feat/biomes-art/#155). Terrain-scaled drain needs TerrainDef.MoveCost, gating the run needs the gait. Agreed order: merge #180 and #155 first, then build stamina on a clean main (an integration branch stacking all three would carry the 4 known conflicts and be redone at merge time anyway).

Design

  • Vital — promote the Mobile.Stamina => Dex stub to MaxStam => Dex, add a current Stam with an intrinsic clamped setter SetStam (mirrors SetHits/SetMana). State lives on the entity; no side collection.
  • Drain — only on run steps (walking is free), scaled by the step's terrain cost (max(cost(from), cost(into))), so a marsh run step drains more. Tracked in tenths (like skills) so marsh costs 1.6x rather than 2x through integer rounding.
  • Regen — 1 unit per StamRegenTicks, mirroring the existing mana regen (currentTick % n), and only when the player did not run this tick (sprinting never regenerates).
  • Exhaustion — at 0 the server applies the walk cooldown and skips the drain regardless of the client's Running flag. A hysteresis threshold (~15% of max) is required before running resumes, so the gait doesn't flip every regen tick.
  • Feedback (no HUD exists yet) — vitals are currently invisible: there is no wire message for Hits/Mana and no client HUD. MVP ships a throttled catalogued cue (new SystemMessageId, e.g. "too winded to run") when the server downgrades the gait, plus an optional client-derived "winded" hint (the client knows it requested a run and sees Running=false come back — no new wire field). The real fix, a vitals HUD, is a separate follow-up (it also finally surfaces HP/mana).
  • Tunables — StamRegenTicks, run drain per step, resume threshold: all GameOptions fields, default in one place.
  • Persistence — transient for the MVP (full stamina on login): no save-format change, and it regenerates in seconds. Revisit if it ever matters.

Invariants Check

  • Scope ✓ only the stamina vital + run gating; HUD explicitly out. Server-authoritative ✓ drain/exhaustion computed in the tick; at 0 the server forces walk ignoring the client's Running — a modified client can't run exhausted. GM auth ✓ /set stam reuses MobileStat + CanExecute. Identity N/A (no account/claim change). Protocol versioned ✓ the new SystemMessageId is a protocol enum → bump in the same change. String catalog ✓ cue is an id + catalog template, no literal. Single-threaded sim ✓ drain/regen run in the tick on the sim thread, no lock. World.cs HARD GATE ✓ no formula in World — drain lives in MovementSystem/a vitals component, World only delegates. Screen HARD GATE ✓ client side is just an existing SystemNotice path; nothing added to GameScreen. Client engine-independence N/A for the MVP (no new client logic beyond a catalog template, already in Client.Core and tested). Gameplay/Networking separation ✓ rule in Gameplay/, cue leaves via the existing notice path. Act on the instance ✓ Stam + SetStam on the entity, no per-id dictionary. Extend by type not switch ✓ stamina is a vital like the others; /set stam is one more MobileStat value, not a new type switch. Server-paced actions ✓ drain and regen advance per tick; a client can neither suppress the drain nor speed the regen. Persistence (GameServer) ✓ transient in the MVP → no format change; if persisted later it is one field + that entity's version bump. Persistence (Auth) N/A. Process separation N/A. Typed options ✓ the three tunables are GameOptions fields. Broadcasts/AoI ✓ no new observable event; the cue goes only to the affected player, positions stay absolute. Multi-platform ✓ pure .NET tick logic. Assets N/A (no new art). Asset naming N/A (no pack entries). ModernUO ✓ follow its stamina-gates-running model; diverge by not replicating weight/hunger-linked regen tables we don't have. Docs & DoD ✓ architecture movement note + the tunables + this DoD.

No ✗, no HARD GATE tripped.

Verification plan

  • Unit against World — sustained running drains stamina and the player drops to walk at zero; with Running=true and 0 stamina the step uses the walk cooldown (the trust-boundary case); regen only rises when not running; hysteresis prevents per-tick gait flapping.
  • Marsh — a run step through marsh drains more than one on grass for the same number of steps, and a marsh crossing exhausts stamina sooner than the same distance on grass.
  • Catalog — the new SystemMessageId has a template (the existing completeness test enforces it).
  • Harness — timed grass vs marsh run to exhaustion, showing the downgrade to walking. Screenshot: only the journal cue — stamina itself is invisible until the HUD lands (declared gap).

Definition of Done (delta)

  • Sustained running depletes stamina and the player drops to walk at zero, server-side (unit-tested against World).
  • A run step through marsh drains measurably more stamina than one on open ground.
  • A modified/fast client cannot run while exhausted (server applies the walk cooldown regardless of the Running flag).
  • Stamina regenerates when not running; the resume threshold prevents walk/run flapping at zero.
  • The "winded" cue is a catalogued SystemMessageId (no loose string) and is throttled so held movement can't flood the journal.
## Agreed design (reviewed with `critical-design-review`) Motivation from the owner: **running through marsh should cost stamina** — otherwise marsh is merely "slower" instead of a real decision. Stamina is what turns the run gait (#177/#180) and the marsh move-cost (#138) into a trade-off. **Blocked on sequencing:** this sits on top of *two* unmerged branches — the run gait (#180) and the marsh move-cost (#138, on `feat/biomes-art`/#155). Terrain-scaled drain needs `TerrainDef.MoveCost`, gating the run needs the gait. Agreed order: **merge #180 and #155 first, then build stamina on a clean `main`** (an integration branch stacking all three would carry the 4 known conflicts and be redone at merge time anyway). ### Design - **Vital** — promote the `Mobile.Stamina => Dex` stub to `MaxStam => Dex`, add a current `Stam` with an intrinsic clamped setter `SetStam` (mirrors `SetHits`/`SetMana`). State lives on the entity; no side collection. - **Drain** — only on **run** steps (walking is free), scaled by the step's terrain cost (`max(cost(from), cost(into))`), so a marsh run step drains more. Tracked in **tenths** (like skills) so marsh costs 1.6x rather than 2x through integer rounding. - **Regen** — 1 unit per `StamRegenTicks`, mirroring the existing mana regen (`currentTick % n`), and **only when the player did not run this tick** (sprinting never regenerates). - **Exhaustion** — at 0 the server applies the **walk** cooldown and skips the drain regardless of the client's `Running` flag. A **hysteresis threshold** (~15% of max) is required before running resumes, so the gait doesn't flip every regen tick. - **Feedback (no HUD exists yet)** — vitals are currently invisible: there is **no wire message for Hits/Mana and no client HUD**. MVP ships a throttled catalogued cue (new `SystemMessageId`, e.g. "too winded to run") when the server downgrades the gait, plus an optional client-derived "winded" hint (the client knows it requested a run and sees `Running=false` come back — no new wire field). The real fix, a vitals HUD, is a **separate follow-up** (it also finally surfaces HP/mana). - **Tunables** — `StamRegenTicks`, run drain per step, resume threshold: all `GameOptions` fields, default in one place. - **Persistence** — **transient** for the MVP (full stamina on login): no save-format change, and it regenerates in seconds. Revisit if it ever matters. ### Invariants Check - **Scope** ✓ only the stamina vital + run gating; HUD explicitly out. **Server-authoritative** ✓ drain/exhaustion computed in the tick; at 0 the server forces walk *ignoring* the client's `Running` — a modified client can't run exhausted. **GM auth** ✓ `/set stam` reuses `MobileStat` + `CanExecute`. **Identity** N/A (no account/claim change). **Protocol versioned** ✓ the new `SystemMessageId` is a protocol enum → bump in the same change. **String catalog** ✓ cue is an id + catalog template, no literal. **Single-threaded sim** ✓ drain/regen run in the tick on the sim thread, no lock. **`World.cs` HARD GATE** ✓ no formula in `World` — drain lives in `MovementSystem`/a vitals component, `World` only delegates. **`Screen` HARD GATE** ✓ client side is just an existing `SystemNotice` path; nothing added to `GameScreen`. **Client engine-independence** N/A for the MVP (no new client logic beyond a catalog template, already in Client.Core and tested). **Gameplay/Networking separation** ✓ rule in `Gameplay/`, cue leaves via the existing notice path. **Act on the instance** ✓ `Stam` + `SetStam` on the entity, no per-id dictionary. **Extend by type not switch** ✓ stamina is a vital like the others; `/set stam` is one more `MobileStat` value, not a new type switch. **Server-paced actions** ✓ drain and regen advance per tick; a client can neither suppress the drain nor speed the regen. **Persistence (GameServer)** ✓ transient in the MVP → no format change; if persisted later it is one field + that entity's version bump. **Persistence (Auth)** N/A. **Process separation** N/A. **Typed options** ✓ the three tunables are `GameOptions` fields. **Broadcasts/AoI** ✓ no new observable event; the cue goes only to the affected player, positions stay absolute. **Multi-platform** ✓ pure .NET tick logic. **Assets** N/A (no new art). **Asset naming** N/A (no pack entries). **ModernUO** ✓ follow its stamina-gates-running model; diverge by not replicating weight/hunger-linked regen tables we don't have. **Docs & DoD** ✓ architecture movement note + the tunables + this DoD. No `✗`, no HARD GATE tripped. ### Verification plan - **Unit against `World`** — sustained running drains stamina and the player **drops to walk** at zero; with `Running=true` and 0 stamina the step uses the *walk* cooldown (the trust-boundary case); regen only rises when not running; hysteresis prevents per-tick gait flapping. - **Marsh** — a run step through marsh drains **more** than one on grass for the same number of steps, and a marsh crossing exhausts stamina sooner than the same distance on grass. - **Catalog** — the new `SystemMessageId` has a template (the existing completeness test enforces it). - **Harness** — timed grass vs marsh run to exhaustion, showing the downgrade to walking. **Screenshot**: only the journal cue — stamina itself is invisible until the HUD lands (declared gap). ## Definition of Done (delta) - Sustained running depletes stamina and the player drops to walk at zero, server-side (unit-tested against `World`). - A run step **through marsh** drains measurably more stamina than one on open ground. - A modified/fast client **cannot** run while exhausted (server applies the walk cooldown regardless of the `Running` flag). - Stamina regenerates when not running; the resume threshold prevents walk/run flapping at zero. - The "winded" cue is a catalogued `SystemMessageId` (no loose string) and is throttled so held movement can't flood the journal.
Author
Collaborator

Vitals HUD (the visibility gap this design leans on) tracked separately in #181.

Vitals HUD (the visibility gap this design leans on) tracked separately in #181.
Author
Collaborator

Marco aiutami invece di farti le unghie a tema pokemon 💅

Risolta: stamina economy (run drena, esaurimento forza a camminare) su main (#185).

Marco aiutami invece di farti le unghie a tema pokemon 💅 Risolta: stamina economy (run drena, esaurimento forza a camminare) su main (#185).
panda closed this issue 2026-08-02 01:04:19 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
marco/IsoMmo#178
No description provided.