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:
@@ -6,7 +6,7 @@ const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const SETTING_SECTION = "Settings"
|
||||
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 WorldParameters = preload("res://world/owniverse/world_parameters.gd")
|
||||
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)
|
||||
payload.format_version = FORMAT_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.
|
||||
@@ -105,7 +105,7 @@ func save_data(filename: String, password: String, data: Dictionary) -> bool:
|
||||
|
||||
static func _write_snapshot(job: Dictionary) -> String:
|
||||
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, ""):
|
||||
return E.add("Invalid save: ", problem)
|
||||
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.
|
||||
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"
|
||||
data = E.decode(data)
|
||||
if ((not (data is Dictionary)) or (not _plain_tree(data))):
|
||||
return "save must contain plain finite values"
|
||||
if (E.ne(data.get("format_version"), FORMAT_VERSION) or (not (data.get("app_version") is String))):
|
||||
return "unsupported save format"
|
||||
if (((not (data.get("ui") is Dictionary)) or (not (data.ui.get("current_page") is int))) or 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"
|
||||
if (not (data.get("state") is 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"
|
||||
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"
|
||||
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))):
|
||||
return "invalid state resources"
|
||||
if ((not (saved.stats is Dictionary)) or (not _number_map(saved.stats.get("total_amount"), true))):
|
||||
|
||||
+5
-3
@@ -219,7 +219,7 @@ func item_activated(item: OwniverseItem) -> void:
|
||||
if item.item_id == "S" or item.item_id == "H":
|
||||
var _value = owniverse.state.amount[item.item_id]
|
||||
_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:
|
||||
@@ -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
|
||||
|
||||
if E.gt(action_amount, 0):
|
||||
owniverse.production.produce(item_id, action_amount)
|
||||
owniverse.request_production(item_id, action_amount)
|
||||
|
||||
|
||||
#endregion
|
||||
@@ -369,7 +369,7 @@ func _refresh_universe_ui() -> void:
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
|
||||
owniverse.evolute(delta)
|
||||
owniverse.evolute_frame(delta, get_tree())
|
||||
indicator_elapsed += delta
|
||||
if indicator_elapsed >= 0.1:
|
||||
indicator_elapsed = 0.0
|
||||
@@ -449,6 +449,7 @@ func load_data() -> bool:
|
||||
var complete: Dictionary = owniverse.state.duplicate(true)
|
||||
complete.merge(restored, true)
|
||||
owniverse.state = complete
|
||||
owniverse.restore_frame_checkpoint(game_data.get("simulation", {}))
|
||||
|
||||
owniverse.evolution.initialize_life_batches()
|
||||
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)
|
||||
SaveManager.request_save("owniverse", "qwerty", {
|
||||
"state": owniverse.state,
|
||||
"simulation": owniverse.frame_checkpoint(),
|
||||
"ui": {"current_page": page},
|
||||
})
|
||||
|
||||
|
||||
@@ -155,3 +155,53 @@ godot --headless --path Owniverse.GI --script res://tests/test_civilization_evol
|
||||
```
|
||||
|
||||
Проверяются реальные коэффициенты C0.2, войны и побочный ущерб C2.0, замена планет колониями с сохранением хозяина, дипломатическое влияние, бонус периода от структур и деградация при их отсутствии. Ускоренная модель проходит все стадии C0.2 → C4.4 → Ct. Проверки контролируют сохранение населения, целочисленность этик, валидацию сохранения и детерминированное продолжение после сериализации.
|
||||
|
||||
## Профиль цивилизаций на шестой скорости
|
||||
|
||||
```sh
|
||||
godot --headless --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- frames=20 profile
|
||||
godot --path Owniverse.GI --script res://tests/benchmark_civilizations.gd -- ui scenario=mixed frames=10 profile
|
||||
```
|
||||
|
||||
Сценарии `mixed`, `large`, `fragmented` заполняют все стадии и этики, синхронизируя
|
||||
переходы; `large` использует по 6e20 цивилизаций, `fragmented` — десять партий.
|
||||
Это искусственные стрессовые сценарии. `scenario=stable` использует штатные
|
||||
сроки стадий. Параметр `snapshot=/tmp/civil` сохраняет состояние каждого сценария
|
||||
для сравнения до и после изменения. UI запускает настоящую сцену со страницей
|
||||
жизни и реальным delta, без загрузки и записи сохранения игрока.
|
||||
|
||||
`profile` выводит суммарные микросекунды и число вызовов по этапам. Вложенные
|
||||
этапы входят во время родительских; складывать их нельзя. В игре сбор выключен.
|
||||
Кэши физических количеств принадлежат отдельному миру и проверяют изменения
|
||||
количеств, вложенных привязок и индексов каталога. Кэш структуры правил не входит
|
||||
в сохранение: при изменении Claim/бонусов нужно перестроить индексы каталога,
|
||||
при изменении home в существующей таблице — invalidate_civilization_layout().
|
||||
|
||||
После оптимизации на M4 Pro, по 20 шагов 1/60 секунды, среднее время:
|
||||
`mixed` 295 → 235 мс, `large` 666 → 539 мс, `fragmented` 359 → 282 мс.
|
||||
Полные состояния всех трёх сценариев после 20 шагов совпали до и после изменения.
|
||||
Дополнительно тест распределений сравнивает новый путь с прежним на 300
|
||||
случайных наборах, включая 1e40 и накопленные долги округления.
|
||||
|
||||
## Расчёт между кадрами
|
||||
|
||||
`godot --headless --path Owniverse.GI --script res://tests/test_frame_evolution.gd`
|
||||
|
||||
Stage вызывает `evolute_frame`: транзакция получает бюджет 40 мс и уступает
|
||||
следующему кадру внутри создания партий и обработки стадий. Расчёт остаётся
|
||||
на главном потоке, состояние публикуется только после полного завершения.
|
||||
Длительность одной транзакции ограничена обычным шагом 1/60 секунды выбранной
|
||||
скорости. Запрошенное время хранится отдельно, поэтому отставание не превращается
|
||||
в один всё более тяжёлый шаг. При предельной нагрузке время симуляции может
|
||||
отставать от запрошенного; это не ограничение FPS интерфейса и не удаление событий.
|
||||
|
||||
Производство во время транзакции выполняется после её завершения. Запрошенное
|
||||
время, активный интервал, скорость и очередь производства входят в сохранение;
|
||||
при загрузке незавершённый интервал пересчитывается с опубликованного состояния.
|
||||
Сброс мира отменяет отложенный расчёт. Тест проверяет точное совпадение непрерывного
|
||||
и распределённого расчёта, очереди, переключение скорости, восстановление и отмену.
|
||||
|
||||
На M4 Pro, 180 кадров реального UI на шестой скорости: смешанный сценарий
|
||||
15.9 FPS (максимальный кадр 98 мс), большие количества 17.5 FPS (106 мс),
|
||||
много партий 16.3 FPS (97 мс).
|
||||
Это FPS отрисовки, а не число завершённых транзакций симуляции в секунду.
|
||||
|
||||
@@ -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])
|
||||
@@ -0,0 +1 @@
|
||||
uid://b5w4sp6eepypa
|
||||
@@ -22,8 +22,8 @@ func run():
|
||||
quit(1)
|
||||
return
|
||||
var data = JSON.parse_string(FileAccess.get_file_as_string(args[0]))
|
||||
if not data is Dictionary or data.get("format_version") != 4:
|
||||
push_error("Expected diagnostic JSON format 4")
|
||||
if not data is Dictionary or data.get("format_version") != 5:
|
||||
push_error("Expected diagnostic JSON format 5")
|
||||
quit(1)
|
||||
return
|
||||
var initial: Dictionary = E.decode(restore_json_integers(data)).state
|
||||
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bboo6op14es24
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3gmlo2vq6kk4
|
||||
Vendored
+19
@@ -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
@@ -0,0 +1 @@
|
||||
uid://d1wm7nav71y3o
|
||||
+1
File diff suppressed because one or more lines are too long
+30
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://bjloakx4qdpsj
|
||||
+59
@@ -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
@@ -0,0 +1 @@
|
||||
uid://dxsly3qpyqrjy
|
||||
@@ -58,6 +58,19 @@ func run() -> void:
|
||||
check(E.eq(completions.size(), 2), "Repeated requests coalesce behind active write")
|
||||
check(E.eq(manager.load_data("latest", "test-password").state.energy, 3), "Last queued snapshot wins")
|
||||
|
||||
var pending_clock: Dictionary = preload("res://world/owniverse/simulation_time.gd").advance(world.state.time, 0.01, 1)
|
||||
data.simulation = {"requested_clock": pending_clock, "active_clock": pending_clock, "speed": 1, "productions": [{"id": "H", "amount": 7, "resource": true}]}
|
||||
manager.request_save("checkpoint", "test-password", data)
|
||||
manager.flush_saves()
|
||||
check(E.eq(manager.load_data("checkpoint", "test-password").simulation, data.simulation), "Encrypted save preserves requested time and queued production")
|
||||
var malformed: Dictionary = manager._snapshot("probe", "test-password", data).payload
|
||||
malformed.simulation.productions[0].id = "missing-entity"
|
||||
check(manager.validate_data(malformed).contains("queued production"), "Unknown queued production is rejected before resume")
|
||||
malformed = manager._snapshot("probe", "test-password", data).payload
|
||||
malformed.simulation.requested_clock.year = -1
|
||||
check(manager.validate_data(malformed).contains("simulation clock"), "Invalid requested clock is rejected before resume")
|
||||
data.erase("simulation")
|
||||
|
||||
var incompatible_path: String = directory.path_join("incompatible.adventure")
|
||||
var incompatible = FileAccess.open_encrypted_with_pass(incompatible_path, FileAccess.WRITE, "test-password")
|
||||
incompatible.store_var({"format_version": E.neg(1), "state": {"unknown": true}}, false)
|
||||
|
||||
@@ -53,7 +53,7 @@ func invariants(world) -> void:
|
||||
check(E.eq(sum_ethics(world, id), physical.get(id, 0)), E.add("Ethics equal physical population for ", id))
|
||||
for n in world.state.ethics[id].values():
|
||||
if (E.lt(n, 0) or E.ne(n, E.floor_value(n))): check(false, "Invalid ethic amount")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
|
||||
|
||||
func run() -> void:
|
||||
var global: Node = root.get_node("Global")
|
||||
|
||||
@@ -59,7 +59,7 @@ func run() -> void:
|
||||
world.state.amount["C0.2"] = 3
|
||||
world.evolution.initialize_life_batches()
|
||||
check(E.eq(world.state.ethics["C0.2"].m, 3), "Losses reduce ethics without inventing population")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
|
||||
var restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
restored.evolution.initialize_life_batches()
|
||||
@@ -117,7 +117,7 @@ func run() -> void:
|
||||
world.evolution.initialize_life_batches()
|
||||
check(E.eq(world.state.amount["C4.2"], 2), "SMBH capacity uses stellar mass equivalent")
|
||||
check(E.eq(world.ledger.physical_amounts(world.state)["C1.9"], 1), "Unsupported civilization degrades through missing habitat families")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
|
||||
dispose(world)
|
||||
|
||||
world = fixture()
|
||||
|
||||
@@ -47,7 +47,7 @@ func run() -> void:
|
||||
var text: String = description.render(world, global, "RD")
|
||||
check((text.contains("[b]") and (not text.contains("%value"))), E.add("Localized template resolved: ", locale))
|
||||
check(text.contains("#40cc40"), E.add("Affordable cost green: ", locale))
|
||||
check(text.contains("4.0000E+10"), E.add("Lifespan substituted: ", locale))
|
||||
check(text.contains("40 000 000 000"), E.add("Lifespan substituted: ", locale))
|
||||
text = description.render(world, global, "He")
|
||||
check((text.contains("He: 20.00% / H: 80.00%") and (not text.contains("%value"))), E.add("Helium metadata and mixture: ", locale))
|
||||
text = description.render(world, global, "Shovelin", "Force")
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://8vkudj0tlnng
|
||||
@@ -94,7 +94,7 @@ func run():
|
||||
check(str(world.state.auto_star_deficits).length() < 500, "true", "fraction storage stays bounded")
|
||||
print("100 changing-composition cycles: %d ms" % (Time.get_ticks_msec() - started))
|
||||
var saves = root.get_node("SaveManager")
|
||||
check(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
|
||||
check(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), "", "long-running state saves")
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
print("Exact math: %d checks, %d failures" % [checks, failures])
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://te15r63sqhxs
|
||||
+2
-2
@@ -54,7 +54,7 @@ func run() -> void:
|
||||
check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
|
||||
var physical: Dictionary = world.ledger.physical_amounts(world.state)
|
||||
check(E.eq(E.add(E.add(physical.HP, physical.EON1), physical.RP), 40000), "Life conserves planets")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
|
||||
var restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
world.evolution.evolve_batches(world.state, 11000000)
|
||||
@@ -118,7 +118,7 @@ func run() -> void:
|
||||
world.evolution.evolve_batches(world.state, 2050)
|
||||
check(E.eq(world.state.binds.RD["C0.2"], 700), "C0.1 reaches ethical civilization without losing host")
|
||||
check(E.eq(world.state.binds.RD["C0.0"], 1000), "C0.1 degradation follows Unity")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
|
||||
check(E.eq(root.get_node("SaveManager").validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Extended life save validates")
|
||||
dispose(world)
|
||||
print(("Life: %d checks, %d failures" % [checks, failures]))
|
||||
quit((0 if E.eq(failures, 0) else 1))
|
||||
|
||||
@@ -2,6 +2,7 @@ extends SceneTree
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
|
||||
const Math = preload("res://world/owniverse/quantity_math.gd")
|
||||
const Legacy = preload("res://tests/fixtures/legacy_quantity_math.gd")
|
||||
var checks: int = 0
|
||||
var failures: int = 0
|
||||
|
||||
@@ -15,6 +16,24 @@ func check(condition: bool, message: String) -> void:
|
||||
push_error(message)
|
||||
|
||||
func run() -> void:
|
||||
var rng = RandomNumberGenerator.new()
|
||||
rng.seed = 65432
|
||||
var old_remainders: Dictionary = {}
|
||||
var new_remainders: Dictionary = {}
|
||||
for sample in 300:
|
||||
var requests: Dictionary = {}
|
||||
var weights: Dictionary = {}
|
||||
for id in ["w", "m", "b", "c", "d", "l"]:
|
||||
requests[id] = E.of(str(rng.randi_range(-10, 100)) + "." + str(rng.randi_range(0, 9)))
|
||||
weights[id] = rng.randi_range(0, 100)
|
||||
if sample % 3 == 0:
|
||||
for id in requests: requests[id] = E.multiply(requests[id], E.of("1e40"))
|
||||
var limit: Variant = E.multiply(rng.randi_range(0, 600), E.of("1e40")) if sample % 3 == 0 else rng.randi_range(0, 600)
|
||||
check(E.eq(Math.allocate_whole(requests, limit), Legacy.allocate_whole(requests, limit)), "Allocation matches legacy with limits, ties and big integers")
|
||||
var count: Variant = limit
|
||||
check(E.eq(Math.split(new_remainders, "population", count, weights), Legacy.split(old_remainders, "population", count, weights)), "Split matches legacy rounding")
|
||||
check(E.eq(new_remainders, old_remainders), "Signed remainder debt matches legacy")
|
||||
check_ledger_cache()
|
||||
var Catalog = load("res://world/owniverse/entity_catalog.gd")
|
||||
check(E.eq(Math.allocate_whole({}, 10), {}), "Empty allocation")
|
||||
check(E.eq(Math.allocate_whole({"b": 1, "a": 1}, 1), {"a": 1, "b": 0}), "Stable lexical tie")
|
||||
@@ -62,3 +81,32 @@ func run() -> void:
|
||||
for entity in catalog.entities.values(): entity.free()
|
||||
print(("Quantity and catalog: %d checks, %d failures" % [checks, failures]))
|
||||
quit((1 if failures else 0))
|
||||
|
||||
func check_ledger_cache() -> void:
|
||||
var world = load("res://world/owniverse/owniverse.gd").new()
|
||||
world.catalog.load_definitions(root.get_node("Global").OwniverseEntities)
|
||||
world.state.amount.RD = E.of("1e40")
|
||||
world.state.binds.GEM = {"RD": 7}
|
||||
check(E.eq(world.ledger.physical_amounts(world.state).RD, E.add(E.of("1e40"), 7)), "Initial cached physical amount")
|
||||
var returned: Dictionary = world.ledger.physical_amounts(world.state)
|
||||
returned.RD = 0
|
||||
check(E.gt(world.ledger.physical_amounts(world.state).RD, 0), "Caller cannot mutate cached physical totals")
|
||||
world.state.binds.GEM.RD = 11
|
||||
check(E.eq(world.ledger.physical_amounts(world.state).RD, E.add(E.of("1e40"), 11)), "Nested binding change invalidates physical cache")
|
||||
world.state.amount.RD = 2
|
||||
check(E.eq(world.ledger.physical_amounts(world.state).RD, 13), "Direct count change invalidates physical cache")
|
||||
var other: Dictionary = world.state.duplicate(true)
|
||||
other.binds.clear()
|
||||
check(E.eq(world.ledger.physical_amounts(other).RD, 2), "Alternate state has independent physical totals")
|
||||
check(E.eq(world.ledger.physical_amounts(world.state).RD, 13), "Returning to original state refreshes cache")
|
||||
var revision: int = world.catalog.index_revision
|
||||
world.catalog.rebuild_group_index()
|
||||
check(world.catalog.index_revision > revision, "Catalog rebuild invalidates cached membership")
|
||||
check(E.eq(world.ledger.physical_amounts(world.state).RD, 13), "Rebuild retains correct physical totals")
|
||||
var rules: Dictionary = root.get_node("Global").CivilizationRules.duplicate(true)
|
||||
var original_stages: int = world.catalog.civilization_layout(rules).stages.size()
|
||||
rules["C2.0"].home = ""
|
||||
world.catalog.invalidate_civilization_layout()
|
||||
check(world.catalog.civilization_layout(rules).stages.size() == original_stages - 1, "Explicit rules change rebuilds civilization layout")
|
||||
for entity in world.catalog.entities.values(): entity.free()
|
||||
world.free()
|
||||
|
||||
@@ -38,8 +38,11 @@ func equivalent(a: Variant, b: Variant) -> bool:
|
||||
for i in a.size():
|
||||
if (not equivalent(a[i], b[i])): return false
|
||||
return true
|
||||
if ((((a is float) or (a is int))) and (((b is float) or (b is int)))):
|
||||
return E.lt(E.absolute(E.subtract(E.of(a), E.of(b))), E.of("1e-6"))
|
||||
if E.numeric(a) and E.numeric(b):
|
||||
# Counts must match exactly. Fractional float remainders can differ
|
||||
# when the analytic integral replaces thousands of subtractions.
|
||||
if not E.eq(E.floor_value(a), E.floor_value(b)): return false
|
||||
return E.le(E.absolute(E.subtract(a, b)), E.of("1e-8"))
|
||||
return E.eq(a, b)
|
||||
|
||||
func run() -> void:
|
||||
@@ -75,6 +78,19 @@ func run() -> void:
|
||||
for batch in fast.state.batches.SN: total = E.add(total, batch.amount)
|
||||
check(E.eq(total, 10000), "Direct completions plus survivors conserve event count")
|
||||
dispose(fast)
|
||||
# Dense completed events must not iterate a million elapsed windows.
|
||||
# An aligned, fully completed cohort produces exactly count * lifespan.
|
||||
fast = fixture(false)
|
||||
pending = {}
|
||||
var lifespan: Variant = fast.catalog.entities.SN.lifespan
|
||||
var window: Variant = E.divide(lifespan, root.get_node("Global").batch_count) if E.gt(lifespan, 10) else lifespan
|
||||
var creation_end: Variant = E.multiply(window, 1000000)
|
||||
var dense_count: Variant = E.of("1e20")
|
||||
var integral: Variant = fast.batches.append_batch(fast.state, "SN", dense_count, 0, creation_end, pending, E.add(creation_end, lifespan))
|
||||
check(E.eq(pending.SN[0].amount, dense_count), "Million-window aggregation conserves every event")
|
||||
check(E.eq(integral, E.multiply(dense_count, lifespan)), "Million-window aggregation preserves lifetime integral")
|
||||
check(fast.state.batches.SN.is_empty(), "Completed million-window cohort leaves no transient batches")
|
||||
dispose(fast)
|
||||
# Sparse explosions: thousands of time windows but very few actual events.
|
||||
# Exact interval edges and partial final windows exercise the jump boundary.
|
||||
for count in [1, 3, 17]:
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://gtvie7p6j62x
|
||||
@@ -96,7 +96,7 @@ func run() -> void:
|
||||
check((E.eq(world.state.binds.RD.GG, 16) and E.eq(world.state.binds.RD.AB, 120)), "Planet multiplicity applied")
|
||||
check((E.eq(world.state.amount.HP, 0) and E.eq(world.state.stats.total_amount.HP, 1)), "Created planets are bound, counted once")
|
||||
var saves: Node = root.get_node("SaveManager")
|
||||
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
|
||||
restored = fixture()
|
||||
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
|
||||
world.stellar.create_planets(world.state, "RD", 37, 0, 0, E.of("0.7"))
|
||||
@@ -118,7 +118,7 @@ func run() -> void:
|
||||
check((E.eq(world.state.amount.RP, 0) and E.eq(world.state.amount["C1.3"], 0)), "Inherited planets never become free")
|
||||
world.evolution.evolve_batches(world.state, E.add(lifespan, width))
|
||||
check((E.eq(world.state.binds.RD["C1.3"], 0) and E.eq(world.state.binds.TWD["C1.3"], 4)), "No planets left at extinct parent type")
|
||||
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
|
||||
dispose(world)
|
||||
|
||||
world = fixture()
|
||||
@@ -158,7 +158,7 @@ func run() -> void:
|
||||
check((E.eq(world.state.amount.SN, 0) and E.eq(world.state.amount.LNS, 1)), "Supernova produces a remnant")
|
||||
check(((E.eq(world.state.amount.H, 8) and E.eq(world.state.amount.He, 50)) and E.eq(world.state.amount.Met, 11)), "Supernova returns table resources")
|
||||
check((not world.state.amount.has("blast")), "Supernova dispatches blast output")
|
||||
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
|
||||
dispose(world)
|
||||
|
||||
world = fixture()
|
||||
|
||||
+2
-2
@@ -51,10 +51,10 @@ func run() -> void:
|
||||
for invalid in [0, E.neg(1), NAN, INF, 1e308]:
|
||||
world.evolute(invalid)
|
||||
check(E.eq(world.state, before), "Invalid/overflow interval rejected")
|
||||
check(E.eq(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
|
||||
check(E.eq(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Fractional save is valid")
|
||||
var invalid_save: Dictionary = world.state.duplicate(true)
|
||||
invalid_save.time.year = E.of("0.25")
|
||||
check(E.ne(saves.validate_data({"format_version": 4, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
|
||||
check(E.ne(saves.validate_data({"format_version": 5, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
|
||||
dispose(world)
|
||||
|
||||
for fps in [30, 60, 144]:
|
||||
|
||||
@@ -133,7 +133,7 @@ func run() -> void:
|
||||
Parameters.advance(source, world.state, rules, E.of("1.0e12"))
|
||||
check((E.ge(world.state.trends.Integration, 0) and E.lt(world.state.trends.Integration, 1)), "Very large intervals finish with a valid trend fraction")
|
||||
check(E.ge(world.state.world_parameters.Agg, 1), "Repeated integration events cannot reduce aggression below the scale")
|
||||
var payload: Dictionary = {"format_version": 4, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
|
||||
var payload: Dictionary = {"format_version": 5, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
|
||||
var manager: Node = root.get_node("SaveManager")
|
||||
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
|
||||
var old: Dictionary = payload.duplicate(true)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
extends Control
|
||||
class_name OwniverseItem
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
|
||||
signal item_activated(item)
|
||||
|
||||
@@ -20,7 +21,7 @@ var item_type = ""
|
||||
|
||||
|
||||
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:
|
||||
description.text = text
|
||||
#description.text = str(value)
|
||||
|
||||
@@ -217,11 +217,7 @@ func produce_newborn_lifetime(target: Dictionary, id: String, count: Variant, bo
|
||||
object_years = E.maximum(0, object_years)
|
||||
for resource in products:
|
||||
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
|
||||
# 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)
|
||||
var output: Variant = E.multiply(object_years, coefficient)
|
||||
if E.contains(["H", "S"], resource):
|
||||
output = E.minimum(output, target.energy)
|
||||
var actual: Variant = world.ledger.credit(target, resource, output)
|
||||
|
||||
@@ -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.
|
||||
# Runtime depends on the number of branches, never the number of civilizations.
|
||||
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 = {}
|
||||
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 = {}
|
||||
for ethic in pop:
|
||||
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:
|
||||
var weight: Variant = (9 if colony else 1)
|
||||
if E.eq(other, ethic): weight = (960 if colony else 196)
|
||||
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()
|
||||
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:
|
||||
for ethic in pop:
|
||||
if E.le(pop[ethic], 0): continue
|
||||
var weights: Dictionary = {}
|
||||
for term in str(rules[plan.id].ethics[ethic].fall).split("^", false):
|
||||
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)
|
||||
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)
|
||||
var remaining: Dictionary = target.ethics.duplicate(true)
|
||||
var remaining: Dictionary = target.ethics.duplicate()
|
||||
var copied: Dictionary = {}
|
||||
var plans: Dictionary = {}
|
||||
var colonies: Array = []
|
||||
var collateral: Dictionary = {}
|
||||
@@ -88,6 +92,7 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
|
||||
var ids: Array = expired.keys()
|
||||
ids.sort()
|
||||
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
|
||||
var plan: Dictionary = new_plan(id, year)
|
||||
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.from = E.minimum(plan.from, part.from)
|
||||
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 successes: Dictionary = {}
|
||||
var failures: Dictionary = {}
|
||||
@@ -113,9 +120,12 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: Varian
|
||||
successes[ethic] = bounded.pro
|
||||
failures[ethic] = 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))
|
||||
fall(target, plan, rules, failures)
|
||||
world.profile_end("civilization_transitions", profile_transitions)
|
||||
if rules[id].fights:
|
||||
var profile_wars: int = world.profile_start()
|
||||
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)))
|
||||
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)
|
||||
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)
|
||||
world.profile_end("wars", profile_wars)
|
||||
emit(plan, id, triggered)
|
||||
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.
|
||||
for id in collateral:
|
||||
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)
|
||||
var plan: Dictionary = plans[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 planetary: Dictionary = {}
|
||||
for id in plans:
|
||||
if budget_timer != null and not await budget_timer.pause(): return {}
|
||||
var plan: Dictionary = plans[id]
|
||||
var products: Dictionary = {}
|
||||
for destination in plan.outputs:
|
||||
var pop: Dictionary = plan.outputs[destination]
|
||||
if rules.has(destination):
|
||||
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))
|
||||
world.profile_end("ethics_drift", profile_drift)
|
||||
if (not incoming.has(destination)): incoming[destination] = {}
|
||||
add(incoming[destination], pop)
|
||||
products[destination] = total(pop)
|
||||
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))
|
||||
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}
|
||||
|
||||
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
|
||||
for id in transaction.incoming:
|
||||
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] = {}
|
||||
target.binds[owner][id] = E.add(target.binds[owner].get(id, 0), shares[owner])
|
||||
target.amount[id] = E.subtract(target.amount[id], shares[owner])
|
||||
colonize(world, target, transaction.colonies, year, rules)
|
||||
State.reconcile(world, target, rules)
|
||||
if budget_timer != null:
|
||||
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
|
||||
for id in rules:
|
||||
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)))
|
||||
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:
|
||||
var physical: Dictionary = world.ledger.physical_amounts(target)
|
||||
static func colonize(world, target: Dictionary, colonies: Array, year: Variant, rules: Dictionary, physical: Dictionary = {}, budget_timer: RefCounted = null) -> void:
|
||||
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 = {}
|
||||
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0)
|
||||
var requests: Dictionary = {}
|
||||
for i in colonies.size(): requests[str(i)] = total(colonies[i].population)
|
||||
var granted: Dictionary = QuantityMath.allocate_whole(requests, total(stock))
|
||||
for i in colonies.size():
|
||||
if budget_timer != null and not await budget_timer.pause(): return
|
||||
var colony: Dictionary = colonies[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)
|
||||
@@ -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] = {}
|
||||
add(target.ethics[colony.stage], population)
|
||||
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
|
||||
world.profile_end("colonization", profile_colonies)
|
||||
|
||||
@@ -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:
|
||||
if physical.is_empty():
|
||||
physical = world.ledger.physical_amounts(target)
|
||||
var layout: Dictionary = world.catalog.civilization_layout(rules)
|
||||
var capacity: Dictionary = {}
|
||||
for id in physical:
|
||||
var entity = world.catalog.entities[id]
|
||||
for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]:
|
||||
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])
|
||||
for id in layout.star_homes:
|
||||
for home in layout.star_homes[id]:
|
||||
capacity[home] = E.add(capacity.get(home, 0), physical.get(id, 0))
|
||||
capacity["SMBH"] = E.maximum(0, target.amount.get("SMBH", 0))
|
||||
for held : Dictionary in target.binds.values():
|
||||
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
|
||||
# advanced populations priority and lets a demotion cascade in the same cycle.
|
||||
static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: Variant) -> void:
|
||||
var profile_started: int = world.profile_start()
|
||||
detach_space_civilizations(target, rules)
|
||||
var physical: Dictionary = world.ledger.physical_amounts(target)
|
||||
reconcile(world, target, rules, physical)
|
||||
var budget: Dictionary = stellar_budget(world, target, rules, physical)
|
||||
var remaining: Dictionary = budget.capacity.duplicate()
|
||||
var remaining_homes: Dictionary = budget.habitats.capacity.duplicate()
|
||||
var stages: Array = []
|
||||
for id in rules:
|
||||
if is_space_civilization(id, rules):
|
||||
stages.append(id)
|
||||
stages.sort()
|
||||
var stages: Array = world.catalog.civilization_layout(rules).stages
|
||||
var changed: bool = false
|
||||
for index in range(E.subtract(stages.size(), 1), E.neg(1), E.neg(1)):
|
||||
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 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 requirement: Dictionary = budget.habitats.requirements.get(id, {})
|
||||
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)
|
||||
if E.le(excess, 0):
|
||||
continue
|
||||
changed = true
|
||||
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))
|
||||
target.amount[id] = supported
|
||||
@@ -161,79 +160,71 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
|
||||
target.binds[owner] = {}
|
||||
target.binds[owner][previous] = E.add(target.binds[owner].get(previous, 0), amount)
|
||||
target.amount[previous] = E.subtract(target.amount[previous], amount)
|
||||
physical = world.ledger.physical_amounts(target)
|
||||
reconcile(world, target, rules, physical)
|
||||
if changed:
|
||||
physical = world.ledger.physical_amounts(target)
|
||||
reconcile(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;
|
||||
# intervening stages require ten times more units of the preceding structure.
|
||||
static func habitat_budget(world, target: Dictionary, rules: Dictionary, physical: Dictionary = {}) -> Dictionary:
|
||||
if physical.is_empty():
|
||||
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 = {}
|
||||
for id in world.catalog.entities:
|
||||
if E.ne(world.catalog.entities[id].claim, ""):
|
||||
structures.append(id)
|
||||
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}
|
||||
for id in structures: capacity[id] = physical.get(id, 0)
|
||||
var requirements: Dictionary = layout.requirements
|
||||
var anchors: Dictionary = layout.anchors
|
||||
var requested: Dictionary = {}
|
||||
for id in requirements:
|
||||
var req: Dictionary = requirements[id]
|
||||
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}
|
||||
|
||||
# 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:
|
||||
var budget: Dictionary = habitat_budget(world, target, rules, physical)
|
||||
var used: Dictionary = {}
|
||||
var profile_started: int = world.profile_start()
|
||||
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 = {}
|
||||
var stages: Array = budget.anchors.keys()
|
||||
stages.sort()
|
||||
stages.reverse()
|
||||
for id in stages:
|
||||
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
|
||||
for index in range(layout.stages.size() - 1, -1, -1):
|
||||
var id: String = layout.stages[index]
|
||||
target.civilization_homes[id] = stage_homes(world, target, id, layout, capacity)
|
||||
world.profile_end("assign_homes", profile_started)
|
||||
|
||||
|
||||
static func effective_span(world, target: Dictionary, id: String, rules: Dictionary) -> Variant:
|
||||
if (not rules.has(id)): return world.catalog.entities[id].lifespan
|
||||
var base: Variant = rules[id].span
|
||||
if (not is_space_civilization(id, rules)): return base
|
||||
assign_homes(world, target, rules)
|
||||
var population: Variant = target.amount.get(id, 0)
|
||||
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
|
||||
for home in target.civilization_homes.get(id, {}):
|
||||
weighted = E.add(weighted, E.multiply(target.civilization_homes[id][home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1))))
|
||||
for home in homes:
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
extends RefCounted
|
||||
const E = preload("res://world/owniverse/math/exact.gd")
|
||||
## Definitions and their indexes only: no population, batches or UI state.
|
||||
## 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 unlockable_entities: Array = []
|
||||
var _group_members: Dictionary = {}
|
||||
var index_revision: int = 0
|
||||
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.
|
||||
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.
|
||||
func rebuild_group_index() -> void:
|
||||
index_revision += 1
|
||||
_civilization_layout.clear()
|
||||
_group_members.clear()
|
||||
for id in entities:
|
||||
var group: String = entities[id].group_as
|
||||
@@ -45,3 +51,47 @@ func is_group(id: String) -> bool:
|
||||
func members(id: String) -> Array:
|
||||
if not is_group(id): return [id]
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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.
|
||||
# Fractional frame updates cannot change capacity or populations.
|
||||
var processed_year: Variant = SimulationTime.processed_year(datetime)
|
||||
if E.gt(processed_year, world.state.time.year):
|
||||
var profile_initialize: int = world.profile_start()
|
||||
initialize_life_batches()
|
||||
world.profile_end("initialize", profile_initialize)
|
||||
var previous: Dictionary = world.state
|
||||
var next: Dictionary = previous.duplicate_deep()
|
||||
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)
|
||||
next.distribution = distribution
|
||||
|
||||
var profile_production: int = world.profile_start()
|
||||
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.clamp_auto_resources(next)
|
||||
var profile_trends: int = world.profile_start()
|
||||
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}
|
||||
|
||||
# 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)
|
||||
|
||||
|
||||
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.
|
||||
var completed: Dictionary = {}
|
||||
while evolve_batch_generation(target, to_year, completed):
|
||||
pass
|
||||
while await evolve_batch_generation(target, to_year, completed, budget):
|
||||
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
|
||||
# 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)
|
||||
completed.clear()
|
||||
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
|
||||
var fractions: 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 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:
|
||||
if budget != null and not await budget.pause(): return false
|
||||
var total: Variant = 0
|
||||
for part : Dictionary in expired[id]:
|
||||
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 part : Dictionary in expired[id]:
|
||||
for successor in part.products:
|
||||
if budget != null and not await budget.pause(): return false
|
||||
var amount: Variant = part.products[successor]
|
||||
if E.eq(successor, "blast"):
|
||||
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)
|
||||
else:
|
||||
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 integral: Variant = world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff)
|
||||
world.automatic.produce_newborn_lifetime(target, successor, amount, part.from, part.to, to_year, before_batches, integral)
|
||||
var profile_newborn: int = world.profile_start()
|
||||
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)
|
||||
world.ledger.accept_transfers(target, incoming)
|
||||
world.ledger.bind_new_owners(target, new_owners)
|
||||
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)
|
||||
CivilizationState.enforce_capacity(world, target, Global.CivilizationRules, to_year)
|
||||
return true
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
uid://hu57pwad02sg
|
||||
@@ -8,7 +8,7 @@ const Parameters = preload("res://world/owniverse/world_parameters.gd")
|
||||
|
||||
static func number(value, digits: int = 0) -> String:
|
||||
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:
|
||||
return E.add(E.add(E.add(E.add("[color=", color), "]"), text), "[/color]")
|
||||
|
||||
@@ -1,101 +1,43 @@
|
||||
# Числа симуляции
|
||||
|
||||
`exact.gd` сохраняет единый API `E` для симуляции и интерфейса. Целые числа,
|
||||
помещающиеся в `int64`, остаются обычными `int`. Остальные значения хранятся
|
||||
в `exact_value.gd`:
|
||||
Целые значения в диапазоне int64 хранятся как `int`. Остальные числа имеют вид
|
||||
`polarity × (sum(whole[i] × 10^(9i)) + fraction)`:
|
||||
`whole` — динамический массив целых блоков по основанию 10^9,
|
||||
`fraction` — float от 0 включительно до 1 исключительно. Числа после создания
|
||||
не изменяются. Ограничения E27 в хранении нет; размер массива растёт по необходимости.
|
||||
|
||||
```
|
||||
polarity × (high × 10^14 + low + fraction)
|
||||
```
|
||||
Используйте API `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 и для очень больших чисел возвращает бесконечность.
|
||||
Сравнение через E сохраняет отличие конечного большого числа от INF.
|
||||
|
||||
`high` и `low` — целые блоки от 0 до 10^14−1, `fraction` — `float` от 0 до 1,
|
||||
`polarity` — знак. Значения неизменяемы после создания. Диапазон включает весь
|
||||
порядок E27: модуль целой части меньше 10^28. Каждая единица целой части
|
||||
сохраняется, в том числе при добавлении и вычитании единицы около 10^27.
|
||||
Выход за диапазон проверяется утверждениями; насыщения или обрезания запасов нет.
|
||||
Целые операции выполняются точно. Умножение и деление используют безопасные
|
||||
разряды из `big_integer.gd`; нативная арифметика применяется при отсутствии
|
||||
переполнения int64. `E.mul_div(count, numerator, denominator)` сохраняет точное
|
||||
целое частное, не превращая заранее отношение в приближённый коэффициент.
|
||||
`E.floor_sum` суммирует округлённые линейные квоты без перебора каждого окна.
|
||||
|
||||
Числители и знаменатели в состоянии больше не хранятся. НОД при обычных
|
||||
арифметических операциях не вычисляется; обход состояния для ограничения дробей
|
||||
больше не нужен. `bounded` и `bound_state` оставлены как совместимые пустые операции.
|
||||
Дробный остаток приблизительный: при умножении и делении он преобразуется
|
||||
в десятичную дробь с 16 значащими цифрами. При сложении двух ненулевых остатков
|
||||
перенос около целой границы стабилизируется с допуском 10^-12.
|
||||
Умножение большого количества на float усиливает ошибку коэффициента;
|
||||
для известных точных отношений используйте mul_div. Целые распределения
|
||||
и сохранение числа объектов проверяются строго, без допуска.
|
||||
|
||||
## Операции
|
||||
Возраст сохраняет точную целую часть. Только дробная часть переводится в
|
||||
наносекундные тики, дополнительный остаток времени переносится между кадрами.
|
||||
Синхронный диагностический API evolute отклоняет шаги от 1e27 игровых лет:
|
||||
это защита от непрактичного интервала, а не предел хранимого возраста.
|
||||
Покадровый API ограничивает одну транзакцию обычным шагом 1/60 секунды;
|
||||
остальное запрошенное время остаётся в очереди.
|
||||
|
||||
Сложение, вычитание и сравнение работают непосредственно с блоками.
|
||||
Деление int64 и объединённое умножение/деление при безопасном произведении
|
||||
используют нативную арифметику. Перевод больших целых между блоками и временными
|
||||
разрядами, извлечение коэффициента и сборка результата обходятся без строк.
|
||||
Дробный коэффициент нормализуется численно до 16 значащих цифр; последние биты
|
||||
float могут отличаться от результата прежнего строкового преобразования.
|
||||
Для умножения и деления `big_integer.gd` используется как временный вычислитель
|
||||
с безопасными разрядами по основанию 10^9. Результат сразу возвращается в
|
||||
фиксированную структуру. Размер временных дробей определяется входными float,
|
||||
а не растёт от шага к шагу.
|
||||
Формат сохранения — **5**, версия состояния — **0.3.0**.
|
||||
Большие целые и числа с дробью кодируются как
|
||||
`{"$exact": [[blocks], "float bytes in hex", sign]}`.
|
||||
Байты остатка сохраняются без потери точности JSON. Миграции старых форматов нет.
|
||||
|
||||
`E.mul_div(a, b, denominator)` вычисляет отношение за одну операцию. Это нужно
|
||||
для распределений: произведение двух количеств может превышать E27, хотя итог
|
||||
деления укладывается в диапазон. Промежуточное произведение не становится
|
||||
значением симуляции. Целое частное вычисляется до перевода остатка в float.
|
||||
|
||||
```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 циклов симуляции.
|
||||
Проверки: `test_exact_math.gd`, `test_dynamic_math.gd`, `test_quantity_catalog.gd`,
|
||||
`test_time.gd` и `test_frame_evolution.gd`. Независимый целочисленный эталон
|
||||
покрывает числа до 220 десятичных цифр; отдельные проверки — до E1000,
|
||||
границы int64, доли, сериализацию, распределения и продолжение расчёта.
|
||||
|
||||
@@ -102,3 +102,26 @@ static func gcd(a: Array, b: Array) -> Array:
|
||||
a = b
|
||||
b = remainder
|
||||
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
@@ -1,27 +1,45 @@
|
||||
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 Value = preload("res://world/owniverse/math/exact_value.gd")
|
||||
const SMALL: int = 1000000000
|
||||
const MAX_INT: int = 9223372036854775807
|
||||
const MIN_INT: int = -9223372036854775807 - 1
|
||||
const BLOCK: int = 100000000000000 # 10^14
|
||||
const LIMIT: Array = [0, 0, 0, 10] # 10^28, exclusive
|
||||
const MAX_INT_LIMBS: Array = [854775807, 223372036, 9]
|
||||
const MIN_INT_LIMBS: Array = [854775808, 223372036, 9]
|
||||
|
||||
static func max_value():
|
||||
return make([999999999, 999999999, 9999])
|
||||
static func _int_limbs(value: int) -> Array:
|
||||
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):
|
||||
assert(d != [0], "Zero denominator")
|
||||
if n.size() <= 2 and d.size() <= 2:
|
||||
var numerator: int = n[0] + (n[1] * SMALL if n.size() == 2 else 0)
|
||||
var denominator: int = d[0] + (d[1] * SMALL if d.size() == 2 else 0)
|
||||
var whole: int = numerator / denominator
|
||||
var fraction: float = minf(float(numerator % denominator) / float(denominator), 1.0 - pow(2.0, -53))
|
||||
return _compose(whole / BLOCK, whole % BLOCK, fraction, polarity)
|
||||
var qr: Array = Big.divmod(n, d)
|
||||
var fraction: float = _fraction_ratio(qr[1], d)
|
||||
return _from_whole(qr[0], fraction, polarity)
|
||||
static func _compose(whole: Array, fraction: float = 0.0, polarity: int = 1):
|
||||
whole = Big.trim(whole.duplicate())
|
||||
if fraction >= 1.0:
|
||||
var carry: float = floorf(fraction)
|
||||
whole = Big.add(whole, Big.parse("%.0f" % carry))
|
||||
fraction -= carry
|
||||
elif fraction < 0.0:
|
||||
assert(Big.compare(whole, [0]) > 0, "Negative magnitude")
|
||||
whole = Big.subtract(whole, [1])
|
||||
fraction += 1.0
|
||||
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:
|
||||
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))
|
||||
return minf(fraction, 1.0 - pow(2.0, -53))
|
||||
|
||||
static func _from_whole(n: Array, fraction: float, polarity: int):
|
||||
assert(Big.compare(n, LIMIT) < 0, "Number exceeds E27 range")
|
||||
var middle: int = n[1] if n.size() > 1 else 0
|
||||
var low: int = n[0] + (middle % 100000) * SMALL
|
||||
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 make(n: Array, d: Array = [1], polarity: int = 1):
|
||||
assert(d != [0], "Zero denominator")
|
||||
var qr: Array = Big.divmod(n, d)
|
||||
return _compose(qr[0], _fraction_ratio(qr[1], d), polarity)
|
||||
|
||||
static func _blocks(value) -> Array:
|
||||
value = of(value)
|
||||
if value is Value: return [value.high, value.low, value.fraction, value.polarity]
|
||||
# Signed division also handles MIN_INT without overflowing abs().
|
||||
return [absi(value / BLOCK), absi(value % BLOCK), 0.0, -1 if value < 0 else 1]
|
||||
if value is Value: return [value.whole, value.fraction, value.polarity]
|
||||
return [_int_limbs(value), 0.0, -1 if value < 0 else 1]
|
||||
|
||||
static func of(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 value == floorf(value) and absf(value) < 1e18: return int(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()
|
||||
if text == "": return 0
|
||||
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)
|
||||
|
||||
static func parts(value) -> Array:
|
||||
value = of(value)
|
||||
if value is int: return [_int_limbs(value), [1], -1 if value < 0 else 1]
|
||||
var whole: Array = _whole_limbs(value.high, value.low)
|
||||
if value.fraction == 0.0: return [whole, [1], value.polarity]
|
||||
# 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)
|
||||
var x: Array = _blocks(value)
|
||||
if x[1] == 0.0: return [x[0], [1], x[2]]
|
||||
var exponent: int = maxi(-323, int(floor(log(x[1]) / log(10.0))))
|
||||
var mantissa: int = roundi((x[1] / pow(10.0, exponent)) * 1000000000000000.0)
|
||||
exponent -= 15
|
||||
while mantissa % 10 == 0 and mantissa > 0:
|
||||
mantissa /= 10
|
||||
exponent += 1
|
||||
var n: Array = _int_limbs(mantissa)
|
||||
var d: Array = []
|
||||
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]
|
||||
var denominator: Array = Big.parse("1" + "0".repeat(-exponent))
|
||||
return [Big.add(Big.multiply(x[0], denominator), _int_limbs(mantissa)), denominator, x[2]]
|
||||
|
||||
static func numeric(value) -> bool:
|
||||
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:
|
||||
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:
|
||||
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 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 y: float = approx(b)
|
||||
return 0 if x == y else (1 if x > y else -1)
|
||||
var x: Array = _blocks(a)
|
||||
var y: Array = _blocks(b)
|
||||
if x[3] != y[3]: return 1 if x[3] > y[3] else -1
|
||||
for i in 3:
|
||||
if x[i] != y[i]: return x[3] * (1 if x[i] > y[i] else -1)
|
||||
return 0
|
||||
if x[2] != y[2]: return 1 if x[2] > y[2] else -1
|
||||
var magnitude: int = Big.compare(x[0], y[0])
|
||||
if magnitude == 0 and x[1] != y[1]: magnitude = 1 if x[1] > y[1] else -1
|
||||
return magnitude * x[2]
|
||||
|
||||
static func eq(a, b) -> bool:
|
||||
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 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 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 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)
|
||||
var x: Array = _blocks(a)
|
||||
var y: Array = _blocks(b)
|
||||
if x[3] == y[3]:
|
||||
var fraction: float = x[2] + y[2]
|
||||
# Repeated fractional accumulation may land just below an integer.
|
||||
# This tolerance only applies to the fractional carry, never whole blocks.
|
||||
if x[2] > 0.0 and y[2] > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12:
|
||||
fraction = roundf(fraction)
|
||||
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 x[2] == y[2]:
|
||||
var fraction: float = x[1] + y[1]
|
||||
if x[1] > 0.0 and y[1] > 0.0 and fraction > 0.5 and absf(fraction - roundf(fraction)) <= 1e-12: fraction = roundf(fraction)
|
||||
return _compose(Big.add(x[0], y[0]), fraction, x[2])
|
||||
var order: int = Big.compare(x[0], y[0])
|
||||
if order == 0 and x[1] != y[1]: order = 1 if x[1] > y[1] else -1
|
||||
if order == 0: return 0
|
||||
if order < 0:
|
||||
var tmp: Array = x
|
||||
var temporary: Array = x
|
||||
x = y
|
||||
y = tmp
|
||||
return _compose(x[0] - y[0], x[1] - y[1], x[2] - y[2], x[3])
|
||||
y = temporary
|
||||
return _compose(Big.subtract(x[0], y[0]), x[1] - y[1], x[2])
|
||||
|
||||
static func subtract(a, b):
|
||||
if not numeric(a) or not numeric(b): return a - b
|
||||
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 not finite(a) or not finite(b): return approx(a) - approx(b)
|
||||
var y: Array = _blocks(b)
|
||||
return add(a, _compose(y[0], y[1], y[2], -y[3]))
|
||||
return add(a, neg(b))
|
||||
|
||||
static func multiply(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")
|
||||
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):
|
||||
if not numeric(a) or not numeric(b): return a / 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 whole: int = numerator / denominator
|
||||
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 y: Array = parts(b)
|
||||
assert(y[0] != [0], "Division by zero")
|
||||
@@ -266,7 +249,7 @@ static func divide(a, b):
|
||||
static func floor_value(a):
|
||||
if not finite(a) or a is int: return 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 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 integer(a) -> int:
|
||||
var result = truncate(of(a))
|
||||
assert(result is int, "Value does not fit native int64")
|
||||
return result
|
||||
if result is int: 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.
|
||||
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 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):
|
||||
# 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 result = 1
|
||||
while exponent:
|
||||
if exponent % 2: result = multiply(result, a)
|
||||
exponent /= 2
|
||||
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)))
|
||||
|
||||
static func coefficient(value: float):
|
||||
@@ -299,8 +291,24 @@ static func coefficient(value: float):
|
||||
if not is_finite(value): return value
|
||||
return of(String.num(value, 12))
|
||||
|
||||
static func square_root(a): return coefficient(sqrt(approx(a)))
|
||||
static func logarithm(a): return coefficient(log(approx(a)))
|
||||
static func square_root(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:
|
||||
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
|
||||
return fixed(mantissa, digits) + ("E+%d" if exponent >= 0 else "E%d") % exponent
|
||||
|
||||
static func grouped(value, digits: int = 0) -> String:
|
||||
if eq(value, 0): return ""
|
||||
if ge(absolute(value), 1000000000): return scientific(value, digits)
|
||||
static func grouped(value, digits: int = 0, compact: bool = true) -> String:
|
||||
if eq(value, 0): return "" if compact else fixed(0, digits)
|
||||
if compact and ge(absolute(value), 1000000000): return scientific(value, digits)
|
||||
var text: String = fixed(absolute(value), digits)
|
||||
var parts_text: PackedStringArray = text.split(".")
|
||||
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 "")
|
||||
|
||||
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()
|
||||
bytes.resize(8)
|
||||
bytes.encode_double(0, value.fraction)
|
||||
return {"$fixed": [str(value.high), str(value.low), bytes.hex_encode(), value.polarity]}
|
||||
if value is int and (value > 9007199254740991 or value < -9007199254740991):
|
||||
return {"$exact": str(value)}
|
||||
bytes.encode_double(0, blocks[1])
|
||||
return {"$exact": [blocks[0], bytes.hex_encode(), blocks[2]]}
|
||||
if value is Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for k in value: result[k] = encode(value[k])
|
||||
for key in value: result[key] = encode(value[key])
|
||||
return result
|
||||
if value is Array:
|
||||
var result: Array = []
|
||||
@@ -369,12 +376,11 @@ static func encode(value):
|
||||
|
||||
static func decode(value):
|
||||
if value is Dictionary:
|
||||
if value.size() == 1 and value.has("$fixed"):
|
||||
var fields: Array = value["$fixed"]
|
||||
return _compose(int(fields[0]), int(fields[1]), fields[2].hex_decode().decode_double(0), int(fields[3]))
|
||||
if value.size() == 1 and value.has("$exact"): return of(value["$exact"])
|
||||
if value.size() == 1 and value.has("$exact"):
|
||||
var fields: Array = value["$exact"]
|
||||
return _compose(fields[0], fields[1].hex_decode().decode_double(0), int(fields[2]))
|
||||
var result: Dictionary = {}
|
||||
for k in value: result[k] = decode(value[k])
|
||||
for key in value: result[key] = decode(value[key])
|
||||
return result
|
||||
if value is Array:
|
||||
var result: Array = []
|
||||
@@ -385,30 +391,19 @@ static func decode(value):
|
||||
static func valid_encoded(value) -> bool:
|
||||
if value is float: return is_finite(value) and absf(value) < 1e28
|
||||
if value is Dictionary:
|
||||
if value.has("$fixed"):
|
||||
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("$fixed"): return false
|
||||
if value.has("$exact"):
|
||||
if value.size() != 1 or not value["$exact"] is String: return false
|
||||
var text: String = value["$exact"]
|
||||
if text.length() > 29: return false
|
||||
var terms: PackedStringArray = text.split("/")
|
||||
if terms.size() != 1: return false
|
||||
var n: String = terms[0].trim_prefix("-")
|
||||
if n.is_empty(): return false
|
||||
for ch in n:
|
||||
if ch < "0" or ch > "9": return false
|
||||
if Big.compare(Big.parse(n), LIMIT) >= 0: return false
|
||||
return true
|
||||
for k in value:
|
||||
if not valid_encoded(value[k]): return false
|
||||
if value.size() != 1 or not value["$exact"] is Array: return false
|
||||
var fields: Array = value["$exact"]
|
||||
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
|
||||
if fields[0].is_empty() or (fields[0].size() > 1 and fields[0].back() == 0): return false
|
||||
if fields[1].length() != 16 or not fields[1].is_valid_hex_number(): return false
|
||||
for limb in fields[0]:
|
||||
if not limb is int or limb < 0 or limb >= SMALL: return false
|
||||
var fraction: float = fields[1].hex_decode().decode_double(0)
|
||||
return is_finite(fraction) and fraction >= 0.0 and fraction < 1.0
|
||||
for key in value:
|
||||
if not valid_encoded(value[key]): return false
|
||||
if value is Array:
|
||||
for item in value:
|
||||
if not valid_encoded(item): return false
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
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 high: int = 0
|
||||
var low: int = 0
|
||||
var whole: Array = [0]
|
||||
var fraction: float = 0.0
|
||||
|
||||
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 ""
|
||||
return ("-" if polarity < 0 else "") + whole + tail
|
||||
return ("-" if polarity < 0 else "") + whole_text + tail
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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)):
|
||||
return NAN
|
||||
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)
|
||||
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 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:
|
||||
var start: Variant = existing.get("created_from", E.neg(INF))
|
||||
if (not batches_by_start.has(str(start))):
|
||||
batches_by_start[str(start)] = []
|
||||
batches_by_start[str(start)].append(existing)
|
||||
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
|
||||
# next integer. Skip expired empty windows without changing the span
|
||||
# passed to the next generation or the lifetime production integral.
|
||||
|
||||
@@ -3,7 +3,16 @@ const E = preload("res://world/owniverse/math/exact.gd")
|
||||
const CivilizationState = preload("res://world/owniverse/civilization_state.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:
|
||||
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 = {}
|
||||
for id in world.catalog.entities:
|
||||
if (not world.catalog.is_group(id)):
|
||||
@@ -12,7 +21,12 @@ func physical_amounts(source: Dictionary) -> Dictionary:
|
||||
for id in bindings:
|
||||
if amounts.has(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:
|
||||
|
||||
@@ -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 environment = preload("res://world/owniverse/universe_environment.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 = {
|
||||
'version': SimulationTime.STATE_VERSION,
|
||||
@@ -44,10 +57,78 @@ var state: Dictionary = {
|
||||
'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 :
|
||||
get:
|
||||
return state.speed
|
||||
return _requested_speed if _requested_speed != 0 else state.speed
|
||||
set(value):
|
||||
if frame_busy:
|
||||
_requested_speed = clampi(value, 1, Global.SPEED_LIMIT)
|
||||
return
|
||||
state.speed = clampi(value, 1, Global.SPEED_LIMIT)
|
||||
state.speed_in_year = E.power(10, E.subtract(state.speed, 1))
|
||||
|
||||
|
||||
@@ -12,24 +12,32 @@ static func accumulate_whole(target: Dictionary, field: String, key: String, val
|
||||
|
||||
|
||||
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 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 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)
|
||||
for i in ids.size():
|
||||
var expected: Variant = E.mul_div(result[ids[i]], available, total)
|
||||
result[ids[i]] = E.floor_value(expected)
|
||||
assigned = E.add(assigned, result[ids[i]])
|
||||
rests.append(E.subtract(expected, result[ids[i]]))
|
||||
order.append(i)
|
||||
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, available): break
|
||||
if E.lt(result[ids[i]], E.floor_value(requests[ids[i]])):
|
||||
result[ids[i]] = E.add(result[ids[i]], 1)
|
||||
assigned = E.add(assigned, 1)
|
||||
return result
|
||||
|
||||
@@ -74,25 +82,32 @@ static func split(remainders: Dictionary, key: String, count: Variant, weights:
|
||||
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 ids: Array = weights.keys()
|
||||
var expected: Array = []
|
||||
var remainder_keys: Array = []
|
||||
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]))
|
||||
for id in ids:
|
||||
var remainder_key: String = key + ":" + str(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 rests: 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:
|
||||
for i in ids.size():
|
||||
var normalized: Variant = E.mul_div(E.maximum(0, expected[i]), count, positive_sum) if E.gt(positive_sum, 0) else 0
|
||||
result[ids[i]] = E.floor_value(normalized)
|
||||
assigned = E.add(assigned, result[ids[i]])
|
||||
rests.append(E.subtract(normalized, result[ids[i]]))
|
||||
order.append(i)
|
||||
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
|
||||
result[entry.id] = E.add(result[entry.id], 1)
|
||||
result[ids[i]] = E.add(result[ids[i]], 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)
|
||||
for i in ids.size():
|
||||
remainders[remainder_keys[i]] = E.clamp_value(E.subtract(expected[i], result[ids[i]]), E.neg(1), 1)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,18 @@ func stellar_environment(source: Dictionary) -> Dictionary:
|
||||
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")))
|
||||
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}
|
||||
|
||||
|
||||
|
||||
@@ -65,8 +65,9 @@ static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary,
|
||||
for n in population.values(): count = E.add(count, n)
|
||||
if E.gt(count, 0):
|
||||
for ethic in population:
|
||||
var proportion: Variant = E.divide(population[ethic], count)
|
||||
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)
|
||||
for i in TRENDS.size():
|
||||
var name: String = TRENDS[i]
|
||||
@@ -84,7 +85,14 @@ static func _integration(target: Dictionary, events: Variant) -> void:
|
||||
var rng = RandomNumberGenerator.new()
|
||||
rng.seed = 76123
|
||||
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]
|
||||
if E.le(events, 256):
|
||||
for i in E.integer(events):
|
||||
|
||||
Reference in New Issue
Block a user