Add life rules and implement tests for civilization and life states

- Introduced a new file `life_rules.txt` to define life progression and degradation rules.
- Created `test_civilization_state.gd` to validate the functionality of civilization rules and ethics.
- Added `test_life.gd` to test life evolution, including initialization, batch processing, and state validation.
- Implemented `civilization_state.gd` to manage civilization ethics and space civilization detachment logic.
- Ensured all tests check for expected outcomes and validate state integrity after operations.
This commit is contained in:
Kirill
2026-09-15 23:05:05 +03:00
parent cc2700d507
commit 5f3875bc14
16 changed files with 673 additions and 25 deletions
+36
View File
@@ -0,0 +1,36 @@
extends RefCounted
const ETHICS = ["m", "l", "w", "d", "c", "b"]
static func is_space_civilization(id: String, rules: Dictionary) -> bool:
return rules.has(id) and str(rules[id].home) != ""
# Preserve any existing ethics. New populations start equally distributed;
# reductions (including blasts) are proportional and never leave negative counts.
static func reconcile(world, target: Dictionary, rules: Dictionary) -> void:
if not target.has("ethics"):
target.ethics = {}
var physical: Dictionary = world._physical_amounts(target)
for id in rules:
var count: float = physical.get(id, 0.0)
var current: Dictionary = target.ethics.get(id, {})
var total: float = 0.0
for ethic in ETHICS:
current[ethic] = maxf(0.0, current.get(ethic, 0.0))
total += current[ethic]
if total > count:
current = world._allocate_whole(current, count)
elif total < count:
var missing: float = count - total
var base: float = floorf(missing / ETHICS.size())
var rest: int = int(missing - base * ETHICS.size())
for i in ETHICS.size():
current[ETHICS[i]] += base + (1.0 if i < rest else 0.0)
target.ethics[id] = current
static func detach_space_civilizations(target: Dictionary, rules: Dictionary) -> void:
for held: Dictionary in target.binds.values():
for id in held:
if is_space_civilization(id, rules):
target.amount[id] = target.amount.get(id, 0.0) + held[id]
held[id] = 0.0
@@ -0,0 +1 @@
uid://bw88euxvptt51
+81 -4
View File
@@ -1,5 +1,6 @@
extends Node
class_name Owniverse
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
#var state: State = State.new()
@@ -62,8 +63,26 @@ func migrate_time_precision() -> void:
_evolute_to_datetime(float(state.time.datetime))
func _evolute_to_datetime(datetime: float) -> 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)
if state.get("life_version", 0) >= 2:
return
var physical: Dictionary = _physical_amounts(state)
for id in entities:
if entities[id].life_rule.is_empty():
continue
var tracked: float = 0.0
for batch: Dictionary in state.batches.get(id, []):
tracked += batch.amount
_append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime)
state.life_version = 2
new_state = state
func _evolute_to_datetime(datetime: float) -> void:
initialize_life_batches()
var previous: Dictionary = state
var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION
@@ -379,7 +398,61 @@ func _add_output_terms(outputs: Dictionary, terms: String, count: float) -> void
outputs[id] = outputs.get(id, 0.0) + count * amount
# One life attempt: progress, degradation, or retry at the same stage.
# Retried worlds are not new planets and must not increment production statistics.
func _life_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary:
var rule: Dictionary = entity.life_rule
var pending: Dictionary = {}
if not target.has("life_remainders"):
target.life_remainders = {}
for channel in ["progress", "degradation"]:
var key: String = entity.id + ":" + channel
var expected: float = count * float(rule[channel]) + target.life_remainders.get(key, 0.0)
target.life_remainders[key] = expected
pending[channel] = floorf(snappedf(expected, 1.0e-12))
var assigned: Dictionary = _allocate_whole(pending, count)
for channel in assigned:
var key: String = entity.id + ":" + channel
target.life_remainders[key] = maxf(0.0, target.life_remainders[key] - assigned[channel])
var result: Dictionary = {entity.id: count - assigned.progress - assigned.degradation}
result[rule.next] = assigned.progress
var weights: Dictionary = {}
var weight_sum: float = 0.0
for term in str(rule.fall).split("^"):
var pair: PackedStringArray = term.split(":")
weights[pair[0]] = float(pair[1])
weight_sum += float(pair[1])
var requests: Dictionary = {}
for id in weights:
var key: String = entity.id + ":fall:" + id
var expected: float = assigned.degradation * weights[id] / weight_sum + target.life_remainders.get(key, 0.0)
target.life_remainders[key] = expected
requests[id] = maxf(0.0, floorf(snappedf(expected, 1.0e-12)))
var falls: Dictionary = _allocate_whole(requests, assigned.degradation)
var distributed: float = 0.0
for id in falls:
distributed += falls[id]
result[id] = result.get(id, 0.0) + falls[id]
var key: String = entity.id + ":fall:" + id
target.life_remainders[key] -= falls[id]
# Fill sub-unit fall quotas deterministically, retaining signed allocation errors.
while distributed < assigned.degradation:
var selected: String = ""
var largest: float = -INF
for id in weights:
var rest: float = target.life_remainders[entity.id + ":fall:" + id]
if rest > largest:
largest = rest
selected = id
result[selected] = result.get(selected, 0.0) + 1.0
target.life_remainders[entity.id + ":fall:" + selected] -= 1.0
distributed += 1.0
return result
func _evolution_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary:
if not entity.life_rule.is_empty():
return _life_products(target, entity, count)
var outputs: Dictionary = {}
for id in entity.output_and:
outputs[id] = count * maxf(0.0, entity.output_and[id])
@@ -587,12 +660,12 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
incoming[heir] = {}
incoming[heir][id] = incoming[heir].get(id, 0.0) + released * (1.0 - fraction) * heirs[heir]
for child in outputs.get(id, {}):
if entities.has(child):
if entities.has(child) and not CivilizationState.is_space_civilization(child, Global.CivilizationRules):
incoming[heir][child] = incoming[heir].get(child, 0.0) + released * fraction * heirs[heir] * outputs[id][child]
var remaining: float = held * owner_survival * (1.0 - fraction)
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + remaining
for successor in outputs.get(id, {}):
if not entities.has(successor):
if not entities.has(successor) or CivilizationState.is_space_civilization(successor, Global.CivilizationRules):
continue
var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor])
if not incoming.has(owner_id):
@@ -614,7 +687,10 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
continue
if not entities.has(successor):
continue
amount = _credit(target, successor, amount)
if successor == id and not entities[id].life_rule.is_empty():
target.amount[id] = target.amount.get(id, 0.0) + amount
else:
amount = _credit(target, successor, amount)
var before_batches: Array = target.batches.get(successor, []).duplicate(true)
_append_batch(target, successor, amount, part.from, part.to)
_produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches)
@@ -629,6 +705,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)
return true
+3 -1
View File
@@ -8,6 +8,7 @@ var cost: Dictionary = {}
var bind: Dictionary = {}
var mass: float
var space: int
var life_rule: Dictionary = {}
var lifespan: float
var lifespan_delta: float
var auto: float
@@ -42,7 +43,8 @@ func _init(values: Dictionary) -> void:
#print(id, " | ", cost, " | ", bind)
mass = float(values.mass)
space = int(values.space)
lifespan = float(values.lifespan)
life_rule = Global.LifeRules.get(id, {}).duplicate(true)
lifespan = float(life_rule.span) if not life_rule.is_empty() else float(values.lifespan)
lifespan_delta = lifespan / Global.batch_count if lifespan > 10 else lifespan
auto = float(values.auto)
gamma = float(values.gamma)