diff --git a/universe/components.py b/universe/components.py
index c9dbf3a..e171be0 100644
--- a/universe/components.py
+++ b/universe/components.py
@@ -72,6 +72,10 @@ class SpeciesComponent(Component):
     plural_name = fields.CharField(required=True)
     growth_rate = fields.IntField(required=True)
 
+
+class SpeciesEnvironmentComponent(Component):
+    _name = 'species_environment'
+
     gravity_min = fields.IntField(min=0, max=100, required=False)
     gravity_max = fields.IntField(min=0, max=100, required=False)
     gravity_immune = fields.BooleanField()
@@ -115,6 +119,22 @@ class SpeciesComponent(Component):
                     "'radiation_min' and 'radiation_max' must be set if 'radiation_immune' is false.")
 
 
+class SpeciesProductionComponent(Component):
+    _name = 'species_production'
+
+    population_per_r = fields.IntField(min=700)
+
+    factories_produce_r = fields.IntField(min=5, max=15)  # each 10 factories produce X production
+    factories_cost_r = fields.IntField(min=5, max=25)  # each factory costs X production
+    factories_per_pop = fields.IntField(min=5, max=25)  # each 10k colonists can operate X factories
+
+    factories_cost_less = fields.BooleanField()  # each factory costs 3kT germanium instead of 4kT
+
+    minerals_per_m = fields.IntField(min=5, max=25)  # each 10 mines produce X kT of minerals
+    mines_cost_r = fields.IntField(min=2, max=15)  # each mine costs X production
+    mines_per_pop = fields.IntField(min=5, max=25)  # each 10k colonists can operate X mines
+
+
 class OwnershipComponent(Component):
     _name = 'ownership'
 
@@ -124,7 +144,7 @@ class OwnershipComponent(Component):
 class PopulationComponent(Component):
     _name = 'population'
 
-    population = fields.IntField(required=False)
+    population = fields.IntField(min=0, required=False)
 
 
 class EnvironmentComponent(Component):
@@ -181,6 +201,13 @@ class MineralInventoryComponent(Component):
     germanium = fields.IntField(min=0, required=False)
 
 
+class PlanetaryFacilitiesComponent(Component):
+    _name = 'facilities'
+
+    mines = fields.IntField(min=0, required=False)
+    factories = fields.IntField(min=0, required=False)
+
+
 class OrderComponent(Component):
     _name = 'orders'
 
diff --git a/universe/engine.py b/universe/engine.py
index 83ffd4a..6b0ab2f 100644
--- a/universe/engine.py
+++ b/universe/engine.py
@@ -144,8 +144,14 @@ class GameState:
         self.manager = Manager()
         self.manager.register_system(systems.UpdateSystem)
         self.manager.register_system(systems.MovementSystem)
+        self.manager.register_system(systems.MiningSystem)
         self.manager.register_system(systems.PopulationGrowthSystem)
 
+        self.manager.register_entity_type('species', [
+            components.SpeciesComponent(),
+            components.SpeciesEnvironmentComponent(),
+            components.SpeciesProductionComponent(),
+        ])
         self.manager.register_entity_type('ship', [
             components.PositionComponent(),
             components.OwnershipComponent(),
@@ -157,12 +163,10 @@ class GameState:
             components.EnvironmentComponent(),
             components.MineralConcentrationComponent(),
             components.MineralInventoryComponent(),
+            components.PlanetaryFacilitiesComponent(),
             components.OwnershipComponent(),
             components.PopulationComponent(),
         ])
-        self.manager.register_entity_type('species', [
-            components.SpeciesComponent(),
-        ])
         self.manager.register_entity_type('movement_order', [
             components.OrderComponent(),
             components.MovementComponent(),
diff --git a/universe/systems.py b/universe/systems.py
index 7383d01..7dc72b4 100644
--- a/universe/systems.py
+++ b/universe/systems.py
@@ -138,6 +138,19 @@ class MovementSystem:
                     order.seq = i
 
 
+class MiningSystem:
+    def process(self, manager):
+        for _id, entity in manager.get_entities('mineral_concentrations').items():
+            species = manager.get_entity('species', entity.owner_id)
+            if species is None:
+                continue
+
+            ir, bo, ge = utils.mining(species, entity)
+            entity.ironium = (entity.ironium or 0) + ir
+            entity.boranium = (entity.boranium or 0) + bo
+            entity.germanium = (entity.germanium or 0) + ge
+
+
 class PopulationGrowthSystem:
     def process(self, manager):
         for _id, entity in manager.get_entities('population').items():
diff --git a/universe/utils.py b/universe/utils.py
index 0efb57b..ae54344 100644
--- a/universe/utils.py
+++ b/universe/utils.py
@@ -1,3 +1,4 @@
+from decimal import Decimal
 import math
 
 
@@ -25,3 +26,23 @@ def planet_value(species, planet):
     if red != 0:
         return -red
     return int(int(math.sqrt(value / 3) + 0.9) * ideal / 10000)
+
+
+def production(species, planet):
+    population = Decimal(planet.population or 0)
+
+    return int(population / species.population_per_r)
+
+
+def mining(species, planet):
+    population = Decimal(planet.population or 0)
+    mines = planet.mines or 0
+    can_operate = int(population / 10_000) * species.mines_per_pop
+
+    mines = Decimal(min(mines, can_operate))
+    capacity = int(mines * species.minerals_per_m / 10)
+    ir = Decimal(planet.ironium_conc) / 100
+    bo = Decimal(planet.boranium_conc) / 100
+    ga = Decimal(planet.germanium_conc) / 100
+
+    return (int(ir * capacity), int(bo * capacity), int(ga * capacity))

Inhabited planets can have planetary facilities
In order for anything to be constructed in this game, as in Stars, minerals need to be mined from planets and population needs to produce action resources which can be enhanced by a special type of planetary facility called factories.

- [x] species get a new parameter determining how many action resources per population planets get per year
- [x] species get new parameters determining how many resources a mine costs, how many mines can be operated by a given quantity of population, and the maximum minerals a mine can produce per year
- [x] add a function that calculates a planet's resource production
- [x] add a function that calculates a planet's mineral production
- [x] species get new parameters determining factory costs in action resources plus germanium, how many factories can be operated by a given quantity of population, and how many action resources an operated factory adds to a planet's productivity
- [x] add a component to planets that has the counts of mines and factories on the planet
- [x] add a new system that calculates and adds new mineral quantities to the planet surface based on the mines, population, and the mineral concentrations of an inhabited planet
- ~~decrease the mineral concentrations as they are mined~~
  - split off into #39
- ~~mines may be queued and built using nothing more than action resources~~
  - split off into #38
- ~~add a new system that builds these facilities, decreasing the relevant resources, stopping work when all resources are exhausted for the year, and leaving partially completed items in the queue to be worked on in subsequent turns~~
  - split off into #38
diff --git a/tests/test_components.py b/tests/test_components.py
index 5e819ef..6deafd5 100644
--- a/tests/test_components.py
+++ b/tests/test_components.py
@@ -122,7 +122,7 @@ class MineralConcentrationComponentTestCase(unittest.TestCase):
         self.assertTrue(components.MineralConcentrationComponent().serialize(data))
 
 
-class SpeciesComponentTestCase(unittest.TestCase):
+class SpeciesEnvironmentComponentTestCase(unittest.TestCase):
     def test_immune(self):
         data = {
             'type': 'species',
@@ -133,12 +133,12 @@ class SpeciesComponentTestCase(unittest.TestCase):
             'temperature_immune': True,
             'radiation_immune': True,
         }
-        component = components.SpeciesComponent()
+        component = components.SpeciesEnvironmentComponent()
 
         self.assertIsNone(component.validate(data))
 
     def test_immune_with_value(self):
-        component = components.SpeciesComponent()
+        component = components.SpeciesEnvironmentComponent()
 
         for fname in ('gravity', 'temperature', 'radiation'):
             data = {
@@ -171,13 +171,13 @@ class SpeciesComponentTestCase(unittest.TestCase):
             'temperature_immune': False,
             'radiation_immune': False,
         }
-        component = components.SpeciesComponent()
+        component = components.SpeciesEnvironmentComponent()
 
         with self.assertRaises(exceptions.ValidationError):
             component.validate(data)
 
     def test_not_immune_without_value(self):
-        component = components.SpeciesComponent()
+        component = components.SpeciesEnvironmentComponent()
 
         for fname in ('gravity', 'temperature', 'radiation'):
             data = {
diff --git a/tests/test_engine.py b/tests/test_engine.py
index 24e7dd9..2fafa0e 100644
--- a/tests/test_engine.py
+++ b/tests/test_engine.py
@@ -57,6 +57,14 @@ class PersistenceTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -112,6 +120,14 @@ class PersistenceTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -155,6 +171,14 @@ class PersistenceTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
             ]
         }
@@ -172,6 +196,14 @@ class PersistenceTestCase(unittest.TestCase):
         self.assertEqual(species['gravity_immune'], True)
         self.assertEqual(species['temperature_immune'], True)
         self.assertEqual(species['radiation_immune'], True)
+        self.assertEqual(species['population_per_r'], 1000)
+        self.assertEqual(species['factories_produce_r'], 10)
+        self.assertEqual(species['factories_cost_r'], 10)
+        self.assertEqual(species['factories_per_pop'], 10)
+        self.assertEqual(species['factories_cost_less'], False)
+        self.assertEqual(species['minerals_per_m'], 10)
+        self.assertEqual(species['mines_cost_r'], 5)
+        self.assertEqual(species['mines_per_pop'], 10)
 
 
 class MovementTestCase(unittest.TestCase):
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 31b6567..aacbcd6 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -60,6 +60,12 @@ class IntFieldTestCase(unittest.TestCase):
 
         self.assertIsNone(field.validate({}))
 
+    def test_ranged_not_required(self):
+        field = fields.IntField(min=0, max=100, required=False)
+        field.name = 'widgets'
+
+        self.assertIsNone(field.validate({}))
+
     def test_explicit_required(self):
         field = fields.IntField(required=True)
         field.name = 'widgets'
diff --git a/tests/test_systems.py b/tests/test_systems.py
index fc098d3..ee98d61 100644
--- a/tests/test_systems.py
+++ b/tests/test_systems.py
@@ -17,6 +17,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
             ]
@@ -52,6 +60,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8}
@@ -88,6 +104,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8}
@@ -124,6 +148,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -163,6 +195,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -202,6 +242,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -237,6 +285,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -272,6 +328,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -302,6 +366,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -336,6 +408,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -371,6 +451,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 1, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 2, 'type': 'movement_order', 'actor_id': 1, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -406,6 +494,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -416,6 +512,14 @@ class UpdateTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {'pk': 2, 'type': 'ship', 'x': 480, 'y': 235, 'owner_id': 0},
                 {'pk': 3, 'type': 'movement_order', 'actor_id': 2, 'seq': 0, 'x_t': 637, 'y_t': 786, 'warp': 8},
@@ -439,6 +543,255 @@ class UpdateTestCase(unittest.TestCase):
         )
 
 
+class MiningTestCase(unittest.TestCase):
+    def test_uninhabited(self):
+        state = {
+            'turn': 2500,
+            'width': 1000,
+            'entities': [
+                {
+                    'pk': 0,
+                    'type': 'species',
+                    'name': 'Human',
+                    'plural_name': 'Humans',
+                    'growth_rate': 15,
+                    'gravity_immune': True,
+                    'temperature_immune': True,
+                    'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
+                },
+                {
+                    'pk': 1,
+                    'type': 'planet',
+                    'population': 1000,
+                    'x': 480,
+                    'y': 235,
+                    'gravity': 50,
+                    'temperature': 50,
+                    'radiation': 50,
+                    'ironium_conc': 100,
+                    'boranium_conc': 100,
+                    'germanium_conc': 100,
+                },
+            ]
+        }
+
+        S = engine.GameState(state, {})
+        systems.MiningSystem().process(S.manager)
+
+        planets = S.manager.get_entities('mineral_concentrations')
+        self.assertEqual(len(planets), 1)
+        self.assertEqual(planets[1].ironium or 0, 0)
+        self.assertEqual(planets[1].germanium or 0, 0)
+        self.assertEqual(planets[1].boranium or 0, 0)
+
+    def test_no_mines(self):
+        state = {
+            'turn': 2500,
+            'width': 1000,
+            'entities': [
+                {
+                    'pk': 0,
+                    'type': 'species',
+                    'name': 'Human',
+                    'plural_name': 'Humans',
+                    'growth_rate': 15,
+                    'gravity_immune': True,
+                    'temperature_immune': True,
+                    'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
+                },
+                {
+                    'pk': 1,
+                    'type': 'planet',
+                    'population': 1000,
+                    'x': 480,
+                    'y': 235,
+                    'gravity': 50,
+                    'temperature': 50,
+                    'radiation': 50,
+                    'ironium_conc': 100,
+                    'boranium_conc': 100,
+                    'germanium_conc': 100,
+                    'owner_id': 0,
+                },
+            ]
+        }
+
+        S = engine.GameState(state, {})
+        systems.MiningSystem().process(S.manager)
+
+        planets = S.manager.get_entities('mineral_concentrations')
+        self.assertEqual(len(planets), 1)
+        self.assertEqual(planets[1].ironium or 0, 0)
+        self.assertEqual(planets[1].germanium or 0, 0)
+        self.assertEqual(planets[1].boranium or 0, 0)
+
+    def test_full_concentration(self):
+        state = {
+            'turn': 2500,
+            'width': 1000,
+            'entities': [
+                {
+                    'pk': 0,
+                    'type': 'species',
+                    'name': 'Human',
+                    'plural_name': 'Humans',
+                    'growth_rate': 15,
+                    'gravity_immune': True,
+                    'temperature_immune': True,
+                    'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
+                },
+                {
+                    'pk': 1,
+                    'type': 'planet',
+                    'population': 1_000_000,
+                    'x': 480,
+                    'y': 235,
+                    'gravity': 50,
+                    'temperature': 50,
+                    'radiation': 50,
+                    'ironium_conc': 100,
+                    'boranium_conc': 100,
+                    'germanium_conc': 100,
+                    'mines': 1000,
+                    'owner_id': 0,
+                },
+            ]
+        }
+
+        S = engine.GameState(state, {})
+        systems.MiningSystem().process(S.manager)
+
+        planets = S.manager.get_entities('mineral_concentrations')
+        self.assertEqual(len(planets), 1)
+        self.assertEqual(planets[1].ironium or 0, 1000)
+        self.assertEqual(planets[1].germanium or 0, 1000)
+        self.assertEqual(planets[1].boranium or 0, 1000)
+
+    def test_partial_concentration(self):
+        state = {
+            'turn': 2500,
+            'width': 1000,
+            'entities': [
+                {
+                    'pk': 0,
+                    'type': 'species',
+                    'name': 'Human',
+                    'plural_name': 'Humans',
+                    'growth_rate': 15,
+                    'gravity_immune': True,
+                    'temperature_immune': True,
+                    'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
+                },
+                {
+                    'pk': 1,
+                    'type': 'planet',
+                    'population': 1_000_000,
+                    'x': 480,
+                    'y': 235,
+                    'gravity': 50,
+                    'temperature': 50,
+                    'radiation': 50,
+                    'ironium_conc': 50,
+                    'boranium_conc': 50,
+                    'germanium_conc': 50,
+                    'mines': 1000,
+                    'owner_id': 0,
+                },
+            ]
+        }
+
+        S = engine.GameState(state, {})
+        systems.MiningSystem().process(S.manager)
+
+        planets = S.manager.get_entities('mineral_concentrations')
+        self.assertEqual(len(planets), 1)
+        self.assertEqual(planets[1].ironium or 0, 500)
+        self.assertEqual(planets[1].germanium or 0, 500)
+        self.assertEqual(planets[1].boranium or 0, 500)
+
+    def test_depleted(self):
+        state = {
+            'turn': 2500,
+            'width': 1000,
+            'entities': [
+                {
+                    'pk': 0,
+                    'type': 'species',
+                    'name': 'Human',
+                    'plural_name': 'Humans',
+                    'growth_rate': 15,
+                    'gravity_immune': True,
+                    'temperature_immune': True,
+                    'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
+                },
+                {
+                    'pk': 1,
+                    'type': 'planet',
+                    'population': 1_000_000,
+                    'x': 480,
+                    'y': 235,
+                    'gravity': 50,
+                    'temperature': 50,
+                    'radiation': 50,
+                    'ironium_conc': 1,
+                    'boranium_conc': 1,
+                    'germanium_conc': 1,
+                    'mines': 1000,
+                    'owner_id': 0,
+                },
+            ]
+        }
+
+        S = engine.GameState(state, {})
+        systems.MiningSystem().process(S.manager)
+
+        planets = S.manager.get_entities('mineral_concentrations')
+        self.assertEqual(len(planets), 1)
+        self.assertEqual(planets[1].ironium or 0, 10)
+        self.assertEqual(planets[1].germanium or 0, 10)
+        self.assertEqual(planets[1].boranium or 0, 10)
+
+
 class PopulationGrowthTestCase(unittest.TestCase):
     def test_habitability_growth(self):
         state = {
@@ -454,6 +807,14 @@ class PopulationGrowthTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -493,6 +854,14 @@ class PopulationGrowthTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -541,6 +910,14 @@ class PopulationGrowthTestCase(unittest.TestCase):
                     'temperature_max': 64,
                     'radiation_min': 38,
                     'radiation_max': 90,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -586,6 +963,14 @@ class PopulationGrowthTestCase(unittest.TestCase):
                     'temperature_max': 64,
                     'radiation_min': 38,
                     'radiation_max': 90,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -633,6 +1018,14 @@ class PopulationGrowthTestCase(unittest.TestCase):
                     'temperature_max': 64,
                     'radiation_min': 38,
                     'radiation_max': 90,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
@@ -674,6 +1067,14 @@ class PopulationGrowthTestCase(unittest.TestCase):
                     'gravity_immune': True,
                     'temperature_immune': True,
                     'radiation_immune': True,
+                    'population_per_r': 1000,
+                    'factories_produce_r': 10,
+                    'factories_cost_r': 10,
+                    'factories_per_pop': 10,
+                    'factories_cost_less': False,
+                    'minerals_per_m': 10,
+                    'mines_cost_r': 5,
+                    'mines_per_pop': 10,
                 },
                 {
                     'pk': 1,
diff --git a/tests/test_utils.py b/tests/test_utils.py
index df720b8..70b25fd 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -3,19 +3,57 @@ import unittest
 from universe import components, engine, utils
 
 
+class PlanetProductionTestCase(unittest.TestCase):
+    def test_values(self):
+        manager = engine.Manager()
+        manager.register_entity_type('species', [
+            components.SpeciesProductionComponent(),
+        ])
+        manager.register_entity_type('planet', [
+            components.PopulationComponent(),
+        ])
+        engine.Entity.register_manager(manager)
+
+        data = [
+            (700, 0, 0),
+            (700, 699, 0),
+            (700, 700, 1),
+            (700, 1_000_000, 1428),
+            (1000, 0, 0),
+            (1000, 999, 0),
+            (1000, 1000, 1),
+            (1000, 1_000_000, 1000),
+            (1500, 0, 0),
+            (1500, 1499, 0),
+            (1500, 1500, 1),
+            (1500, 1_000_000, 666),
+        ]
+
+        for per, pop, value in data:
+            species = engine.Entity(**{
+                'type': 'species',
+                'population_per_r': per,
+                'minerals_per_m': 5,
+                'mines_cost_r': 2,
+                'mines_per_pop': 5,
+            })
+            planet = engine.Entity(**{
+                'type': 'planet',
+                'population': pop,
+            })
+            self.assertEqual(utils.production(species, planet), value,
+                             f"population_per_r: {per}, population: {pop}")
+
+
 class PlanetValueTestCase(unittest.TestCase):
     def test_values(self):
         manager = engine.Manager()
-        manager._entity_registry = {
-            'species': {
-                'metadata': components.MetadataComponent(),
-                'species': components.SpeciesComponent(),
-            },
-            'planet': {
-                'metadata': components.MetadataComponent(),
-                'environment': components.EnvironmentComponent(),
-            },
-        }
+        manager.register_entity_type('species', [
+            components.SpeciesComponent(),
+        ])
+        manager.register_entity_type('planet', [
+            components.EnvironmentComponent(),
+        ])
         engine.Entity.register_manager(manager)
 
         species = engine.Entity(**

pip install -e .[dev]
parse_log_pytest
pytest
pytest
3.7
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
attrs @ file:///croot/attrs_1668696182826/work
certifi @ file:///croot/certifi_1671487769961/work/certifi
flit_core @ file:///opt/conda/conda-bld/flit-core_1644941570762/work/source/flit_core
importlib-metadata @ file:///tmp/build/80754af9/importlib-metadata_1648562407465/work
iniconfig @ file:///home/linux1/recipes/ci/iniconfig_1610983019677/work
packaging @ file:///croot/packaging_1671697413597/work
pluggy @ file:///tmp/build/80754af9/pluggy_1648042572264/work
py @ file:///opt/conda/conda-bld/py_1644396412707/work
pytest==7.1.2
tomli @ file:///opt/conda/conda-bld/tomli_1657175507142/work
typing_extensions @ file:///croot/typing_extensions_1669924550328/work
-e git+https://github.com/jbradberry/universe.git@5293c19f98028237a09663044c1795040f2777ad#egg=universe
zipp @ file:///croot/zipp_1672387121353/work

name: universe
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - attrs=22.1.0=py37h06a4308_0
  - ca-certificates=2025.2.25=h06a4308_0
  - certifi=2022.12.7=py37h06a4308_0
  - flit-core=3.6.0=pyhd3eb1b0_0
  - importlib-metadata=4.11.3=py37h06a4308_0
  - importlib_metadata=4.11.3=hd3eb1b0_0
  - iniconfig=1.1.1=pyhd3eb1b0_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=1.1.1w=h7f8727e_0
  - packaging=22.0=py37h06a4308_0
  - pip=22.3.1=py37h06a4308_0
  - pluggy=1.0.0=py37h06a4308_1
  - py=1.11.0=pyhd3eb1b0_0
  - pytest=7.1.2=py37h06a4308_0
  - python=3.7.16=h7a1cb2a_0
  - readline=8.2=h5eee18b_0
  - setuptools=65.6.3=py37h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tomli=2.0.1=py37h06a4308_0
  - typing_extensions=4.4.0=py37h06a4308_0
  - wheel=0.38.4=py37h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zipp=3.11.0=py37h06a4308_0
  - zlib=1.2.13=h5eee18b_1
prefix: /opt/conda/envs/universe

tests/test_components.py::SpeciesEnvironmentComponentTestCase::test_immune tests/test_components.py::SpeciesEnvironmentComponentTestCase::test_immune_with_value tests/test_components.py::SpeciesEnvironmentComponentTestCase::test_not_immune tests/test_components.py::SpeciesEnvironmentComponentTestCase::test_not_immune_without_value tests/test_engine.py::PersistenceTestCase::test_species tests/test_systems.py::MiningTestCase::test_depleted tests/test_systems.py::MiningTestCase::test_full_concentration tests/test_systems.py::MiningTestCase::test_no_mines tests/test_systems.py::MiningTestCase::test_partial_concentration tests/test_systems.py::MiningTestCase::test_uninhabited tests/test_utils.py::PlanetProductionTestCase::test_values
tests/test_components.py::ComponentTestCase::test_custom_component_validation tests/test_components.py::ComponentTestCase::test_custom_field_validation tests/test_components.py::ComponentTestCase::test_invalid_type_bar tests/test_components.py::ComponentTestCase::test_invalid_type_foo tests/test_components.py::ComponentTestCase::test_valid_bar tests/test_components.py::ComponentTestCase::test_valid_foo tests/test_components.py::MetadataComponentTestCase::test_missing_required_field tests/test_components.py::MetadataComponentTestCase::test_simple tests/test_components.py::EnvironmentComponentTestCase::test_component_display tests/test_components.py::EnvironmentComponentTestCase::test_display_fields tests/test_components.py::EnvironmentComponentTestCase::test_random tests/test_components.py::MineralConcentrationComponentTestCase::test_random tests/test_components.py::OwnershipComponentTestCase::test_entity_reference tests/test_components.py::OwnershipComponentTestCase::test_null_pointer tests/test_components.py::OwnershipComponentTestCase::test_set_reference tests/test_components.py::OwnershipComponentTestCase::test_wrong_owner_type tests/test_components.py::MovementComponentTestCase::test_no_goal tests/test_components.py::MovementComponentTestCase::test_self_move tests/test_components.py::MovementComponentTestCase::test_two_goals tests/test_components.py::MovementComponentTestCase::test_valid_to_coordinates tests/test_components.py::MovementComponentTestCase::test_valid_to_target tests/test_components.py::MovementComponentTestCase::test_wrong_actor_type tests/test_components.py::MovementComponentTestCase::test_wrong_target_type tests/test_engine.py::EntityTestCase::test_invalid_ownership tests/test_engine.py::PersistenceTestCase::test_empty_universe tests/test_engine.py::PersistenceTestCase::test_planet tests/test_engine.py::PersistenceTestCase::test_ship tests/test_engine.py::MovementTestCase::test_four_way_cycle_intercept tests/test_engine.py::MovementTestCase::test_multi_turn_single_move tests/test_engine.py::MovementTestCase::test_mutual_intercept tests/test_engine.py::MovementTestCase::test_one_object_single_turn_move tests/test_engine.py::MovementTestCase::test_one_stationary_object tests/test_engine.py::MovementTestCase::test_slow_motion tests/test_engine.py::MovementTestCase::test_target_can_barely_reach tests/test_engine.py::MovementTestCase::test_target_moving_object tests/test_engine.py::MovementTestCase::test_target_stationary_object tests/test_engine.py::MovementTestCase::test_target_stops_short tests/test_engine.py::MovementTestCase::test_three_way_cycle_intercept tests/test_fields.py::FieldTestCase::test_explicit_required tests/test_fields.py::FieldTestCase::test_implicit tests/test_fields.py::FieldTestCase::test_not_required tests/test_fields.py::BooleanFieldTestCase::test_bad_type tests/test_fields.py::BooleanFieldTestCase::test_implicit tests/test_fields.py::BooleanFieldTestCase::test_ok tests/test_fields.py::IntFieldTestCase::test_bad_type tests/test_fields.py::IntFieldTestCase::test_explicit_required tests/test_fields.py::IntFieldTestCase::test_in_range tests/test_fields.py::IntFieldTestCase::test_not_required tests/test_fields.py::IntFieldTestCase::test_over_max tests/test_fields.py::IntFieldTestCase::test_ranged_not_required tests/test_fields.py::IntFieldTestCase::test_under_min tests/test_fields.py::CharFieldTestCase::test_bad_type tests/test_fields.py::CharFieldTestCase::test_empty_string_is_valid tests/test_fields.py::CharFieldTestCase::test_explicit_required tests/test_fields.py::CharFieldTestCase::test_not_required tests/test_fields.py::CharFieldTestCase::test_valid_string tests/test_fields.py::ReferenceTestCase::test_bad_type tests/test_fields.py::ReferenceTestCase::test_explicit_required tests/test_fields.py::ReferenceTestCase::test_not_required tests/test_systems.py::UpdateTestCase::test_create tests/test_systems.py::UpdateTestCase::test_create_with_conflict tests/test_systems.py::UpdateTestCase::test_create_with_existing tests/test_systems.py::UpdateTestCase::test_delete tests/test_systems.py::UpdateTestCase::test_delete_does_not_exist tests/test_systems.py::UpdateTestCase::test_issued_by_non_existent_species tests/test_systems.py::UpdateTestCase::test_issued_by_wrong_species tests/test_systems.py::UpdateTestCase::test_issued_for_non_existent_entity tests/test_systems.py::UpdateTestCase::test_reorder tests/test_systems.py::UpdateTestCase::test_reorder_does_not_exist tests/test_systems.py::UpdateTestCase::test_update tests/test_systems.py::UpdateTestCase::test_update_does_not_exist tests/test_systems.py::PopulationGrowthTestCase::test_capacity_growth tests/test_systems.py::PopulationGrowthTestCase::test_crowding_growth tests/test_systems.py::PopulationGrowthTestCase::test_habitability_growth tests/test_systems.py::PopulationGrowthTestCase::test_lose_ownership tests/test_systems.py::PopulationGrowthTestCase::test_overcrowding_growth tests/test_systems.py::PopulationGrowthTestCase::test_uninhabitable_growth tests/test_utils.py::PlanetValueTestCase::test_values
universe/utils.py universe/components.py universe/systems.py universe/engine.py
universe/utils.py universe/components.py universe/systems.py universe/engine.py