Refactor save manager validation and update state structure

- Updated the save manager to enforce a new format version (2) and improved validation checks for the save data structure.
- Enhanced the `validate_data` function to ensure all required fields are present and valid.
- Adjusted the handling of legacy saves and introduced new checks for various state fields.
- Improved number formatting in the global script for better readability of large numbers.
- Updated tests to reflect changes in the save manager and validation logic, ensuring compatibility with the new state structure.
- Added new tests for group indexing and production transactions to ensure correctness of entity management and resource production.
This commit is contained in:
Kirill
2026-09-20 20:56:15 +03:00
parent 26347fca30
commit a09e3df220
15 changed files with 295 additions and 154 deletions
+77 -33
View File
@@ -10,6 +10,9 @@ var entities: Dictionary[String, OwniverseEntity] = {}
var entity_groups: Dictionary[String, Array] = {}
var entity_event_groups: Dictionary[String, Array] = {}
var _group_members: Dictionary = {}
var _indexed_entity_count: int = -1
var items_to_update: Dictionary[String, float] = {}
var auto_production: Array = []
@@ -32,6 +35,18 @@ var state: Dictionary = {
'amount': {},
'distribution': {},
'auto_resources': {},
'auto_star_deficits': {},
'production_remainders': {},
'resource_remainders': {'basic': {}, 'automatic': {}},
'evolution_remainders': {},
'life_remainders': {},
'planet_remainders': {},
'blast_remainders': {},
'destruction_remainders': {},
'civilization_remainders': {},
'civilization_stats': {},
'civilization_homes': {},
'ethics': {},
'stats': {
'total_amount': {},
}
@@ -57,19 +72,9 @@ func evolute(delta: float) -> void:
_evolute_to_datetime(datetime)
## Legacy saves may have up to 0.9 unprocessed years. Process them exactly once,
## without changing accumulated time or discarding production/evolution.
func migrate_time_precision() -> void:
if state.version == SimulationTime.LEGACY_VERSION:
_evolute_to_datetime(float(state.time.datetime))
# 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.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
if state.get("life_version", 0) >= 3:
return
var physical: Dictionary = _physical_amounts(state)
for id in entities:
if entities[id].life_rule.is_empty():
@@ -78,19 +83,22 @@ func initialize_life_batches() -> void:
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 = 3
new_state = state
func _evolute_to_datetime(datetime: float) -> void:
initialize_life_batches()
# Reconcile only when simulation advances.
# Fractional frame updates cannot change capacity or populations.
var processed_year: float = SimulationTime.processed_year(datetime)
if processed_year > state.time.year:
initialize_life_batches()
var previous: Dictionary = state
var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
next.speed_in_year = pow(10.0, next.speed - 1)
var from_year: float = previous.time.year
var to_year: float = SimulationTime.processed_year(datetime)
var to_year: float = processed_year
if to_year > from_year:
var quantities: Dictionary = _physical_amounts(previous)
var distribution: Dictionary = _normalize_distribution(quantities)
@@ -147,13 +155,26 @@ func _add_time_delta(delta: float) -> Dictionary:
return {"datetime": new_datetime, "year": SimulationTime.processed_year(new_datetime)}
## Rebuild after changing group_as or replacing definitions without changing their count.
## Adding/removing definitions is detected automatically during initialization.
func rebuild_group_index() -> void:
_group_members.clear()
for id in entities:
var group: String = entities[id].group_as
if not _group_members.has(group):
_group_members[group] = []
_group_members[group].append(id)
_indexed_entity_count = entities.size()
func _ensure_group_index() -> void:
if _indexed_entity_count != entities.size():
rebuild_group_index()
func _is_group(id: String) -> bool:
if entity_groups.has(id):
return true
for entity: OwniverseEntity in entities.values():
if entity.group_as == id:
return true
return false
_ensure_group_index()
return entity_groups.has(id) or _group_members.has(id)
func _physical_amounts(source: Dictionary) -> Dictionary:
@@ -171,11 +192,8 @@ func _physical_amounts(source: Dictionary) -> Dictionary:
func _members(id: String) -> Array:
if not _is_group(id):
return [id]
var members: Array = []
for key in entities:
if entities[key].group_as == id:
members.append(key)
return members
# Callers may modify the returned array without changing the index.
return _group_members.get(id, []).duplicate()
func _available(source: Dictionary, id: String) -> float:
@@ -342,6 +360,12 @@ func _append_batch(target: Dictionary, id: String, amount: float,
var window_index: float = floorf((cursor - origin) / batch_duration)
if origin + (window_index + 1.0) * batch_duration <= cursor:
window_index += 1.0
var batches_by_start: Dictionary = {}
for existing: Dictionary in batches:
var start: float = existing.get("created_from", -INF)
if not batches_by_start.has(start):
batches_by_start[start] = []
batches_by_start[start].append(existing)
while true:
var window_start: float = origin + window_index * batch_duration
var window_end: float = origin + (window_index + 1.0) * batch_duration
@@ -353,7 +377,7 @@ func _append_batch(target: Dictionary, id: String, amount: float,
if duration > 0.0 and part_end < created_to:
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
var batch: Dictionary = {}
for existing: Dictionary in batches:
for existing: Dictionary in batches_by_start.get(window_start, []):
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
batch = existing
break
@@ -366,6 +390,9 @@ func _append_batch(target: Dictionary, id: String, amount: float,
"amount": 0.0,
}
batches.append(batch)
if not batches_by_start.has(window_start):
batches_by_start[window_start] = []
batches_by_start[window_start].append(batch)
batch.amount += part_amount
remaining_amount -= part_amount
if duration == 0.0 or part_end >= created_to:
@@ -548,12 +575,13 @@ func _produce_newborn_lifetime(target: Dictionary, id: String, count: float,
if products.is_empty() or count <= 0.0:
return
var object_years: float = count * maxf(0.0, to_year - (born_from + born_to) * 0.5)
var previous_amounts: Dictionary = {}
for old: Dictionary in before_batches:
var start: float = old.get("created_from", -INF)
if not previous_amounts.has(start):
previous_amounts[start] = old.amount
for batch: Dictionary in target.batches.get(id, []):
var added: float = batch.amount
for old: Dictionary in before_batches:
if old.get("created_from", -INF) == batch.get("created_from", INF):
added -= old.amount
break
var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0)
object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year)
object_years = maxf(0.0, object_years)
for resource in products:
@@ -623,7 +651,10 @@ func _evolve_batches(target: Dictionary, to_year: float) -> void:
func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
var quantities: Dictionary = _physical_amounts(target)
var expired: Dictionary = {}
var preview: Dictionary = target.duplicate(true)
var preview: Dictionary = {"batches": {}}
for id in target.batches:
if entities.has(id) and entities[id].destroy_level > 0:
preview.batches[id] = target.batches[id].duplicate(true)
for id in target.batches.keys():
if not entities.has(id) or entities[id].destroy_level <= 0:
continue
@@ -741,7 +772,11 @@ func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
# Plan all bindings on a copy, reserving specific types before broader groups.
func _bind_full(target: Dictionary, amounts: Dictionary) -> bool:
var trial: Dictionary = target.duplicate(true)
# Binding only mutates free amounts and owners' holdings.
var trial: Dictionary = {
"amount": target.amount.duplicate(),
"binds": target.binds.duplicate(true),
}
var requests: Array = []
for owner_id in amounts:
if not entities.has(owner_id):
@@ -777,7 +812,12 @@ func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
automatic: bool = false) -> bool:
if amount <= 0.0 or amount != floorf(amount):
return false
var trial: Dictionary = target.duplicate(true)
var trial: Dictionary = target.duplicate()
trial.amount = target.amount.duplicate()
trial.binds = target.binds.duplicate(true)
trial.auto_resources = target.auto_resources.duplicate()
trial.batches = target.batches.duplicate()
var copied_batches: Dictionary = {}
for resource in entity.cost:
var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount)
if automatic and trial.auto_resources.get(resource, 0.0) < cost:
@@ -792,6 +832,10 @@ func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
if automatic:
trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost)
for id in taken:
# Isolate only batches actually touched by this transaction.
if trial.batches.has(id) and not copied_batches.has(id):
trial.batches[id] = trial.batches[id].duplicate(true)
copied_batches[id] = true
var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0
_scale_batches(trial, id, factor)
if not _bind_new_owners(trial, {entity.id: amount}, true):
-1
View File
@@ -1,7 +1,6 @@
extends RefCounted
## datetime is accumulated time; year is the last processed tenth of a year.
const STATE_VERSION: String = "0.1.1"
const LEGACY_VERSION: String = "0.1.0"
static func processed_year(datetime: float) -> float:
# Remove only sub-nanotick accumulation noise at decimal boundaries.