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:
+17
-2
@@ -59,8 +59,23 @@ func get_description(item_id: String, items: Array = []) -> String:
|
|||||||
|
|
||||||
func format_number(value: float, digits: int = 0) -> String:
|
func format_number(value: float, digits: int = 0) -> String:
|
||||||
if value == 0: return ""
|
if value == 0: return ""
|
||||||
if value < 10_000:
|
# Keep ordinary quantities readable, grouping thousands with spaces.
|
||||||
return "%.*f" % [digits, value]
|
# Scientific notation is reserved for values whose fixed form would be
|
||||||
|
# unwieldy in the UI.
|
||||||
|
if absf(value) < 1_000_000_000.0:
|
||||||
|
var fixed: String = "%.*f" % [digits, value]
|
||||||
|
var parts: PackedStringArray = fixed.split(".", true)
|
||||||
|
var integer: String = parts[0]
|
||||||
|
var sign: String = ""
|
||||||
|
if integer.begins_with("-") or integer.begins_with("+"):
|
||||||
|
sign = integer.left(1)
|
||||||
|
integer = integer.substr(1)
|
||||||
|
var grouped: String = ""
|
||||||
|
while integer.length() > 3:
|
||||||
|
grouped = " " + integer.right(3) + grouped
|
||||||
|
integer = integer.left(integer.length() - 3)
|
||||||
|
grouped = sign + integer + grouped
|
||||||
|
return grouped + ("." + parts[1] if parts.size() > 1 else "")
|
||||||
|
|
||||||
var exp_value = int(floor(log(abs(value)) / log(10.0)))
|
var exp_value = int(floor(log(abs(value)) / log(10.0)))
|
||||||
var mantissa = value / pow(10.0, exp_value)
|
var mantissa = value / pow(10.0, exp_value)
|
||||||
|
|||||||
+60
-75
@@ -5,7 +5,7 @@ extends Node
|
|||||||
const SETTING_SECTION := "Settings"
|
const SETTING_SECTION := "Settings"
|
||||||
const SAVE_PATH = "user://settings.cfg"
|
const SAVE_PATH = "user://settings.cfg"
|
||||||
|
|
||||||
const FORMAT_VERSION: int = 1
|
const FORMAT_VERSION: int = 2
|
||||||
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
|
||||||
const STATE_VERSION: String = SimulationTime.STATE_VERSION
|
const STATE_VERSION: String = SimulationTime.STATE_VERSION
|
||||||
var last_error: String = ""
|
var last_error: String = ""
|
||||||
@@ -113,44 +113,44 @@ func _plain_tree(value: Variant) -> bool:
|
|||||||
|
|
||||||
## Return a diagnostic path on failure; an empty string means the structure is valid.
|
## Return a diagnostic path on failure; an empty string means the structure is valid.
|
||||||
func validate_data(data: Variant) -> String:
|
func validate_data(data: Variant) -> String:
|
||||||
if not data is Dictionary or not data.get("state") is Dictionary:
|
if not data is Dictionary or not _plain_tree(data):
|
||||||
|
return "save must contain plain finite values"
|
||||||
|
if data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String:
|
||||||
|
return "unsupported save format"
|
||||||
|
if not data.get("ui") is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
|
||||||
|
return "invalid ui.current_page"
|
||||||
|
if not data.get("state") is Dictionary:
|
||||||
return "state must be a Dictionary"
|
return "state must be a Dictionary"
|
||||||
if not _plain_tree(data):
|
|
||||||
return "unsupported value or non-finite number"
|
|
||||||
# Legacy files contained only {state}; accept their known state schema.
|
|
||||||
if data.has("format_version") and (not data.format_version is int or data.format_version != FORMAT_VERSION):
|
|
||||||
return "unsupported format_version"
|
|
||||||
if data.has("ui"):
|
|
||||||
if not data.ui is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
|
|
||||||
return "invalid ui.current_page"
|
|
||||||
var saved: Dictionary = data.state
|
var saved: Dictionary = data.state
|
||||||
if saved.get("version") not in [STATE_VERSION, SimulationTime.LEGACY_VERSION]:
|
var required := [
|
||||||
|
"version", "time", "speed", "speed_in_year", "energy", "batches", "batch_origins",
|
||||||
|
"binds", "amount", "distribution", "auto_resources", "auto_star_deficits",
|
||||||
|
"production_remainders", "resource_remainders", "evolution_remainders", "life_remainders",
|
||||||
|
"planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders",
|
||||||
|
"civilization_stats", "civilization_homes", "ethics", "stats",
|
||||||
|
]
|
||||||
|
if saved.size() != required.size():
|
||||||
|
return "unsupported state structure"
|
||||||
|
for field in required:
|
||||||
|
if not saved.has(field):
|
||||||
|
return "missing state." + field
|
||||||
|
if saved.version != STATE_VERSION:
|
||||||
return "unsupported state.version"
|
return "unsupported state.version"
|
||||||
if not saved.get("time") is Dictionary:
|
if not saved.time is Dictionary or not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")):
|
||||||
return "state.time must be a Dictionary"
|
|
||||||
if not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")):
|
|
||||||
return "invalid state.time"
|
return "invalid state.time"
|
||||||
if not is_finite(float(saved.time.datetime) * 10.0):
|
if not is_finite(float(saved.time.datetime) * 10.0) or saved.time.year != SimulationTime.processed_year(saved.time.datetime):
|
||||||
return "state.time exceeds supported precision range"
|
return "invalid processed time"
|
||||||
var expected_year: float = SimulationTime.processed_year(saved.time.datetime)
|
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
|
||||||
if saved.version == SimulationTime.LEGACY_VERSION:
|
|
||||||
expected_year = floorf(saved.time.datetime)
|
|
||||||
if saved.time.year != expected_year:
|
|
||||||
return "state.time.year does not match the processed time for this version"
|
|
||||||
if not _number(saved.get("speed"), true) or saved.speed < 1 or saved.speed > Global.SPEED_LIMIT:
|
|
||||||
return "invalid state.speed"
|
return "invalid state.speed"
|
||||||
if not _number(saved.get("speed_in_year")) or saved.speed_in_year != pow(10.0, saved.speed - 1):
|
if not _number(saved.speed_in_year) or saved.speed_in_year != pow(10.0, saved.speed - 1):
|
||||||
return "invalid state.speed_in_year"
|
return "invalid state.speed_in_year"
|
||||||
if not _number(saved.get("energy"), true):
|
if not _number(saved.energy, true) or not _number_map(saved.amount, true):
|
||||||
return "invalid state.energy"
|
return "invalid state resources"
|
||||||
if not _number_map(saved.get("amount"), true):
|
if not saved.stats is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
|
||||||
return "invalid state.amount"
|
|
||||||
if not saved.get("stats") is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
|
|
||||||
return "invalid state.stats.total_amount"
|
return "invalid state.stats.total_amount"
|
||||||
for id in saved.amount:
|
if saved.amount.keys() != saved.stats.total_amount.keys():
|
||||||
if not saved.stats.total_amount.has(id):
|
return "amount and total_amount keys differ"
|
||||||
return "missing total_amount for " + id
|
if not saved.binds is Dictionary or not saved.batches is Dictionary:
|
||||||
if not saved.get("binds") is Dictionary or not saved.get("batches") is Dictionary:
|
|
||||||
return "state.binds and state.batches must be Dictionaries"
|
return "state.binds and state.batches must be Dictionaries"
|
||||||
for owner_id in saved.binds:
|
for owner_id in saved.binds:
|
||||||
if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true):
|
if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true):
|
||||||
@@ -162,57 +162,42 @@ func validate_data(data: Variant) -> String:
|
|||||||
if not saved.amount.has(id) or not saved.batches[id] is Array:
|
if not saved.amount.has(id) or not saved.batches[id] is Array:
|
||||||
return "invalid state.batches." + id
|
return "invalid state.batches." + id
|
||||||
for batch in saved.batches[id]:
|
for batch in saved.batches[id]:
|
||||||
if not batch is Dictionary:
|
if not batch is Dictionary or not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")):
|
||||||
return "batch must be a Dictionary"
|
|
||||||
if not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")):
|
|
||||||
return "invalid batch in " + id
|
return "invalid batch in " + id
|
||||||
if batch.end_year < batch.start_year:
|
if batch.end_year < batch.start_year:
|
||||||
return "reversed batch interval in " + id
|
return "reversed batch interval in " + id
|
||||||
if batch.has("created_from") or batch.has("created_to"):
|
if batch.has("created_from") != batch.has("created_to"):
|
||||||
if not _number(batch.get("created_from")) or not _number(batch.get("created_to")):
|
return "incomplete batch creation interval in " + id
|
||||||
return "invalid batch creation interval in " + id
|
if batch.has("created_from") and (not _number(batch.created_from) or not _number(batch.created_to) or batch.created_to <= batch.created_from or batch.start_year < batch.created_from):
|
||||||
if batch.created_to <= batch.created_from or batch.start_year < batch.created_from:
|
return "invalid batch creation interval in " + id
|
||||||
return "reversed batch creation interval in " + id
|
if batch.has("processed_until") and (not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year):
|
||||||
if batch.has("processed_until"):
|
return "invalid batch.processed_until in " + id
|
||||||
if not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year:
|
|
||||||
return "invalid batch.processed_until in " + id
|
|
||||||
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
|
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
|
||||||
return "invalid batch.withdraw_remainder in " + id
|
return "invalid batch.withdraw_remainder in " + id
|
||||||
for field in ["batch_origins", "distribution", "auto_resources", "production_remainders", "auto_star_deficits", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders"]:
|
for field in ["batch_origins", "distribution", "production_remainders", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "life_remainders", "civilization_remainders", "civilization_stats"]:
|
||||||
if saved.has(field) and not _number_map(saved[field], field == "auto_resources", field != "auto_star_deficits"):
|
if not _number_map(saved[field], false, false):
|
||||||
return "invalid state." + field
|
return "invalid state." + field
|
||||||
if saved.has("ethics"):
|
if not _number_map(saved.auto_resources, true) or not _number_map(saved.auto_star_deficits, false, false):
|
||||||
if not saved.ethics is Dictionary:
|
return "invalid automatic production state"
|
||||||
return "invalid state.ethics"
|
if not saved.resource_remainders is Dictionary or saved.resource_remainders.keys().size() != 2 or not saved.resource_remainders.has("basic") or not saved.resource_remainders.has("automatic"):
|
||||||
for id in saved.ethics:
|
return "invalid state.resource_remainders"
|
||||||
if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true):
|
for channel in ["basic", "automatic"]:
|
||||||
return "invalid state.ethics." + str(id)
|
if not _number_map(saved.resource_remainders[channel], false, false):
|
||||||
for ethic in saved.ethics[id]:
|
return "invalid state.resource_remainders." + channel
|
||||||
if ethic not in ["m", "l", "w", "d", "c", "b"]:
|
if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary:
|
||||||
return "invalid ethic " + str(ethic)
|
return "invalid civilization state"
|
||||||
if saved.has("life_version") and saved.life_version not in [1, 2, 3]:
|
for id in saved.ethics:
|
||||||
return "invalid state.life_version"
|
if not Global.CivilizationRules.has(id) or not _number_map(saved.ethics[id], true):
|
||||||
if saved.has("civilization_remainders") and not _number_map(saved.civilization_remainders, false, false):
|
return "invalid state.ethics." + str(id)
|
||||||
return "invalid state.civilization_remainders"
|
for ethic in saved.ethics[id]:
|
||||||
if saved.has("civilization_stats") and not _number_map(saved.civilization_stats):
|
if ethic not in ["m", "l", "w", "d", "c", "b"]:
|
||||||
return "invalid state.civilization_stats"
|
return "invalid ethic " + str(ethic)
|
||||||
if saved.has("civilization_homes"):
|
for id in saved.civilization_homes:
|
||||||
if not saved.civilization_homes is Dictionary:
|
if not _number_map(saved.civilization_homes[id], true):
|
||||||
return "invalid state.civilization_homes"
|
return "invalid civilization homes"
|
||||||
for id in saved.civilization_homes:
|
|
||||||
if not _number_map(saved.civilization_homes[id], true):
|
|
||||||
return "invalid civilization homes"
|
|
||||||
if saved.has("life_remainders") and not _number_map(saved.life_remainders, false, false):
|
|
||||||
return "invalid state.life_remainders"
|
|
||||||
if saved.has("resource_remainders"):
|
|
||||||
if not saved.resource_remainders is Dictionary:
|
|
||||||
return "invalid state.resource_remainders"
|
|
||||||
for channel in ["basic", "automatic"]:
|
|
||||||
# Signed allocation errors are valid, even though resource balances are whole.
|
|
||||||
if not _number_map(saved.resource_remainders.get(channel), false, false):
|
|
||||||
return "invalid state.resource_remainders." + channel
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -86,7 +86,10 @@ var locked_items: Array = []
|
|||||||
|
|
||||||
|
|
||||||
func set_datetime(value: float):
|
func set_datetime(value: float):
|
||||||
date_time.text = Global.format_number(value, 5) if value != 0.0 else "0.0"
|
# Simulation time advances in tenths of a year; displaying five decimal
|
||||||
|
# places only adds zeroes and suggests precision the simulation does not
|
||||||
|
# have.
|
||||||
|
date_time.text = Global.format_number(value, 1) if value != 0.0 else "0.0"
|
||||||
|
|
||||||
#region Item manipulations: update and visibility
|
#region Item manipulations: update and visibility
|
||||||
|
|
||||||
@@ -403,12 +406,9 @@ func load_data() -> bool:
|
|||||||
SaveManager.blocked_saves["owniverse"] = true
|
SaveManager.blocked_saves["owniverse"] = true
|
||||||
push_error("Save is missing the current entity: " + id)
|
push_error("Save is missing the current entity: " + id)
|
||||||
return false
|
return false
|
||||||
var complete: Dictionary = owniverse.state.duplicate(true)
|
owniverse.state = restored
|
||||||
complete.merge(restored, true)
|
owniverse.new_state = restored.duplicate(true)
|
||||||
owniverse.state = complete
|
|
||||||
owniverse.new_state = complete
|
|
||||||
owniverse.initialize_life_batches()
|
owniverse.initialize_life_batches()
|
||||||
owniverse.migrate_time_precision()
|
|
||||||
owniverse.refresh_item_amounts()
|
owniverse.refresh_item_amounts()
|
||||||
pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1)
|
pending_page = clampi(int(game_data.get("ui", {}).get("current_page", 0)), 0, PAGE_COUNT - 1)
|
||||||
_restore_page.call_deferred()
|
_restore_page.call_deferred()
|
||||||
|
|||||||
+4
-4
@@ -8,14 +8,14 @@ godot --headless --path Owniverse.GI --script res://tests/test_time.gd
|
|||||||
|
|
||||||
Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается.
|
Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается.
|
||||||
|
|
||||||
Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки, недопустимые интервалы и миграция сохранений.
|
Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки и недопустимые интервалы.
|
||||||
|
|
||||||
Схема состояния `0.1.1` хранит:
|
Схема состояния `0.1.1` хранит:
|
||||||
|
|
||||||
- `time.datetime` — накопленный возраст, включая необработанную дробную часть;
|
- `time.datetime` — накопленный возраст, включая необработанную дробную часть;
|
||||||
- `time.year` — последний рассчитанный возраст с точностью до десятой доли года.
|
- `time.year` — последний рассчитанный возраст с точностью до десятой доли года.
|
||||||
|
|
||||||
Сохранения `0.1.0` проходят старую проверку целого года. При загрузке стадией игры оставшийся промежуток до последней десятой рассчитывается ровно один раз, после чего применяется версия `0.1.1`. Оставшаяся часть меньше 0,1 года сохраняется.
|
Сохранения используют только актуальную структуру состояния и не поддерживают прежние форматы.
|
||||||
|
|
||||||
## Согласованные правила эволюции
|
## Согласованные правила эволюции
|
||||||
|
|
||||||
@@ -55,7 +55,7 @@ godot --headless --path Owniverse.GI --script res://tests/test_life.gd
|
|||||||
|
|
||||||
Проверяются абиогенез, деградация, повторные попытки без создания дополнительных планет, появление C0.0 на прежней звезде, остатки вероятностей, сохранение/продолжение, однократная инициализация старых планет и отсутствие воскрешения после бластов.
|
Проверяются абиогенез, деградация, повторные попытки без создания дополнительных планет, появление C0.0 на прежней звезде, остатки вероятностей, сохранение/продолжение, однократная инициализация старых планет и отсутствие воскрешения после бластов.
|
||||||
|
|
||||||
Проверка загрузки этических правил, миграции распределений и отделения космических цивилизаций:
|
Проверка загрузки этических правил, распределений и отделения космических цивилизаций:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
godot --headless --path Owniverse.GI --script res://tests/test_civilization_state.gd
|
godot --headless --path Owniverse.GI --script res://tests/test_civilization_state.gd
|
||||||
@@ -70,4 +70,4 @@ godot --headless --path Owniverse.GI --script res://tests/test_civilization_stat
|
|||||||
godot --headless --path Owniverse.GI --script res://tests/test_civilization_evolution.gd
|
godot --headless --path Owniverse.GI --script res://tests/test_civilization_evolution.gd
|
||||||
```
|
```
|
||||||
|
|
||||||
Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации. Миграция часов цивилизаций использует `life_version = 3`.
|
Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации.
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func invariants(world) -> void:
|
|||||||
check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id)
|
check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id)
|
||||||
for n in world.state.ethics[id].values():
|
for n in world.state.ethics[id].values():
|
||||||
if n < 0.0 or n != floorf(n): check(false, "Invalid ethic amount")
|
if n < 0.0 or n != floorf(n): check(false, "Invalid ethic amount")
|
||||||
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Civilization state validates")
|
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Civilization state validates")
|
||||||
|
|
||||||
func run() -> void:
|
func run() -> void:
|
||||||
var global: Node = root.get_node("Global")
|
var global: Node = root.get_node("Global")
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ func run() -> void:
|
|||||||
world.state.amount["C0.2"] = 3.0
|
world.state.amount["C0.2"] = 3.0
|
||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
check(world.state.ethics["C0.2"].m == 3.0, "Losses reduce ethics without inventing population")
|
check(world.state.ethics["C0.2"].m == 3.0, "Losses reduce ethics without inventing population")
|
||||||
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Ethics state validates")
|
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Ethics state validates")
|
||||||
var restored = fixture()
|
var restored = fixture()
|
||||||
restored.state = bytes_to_var(var_to_bytes(world.state))
|
restored.state = bytes_to_var(var_to_bytes(world.state))
|
||||||
restored.initialize_life_batches()
|
restored.initialize_life_batches()
|
||||||
@@ -116,7 +116,7 @@ func run() -> void:
|
|||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent")
|
check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent")
|
||||||
check(world._physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families")
|
check(world._physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families")
|
||||||
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Capacity-limited state validates")
|
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Capacity-limited state validates")
|
||||||
dispose(world)
|
dispose(world)
|
||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
extends SceneTree
|
||||||
|
const Clock = preload("res://world/owniverse/simulation_time.gd")
|
||||||
|
var checks: int = 0
|
||||||
|
var failures: int = 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func check(condition: bool, message: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if not condition:
|
||||||
|
failures += 1
|
||||||
|
push_error(message)
|
||||||
|
|
||||||
|
func fixture():
|
||||||
|
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||||
|
var global: Node = root.get_node("Global")
|
||||||
|
for id in global.OwniverseEntities:
|
||||||
|
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
|
||||||
|
world.entities[id] = entity
|
||||||
|
world.state.amount[id] = 0.0
|
||||||
|
world.state.stats.total_amount[id] = 0.0
|
||||||
|
if entity.group_as != "":
|
||||||
|
world.state.amount[entity.group_as] = 0.0
|
||||||
|
world.state.stats.total_amount[entity.group_as] = 0.0
|
||||||
|
world.entities["N"].life_product = {}
|
||||||
|
world.entities["N"].product_per_year_type = "H"
|
||||||
|
world.entities["N"].product_per_year_amount = 10.0
|
||||||
|
world.entities["N"].auto = 0.0
|
||||||
|
world.state.amount.N = 1.0
|
||||||
|
return world
|
||||||
|
|
||||||
|
func dispose(world) -> void:
|
||||||
|
for entity in world.entities.values():
|
||||||
|
entity.free()
|
||||||
|
world.free()
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
var world = fixture()
|
||||||
|
for id in world.entities:
|
||||||
|
var expected: Array = []
|
||||||
|
for key in world.entities:
|
||||||
|
if world.entities[key].group_as == id:
|
||||||
|
expected.append(key)
|
||||||
|
check(world._is_group(id) == (not expected.is_empty()), "Group classification: " + id)
|
||||||
|
check(world._members(id) == (expected if not expected.is_empty() else [id]), "Group members: " + id)
|
||||||
|
var members: Array = world._members("Star")
|
||||||
|
members.clear()
|
||||||
|
check(not world._members("Star").is_empty(), "Returned members are independent")
|
||||||
|
world.entities.N.group_as = "TestGroup"
|
||||||
|
world.rebuild_group_index()
|
||||||
|
check(world._members("TestGroup") == ["N"], "Explicit definition reload")
|
||||||
|
world.entities.N.free()
|
||||||
|
world.entities.erase("N")
|
||||||
|
check(not world._is_group("TestGroup"), "Removed definition invalidates index")
|
||||||
|
print("Group index: %d checks, %d failures" % [checks, failures])
|
||||||
|
dispose(world)
|
||||||
|
quit(1 if failures else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bykrkembtacbw
|
||||||
+4
-5
@@ -53,7 +53,7 @@ func run() -> void:
|
|||||||
check(world.state.batches.HP.size() > 0, "Survivors get next attempt")
|
check(world.state.batches.HP.size() > 0, "Survivors get next attempt")
|
||||||
var physical: Dictionary = world._physical_amounts(world.state)
|
var physical: Dictionary = world._physical_amounts(world.state)
|
||||||
check(physical.HP + physical.EON1 + physical.RP == 40000.0, "Life conserves planets")
|
check(physical.HP + physical.EON1 + physical.RP == 40000.0, "Life conserves planets")
|
||||||
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Life save validates")
|
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Life save validates")
|
||||||
var restored = fixture()
|
var restored = fixture()
|
||||||
restored.state = bytes_to_var(var_to_bytes(world.state))
|
restored.state = bytes_to_var(var_to_bytes(world.state))
|
||||||
world._evolve_batches(world.state, 11e6)
|
world._evolve_batches(world.state, 11e6)
|
||||||
@@ -100,13 +100,12 @@ func run() -> void:
|
|||||||
dispose(world)
|
dispose(world)
|
||||||
world = fixture()
|
world = fixture()
|
||||||
world.state.time = {"datetime": 123.4, "year": 123.4}
|
world.state.time = {"datetime": 123.4, "year": 123.4}
|
||||||
world.state.life_version = 1
|
|
||||||
world.state.amount["C0.0"] = 40000.0
|
world.state.amount["C0.0"] = 40000.0
|
||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
check(world.state.life_version == 3 and world.state.batches["C0.0"][0].created_from == 123.4, "Previous life saves receive missing civilization cohorts")
|
check(world.state.batches["C0.0"][0].created_from == 123.4, "Civilization cohorts begin at the current time")
|
||||||
before = world.state.duplicate(true)
|
before = world.state.duplicate(true)
|
||||||
world.initialize_life_batches()
|
world.initialize_life_batches()
|
||||||
check(world.state == before, "Second life migration is idempotent")
|
check(world.state == before, "Life batch initialization is idempotent")
|
||||||
world._evolve_batches(world.state, 2173.4)
|
world._evolve_batches(world.state, 2173.4)
|
||||||
check(world.state.amount["C0.1"] == 700.0 and world.state.amount.EON5 == 1000.0, "C0.0 progresses and degrades by Unity rates")
|
check(world.state.amount["C0.1"] == 700.0 and world.state.amount.EON5 == 1000.0, "C0.0 progresses and degrades by Unity rates")
|
||||||
check(world.state.amount["C0.0"] == 38300.0, "C0.0 remainder stays alive")
|
check(world.state.amount["C0.0"] == 38300.0, "C0.0 remainder stays alive")
|
||||||
@@ -118,7 +117,7 @@ func run() -> void:
|
|||||||
world._evolve_batches(world.state, 2050.0)
|
world._evolve_batches(world.state, 2050.0)
|
||||||
check(world.state.binds.RD["C0.2"] == 700.0, "C0.1 reaches ethical civilization without losing host")
|
check(world.state.binds.RD["C0.2"] == 700.0, "C0.1 reaches ethical civilization without losing host")
|
||||||
check(world.state.binds.RD["C0.0"] == 1000.0, "C0.1 degradation follows Unity")
|
check(world.state.binds.RD["C0.0"] == 1000.0, "C0.1 degradation follows Unity")
|
||||||
check(root.get_node("SaveManager").validate_data({"state": world.state}) == "", "Extended life save validates")
|
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Extended life save validates")
|
||||||
dispose(world)
|
dispose(world)
|
||||||
print("Life: %d checks, %d failures" % [checks, failures])
|
print("Life: %d checks, %d failures" % [checks, failures])
|
||||||
quit(0 if failures == 0 else 1)
|
quit(0 if failures == 0 else 1)
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
extends SceneTree
|
||||||
|
const Clock = preload("res://world/owniverse/simulation_time.gd")
|
||||||
|
var checks: int = 0
|
||||||
|
var failures: int = 0
|
||||||
|
|
||||||
|
func _initialize() -> void:
|
||||||
|
call_deferred("run")
|
||||||
|
|
||||||
|
func check(condition: bool, message: String) -> void:
|
||||||
|
checks += 1
|
||||||
|
if not condition:
|
||||||
|
failures += 1
|
||||||
|
push_error(message)
|
||||||
|
|
||||||
|
func fixture():
|
||||||
|
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||||
|
var global: Node = root.get_node("Global")
|
||||||
|
for id in global.OwniverseEntities:
|
||||||
|
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
|
||||||
|
world.entities[id] = entity
|
||||||
|
world.state.amount[id] = 0.0
|
||||||
|
world.state.stats.total_amount[id] = 0.0
|
||||||
|
if entity.group_as != "":
|
||||||
|
world.state.amount[entity.group_as] = 0.0
|
||||||
|
world.state.stats.total_amount[entity.group_as] = 0.0
|
||||||
|
world.entities["N"].life_product = {}
|
||||||
|
world.entities["N"].product_per_year_type = "H"
|
||||||
|
world.entities["N"].product_per_year_amount = 10.0
|
||||||
|
world.entities["N"].auto = 0.0
|
||||||
|
world.state.amount.N = 1.0
|
||||||
|
return world
|
||||||
|
|
||||||
|
func dispose(world) -> void:
|
||||||
|
for entity in world.entities.values():
|
||||||
|
entity.free()
|
||||||
|
world.free()
|
||||||
|
|
||||||
|
func run() -> void:
|
||||||
|
var world = fixture()
|
||||||
|
world.initialize_life_batches()
|
||||||
|
world.entities.N.cost = {"H": 2.0}
|
||||||
|
world.entities.N.bind = {"S": 3.0}
|
||||||
|
world.state.amount.H = 10.0
|
||||||
|
world.state.amount.S = 0.0
|
||||||
|
world.state.auto_resources = {"H": 10.0}
|
||||||
|
world.state.batches.H = [{"amount": 10.0, "start_year": 1.0, "end_year": 2.0}]
|
||||||
|
var before: Dictionary = world.state.duplicate(true)
|
||||||
|
check(not world._try_production(world.state, world.entities.N, 1.0, true), "Binding failure rejects production")
|
||||||
|
check(world.state == before, "Failure preserves costs, batches and budget")
|
||||||
|
world.state.amount.S = 10.0
|
||||||
|
var old_batches: Array = world.state.batches.H
|
||||||
|
check(world._try_production(world.state, world.entities.N, 2.0, true), "Funded production succeeds")
|
||||||
|
check(world.state.amount.H == 6.0 and world.state.amount.S == 4.0, "Exact cost and binding")
|
||||||
|
check(world.state.auto_resources.H == 6.0, "Exact automatic budget")
|
||||||
|
check(old_batches[0].amount == 10.0 and world.state.batches.H[0].amount == 6.0, "Consumed batches isolated")
|
||||||
|
print("Transaction: %d checks, %d failures" % [checks, failures])
|
||||||
|
dispose(world)
|
||||||
|
quit(1 if failures else 0)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://byjixspismsnh
|
||||||
@@ -84,7 +84,7 @@ func run() -> void:
|
|||||||
check(world.state.binds.RD.GG == 16.0 and world.state.binds.RD.AB == 120.0, "Planet multiplicity applied")
|
check(world.state.binds.RD.GG == 16.0 and world.state.binds.RD.AB == 120.0, "Planet multiplicity applied")
|
||||||
check(world.state.amount.HP == 0.0 and world.state.stats.total_amount.HP == 1.0, "Created planets are bound, counted once")
|
check(world.state.amount.HP == 0.0 and world.state.stats.total_amount.HP == 1.0, "Created planets are bound, counted once")
|
||||||
var saves: Node = root.get_node("SaveManager")
|
var saves: Node = root.get_node("SaveManager")
|
||||||
check(saves.validate_data({"state": world.state}) == "", "Planet carry save validates")
|
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Planet carry save validates")
|
||||||
restored = fixture()
|
restored = fixture()
|
||||||
restored.state = bytes_to_var(var_to_bytes(world.state))
|
restored.state = bytes_to_var(var_to_bytes(world.state))
|
||||||
world._create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7)
|
world._create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7)
|
||||||
@@ -106,7 +106,7 @@ func run() -> void:
|
|||||||
check(world.state.amount.RP == 0.0 and world.state.amount["C1.3"] == 0.0, "Inherited planets never become free")
|
check(world.state.amount.RP == 0.0 and world.state.amount["C1.3"] == 0.0, "Inherited planets never become free")
|
||||||
world._evolve_batches(world.state, lifespan + width)
|
world._evolve_batches(world.state, lifespan + width)
|
||||||
check(world.state.binds.RD["C1.3"] == 0.0 and world.state.binds.TWD["C1.3"] == 4.0, "No planets left at extinct parent type")
|
check(world.state.binds.RD["C1.3"] == 0.0 and world.state.binds.TWD["C1.3"] == 4.0, "No planets left at extinct parent type")
|
||||||
check(saves.validate_data({"state": world.state}) == "", "Inherited state validates")
|
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Inherited state validates")
|
||||||
dispose(world)
|
dispose(world)
|
||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
@@ -146,7 +146,7 @@ func run() -> void:
|
|||||||
check(world.state.amount.SN == 0.0 and world.state.amount.LNS == 1.0, "Supernova produces a remnant")
|
check(world.state.amount.SN == 0.0 and world.state.amount.LNS == 1.0, "Supernova produces a remnant")
|
||||||
check(world.state.amount.H == 8.0 and world.state.amount.He == 50.0 and world.state.amount.Met == 11.0, "Supernova returns table resources")
|
check(world.state.amount.H == 8.0 and world.state.amount.He == 50.0 and world.state.amount.Met == 11.0, "Supernova returns table resources")
|
||||||
check(not world.state.amount.has("blast"), "Supernova dispatches blast output")
|
check(not world.state.amount.has("blast"), "Supernova dispatches blast output")
|
||||||
check(saves.validate_data({"state": world.state}) == "", "Blast state validates")
|
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Blast state validates")
|
||||||
dispose(world)
|
dispose(world)
|
||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
|
|||||||
+3
-22
@@ -50,10 +50,10 @@ func run() -> void:
|
|||||||
for invalid in [0.0, -1.0, NAN, INF, 1e308]:
|
for invalid in [0.0, -1.0, NAN, INF, 1e308]:
|
||||||
world.evolute(invalid)
|
world.evolute(invalid)
|
||||||
check(world.state == before, "Invalid/overflow interval rejected")
|
check(world.state == before, "Invalid/overflow interval rejected")
|
||||||
check(saves.validate_data({"state": world.state}) == "", "Fractional save is valid")
|
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Fractional save is valid")
|
||||||
var invalid_save: Dictionary = world.state.duplicate(true)
|
var invalid_save: Dictionary = world.state.duplicate(true)
|
||||||
invalid_save.time.year = 0.25
|
invalid_save.time.year = 0.25
|
||||||
check(saves.validate_data({"state": invalid_save}) != "", "Reject inconsistent fractional clock")
|
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}) != "", "Reject inconsistent fractional clock")
|
||||||
dispose(world)
|
dispose(world)
|
||||||
|
|
||||||
for fps in [30, 60, 144]:
|
for fps in [30, 60, 144]:
|
||||||
@@ -76,26 +76,7 @@ func run() -> void:
|
|||||||
dispose(world)
|
dispose(world)
|
||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
world.state.version = "0.1.0"
|
|
||||||
world.state.time = {"datetime": 0.45, "year": 0.0}
|
|
||||||
check(saves.validate_data({"state": world.state}) == "", "Accept legacy integer-year save")
|
|
||||||
world.migrate_time_precision()
|
|
||||||
check(world.state.version == "0.1.1", "Migrate schema version")
|
|
||||||
check(world.state.time.year == 0.4 and world.state.time.datetime == 0.45, "Keep unprocessed sub-tenth remainder")
|
|
||||||
check(world.state.amount.H == 4.0, "Migration processes pending legacy time")
|
|
||||||
check(saves.validate_data({"state": world.state}) == "", "Migrated state validates")
|
|
||||||
before = world.state.duplicate(true)
|
|
||||||
world.migrate_time_precision()
|
|
||||||
check(world.state == before, "Migration is idempotent")
|
|
||||||
# Verify in-memory save roundtrip without touching the player's save.
|
|
||||||
var restored = fixture()
|
|
||||||
restored.state = bytes_to_var(var_to_bytes(world.state))
|
|
||||||
world.evolute(0.05)
|
|
||||||
restored.evolute(0.05)
|
|
||||||
check(world.state == restored.state, "Save continuation preserves time and production")
|
|
||||||
check(world.state.time.year == 0.5 and world.state.amount.H == 5.0, "No double-counted migration time")
|
|
||||||
dispose(world)
|
dispose(world)
|
||||||
dispose(restored)
|
|
||||||
|
|
||||||
world = fixture()
|
world = fixture()
|
||||||
world.entities.N.output_and = {"He": 1.0}
|
world.entities.N.output_and = {"He": 1.0}
|
||||||
@@ -106,5 +87,5 @@ func run() -> void:
|
|||||||
dispose(world)
|
dispose(world)
|
||||||
check(Clock.processed_year(0.099) == 0.0, "Do not round a genuine incomplete tenth forward")
|
check(Clock.processed_year(0.099) == 0.0, "Do not round a genuine incomplete tenth forward")
|
||||||
check(Clock.processed_year(1e9 + 0.45) == 1e9 + 0.4, "Large ages preserve fractional precision")
|
check(Clock.processed_year(1e9 + 0.45) == 1e9 + 0.4, "Large ages preserve fractional precision")
|
||||||
print("Time migration: %d checks, %d failures" % [checks, failures])
|
print("Time state: %d checks, %d failures" % [checks, failures])
|
||||||
quit(0 if failures == 0 else 1)
|
quit(0 if failures == 0 else 1)
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ var entities: Dictionary[String, OwniverseEntity] = {}
|
|||||||
var entity_groups: Dictionary[String, Array] = {}
|
var entity_groups: Dictionary[String, Array] = {}
|
||||||
var entity_event_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 items_to_update: Dictionary[String, float] = {}
|
||||||
|
|
||||||
var auto_production: Array = []
|
var auto_production: Array = []
|
||||||
@@ -32,6 +35,18 @@ var state: Dictionary = {
|
|||||||
'amount': {},
|
'amount': {},
|
||||||
'distribution': {},
|
'distribution': {},
|
||||||
'auto_resources': {},
|
'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': {
|
'stats': {
|
||||||
'total_amount': {},
|
'total_amount': {},
|
||||||
}
|
}
|
||||||
@@ -57,19 +72,9 @@ func evolute(delta: float) -> void:
|
|||||||
_evolute_to_datetime(datetime)
|
_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:
|
func initialize_life_batches() -> void:
|
||||||
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
|
CivilizationState.detach_space_civilizations(state, Global.CivilizationRules)
|
||||||
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
|
CivilizationState.enforce_capacity(self, state, Global.CivilizationRules, state.time.datetime)
|
||||||
if state.get("life_version", 0) >= 3:
|
|
||||||
return
|
|
||||||
var physical: Dictionary = _physical_amounts(state)
|
var physical: Dictionary = _physical_amounts(state)
|
||||||
for id in entities:
|
for id in entities:
|
||||||
if entities[id].life_rule.is_empty():
|
if entities[id].life_rule.is_empty():
|
||||||
@@ -78,19 +83,22 @@ func initialize_life_batches() -> void:
|
|||||||
for batch: Dictionary in state.batches.get(id, []):
|
for batch: Dictionary in state.batches.get(id, []):
|
||||||
tracked += batch.amount
|
tracked += batch.amount
|
||||||
_append_batch(state, id, maxf(0.0, physical.get(id, 0.0) - tracked), state.time.datetime, state.time.datetime)
|
_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
|
new_state = state
|
||||||
|
|
||||||
|
|
||||||
func _evolute_to_datetime(datetime: float) -> void:
|
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 previous: Dictionary = state
|
||||||
var next: Dictionary = previous.duplicate_deep()
|
var next: Dictionary = previous.duplicate_deep()
|
||||||
next.version = SimulationTime.STATE_VERSION
|
next.version = SimulationTime.STATE_VERSION
|
||||||
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
|
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
|
||||||
next.speed_in_year = pow(10.0, next.speed - 1)
|
next.speed_in_year = pow(10.0, next.speed - 1)
|
||||||
var from_year: float = previous.time.year
|
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:
|
if to_year > from_year:
|
||||||
var quantities: Dictionary = _physical_amounts(previous)
|
var quantities: Dictionary = _physical_amounts(previous)
|
||||||
var distribution: Dictionary = _normalize_distribution(quantities)
|
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)}
|
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:
|
func _is_group(id: String) -> bool:
|
||||||
if entity_groups.has(id):
|
_ensure_group_index()
|
||||||
return true
|
return entity_groups.has(id) or _group_members.has(id)
|
||||||
for entity: OwniverseEntity in entities.values():
|
|
||||||
if entity.group_as == id:
|
|
||||||
return true
|
|
||||||
return false
|
|
||||||
|
|
||||||
|
|
||||||
func _physical_amounts(source: Dictionary) -> Dictionary:
|
func _physical_amounts(source: Dictionary) -> Dictionary:
|
||||||
@@ -171,11 +192,8 @@ func _physical_amounts(source: Dictionary) -> Dictionary:
|
|||||||
func _members(id: String) -> Array:
|
func _members(id: String) -> Array:
|
||||||
if not _is_group(id):
|
if not _is_group(id):
|
||||||
return [id]
|
return [id]
|
||||||
var members: Array = []
|
# Callers may modify the returned array without changing the index.
|
||||||
for key in entities:
|
return _group_members.get(id, []).duplicate()
|
||||||
if entities[key].group_as == id:
|
|
||||||
members.append(key)
|
|
||||||
return members
|
|
||||||
|
|
||||||
|
|
||||||
func _available(source: Dictionary, id: String) -> float:
|
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)
|
var window_index: float = floorf((cursor - origin) / batch_duration)
|
||||||
if origin + (window_index + 1.0) * batch_duration <= cursor:
|
if origin + (window_index + 1.0) * batch_duration <= cursor:
|
||||||
window_index += 1.0
|
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:
|
while true:
|
||||||
var window_start: float = origin + window_index * batch_duration
|
var window_start: float = origin + window_index * batch_duration
|
||||||
var window_end: float = origin + (window_index + 1.0) * 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:
|
if duration > 0.0 and part_end < created_to:
|
||||||
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
|
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
|
||||||
var batch: Dictionary = {}
|
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:
|
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
|
||||||
batch = existing
|
batch = existing
|
||||||
break
|
break
|
||||||
@@ -366,6 +390,9 @@ func _append_batch(target: Dictionary, id: String, amount: float,
|
|||||||
"amount": 0.0,
|
"amount": 0.0,
|
||||||
}
|
}
|
||||||
batches.append(batch)
|
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
|
batch.amount += part_amount
|
||||||
remaining_amount -= part_amount
|
remaining_amount -= part_amount
|
||||||
if duration == 0.0 or part_end >= created_to:
|
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:
|
if products.is_empty() or count <= 0.0:
|
||||||
return
|
return
|
||||||
var object_years: float = count * maxf(0.0, to_year - (born_from + born_to) * 0.5)
|
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, []):
|
for batch: Dictionary in target.batches.get(id, []):
|
||||||
var added: float = batch.amount
|
var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0)
|
||||||
for old: Dictionary in before_batches:
|
|
||||||
if old.get("created_from", -INF) == batch.get("created_from", INF):
|
|
||||||
added -= old.amount
|
|
||||||
break
|
|
||||||
object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year)
|
object_years -= maxf(0.0, added) * _expired_years(to_year, batch.start_year, batch.end_year)
|
||||||
object_years = maxf(0.0, object_years)
|
object_years = maxf(0.0, object_years)
|
||||||
for resource in products:
|
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:
|
func _evolve_batch_generation(target: Dictionary, to_year: float) -> bool:
|
||||||
var quantities: Dictionary = _physical_amounts(target)
|
var quantities: Dictionary = _physical_amounts(target)
|
||||||
var expired: Dictionary = {}
|
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():
|
for id in target.batches.keys():
|
||||||
if not entities.has(id) or entities[id].destroy_level <= 0:
|
if not entities.has(id) or entities[id].destroy_level <= 0:
|
||||||
continue
|
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.
|
# Plan all bindings on a copy, reserving specific types before broader groups.
|
||||||
func _bind_full(target: Dictionary, amounts: Dictionary) -> bool:
|
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 = []
|
var requests: Array = []
|
||||||
for owner_id in amounts:
|
for owner_id in amounts:
|
||||||
if not entities.has(owner_id):
|
if not entities.has(owner_id):
|
||||||
@@ -777,7 +812,12 @@ func _try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
|
|||||||
automatic: bool = false) -> bool:
|
automatic: bool = false) -> bool:
|
||||||
if amount <= 0.0 or amount != floorf(amount):
|
if amount <= 0.0 or amount != floorf(amount):
|
||||||
return false
|
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:
|
for resource in entity.cost:
|
||||||
var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount)
|
var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount)
|
||||||
if automatic and trial.auto_resources.get(resource, 0.0) < cost:
|
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:
|
if automatic:
|
||||||
trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost)
|
trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost)
|
||||||
for id in taken:
|
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
|
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)
|
_scale_batches(trial, id, factor)
|
||||||
if not _bind_new_owners(trial, {entity.id: amount}, true):
|
if not _bind_new_owners(trial, {entity.id: amount}, true):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
extends RefCounted
|
extends RefCounted
|
||||||
## datetime is accumulated time; year is the last processed tenth of a year.
|
## datetime is accumulated time; year is the last processed tenth of a year.
|
||||||
const STATE_VERSION: String = "0.1.1"
|
const STATE_VERSION: String = "0.1.1"
|
||||||
const LEGACY_VERSION: String = "0.1.0"
|
|
||||||
|
|
||||||
static func processed_year(datetime: float) -> float:
|
static func processed_year(datetime: float) -> float:
|
||||||
# Remove only sub-nanotick accumulation noise at decimal boundaries.
|
# Remove only sub-nanotick accumulation noise at decimal boundaries.
|
||||||
|
|||||||
Reference in New Issue
Block a user