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
+29 -6
View File
@@ -6,7 +6,7 @@ const E = preload("res://world/owniverse/math/exact.gd")
const SETTING_SECTION = "Settings" const SETTING_SECTION = "Settings"
const SAVE_PATH = "user://settings.cfg" const SAVE_PATH = "user://settings.cfg"
const FORMAT_VERSION : int = 4 const FORMAT_VERSION : int = 5
const SimulationTime = preload("res://world/owniverse/simulation_time.gd") const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd") const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const STATE_VERSION : String = SimulationTime.STATE_VERSION const STATE_VERSION : String = SimulationTime.STATE_VERSION
@@ -35,7 +35,7 @@ func _snapshot(filename: String, password: String, data: Dictionary) -> Dictiona
var payload: Dictionary = E.encode(data) var payload: Dictionary = E.encode(data)
payload.format_version = FORMAT_VERSION payload.format_version = FORMAT_VERSION
payload.app_version = str(ProjectSettings.get_setting("application/config/version", "")) payload.app_version = str(ProjectSettings.get_setting("application/config/version", ""))
return {"filename": filename, "path": save_directory.path_join(E.add(filename, ".adventure")), "password": password, "payload": payload, "plaintext": Global.save_plaintext_copy, "speed_limit": Global.SPEED_LIMIT, "stages": Global.CivilizationRules.keys()} return {"filename": filename, "path": save_directory.path_join(E.add(filename, ".adventure")), "password": password, "payload": payload, "plaintext": Global.save_plaintext_copy, "speed_limit": Global.SPEED_LIMIT, "stages": Global.CivilizationRules.keys(), "entities": Global.OwniverseEntities.keys()}
## True means queued, not written. Completion is reported by save_completed. ## True means queued, not written. Completion is reported by save_completed.
@@ -105,7 +105,7 @@ func save_data(filename: String, password: String, data: Dictionary) -> bool:
static func _write_snapshot(job: Dictionary) -> String: static func _write_snapshot(job: Dictionary) -> String:
var payload: Dictionary = job.payload var payload: Dictionary = job.payload
var problem: String = _validate_payload(payload, job.speed_limit, job.stages) var problem: String = _validate_payload(payload, job.speed_limit, job.stages, job.entities)
if E.ne(problem, ""): if E.ne(problem, ""):
return E.add("Invalid save: ", problem) return E.add("Invalid save: ", problem)
var path: String = job.path var path: String = job.path
@@ -207,17 +207,17 @@ static 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:
return _validate_payload(data, Global.SPEED_LIMIT, Global.CivilizationRules.keys()) return _validate_payload(data, Global.SPEED_LIMIT, Global.CivilizationRules.keys(), Global.OwniverseEntities.keys())
static func _validate_payload(data: Variant, speed_limit: int, stages: Array) -> String: static func _validate_payload(data: Variant, speed_limit: int, stages: Array, entities: Array) -> String:
if not E.valid_encoded(data): return "invalid exact number encoding" if not E.valid_encoded(data): return "invalid exact number encoding"
data = E.decode(data) data = E.decode(data)
if ((not (data is Dictionary)) or (not _plain_tree(data))): if ((not (data is Dictionary)) or (not _plain_tree(data))):
return "save must contain plain finite values" return "save must contain plain finite values"
if (E.ne(data.get("format_version"), FORMAT_VERSION) or (not (data.get("app_version") is String))): if (E.ne(data.get("format_version"), FORMAT_VERSION) or (not (data.get("app_version") is String))):
return "unsupported save format" return "unsupported save format"
if (((not (data.get("ui") is Dictionary)) or (not (data.ui.get("current_page") is int))) or E.lt(data.ui.current_page, 0)): if (((not (data.get("ui") is Dictionary)) or (not E.numeric(data.ui.get("current_page")))) or E.lt(data.ui.current_page, 0)):
return "invalid ui.current_page" return "invalid ui.current_page"
if (not (data.get("state") is Dictionary)): if (not (data.get("state") is Dictionary)):
return "state must be a Dictionary" return "state must be a Dictionary"
@@ -258,6 +258,29 @@ static func _validate_payload(data: Variant, speed_limit: int, stages: Array) ->
return "invalid state.speed" return "invalid state.speed"
if ((not _number(saved.speed_in_year)) or E.ne(saved.speed_in_year, E.power(10, E.subtract(saved.speed, 1)))): if ((not _number(saved.speed_in_year)) or E.ne(saved.speed_in_year, E.power(10, E.subtract(saved.speed, 1)))):
return "invalid state.speed_in_year" return "invalid state.speed_in_year"
if data.has("simulation"):
var checkpoint: Variant = data.simulation
if not checkpoint is Dictionary or not checkpoint.get("productions") is Array:
return "invalid simulation checkpoint"
if not _number(checkpoint.get("speed"), true) or E.lt(checkpoint.speed, 1) or E.gt(checkpoint.speed, speed_limit):
return "invalid simulation speed"
for name in ["requested_clock", "active_clock"]:
var clock: Variant = checkpoint.get(name)
if not clock is Dictionary: return "invalid simulation clock"
if clock.is_empty(): continue
if not _number(clock.get("datetime")) or not _number(clock.get("year")):
return "invalid simulation clock"
if E.lt(clock.datetime, saved.time.datetime) or E.ne(clock.year, SimulationTime.processed_year(clock.datetime)):
return "invalid simulation clock"
if not E.finite(clock.get("tick_remainder", 0)) or E.ge(E.absolute(clock.get("tick_remainder", 0)), 1):
return "invalid simulation tick"
if not checkpoint.active_clock.is_empty():
if checkpoint.requested_clock.is_empty() or E.gt(checkpoint.active_clock.datetime, checkpoint.requested_clock.datetime):
return "invalid simulation clock order"
for command in checkpoint.productions:
if not command is Dictionary or not entities.has(command.get("id")) or not command.get("resource") is bool or not _number(command.get("amount")):
return "invalid queued production"
if command.resource and command.id not in ["S", "H"]: return "invalid queued resource production"
if ((not _number(saved.energy, true)) or (not _number_map(saved.amount, true))): if ((not _number(saved.energy, true)) or (not _number_map(saved.amount, true))):
return "invalid state resources" return "invalid state resources"
if ((not (saved.stats is Dictionary)) or (not _number_map(saved.stats.get("total_amount"), true))): if ((not (saved.stats is Dictionary)) or (not _number_map(saved.stats.get("total_amount"), true))):
+5 -3
View File
@@ -219,7 +219,7 @@ func item_activated(item: OwniverseItem) -> void:
if item.item_id == "S" or item.item_id == "H": if item.item_id == "S" or item.item_id == "H":
var _value = owniverse.state.amount[item.item_id] var _value = owniverse.state.amount[item.item_id]
_value = E.multiply(9, _value) if E.gt(_value, 0) else 1 _value = E.multiply(9, _value) if E.gt(_value, 0) else 1
owniverse.production.produce_hs(item.item_id, _value) owniverse.request_production(item.item_id, _value, true)
func _check_counters(delta: float) -> Array: func _check_counters(delta: float) -> Array:
@@ -302,7 +302,7 @@ func _page_action_pressed(page_id: int, action_amount) -> void:
var item_id: String = g_active_items[page_id].item_id var item_id: String = g_active_items[page_id].item_id
if E.gt(action_amount, 0): if E.gt(action_amount, 0):
owniverse.production.produce(item_id, action_amount) owniverse.request_production(item_id, action_amount)
#endregion #endregion
@@ -369,7 +369,7 @@ func _refresh_universe_ui() -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
owniverse.evolute(delta) owniverse.evolute_frame(delta, get_tree())
indicator_elapsed += delta indicator_elapsed += delta
if indicator_elapsed >= 0.1: if indicator_elapsed >= 0.1:
indicator_elapsed = 0.0 indicator_elapsed = 0.0
@@ -449,6 +449,7 @@ func load_data() -> bool:
var complete: Dictionary = owniverse.state.duplicate(true) var complete: Dictionary = owniverse.state.duplicate(true)
complete.merge(restored, true) complete.merge(restored, true)
owniverse.state = complete owniverse.state = complete
owniverse.restore_frame_checkpoint(game_data.get("simulation", {}))
owniverse.evolution.initialize_life_batches() owniverse.evolution.initialize_life_batches()
owniverse.presentation.refresh(owniverse.state, owniverse.catalog) owniverse.presentation.refresh(owniverse.state, owniverse.catalog)
@@ -471,6 +472,7 @@ func save_data() -> void:
page = clampi(roundi(float(stage_page_scroller.scroll_horizontal) / PAGE_WIDTH), 0, PAGE_COUNT - 1) page = clampi(roundi(float(stage_page_scroller.scroll_horizontal) / PAGE_WIDTH), 0, PAGE_COUNT - 1)
SaveManager.request_save("owniverse", "qwerty", { SaveManager.request_save("owniverse", "qwerty", {
"state": owniverse.state, "state": owniverse.state,
"simulation": owniverse.frame_checkpoint(),
"ui": {"current_page": page}, "ui": {"current_page": page},
}) })
+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. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации. Проверяются реальные коэффициенты 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) quit(1)
return return
var data = JSON.parse_string(FileAccess.get_file_as_string(args[0])) var data = JSON.parse_string(FileAccess.get_file_as_string(args[0]))
if not data is Dictionary or data.get("format_version") != 4: if not data is Dictionary or data.get("format_version") != 5:
push_error("Expected diagnostic JSON format 4") push_error("Expected diagnostic JSON format 5")
quit(1) quit(1)
return return
var initial: Dictionary = E.decode(restore_json_integers(data)).state 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(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") 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_path: String = directory.path_join("incompatible.adventure")
var incompatible = FileAccess.open_encrypted_with_pass(incompatible_path, FileAccess.WRITE, "test-password") 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) 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)) 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(): 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") 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: func run() -> void:
var global: Node = root.get_node("Global") 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.state.amount["C0.2"] = 3
world.evolution.initialize_life_batches() world.evolution.initialize_life_batches()
check(E.eq(world.state.ethics["C0.2"].m, 3), "Losses reduce ethics without inventing population") 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() var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
restored.evolution.initialize_life_batches() restored.evolution.initialize_life_batches()
@@ -117,7 +117,7 @@ func run() -> void:
world.evolution.initialize_life_batches() world.evolution.initialize_life_batches()
check(E.eq(world.state.amount["C4.2"], 2), "SMBH capacity uses stellar mass equivalent") 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(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) dispose(world)
world = fixture() world = fixture()
+1 -1
View File
@@ -47,7 +47,7 @@ func run() -> void:
var text: String = description.render(world, global, "RD") 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("[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("#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") 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)) 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") 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") 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)) print("100 changing-composition cycles: %d ms" % (Time.get_ticks_msec() - started))
var saves = root.get_node("SaveManager") 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() for entity in world.catalog.entities.values(): entity.free()
world.free() world.free()
print("Exact math: %d checks, %d failures" % [checks, failures]) 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") check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
var physical: Dictionary = world.ledger.physical_amounts(world.state) 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(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() var restored = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolution.evolve_batches(world.state, 11000000) world.evolution.evolve_batches(world.state, 11000000)
@@ -118,7 +118,7 @@ func run() -> void:
world.evolution.evolve_batches(world.state, 2050) 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.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(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) dispose(world)
print(("Life: %d checks, %d failures" % [checks, failures])) print(("Life: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1)) 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 E = preload("res://world/owniverse/math/exact.gd")
const Math = preload("res://world/owniverse/quantity_math.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 checks: int = 0
var failures: int = 0 var failures: int = 0
@@ -15,6 +16,24 @@ func check(condition: bool, message: String) -> void:
push_error(message) push_error(message)
func run() -> void: 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") var Catalog = load("res://world/owniverse/entity_catalog.gd")
check(E.eq(Math.allocate_whole({}, 10), {}), "Empty allocation") 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") 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() for entity in catalog.entities.values(): entity.free()
print(("Quantity and catalog: %d checks, %d failures" % [checks, failures])) print(("Quantity and catalog: %d checks, %d failures" % [checks, failures]))
quit((1 if failures else 0)) 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(): for i in a.size():
if (not equivalent(a[i], b[i])): return false if (not equivalent(a[i], b[i])): return false
return true return true
if ((((a is float) or (a is int))) and (((b is float) or (b is int)))): if E.numeric(a) and E.numeric(b):
return E.lt(E.absolute(E.subtract(E.of(a), E.of(b))), E.of("1e-6")) # 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) return E.eq(a, b)
func run() -> void: func run() -> void:
@@ -75,6 +78,19 @@ func run() -> void:
for batch in fast.state.batches.SN: total = E.add(total, batch.amount) 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") check(E.eq(total, 10000), "Direct completions plus survivors conserve event count")
dispose(fast) 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. # Sparse explosions: thousands of time windows but very few actual events.
# Exact interval edges and partial final windows exercise the jump boundary. # Exact interval edges and partial final windows exercise the jump boundary.
for count in [1, 3, 17]: 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.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") 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") 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 = fixture()
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state)))) 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")) 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") 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)) 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(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) dispose(world)
world = fixture() 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.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(((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((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) dispose(world)
world = fixture() world = fixture()
+2 -2
View File
@@ -51,10 +51,10 @@ func run() -> void:
for invalid in [0, E.neg(1), NAN, INF, 1e308]: for invalid in [0, E.neg(1), NAN, INF, 1e308]:
world.evolute(invalid) world.evolute(invalid)
check(E.eq(world.state, before), "Invalid/overflow interval rejected") 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) var invalid_save: Dictionary = world.state.duplicate(true)
invalid_save.time.year = E.of("0.25") 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) dispose(world)
for fps in [30, 60, 144]: 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")) 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.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") 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") var manager: Node = root.get_node("SaveManager")
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation") check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
var old: Dictionary = payload.duplicate(true) var old: Dictionary = payload.duplicate(true)
+2 -1
View File
@@ -1,5 +1,6 @@
extends Control extends Control
class_name OwniverseItem class_name OwniverseItem
const E = preload("res://world/owniverse/math/exact.gd")
signal item_activated(item) signal item_activated(item)
@@ -20,7 +21,7 @@ var item_type = ""
func set_label(value) -> void: func set_label(value) -> void:
var text: String = Global.format_number(value) var text: String = E.scientific(value, 5) if E.gt(E.absolute(value), 1000000000) else (E.grouped(value, 0, false) if E.ne(value, 0) else "")
if description.text != text: if description.text != text:
description.text = text description.text = text
#description.text = str(value) #description.text = str(value)
+1 -5
View File
@@ -217,11 +217,7 @@ func produce_newborn_lifetime(target: Dictionary, id: String, count: Variant, bo
object_years = E.maximum(0, object_years) object_years = E.maximum(0, object_years)
for resource in products: for resource in products:
var coefficient: Variant = E.multiply(products[resource], WorldParameters.production(target, world.catalog.entities[id])) var coefficient: Variant = E.multiply(products[resource], WorldParameters.production(target, world.catalog.entities[id]))
# A giant cohort can accumulate more than E27 object-years. Clamp the var output: Variant = E.multiply(object_years, coefficient)
# multiplicand before multiplying so the exact-number backend never
# constructs an out-of-range intermediate value.
var safe_years: Variant = (E.minimum(object_years, E.divide(E.max_value(), coefficient)) if E.gt(coefficient, 0) else 0)
var output: Variant = E.multiply(safe_years, coefficient)
if E.contains(["H", "S"], resource): if E.contains(["H", "S"], resource):
output = E.minimum(output, target.energy) output = E.minimum(output, target.energy)
var actual: Variant = world.ledger.credit(target, resource, output) var actual: Variant = world.ledger.credit(target, resource, output)
+37 -9
View File
@@ -26,7 +26,7 @@ static func rate(target: Dictionary, key: String, count: Variant, probability: V
# Fixed-size largest-remainder apportionment with saved signed rounding errors. # Fixed-size largest-remainder apportionment with saved signed rounding errors.
# Runtime depends on the number of branches, never the number of civilizations. # Runtime depends on the number of branches, never the number of civilizations.
static func split(target: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary: static func split(target: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
if (E.le(count, 0) or E.le(QuantityMath.sum(weights), 0)): return {} if E.le(count, 0): return {}
if (not target.has("civilization_remainders")): target.civilization_remainders = {} if (not target.has("civilization_remainders")): target.civilization_remainders = {}
return QuantityMath.split(target.civilization_remainders, key, count, weights) return QuantityMath.split(target.civilization_remainders, key, count, weights)
@@ -34,11 +34,12 @@ static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictio
var weights: Dictionary = {} var weights: Dictionary = {}
for ethic in pop: for ethic in pop:
var influence: Variant = (E.of(rows[ethic].diplomacy_rate) if diplomacy else 1) var influence: Variant = (E.of(rows[ethic].diplomacy_rate) if diplomacy else 1)
var contribution: Variant = E.multiply(pop[ethic], influence)
for other in ETHICS: for other in ETHICS:
var weight: Variant = (9 if colony else 1) var weight: Variant = (9 if colony else 1)
if E.eq(other, ethic): weight = (960 if colony else 196) if E.eq(other, ethic): weight = (960 if colony else 196)
elif E.eq(other, OPPOSITE[ethic]): weight = (4 if colony else 0) elif E.eq(other, OPPOSITE[ethic]): weight = (4 if colony else 0)
weights[other] = E.add(weights.get(other, 0), E.multiply(E.multiply(pop[ethic], influence), weight)) weights[other] = E.add(weights.get(other, 0), E.multiply(contribution, weight))
if E.le(total(weights), 0): return pop.duplicate() if E.le(total(weights), 0): return pop.duplicate()
return split(target, key, total(pop), weights) return split(target, key, total(pop), weights)
@@ -48,6 +49,7 @@ static func emit(plan: Dictionary, destination: String, pop: Dictionary) -> void
static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: Dictionary) -> void: static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: Dictionary) -> void:
for ethic in pop: for ethic in pop:
if E.le(pop[ethic], 0): continue
var weights: Dictionary = {} var weights: Dictionary = {}
for term in str(rules[plan.id].ethics[ethic].fall).split("^", false): for term in str(rules[plan.id].ethics[ethic].fall).split("^", false):
var pair: PackedStringArray = term.split(":") var pair: PackedStringArray = term.split(":")
@@ -76,9 +78,11 @@ static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop
plan.to = E.maximum(plan.to, year) plan.to = E.maximum(plan.to, year)
return removed return removed
static func prepare(world, target: Dictionary, expired: Dictionary, year: Variant, budget: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> Dictionary: static func prepare(world, target: Dictionary, expired: Dictionary, year: Variant, budget: Dictionary, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null) -> Dictionary:
var profile_started: int = world.profile_start()
State.reconcile(world, target, rules, physical) State.reconcile(world, target, rules, physical)
var remaining: Dictionary = target.ethics.duplicate(true) var remaining: Dictionary = target.ethics.duplicate()
var copied: Dictionary = {}
var plans: Dictionary = {} var plans: Dictionary = {}
var colonies: Array = [] var colonies: Array = []
var collateral: Dictionary = {} var collateral: Dictionary = {}
@@ -88,6 +92,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var ids: Array = expired.keys() var ids: Array = expired.keys()
ids.sort() ids.sort()
for id in ids: for id in ids:
if budget_timer != null and not await budget_timer.pause(): return {}
if ((not rules.has(id)) or expired[id].is_empty()): continue if ((not rules.has(id)) or expired[id].is_empty()): continue
var plan: Dictionary = new_plan(id, year) var plan: Dictionary = new_plan(id, year)
plans[id] = plan plans[id] = plan
@@ -95,6 +100,8 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
plan.amount = E.add(plan.amount, part.amount) plan.amount = E.add(plan.amount, part.amount)
plan.from = E.minimum(plan.from, part.from) plan.from = E.minimum(plan.from, part.from)
plan.to = E.maximum(plan.to, part.to) plan.to = E.maximum(plan.to, part.to)
remaining[id] = remaining[id].duplicate()
copied[id] = true
var triggered: Dictionary = take(world, remaining[id], plan.amount) var triggered: Dictionary = take(world, remaining[id], plan.amount)
var successes: Dictionary = {} var successes: Dictionary = {}
var failures: Dictionary = {} var failures: Dictionary = {}
@@ -113,9 +120,12 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
successes[ethic] = bounded.pro successes[ethic] = bounded.pro
failures[ethic] = bounded.deg failures[ethic] = bounded.deg
triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg)) triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg))
var profile_transitions: int = world.profile_start()
add(triggered, progress(world, target, rules, budget, plan, successes)) add(triggered, progress(world, target, rules, budget, plan, successes))
fall(target, plan, rules, failures) fall(target, plan, rules, failures)
world.profile_end("civilization_transitions", profile_transitions)
if rules[id].fights: if rules[id].fights:
var profile_wars: int = world.profile_start()
for ethic in warriors: warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic]) for ethic in warriors: warriors[ethic] = E.minimum(warriors[ethic], triggered[ethic])
var wars: Variant = E.minimum(total(warriors), E.floor_value(E.divide((E.add(total(triggered), total(remaining[id]))), 2))) var wars: Variant = E.minimum(total(warriors), E.floor_value(E.divide((E.add(total(triggered), total(remaining[id]))), 2)))
warriors = QuantityMath.allocate_whole(warriors, wars) warriors = QuantityMath.allocate_whole(warriors, wars)
@@ -136,11 +146,15 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide((E.add(E.multiply(outcomes.get("pro50", 0), 10), E.multiply(outcomes.get("pro75", 0), 30))), order), 1) var damage: Variant = rate(target, E.add(id, ":collateral"), E.divide((E.add(E.multiply(outcomes.get("pro50", 0), 10), E.multiply(outcomes.get("pro75", 0), 30))), order), 1)
collateral[rules[id].previous] = E.add(collateral.get(rules[id].previous, 0), damage) collateral[rules[id].previous] = E.add(collateral.get(rules[id].previous, 0), damage)
target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), wars) target.civilization_stats.wars = E.add(target.civilization_stats.get("wars", 0), wars)
world.profile_end("wars", profile_wars)
emit(plan, id, triggered) emit(plan, id, triggered)
if E.gt(total(settlers), 0): colonies.append({"stage": rules[id].colony, "source": id, "population": settlers}) if E.gt(total(settlers), 0): colonies.append({"stage": rules[id].colony, "source": id, "population": settlers})
# Collateral is applied after all stages planned their own attempts. # Collateral is applied after all stages planned their own attempts.
for id in collateral: for id in collateral:
if (not rules.has(id)): continue if (not rules.has(id)): continue
if not copied.has(id):
remaining[id] = remaining[id].duplicate()
copied[id] = true
if (not plans.has(id)): plans[id] = new_plan(id, year) if (not plans.has(id)): plans[id] = new_plan(id, year)
var plan: Dictionary = plans[id] var plan: Dictionary = plans[id]
var retry: Dictionary = plan.outputs.get(id, {}) var retry: Dictionary = plan.outputs.get(id, {})
@@ -153,22 +167,27 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
var incoming: Dictionary = {} var incoming: Dictionary = {}
var planetary: Dictionary = {} var planetary: Dictionary = {}
for id in plans: for id in plans:
if budget_timer != null and not await budget_timer.pause(): return {}
var plan: Dictionary = plans[id] var plan: Dictionary = plans[id]
var products: Dictionary = {} var products: Dictionary = {}
for destination in plan.outputs: for destination in plan.outputs:
var pop: Dictionary = plan.outputs[destination] var pop: Dictionary = plan.outputs[destination]
if rules.has(destination): if rules.has(destination):
if E.ne(destination, "Ct"): if E.ne(destination, "Ct"):
var profile_drift: int = world.profile_start()
pop = drift(target, E.add(E.add(E.add(id, ":"), destination), ":drift"), pop, rules[id].ethics, (E.eq(destination, id) and rules[id].diplomacy)) pop = drift(target, E.add(E.add(E.add(id, ":"), destination), ":drift"), pop, rules[id].ethics, (E.eq(destination, id) and rules[id].diplomacy))
world.profile_end("ethics_drift", profile_drift)
if (not incoming.has(destination)): incoming[destination] = {} if (not incoming.has(destination)): incoming[destination] = {}
add(incoming[destination], pop) add(incoming[destination], pop)
products[destination] = total(pop) products[destination] = total(pop)
if (State.is_space_civilization(id, rules) and E.eq(world.catalog.entities[destination].group_as, "Planet")): if (State.is_space_civilization(id, rules) and E.eq(world.catalog.entities[destination].group_as, "Planet")):
planetary[destination] = E.add(planetary.get(destination, 0), total(pop)) planetary[destination] = E.add(planetary.get(destination, 0), total(pop))
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": E.maximum(plan.from, plan.to), "products": products}] expired[id] = [{"amount": plan.amount, "from": plan.from, "to": E.maximum(plan.from, plan.to), "products": products}]
world.profile_end("civilization_prepare", profile_started)
return {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies} return {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary) -> void: static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary, budget_timer: RefCounted = null) -> void:
var profile_started: int = world.profile_start()
target.ethics = transaction.remaining target.ethics = transaction.remaining
for id in transaction.incoming: for id in transaction.incoming:
if (not target.ethics.has(id)): target.ethics[id] = {} if (not target.ethics.has(id)): target.ethics[id] = {}
@@ -184,22 +203,30 @@ static func finish(world, target: Dictionary, transaction: Dictionary, year: Var
if (not target.binds.has(owner)): target.binds[owner] = {} if (not target.binds.has(owner)): target.binds[owner] = {}
target.binds[owner][id] = E.add(target.binds[owner].get(id, 0), shares[owner]) target.binds[owner][id] = E.add(target.binds[owner].get(id, 0), shares[owner])
target.amount[id] = E.subtract(target.amount[id], shares[owner]) target.amount[id] = E.subtract(target.amount[id], shares[owner])
colonize(world, target, transaction.colonies, year, rules) if budget_timer != null:
State.reconcile(world, target, rules) await colonize(world, target, transaction.colonies, year, rules, physical, budget_timer)
if budget_timer.cancelled: return
else:
colonize(world, target, transaction.colonies, year, rules, physical)
State.reconcile(world, target, rules, physical if transaction.colonies.is_empty() else {})
var worship: Variant = 0 var worship: Variant = 0
for id in rules: for id in rules:
if (not rules[id].worships): continue if (not rules[id].worships): continue
for ethic in target.ethics[id]: worship = E.add(worship, E.multiply(target.ethics[id][ethic], E.of(rules[id].ethics[ethic].worship))) for ethic in target.ethics[id]: worship = E.add(worship, E.multiply(target.ethics[id][ethic], E.of(rules[id].ethics[ethic].worship)))
target.civilization_stats.worship = worship target.civilization_stats.worship = worship
world.profile_end("civilization_finish", profile_started)
static func colonize(world, target: Dictionary, colonies: Array, year: Variant, rules: Dictionary) -> void: static func colonize(world, target: Dictionary, colonies: Array, year: Variant, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null) -> void:
var physical: Dictionary = world.ledger.physical_amounts(target) if colonies.is_empty(): return
var profile_colonies: int = world.profile_start()
if physical.is_empty(): physical = world.ledger.physical_amounts(target)
var stock: Dictionary = {} var stock: Dictionary = {}
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0) for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0)
var requests: Dictionary = {} var requests: Dictionary = {}
for i in colonies.size(): requests[str(i)] = total(colonies[i].population) for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock)) var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock))
for i in colonies.size(): for i in colonies.size():
if budget_timer != null and not await budget_timer.pause(): return
var colony: Dictionary = colonies[i] var colony: Dictionary = colonies[i]
var population: Dictionary = QuantityMath.allocate_whole(colony.population, granted[str(i)]) var population: Dictionary = QuantityMath.allocate_whole(colony.population, granted[str(i)])
population = drift(target, E.add(colony.source, ":colonies"), population, rules[colony.source].ethics, false, true) population = drift(target, E.add(colony.source, ":colonies"), population, rules[colony.source].ethics, false, true)
@@ -225,3 +252,4 @@ static func colonize(world, target: Dictionary, colonies: Array, year: Variant,
if (not target.ethics.has(colony.stage)): target.ethics[colony.stage] = {} if (not target.ethics.has(colony.stage)): target.ethics[colony.stage] = {}
add(target.ethics[colony.stage], population) add(target.ethics[colony.stage], population)
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count) target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
world.profile_end("colonization", profile_colonies)
+53 -62
View File
@@ -44,12 +44,11 @@ static func detach_space_civilizations(target: Dictionary, rules: Dictionary) ->
static func stellar_budget(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> Dictionary: static func stellar_budget(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> Dictionary:
if physical.is_empty(): if physical.is_empty():
physical = world.ledger.physical_amounts(target) physical = world.ledger.physical_amounts(target)
var layout: Dictionary = world.catalog.civilization_layout(rules)
var capacity: Dictionary = {} var capacity: Dictionary = {}
for id in physical: for id in layout.star_homes:
var entity = world.catalog.entities[id] for home in layout.star_homes[id]:
for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]: capacity[home] = E.add(capacity.get(home, 0), physical.get(id, 0))
if ((E.eq(entity.event_group, home) or E.eq(entity.count_as, home)) or E.eq(id, home)):
capacity[home] = E.add(capacity.get(home, 0), physical[id])
capacity["SMBH"] = E.maximum(0, target.amount.get("SMBH", 0)) capacity["SMBH"] = E.maximum(0, target.amount.get("SMBH", 0))
for held : Dictionary in target.binds.values(): for held : Dictionary in target.binds.values():
capacity["SMBH"] = E.add(capacity["SMBH"], E.maximum(0, held.get("SMBH", 0))) capacity["SMBH"] = E.add(capacity["SMBH"], E.maximum(0, held.get("SMBH", 0)))
@@ -106,22 +105,21 @@ static func admit_transition(budget: Dictionary, rules: Dictionary, source: Stri
# Resolve shortages from the most advanced stages downwards. This gives existing # Resolve shortages from the most advanced stages downwards. This gives existing
# advanced populations priority and lets a demotion cascade in the same cycle. # advanced populations priority and lets a demotion cascade in the same cycle.
static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: Variant) -> void: static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: Variant) -> void:
var profile_started: int = world.profile_start()
detach_space_civilizations(target, rules) detach_space_civilizations(target, rules)
var physical: Dictionary = world.ledger.physical_amounts(target) var physical: Dictionary = world.ledger.physical_amounts(target)
reconcile(world, target, rules, physical) reconcile(world, target, rules, physical)
var budget: Dictionary = stellar_budget(world, target, rules, physical) var budget: Dictionary = stellar_budget(world, target, rules, physical)
var remaining: Dictionary = budget.capacity.duplicate() var remaining: Dictionary = budget.capacity.duplicate()
var remaining_homes: Dictionary = budget.habitats.capacity.duplicate() var remaining_homes: Dictionary = budget.habitats.capacity.duplicate()
var stages: Array = [] var stages: Array = world.catalog.civilization_layout(rules).stages
for id in rules: var changed: bool = false
if is_space_civilization(id, rules):
stages.append(id)
stages.sort()
for index in range(E.subtract(stages.size(), 1), E.neg(1), E.neg(1)): for index in range(E.subtract(stages.size(), 1), E.neg(1), E.neg(1)):
var id: String = stages[index] var id: String = stages[index]
var count: Variant = target.amount.get(id, 0)
if E.le(count, 0): continue
var home: String = rules[id].home var home: String = rules[id].home
var cost: Variant = E.maximum(1, rules[id].stars) var cost: Variant = E.maximum(1, rules[id].stars)
var count: Variant = target.amount.get(id, 0)
var supported: Variant = E.minimum(count, E.floor_value(E.divide(remaining.get(home, 0), cost))) var supported: Variant = E.minimum(count, E.floor_value(E.divide(remaining.get(home, 0), cost)))
var requirement: Dictionary = budget.habitats.requirements.get(id, {}) var requirement: Dictionary = budget.habitats.requirements.get(id, {})
if (not requirement.is_empty()): if (not requirement.is_empty()):
@@ -131,6 +129,7 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
var excess: Variant = E.subtract(count, supported) var excess: Variant = E.subtract(count, supported)
if E.le(excess, 0): if E.le(excess, 0):
continue continue
changed = true
var previous: String = (stages[E.subtract(index, 1)] if E.gt(index, 0) else "C1.9") var previous: String = (stages[E.subtract(index, 1)] if E.gt(index, 0) else "C1.9")
world.batches.scale_batches(target, id, E.divide(supported, count)) world.batches.scale_batches(target, id, E.divide(supported, count))
target.amount[id] = supported target.amount[id] = supported
@@ -161,79 +160,71 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
target.binds[owner] = {} target.binds[owner] = {}
target.binds[owner][previous] = E.add(target.binds[owner].get(previous, 0), amount) target.binds[owner][previous] = E.add(target.binds[owner].get(previous, 0), amount)
target.amount[previous] = E.subtract(target.amount[previous], amount) target.amount[previous] = E.subtract(target.amount[previous], amount)
physical = world.ledger.physical_amounts(target) if changed:
reconcile(world, target, rules, physical) physical = world.ledger.physical_amounts(target)
reconcile(world, target, rules, physical)
assign_homes(world, target, rules, physical) assign_homes(world, target, rules, physical)
world.profile_end("enforce_capacity", profile_started)
# Unity's structure chain: direct Claim resets the required structure count; # Unity's structure chain: direct Claim resets the required structure count;
# intervening stages require ten times more units of the preceding structure. # intervening stages require ten times more units of the preceding structure.
static func habitat_budget(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> Dictionary: static func habitat_budget(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> Dictionary:
if physical.is_empty(): if physical.is_empty():
physical = world.ledger.physical_amounts(target) physical = world.ledger.physical_amounts(target)
var structures: Array = [] var layout: Dictionary = world.catalog.civilization_layout(rules)
var structures: Array = layout.structures
var capacity: Dictionary = {} var capacity: Dictionary = {}
for id in world.catalog.entities: for id in structures: capacity[id] = physical.get(id, 0)
if E.ne(world.catalog.entities[id].claim, ""): var requirements: Dictionary = layout.requirements
structures.append(id) var anchors: Dictionary = layout.anchors
capacity[id] = physical.get(id, 0)
structures.sort_custom(func ( a , b ) : return (E.gt(world.catalog.entities[a].civ_bonus, world.catalog.entities[b].civ_bonus) if E.ne(world.catalog.entities[a].civ_bonus, world.catalog.entities[b].civ_bonus) else E.lt(str(a), str(b))))
var stages: Array = []
for id in rules:
if is_space_civilization(id, rules): stages.append(id)
stages.sort()
var requirements: Dictionary = {}
var anchors: Dictionary = {}
var anchor: String = ""
var units: Variant = 1
for id in stages:
var direct: String = ""
for structure in structures:
if E.eq(world.catalog.entities[structure].claim, id): direct = structure
if E.ne(direct, ""):
anchor = direct
units = 1
else:
units = E.multiply(units, 10)
anchors[id] = anchor
if (id.begins_with("C3.") or id.begins_with("C4.")):
requirements[id] = {"id": anchor, "units": units}
var requested: Dictionary = {} var requested: Dictionary = {}
for id in requirements: for id in requirements:
var req: Dictionary = requirements[id] var req: Dictionary = requirements[id]
requested[req.id] = E.add(requested.get(req.id, 0), E.multiply(physical.get(id, 0), req.units)) requested[req.id] = E.add(requested.get(req.id, 0), E.multiply(physical.get(id, 0), req.units))
return {"capacity": capacity, "requested": requested, "requirements": requirements, "structures": structures, "anchors": anchors} return {"capacity": capacity, "requested": requested, "requirements": requirements, "structures": structures, "anchors": anchors}
# A structure's direct reservation belongs only to its Claim stage. Other
# stages do not debit it, so each stage's placement can be calculated alone.
static func stage_homes(world, target: Dictionary, id: String, layout: Dictionary, capacity: Dictionary = {}) -> Dictionary:
var left: Variant = target.amount.get(id, 0)
var homes: Dictionary = {}
if E.le(left, 0): return homes
var allowed: bool = layout.anchors[id] == ""
for structure in layout.structures:
if structure == layout.anchors[id]: allowed = true
if not allowed: continue
var stock: Variant = capacity.get(structure, 0) if not capacity.is_empty() else world.ledger.physical_amount(target, structure)
if E.le(stock, 0): continue
var amount: Variant = left
if world.catalog.entities[structure].claim == id:
amount = E.minimum(amount, stock)
if E.gt(amount, 0): homes[structure] = amount
left = E.subtract(left, amount)
if E.le(left, 0): break
return homes
static func assign_homes(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> void: static func assign_homes(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> void:
var budget: Dictionary = habitat_budget(world, target, rules, physical) var profile_started: int = world.profile_start()
var used: Dictionary = {} var layout: Dictionary = world.catalog.civilization_layout(rules)
var capacity: Dictionary = {}
for structure in layout.structures:
capacity[structure] = world.ledger.physical_amount(target, structure) if physical.is_empty() else physical.get(structure, 0)
target.civilization_homes = {} target.civilization_homes = {}
var stages: Array = budget.anchors.keys() for index in range(layout.stages.size() - 1, -1, -1):
stages.sort() var id: String = layout.stages[index]
stages.reverse() target.civilization_homes[id] = stage_homes(world, target, id, layout, capacity)
for id in stages: world.profile_end("assign_homes", profile_started)
var left: Variant = target.amount.get(id, 0)
var homes: Dictionary = {}
var allowed: bool = E.eq(budget.anchors[id], "")
for structure in budget.structures:
if E.eq(structure, budget.anchors[id]): allowed = true
if ((not allowed) or E.le(budget.capacity[structure], 0)): continue
var amount: Variant = left
if E.eq(world.catalog.entities[structure].claim, id):
amount = E.minimum(amount, E.maximum(0, E.subtract(budget.capacity[structure], used.get(structure, 0))))
used[structure] = E.add(used.get(structure, 0), amount)
if E.gt(amount, 0): homes[structure] = amount
left = E.subtract(left, amount)
if E.le(left, 0): break
target.civilization_homes[id] = homes
static func effective_span(world, target: Dictionary, id: String, rules: Dictionary) -> Variant: static func effective_span(world, target: Dictionary, id: String, rules: Dictionary) -> Variant:
if (not rules.has(id)): return world.catalog.entities[id].lifespan if (not rules.has(id)): return world.catalog.entities[id].lifespan
var base: Variant = rules[id].span var base: Variant = rules[id].span
if (not is_space_civilization(id, rules)): return base if (not is_space_civilization(id, rules)): return base
assign_homes(world, target, rules)
var population: Variant = target.amount.get(id, 0) var population: Variant = target.amount.get(id, 0)
if E.le(population, 0): return base if E.le(population, 0): return base
var homes: Dictionary = stage_homes(world, target, id, world.catalog.civilization_layout(rules))
target.civilization_homes[id] = homes
var weighted: Variant = population var weighted: Variant = population
for home in target.civilization_homes.get(id, {}): for home in homes:
weighted = E.add(weighted, E.multiply(target.civilization_homes[id][home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1)))) weighted = E.add(weighted, E.multiply(homes[home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1))))
return (E.mul_div(base, population, weighted) if E.gt(weighted, 0) else base) return (E.mul_div(base, population, weighted) if E.gt(weighted, 0) else base)
+50
View File
@@ -1,4 +1,5 @@
extends RefCounted extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
## Definitions and their indexes only: no population, batches or UI state. ## Definitions and their indexes only: no population, batches or UI state.
## Definitions are Nodes owned by the caller, as before this extraction. ## Definitions are Nodes owned by the caller, as before this extraction.
@@ -8,7 +9,10 @@ var event_groups: Dictionary[String, Array] = {}
var automatic_producers: Array = [] var automatic_producers: Array = []
var unlockable_entities: Array = [] var unlockable_entities: Array = []
var _group_members: Dictionary = {} var _group_members: Dictionary = {}
var index_revision: int = 0
var _indexed_entity_count: int = -1 var _indexed_entity_count: int = -1
var _civilization_layout: Dictionary = {}
var _civilization_rules: Dictionary = {}
## Populate an empty catalog; replacement uses a new catalog to preserve ownership. ## Populate an empty catalog; replacement uses a new catalog to preserve ownership.
func load_definitions(rows: Dictionary) -> void: func load_definitions(rows: Dictionary) -> void:
@@ -28,6 +32,8 @@ func load_definitions(rows: Dictionary) -> void:
## Call explicitly after modifying group_as without changing the entity count. ## Call explicitly after modifying group_as without changing the entity count.
func rebuild_group_index() -> void: func rebuild_group_index() -> void:
index_revision += 1
_civilization_layout.clear()
_group_members.clear() _group_members.clear()
for id in entities: for id in entities:
var group: String = entities[id].group_as var group: String = entities[id].group_as
@@ -45,3 +51,47 @@ func is_group(id: String) -> bool:
func members(id: String) -> Array: func members(id: String) -> Array:
if not is_group(id): return [id] if not is_group(id): return [id]
return _group_members.get(id, []).duplicate() return _group_members.get(id, []).duplicate()
## Definition-only cache. Rebuild indexes after changing claims or bonuses;
## invalidate this layout after changing stage homes in an existing rules table.
func invalidate_civilization_layout() -> void:
_civilization_layout.clear()
func civilization_layout(rules: Dictionary) -> Dictionary:
_ensure_group_index()
if not _civilization_layout.is_empty() and is_same(_civilization_rules, rules):
return _civilization_layout
_civilization_rules = rules
var structures: Array = []
for id in entities:
if entities[id].claim != "": structures.append(id)
structures.sort_custom(func(a, b): return E.gt(entities[a].civ_bonus, entities[b].civ_bonus) if E.ne(entities[a].civ_bonus, entities[b].civ_bonus) else str(a) < str(b))
var stages: Array = []
for id in rules:
if str(rules[id].home) != "": stages.append(id)
stages.sort()
var anchors: Dictionary = {}
var requirements: Dictionary = {}
var anchor: String = ""
var units: Variant = 1
for id in stages:
var direct: String = ""
for structure in structures:
if entities[structure].claim == id: direct = structure
if direct != "":
anchor = direct
units = 1
else:
units = E.multiply(units, 10)
anchors[id] = anchor
if id.begins_with("C3.") or id.begins_with("C4."):
requirements[id] = {"id": anchor, "units": units}
var star_homes: Dictionary = {}
for id in entities:
var homes: Array = []
for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]:
if entities[id].event_group == home or entities[id].count_as == home or id == home:
homes.append(home)
if not homes.is_empty(): star_homes[id] = homes
_civilization_layout = {"structures": structures, "stages": stages, "anchors": anchors, "requirements": requirements, "star_homes": star_homes}
return _civilization_layout
+49 -12
View File
@@ -19,12 +19,20 @@ func initialize_life_batches() -> void:
world.batches.append_batch(world.state, id, E.maximum(0, E.subtract(physical.get(id, 0), tracked)), world.state.time.datetime, world.state.time.datetime) world.batches.append_batch(world.state, id, E.maximum(0, E.subtract(physical.get(id, 0), tracked)), world.state.time.datetime, world.state.time.datetime)
func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}) -> void: func run_frame(clock: Dictionary, budget: RefCounted) -> void:
var _keep_alive: RefCounted = self
await evolute_to_datetime(clock.datetime, clock, budget)
if not budget.cancelled and world != null: world.finish_frame()
func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}, budget: RefCounted = null) -> void:
var _keep_alive: RefCounted = self
# Reconcile only when simulation advances. # Reconcile only when simulation advances.
# Fractional frame updates cannot change capacity or populations. # Fractional frame updates cannot change capacity or populations.
var processed_year: Variant = SimulationTime.processed_year(datetime) var processed_year: Variant = SimulationTime.processed_year(datetime)
if E.gt(processed_year, world.state.time.year): if E.gt(processed_year, world.state.time.year):
var profile_initialize: int = world.profile_start()
initialize_life_batches() initialize_life_batches()
world.profile_end("initialize", profile_initialize)
var previous: Dictionary = world.state var previous: Dictionary = world.state
var next: Dictionary = previous.duplicate_deep() var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION next.version = SimulationTime.STATE_VERSION
@@ -37,11 +45,21 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}) -> void:
var distribution: Dictionary = QuantityMath.normalize_distribution(quantities) var distribution: Dictionary = QuantityMath.normalize_distribution(quantities)
next.distribution = distribution next.distribution = distribution
var profile_production: int = world.profile_start()
world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year) world.automatic.produce_for_period(previous, next, quantities, distribution, from_year, to_year)
evolve_batches(next, to_year) world.profile_end("production", profile_production)
var profile_generations: int = world.profile_start()
if budget != null:
await evolve_batches(next, to_year, budget)
if budget.cancelled: return
else:
evolve_batches(next, to_year)
world.profile_end("generations", profile_generations)
world.ledger.refresh_groups(next) world.ledger.refresh_groups(next)
world.ledger.clamp_auto_resources(next) world.ledger.clamp_auto_resources(next)
var profile_trends: int = world.profile_start()
WorldParameters.advance(previous, next, Global.CivilizationRules, E.subtract(to_year, from_year)) WorldParameters.advance(previous, next, Global.CivilizationRules, E.subtract(to_year, from_year))
world.profile_end("trends", profile_trends)
next.time = clock if not clock.is_empty() else {"datetime": datetime, "year": to_year} next.time = clock if not clock.is_empty() else {"datetime": datetime, "year": to_year}
# Publish only after every phase has finished; previous stays untouched. # Publish only after every phase has finished; previous stays untouched.
@@ -50,16 +68,19 @@ func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}) -> void:
world.presentation.refresh(world.state, world.catalog) world.presentation.refresh(world.state, world.catalog)
func evolve_batches(target: Dictionary, to_year: Variant) -> void: func evolve_batches(target: Dictionary, to_year: Variant, budget: RefCounted = null) -> void:
var _keep_alive: RefCounted = self
# A long cycle may contain several generations; preserve actual birth intervals. # A long cycle may contain several generations; preserve actual birth intervals.
var completed: Dictionary = {} var completed: Dictionary = {}
while evolve_batch_generation(target, to_year, completed): while await evolve_batch_generation(target, to_year, completed, budget):
pass if budget != null and not await budget.pause(): return
func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }) -> bool: func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }, budget: RefCounted = null) -> bool:
var _keep_alive: RefCounted = self
# Apply direct completions at the original next-generation boundary, so # Apply direct completions at the original next-generation boundary, so
# binding transfers, the blast environment and capacity checks keep order. # binding transfers, the blast environment and capacity checks keep order.
if budget != null and not await budget.pause(): return false
var completed_now: Dictionary = completed.duplicate(true) var completed_now: Dictionary = completed.duplicate(true)
completed.clear() completed.clear()
var quantities: Dictionary = world.ledger.physical_amounts(target) var quantities: Dictionary = world.ledger.physical_amounts(target)
@@ -92,10 +113,12 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
return false return false
var fractions: Dictionary = {} var fractions: Dictionary = {}
var outputs: Dictionary = {} var outputs: Dictionary = {}
var current_quantities: Dictionary = world.ledger.physical_amounts(target) var current_quantities: Dictionary = quantities
var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules, current_quantities) var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules, current_quantities)
var civilization_transaction: Dictionary = CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities) var civilization_transaction: Dictionary = await CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules, current_quantities, budget)
if budget != null and budget.cancelled: return false
for id in expired: for id in expired:
if budget != null and not await budget.pause(): return false
var total: Variant = 0 var total: Variant = 0
for part : Dictionary in expired[id]: for part : Dictionary in expired[id]:
total = E.add(total, part.amount) total = E.add(total, part.amount)
@@ -127,6 +150,7 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
for id in expired: for id in expired:
for part : Dictionary in expired[id]: for part : Dictionary in expired[id]:
for successor in part.products: for successor in part.products:
if budget != null and not await budget.pause(): return false
var amount: Variant = part.products[successor] var amount: Variant = part.products[successor]
if E.eq(successor, "blast"): if E.eq(successor, "blast"):
blast_power = E.add(blast_power, amount) blast_power = E.add(blast_power, amount)
@@ -137,15 +161,28 @@ func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Di
target.amount[id] = E.add(target.amount.get(id, 0), amount) target.amount[id] = E.add(target.amount.get(id, 0), amount)
else: else:
amount = world.ledger.credit(target, successor, amount) amount = world.ledger.credit(target, successor, amount)
var before_batches: Array = target.batches.get(successor, []).duplicate(true) var produces: bool = not world.automatic.yearly_products(world.catalog.entities[successor]).is_empty()
var before_batches: Array = target.batches.get(successor, []).duplicate(true) if produces else []
var cutoff: Variant = (to_year if world.stellar.uses_short_lived_fast_path(successor) else E.neg(INF)) var cutoff: Variant = (to_year if world.stellar.uses_short_lived_fast_path(successor) else E.neg(INF))
var integral: Variant = world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff) var profile_newborn: int = world.profile_start()
world.automatic.produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral) var integral: Variant
if budget != null:
integral = await world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff, budget)
if budget.cancelled: return false
else:
integral = world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff)
if produces:
world.automatic.produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral)
world.profile_end("newborn_batches", profile_newborn)
new_owners[successor] = E.add(new_owners.get(successor, 0), amount) new_owners[successor] = E.add(new_owners.get(successor, 0), amount)
world.ledger.accept_transfers(target, incoming) world.ledger.accept_transfers(target, incoming)
world.ledger.bind_new_owners(target, new_owners) world.ledger.bind_new_owners(target, new_owners)
world.ledger.round_object_allocations(target) world.ledger.round_object_allocations(target)
CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules) if budget != null:
await CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules, budget)
if budget.cancelled: return false
else:
CivilizationEvolution.finish(world, target, civilization_transaction, to_year, Global.CivilizationRules)
world.stellar.apply_blast(target, blast_power, environment) world.stellar.apply_blast(target, blast_power, environment)
CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year) CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year)
return true return true
+22
View File
@@ -0,0 +1,22 @@
extends RefCounted
## Cooperative work on the main thread. No partially calculated state is published.
var tree: SceneTree
var slice_usec: int = 40000
var deadline: int
var cancelled: bool = false
var waiting_usec: int = 0
func _init(scene_tree: SceneTree, milliseconds: int = 40):
tree = scene_tree
slice_usec = milliseconds * 1000
deadline = Time.get_ticks_usec() + slice_usec
func pause() -> bool:
var keep_alive: RefCounted = self
if cancelled: return false
if Time.get_ticks_usec() >= deadline:
var waiting_from: int = Time.get_ticks_usec()
await tree.process_frame
keep_alive.waiting_usec += Time.get_ticks_usec() - waiting_from
deadline = Time.get_ticks_usec() + slice_usec
return not keep_alive.cancelled
+1
View File
@@ -0,0 +1 @@
uid://hu57pwad02sg
+1 -1
View File
@@ -8,7 +8,7 @@ const Parameters = preload("res://world/owniverse/world_parameters.gd")
static func number(value, digits: int = 0) -> String: static func number(value, digits: int = 0) -> String:
if not E.finite(value): return "—" if not E.finite(value): return "—"
return E.scientific(value, 4) if E.ge(E.absolute(value), 100000) else E.fixed(value, digits) return E.grouped(value, digits, false)
static func colored(text: String, color: String) -> String: static func colored(text: String, color: String) -> String:
return E.add(E.add(E.add(E.add("[color=", color), "]"), text), "[/color]") return E.add(E.add(E.add(E.add("[color=", color), "]"), text), "[/color]")
+35 -93
View File
@@ -1,101 +1,43 @@
# Числа симуляции # Числа симуляции
`exact.gd` сохраняет единый API `E` для симуляции и интерфейса. Целые числа, Целые значения в диапазоне int64 хранятся как `int`. Остальные числа имеют вид
помещающиеся в `int64`, остаются обычными `int`. Остальные значения хранятся `polarity × (sum(whole[i] × 10^(9i)) + fraction)`:
в `exact_value.gd`: `whole` — динамический массив целых блоков по основанию 10^9,
`fraction` — float от 0 включительно до 1 исключительно. Числа после создания
не изменяются. Ограничения E27 в хранении нет; размер массива растёт по необходимости.
``` Используйте API `E.add/subtract/multiply/divide`, `E.eq/lt/le/gt/ge` и
polarity × (high × 10^14 + low + fraction) `E.floor_value/ceil_value/round_value`. Обычные операторы над ExactValue не работают.
``` `E.integer` требует значения в диапазоне int64; `E.approx` явно преобразует
число в float и для очень больших чисел возвращает бесконечность.
Сравнение через E сохраняет отличие конечного большого числа от INF.
`high` и `low` — целые блоки от 0 до 10^14−1, `fraction` — `float` от 0 до 1, Целые операции выполняются точно. Умножение и деление используют безопасные
`polarity` — знак. Значения неизменяемы после создания. Диапазон включает весь разряды из `big_integer.gd`; нативная арифметика применяется при отсутствии
порядок E27: модуль целой части меньше 10^28. Каждая единица целой части переполнения int64. `E.mul_div(count, numerator, denominator)` сохраняет точное
сохраняется, в том числе при добавлении и вычитании единицы около 10^27. целое частное, не превращая заранее отношение в приближённый коэффициент.
Выход за диапазон проверяется утверждениями; насыщения или обрезания запасов нет. `E.floor_sum` суммирует округлённые линейные квоты без перебора каждого окна.
Числители и знаменатели в состоянии больше не хранятся. НОД при обычных Дробный остаток приблизительный: при умножении и делении он преобразуется
арифметических операциях не вычисляется; обход состояния для ограничения дробей в десятичную дробь с 16 значащими цифрами. При сложении двух ненулевых остатков
больше не нужен. `bounded` и `bound_state` оставлены как совместимые пустые операции. перенос около целой границы стабилизируется с допуском 10^-12.
Умножение большого количества на float усиливает ошибку коэффициента;
для известных точных отношений используйте mul_div. Целые распределения
и сохранение числа объектов проверяются строго, без допуска.
## Операции Возраст сохраняет точную целую часть. Только дробная часть переводится в
наносекундные тики, дополнительный остаток времени переносится между кадрами.
Синхронный диагностический API evolute отклоняет шаги от 1e27 игровых лет:
это защита от непрактичного интервала, а не предел хранимого возраста.
Покадровый API ограничивает одну транзакцию обычным шагом 1/60 секунды;
остальное запрошенное время остаётся в очереди.
Сложение, вычитание и сравнение работают непосредственно с блоками. Формат сохранения — **5**, версия состояния — **0.3.0**.
Деление int64 и объединённое умножение/деление при безопасном произведении Большие целые и числа с дробью кодируются как
используют нативную арифметику. Перевод больших целых между блоками и временными `{"$exact": [[blocks], "float bytes in hex", sign]}`.
разрядами, извлечение коэффициента и сборка результата обходятся без строк. Байты остатка сохраняются без потери точности JSON. Миграции старых форматов нет.
Дробный коэффициент нормализуется численно до 16 значащих цифр; последние биты
float могут отличаться от результата прежнего строкового преобразования.
Для умножения и деления `big_integer.gd` используется как временный вычислитель
с безопасными разрядами по основанию 10^9. Результат сразу возвращается в
фиксированную структуру. Размер временных дробей определяется входными float,
а не растёт от шага к шагу.
`E.mul_div(a, b, denominator)` вычисляет отношение за одну операцию. Это нужно Проверки: `test_exact_math.gd`, `test_dynamic_math.gd`, `test_quantity_catalog.gd`,
для распределений: произведение двух количеств может превышать E27, хотя итог `test_time.gd` и `test_frame_evolution.gd`. Независимый целочисленный эталон
деления укладывается в диапазон. Промежуточное произведение не становится покрывает числа до 220 десятичных цифр; отдельные проверки — до E1000,
значением симуляции. Целое частное вычисляется до перевода остатка в float. границы int64, доли, сериализацию, распределения и продолжение расчёта.
```gdscript
const E = preload("res://world/owniverse/math/exact.gd")
var stock = E.of("1e27") # Большое целое вводится строкой.
stock = E.subtract(stock, 1)
var share = E.mul_div(stock, 15, 100) # Точные 15% целого запаса.
var whole_share = E.floor_value(share)
```
Использовать `E.add/subtract/multiply/divide`, `E.eq/lt/le/gt/ge`,
`E.floor_value/ceil_value/round_value`. Обычные операторы над объектами
`ExactValue` не подходят. `E.integer` допускается только для значений int64;
`E.approx` — явный переход к приближённому float.
## Дробная точность
Дробная часть приближённая. Для временного умножения/деления она преобразуется
в десятичную дробь с 16 значащими цифрами; нормализация сохраняет значащие цифры
малых коэффициентов. Десятичные стоимости вроде 0.125 и 0.0000000000001
сохраняют корректный размер производственной партии. НОД применяется только
при определении знаменателя стоимости в `production_step`.
При сложении двух ненулевых дробных частей перенос около целой границы
стабилизируется с допуском 10^-12. Это предотвращает задержку редких событий
из-за накопленной ошибки float. Допуск не применяется к целым блокам или
округлению частного при делении. Суммы распределённых целых объектов проверяются
строго, без допуска.
При умножении большого количества на приближённый коэффициент ошибка самого
коэффициента усиливается: float-доля не обещает точности результата до единицы.
Известные точные отношения нужно передавать через `mul_div(count, numerator,
denominator)`, не вычисляя коэффициент заранее.
Возраст хранится как целые годы и дробный остаток; в наносекундные тики
переводится только дробная часть года. Полный возраст больше не умножается на
10^9. Остаток кадрового времени переносится отдельно. Геометрический объём
пустот может превышать E27 из-за множителя 10^22: он вычисляется в float и
используется только для приближённых коэффициентов плотности и бластов.
Форматирование не создаёт больших промежуточных значений симуляции: например,
округлённая подпись `1.00E+28` допустима для количества чуть меньше 10^28.
## Сохранение
Формат сохранения — **4**, версия состояния — **0.3.0**. Большие целые
кодируются как `{"$exact": "целое число"}`. Блоки с дробью кодируются как
`{"$fixed": ["high", "low", "float bytes in hex", sign]}`. Восемь байтов float
сохраняются без потери точности JSON, включая очень малые остатки.
Зашифрованный файл и диагностический JSON используют один снимок.
Миграции нет. Сохранение прежнего формата при обычной загрузке архивируется
существующим механизмом `.incompatible.bak`; начинается новая игра.
Тесты не загружают и не перезаписывают рабочее сохранение игрока.
## Проверки
`godot --headless --path Owniverse.GI --script res://tests/test_exact_math.gd`
Независимый эталон `tests/fixtures/exact_oracle.json` получен через Python
`fractions.Fraction` (160 пар, seed 4321). Произведения находятся внутри E27;
дробные результаты сравниваются с абсолютным допуском 10^-12, целые — точно.
Дополнительно проверяются границы int64/E27, единичные изменения, точные
проценты, объединённое умножение с делением, JSON, дробные рецепты,
распределения, жизнь, энергия, время, форматирование и 100 циклов симуляции.
+23
View File
@@ -102,3 +102,26 @@ static func gcd(a: Array, b: Array) -> Array:
a = b a = b
b = remainder b = remainder
return a return a
## Sum floor((a*i+b)/m), i=0..n-1, using Euclidean reduction.
## All arguments are unsigned; no loop over the number of time windows.
static func floor_sum(n: Array, m: Array, a: Array, b: Array) -> Array:
var result: Array = [0]
while n != [0]:
var qr: Array = divmod(a, m)
if qr[0] != [0]:
var pairs: Array = divmod(multiply(n, subtract(n, [1])), [2])[0]
result = add(result, multiply(pairs, qr[0]))
a = qr[1]
qr = divmod(b, m)
result = add(result, multiply(n, qr[0]))
b = qr[1]
var upper: Array = add(multiply(a, n), b)
if compare(upper, m) < 0: break
qr = divmod(upper, m)
n = qr[0]
b = qr[1]
var temporary: Array = m
m = a
a = temporary
return result
+144 -149
View File
@@ -1,27 +1,45 @@
extends RefCounted extends RefCounted
## Fixed whole blocks and a float remainder. Native integers are the fast path. ## Dynamic whole blocks and a float remainder. Native integers are the fast path.
const Big = preload("res://world/owniverse/math/big_integer.gd") const Big = preload("res://world/owniverse/math/big_integer.gd")
const Value = preload("res://world/owniverse/math/exact_value.gd") const Value = preload("res://world/owniverse/math/exact_value.gd")
const SMALL: int = 1000000000 const SMALL: int = 1000000000
const MAX_INT: int = 9223372036854775807 const MAX_INT: int = 9223372036854775807
const MIN_INT: int = -9223372036854775807 - 1 const MIN_INT: int = -9223372036854775807 - 1
const BLOCK: int = 100000000000000 # 10^14 const MAX_INT_LIMBS: Array = [854775807, 223372036, 9]
const LIMIT: Array = [0, 0, 0, 10] # 10^28, exclusive const MIN_INT_LIMBS: Array = [854775808, 223372036, 9]
static func max_value(): static func _int_limbs(value: int) -> Array:
return make([999999999, 999999999, 9999]) var result: Array = []
while value != 0:
result.append(absi(value % SMALL))
value /= SMALL
return result if not result.is_empty() else [0]
static func make(n: Array, d: Array = [1], polarity: int = 1): static func _compose(whole: Array, fraction: float = 0.0, polarity: int = 1):
assert(d != [0], "Zero denominator") whole = Big.trim(whole.duplicate())
if n.size() <= 2 and d.size() <= 2: if fraction >= 1.0:
var numerator: int = n[0] + (n[1] * SMALL if n.size() == 2 else 0) var carry: float = floorf(fraction)
var denominator: int = d[0] + (d[1] * SMALL if d.size() == 2 else 0) whole = Big.add(whole, Big.parse("%.0f" % carry))
var whole: int = numerator / denominator fraction -= carry
var fraction: float = minf(float(numerator % denominator) / float(denominator), 1.0 - pow(2.0, -53)) elif fraction < 0.0:
return _compose(whole / BLOCK, whole % BLOCK, fraction, polarity) assert(Big.compare(whole, [0]) > 0, "Negative magnitude")
var qr: Array = Big.divmod(n, d) whole = Big.subtract(whole, [1])
var fraction: float = _fraction_ratio(qr[1], d) fraction += 1.0
return _from_whole(qr[0], fraction, polarity) if fraction >= 1.0:
whole = Big.add(whole, [1])
fraction = 0.0
if whole == [0] and fraction == 0.0:
return 0
if fraction == 0.0 and Big.compare(whole, MAX_INT_LIMBS) <= 0:
var magnitude: int = int(whole[0]) + (int(whole[1]) * SMALL if whole.size() > 1 else 0) + (int(whole[2]) * 1000000000000000000 if whole.size() > 2 else 0)
return magnitude * (-1 if polarity < 0 else 1)
if fraction == 0.0 and polarity < 0 and Big.compare(whole, MIN_INT_LIMBS) == 0:
return MIN_INT
var result = Value.new()
result.whole = whole
result.fraction = minf(fraction, 1.0 - pow(2.0, -53))
result.polarity = -1 if polarity < 0 else 1
return result
static func _fraction_ratio(n: Array, d: Array) -> float: static func _fraction_ratio(n: Array, d: Array) -> float:
if n == [0]: return 0.0 if n == [0]: return 0.0
@@ -36,57 +54,15 @@ static func _fraction_ratio(n: Array, d: Array) -> float:
fraction *= pow(10.0, mini(0, exponent + 308)) fraction *= pow(10.0, mini(0, exponent + 308))
return minf(fraction, 1.0 - pow(2.0, -53)) return minf(fraction, 1.0 - pow(2.0, -53))
static func _from_whole(n: Array, fraction: float, polarity: int): static func make(n: Array, d: Array = [1], polarity: int = 1):
assert(Big.compare(n, LIMIT) < 0, "Number exceeds E27 range") assert(d != [0], "Zero denominator")
var middle: int = n[1] if n.size() > 1 else 0 var qr: Array = Big.divmod(n, d)
var low: int = n[0] + (middle % 100000) * SMALL return _compose(qr[0], _fraction_ratio(qr[1], d), polarity)
var high: int = middle / 100000
if n.size() > 2: high += n[2] * 10000
if n.size() > 3: high += n[3] * 10000000000000
return _compose(high, low, fraction, polarity)
static func _whole_limbs(high: int, low: int) -> Array:
return Big.trim([low % SMALL, low / SMALL + (high % 10000) * 100000,
(high / 10000) % SMALL, high / 10000000000000])
static func _int_limbs(value: int) -> Array:
var result: Array = []
# Modulo/division before abs also supports MIN_INT.
while value != 0:
result.append(absi(value % SMALL))
value /= SMALL
return result if not result.is_empty() else [0]
static func _compose(high: int, low: int, fraction: float, polarity: int):
if fraction >= 1.0:
low += int(floorf(fraction))
fraction -= floorf(fraction)
if fraction < 0.0:
low -= 1
fraction += 1.0
if low >= BLOCK:
high += low / BLOCK
low %= BLOCK
elif low < 0:
high -= 1
low += BLOCK
assert(high >= 0 and high < BLOCK, "Number exceeds E27 range")
if fraction == 0.0 and high <= 92233:
var prefix: int = high * BLOCK
if low <= MAX_INT - prefix: return polarity * (prefix + low)
if polarity < 0 and low == MAX_INT - prefix + 1: return MIN_INT
var result = Value.new()
result.high = high
result.low = low
result.fraction = fraction
result.polarity = polarity
return result
static func _blocks(value) -> Array: static func _blocks(value) -> Array:
value = of(value) value = of(value)
if value is Value: return [value.high, value.low, value.fraction, value.polarity] if value is Value: return [value.whole, value.fraction, value.polarity]
# Signed division also handles MIN_INT without overflowing abs(). return [_int_limbs(value), 0.0, -1 if value < 0 else 1]
return [absi(value / BLOCK), absi(value % BLOCK), 0.0, -1 if value < 0 else 1]
static func of(value): static func of(value):
if value is Value or value is int: return value if value is Value or value is int: return value
@@ -94,7 +70,7 @@ static func of(value):
if not is_finite(value): return value if not is_finite(value): return value
if value == floorf(value) and absf(value) < 1e18: return int(value) if value == floorf(value) and absf(value) < 1e18: return int(value)
var magnitude: float = absf(value) var magnitude: float = absf(value)
return _from_whole(Big.parse("%.0f" % floorf(magnitude)), magnitude - floorf(magnitude), -1 if value < 0 else 1) return _compose(Big.parse("%.0f" % floorf(magnitude)), magnitude - floorf(magnitude), -1 if value < 0 else 1)
var text: String = str(value).strip_edges().to_lower() var text: String = str(value).strip_edges().to_lower()
if text == "": return 0 if text == "": return 0
if "/" in text: if "/" in text:
@@ -114,25 +90,16 @@ static func of(value):
return make(Big.parse(text + "0".repeat(maxi(0, power))), Big.parse("1" + "0".repeat(maxi(0, -power))), polarity) return make(Big.parse(text + "0".repeat(maxi(0, power))), Big.parse("1" + "0".repeat(maxi(0, -power))), polarity)
static func parts(value) -> Array: static func parts(value) -> Array:
value = of(value) var x: Array = _blocks(value)
if value is int: return [_int_limbs(value), [1], -1 if value < 0 else 1] if x[1] == 0.0: return [x[0], [1], x[2]]
var whole: Array = _whole_limbs(value.high, value.low) var exponent: int = maxi(-323, int(floor(log(x[1]) / log(10.0))))
if value.fraction == 0.0: return [whole, [1], value.polarity] var mantissa: int = roundi((x[1] / pow(10.0, exponent)) * 1000000000000000.0)
# Bound temporary decimal work to 16 significant digits of the float.
# Normalize first so tiny rates retain their significant digits as well.
var exponent: int = maxi(-323, int(floor(log(value.fraction) / log(10.0))))
var mantissa: int = roundi((value.fraction / pow(10.0, exponent)) * 1000000000000000.0)
exponent -= 15 exponent -= 15
while mantissa % 10 == 0 and mantissa > 0: while mantissa % 10 == 0 and mantissa > 0:
mantissa /= 10 mantissa /= 10
exponent += 1 exponent += 1
var n: Array = _int_limbs(mantissa) var denominator: Array = Big.parse("1" + "0".repeat(-exponent))
var d: Array = [] return [Big.add(Big.multiply(x[0], denominator), _int_limbs(mantissa)), denominator, x[2]]
d.resize((-exponent) / 9 + 1)
d.fill(0)
const POWERS: Array = [1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000]
d[d.size() - 1] = POWERS[(-exponent) % 9]
return [Big.add(Big.multiply(whole, d), n), d, value.polarity]
static func numeric(value) -> bool: static func numeric(value) -> bool:
return value is int or value is float or value is Value return value is int or value is float or value is Value
@@ -142,20 +109,28 @@ static func finite(value) -> bool:
static func approx(value) -> float: static func approx(value) -> float:
if not value is Value: return float(value) if not value is Value: return float(value)
return value.polarity * (float(value.high) * BLOCK + float(value.low) + value.fraction) if value.whole == [0]: return value.polarity * value.fraction
var start: int = maxi(0, value.whole.size() - 3)
var leading: float = 0.0
for i in range(value.whole.size() - 1, start - 1, -1): leading = leading * SMALL + value.whole[i]
var exponent: int = start * 9
if exponent > 308: return INF * value.polarity
return value.polarity * (leading * pow(10.0, exponent) + value.fraction)
static func compare(a, b) -> int: static func compare(a, b) -> int:
if a is int and b is int: return 0 if a == b else (1 if a > b else -1) if a is int and b is int: return 0 if a == b else (1 if a > b else -1)
if not finite(a) or not finite(b): if not finite(a) or not finite(b):
if finite(a): return -1 if b > 0 else 1
if finite(b): return 1 if a > 0 else -1
var x: float = approx(a) var x: float = approx(a)
var y: float = approx(b) var y: float = approx(b)
return 0 if x == y else (1 if x > y else -1) return 0 if x == y else (1 if x > y else -1)
var x: Array = _blocks(a) var x: Array = _blocks(a)
var y: Array = _blocks(b) var y: Array = _blocks(b)
if x[3] != y[3]: return 1 if x[3] > y[3] else -1 if x[2] != y[2]: return 1 if x[2] > y[2] else -1
for i in 3: var magnitude: int = Big.compare(x[0], y[0])
if x[i] != y[i]: return x[3] * (1 if x[i] > y[i] else -1) if magnitude == 0 and x[1] != y[1]: magnitude = 1 if x[1] > y[1] else -1
return 0 return magnitude * x[2]
static func eq(a, b) -> bool: static func eq(a, b) -> bool:
if a == null: return b == null if a == null: return b == null
@@ -186,7 +161,11 @@ static func ge(a, b) -> bool: return compare(a, b) >= 0 if numeric(a) and numeri
static func minimum(a, b): return a if le(a, b) else b static func minimum(a, b): return a if le(a, b) else b
static func maximum(a, b): return a if ge(a, b) else b static func maximum(a, b): return a if ge(a, b) else b
static func clamp_value(a, low, high): return minimum(maximum(a, low), high) static func clamp_value(a, low, high): return minimum(maximum(a, low), high)
static func neg(a): return subtract(0, a) static func neg(a):
if not numeric(a): return -a
if a is int: return -a if a != MIN_INT else _compose(_int_limbs(a), 0.0, 1)
if a is float: return -a
return _compose(a.whole, a.fraction, -a.polarity)
static func absolute(a): return neg(a) if lt(a, 0) else a static func absolute(a): return neg(a) if lt(a, 0) else a
static func add(a, b): static func add(a, b):
@@ -196,32 +175,24 @@ static func add(a, b):
if not finite(a) or not finite(b): return approx(a) + approx(b) if not finite(a) or not finite(b): return approx(a) + approx(b)
var x: Array = _blocks(a) var x: Array = _blocks(a)
var y: Array = _blocks(b) var y: Array = _blocks(b)
if x[3] == y[3]: if x[2] == y[2]:
var fraction: float = x[2] + y[2] var fraction: float = x[1] + y[1]
# Repeated fractional accumulation may land just below an integer. if x[1] > 0.0 and y[1] > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12: fraction = roundf(fraction)
# This tolerance only applies to the fractional carry, never whole blocks. return _compose(Big.add(x[0], y[0]), fraction, x[2])
if x[2] > 0.0 and y[2] > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12: var order: int = Big.compare(x[0], y[0])
fraction = roundf(fraction) if order == 0 and x[1] != y[1]: order = 1 if x[1] > y[1] else -1
return _compose(x[0] + y[0], x[1] + y[1], fraction, x[3])
var order: int = 0
for i in 3:
if x[i] != y[i]:
order = 1 if x[i] > y[i] else -1
break
if order == 0: return 0 if order == 0: return 0
if order < 0: if order < 0:
var tmp: Array = x var temporary: Array = x
x = y x = y
y = tmp y = temporary
return _compose(x[0] - y[0], x[1] - y[1], x[2] - y[2], x[3]) return _compose(Big.subtract(x[0], y[0]), x[1] - y[1], x[2])
static func subtract(a, b): static func subtract(a, b):
if not numeric(a) or not numeric(b): return a - b if not numeric(a) or not numeric(b): return a - b
if a is int and b is int: if a is int and b is int:
if (b >= 0 and a >= MIN_INT + b) or (b < 0 and a <= MAX_INT + b): return a - b if (b >= 0 and a >= MIN_INT + b) or (b < 0 and a <= MAX_INT + b): return a - b
if not finite(a) or not finite(b): return approx(a) - approx(b) return add(a, neg(b))
var y: Array = _blocks(b)
return add(a, _compose(y[0], y[1], y[2], -y[3]))
static func multiply(a, b): static func multiply(a, b):
if not numeric(a) or not numeric(b): return a * b if not numeric(a) or not numeric(b): return a * b
@@ -246,6 +217,18 @@ static func mul_div(a, b, divisor):
assert(z[0] != [0], "Division by zero") assert(z[0] != [0], "Division by zero")
return make(Big.multiply(Big.multiply(x[0], y[0]), z[1]), Big.multiply(Big.multiply(x[1], y[1]), z[0]), x[2] * y[2] * z[2]) return make(Big.multiply(Big.multiply(x[0], y[0]), z[1]), Big.multiply(Big.multiply(x[1], y[1]), z[0]), x[2] * y[2] * z[2])
## Sum of nonnegative linear quotas rounded down separately at each boundary.
static func floor_sum(count, step, offset, divisor):
var n: Array = parts(count)
var a: Array = parts(step)
var b: Array = parts(offset)
var d: Array = parts(divisor)
assert(n[1] == [1] and n[2] > 0 and a[2] > 0 and b[2] > 0 and d[2] > 0 and d[0] != [0])
var numerator_step: Array = Big.multiply(Big.multiply(a[0], b[1]), d[1])
var numerator_offset: Array = Big.multiply(Big.multiply(b[0], a[1]), d[1])
var denominator: Array = Big.multiply(Big.multiply(a[1], b[1]), d[0])
return make(Big.floor_sum(n[0], denominator, numerator_step, numerator_offset))
static func divide(a, b): static func divide(a, b):
if not numeric(a) or not numeric(b): return a / b if not numeric(a) or not numeric(b): return a / b
if not finite(a) or not finite(b): return approx(a) / approx(b) if not finite(a) or not finite(b): return approx(a) / approx(b)
@@ -257,7 +240,7 @@ static func divide(a, b):
var denominator: int = absi(b) var denominator: int = absi(b)
var whole: int = numerator / denominator var whole: int = numerator / denominator
var fraction: float = minf(float(numerator % denominator) / float(denominator), 1.0 - pow(2.0, -53)) var fraction: float = minf(float(numerator % denominator) / float(denominator), 1.0 - pow(2.0, -53))
return _compose(whole / BLOCK, whole % BLOCK, fraction, -1 if (a < 0) != (b < 0) else 1) return _compose(_int_limbs(whole), fraction, -1 if (a < 0) != (b < 0) else 1)
var x: Array = parts(a) var x: Array = parts(a)
var y: Array = parts(b) var y: Array = parts(b)
assert(y[0] != [0], "Division by zero") assert(y[0] != [0], "Division by zero")
@@ -266,7 +249,7 @@ static func divide(a, b):
static func floor_value(a): static func floor_value(a):
if not finite(a) or a is int: return a if not finite(a) or a is int: return a
var x: Array = _blocks(a) var x: Array = _blocks(a)
return _compose(x[0], x[1] + (1 if x[3] < 0 and x[2] > 0 else 0), 0.0, x[3]) return _compose(Big.add(x[0], [1]) if x[2] < 0 and x[1] > 0.0 else x[0], 0.0, x[2])
static func ceil_value(a): return neg(floor_value(neg(a))) static func ceil_value(a): return neg(floor_value(neg(a)))
static func round_value(a): return multiply(-1 if lt(a, 0) else 1, floor_value(add(absolute(a), of("0.5")))) static func round_value(a): return multiply(-1 if lt(a, 0) else 1, floor_value(add(absolute(a), of("0.5"))))
@@ -274,8 +257,14 @@ static func remainder(a, b): return subtract(a, multiply(truncate(divide(a, b)),
static func truncate(a): return ceil_value(a) if lt(a, 0) else floor_value(a) static func truncate(a): return ceil_value(a) if lt(a, 0) else floor_value(a)
static func integer(a) -> int: static func integer(a) -> int:
var result = truncate(of(a)) var result = truncate(of(a))
assert(result is int, "Value does not fit native int64") if result is int: return result
return result if result is Value and result.fraction == 0.0:
if result.polarity < 0 and Big.compare(result.whole, MIN_INT_LIMBS) == 0: return MIN_INT
if Big.compare(result.whole, MAX_INT_LIMBS) <= 0:
var magnitude: int = int(result.whole[0]) + (int(result.whole[1]) * SMALL if result.whole.size() > 1 else 0) + (int(result.whole[2]) * 1000000000000000000 if result.whole.size() > 2 else 0)
return magnitude * result.polarity
assert(false, "Value does not fit native int64")
return 0
## Kept as no-op API: float remainder storage is already bounded. ## Kept as no-op API: float remainder storage is already bounded.
static func bounded(value): return value static func bounded(value): return value
@@ -283,15 +272,18 @@ static func bounded(value): return value
static func bound_state(value): return value static func bound_state(value): return value
static func power(a, b): static func power(a, b):
if finite(b) and lt(b, 0): return of(pow(approx(a), approx(b)))
if finite(b) and eq(b, floor_value(b)) and le(absolute(b), 10000): if finite(b) and eq(b, floor_value(b)) and le(absolute(b), 10000):
# Small negative powers retain the native float precision; huge bases
# need block arithmetic because approx(base) can become infinity.
if lt(b, 0) and is_finite(approx(a)):
return of(pow(approx(a), approx(b)))
var exponent: int = integer(absolute(b)) var exponent: int = integer(absolute(b))
var result = 1 var result = 1
while exponent: while exponent:
if exponent % 2: result = multiply(result, a) if exponent % 2: result = multiply(result, a)
exponent /= 2 exponent /= 2
if exponent: a = multiply(a, a) if exponent: a = multiply(a, a)
return result return divide(1, result) if lt(b, 0) else result
return coefficient(pow(approx(a), approx(b))) return coefficient(pow(approx(a), approx(b)))
static func coefficient(value: float): static func coefficient(value: float):
@@ -299,8 +291,24 @@ static func coefficient(value: float):
if not is_finite(value): return value if not is_finite(value): return value
return of(String.num(value, 12)) return of(String.num(value, 12))
static func square_root(a): return coefficient(sqrt(approx(a))) static func square_root(a):
static func logarithm(a): return coefficient(log(approx(a))) var approximate: float = approx(a)
if is_finite(approximate): return coefficient(sqrt(approximate))
if a is Value and a.polarity > 0:
var exponent: int = (Big.decimal(a.whole).length() - 1) / 2
var scale = of("1e%d" % exponent)
var reduced = divide(a, multiply(scale, scale))
return multiply(coefficient(sqrt(approx(reduced))), scale)
return coefficient(sqrt(approximate))
static func logarithm(a):
var approximate: float = approx(a)
if is_finite(approximate): return coefficient(log(approximate))
if a is Value and a.polarity > 0:
var start: int = maxi(0, a.whole.size() - 3)
var leading: float = 0.0
for i in range(a.whole.size() - 1, start - 1, -1): leading = leading * SMALL + a.whole[i]
return coefficient(log(leading) + start * 9 * log(10.0))
return coefficient(log(approximate))
static func near(a, b) -> bool: static func near(a, b) -> bool:
return le(absolute(subtract(a, b)), multiply(of("0.00001"), maximum(1, maximum(absolute(a), absolute(b))))) return le(absolute(subtract(a, b)), multiply(of("0.00001"), maximum(1, maximum(absolute(a), absolute(b)))))
@@ -337,9 +345,9 @@ static func scientific(value, digits: int = 4) -> String:
exponent += 1 exponent += 1
return fixed(mantissa, digits) + ("E+%d" if exponent >= 0 else "E%d") % exponent return fixed(mantissa, digits) + ("E+%d" if exponent >= 0 else "E%d") % exponent
static func grouped(value, digits: int = 0) -> String: static func grouped(value, digits: int = 0, compact: bool = true) -> String:
if eq(value, 0): return "" if eq(value, 0): return "" if compact else fixed(0, digits)
if ge(absolute(value), 1000000000): return scientific(value, digits) if compact and ge(absolute(value), 1000000000): return scientific(value, digits)
var text: String = fixed(absolute(value), digits) var text: String = fixed(absolute(value), digits)
var parts_text: PackedStringArray = text.split(".") var parts_text: PackedStringArray = text.split(".")
var integer_text: String = parts_text[0] var integer_text: String = parts_text[0]
@@ -350,16 +358,15 @@ static func grouped(value, digits: int = 0) -> String:
return ("-" if lt(value, 0) else "") + integer_text + result + ("." + parts_text[1] if parts_text.size() > 1 else "") return ("-" if lt(value, 0) else "") + integer_text + result + ("." + parts_text[1] if parts_text.size() > 1 else "")
static func encode(value): static func encode(value):
if value is Value: if value is Value or (value is int and (value > 9007199254740991 or value < -9007199254740991)):
var blocks: Array = _blocks(value)
var bytes := PackedByteArray() var bytes := PackedByteArray()
bytes.resize(8) bytes.resize(8)
bytes.encode_double(0, value.fraction) bytes.encode_double(0, blocks[1])
return {"$fixed": [str(value.high), str(value.low), bytes.hex_encode(), value.polarity]} return {"$exact": [blocks[0], bytes.hex_encode(), blocks[2]]}
if value is int and (value > 9007199254740991 or value < -9007199254740991):
return {"$exact": str(value)}
if value is Dictionary: if value is Dictionary:
var result: Dictionary = {} var result: Dictionary = {}
for k in value: result[k] = encode(value[k]) for key in value: result[key] = encode(value[key])
return result return result
if value is Array: if value is Array:
var result: Array = [] var result: Array = []
@@ -369,12 +376,11 @@ static func encode(value):
static func decode(value): static func decode(value):
if value is Dictionary: if value is Dictionary:
if value.size() == 1 and value.has("$fixed"): if value.size() == 1 and value.has("$exact"):
var fields: Array = value["$fixed"] var fields: Array = value["$exact"]
return _compose(int(fields[0]), int(fields[1]), fields[2].hex_decode().decode_double(0), int(fields[3])) return _compose(fields[0], fields[1].hex_decode().decode_double(0), int(fields[2]))
if value.size() == 1 and value.has("$exact"): return of(value["$exact"])
var result: Dictionary = {} var result: Dictionary = {}
for k in value: result[k] = decode(value[k]) for key in value: result[key] = decode(value[key])
return result return result
if value is Array: if value is Array:
var result: Array = [] var result: Array = []
@@ -385,30 +391,19 @@ static func decode(value):
static func valid_encoded(value) -> bool: static func valid_encoded(value) -> bool:
if value is float: return is_finite(value) and absf(value) < 1e28 if value is float: return is_finite(value) and absf(value) < 1e28
if value is Dictionary: if value is Dictionary:
if value.has("$fixed"): if value.has("$fixed"): return false
if value.size() != 1 or not value["$fixed"] is Array: return false
var fields: Array = value["$fixed"]
if fields.size() != 4: return false
for i in 2:
if not fields[i] is String or not fields[i].is_valid_int() or fields[i].length() > 14: return false
if int(fields[i]) < 0 or int(fields[i]) >= BLOCK: return false
if not fields[2] is String or fields[2].length() != 16 or not fields[2].is_valid_hex_number(): return false
var fraction: float = fields[2].hex_decode().decode_double(0)
return is_finite(fraction) and fraction >= 0.0 and fraction < 1.0 and fields[3] in [-1, 1]
if value.has("$exact"): if value.has("$exact"):
if value.size() != 1 or not value["$exact"] is String: return false if value.size() != 1 or not value["$exact"] is Array: return false
var text: String = value["$exact"] var fields: Array = value["$exact"]
if text.length() > 29: return false if fields.size() != 3 or not fields[0] is Array or not fields[1] is String or not fields[2] in [-1, 1]: return false
var terms: PackedStringArray = text.split("/") if fields[0].is_empty() or (fields[0].size() > 1 and fields[0].back() == 0): return false
if terms.size() != 1: return false if fields[1].length() != 16 or not fields[1].is_valid_hex_number(): return false
var n: String = terms[0].trim_prefix("-") for limb in fields[0]:
if n.is_empty(): return false if not limb is int or limb < 0 or limb >= SMALL: return false
for ch in n: var fraction: float = fields[1].hex_decode().decode_double(0)
if ch < "0" or ch > "9": return false return is_finite(fraction) and fraction >= 0.0 and fraction < 1.0
if Big.compare(Big.parse(n), LIMIT) >= 0: return false for key in value:
return true if not valid_encoded(value[key]): return false
for k in value:
if not valid_encoded(value[k]): return false
if value is Array: if value is Array:
for item in value: for item in value:
if not valid_encoded(item): return false if not valid_encoded(item): return false
+6 -5
View File
@@ -1,11 +1,12 @@
extends RefCounted extends RefCounted
## Immutable magnitude high * 10^14 + low + fraction, with a separate sign. ## Immutable signed magnitude: whole is little-endian base 1e9 blocks.
var polarity: int = 1 var polarity: int = 1
var high: int = 0 var whole: Array = [0]
var low: int = 0
var fraction: float = 0.0 var fraction: float = 0.0
func _to_string() -> String: func _to_string() -> String:
var whole: String = str(high) + "%014d" % low if high else str(low) var whole_text: String = str(whole.back())
for i in range(whole.size() - 2, -1, -1):
whole_text += "%09d" % whole[i]
var tail: String = String.num(fraction, 16).trim_prefix("0").trim_suffix(".") if fraction else "" var tail: String = String.num(fraction, 16).trim_prefix("0").trim_suffix(".") if fraction else ""
return ("-" if polarity < 0 else "") + whole + tail return ("-" if polarity < 0 else "") + whole_text + tail
+36 -1
View File
@@ -34,7 +34,8 @@ func scale_batches(target: Dictionary, id: String, factor: Variant) -> void:
share.batch.withdraw_remainder = E.minimum(share.batch.get("withdraw_remainder", 0), share.batch.amount) share.batch.withdraw_remainder = E.minimum(share.batch.get("withdraw_remainder", 0), share.batch.amount)
func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF) -> Variant: func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF, budget: RefCounted = null) -> Variant:
var _keep_alive: RefCounted = self
if ((E.le(amount, 0) or (not world.catalog.entities.has(id))) or E.le(world.catalog.entities[id].lifespan, 0)): if ((E.le(amount, 0) or (not world.catalog.entities.has(id))) or E.le(world.catalog.entities[id].lifespan, 0)):
return NAN return NAN
if E.lt(created_to, created_from): if E.lt(created_to, created_from):
@@ -67,12 +68,46 @@ func append_batch(target: Dictionary, id: String, amount: Variant, created_from:
window_index = E.add(window_index, 1) window_index = E.add(window_index, 1)
var batches_by_start: Dictionary = {} var batches_by_start: Dictionary = {}
var skip_empty_expired: bool = E.gt(duration, 0) and E.finite(completed_before) and E.eq(amount, E.floor_value(amount)) var skip_empty_expired: bool = E.gt(duration, 0) and E.finite(completed_before) and E.eq(amount, E.floor_value(amount))
var sparse_windows: bool = E.gt(duration, 0) and E.gt(amount, 0) and E.gt(duration, E.multiply(batch_duration, amount)) and not skip_empty_expired
for existing : Dictionary in batches: for existing : Dictionary in batches:
var start: Variant = existing.get("created_from", E.neg(INF)) var start: Variant = existing.get("created_from", E.neg(INF))
if (not batches_by_start.has(str(start))): if (not batches_by_start.has(str(start))):
batches_by_start[str(start)] = [] batches_by_start[str(start)] = []
batches_by_start[str(start)].append(existing) batches_by_start[str(start)].append(existing)
while true: while true:
if budget != null and not await budget.pause(): return NAN
# A zero cohort is discarded by withdraw_batches. Jump straight to the
# first boundary whose cumulative whole quota increases, rather than
# allocating and later removing hundreds of empty dictionaries.
if sparse_windows:
var threshold: Variant = E.add(created_from, E.mul_div(duration, E.add(E.subtract(amount, remaining_amount), 1), amount))
var next_nonempty: Variant = E.subtract(E.ceil_value(E.divide(E.subtract(threshold, origin), batch_duration)), 1)
if E.gt(next_nonempty, window_index):
window_index = next_nonempty
cursor = E.maximum(created_from, E.add(origin, E.multiply(window_index, batch_duration)))
if skip_empty_expired:
var completed_end_index = E.floor_value(E.divide(E.subtract(E.minimum(created_to, E.subtract(completed_before, lifespan)), origin), batch_duration))
var window_count = E.subtract(completed_end_index, window_index)
if E.gt(window_count, 1):
var first_start = E.add(origin, E.multiply(window_index, batch_duration))
var first_end = E.add(first_start, batch_duration)
var last_end = E.add(origin, E.multiply(completed_end_index, batch_duration))
var last_quota = E.floor_value(E.mul_div(amount, E.subtract(last_end, created_from), duration))
var total_completed = E.subtract(last_quota, E.subtract(amount, remaining_amount))
# Summation by parts preserves every per-window floor. Its
# weighted sum gives the same lifetime production integral.
var preceding_quotas = E.floor_sum(E.subtract(window_count, 1), E.multiply(amount, batch_duration), E.multiply(amount, E.subtract(first_end, created_from)), duration)
var weighted_windows = E.subtract(E.multiply(E.subtract(window_count, 1), last_quota), preceding_quotas)
var first_expired_years = E.subtract(E.subtract(completed_before, E.add(first_start, lifespan)), E.divide(batch_duration, 2))
var expired_integral = E.subtract(E.multiply(total_completed, first_expired_years), E.multiply(batch_duration, weighted_windows))
remaining_object_years = E.subtract(remaining_object_years, expired_integral)
completed_amount = E.add(completed_amount, total_completed)
completed_from = E.minimum(completed_from, E.add(first_start, lifespan))
completed_to = E.maximum(completed_to, E.add(last_end, lifespan))
remaining_amount = E.subtract(remaining_amount, total_completed)
window_index = completed_end_index
cursor = last_end
if E.ge(cursor, created_to): break
# Whole allocation changes only when its cumulative quota reaches the # Whole allocation changes only when its cumulative quota reaches the
# next integer. Skip expired empty windows without changing the span # next integer. Skip expired empty windows without changing the span
# passed to the next generation or the lifetime production integral. # passed to the next generation or the lifetime production integral.
+15 -1
View File
@@ -3,7 +3,16 @@ const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd") const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd") const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
var _amount_snapshot: Dictionary = {}
var _binds_snapshot: Dictionary = {}
var _physical_cache: Dictionary = {}
var _physical_revision: int = -1
func physical_amounts(source: Dictionary) -> Dictionary: func physical_amounts(source: Dictionary) -> Dictionary:
world.catalog._ensure_group_index()
if _physical_revision == world.catalog.index_revision and source.amount == _amount_snapshot and source.binds == _binds_snapshot:
return _physical_cache.duplicate()
var profile_started: int = world.profile_start()
var amounts: Dictionary = {} var amounts: Dictionary = {}
for id in world.catalog.entities: for id in world.catalog.entities:
if (not world.catalog.is_group(id)): if (not world.catalog.is_group(id)):
@@ -12,7 +21,12 @@ func physical_amounts(source: Dictionary) -> Dictionary:
for id in bindings: for id in bindings:
if amounts.has(id): if amounts.has(id):
amounts[id] = E.add(amounts[id], E.maximum(0, bindings[id])) amounts[id] = E.add(amounts[id], E.maximum(0, bindings[id]))
return amounts _amount_snapshot = source.amount.duplicate()
_binds_snapshot = source.binds.duplicate(true)
_physical_cache = amounts
_physical_revision = world.catalog.index_revision
world.profile_end("physical_amounts_build", profile_started)
return amounts.duplicate()
func physical_amount(source: Dictionary, id: String) -> Variant: func physical_amount(source: Dictionary, id: String) -> Variant:
+82 -1
View File
@@ -14,6 +14,19 @@ var stellar = preload("res://world/owniverse/stellar_rules.gd").new(self)
var life = preload("res://world/owniverse/life_evolution.gd").new(self) var life = preload("res://world/owniverse/life_evolution.gd").new(self)
var environment = preload("res://world/owniverse/universe_environment.gd").new(self) var environment = preload("res://world/owniverse/universe_environment.gd").new(self)
var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(self) var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(self)
var profile_enabled: bool = false
var profile: Dictionary = {}
func profile_start() -> int:
if not profile_enabled: return 0
return Time.get_ticks_usec() - (frame_budget.waiting_usec if frame_busy and frame_budget != null else 0)
func profile_end(section: String, started: int) -> void:
if not profile_enabled: return
var sample: Array = profile.get(section, [0, 0])
sample[0] += profile_start() - started
sample[1] += 1
profile[section] = sample
var state: Dictionary = { var state: Dictionary = {
'version': SimulationTime.STATE_VERSION, 'version': SimulationTime.STATE_VERSION,
@@ -44,10 +57,78 @@ var state: Dictionary = {
'stats': {'total_amount': {}}, 'stats': {'total_amount': {}},
} }
var frame_busy: bool = false
var frame_commits: int = 0
var frame_clock: Dictionary = {}
var frame_budget: RefCounted
var _requested_speed: int = 0
var _queued_productions: Array = []
var frame_active_clock: Dictionary = {}
var _resume_clock: Dictionary = {}
## Keep the requested clock advancing while a transaction spans render frames.
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)
if frame_busy: return
if E.le(frame_clock.datetime, state.time.datetime) and _resume_clock.is_empty(): return
frame_busy = true
frame_budget = preload("res://world/owniverse/frame_budget.gd").new(tree)
frame_active_clock = _resume_clock if not _resume_clock.is_empty() else frame_clock.duplicate()
# Keep each transaction at the normal 60 Hz simulation interval. The
# requested clock holds the backlog, rather than feeding an ever-growing
# multi-generation interval into one transaction and freezing publication.
if _resume_clock.is_empty():
var normal_clock: Dictionary = SimulationTime.advance(state.time, 1.0 / 60.0, speed)
if E.lt(normal_clock.datetime, frame_clock.datetime):
frame_active_clock = normal_clock
_resume_clock = {}
evolution.run_frame(frame_active_clock, frame_budget)
func request_production(id: String, amount: Variant, resource: bool = false) -> void:
if frame_busy:
_queued_productions.append({"id": id, "amount": amount, "resource": resource})
elif resource:
production.produce_hs(id, amount)
else:
production.produce(id, amount)
## 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}
func restore_frame_checkpoint(checkpoint: Dictionary) -> void:
frame_clock = checkpoint.get("requested_clock", {})
_resume_clock = checkpoint.get("active_clock", {})
_queued_productions = checkpoint.get("productions", [])
if _resume_clock.is_empty():
speed = checkpoint.get("speed", state.speed)
else:
_requested_speed = checkpoint.get("speed", state.speed)
func finish_frame() -> void:
frame_busy = false
frame_commits += 1
if _requested_speed != 0:
speed = _requested_speed
_requested_speed = 0
var productions: Array = _queued_productions
_queued_productions = []
for command in productions:
request_production(command.id, command.amount, command.resource)
func _notification(what: int) -> void:
if what == NOTIFICATION_PREDELETE and frame_budget != null:
frame_budget.cancelled = true
var speed: int : var speed: int :
get: get:
return state.speed return _requested_speed if _requested_speed != 0 else state.speed
set(value): set(value):
if frame_busy:
_requested_speed = clampi(value, 1, Global.SPEED_LIMIT)
return
state.speed = clampi(value, 1, Global.SPEED_LIMIT) state.speed = clampi(value, 1, Global.SPEED_LIMIT)
state.speed_in_year = E.power(10, E.subtract(state.speed, 1)) state.speed_in_year = E.power(10, E.subtract(state.speed, 1))
+44 -29
View File
@@ -12,24 +12,32 @@ static func accumulate_whole(target: Dictionary, field: String, key: String, val
static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary: 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 result: Dictionary = {}
var total: Variant = 0
for id in requests:
result[id] = E.floor_value(E.maximum(0, requests[id]))
total = E.add(total, result[id])
var available: Variant = E.floor_value(E.minimum(E.maximum(0, limit), total))
# No apportionment or rounding is necessary when every request fits.
if E.eq(available, total): return result
if E.le(available, 0):
for id in result: result[id] = 0
return result
var ids: Array = requests.keys()
var rests: Array = []
var order: Array = [] var order: Array = []
var assigned: Variant = 0 var assigned: Variant = 0
for id in requests: for i in ids.size():
var expected: Variant = (E.mul_div(E.floor_value(E.maximum(0, requests[id])), available, total) if E.gt(total, 0) else 0) var expected: Variant = E.mul_div(result[ids[i]], available, total)
result[id] = E.floor_value(expected) result[ids[i]] = E.floor_value(expected)
assigned = E.add(assigned, result[id]) assigned = E.add(assigned, result[ids[i]])
order.append({"id": id, "rest": E.subtract(expected, result[id])}) rests.append(E.subtract(expected, result[ids[i]]))
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)))) order.append(i)
for item : Dictionary in order: order.sort_custom(func(a: int, b: int) -> bool: return E.gt(rests[a], rests[b]) if E.ne(rests[a], rests[b]) else str(ids[a]) < str(ids[b]))
if E.ge(assigned, available): for i in order:
break if E.ge(assigned, available): break
if E.lt(result[item.id], E.floor_value(requests[item.id])): if E.lt(result[ids[i]], E.floor_value(requests[ids[i]])):
result[item.id] = E.add(result[item.id], 1) result[ids[i]] = E.add(result[ids[i]], 1)
assigned = E.add(assigned, 1) assigned = E.add(assigned, 1)
return result return result
@@ -74,25 +82,32 @@ static func split(remainders: Dictionary, key: String, count: Variant, weights:
var result: Dictionary = {} var result: Dictionary = {}
var sum_weights: Variant = sum(weights) var sum_weights: Variant = sum(weights)
if (E.le(count, 0) or E.le(sum_weights, 0)): return result if (E.le(count, 0) or E.le(sum_weights, 0)): return result
var expected: Dictionary = {} var ids: Array = weights.keys()
var expected: Array = []
var remainder_keys: Array = []
var positive_sum: Variant = 0 var positive_sum: Variant = 0
for id in weights: for id in ids:
expected[id] = E.add(E.mul_div(count, weights[id], sum_weights), remainders.get(E.add(E.add(key, ":"), id), 0)) var remainder_key: String = key + ":" + str(id)
positive_sum = E.add(positive_sum, E.maximum(0, expected[id])) remainder_keys.append(remainder_key)
var value: Variant = E.add(E.mul_div(count, weights[id], sum_weights), remainders.get(remainder_key, 0))
expected.append(value)
positive_sum = E.add(positive_sum, E.maximum(0, value))
var order: Array = [] var order: Array = []
var rests: Array = []
var assigned: Variant = 0 var assigned: Variant = 0
for id in weights: for i in ids.size():
var normalized: Variant = (E.mul_div(E.maximum(0, expected[id]), count, positive_sum) if E.gt(positive_sum, 0) else 0) var normalized: Variant = E.mul_div(E.maximum(0, expected[i]), count, positive_sum) if E.gt(positive_sum, 0) else 0
result[id] = E.floor_value(normalized) result[ids[i]] = E.floor_value(normalized)
assigned = E.add(assigned, result[id]) assigned = E.add(assigned, result[ids[i]])
order.append({"id": id, "rest": E.subtract(normalized, result[id])}) rests.append(E.subtract(normalized, result[ids[i]]))
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)))) order.append(i)
for entry in order: order.sort_custom(func(a: int, b: int) -> bool: return E.gt(rests[a], rests[b]) if E.ne(rests[a], rests[b]) else str(ids[a]) < str(ids[b]))
for i in order:
if E.ge(assigned, count): break if E.ge(assigned, count): break
result[entry.id] = E.add(result[entry.id], 1) result[ids[i]] = E.add(result[ids[i]], 1)
assigned = E.add(assigned, 1) assigned = E.add(assigned, 1)
for id in weights: for i in ids.size():
remainders[E.add(E.add(key, ":"), id)] = E.clamp_value(E.subtract(expected[id], result[id]), E.neg(1), 1) remainders[remainder_keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
return result return result
+12
View File
@@ -21,6 +21,18 @@ func stellar_environment(source: Dictionary) -> Dictionary:
if E.gt(mass, 0): if E.gt(mass, 0):
metallicity = E.maximum(0, E.add(E.multiply(E.of("0.4"), E.logarithm(E.add(E.mul_div(100, source.amount.get("Met", 0), mass), E.of("0.11")))), E.of("0.88"))) metallicity = E.maximum(0, E.add(E.multiply(E.of("0.4"), E.logarithm(E.add(E.mul_div(100, source.amount.get("Met", 0), mass), E.of("0.11")))), E.of("0.88")))
var base_mass: Variant = E.multiply(65536, ((E.power(mass, E.of("0.1")) if (E.gt(mass, 0) and E.lt(mass, E.of("1.0e10"))) else ((10 if E.ge(mass, E.of("1.0e10")) else 1))))) var base_mass: Variant = E.multiply(65536, ((E.power(mass, E.of("0.1")) if (E.gt(mass, 0) and E.lt(mass, E.of("1.0e10"))) else ((10 if E.ge(mass, E.of("1.0e10")) else 1)))))
if not is_finite(volume) or not is_finite(E.approx(E.add(base_mass, mass))):
# Dynamic counts may exceed the float exponent range. Evaluate the
# same geometry using logarithms, avoiding infinity / infinity.
var exact_volume: Variant = quantities.get("S", 0)
for id in quantities:
var entity: OwniverseEntity = world.catalog.entities[id]
var size: Variant = E.add(E.maximum(0, entity.space), E.multiply(E.maximum(0, entity.voids), E.of("1e22")))
exact_volume = E.add(exact_volume, E.multiply(quantities[id], size))
var density_log: float = log(1.23456e-7) + E.approx(E.logarithm(E.add(base_mass, mass))) / 2.0 - E.approx(E.logarithm(E.maximum(1, exact_volume))) / 6.0
var density: Variant = E.of("1e%d" % int(floor(density_log / log(10.0))))
density = E.multiply(density, E.coefficient(exp(density_log - floor(density_log / log(10.0)) * log(10.0))))
return {"metallicity": metallicity, "density": density, "blast": E.clamp_value(E.divide(source.amount.get("S", 0), exact_volume), 0, 1) if E.gt(exact_volume, 0) else 1}
return {"metallicity": metallicity, "density": E.coefficient(1.23456e-7 * sqrt(E.approx(E.add(base_mass, mass))) / pow(maxf(1.0, volume), 1.0 / 6.0)), "blast": E.coefficient(clampf(E.approx(source.amount.get("S", 0)) / volume, 0.0, 1.0)) if volume > 0.0 else 1} return {"metallicity": metallicity, "density": E.coefficient(1.23456e-7 * sqrt(E.approx(E.add(base_mass, mass))) / pow(maxf(1.0, volume), 1.0 / 6.0)), "blast": E.coefficient(clampf(E.approx(source.amount.get("S", 0)) / volume, 0.0, 1.0)) if volume > 0.0 else 1}
+10 -2
View File
@@ -65,8 +65,9 @@ static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary,
for n in population.values(): count = E.add(count, n) for n in population.values(): count = E.add(count, n)
if E.gt(count, 0): if E.gt(count, 0):
for ethic in population: for ethic in population:
var proportion: Variant = E.divide(population[ethic], count)
for i in TRENDS.size(): for i in TRENDS.size():
rates[i] = E.add(rates[i], E.multiply(E.multiply(E.divide(population[ethic], count), WEIGHTS[ethic][i]), weight)) rates[i] = E.add(rates[i], E.multiply(E.multiply(proportion, WEIGHTS[ethic][i]), weight))
weight = E.multiply(weight, 2) weight = E.multiply(weight, 2)
for i in TRENDS.size(): for i in TRENDS.size():
var name: String = TRENDS[i] var name: String = TRENDS[i]
@@ -84,7 +85,14 @@ static func _integration(target: Dictionary, events: Variant) -> void:
var rng = RandomNumberGenerator.new() var rng = RandomNumberGenerator.new()
rng.seed = 76123 rng.seed = 76123
if target.civilization_stats.has("trend_rng"): if target.civilization_stats.has("trend_rng"):
rng.state = E.integer(target.civilization_stats.trend_rng) # Godot exposes the generator state as an unsigned 64-bit value. The
# arithmetic layer intentionally stores values above int64 as blocks;
# convert its bit pattern back to the signed integer accepted by the
# RNG setter after loading.
var saved_state: Variant = target.civilization_stats.trend_rng
if E.gt(saved_state, 9223372036854775807):
saved_state = E.subtract(saved_state, E.of("18446744073709551616"))
rng.state = E.integer(saved_state)
var counts: Array = [0, 0, 0] var counts: Array = [0, 0, 0]
if E.le(events, 256): if E.le(events, 256):
for i in E.integer(events): for i in E.integer(events):