Implement gradual evolution mechanics and simulation time handling in Owniverse.GI

- Added `blastability` property to entities for vulnerability tracking.
- Enhanced planet string parsing with validation and error handling.
- Introduced `simulation_rules.md` to document evolution mechanics and rules.
- Created tests for time progression and stellar evolution rules, ensuring correct handling of fractional years and resource production.
- Implemented `simulation_time.gd` for managing accumulated time and year processing.
- Updated tests to validate legacy save compatibility and migration to new time schema.
This commit is contained in:
Kirill
2026-09-15 08:27:04 +03:00
parent 7de65f6145
commit 5f5cc6f894
13 changed files with 647 additions and 28 deletions
+36
View File
@@ -0,0 +1,36 @@
# Проверки поэтапного переноса из Owniverse.U
## Время: шаг 0,1 игрового года
```sh
godot --headless --path Owniverse.GI --script res://tests/test_time.gd
```
Команда выполняется из общей папки проектов. Тесты используют искусственные состояния; сохранение игрока не читается и не перезаписывается.
Проверяются границы десятых долей года, производство ресурсов на этих границах, накопление дробного остатка, скорости 1–10 000 лет/сек, продолжение после загрузки, недопустимые интервалы и миграция сохранений.
Схема состояния `0.1.1` хранит:
- `time.datetime` — накопленный возраст, включая необработанную дробную часть;
- `time.year` — последний рассчитанный возраст с точностью до десятой доли года.
Сохранения `0.1.0` проходят старую проверку целого года. При загрузке стадией игры оставшийся промежуток до последней десятой рассчитывается ровно один раз, после чего применяется версия `0.1.1`. Оставшаяся часть меньше 0,1 года сохраняется.
## Согласованные правила эволюции
Сохраняются постепенные интервальные окна GI: начало преобразования — через `lifespan`, длительность окна — `lifespan / 40` при `lifespan > 10`, иначе `lifespan`.
Вероятностные исходы используют накопление дробного остатка GI, без случайного округления. Этот же подход выбран для будущего создания планет. Шансы планет записаны долями (`0.01` = 1%), а альтернативные исходы звёзд — процентами (`%0.1` = 0.1%). Парсер планет сохраняет дроби и поддерживает один элемент без `&`.
Уязвимость к бластам читается из `blastability` таблицы GI: HP, EON и цивилизации до C1.2 включительно; C1.3 и выше неуязвимы. Это ограничение относится к бластам уровня 1; локальное уничтожение планет уровня 2 в Unity охватывает все планеты.
```sh
godot --headless --path Owniverse.GI --script res://tests/test_stellar_rules.gd
```
Проверяются парсер планет, граница уязвимости, накопление редких исходов, сериализация остатков и постепенное преобразование реальной партии RD в TWD.
Создание планет подключено к ручному и автоматическому производству звёзд. Бласты обрабатываются как события, а не ресурс. Выжившие планеты переходят преемнику звезды пропорционально числу реально полученных преемников; при отсутствии преемника освобождаются. Локальное разрушение выполняется перед передачей.
Дополнительно проверяются частичная передача, альтернативные преемники, уровни разрушения 1/2, накопление слабых бластов, сохранения, ручное/автоматическое создание планет и несколько поколений за длинный шаг. Правила и формулы описаны в `../simulation_rules.md`.
+192
View File
@@ -0,0 +1,192 @@
extends SceneTree
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()
var rd = world.entities.RD
check(rd.planets.HP.chance == 0.01, "Preserve fractional planet probability")
check(rd.planets.GG.amount == 2.0 and rd.planets.GG.chance == 0.08, "Read count and chance separately")
check(rd._parse_planet_string("HP:1:0.01") == {"HP": {"amount": 1.0, "chance": 0.01}}, "Accept a single planet term")
check(rd._parse_planet_string("").is_empty(), "Empty planet definitions stay empty")
for id in ["HP", "EON1", "C0.9", "C1.2"]:
check(world.entities[id].blastability, "Blast vulnerable: " + id)
for id in ["RP", "GG", "AB", "C1.3", "C1.4", "C1.5"]:
check(not world.entities[id].blastability, "Blast protected: " + id)
# Actual GI table: HRG -> SN, with 1% GRB; no random rounding.
var totals: Dictionary = {}
for i in 100:
var products: Dictionary = world._evolution_products(world.state, world.entities.HRG, 1.0)
for id in products:
totals[id] = totals.get(id, 0.0) + products[id]
check(totals.get("SN") == 99.0 and totals.get("GRB") == 1.0, "GI rare outcomes accumulate deterministically")
var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
check(world._evolution_products(world.state, world.entities.HRG, 37.0) == restored._evolution_products(restored.state, restored.entities.HRG, 37.0), "Evolution carry survives serialization")
dispose(restored)
# Real RD lifespan, four stars spread over the existing lifespan/40 window.
world.state.amount.N = 0.0
world.state.amount.RD = 4.0
world._append_batch(world.state, "RD", 4.0, 0.0, 0.0)
var lifespan: float = rd.lifespan
var width: float = rd.lifespan_delta
world._evolve_batches(world.state, lifespan)
check(world.state.amount.RD == 4.0, "Window start does not kill the whole batch")
world._evolve_batches(world.state, lifespan + width / 4.0)
check(world.state.amount.RD == 3.0 and world.state.amount.TWD == 1.0, "One quarter of a batch evolves")
world._evolve_batches(world.state, lifespan + width / 2.0)
check(world.state.amount.RD == 2.0 and world.state.amount.TWD == 2.0, "Half the batch evolves")
world._evolve_batches(world.state, lifespan + width)
check(world.state.amount.RD == 0.0 and world.state.amount.TWD == 4.0, "Window end completes evolution")
check(world.state.batches.RD.is_empty(), "Completed batch removed")
dispose(world)
# Creation accumulates independently for each star/planet pair.
world = fixture()
world.state.amount.RD = 100.0
for i in 100:
world._create_planets(world.state, "RD", 1.0, 0.0, 0.0, 1.0)
check(world.state.binds.RD.HP == 1.0, "100 RD at metallicity 1 produce one HP")
check(world.state.binds.RD.RP == 21.0, "Rocky planet probability retains fractions")
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")
var saves: Node = root.get_node("SaveManager")
check(saves.validate_data({"state": world.state}) == "", "Planet carry save validates")
restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
world._create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7)
restored._create_planets(restored.state, "RD", 37.0, 0.0, 0.0, 0.7)
check(world.state == restored.state, "Planet carry continues after save")
dispose(restored)
dispose(world)
# A partially dying dwarf sterilizes HP; survivors and RP move to its heir.
world = fixture()
world.state.amount.RD = 4.0
world.state.binds.RD = {"HP": 8.0, "RP": 4.0, "C1.2": 4.0, "C1.3": 4.0, "GG": 4.0}
world._append_batch(world.state, "RD", 4.0, 0.0, 0.0)
world._evolve_batches(world.state, lifespan + width / 2.0)
check(world.state.amount.RD == 2.0 and world.state.amount.TWD == 2.0, "Partial star transition with planets")
check(world.state.binds.RD.HP + world.state.binds.TWD.HP == 4.0, "Half of HP sterilized")
check(world.state.binds.RD["C1.3"] == 2.0 and world.state.binds.TWD["C1.3"] == 2.0, "Protected civilization splits between parent and heir")
check(world.state.binds.RD.RP + world.state.binds.TWD.RP == 10.0, "Sterilized planets become RP without loss")
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)
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")
dispose(world)
world = fixture()
world.state.amount.WS = 2.0
world.state.binds.WS = {"HP": 4.0, "C1.3": 4.0, "RP": 4.0, "GG": 4.0}
world._append_batch(world.state, "WS", 2.0, 0.0, 0.0)
world._evolve_batches(world.state, world.entities.WS.lifespan + world.entities.WS.lifespan_delta)
var physical: Dictionary = world._physical_amounts(world.state)
check(physical.HP == 0.0 and physical["C1.3"] == 0.0 and physical.RP == 0.0 and physical.GG == 0.0, "Level 2 removes all planets, including protected civilization")
check(world.state.amount.LRG == 2.0, "Giant evolves after local destruction")
dispose(world)
world = fixture()
world.state.amount.RD = 10.0
world.state.binds.RD = {"HP": 10.0, "C1.2": 10.0, "C1.3": 10.0, "RP": 0.0}
world._apply_blast(world.state, 5.0, {"blast": 1.0, "density": 1.0})
check(world.state.binds.RD.HP == 5.0 and world.state.binds.RD["C1.2"] == 5.0, "Blast damages vulnerable worlds proportionally")
check(world.state.binds.RD["C1.3"] == 10.0 and world.state.binds.RD.RP == 10.0, "Blast protects C1.3 and creates RP")
world._apply_blast(world.state, 1e6, {"blast": 1.0, "density": 1.0})
check(world.state.binds.RD.HP == 0.0 and world.state.binds.RD.RP == 20.0, "Overwhelming blast is capped at available planets")
check(not world.state.amount.has("blast"), "Blast is not a resource")
dispose(world)
world = fixture()
world.state.amount.RD = 1.0
world.state.binds.RD = {"HP": 1.0}
for i in 10:
world._apply_blast(world.state, 1.0, {"blast": 1.0, "density": 0.1})
check(world.state.binds.RD.HP == 0.0 and world.state.binds.RD.RP == 1.0, "Small blast effects accumulate across cycles")
dispose(world)
# Actual supernova output: one SN gives a neutron star, resources, and an event.
world = fixture()
world.state.amount.SN = 1.0
world._append_batch(world.state, "SN", 1.0, 0.0, 0.0)
world._evolve_batches(world.state, 2.0)
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(not world.state.amount.has("blast"), "Supernova dispatches blast output")
check(saves.validate_data({"state": world.state}) == "", "Blast state validates")
dispose(world)
world = fixture()
world.state.amount.H = 1000.0
world.state.amount.Met = 100.0
check(world.produce("RD", 100.0) == 100.0, "Manual star creation succeeds")
check(world.state.binds.RD.HP >= 1.0, "Manual creation dispatches planet formation")
check(world.state.amount.H == 900.0, "Planet creation does not duplicate or spend stellar fuel")
dispose(world)
world = fixture()
world.state.amount.N = 0.0
world.state.amount.RD = 1.0
world.state.amount.H = 100.0
world.state.amount.Met = 100.0
world.state.auto_resources.H = 100.0
world.evolute(0.1)
check(world.state.amount.RD == 101.0 and world.state.amount.H == 0.0, "Automatic formation uses its resource budget")
check(world.state.binds.RD.HP >= 1.0, "Automatic formation also creates planets")
dispose(world)
# Two simultaneous successors share surviving planets in the same 99:1 ratio.
world = fixture()
world.entities.HRG.destroy_level = 0
world.state.amount.HRG = 100.0
world.state.binds.HRG = {"C1.3": 100.0}
world.state.batches.HRG = [{"start_year": 0.1, "end_year": 0.1, "amount": 100.0}]
world._evolve_batches(world.state, 0.1)
check(world.state.binds.SN["C1.3"] == 99.0 and world.state.binds.GRB["C1.3"] == 1.0, "Planets split among actual alternative successors")
check(world.state.binds.HRG["C1.3"] == 0.0, "Alternative evolution leaves no orphan bindings")
dispose(world)
world = fixture()
world.state.amount.N = 0.0
world.state.amount.HRG = 1.0
world.state.batches.HRG = [{"start_year": 0.1, "end_year": 0.1, "amount": 1.0}]
world.evolute(3.0)
check(world.state.amount.LNS == 1.0 and world.state.amount.SN == 0.0, "Long step follows multiple stellar generations")
check(world.state.amount.S >= 99.0, "Short-lived newborn supernova emits S before disappearing")
dispose(world)
print("Stellar rules: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://ccegf5mell4ph
+110
View File
@@ -0,0 +1,110 @@
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 saves: Node = root.get_node("SaveManager")
var world = fixture()
world.evolute(0.09)
check(world.state.time.year == 0.0, "Do not process before a tenth boundary")
check(world.state.amount.H == 0.0, "No premature production")
world.evolute(0.01)
check(world.state.time.year == 0.1, "Process the first tenth")
check(world.state.amount.H == 1.0, "Production runs at 0.1 year")
world.evolute(0.2)
check(world.state.time.year == 0.3 and world.state.amount.H == 3.0, "Do not reprocess previous tenths")
var before: Dictionary = world.state.duplicate(true)
for invalid in [0.0, -1.0, NAN, INF, 1e308]:
world.evolute(invalid)
check(world.state == before, "Invalid/overflow interval rejected")
check(saves.validate_data({"state": world.state}) == "", "Fractional save is valid")
var invalid_save: Dictionary = world.state.duplicate(true)
invalid_save.time.year = 0.25
check(saves.validate_data({"state": invalid_save}) != "", "Reject inconsistent fractional clock")
dispose(world)
for fps in [30, 60, 144]:
world = fixture()
for frame in fps:
world.evolute(1.0 / fps)
check(world.state.time.year == 1.0, "One second at %d fps" % fps)
check(world.state.amount.H == 10.0, "Production across frames at %d fps" % fps)
dispose(world)
world = fixture()
world.evolute(0.05)
world.speed = 2
world.evolute(0.005)
check(world.state.time.year == 0.1, "Speed change preserves accumulated fraction")
world.speed = 100
check(world.speed == 5 and world.state.speed_in_year == 10000.0, "Upper speed limit unchanged")
world.speed = -10
check(world.speed == 1 and world.state.speed_in_year == 1.0, "Lower speed limit unchanged")
dispose(world)
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(restored)
world = fixture()
world.entities.N.output_and = {"He": 1.0}
world.entities.N.output_or = {}
world.state.batches.N = [{"start_year": 0.1, "end_year": 0.1, "amount": 1.0}]
world.evolute(0.1)
check(world.state.amount.N == 0.0 and world.state.amount.He == 1.0, "Existing evolution executes at fractional boundary")
dispose(world)
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")
print("Time migration: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://bwon17tg8v8di