Skip to content

Planetary Production Tick

Status: 🚧 Partial — Resource-credit half is genuinely live as a lazy advance-on-read on get_planet_details (rates with building/specialization/citadel/siege modifiers all wired), exactly as the page's ✅ note claims … (impl audit 2026-06-16)

Purpose

Each tick (default 5 minutes), every owned, colonized planet runs a per-planet production cycle: colonists consume food, allocations produce fuel / organics / equipment / colonists, citadel-driven multipliers and habitability shape the rates, storage caps clamp the result, and overflow / starvation conditions feed back into morale and population. This is the per-planet step that makes empires economically alive — without it, a planet is just a parking spot.

✅ Shipped (2026-06-14) — commodity accrual via lazy advance-on-read. The resource-credit half of this tick is now live, but realized lazily on planet read (planetary_service.apply_resource_production, the same advance-on-read pattern as apply_population_growth / market regen) rather than via a background scheduler. On every get_planet_details, elapsed wall-clock since planet.last_production is multiplied by the per-day rates and added to fuel_ore / organics / equipment; sub-unit progress is banked exactly in a per-resource fractional carry in planet.active_events['production_carry'] (no migration). The read is hardened with lock_timeout='3s' and serves un-accrued data on row-lock contention rather than blocking. Storage caps + food consumption + overflow/starvation morale feedback remain 📐 design-only (stockpiles are uncapped today). The standalone tick_production scheduler below stays the canonical target for the consumption/clamp/feedback half. Proven: 653-allocation planet with a 1 h backdated anchor accrued +285 fuel on read; carry banked; GET under a held FOR UPDATE lock returned 200 in 3.2 s (no 504). See FINDINGS.md.

Inputs

What triggers this: - Scheduled tick (tick_production job, default 5-minute cadence). - Manual admin trigger via POST /api/v1/admin/planets/{id}/tick. - On-demand recompute when allocations change (no resource credit; only rate refresh).

State read: - Planet.id, .owner_id, .specialization, .last_production. - Allocations: fuel_allocation, organics_allocation, equipment_allocation (sum ≤ 100% of colonists). - Resources on hand: fuel_ore, organics, equipment. - Population: colonists, max_colonists. - Buildings: factory_level, farm_level, mine_level, defense_level, research_level. - Citadel state: citadel level, turret/shield/orbital-platform counts (see ../FEATURES/planets/citadels.md). - Habitability: habitability_score (0–100). - Production multipliers: production_efficiency (0.0–2.0), specialization bonuses. - Siege state: under_siege flag.

Process

Tick loop

For each planet where owner_id IS NOT NULL and colonists > 0:

  1. Compute elapsed since last_production. If 0, skip.
  2. Compute production rates (per-day basis; see formulas).
  3. Scale rates by elapsed-time fraction (so a 5-minute tick yields 5/1440 of a daily rate).
  4. Compute colonist consumption (food = organics).
  5. Apply produced - consumed deltas, clamping to storage caps.
  6. Apply colonist growth (births - starvation).
  7. Update last_production = now.
  8. Emit planet.production_tick event with deltas.

The tick is idempotent against last_production — running twice in quick succession produces almost nothing the second time.

Production rate formula

Base rate is 10 units / colonist / day for each commodity, distributed by allocation:

fuel_rate     = fuel_allocation     * 10 * (1 + 0.10 * mine_level)
organics_rate = organics_allocation * 10 * (1 + 0.10 * farm_level)
equipment_rate= equipment_allocation* 10 * (1 + 0.10 * factory_level)

(fuel_allocation etc. is the count of colonists assigned, not a percentage. Sum of allocations cannot exceed colonists.)

Specialization bonus

If Planet.specialization is set, the per-specialization multiplier set (ADR-0087, SPECIALIZATION_BONUSES in planetary_service.py) scales production, defense, and research. Production multipliers scale the relevant resource rate; the defense multiplier scales the planet's effective defence in the siege/combat resolver; the research multiplier scales the per-day research-point yield.

Specialization Fuel Organics Equipment Colonists Defense Research
agricultural ×0.8 ×1.5 ×0.8 ×1.2 ×0.9 ×0.8
industrial ×0.9 ×0.8 ×1.5 ×0.9 ×1.0 ×0.9
military ×0.9 ×0.9 ×1.1 ×0.8 ×1.5 ×0.8
research ×0.8 ×0.8 ×0.9 ×0.9 ×0.8 ×1.5
balanced ×1.1 ×1.1 ×1.1 ×1.1 ×1.1 ×1.1

balanced is the generalist default (a uniform +10% all-round, not a strict ×1.0 no-op) and the fallback for any unrecognised value. Research-point yield accrues per day from Research Lab level (RESEARCH_POINTS_PER_LAB_LEVEL_PER_DAY = 25) scaled by the research multiplier, citadel bonus, and siege penalty.

Habitability scaling

Colonist growth multiplier:

habitability_ratio = max(1, habitability_score) / 100
colonist_rate = colonists * 0.01 * habitability_ratio   # 1% per day at 100 habitability
effective_max_colonists = max_colonists * habitability_ratio

Habitability ≤ 50 produces stagnation; ≤ 25 triggers slow population decline (target spec for very harsh worlds).

Citadel-driven multipliers

Citadel buildings provide passive bonuses:

Citadel level Production bonus
0 none
1 +5% all production
2 +10%
3 +15%
4 +20%
5 +25%

Combined with specialization, the math is:

effective_rate = base_rate
               * (1 + 0.10 * relevant_building_level)
               * specialization_multiplier
               * (1 + 0.05 * citadel_level)
               * production_efficiency

production_efficiency is the catch-all admin-tunable multiplier (0.0–2.0).

Siege effects

If Planet.under_siege == true:

  • All production rates × 0.75 (SIEGE_PRODUCTION_PENALTY = 0.25).
  • Colonist growth = 0 (population stagnates).
  • Resource theft: a fraction of generated commodities is intercepted by the besieger (📐 Design-only — currently penalty applies but no transfer).

Colonist consumption

Each colonist consumes 0.5 organics / day (food). At tick scale:

food_consumed = colonists * 0.5 * (elapsed_minutes / 1440)

If organics_on_hand < food_consumed: - food_deficit = food_consumed - organics_on_hand - organics_on_hand = 0 - Apply starvation: colonists -= ceil(food_deficit * 2) (each missing unit kills 2 colonists). - Habitability score temporarily reduced (target spec).

Storage caps

Each commodity has a maximum derived from citadel level and storage building level (target spec):

cap_fuel       = base_cap * (1 + storage_level * 0.5)
cap_organics   = base_cap * (1 + storage_level * 0.5)
cap_equipment  = base_cap * (1 + storage_level * 0.5)

If production would exceed cap: - The excess is wasted (not stored, not transferred). Surface as overflow_warning event. - 📐 Design-only — overflow could spill into the orbital station's market for a fire-sale price.

Population growth

births = colonists * 0.01 * habitability_ratio * (elapsed_minutes / 1440)
deaths = starvation_deaths + siege_deaths

new_colonists = clamp(
    colonists + births - deaths,
    0,
    effective_max_colonists
)

If colonists == 0 after the tick: planet is uninhabited (allocations zero out, production halts; ownership remains).

Tick output

After all clamping and consumption:

deltas = {
    "fuel_ore":   produced_fuel,
    "organics":   produced_organics - food_consumed,
    "equipment":  produced_equipment,
    "colonists":  births - deaths
}

planet.fuel_ore  = clamp(planet.fuel_ore + deltas.fuel_ore,  0, cap_fuel)
planet.organics  = clamp(planet.organics + deltas.organics, 0, cap_organics)
planet.equipment = clamp(planet.equipment + deltas.equipment, 0, cap_equipment)
planet.colonists = clamp(planet.colonists + deltas.colonists, 0, effective_max_colonists)
planet.last_production = now

Commit; emit event.

Outputs / state changes

Per tick, per planet: - Planet.fuel_ore, .organics, .equipment, .colonists updated. - Planet.last_production updated to now. - Planet.under_siege evaluated and possibly cleared (see ../FEATURES/planets/defense.md). - Events: - planet.production_tick — per-planet, includes deltas, rates, current resources. - planet.starvation_warning — if food deficit occurred. - planet.overflow_warning — if any cap was hit. - planet.colonist_milestone — at population threshold (1k, 10k, 100k, max). - Owner notification (websocket) if any warning event fires.

Invariants

  1. colonists ≥ 0, bounded by effective_max_colonists.
  2. fuel_ore, organics, equipment ≥ 0, bounded by their respective caps.
  3. fuel_allocation + organics_allocation + equipment_allocation ≤ colonists.
  4. last_production is monotonically non-decreasing.
  5. Production rates are non-negative.
  6. Tick is idempotent given the same last_production (no double-credit).
  7. Siege flag toggles via siege resolution path only — production tick does not modify it.
  8. Starvation does not produce negative organics — overflow into deaths instead.
  9. Habitability ≤ 0 zeroes growth rate; production rates still apply.
  10. Citadel level ≤ 5 (defined cap); production multiplier capped at +25%.

Failure modes

Mode Target handling
last_production corrupt / NULL Treat as now; first tick produces nothing; subsequent ticks normal.
Allocation sum exceeds colonists (e.g., colonist death) Clamp allocations to current colonist count proportionally; preserve ratios.
Habitability ≤ 0 Growth rate forced to 0; production still applies.
Specialization missing from bonus table Default multipliers (1.0) — no bonus, no penalty.
Tick scheduler runs late (huge elapsed) Cap elapsed at 24 hours per tick to prevent runaway growth.
Concurrent admin tick + scheduled tick Row lock on planet; second waits, sees updated last_production, produces nothing.
Storage cap ≤ 0 due to misconfiguration Treat as cap of max(1, base_cap); log warning.
Planet has owner but colonists == 0 Skip — no production, no events.
Siege flag set but no besieger Production penalty still applies until siege resolution clears flag.
Specialization changed mid-tick Read fresh on tick; old rate reflects new specialization for that whole tick.

Performance budget

Per ADR-0051 SK29, the tick batches all planets in a region into a single bulk UPDATE per region per tick, reducing per-tick overhead ~10× vs per-planet writes:

  • Cadence: 12 seconds per tick (5 ticks/min). Adjusted from the 5-minute placeholder above.
  • Per-region transaction: one bulk UPDATE covering every planet in the region. Each planet's rates are computed independently in memory; the persistence step writes them all together.
  • P99 region-tick latency budget: < 500 ms.
  • Operator dashboards alert at 80% of budget.

If the budget is missed at scale, optimization paths in order:

  1. Shard the per-region batch by planet count — regions with >100 planets split into two parallel batches.
  2. Async per-region workers — each region drains a planet-tick queue continuously, decoupling cadence from worker latency.

Source map

Concern Path (target)
Tick service services/gameserver/src/services/planet_production_service.py (target — currently inside planetary_service.py)
Production rate calculator services/gameserver/src/services/planetary_service.py:_calculate_production_rates
Habitability effects services/gameserver/src/services/planetary_service.py:get_habitability_effects
Specialization bonus table same file (_calculate_specialization_bonuses)
Siege evaluation services/gameserver/src/services/planetary_service.py:check_and_update_siege
Planet model services/gameserver/src/models/planet.py
Scheduler entry services/gameserver/src/scheduler/tick_planets.py (target — not yet split out)
Storage caps services/gameserver/src/models/planet.py (max_colonists, target storage cap fields)