Add new tests and implement frame budget management

- Introduced `civilization_benchmark.gd` and `legacy_quantity_math.gd` for benchmarking and legacy quantity calculations.
- Created `test_dynamic_math.gd`, `test_frame_evolution.gd`, and `test_sparse_batches.gd` to validate mathematical operations and frame evolution logic.
- Implemented `frame_budget.gd` to manage cooperative work on the main thread, ensuring no partially calculated state is published.
- Added UID files for all new scripts to maintain unique identifiers.
This commit is contained in:
Kirill
2026-10-01 23:43:34 +03:00
parent f51eea6c0f
commit c4e7302567
53 changed files with 1313 additions and 397 deletions
+50
View File
@@ -155,3 +155,53 @@ godot --headless --path Owniverse.GI --script res://tests/test_civilization_evol
```
Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации.
## Профиль цивилизаций на шестой скорости
```sh
godot --headless --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- frames=20 profile
godot --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- ui scenario=mixed frames=10 profile
```
Сценарии `mixed`, `large`, `fragmented` заполняют все стадии и этики, синхронизируя
переходы; `large` использует по 6e20 цивилизаций, `fragmented` — десять партий.
Это искусственные стрессовые сценарии. `scenario=stable` использует штатные
сроки стадий. Параметр `snapshot=/tmp/civil` сохраняет состояние каждого сценария
для сравнения до и после изменения. UI запускает настоящую сцену со страницей
жизни и реальным delta, без загрузки и записи сохранения игрока.
`profile` выводит суммарные микросекунды и число вызовов по этапам. Вложенные
этапы входят во время родительских; складывать их нельзя. В игре сбор выключен.
Кэши физических количеств принадлежат отдельному миру и проверяют изменения
количеств, вложенных привязок и индексов каталога. Кэш структуры правил не входит
в сохранение: при изменении Claim/бонусов нужно перестроить индексы каталога,
при изменении home в существующей таблице — invalidate_civilization_layout().
После оптимизации на M4 Pro, по 20 шагов 1/60 секунды, среднее время:
`mixed` 295 → 235 мс, `large` 666 → 539 мс, `fragmented` 359 → 282 мс.
Полные состояния всех трёх сценариев после 20 шагов совпали до и после изменения.
Дополнительно тест распределений сравнивает новый путь с прежним на 300
случайных наборах, включая 1e40 и накопленные долги округления.
## Расчёт между кадрами
`godot --headless --path Owniverse.GI --script res://tests/test_frame_evolution.gd`
Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает
следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся
на главном потоке, состояние публикуется только после полного завершения.
Длительность одной транзакции ограничена обычным шагом 1/60 секунды выбранной
скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может
отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий.
Производство во время транзакции выполняется после её завершения. Запрошенное
время, активный интервал, скорость и очередь производства входят в сохранение;
при загрузке незавершённый интервал пересчитывается с опубликованного состояния.
Сброс мира отменяет отложенный расчёт. Тест проверяет точное совпадение непрерывного
и распределённого расчёта, очереди, переключение скорости, восстановление и отмену.
На M4 Pro, 180 кадров реального UI на шестой скорости: смешанный сценарий
15.9 FPS (максимальный кадр 98 мс), большие количества 17.5 FPS (106 мс),
много партий 16.3 FPS (97 мс).
Это FPS отрисовки, а не число завершённых транзакций симуляции в секунду.
+74
View File
@@ -0,0 +1,74 @@
extends SceneTree
const Fixture = preload("res://tests/fixtures/civilization_benchmark.gd")
func _initialize(): call_deferred("run")
func run():
var ui: bool = "ui" in OS.get_cmdline_user_args()
var scenarios: Array = ["mixed", "large", "fragmented"]
var frames: int = 120
var step_delta: float = 1.0 / 60.0
for argument in OS.get_cmdline_user_args():
if argument.begins_with("scenario="): scenarios = [argument.trim_prefix("scenario=")]
if argument.begins_with("delta="): step_delta = float(argument.trim_prefix("delta="))
if argument.begins_with("frames="): frames = maxi(2, int(argument.trim_prefix("frames=")))
Engine.max_fps = 60
if ui: DisplayServer.window_set_size(Vector2i(540, 960))
for scenario in scenarios:
var scene
var stage
var world
if ui:
scene = load("res://world/world.tscn").instantiate()
stage = scene.get_node("Stage")
stage.owniverse.free()
stage.set_script(load("res://tests/fixtures/benchmark_ui_stage.gd"))
root.add_child(scene)
await process_frame
await process_frame
world = stage.owniverse
else:
world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
Fixture.seed(world, root.get_node("Global"), scenario == "fragmented", scenario == "large", scenario == "stable")
world.profile_enabled = "profile" in OS.get_cmdline_user_args()
var samples: Array = []
if ui:
stage.stage_page_scroller.scroll_horizontal = 4 * stage.PAGE_WIDTH
for item in stage.owniverse_items.values(): item.visible = true
for page in stage.item_pages: page.visible = true
stage._set_item_description(stage.owniverse_items["C2.0"])
stage._refresh_universe_ui()
for warmup in 10: await process_frame
stage.measuring = true
for frame in frames:
await process_frame
if (frame + 1) % 10 == 0:
print("%s: measured %d UI frames" % [scenario, frame + 1])
stage.measuring = false
samples = stage.process_samples
report(scenario + " UI frame", stage.frame_samples)
else:
for frame in frames:
var started: int = Time.get_ticks_usec()
world.evolute(step_delta)
samples.append((Time.get_ticks_usec() - started) / 1000.0)
report(scenario + (" UI handler" if ui else " CPU"), samples)
if ui:
print("Completed simulation transactions: ", world.frame_commits, "; published year: ", world.state.time.year, "; requested year: ", world.frame_clock.get("year", 0))
print("Statistics ", world.state.civilization_stats)
if world.profile_enabled: print("Profile microseconds/calls: ", world.profile)
for argument in OS.get_cmdline_user_args():
if argument.begins_with("snapshot="):
var file = FileAccess.open(argument.trim_prefix("snapshot=") + "-" + scenario + ".json", FileAccess.WRITE)
file.store_string(JSON.stringify(world.E.encode(world.state)))
for entity in world.catalog.entities.values(): entity.free()
world.free()
if ui: scene.free()
quit()
func report(label: String, samples: Array):
var total: float = 0.0
for sample in samples: total += sample
samples.sort()
print("%s: mean %.2f ms, p95 %.2f ms, max %.2f ms, %.1f per second" % [label, total / samples.size(), samples[ceili(samples.size() * 0.95) - 1], samples.back(), samples.size() * 1000 / total])
+1
View File
@@ -0,0 +1 @@
uid://b5w4sp6eepypa
+2 -2
View File
@@ -22,8 +22,8 @@ func run():
quit(1)
return
var data = JSON.parse_string(FileAccess.get_file_as_string(args[0]))
if not data is Dictionary or data.get("format_version") != 4:
push_error("Expected diagnostic JSON format 4")
if not data is Dictionary or data.get("format_version") != 5:
push_error("Expected diagnostic JSON format 5")
quit(1)
return
var initial: Dictionary = E.decode(restore_json_integers(data)).state
+40
View File
@@ -0,0 +1,40 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
func _initialize(): call_deferred("run")
func run():
for scenario in ["ordinary", "hypergiants"]:
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
for id in world.catalog.entities:
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
var group: String = world.catalog.entities[id].group_as
if group != "":
world.state.amount[group] = 0
world.state.stats.total_amount[group] = 0
world.speed = 6
world.state.amount.S = E.of("1e24")
world.state.amount.H = E.of("1e24")
for entry in [["RD", 1234567], ["YD", 765432], ["STN", 654321]]:
world.state.amount[entry[0]] = entry[1]
world.batches.append_batch(world.state, entry[0], entry[1], 0, 0)
if scenario == "hypergiants":
world.state.amount.HG = E.of("1e20")
world.batches.append_batch(world.state, "HG", E.of("1e20"), 0, 0)
var start_year = E.subtract(world.catalog.entities.HG.lifespan, 1000)
world.state.time = {"datetime": start_year, "year": start_year}
var samples: Array = []
var total: int = 0
for frame in 600:
var started: int = Time.get_ticks_usec()
world.evolute(1.0 / 60.0)
var duration: int = Time.get_ticks_usec() - started
samples.append(duration)
total += duration
samples.sort()
print("Speed 6 %s: 600 frames, mean %.3f ms, p95 %.3f ms, max %.3f ms, %.1f cycles/sec" % [scenario, total / 600000.0, samples[569] / 1000.0, samples.back() / 1000.0, 600000000.0 / total])
for entity in world.catalog.entities.values(): entity.free()
world.free()
quit()
+1
View File
@@ -0,0 +1 @@
uid://bboo6op14es24
+56
View File
@@ -0,0 +1,56 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
func _initialize(): call_deferred("run")
func report(label: String, samples: Array):
var total: float = 0.0
var slow: int = 0
for sample in samples:
total += sample
if sample > 33.333: slow += 1
samples.sort()
print("%s: %d samples, mean %.3f ms, p95 %.3f ms, max %.3f ms, >33ms %d" % [label, samples.size(), total / samples.size(), samples[int(samples.size() * 0.95) - 1], samples.back(), slow])
func run():
Engine.max_fps = 60
DisplayServer.window_set_size(Vector2i(540, 960))
print("Renderer: ", RenderingServer.get_current_rendering_method(), " adapter: ", RenderingServer.get_video_adapter_name())
for scenario in ["ordinary", "hypergiants"]:
for page in ([2] if "stars" in OS.get_cmdline_user_args() else [1, 2]):
var scene = load("res://world/world.tscn").instantiate()
var stage = scene.get_node("Stage")
stage.owniverse.free()
stage.set_script(load("res://tests/fixtures/benchmark_ui_stage.gd"))
root.add_child(scene)
await process_frame
await process_frame
var world = stage.owniverse
world.speed = 6
world.state.amount.S = E.of("1e24")
world.state.amount.H = E.of("1e24")
for entry in [["RD", 1234567], ["YD", 765432], ["STN", 654321]]:
world.state.amount[entry[0]] = entry[1]
world.batches.append_batch(world.state, entry[0], entry[1], 0, 0)
if scenario == "hypergiants":
world.state.amount.HG = E.of("1e20")
world.batches.append_batch(world.state, "HG", E.of("1e20"), 0, 0)
var start_year = E.subtract(world.catalog.entities.HG.lifespan, 1000)
world.state.time = {"datetime": start_year, "year": start_year}
stage.stage_page_scroller.scroll_horizontal = page * stage.PAGE_WIDTH
for item in stage.owniverse_items.values(): item.visible = true
if page == 2: stage._control_page(stage.owniverse_items.HG)
stage._set_item_description(stage.owniverse_items.HG if page == 2 else stage.owniverse_items.H)
stage._refresh_universe_ui()
for warmup in 30: await process_frame
stage.measuring = true
for frame in 600: await process_frame
stage.measuring = false
var label: String = "%s page %d" % [scenario, page]
report(label + " Stage CPU", stage.process_samples)
report(label + " frame wall", stage.frame_samples)
print("UI counters: H=", stage.owniverse_items.H.description.text, " HG=", stage.owniverse_items.HG.description.text, " date=", stage.date_time.text)
for entity in world.catalog.entities.values(): entity.free()
world.free()
scene.free()
quit()
+1
View File
@@ -0,0 +1 @@
uid://b3gmlo2vq6kk4
+19
View File
@@ -0,0 +1,19 @@
extends "res://stage/stage.gd"
var measuring: bool = false
var process_samples: Array = []
var frame_samples: Array = []
var previous_frame: int = 0
# Exercise the production Stage, with isolated state and no player-save I/O.
func load_data() -> bool: return false
func save_data() -> void: pass
func _notification(_what: int) -> void: pass
func _process(delta: float) -> void:
if not measuring: return
var started: int = Time.get_ticks_usec()
if previous_frame: frame_samples.append((started - previous_frame) / 1000.0)
previous_frame = started
super._process(delta)
process_samples.append((Time.get_ticks_usec() - started) / 1000.0)
+1
View File
@@ -0,0 +1 @@
uid://d1wm7nav71y3o
File diff suppressed because one or more lines are too long
+30
View File
@@ -0,0 +1,30 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
static func seed(world, global, fragmented: bool = false, large: bool = false, stable: bool = false):
for id in world.catalog.entities:
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
var entity = world.catalog.entities[id]
if entity.group_as != "":
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
for id in ["RD", "BG", "TWD", "SNS", "MGT", "BH25", "SMBH", "SCD", "GEM", "LGG", "LSC", "LGF", "LGW", "LUB"]:
world.state.amount[id] = E.of("1e48")
world.state.amount.S = E.of("1e50")
world.state.amount.H = E.of("1e48")
world.state.binds.RD = {"HP": E.of("1e30"), "RP": E.of("1e30"), "GG": E.of("1e30"), "AB": E.of("1e30")}
var count = E.of("6e20") if large else 6000
for id in global.CivilizationRules:
if E.le(global.CivilizationRules[id].span, 0): continue
world.state.amount[id] = count
world.state.ethics[id] = {}
for ethic in ["m", "l", "w", "d", "c", "b"]:
world.state.ethics[id][ethic] = E.divide(count, 6)
world.state.batches[id] = []
var parties: int = 10 if fragmented else 1
for i in parties:
var lifespan = E.of(global.CivilizationRules[id].span) if stable else 1
world.state.batches[id].append({"amount": E.divide(count, parties), "created_from": i * 100, "created_to": 1000 + i * 100, "start_year": E.add(lifespan, i * 100), "end_year": E.add(lifespan, 1000 + i * 100)})
world.ledger.refresh_groups(world.state)
world.speed = 6
+1
View File
@@ -0,0 +1 @@
uid://bjloakx4qdpsj
+59
View File
@@ -0,0 +1,59 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
# Frozen apportionment before the civilization performance refactor.
static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
var total: Variant = 0
for amount in requests.values():
total = E.add(total, E.floor_value(E.maximum(0, amount)))
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
var result: Dictionary = {}
var order: Array = []
var assigned: Variant = 0
for id in requests:
var expected: Variant = (E.mul_div(E.floor_value(E.maximum(0, requests[id])), available, total) if E.gt(total, 0) else 0)
result[id] = E.floor_value(expected)
assigned = E.add(assigned, result[id])
order.append({"id": id, "rest": E.subtract(expected, result[id])})
order.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return (E.gt(a.rest, b.rest) if E.ne(a.rest, b.rest) else E.lt(str(a.id), str(b.id))))
for item : Dictionary in order:
if E.ge(assigned, available):
break
if E.lt(result[item.id], E.floor_value(requests[item.id])):
result[item.id] = E.add(result[item.id], 1)
assigned = E.add(assigned, 1)
return result
static func sum(pop: Dictionary) -> Variant:
var result: Variant = 0
for n in pop.values(): result = E.add(result, n)
return result
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
var result: Dictionary = {}
var sum_weights: Variant = sum(weights)
if (E.le(count, 0) or E.le(sum_weights, 0)): return result
var expected: Dictionary = {}
var positive_sum: Variant = 0
for id in weights:
expected[id] = E.add(E.mul_div(count, weights[id], sum_weights), remainders.get(E.add(E.add(key, ":"), id), 0))
positive_sum = E.add(positive_sum, E.maximum(0, expected[id]))
var order: Array = []
var assigned: Variant = 0
for id in weights:
var normalized: Variant = (E.mul_div(E.maximum(0, expected[id]), count, positive_sum) if E.gt(positive_sum, 0) else 0)
result[id] = E.floor_value(normalized)
assigned = E.add(assigned, result[id])
order.append({"id": id, "rest": E.subtract(normalized, result[id])})
order.sort_custom(func ( a , b ) : return (E.gt(a.rest, b.rest) if E.ne(a.rest, b.rest) else E.lt(str(a.id), str(b.id))))
for entry in order:
if E.ge(assigned, count): break
result[entry.id] = E.add(result[entry.id], 1)
assigned = E.add(assigned, 1)
for id in weights:
remainders[E.add(E.add(key, ":"), id)] = E.clamp_value(E.subtract(expected[id], result[id]), E.neg(1), 1)
return result
+1
View File
@@ -0,0 +1 @@
uid://dxsly3qpyqrjy
+13
View File
@@ -58,6 +58,19 @@ func run() -> void:
check(E.eq(completions.size(), 2), "Repeated requests coalesce behind active write")
check(E.eq(manager.load_data("latest", "test-password").state.energy, 3), "Last queued snapshot wins")
var pending_clock: Dictionary = preload("res://world/owniverse/simulation_time.gd").advance(world.state.time, 0.01, 1)
data.simulation = {"requested_clock": pending_clock, "active_clock": pending_clock, "speed": 1, "productions": [{"id": "H", "amount": 7, "resource": true}]}
manager.request_save("checkpoint", "test-password", data)
manager.flush_saves()
check(E.eq(manager.load_data("checkpoint", "test-password").simulation, data.simulation), "Encrypted save preserves requested time and queued production")
var malformed: Dictionary = manager._snapshot("probe", "test-password", data).payload
malformed.simulation.productions[0].id = "missing-entity"
check(manager.validate_data(malformed).contains("queued production"), "Unknown queued production is rejected before resume")
malformed = manager._snapshot("probe", "test-password", data).payload
malformed.simulation.requested_clock.year = -1
check(manager.validate_data(malformed).contains("simulation clock"), "Invalid requested clock is rejected before resume")
data.erase("simulation")
var incompatible_path: String = directory.path_join("incompatible.adventure")
var incompatible = FileAccess.open_encrypted_with_pass(incompatible_path, FileAccess.WRITE, "test-password")
incompatible.store_var({"format_version": E.neg(1), "state": {"unknown": true}}, false)
+1 -1
View File
@@ -53,7 +53,7 @@ func invariants(world) -> void:
check(E.eq(sum_ethics(world, id), physical.get(id, 0)), E.add("Ethics equal physical population for ", id))
for n in world.state.ethics[id].values():
if (E.lt(n, 0) or E.ne(n, E.floor_value(n))): check(false, "Invalid ethic amount")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
func run() -> void:
var global: Node = root.get_node("Global")
+2 -2
View File
@@ -59,7 +59,7 @@ func run() -> void:
world.state.amount["C0.2"] = 3
world.evolution.initialize_life_batches()
check(E.eq(world.state.ethics["C0.2"].m, 3), "Losses reduce ethics without inventing population")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
restored.evolution.initialize_life_batches()
@@ -117,7 +117,7 @@ func run() -> void:
world.evolution.initialize_life_batches()
check(E.eq(world.state.amount["C4.2"], 2), "SMBH capacity uses stellar mass equivalent")
check(E.eq(world.ledger.physical_amounts(world.state)["C1.9"], 1), "Unsupported civilization degrades through missing habitat families")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
dispose(world)
world = fixture()
+1 -1
View File
@@ -47,7 +47,7 @@ func run() -> void:
var text: String = description.render(world, global, "RD")
check((text.contains("[b]") and (not text.contains("%value"))), E.add("Localized template resolved: ", locale))
check(text.contains("#40cc40"), E.add("Affordable cost green: ", locale))
check(text.contains("4.0000E+10"), E.add("Lifespan substituted: ", locale))
check(text.contains("40 000 000 000"), E.add("Lifespan substituted: ", locale))
text = description.render(world, global, "He")
check((text.contains("He: 20.00% / H: 80.00%") and (not text.contains("%value"))), E.add("Helium metadata and mixture: ", locale))
text = description.render(world, global, "Shovelin", "Force")
+72
View File
@@ -0,0 +1,72 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const B = preload("res://world/owniverse/math/big_integer.gd")
var checks: int = 0
var failures: int = 0
func verify(condition: bool, label: String):
checks += 1
if not condition:
failures += 1
push_error(label)
func _initialize():
call_deferred("run")
func run():
var rng = RandomNumberGenerator.new()
rng.seed = 619
for trial in 300:
var n: int = rng.randi_range(0, 100)
var m: int = rng.randi_range(1, 100)
var a: int = rng.randi_range(0, 1000)
var b: int = rng.randi_range(0, 1000)
var expected: int = 0
for i in n: expected += (a * i + b) / m
verify(E.eq(E.floor_sum(n, a, b, m), expected), "floor sum matches explicit windows")
var huge = E.of("1e40")
verify(E.lt(E.of("1e1000"), INF), "finite huge number is below positive infinity")
verify(E.gt(E.of("-1e1000"), -INF), "finite huge negative number is above negative infinity")
verify(E.gt(INF, E.of("1e1000")), "positive infinity is above finite huge number")
verify(not E.eq(E.of("1e1000"), INF), "huge number is not infinity")
verify(E.eq(E.floor_sum(huge, 2, 1, 2), E.divide(E.multiply(huge, E.subtract(huge, 1)), 2)), "floor sum supports huge window counts")
var rows = JSON.parse_string(FileAccess.get_file_as_string("res://tests/fixtures/big_integer_oracle.json"))
for row in rows:
var a: Array = B.parse(row[0])
var b: Array = B.parse(row[1])
var qr: Array = B.divmod(a, b)
verify(B.decimal(B.add(a, b)) == row[2], "independent big sum")
verify(B.decimal(B.multiply(a, b)) == row[3], "independent big product")
verify(B.decimal(qr[0]) == row[4], "independent big quotient")
verify(B.decimal(qr[1]) == row[5], "independent big remainder")
verify(E.fixed(E.multiply(E.of(row[0]), E.of(row[1]))) == row[3], "dynamic product")
verify(E.eq(E.mul_div(E.of(row[0]), E.of(row[1]), E.of(row[1])), E.of(row[0])), "fused conservation")
for text in ["9223372036854775807", "-9223372036854775808", "9223372036854775808", "-9223372036854775809", "1e300", "1e1000"]:
var n = E.of(text)
verify(E.eq(E.subtract(E.add(n, 1), n), 1), "unit preserved at " + text)
verify(E.eq(E.decode(JSON.parse_string(JSON.stringify(E.encode(n)))), n), "round trip " + text)
for text in ["1.25", "-1.25", "123.5"]:
verify(E.approx(E.of(text)) == float(text), "approx fraction " + text)
verify(not E.valid_encoded({"$exact": [[], "0000000000000000", 1]}), "reject empty blocks")
verify(not E.valid_encoded({"$exact": [[1, 0], "0000000000000000", 1]}), "reject noncanonical blocks")
verify(E.gt(E.power(E.of("1e400"), -1), 0) == false, "float fraction underflows beyond its range")
verify(absf(E.approx(E.logarithm(E.of("1e1000"))) - 1000 * log(10.0)) < 1e-8, "logarithm beyond float range")
verify(E.eq(E.square_root(E.of("1e1000")), E.of("1e500")), "square root beyond float range")
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
for id in world.catalog.entities:
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
var group: String = world.catalog.entities[id].group_as
if group != "":
world.state.amount[group] = 0
world.state.stats.total_amount[group] = 0
world.state.amount.H = E.of("1e1000")
world.state.amount.S = E.of("1e1000")
var environment: Dictionary = world.environment.stellar_environment(world.state)
verify(E.finite(environment.density) and E.gt(environment.density, 0), "huge density remains finite")
verify(E.near(environment.blast, 1), "huge geometric ratio stays valid")
for entity in world.catalog.entities.values(): entity.free()
world.free()
print("Dynamic math: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://8vkudj0tlnng
+1 -1
View File
@@ -94,7 +94,7 @@ func run():
check(str(world.state.auto_star_deficits).length() < 500, "true", "fraction storage stays bounded")
print("100 changing-composition cycles: %d ms" % (Time.get_ticks_msec() - started))
var saves = root.get_node("SaveManager")
check(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
check(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
for entity in world.catalog.entities.values(): entity.free()
world.free()
print("Exact math: %d checks, %d failures" % [checks, failures])
+100
View File
@@ -0,0 +1,100 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
const Fixture = preload("res://tests/fixtures/civilization_benchmark.gd")
var checks: int = 0
var failures: int = 0
func _initialize(): call_deferred("run")
func check(condition: bool, message: String):
checks += 1
if not condition:
failures += 1
push_error(message)
func fixture():
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
Fixture.seed(world, root.get_node("Global"))
return world
func dispose(world):
for entity in world.catalog.entities.values(): entity.free()
world.free()
func run():
Engine.max_fps = 0
var direct = fixture()
var sliced = fixture()
for delta in [1.0 / 60.0, 0.133333333333]:
direct.evolute(delta)
var clock: Dictionary = Clock.advance(sliced.state.time, delta, sliced.speed)
var budget = load("res://world/owniverse/frame_budget.gd").new(self, 1)
await sliced.evolution.evolute_to_datetime(clock.datetime, clock, budget)
check(E.eq(direct.state, sliced.state), "Sliced transaction exactly matches uninterrupted evolution")
dispose(direct)
dispose(sliced)
direct = fixture()
sliced = fixture()
var initial_h: Variant = sliced.state.amount.H
sliced.evolute_frame(1.0 / 60.0, self)
check(sliced.frame_busy, "Heavy transaction yields before publishing")
check(E.eq(sliced.state.time.year, 0), "Published clock stays at completed transaction")
sliced.request_production("H", 1, true)
check(E.eq(sliced.state.amount.H, initial_h), "Player action waits for transaction commit")
sliced.evolute_frame(0.01, self)
var requested: Dictionary = sliced.frame_clock.duplicate()
sliced.speed = 3
check(sliced.speed == 3 and sliced.state.speed == 6, "Speed change is visible and cannot mutate an unfinished transaction")
while sliced.frame_busy: await process_frame
direct.evolute(1.0 / 60.0)
direct.production.produce_hs("H", 1)
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)
var active: Dictionary = sliced.frame_active_clock.duplicate()
while sliced.frame_busy: await process_frame
direct.evolution.evolute_to_datetime(active.datetime, active)
check(E.eq(direct.state, sliced.state), "Bounded frame transaction matches its exact target clock")
check(E.eq(sliced.frame_clock, second_clock), "All elapsed time during a busy frame remains in the requested clock")
check(sliced.frame_commits == 2, "Only complete simulation states are committed")
dispose(direct)
dispose(sliced)
var original = fixture()
original.evolute_frame(1.0 / 60.0, self)
original.request_production("H", 123, true)
original.evolute_frame(0.01, self)
original.speed = 3
var checkpoint: Dictionary = E.decode(E.encode(original.frame_checkpoint()))
var published: Dictionary = E.decode(E.encode(original.state))
var expected_clock: Dictionary = original.frame_active_clock.duplicate()
dispose(original)
for frame in 3: await process_frame
var resumed = fixture()
var expected = fixture()
resumed.state = published.duplicate(true)
expected.state = published.duplicate(true)
resumed.restore_frame_checkpoint(checkpoint)
resumed.evolute_frame(0.0, self)
while resumed.frame_busy: await process_frame
expected.evolution.evolute_to_datetime(expected_clock.datetime, expected_clock)
expected.production.produce_hs("H", 123)
expected.speed = 3
check(E.eq(expected.state, resumed.state), "Checkpoint recomputes interrupted transaction and preserves queued production")
check(E.eq(resumed.frame_clock, checkpoint.requested_clock), "Checkpoint preserves all requested elapsed time")
dispose(resumed)
dispose(expected)
var cancelled = fixture()
cancelled.evolute_frame(0.133333333333, self)
check(cancelled.frame_busy, "Cancellation fixture has suspended work")
var budget_ref: RefCounted = cancelled.frame_budget
dispose(cancelled)
for frame in 3: await process_frame
check(budget_ref.cancelled, "Resetting the world cancels suspended work without publishing")
var invalid = fixture()
var before: Dictionary = invalid.state.duplicate(true)
invalid.evolute_frame(1e308, self)
invalid.evolute(1e308)
check(not invalid.frame_busy and invalid.frame_clock.is_empty() and E.eq(before, invalid.state), "Overflowing elapsed float cannot enter either clock API")
dispose(invalid)
print("Frame evolution: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://te15r63sqhxs
+2 -2
View File
@@ -54,7 +54,7 @@ func run() -> void:
check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
var physical: Dictionary = world.ledger.physical_amounts(world.state)
check(E.eq(E.add(E.add(physical.HP, physical.EON1), physical.RP), 40000), "Life conserves planets")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolution.evolve_batches(world.state, 11000000)
@@ -118,7 +118,7 @@ func run() -> void:
world.evolution.evolve_batches(world.state, 2050)
check(E.eq(world.state.binds.RD["C0.2"], 700), "C0.1 reaches ethical civilization without losing host")
check(E.eq(world.state.binds.RD["C0.0"], 1000), "C0.1 degradation follows Unity")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
dispose(world)
print(("Life: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+48
View File
@@ -2,6 +2,7 @@ extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Math = preload("res://world/owniverse/quantity_math.gd")
const Legacy = preload("res://tests/fixtures/legacy_quantity_math.gd")
var checks: int = 0
var failures: int = 0
@@ -15,6 +16,24 @@ func check(condition: bool, message: String) -> void:
push_error(message)
func run() -> void:
var rng = RandomNumberGenerator.new()
rng.seed = 65432
var old_remainders: Dictionary = {}
var new_remainders: Dictionary = {}
for sample in 300:
var requests: Dictionary = {}
var weights: Dictionary = {}
for id in ["w", "m", "b", "c", "d", "l"]:
requests[id] = E.of(str(rng.randi_range(-10, 100)) + "." + str(rng.randi_range(0, 9)))
weights[id] = rng.randi_range(0, 100)
if sample % 3 == 0:
for id in requests: requests[id] = E.multiply(requests[id], E.of("1e40"))
var limit: Variant = E.multiply(rng.randi_range(0, 600), E.of("1e40")) if sample % 3 == 0 else rng.randi_range(0, 600)
check(E.eq(Math.allocate_whole(requests, limit), Legacy.allocate_whole(requests, limit)), "Allocation matches legacy with limits, ties and big integers")
var count: Variant = limit
check(E.eq(Math.split(new_remainders, "population", count, weights), Legacy.split(old_remainders, "population", count, weights)), "Split matches legacy rounding")
check(E.eq(new_remainders, old_remainders), "Signed remainder debt matches legacy")
check_ledger_cache()
var Catalog = load("res://world/owniverse/entity_catalog.gd")
check(E.eq(Math.allocate_whole({}, 10), {}), "Empty allocation")
check(E.eq(Math.allocate_whole({"b": 1, "a": 1}, 1), {"a": 1, "b": 0}), "Stable lexical tie")
@@ -62,3 +81,32 @@ func run() -> void:
for entity in catalog.entities.values(): entity.free()
print(("Quantity and catalog: %d checks, %d failures" % [checks, failures]))
quit((1 if failures else 0))
func check_ledger_cache() -> void:
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
world.state.amount.RD = E.of("1e40")
world.state.binds.GEM = {"RD": 7}
check(E.eq(world.ledger.physical_amounts(world.state).RD, E.add(E.of("1e40"), 7)), "Initial cached physical amount")
var returned: Dictionary = world.ledger.physical_amounts(world.state)
returned.RD = 0
check(E.gt(world.ledger.physical_amounts(world.state).RD, 0), "Caller cannot mutate cached physical totals")
world.state.binds.GEM.RD = 11
check(E.eq(world.ledger.physical_amounts(world.state).RD, E.add(E.of("1e40"), 11)), "Nested binding change invalidates physical cache")
world.state.amount.RD = 2
check(E.eq(world.ledger.physical_amounts(world.state).RD, 13), "Direct count change invalidates physical cache")
var other: Dictionary = world.state.duplicate(true)
other.binds.clear()
check(E.eq(world.ledger.physical_amounts(other).RD, 2), "Alternate state has independent physical totals")
check(E.eq(world.ledger.physical_amounts(world.state).RD, 13), "Returning to original state refreshes cache")
var revision: int = world.catalog.index_revision
world.catalog.rebuild_group_index()
check(world.catalog.index_revision > revision, "Catalog rebuild invalidates cached membership")
check(E.eq(world.ledger.physical_amounts(world.state).RD, 13), "Rebuild retains correct physical totals")
var rules: Dictionary = root.get_node("Global").CivilizationRules.duplicate(true)
var original_stages: int = world.catalog.civilization_layout(rules).stages.size()
rules["C2.0"].home = ""
world.catalog.invalidate_civilization_layout()
check(world.catalog.civilization_layout(rules).stages.size() == original_stages - 1, "Explicit rules change rebuilds civilization layout")
for entity in world.catalog.entities.values(): entity.free()
world.free()
+18 -2
View File
@@ -38,8 +38,11 @@ func equivalent(a: Variant, b: Variant) -> bool:
for i in a.size():
if (not equivalent(a[i], b[i])): return false
return true
if ((((a is float) or (a is int))) and (((b is float) or (b is int)))):
return E.lt(E.absolute(E.subtract(E.of(a), E.of(b))), E.of("1e-6"))
if E.numeric(a) and E.numeric(b):
# Counts must match exactly. Fractional float remainders can differ
# when the analytic integral replaces thousands of subtractions.
if not E.eq(E.floor_value(a), E.floor_value(b)): return false
return E.le(E.absolute(E.subtract(a, b)), E.of("1e-8"))
return E.eq(a, b)
func run() -> void:
@@ -75,6 +78,19 @@ func run() -> void:
for batch in fast.state.batches.SN: total = E.add(total, batch.amount)
check(E.eq(total, 10000), "Direct completions plus survivors conserve event count")
dispose(fast)
# Dense completed events must not iterate a million elapsed windows.
# An aligned, fully completed cohort produces exactly count * lifespan.
fast = fixture(false)
pending = {}
var lifespan: Variant = fast.catalog.entities.SN.lifespan
var window: Variant = E.divide(lifespan, root.get_node("Global").batch_count) if E.gt(lifespan, 10) else lifespan
var creation_end: Variant = E.multiply(window, 1000000)
var dense_count: Variant = E.of("1e20")
var integral: Variant = fast.batches.append_batch(fast.state, "SN", dense_count, 0, creation_end, pending, E.add(creation_end, lifespan))
check(E.eq(pending.SN[0].amount, dense_count), "Million-window aggregation conserves every event")
check(E.eq(integral, E.multiply(dense_count, lifespan)), "Million-window aggregation preserves lifetime integral")
check(fast.state.batches.SN.is_empty(), "Completed million-window cohort leaves no transient batches")
dispose(fast)
# Sparse explosions: thousands of time windows but very few actual events.
# Exact interval edges and partial final windows exercise the jump boundary.
for count in [1, 3, 17]:
+52
View File
@@ -0,0 +1,52 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var checks: int = 0
var failures: int = 0
func _initialize(): call_deferred("run")
func check(value: bool, message: String):
checks += 1
if not value:
failures += 1
push_error(message)
func run():
var world = load("res://world/owniverse/owniverse.gd").new()
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
world.catalog.entities.N.lifespan = 24
var width: Variant = E.divide(24, root.get_node("Global").batch_count)
for count in [1, 2, 7, 11, 100, E.of("1e40")]:
for interval in [[E.of("0.25"), E.of("101.75")], [0, 120], [3, 3]]:
world.state.batches = {}
world.state.batch_origins = {"N": E.of("0.125")}
var expected: Array = []
var origin: Variant = world.state.batch_origins.N
var index: Variant = E.floor_value(E.divide(E.subtract(interval[0], origin), width))
var assigned: Variant = 0
var cursor: Variant = interval[0]
while true:
var start: Variant = E.add(origin, E.multiply(index, width))
var end: Variant = E.add(origin, E.multiply(E.add(index, 1), width))
if E.le(end, cursor):
index = E.add(index, 1)
continue
var until: Variant = E.minimum(interval[1], end)
var amount: Variant = E.subtract(count, assigned)
if E.gt(interval[1], interval[0]) and E.lt(until, interval[1]):
amount = E.subtract(E.floor_value(E.mul_div(count, E.subtract(until, interval[0]), E.subtract(interval[1], interval[0]))), assigned)
if E.gt(amount, 0):
expected.append({"created_from": start, "created_to": end, "start_year": E.add(start, 24), "end_year": E.add(end, 24), "amount": amount})
assigned = E.add(assigned, amount)
if E.ge(until, interval[1]): break
cursor = end
index = E.add(index, 1)
world.batches.append_batch(world.state, "N", count, interval[0], interval[1])
var actual: Array = []
for batch in world.state.batches.N:
if E.gt(batch.amount, 0): actual.append(batch)
check(E.eq(actual, expected), "Sparse jump preserves every nonempty quota and boundary")
var total: Variant = 0
for batch in actual: total = E.add(total, batch.amount)
check(E.eq(total, count), "Sparse jump preserves complete whole population")
for entity in world.catalog.entities.values(): entity.free()
world.free()
print("Sparse batches: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0)
+1
View File
@@ -0,0 +1 @@
uid://gtvie7p6j62x
+3 -3
View File
@@ -96,7 +96,7 @@ func run() -> void:
check((E.eq(world.state.binds.RD.GG, 16) and E.eq(world.state.binds.RD.AB, 120)), "Planet multiplicity applied")
check((E.eq(world.state.amount.HP, 0) and E.eq(world.state.stats.total_amount.HP, 1)), "Created planets are bound, counted once")
var saves: Node = root.get_node("SaveManager")
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.stellar.create_planets(world.state, "RD", 37, 0, 0, E.of("0.7"))
@@ -118,7 +118,7 @@ func run() -> void:
check((E.eq(world.state.amount.RP, 0) and E.eq(world.state.amount["C1.3"], 0)), "Inherited planets never become free")
world.evolution.evolve_batches(world.state, E.add(lifespan, width))
check((E.eq(world.state.binds.RD["C1.3"], 0) and E.eq(world.state.binds.TWD["C1.3"], 4)), "No planets left at extinct parent type")
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
dispose(world)
world = fixture()
@@ -158,7 +158,7 @@ func run() -> void:
check((E.eq(world.state.amount.SN, 0) and E.eq(world.state.amount.LNS, 1)), "Supernova produces a remnant")
check(((E.eq(world.state.amount.H, 8) and E.eq(world.state.amount.He, 50)) and E.eq(world.state.amount.Met, 11)), "Supernova returns table resources")
check((not world.state.amount.has("blast")), "Supernova dispatches blast output")
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
dispose(world)
world = fixture()
+2 -2
View File
@@ -51,10 +51,10 @@ func run() -> void:
for invalid in [0, E.neg(1), NAN, INF, 1e308]:
world.evolute(invalid)
check(E.eq(world.state, before), "Invalid/overflow interval rejected")
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
var invalid_save: Dictionary = world.state.duplicate(true)
invalid_save.time.year = E.of("0.25")
check(E.ne(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
check(E.ne(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
dispose(world)
for fps in [30, 60, 144]:
+1 -1
View File
@@ -133,7 +133,7 @@ func run() -> void:
Parameters.advance(source, world.state, rules, E.of("1.0e12"))
check((E.ge(world.state.trends.Integration, 0) and E.lt(world.state.trends.Integration, 1)), "Very large intervals finish with a valid trend fraction")
check(E.ge(world.state.world_parameters.Agg, 1), "Repeated integration events cannot reduce aggression below the scale")
var payload: Dictionary = {"format_version": 4, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
var payload: Dictionary = {"format_version": 5, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
var manager: Node = root.get_node("SaveManager")
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
var old: Dictionary = payload.duplicate(true)