Skip to content

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 prior 9eeba741 is 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) every PLANETARY_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 (durable Planet.last_production + production_carry bank). An operator can force one planet forward via POST /api/v1/admin/planets/{id}/tick. On lazy read, 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 in planet.active_events['production_carry']. The read is hardened with lock_timeout='3s' and serves un-accrued data on row-lock contention rather than blocking. Storage caps are โœ… Shipped via CITADEL_LEVELS[level]["safe_storage"] (see ยง Storage caps; L0 uncapped, L1+ clamped). Food consumption + overflow/starvation morale feedback remain ๐Ÿ“ Design-only. A live planetary job named tick_production is not on tip (do not confuse with TradingService.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:

  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).

  • 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. See research-tech-tree.md node t.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_production diverts SIEGE_RESOURCE_THEFT_FRACTION = 0.15 of each newly-produced fuel_ore/organics/equipment unit before the planet stockpile, then _deliver_siege_theft loads 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

  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 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:

  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 (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)