Skip to content

Turn Regeneration

Purpose

Turns are the action economy. The turn pool refills continuously at a base rate modulated by ARIA bonus and capped at a per-player maximum. The system is designed to feel "always on" without requiring a background scheduler — instead it runs lazily the next time the player's turn pool is read or spent. This eliminates a class of cron/lock failures and guarantees the pool reflects real elapsed time.

Inputs

The system reads: - Player.turns — current pool, integer. - Player.max_turns — cap, default 1,000, augmented by rank bonus. - Player.last_turn_regeneration — anchor timestamp for elapsed-time math; falls back to Player.created_at for never-played accounts. - Player.aria_bonus_multiplier — float in [1.0, 1.5] derived from ARIA consciousness level. - Player.military_rank — used to compute max_turns_bonus (Recruit = 0, Fleet Admiral = +120). - The current wall-clock time (now).

The system fires on: - Lazy trigger — every player-side action that reads or spends turns (movement, combat, genesis deployment, trade dock/undock). Implemented via MovementService._regenerate_turns(player) and equivalent helpers, called inside the row-locked transaction. - On-demand read — any API endpoint that returns the player's turns value re-applies regen first so the value is always current. - Authoritative push — successful regen emits a turn_pool_updated realtime event so connected clients refresh without polling.

Process

┌────────────────────────────────┐
│ Player action enters service   │
│ (movement, combat, trade, ...) │
└──────────────┬─────────────────┘
               │ SELECT ... FOR UPDATE on Player
               ▼
┌────────────────────────────────┐
│ regenerate_turns(player, now)  │
└──────────────┬─────────────────┘
               │
               ▼
   anchor = player.last_turn_regeneration
            or player.created_at
   elapsed = (now - anchor).total_seconds()
   if elapsed <= 0: return  (clock skew guard)
               │
               ▼
   base_rate     = max_turns_baseline / 86400      (≈0.01157 t/s for 1000-cap)
   effective     = base_rate * player.aria_bonus_multiplier
   raw_added     = elapsed * effective
   added         = floor(raw_added)
               │
               ▼
   effective_max = 1000 + rank_max_turns_bonus(player.military_rank)
   if player.max_turns != effective_max:
       player.max_turns = effective_max     (rank promotion side-effect)
               │
               ▼
   if player.turns >= effective_max:
       player.last_turn_regeneration = now   (no carryover)
       return
               │
               ▼
   new_pool = min(effective_max, player.turns + added)
   if added > 0:
       player.turns = new_pool
       # Advance the anchor only by the integer turns we counted, so
       # the fractional remainder rolls over to the next tick.
       seconds_consumed = int(added / effective)
       player.last_turn_regeneration = anchor + timedelta(seconds=seconds_consumed)
   else:
       # Elapsed too short to add a full turn — leave anchor alone.
       return
               │
               ▼
   emit realtime event:
     {type: "turn_pool_updated", player_id, turns, max_turns, bonus_multiplier}

Formula details

BASE_RATE = 1000 / 86400        # 0.01157 turns/sec at 1.0× — full pool in 24h
effective_rate  = BASE_RATE * player.aria_bonus_multiplier
elapsed_seconds = (now - anchor).total_seconds()
turns_added     = int(elapsed_seconds * effective_rate)
new_pool        = min(player.max_turns, player.turns + turns_added)

In hourly terms: 41.67 t/h at 1.0×, up to 62.5 t/h at the top ARIA tier.

ARIA multiplier table

ARIA interactions Consciousness level Multiplier
0–49 1 1.0×
50+ 2 1.1×
150+ 3 1.2×
400+ 4 1.35×
1,000+ 5 1.5×

Multiplier is updated by the combat-resolver on victory (see combat-resolver.md) and by the ARIA dialogue flow on each successful interaction (see aria-dialogue.md).

Rank max-turn bonus

max_turns is recomputed at promotion time and during regen. Each rank carries a max_turns_bonus; effective cap is 1000 + bonus. The bonus is only ever additive — losing a rank is not a designed event.

Outputs / state changes

Mutations on Player: - turns — increased toward max_turns. - last_turn_regeneration — advanced by the integer-turn-equivalent number of seconds (preserves fractional remainder). - max_turns — synced to 1000 + rank_max_turns_bonus if rank changed since last regen.

Events emitted (see realtime-bus.md): - turn_pool_updated — unicast to the player's session(s).

Downstream readers: - Turn-cost preview UIs, low-turn warning UI, time-to-full estimator. - Action handlers (movement, combat, etc.) that block when player.turns < cost.

Invariants

  1. 0 ≤ player.turns ≤ player.max_turns at the end of every regen call.
  2. player.last_turn_regeneration ≤ now (monotonic; never set to a future time).
  3. No regen tick ever decreases player.turns.
  4. While at cap (turns == max_turns), the anchor is bumped to now so excess time does not bank.
  5. Concurrent regen on the same player is serialized by the row lock (SELECT ... FOR UPDATE) — two simultaneous spends cannot double-credit.
  6. Regen always runs before the cost check on any turn-consuming action — players never get charged with stale-pool numbers.

Failure modes

Mode Target handling
Clock skew (now < anchor) elapsed_seconds clamped to 0; no mutation; log warning.
Rounding loss (elapsed too short for a whole turn) Anchor left in place so the fractional time is preserved for the next call.
Player at cap for a long period Anchor bumped to now on every action; no banking, no overflow.
Rank demotion (admin-driven) Cap recomputed; if turns > max_turns, pool clipped down — emit a warning event but do not raise.
ARIA multiplier mid-fight Read once at the top of the regen call; later changes apply on the next action.
Lock contention The row lock blocks; the API request queues. Long blocks are surfaced as 5xx after the request timeout.
Stuck advanced-genesis lock While in a Genesis sequence the action cap can be reduced (see genesis-deploy.md); the regen formula itself is unchanged — only max_turns is temporarily lowered.

Source map

Concern Path (target)
Regen helper (lazy entry point) services/gameserver/src/services/movement_service.py:_regenerate_turns
Same helper reused in combat services/gameserver/src/services/combat_service.py:_regenerate_turns
Same helper reused in trade services/gameserver/src/services/trading_service.py:_regenerate_turns
Rank bonus lookup services/gameserver/src/services/ranking_service.py:get_rank_bonuses
ARIA-multiplier write site services/gameserver/src/services/combat_service.py (consciousness thresholds) and aria_dialogue_service.py
Realtime event emission services/gameserver/src/services/websocket_service.py:send_personal_message
Player fields services/gameserver/src/models/player.py
Turn pool API services/gameserver/src/api/routes/player.py