Update export presets, project version, and enhance civilization simulation rules

- Updated export path and version code in export_presets.cfg
- Changed project version in project.godot to 0.7.0
- Expanded simulation rules in simulation_rules.md to clarify resource management
- Improved testing framework in tests/README.md to include new civilization budget checks
- Added new tests for batch boundaries and civilization state transitions
- Enhanced civilization_state.gd with shared budget and transition logic
- Refactored owniverse.gd to enforce capacity during life batch initialization
This commit is contained in:
Kirill
2026-09-16 07:58:19 +03:00
parent 5f3875bc14
commit 5ea65a7e9b
9 changed files with 250 additions and 8 deletions
+107
View File
@@ -34,3 +34,110 @@ static func detach_space_civilizations(target: Dictionary, rules: Dictionary) ->
if is_space_civilization(id, rules):
target.amount[id] = target.amount.get(id, 0.0) + held[id]
held[id] = 0.0
# A shared budget per stellar family, including stars bound into structures.
# Reservations affect capacity, not the physical stock or its production.
static func stellar_budget(world, target: Dictionary, rules: Dictionary) -> Dictionary:
var physical: Dictionary = world._physical_amounts(target)
var capacity: Dictionary = {}
for id in physical:
var entity = world.entities[id]
for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]:
if entity.event_group == home or entity.count_as == home or id == home:
capacity[home] = capacity.get(home, 0.0) + physical[id]
capacity["SMBH"] = maxf(0.0, target.amount.get("SMBH", 0.0))
for held: Dictionary in target.binds.values():
capacity["SMBH"] += maxf(0.0, held.get("SMBH", 0.0))
var populations: Dictionary = {}
var requested: Dictionary = {}
for id in rules:
if not is_space_civilization(id, rules):
continue
populations[id] = physical.get(id, 0.0)
var home: String = rules[id].home
requested[home] = requested.get(home, 0.0) + populations[id] * maxf(1.0, rules[id].stars)
var free: Dictionary = {}
for home in capacity:
free[home] = maxf(0.0, capacity[home] - requested.get(home, 0.0))
return {"capacity": capacity, "requested": requested, "free": free, "populations": populations}
# Account for the source's existing reservation when changing stages. A failed
# transition retains its population at the source, rather than losing objects.
static func admit_transition(budget: Dictionary, rules: Dictionary, source: String,
destination: String, requested: float) -> float:
if source == destination or not is_space_civilization(destination, rules):
return requested
var home: String = rules[destination].home
var cost: float = maxf(1.0, rules[destination].stars)
var old_home: String = ""
var old_cost: float = 0.0
if is_space_civilization(source, rules):
old_home = rules[source].home
old_cost = maxf(1.0, rules[source].stars)
# Existing overcapacity is handled by the separate population policy.
if budget.requested.get(home, 0.0) > budget.capacity.get(home, 0.0):
return 0.0
var net_cost: float = cost - old_cost if old_home == home else cost
var admitted: float = floorf(maxf(0.0, requested))
if net_cost > 0.0:
admitted = minf(admitted, floorf(budget.free.get(home, 0.0) / net_cost))
if old_home != "":
budget.requested[old_home] = maxf(0.0, budget.requested.get(old_home, 0.0) - admitted * old_cost)
budget.free[old_home] = maxf(0.0, budget.capacity.get(old_home, 0.0) - budget.requested[old_home])
budget.requested[home] = budget.requested.get(home, 0.0) + admitted * cost
budget.free[home] = maxf(0.0, budget.capacity.get(home, 0.0) - budget.requested[home])
return admitted
# Resolve shortages from the most advanced stages downwards. This gives existing
# advanced populations priority and lets a demotion cascade in the same cycle.
static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: float) -> void:
detach_space_civilizations(target, rules)
reconcile(world, target, rules)
var budget: Dictionary = stellar_budget(world, target, rules)
var remaining: Dictionary = budget.capacity.duplicate()
var stages: Array = []
for id in rules:
if is_space_civilization(id, rules):
stages.append(id)
stages.sort()
for index in range(stages.size() - 1, -1, -1):
var id: String = stages[index]
var home: String = rules[id].home
var cost: float = maxf(1.0, rules[id].stars)
var count: float = target.amount.get(id, 0.0)
var supported: float = minf(count, floorf(remaining.get(home, 0.0) / cost))
remaining[home] = maxf(0.0, remaining.get(home, 0.0) - supported * cost)
var excess: float = count - supported
if excess <= 0.0:
continue
var previous: String = stages[index - 1] if index > 0 else "C1.9"
world._scale_batches(target, id, supported / count)
target.amount[id] = supported
world._credit(target, previous, excess)
world._append_batch(target, previous, excess, year, year)
var removed: Dictionary = world._allocate_whole(target.ethics[id], excess)
if not target.ethics.has(previous):
target.ethics[previous] = {}
for ethic in ETHICS:
target.ethics[id][ethic] -= removed[ethic]
target.ethics[previous][ethic] = target.ethics[previous].get(ethic, 0.0) + removed[ethic]
if previous == "C1.9":
var stars: Dictionary = {}
var physical: Dictionary = world._physical_amounts(target)
for star in physical:
if world.entities[star].event_group == "DwStar" and physical[star] > 0.0:
stars[star] = physical[star]
var weights: Dictionary = world._normalize_distribution(stars)
# Worlds are aggregates: a star may host several inhabited planets.
var assigned: float = 0.0
var owners: Array = weights.keys()
owners.sort()
for i in owners.size():
var owner: String = owners[i]
var amount: float = floorf(excess * weights[owner]) if i + 1 < owners.size() else excess - assigned
assigned += amount
if not target.binds.has(owner):
target.binds[owner] = {}
target.binds[owner][previous] = target.binds[owner].get(previous, 0.0) + amount
target.amount[previous] -= amount
reconcile(world, target, rules)
+21 -4
View File
@@ -66,7 +66,7 @@ func migrate_time_precision() -> void:
# Old saves have counts but no life ages. Start only missing cohorts at load time.
func initialize_life_batches() -> void:
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
CivilizationState.reconcile(self, state, Global.CivilizationRules)
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
if state.get("life_version", 0) >= 2:
return
var physical: Dictionary = _physical_amounts(state)
@@ -327,10 +327,17 @@ func _append_batch(target: Dictionary, id: String, amount: float,
var cursor: float = created_from
var duration: float = created_to - created_from
var remaining_amount: float = amount
# Compute the index once. Recomputing floor from a rounded boundary can
# select the preceding window forever (e.g. fractional origins at large ages).
var window_index: float = floorf((cursor - origin) / batch_duration)
if origin + (window_index + 1.0) * batch_duration <= cursor:
window_index += 1.0
while true:
var window_index: float = floorf((cursor - origin) / batch_duration)
var window_start: float = origin + window_index * batch_duration
var window_end: float = origin + (window_index + 1.0) * batch_duration
if window_end <= cursor:
push_error("Batch window exceeds floating-point time precision: " + id)
return
var part_end: float = minf(created_to, window_end)
var part_amount: float = remaining_amount
if duration > 0.0 and part_end < created_to:
@@ -354,6 +361,7 @@ func _append_batch(target: Dictionary, id: String, amount: float,
if duration == 0.0 or part_end >= created_to:
break
cursor = window_end
window_index += 1.0
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.created_from < b.created_from)
@@ -628,6 +636,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
return false
var fractions: Dictionary = {}
var outputs: Dictionary = {}
var star_budget: Dictionary = CivilizationState.stellar_budget(self, target, Global.CivilizationRules)
for id in expired:
var total: float = 0.0
for part: Dictionary in expired[id]:
@@ -636,6 +645,12 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
var combined: Dictionary = {}
for part: Dictionary in expired[id]:
part.products = _evolution_products(target, entities[id], part.amount)
for successor in part.products.keys():
var requested: float = part.products[successor]
var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
part.products[successor] = admitted
if admitted < requested:
part.products[id] = part.products.get(id, 0.0) + requested - admitted
for successor in part.products:
combined[successor] = combined.get(successor, 0.0) + part.products[successor]
outputs[id] = {}
@@ -687,7 +702,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
continue
if not entities.has(successor):
continue
if successor == id and not entities[id].life_rule.is_empty():
if successor == id and (not entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)):
target.amount[id] = target.amount.get(id, 0.0) + amount
else:
amount = _credit(target, successor, amount)
@@ -705,7 +720,7 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
_bind_new_owners(target, new_owners)
_round_object_allocations(target)
_apply_blast(target, blast_power, environment)
CivilizationState.reconcile(self, target, Global.CivilizationRules)
CivilizationState.enforce_capacity(self, target, Global.CivilizationRules, to_year)
return true
@@ -1116,6 +1131,8 @@ func produce(item_id: String, desired_amount: float) -> float:
return 0.0
var max_producible = floorf(calculate_max_producible(entity, desired_amount))
var budget: Dictionary = CivilizationState.stellar_budget(self, state, Global.CivilizationRules)
max_producible = CivilizationState.admit_transition(budget, Global.CivilizationRules, "", item_id, max_producible)
if max_producible <= 0:
return 0.0