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 asapply_population_growth/ market regen) rather than via a background scheduler. On everyget_planet_details, elapsed wall-clock sinceplanet.last_productionis multiplied by the per-day rates and added tofuel_ore/organics/equipment; sub-unit progress is banked exactly in a per-resource fractional carry inplanet.active_events['production_carry'](no migration). The read is hardened withlock_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 standalonetick_productionscheduler 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:
- Compute elapsed since
last_production. If 0, skip. - Compute production rates (per-day basis; see formulas).
- Scale rates by elapsed-time fraction (so a 5-minute tick yields 5/1440 of a daily rate).
- Compute colonist consumption (food = organics).
- Apply produced - consumed deltas, clamping to storage caps.
- Apply colonist growth (births - starvation).
- Update
last_production = now. - Emit
planet.production_tickevent 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¶
colonists ≥ 0, bounded byeffective_max_colonists.fuel_ore,organics,equipment≥ 0, bounded by their respective caps.fuel_allocation + organics_allocation + equipment_allocation ≤ colonists.last_productionis monotonically non-decreasing.- Production rates are non-negative.
- Tick is idempotent given the same
last_production(no double-credit). - Siege flag toggles via siege resolution path only — production tick does not modify it.
- Starvation does not produce negative organics — overflow into deaths instead.
- Habitability ≤ 0 zeroes growth rate; production rates still apply.
- 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:
- Shard the per-region batch by planet count — regions with >100 planets split into two parallel batches.
- 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) |
Related¶
../FEATURES/planets/production.md— player-facing production controls.../FEATURES/planets/colonization.md— initial colonization that establishes a planet's first colonists.../FEATURES/planets/citadels.md— citadel buildings driving multipliers.../FEATURES/planets/defense.md— siege state.turn-regeneration.md— sibling tick that uses the same scheduler infrastructure.market-pricing.md— produced resources feed back into the market.