Planetary Production Tick¶
Status: ๐ง Partial โ Commodity accrual is live on a scheduled sweep and lazy advance-on-read (idempotent; re-verified 2026-08-21 vs tip
46bce720; tip delta from prior9eeba741is GS #704 thin-floor only โ Soft-HOLD residuals unchanged), but food consumption + overflow/starvation morale remain ๐ Design-only.
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 (re-verified 2026-08-21, tip
46bce720) โ scheduled sweep + lazy advance-on-read + admin tick. The resource-credit half runs on_run_planetary_advance_sync(services/gameserver/src/services/scheduler/economy_governance_sweeps.py) everyPLANETARY_ADVANCE_SECONDS(5 minutes;scheduler/_common.py), so owned colonized planets bank fuel / organics / equipment without a player opening the screen โ matching../FEATURES/planets/production.md. The same accrual is also realized lazily on planet read (planetary_service.apply_resource_production/realize_production); sweep and read are time-accurate and idempotent (durablePlanet.last_production+production_carrybank). An operator can force one planet forward viaPOST /api/v1/admin/planets/{id}/tick. On lazy read, elapsed wall-clock sinceplanet.last_productionis multiplied by the per-day rates and added tofuel_ore/organics/equipment; sub-unit progress is banked inplanet.active_events['production_carry']. The read is hardened withlock_timeout='3s'and serves un-accrued data on row-lock contention rather than blocking. Storage caps are โ Shipped viaCITADEL_LEVELS[level]["safe_storage"](see ยง Storage caps; L0 uncapped, L1+ clamped). Food consumption + overflow/starvation morale feedback remain ๐ Design-only. A live planetary job namedtick_productionis not on tip (do not confuse withTradingService.tick_production, which is station stock-regen); the consumption/feedback half below stays the canonical target for that unbuilt work. Proven (lazy path): 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 sweep _run_planetary_advance_sync on PLANETARY_ADVANCE_SECONDS (5 minutes).
- 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).
- Research production bonus โ โ
Shipped (PR #583). When the owner has unlocked
t.production.yield.1, fuel / organics / equipment rates multiply by(1 + tech_modifier(owner, "production_rate"))โ catalog magnitude +0.05 โ inside_calculate_production_rates(planetary_service.py). Verified:test_tech_tree_point_of_use.py::test_production_rate_modifier_lifts_commodity_rates. Seeresearch-tech-tree.mdnodet.production.yield.1.
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. โ
Shipped on origin/feat
46bce720โplanetary_service.apply_resource_productiondivertsSIEGE_RESOURCE_THEFT_FRACTION = 0.15of each newly-produced fuel_ore/organics/equipment unit before the planet stockpile, then_deliver_siege_theftloads it into the besieger's current ship cargo (capacity-clamped; no besieger / no ship / full hold silently drops that tick's theft). Same path and honesty as../FEATURES/planets/defense.mdยง Siege (do not invent a different fraction).
Colonist consumption¶
Each colonist consumes 0.5 organics / day (food). At tick scale:
food_consumed = colonists * 0.5 * (elapsed_minutes / 1440)
organics_on_hand is the planet's pre-tick stockpile โ the balance before this tick's production gains (step 5 of the tick loop) are credited. A tick's own harvest cannot retroactively feed colonists who already starved against the stockpile they held at the start of the tick.
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).
- Starvation nets out of the colonist count before population growth is computed โ see "Population growth" below.
- Habitability score temporarily reduced โ ๐ Design-only, a planned follow-up feedback loop, not a defect in the current implementation.
Storage caps¶
โ
Shipped. Each commodity's cap is CITADEL_LEVELS[level]["safe_storage"] (services/gameserver/src/services/citadel_service.py) โ citadel-level-scaled, not a separate storage-building/storage_level mechanic. Ladder: L0=0, L1=100,000, L2=500,000, L3=2,000,000, L4=10,000,000, L5=50,000,000 units per commodity. There is no base_cap/storage_level multiplier formula and no storage-building model in code (ruled 2026-08-09, DECISIONS.md storage-cap-formula-mismatch) โ safe_storage is the real, permanent cap mechanic.
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¶
Starvation applies before growth within the same tick: deaths net out of the colonist count first, and births are computed off the post-starvation total, not the pre-tick total. Both the pre-tick-stockpile check and this before-growth ordering were ratified by ADR-0093 item 11 as shipped readings (a); the habitability-reduction feedback loop (reading c) remains unbuilt โ a future work order, not a defect (folded from ADR-0093, re-verified 2026-08-07).
deaths = starvation_deaths + siege_deaths
post_starvation_colonists = clamp(colonists - deaths, 0, colonists)
births = post_starvation_colonists * 0.01 * habitability_ratio * (elapsed_minutes / 1440)
new_colonists = clamp(
post_starvation_colonists + births,
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 no cap (skip clamp); log warning. Matches storage_cap_for returning 0 for L0 / missing safe_storage. |
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 ADR's target design was a per-region bulk UPDATE. Corrected 2026-08-04 โ the shipped mechanism (economy_governance_sweeps.py::_run_planetary_advance_sync) is architecturally different: a single scheduled sweep walks ALL regions together, row-locking (with_for_update()) and advancing terraforming/siege/production one planet at a time, with per-planet commit isolation so one bad planet's failure rolls back independently rather than aborting a region-wide batch. There is no per-region bulk UPDATE anywhere in the path.
- Cadence: 12 seconds per tick (5 ticks/min). Adjusted from the 5-minute placeholder above.
- Per-planet transaction: each qualifying planet (terraforming_active / under_siege / owned-and-colonized) is locked, advanced, and committed individually โ not batched per region. Advisory-lock-gated so a second scheduler instance skips instead of double-advancing.
- P99 region-tick latency budget: < 500 ms โ retained as a target metric even though the underlying batching strategy changed; not yet re-measured against the per-planet-commit architecture.
- 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 (includes tech_modifier(..., "production_rate") when t.production.yield.1 unlocked) |
| 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/services/scheduler/economy_governance_sweeps.py:_run_planetary_advance_sync (cadence PLANETARY_ADVANCE_SECONDS; dispatcher scheduler/core_loop.py) |
| 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.