From 413f2be9044090c878c2909dd9d0646f77513a5f Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 8 Oct 2026 00:15:23 +0300 Subject: [PATCH] Refactor simulation timing and frame processing logic for improved accuracy and performance --- simulation_rules.md | 6 +++--- tests/README.md | 11 ++--------- tests/test_frame_evolution.gd | 20 ++++++++++++++------ world/owniverse/owniverse.gd | 14 ++++++++++---- 4 files changed, 29 insertions(+), 22 deletions(-) diff --git a/simulation_rules.md b/simulation_rules.md index 331d003..68da07c 100644 --- a/simulation_rules.md +++ b/simulation_rules.md @@ -46,9 +46,9 @@ float. Весь порядок E27 сохраняет целые единицы. ### Общие окна рождения и эволюции -На всех скоростях полный цикл симуляции обрабатывает 0,2 реальной секунды: пять циклов на секунду запрошенного времени. До накопления полного интервала расчёт не запускается. Игровая длительность цикла равна `0.2 × 10^(speed - 1)` лет. При отставании размер шага не увеличивается, долг сохраняется. Незавершённая транзакция из сохранения возобновляется с прежней границей. Кооперативный бюджет кадра — 40 мс; частота отрисовки не определяет частоту полных циклов. +На всех скоростях целевая частота запуска — пять полных циклов в секунду. После накопления не менее 0,2 реальной секунды запускается расчёт за весь ожидающий игровой интервал. Время, поступающее во время расчёта, остаётся для следующего цикла. При задержках шаг увеличивается; время не отбрасывается. Смена скорости не сокращает реальный интервал между запусками. Незавершённая транзакция из сохранения возобновляется с прежней границей. Кооперативный бюджет кадра — 40 мс; фактическая частота завершения зависит от стоимости расчёта. -На скоростях 4–6 укрупнение звёздных партий зависит от размера цикла и прежнего окна рождения. Размер цикла — `0.2 × 10^(speed - 1)` игровых лет: 200, 2000 и 20000 лет. Если прежнее окно `max(lifespan / 40, временное разрешение)` шире цикла, оно сохраняется: долгоживущие звёзды не дробятся на более мелкую сетку. Если оно не шире цикла, новые звёзды группы `Star` (включая красные гиганты) объединяются по общей границе завершения жизни и переходят целиком. +На скоростях 4–6 укрупнение звёздных партий зависит от размера цикла и прежнего окна рождения. Номинальная ширина сетки партий — `0.2 × 10^(speed - 1)` игровых лет: 200, 2000 и 20000 лет. Если прежнее окно `max(lifespan / 40, временное разрешение)` шире цикла, оно сохраняется: долгоживущие звёзды не дробятся на более мелкую сетку. Если оно не шире цикла, новые звёзды группы `Star` (включая красные гиганты) объединяются по общей границе завершения жизни и переходят целиком. При обработке существующих партий объединяются только две или более партии одного типа, весь оставшийся интервал превращения которых попадает в один и тот же цикл. Широкие партии и партии, пересекающие границу циклов, остаются без изменений. Количества и накопленные остатки складываются точно; объединённая партия переходит целиком на границе цикла. Повторное укрупнение не меняет результат. @@ -117,7 +117,7 @@ GI не хранит накопленные счётчики первичной Прогресс и деградация округляются вниз с сохранением остатков. Если оба накопленных исхода конкурируют за последнюю планету, распределяются только существующие объекты, а неиспользованный остаток переносится на следующую попытку. Деградация распределяется по весам `fall`; знаковые ошибки распределения также сохраняются. -Для всех шести стадий базовые доли за попытку — `progress=0.00025`, `degradation=0.0025`. Интервалы HP/EON1/EON2/EON3/EON4/EON5: 5 млн, 10 млн, 10 млн, 1 млн, 1 млн, 500 тыс. лет. Используются постепенные окна GI шириной `span / 40`. Случайные множители Unity не применяются. Агрессия, порядок и просвещение изменяют вероятности по формулам из раздела индикаторов ниже. +Базовые доли за попытку: для HP `progress=0.00025`, для EON1–EON5 `progress=0.025`; у всех `degradation=0.0025`. Текущие интервалы HP/EON1/EON2/EON3/EON4/EON5: 5 млн, 1 млн, 1 млн, 100 тыс., 100 тыс., 500 тыс. лет. Используются постепенные окна GI шириной `span / 40`. Случайные множители Unity не применяются. Агрессия, порядок и просвещение изменяют вероятности по формулам из раздела индикаторов ниже. HP деградирует в RP. У эонов возможен откат к предыдущим стадиям по таблице. RP не развивается сам. C0.0 и C0.1 также развиваются: период 2000 лет, прогресс 0.0175, деградация 0.025. Переходы C0.0 → C0.1 → C0.2, откат C0.0 → EON5 и C0.1 → C0.0. Развитие C0.2 и выше подключено через этические правила. Конфликт civ_bonus устранён повторным импортом: бонусы структур совпадают с Unity. Для существующих этических цивилизаций пользователь выбрал равномерное распределение между шестью этиками с детерминированным целым остатком. diff --git a/tests/README.md b/tests/README.md index 844c407..69dd95b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -243,9 +243,7 @@ godot --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- ui Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся на главном потоке, состояние публикуется только после полного завершения. -Одна транзакция обрабатывает 0,2 реальной секунды на любой скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается -в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может -отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий. +Расчёт запускается после накопления 0,2 реальной секунды и обрабатывает весь ожидающий интервал. Время, поступившее во время расчёта, обрабатывается следующим циклом. При перегрузке шаг растёт; пять завершений в секунду остаются целевой частотой, а не гарантией. Производство во время транзакции выполняется после её завершения. Запрошенное время, активный интервал, скорость и очередь производства входят в сохранение; @@ -292,12 +290,7 @@ Stage вызывает `evolute_frame`: транзакция получает б 1.057. Замер включает сокращение накопленного отставания сохранения; это результат конкретного сценария на Mac, а не гарантия для Android. -Полный шаг фиксирован: 0,2 реальной секунды на всех скоростях, пять циклов -на секунду запрошенного времени. Неполный интервал накапливается. При отставании -шаг сохраняется, долг не отбрасывается. Возобновляемая транзакция сохраняет -границу из checkpoint. Бюджет кадра остаётся 40 мс. `test_frame_evolution` -проверяет расписание всех шести скоростей, сохранение долга и пять циклов -на одну секунду. +Целевая частота запуска — пять циклов в секунду. Размер шага равен всему накопленному интервалу. Checkpoint сохраняет границу активного расчёта и ожидающее реальное время. Тест проверяет все шесть скоростей, смену скорости, задержку запуска и накопление времени во время расчёта. Дипломатический drift использует шесть взвешенных вкладов вместо матрицы пар этик: вес = сумма вкладов + 195 × свой вклад − противоположный вклад. diff --git a/tests/test_frame_evolution.gd b/tests/test_frame_evolution.gd index 7d88666..d96f597 100644 --- a/tests/test_frame_evolution.gd +++ b/tests/test_frame_evolution.gd @@ -50,7 +50,9 @@ func run(): direct.speed = 3 check(E.eq(direct.state, sliced.state), "Queued action and speed change survive publication") sliced.evolute_frame(0.001, self) - var second_clock: Dictionary = Clock.advance(requested, 0.001, 3) + check(not sliced.frame_busy, "Speed change does not bypass real-time cadence") + sliced.evolute_frame(0.189, self) + var second_clock: Dictionary = Clock.advance(Clock.advance(requested, 0.001, 3), 0.189, 3) var active: Dictionary = sliced.frame_active_clock.duplicate() while sliced.frame_busy: await process_frame direct.evolution.evolute_to_datetime(active.datetime, active) @@ -111,11 +113,17 @@ func run(): var behind = fixture() behind.evolute_frame(1.0, self) while behind.frame_busy: await process_frame - check(E.eq(behind.state.time.datetime, 20000) and E.eq(behind.frame_clock.datetime, 100000), "Debt keeps the fixed step and preserves all requested time") - for cycle in 4: - behind.evolute_frame(0.0, self) - while behind.frame_busy: await process_frame - check(behind.frame_commits == 5 and E.eq(behind.state.time, behind.frame_clock), "One second is processed in exactly five full cycles") + check(E.eq(behind.state.time.datetime, 100000) and E.eq(behind.state.time, behind.frame_clock), "Delayed start consumes all elapsed time in one cycle") + behind.evolute_frame(0.0, self) + check(behind.frame_commits == 1 and not behind.frame_busy, "Consumed interval cannot be processed twice") + behind.evolute_frame(0.23, self) + behind.evolute_frame(0.53, self) + var pending_clock: Dictionary = behind.frame_clock.duplicate() + while behind.frame_busy: await process_frame + check(E.eq(behind.state.time.datetime, 123000), "Busy transaction keeps its original target") + behind.evolute_frame(0.0, self) + while behind.frame_busy: await process_frame + check(E.eq(behind.state.time, pending_clock), "Next cycle consumes all time accumulated during computation") dispose(behind) print("Frame evolution: %d checks, %d failures" % [checks, failures]) quit(1 if failures else 0) diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index efd2927..fe32f22 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -66,6 +66,7 @@ var _requested_speed: int = 0 var _queued_productions: Array = [] var frame_active_clock: Dictionary = {} var _resume_clock: Dictionary = {} +var frame_elapsed_seconds: float = 0.0 const frame_step_seconds: float = 0.2 ## Keep the requested clock advancing while a transaction spans render frames. @@ -73,12 +74,14 @@ func evolute_frame(delta: float, tree: SceneTree) -> void: if not is_finite(delta) or delta < 0.0 or not is_finite(delta * pow(10.0, speed - 1)): return if frame_clock.is_empty(): frame_clock = state.time.duplicate() frame_clock = SimulationTime.advance(frame_clock, delta, speed) + frame_elapsed_seconds += delta if frame_busy: return var next_clock: Dictionary = _resume_clock if next_clock.is_empty(): - next_clock = SimulationTime.advance(state.time, frame_step_seconds, speed) - # Start only after a full 0.2 seconds of simulation time is requested. - if E.gt(next_clock.datetime, frame_clock.datetime): return + # Cadence limits starts; each transaction consumes the whole pending interval. + if frame_elapsed_seconds + 1e-12 < frame_step_seconds: return + next_clock = frame_clock.duplicate() + frame_elapsed_seconds = 0.0 frame_busy = true frame_budget = preload("res://world/owniverse/frame_budget.gd").new(tree) frame_active_clock = next_clock @@ -95,11 +98,14 @@ func request_production(id: String, amount: Variant, resource: bool = false) -> ## Persist work requests, never the partially calculated transaction. func frame_checkpoint() -> Dictionary: - return {"requested_clock": frame_clock, "active_clock": frame_active_clock if frame_busy else {}, "speed": speed, "productions": _queued_productions} + return {"requested_clock": frame_clock, "active_clock": frame_active_clock if frame_busy else {}, "speed": speed, "productions": _queued_productions, "elapsed_seconds": frame_elapsed_seconds} func restore_frame_checkpoint(checkpoint: Dictionary) -> void: frame_clock = checkpoint.get("requested_clock", {}) _resume_clock = checkpoint.get("active_clock", {}) + var reserved_clock: Dictionary = _resume_clock if not _resume_clock.is_empty() else state.time + var pending_years: Variant = E.maximum(0, E.subtract(frame_clock.get("datetime", reserved_clock.datetime), reserved_clock.datetime)) + frame_elapsed_seconds = float(checkpoint.get("elapsed_seconds", E.approx(pending_years) / pow(10.0, int(checkpoint.get("speed", state.speed)) - 1))) _queued_productions = checkpoint.get("productions", []) if _resume_clock.is_empty(): speed = checkpoint.get("speed", state.speed)