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¶
0 ≤ player.turns ≤ player.max_turnsat the end of every regen call.player.last_turn_regeneration ≤ now(monotonic; never set to a future time).- No regen tick ever decreases
player.turns. - While at cap (
turns == max_turns), the anchor is bumped tonowso excess time does not bank. - Concurrent regen on the same player is serialized by the row lock (
SELECT ... FOR UPDATE) — two simultaneous spends cannot double-credit. - 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 |
Related¶
- DATA_MODELS:
../DATA_MODELS/player.md— Player columns (turns,max_turns,last_turn_regeneration,aria_bonus_multiplier). - FEATURES:
../FEATURES/gameplay/turns.md,../FEATURES/gameplay/ranking.md. - SYSTEMS: combat-resolver.md, aria-dialogue.md, realtime-bus.md.
- REST API:
GET /api/v1/player/meand action endpoints, auto-published at<api-host>/docs.