Add exact arithmetic implementation and tests

- Introduced exact arithmetic with support for large integers and fractions in `exact.gd`, `big_integer.gd`, and `exact_value.gd`.
- Implemented a comprehensive test suite in `test_exact_math.gd` to validate arithmetic operations against expected results.
- Added a README in `math` directory to document the exact arithmetic system and its usage.
- Ensured that all arithmetic operations maintain precision without floating-point errors, using a custom representation for large numbers.
- Included methods for encoding and decoding exact values for serialization.
This commit is contained in:
Kirill
2026-09-30 08:08:27 +03:00
parent f2111a376a
commit fb8a3f5658
58 changed files with 5139 additions and 2162 deletions
+6 -25
View File
@@ -1,4 +1,5 @@
extends Node
const E = preload("res://world/owniverse/math/exact.gd")
const SPEED_LIMIT: int = 6
@@ -57,37 +58,15 @@ func get_description(item_id: String, items: Array = []) -> String:
return render_template(row, variables)
func format_number(value: float, digits: int = 0) -> String:
if value == 0: return ""
# Keep ordinary quantities readable, grouping thousands with spaces.
# Scientific notation is reserved for values whose fixed form would be
# unwieldy in the UI.
if absf(value) < 1_000_000_000.0:
var fixed: String = "%.*f" % [digits, value]
var parts: PackedStringArray = fixed.split(".", true)
var integer: String = parts[0]
var sign_prefix: String = ""
if integer.begins_with("-") or integer.begins_with("+"):
sign_prefix = integer.left(1)
integer = integer.substr(1)
var grouped: String = ""
while integer.length() > 3:
grouped = " " + integer.right(3) + grouped
integer = integer.left(integer.length() - 3)
grouped = sign_prefix + integer + grouped
return grouped + ("." + parts[1] if parts.size() > 1 else "")
var exp_value = int(floor(log(abs(value)) / log(10.0)))
var mantissa = value / pow(10.0, exp_value)
return "%.*f" % [digits, mantissa] + "E%+d" % exp_value
func format_number(value, digits: int = 0) -> String:
return E.grouped(value, digits)
func _ready() -> void:
CivilizationRules.clear()
for row: Dictionary in _read_from_csv("res://data/civilization_rules.txt").values():
if not CivilizationRules.has(row.stage):
CivilizationRules[row.stage] = {"home": row.home, "span": float(row.span), "stars": float(row.stars), "ethics": {},
CivilizationRules[row.stage] = {"home": row.home, "span": E.of(row.span), "stars": E.of(row.stars), "ethics": {},
"colonizes": row.colonizes == "1", "fights": row.fights == "1", "diplomacy": row.diplomacy == "1", "worships": row.worships == "1"}
CivilizationRules[row.stage].ethics[row.ethic] = row
var stages: Array = CivilizationRules.keys()
@@ -107,6 +86,8 @@ func _ready() -> void:
Templates["en"] = _read_from_csv("res://data/templates.en.txt")
Templates["ru"] = _read_from_csv("res://data/templates.ru.txt")
#Templates = OwniverseTemplates.get_data()
func _read_from_csv(file_path: String) -> Dictionary:
if not FileAccess.file_exists(file_path):
+130 -131
View File
@@ -1,18 +1,19 @@
# SaveManager.gd (добавить в AutoLoad)
extends Node
const E = preload("res://world/owniverse/math/exact.gd")
# ~/Library/Application Support/Godot/app_userdata/Owniverse Adventure/savegame.cfg
const SETTING_SECTION := "Settings"
const SETTING_SECTION = "Settings"
const SAVE_PATH = "user://settings.cfg"
const FORMAT_VERSION: int = 2
const FORMAT_VERSION : int = 3
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
const STATE_VERSION : String = SimulationTime.STATE_VERSION
var last_error: String = ""
var blocked_saves: Dictionary = {}
var save_directory: String = "user://"
signal save_completed(filename: String, success: bool, error: String)
signal save_completed ( filename : String , success : bool , error : String )
signal saves_drained
# Queue ownership stays on the main thread. The worker only sees its snapshot.
@@ -21,30 +22,28 @@ var _active_filename: String = ""
var _pending_saves: Dictionary = {}
func _process(_delta: float) -> void:
if _save_thread != null and not _save_thread.is_alive():
func _process(_delta: Variant) -> void:
if (E.ne(_save_thread, null) and (not _save_thread.is_alive())):
_finish_save()
func is_saving() -> bool:
return _save_thread != null or not _pending_saves.is_empty()
return (E.ne(_save_thread, null) or (not _pending_saves.is_empty()))
func _snapshot(filename: String, password: String, data: Dictionary) -> Dictionary:
var payload: Dictionary = data.duplicate(true)
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(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()}
## True means queued, not written. Completion is reported by save_completed.
func request_save(filename: String, password: String, data: Dictionary) -> bool:
if blocked_saves.has(filename):
return _fail("Save blocked to preserve an unreadable save: " + filename)
return _fail(E.add("Save blocked to preserve an unreadable save: ", filename))
_pending_saves[filename] = _snapshot(filename, password, data)
if _save_thread == null:
if E.eq(_save_thread, null):
_start_save()
return true
@@ -57,12 +56,12 @@ func _start_save() -> void:
_pending_saves.erase(_active_filename)
_save_thread = Thread.new()
var error: Error = _save_thread.start(_write_snapshot.bind(job))
if error != OK:
if E.ne(error, OK):
_save_thread = null
_fail("Cannot start save worker: " + error_string(error))
_fail(E.add("Cannot start save worker: ", error_string(error)))
save_completed.emit(_active_filename, false, last_error)
_start_save()
if not is_saving(): saves_drained.emit()
if (not is_saving()): saves_drained.emit()
func _finish_save() -> void:
@@ -70,17 +69,17 @@ func _finish_save() -> void:
var filename: String = _active_filename
_save_thread = null
last_error = error
if error != "": push_error(error)
save_completed.emit(filename, error == "", error)
if _save_thread == null: _start_save()
if not is_saving(): saves_drained.emit()
if E.ne(error, ""): push_error(error)
save_completed.emit(filename, E.eq(error, ""), error)
if E.eq(_save_thread, null): _start_save()
if (not is_saving()): saves_drained.emit()
## Used for application suspension and teardown, when frames may stop arriving.
func flush_saves() -> void:
while is_saving():
if _save_thread == null: _start_save()
if _save_thread != null: _finish_save()
if E.eq(_save_thread, null): _start_save()
if E.ne(_save_thread, null): _finish_save()
func _exit_tree() -> void:
@@ -99,44 +98,44 @@ func save_data(filename: String, password: String, data: Dictionary) -> bool:
flush_saves()
last_error = ""
if blocked_saves.has(filename):
return _fail("Save blocked to preserve an unreadable save: " + filename)
return _fail(E.add("Save blocked to preserve an unreadable save: ", filename))
var error: String = _write_snapshot(_snapshot(filename, password, data))
return _fail(error) if error != "" else true
return (_fail(error) if E.ne(error, "") else true)
static func _write_snapshot(job: Dictionary) -> String:
var payload: Dictionary = job.payload
var problem: String = _validate_payload(payload, job.speed_limit, job.stages)
if problem != "":
return "Invalid save: " + problem
if E.ne(problem, ""):
return E.add("Invalid save: ", problem)
var path: String = job.path
var temporary: String = path + ".tmp"
var file := FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password)
if file == null:
return "Cannot write encrypted save: " + temporary
var temporary: String = E.add(path, ".tmp")
var file = FileAccess.open_encrypted_with_pass(temporary, FileAccess.WRITE, job.password)
if E.eq(file, null):
return E.add("Cannot write encrypted save: ", temporary)
file.store_var(payload, false)
file.close()
# Check the actual encrypted file, including finalization, before replacing the old save.
var verification := FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, job.password)
if verification == null:
return "Cannot verify encrypted save: " + temporary
var verification = FileAccess.open_encrypted_with_pass(temporary, FileAccess.READ, job.password)
if E.eq(verification, null):
return E.add("Cannot verify encrypted save: ", temporary)
var restored: Variant = verification.get_var(false)
verification.close()
if not restored is Dictionary or restored != payload:
return "Encrypted save verification failed: " + temporary
if DirAccess.rename_absolute(temporary, path) != OK:
return "Cannot replace encrypted save: " + path
if ((not (restored is Dictionary)) or E.ne(restored, payload)):
return E.add("Encrypted save verification failed: ", temporary)
if E.ne(DirAccess.rename_absolute(temporary, path), OK):
return E.add("Cannot replace encrypted save: ", path)
if job.plaintext:
var plain_path: String = path + ".json"
file = FileAccess.open(plain_path + ".tmp", FileAccess.WRITE)
if file == null:
return "Encrypted save written, but cannot write diagnostic copy: " + plain_path
file.store_string(JSON.stringify(payload, " ", true, true) + "\n")
var plain_path: String = E.add(path, ".json")
file = FileAccess.open(E.add(plain_path, ".tmp"), FileAccess.WRITE)
if E.eq(file, null):
return E.add("Encrypted save written, but cannot write diagnostic copy: ", plain_path)
file.store_string(E.add(JSON.stringify(payload, " ", true, true), "\n"))
file.flush()
var write_error: Error = file.get_error()
file.close()
if write_error != OK or DirAccess.rename_absolute(plain_path + ".tmp", plain_path) != OK:
return "Encrypted save written, but diagnostic copy failed: " + plain_path
if (E.ne(write_error, OK) or E.ne(DirAccess.rename_absolute(E.add(plain_path, ".tmp"), plain_path), OK)):
return E.add("Encrypted save written, but diagnostic copy failed: ", plain_path)
return ""
@@ -144,68 +143,66 @@ func load_data(filename: String, password: String) -> Dictionary:
flush_saves()
last_error = ""
# Plaintext is diagnostic only: never use it as a fallback.
var path: String = save_directory.path_join(filename + ".adventure")
if not FileAccess.file_exists(path):
var path: String = save_directory.path_join(E.add(filename, ".adventure"))
if (not FileAccess.file_exists(path)):
return {}
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
if file == null:
var file = FileAccess.open_encrypted_with_pass(path, FileAccess.READ, password)
if E.eq(file, null):
blocked_saves[filename] = true
_fail("Cannot decrypt save: " + path)
_fail(E.add("Cannot decrypt save: ", path))
return {}
var data: Variant = file.get_var(false)
file.close()
# Unsupported formats are archived, never converted or overwritten.
# Returning no data lets the stage start a new, saveable session.
if data is Dictionary and data.get("format_version") != FORMAT_VERSION:
var archive: String = path + ".incompatible.bak"
if ((data is Dictionary) and E.ne(data.get("format_version"), FORMAT_VERSION)):
var archive: String = E.add(path, ".incompatible.bak")
var index: int = 0
while FileAccess.file_exists(archive):
index += 1
archive = path + ".incompatible.bak." + str(index)
index = E.add(index, 1)
archive = E.add(E.add(path, ".incompatible.bak."), str(index))
var error: Error = DirAccess.rename_absolute(path, archive)
if error != OK:
if E.ne(error, OK):
blocked_saves[filename] = true
_fail("Cannot archive incompatible save: " + error_string(error))
_fail(E.add("Cannot archive incompatible save: ", error_string(error)))
return {}
blocked_saves.erase(filename)
push_warning("Incompatible save preserved at " + archive + "; starting a new game.")
push_warning(E.add(E.add("Incompatible save preserved at ", archive), "; starting a new game."))
return {}
var problem: String = validate_data(data)
if problem != "":
if E.ne(problem, ""):
blocked_saves[filename] = true
_fail("Invalid save " + path + ": " + problem)
_fail(E.add(E.add(E.add("Invalid save ", path), ": "), problem))
return {}
blocked_saves.erase(filename)
return data
return E.decode(data)
static func _number(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
if not (value is int or value is float) or not is_finite(float(value)):
return false
return (not whole or float(value) == floorf(float(value))) and (not nonnegative or value >= 0)
return E.numeric(value) and E.finite(value) and (not whole or E.eq(value, E.floor_value(value))) and (not nonnegative or E.ge(value, 0))
static func _number_map(value: Variant, whole: bool = false, nonnegative: bool = true) -> bool:
if not value is Dictionary:
if (not (value is Dictionary)):
return false
for key in value:
if not (key is String or key is StringName) or not _number(value[key], whole, nonnegative):
if ((not (((key is String) or (key is StringName)))) or (not _number(value[key], whole, nonnegative))):
return false
return true
static func _plain_tree(value: Variant) -> bool:
if value is Dictionary:
if (value is Dictionary):
for key in value:
if not (key is String or key is StringName) or not _plain_tree(value[key]):
if ((not (((key is String) or (key is StringName)))) or (not _plain_tree(value[key]))):
return false
return true
if value is Array:
if (value is Array):
for item in value:
if not _plain_tree(item):
if (not _plain_tree(item)):
return false
return true
return value == null or value is String or value is StringName or value is bool or _number(value, false, false)
return ((((E.eq(value, null) or (value is String)) or (value is StringName)) or (value is bool)) or _number(value, false, false))
## Return a diagnostic path on failure; an empty string means the structure is valid.
@@ -214,104 +211,106 @@ func validate_data(data: Variant) -> String:
static func _validate_payload(data: Variant, speed_limit: int, stages: Array) -> String:
if not data is Dictionary or not _plain_tree(data):
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 data.get("format_version") != FORMAT_VERSION or not data.get("app_version") is String:
if (E.ne(data.get("format_version"), FORMAT_VERSION) or (not (data.get("app_version") is String))):
return "unsupported save format"
if not data.get("ui") is Dictionary or not data.ui.get("current_page") is int or data.ui.current_page < 0:
if (((not (data.get("ui") is Dictionary)) or (not (data.ui.get("current_page") is int))) or E.lt(data.ui.current_page, 0)):
return "invalid ui.current_page"
if not data.get("state") is Dictionary:
if (not (data.get("state") is Dictionary)):
return "state must be a Dictionary"
var saved: Dictionary = data.state
var required := [
"version", "time", "speed", "speed_in_year", "energy", "batches", "batch_origins",
"binds", "amount", "distribution", "auto_resources", "auto_star_deficits",
"production_remainders", "resource_remainders", "evolution_remainders", "life_remainders",
"planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders",
"civilization_stats", "civilization_homes", "ethics", "stats",
]
var required = ["version", "time", "speed", "speed_in_year", "energy", "batches", "batch_origins", "binds", "amount", "distribution", "auto_resources", "auto_star_deficits", "production_remainders", "resource_remainders", "evolution_remainders", "life_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "civilization_remainders", "civilization_stats", "civilization_homes", "ethics", "stats"]
# Optional additions to the current format. Saves predating indicators keep
# their quantities and receive neutral defaults from Owniverse on load.
for field in saved:
if field not in required and field not in ["world_parameters", "trends"]:
if (not E.contains(required, field) and not E.contains(["world_parameters", "trends"], field)):
return "unsupported state structure"
if saved.has("world_parameters"):
if not _number_map(saved.world_parameters) or saved.world_parameters.size() != WorldParameters.defaults().size():
if ((not _number_map(saved.world_parameters)) or E.ne(saved.world_parameters.size(), WorldParameters.defaults().size())):
return "invalid state.world_parameters"
for key in WorldParameters.defaults():
if not saved.world_parameters.has(key) or saved.world_parameters[key] < 1.0:
return "invalid world parameter " + key
if key in ["Bur", "Exp", "Agg"] and saved.world_parameters[key] > 19.0:
return "invalid world scale " + key
if ((not saved.world_parameters.has(key)) or E.lt(saved.world_parameters[key], 1)):
return E.add("invalid world parameter ", key)
if (E.contains(["Bur", "Exp", "Agg"], key) and E.gt(saved.world_parameters[key], 19)):
return E.add("invalid world scale ", key)
if saved.has("trends"):
if not _number_map(saved.trends) or saved.trends.size() != WorldParameters.TRENDS.size():
if ((not _number_map(saved.trends)) or E.ne(saved.trends.size(), WorldParameters.TRENDS.size())):
return "invalid state.trends"
for key in WorldParameters.TRENDS:
if not saved.trends.has(key) or saved.trends[key] >= 1.0:
return "invalid trend " + key
if ((not saved.trends.has(key)) or E.ge(saved.trends[key], 1)):
return E.add("invalid trend ", key)
for field in required:
if not saved.has(field):
return "missing state." + field
if saved.version != STATE_VERSION:
if (not saved.has(field)):
return E.add("missing state.", field)
if E.ne(saved.version, STATE_VERSION):
return "unsupported state.version"
if not saved.time is Dictionary or not _number(saved.time.get("datetime")) or not _number(saved.time.get("year")):
if (((not (saved.time is Dictionary)) or (not _number(saved.time.get("datetime")))) or (not _number(saved.time.get("year")))):
return "invalid state.time"
if not is_finite(float(saved.time.datetime) * 10.0) or saved.time.year != SimulationTime.processed_year(saved.time.datetime):
if ((not E.finite(E.multiply(E.of(saved.time.datetime), 10))) or E.ne(saved.time.year, SimulationTime.processed_year(saved.time.datetime))):
return "invalid processed time"
if not _number(saved.speed, true) or saved.speed < 1 or saved.speed > speed_limit:
if saved.time.has("ticks"):
if not _number(saved.time.ticks, true) or not E.eq(saved.time.ticks, E.multiply(saved.time.datetime, SimulationTime.TICKS_PER_YEAR)):
return "invalid time ticks"
if saved.time.has("tick_remainder"):
if not E.finite(saved.time.tick_remainder) or E.ge(E.absolute(saved.time.tick_remainder), 1):
return "invalid fractional tick"
if (((not _number(saved.speed, true)) or E.lt(saved.speed, 1)) or E.gt(saved.speed, speed_limit)):
return "invalid state.speed"
if not _number(saved.speed_in_year) or saved.speed_in_year != pow(10.0, saved.speed - 1):
if ((not _number(saved.speed_in_year)) or E.ne(saved.speed_in_year, E.power(10, E.subtract(saved.speed, 1)))):
return "invalid state.speed_in_year"
if not _number(saved.energy, true) or not _number_map(saved.amount, true):
if ((not _number(saved.energy, true)) or (not _number_map(saved.amount, true))):
return "invalid state resources"
if not saved.stats is Dictionary or not _number_map(saved.stats.get("total_amount"), true):
if ((not (saved.stats is Dictionary)) or (not _number_map(saved.stats.get("total_amount"), true))):
return "invalid state.stats.total_amount"
if saved.amount.keys() != saved.stats.total_amount.keys():
if E.ne(saved.amount.keys(), saved.stats.total_amount.keys()):
return "amount and total_amount keys differ"
if not saved.binds is Dictionary or not saved.batches is Dictionary:
if ((not (saved.binds is Dictionary)) or (not (saved.batches is Dictionary))):
return "state.binds and state.batches must be Dictionaries"
for owner_id in saved.binds:
if not saved.amount.has(owner_id) or not _number_map(saved.binds[owner_id], true):
return "invalid state.binds." + owner_id
if ((not saved.amount.has(owner_id)) or (not _number_map(saved.binds[owner_id], true))):
return E.add("invalid state.binds.", owner_id)
for id in saved.binds[owner_id]:
if not saved.amount.has(id):
return "unknown bound resource " + id
if (not saved.amount.has(id)):
return E.add("unknown bound resource ", id)
for id in saved.batches:
if not saved.amount.has(id) or not saved.batches[id] is Array:
return "invalid state.batches." + id
if ((not saved.amount.has(id)) or (not (saved.batches[id] is Array))):
return E.add("invalid state.batches.", id)
for batch in saved.batches[id]:
if not batch is Dictionary or not _number(batch.get("amount"), true) or not _number(batch.get("start_year")) or not _number(batch.get("end_year")):
return "invalid batch in " + id
if batch.end_year < batch.start_year:
return "reversed batch interval in " + id
if batch.has("created_from") != batch.has("created_to"):
return "incomplete batch creation interval in " + id
if batch.has("created_from") and (not _number(batch.created_from) or not _number(batch.created_to) or batch.created_to <= batch.created_from or batch.start_year < batch.created_from):
return "invalid batch creation interval in " + id
if batch.has("processed_until") and (not _number(batch.processed_until) or batch.processed_until < batch.start_year or batch.processed_until > batch.end_year):
return "invalid batch.processed_until in " + id
if batch.has("withdraw_remainder") and (not _number(batch.withdraw_remainder) or batch.withdraw_remainder > batch.amount):
return "invalid batch.withdraw_remainder in " + id
if ((((not (batch is Dictionary)) or (not _number(batch.get("amount"), true))) or (not _number(batch.get("start_year")))) or (not _number(batch.get("end_year")))):
return E.add("invalid batch in ", id)
if E.lt(batch.end_year, batch.start_year):
return E.add("reversed batch interval in ", id)
if E.ne(batch.has("created_from"), batch.has("created_to")):
return E.add("incomplete batch creation interval in ", id)
if (batch.has("created_from") and (((((not _number(batch.created_from)) or (not _number(batch.created_to))) or E.le(batch.created_to, batch.created_from)) or E.lt(batch.start_year, batch.created_from)))):
return E.add("invalid batch creation interval in ", id)
if (batch.has("processed_until") and ((((not _number(batch.processed_until)) or E.lt(batch.processed_until, batch.start_year)) or E.gt(batch.processed_until, batch.end_year)))):
return E.add("invalid batch.processed_until in ", id)
if (batch.has("withdraw_remainder") and (((not _number(batch.withdraw_remainder)) or E.gt(batch.withdraw_remainder, batch.amount)))):
return E.add("invalid batch.withdraw_remainder in ", id)
for field in ["batch_origins", "distribution", "production_remainders", "evolution_remainders", "planet_remainders", "blast_remainders", "destruction_remainders", "life_remainders", "civilization_remainders", "civilization_stats"]:
if not _number_map(saved[field], false, false):
return "invalid state." + field
if not _number_map(saved.auto_resources, true) or not _number_map(saved.auto_star_deficits, false, false):
if (not _number_map(saved[field], false, false)):
return E.add("invalid state.", field)
if ((not _number_map(saved.auto_resources, true)) or (not _number_map(saved.auto_star_deficits, false, false))):
return "invalid automatic production state"
if not saved.resource_remainders is Dictionary or saved.resource_remainders.keys().size() != 2 or not saved.resource_remainders.has("basic") or not saved.resource_remainders.has("automatic"):
if ((((not (saved.resource_remainders is Dictionary)) or E.ne(saved.resource_remainders.keys().size(), 2)) or (not saved.resource_remainders.has("basic"))) or (not saved.resource_remainders.has("automatic"))):
return "invalid state.resource_remainders"
for channel in ["basic", "automatic"]:
if not _number_map(saved.resource_remainders[channel], false, false):
return "invalid state.resource_remainders." + channel
if not saved.ethics is Dictionary or not saved.civilization_homes is Dictionary:
if (not _number_map(saved.resource_remainders[channel], false, false)):
return E.add("invalid state.resource_remainders.", channel)
if ((not (saved.ethics is Dictionary)) or (not (saved.civilization_homes is Dictionary))):
return "invalid civilization state"
for id in saved.ethics:
if id not in stages or not _number_map(saved.ethics[id], true):
return "invalid state.ethics." + str(id)
if (not E.contains(stages, id) or (not _number_map(saved.ethics[id], true))):
return E.add("invalid state.ethics.", str(id))
for ethic in saved.ethics[id]:
if ethic not in ["m", "l", "w", "d", "c", "b"]:
return "invalid ethic " + str(ethic)
if not E.contains(["m", "l", "w", "d", "c", "b"], ethic):
return E.add("invalid ethic ", str(ethic))
for id in saved.civilization_homes:
if not _number_map(saved.civilization_homes[id], true):
if (not _number_map(saved.civilization_homes[id], true)):
return "invalid civilization homes"
return ""
@@ -330,7 +329,7 @@ func save_settings():
func load_settings():
var config = ConfigFile.new()
var err = config.load(SAVE_PATH)
if err != OK:
if E.ne(err, OK):
return false
for item in Global.Settings:
Global.Settings[item] = config.get_value(SETTING_SECTION, item, Global.Settings[item])
+36
View File
@@ -0,0 +1,36 @@
class_name CSVLoader
static func load(csv_text: String) -> Array[Dictionary]:
var rows: Array[Dictionary] = []
var lines := csv_text.strip_edges().split("\n")
if lines.size() < 2:
return rows
var headers := lines[0].split(",")
for i in range(1, lines.size()):
var line := lines[i].strip_edges()
if line.is_empty():
continue
var values := line.split(",")
if values.size() != headers.size():
push_warning(
"CSV row %d has %d columns, expected %d"
% [i + 1, values.size(), headers.size()]
)
continue
var row: Dictionary = {}
for j in range(headers.size()):
row[headers[j].strip_edges()] = values[j].strip_edges()
rows.append(row)
print(rows)
return rows
+1
View File
@@ -0,0 +1 @@
uid://bfe1fyutcw0cx
+2389
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
uid://cunl2sqmcj1ff
+6
View File
@@ -1,5 +1,11 @@
# Эволюция звёзд Owniverse.GI
Числа симуляции переведены на целочисленное хранение с большими разрядами и
рациональную арифметику. Количества до `10^27` сохраняют единицы; время хранится
в тиках по `10^-9` года. Границы точности коэффициентов, ограничение накопленных
дробей и несовместимый со старым формат сохранения 3 описаны в
[документации математики](world/owniverse/math/README.md).
Механики перенесены из Owniverse.U с согласованными изменениями GI: постепенные партии, детерминированные дробные остатки, уязвимость цивилизаций до C1.2 и передача выживших планет преемнику. Баланс таблицы GI сохраняется.
## Порядок расчёта
+10 -9
View File
@@ -1,4 +1,5 @@
extends Control
const E = preload("res://world/owniverse/math/exact.gd")
const OWNIVERSE_ITEM = preload("uid://d3xjfxwe3w16v")
@@ -96,11 +97,11 @@ var owniverse_items: Dictionary[String, OwniverseItem] = {}
var locked_items: Array = []
func set_datetime(value: float):
func set_datetime(value):
# Simulation time advances in tenths of a year; displaying five decimal
# places only adds zeroes and suggests precision the simulation does not
# have.
date_time.text = Global.format_number(value, 1) if value != 0.0 else "0.0"
date_time.text = Global.format_number(value, 1) if not E.eq(value, 0) else "0.0"
#region Item manipulations: update and visibility
@@ -156,8 +157,8 @@ func _update_descriptions(delta: float) -> void:
return
for page in description_targets:
var id: String = description_targets[page].id
var total: float = ItemDescription.quantities(owniverse, id).total
description_rates[id] = (total - description_samples[id]) / description_elapsed if description_samples.has(id) else 0.0
var total = ItemDescription.quantities(owniverse, id).total
description_rates[id] = E.divide(E.subtract(total, description_samples[id]), E.of(description_elapsed)) if description_samples.has(id) else 0.0
description_samples[id] = total
description_elapsed = 0.0
_refresh_descriptions()
@@ -214,7 +215,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 = 9 * _value if _value > 0 else 1
_value = E.multiply(9, _value) if E.gt(_value, 0) else 1
owniverse.production.produce_hs(item.item_id, _value)
@@ -284,20 +285,20 @@ func _set_active_page_actions() -> void:
var amount = owniverse.production.calculate_max_producible(entity)
#print(item_id, " : ", amount)
if item_id == "SMBH":
var preview: Callable = func(requested: float) -> float:
var preview: Callable = func(requested):
return owniverse.production.calculate_max_producible(entity, requested)
page_actions[pa].set_actions(item_id, amount, preview)
else:
page_actions[pa].set_actions(item_id, amount)
func _page_action_pressed(page_id: int, action_amount: float) -> void:
func _page_action_pressed(page_id: int, action_amount) -> void:
if !g_active_items.has(page_id):
return
var item_id: String = g_active_items[page_id].item_id
if action_amount > 0:
if E.gt(action_amount, 0):
owniverse.production.produce(item_id, action_amount)
@@ -496,7 +497,7 @@ func init_enitities() -> Dictionary:
owniverse.state.stats.total_amount[entity_id] = 0
owniverse.state.amount[entity_id] = 0
if entity.lifespan > 0:
if E.gt(entity.lifespan, 0):
owniverse.state.batches[entity.id] = []
owniverse.batches.add_entity_to_batch(entity_id, 0, 0)
if entity.group_as != "":
+10
View File
@@ -1,5 +1,15 @@
# Проверки поэтапного переноса из Owniverse.U
## Точная арифметика
`godot --headless --path Owniverse.GI --script res://tests/test_exact_math.gd`
Независимый эталон Python Fraction, границы int64 и 10^27, точная сериализация,
рецепты с дробными стоимостями, сохранение сумм, время и 100 циклов с меняющимися
долями. Все остальные тесты используют числовое сравнение и сериализацию через
`E`, а не сравнение ссылок на большие числа. Границы точности и новый формат
сохранения описаны в [документации арифметики](../world/owniverse/math/README.md).
## Модули симуляции
`godot --headless --path Owniverse.GI --script res://tests/test_module_lifecycle.gd`
File diff suppressed because one or more lines are too long
+22 -21
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var failures: int = 0
var checks: int = 0
@@ -7,41 +8,41 @@ func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures += 1
checks = E.add(checks, 1)
if (not value):
failures = E.add(failures, 1)
push_error(message)
func run() -> void:
var actions = load("res://ui/actions/actions.tscn").instantiate()
root.add_child(actions)
for maximum in [0.0, 0.1, 0.99, 1.0, 1.9, 10.0, 100.0, 123.0, 1000.0, 10000.0, 1e9, 1.1e9, 1e150]:
for maximum in [0, E.of("0.1"), E.of("0.99"), 1, E.of("1.9"), 10, 100, 123, 1000, 10000, E.of("1e9"), E.of("1.1e9"), E.of("1e150")]:
actions.set_actions("test", maximum)
var labels: Array = []
var values: Array = []
for i in 5:
var value: float = actions.action_values[i]
var value: Variant = actions.action_values[i]
var text: String = actions.action_labels[i].text
check(value == floorf(value) and value <= floorf(maximum), "Whole affordable request")
if value > 0.0:
check(not values.has(value) and not labels.has(text), "Unique requests and displayed labels")
check((E.eq(value, E.floor_value(value)) and E.le(value, E.floor_value(maximum))), "Whole affordable request")
if E.gt(value, 0):
check(((not values.has(value)) and (not labels.has(text))), "Unique requests and displayed labels")
values.append(value)
labels.append(text)
else:
check(text == "" and actions.action_labels[i].get_parent().get_node("TextureButton").disabled, "Unused button blank and disabled")
if maximum >= 1.0: check(values.back() == floorf(maximum), "Maximum retained")
actions.set_actions("test", 100.0)
check(actions.action_values.values() == [1.0, 10.0, 100.0, 0.0, 0.0], "Exact power of ten not duplicated")
actions.set_actions("SMBH", 25.0, func(_request): return 25.0)
check(actions.action_values.values() == [25.0, 0.0, 0.0, 0.0, 0.0], "Identical SMBH previews collapse to one button")
actions.set_actions("step", 123.0, func(request): return floorf(request / 5.0) * 5.0)
check(actions.action_values.values() == [10.0, 100.0, 123.0, 0.0, 0.0], "Unproducible small requests omitted")
check(actions.action_labels[2].text == "120", "Recipe preview displayed while original request retained")
check((E.eq(text, "") and actions.action_labels[i].get_parent().get_node("TextureButton").disabled), "Unused button blank and disabled")
if E.ge(maximum, 1): check(E.eq(values.back(), E.floor_value(maximum)), "Maximum retained")
actions.set_actions("test", 100)
check(E.eq(actions.action_values.values(), [1, 10, 100, 0, 0]), "Exact power of ten not duplicated")
actions.set_actions("SMBH", 25, func ( _request ) : return 25)
check(E.eq(actions.action_values.values(), [25, 0, 0, 0, 0]), "Identical SMBH previews collapse to one button")
actions.set_actions("step", 123, func ( request ) : return E.multiply(E.floor_value(E.divide(request, 5)), 5))
check(E.eq(actions.action_values.values(), [10, 100, 123, 0, 0]), "Unproducible small requests omitted")
check(E.eq(actions.action_labels[2].text, "120"), "Recipe preview displayed while original request retained")
var emitted: Array = []
actions.action_pressed.connect(func(_page, amount): emitted.append(amount))
actions.action_pressed.connect(func ( _page , amount ) : emitted.append(amount))
actions._on_action_button_pressed(2)
actions._on_action_button_pressed(4)
check(emitted == [123.0], "Only active button emits its matching request")
check(E.eq(emitted, [123]), "Only active button emits its matching request")
actions.queue_free()
print("Action values: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Action values: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+44 -39
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var failures: int = 0
var checks: int = 0
@@ -8,89 +9,93 @@ func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures += 1
checks = E.add(checks, 1)
if (not value):
failures = E.add(failures, 1)
push_error(message)
func run() -> void:
var manager = load("res://autoload/save_manager.gd").new()
var directory: String = "/tmp/owniverse-async-%s-%s" % [OS.get_process_id(), Time.get_ticks_usec()]
var directory: String = ("/tmp/owniverse-async-%s-%s" % [OS.get_process_id(), Time.get_ticks_usec()])
DirAccess.make_dir_recursive_absolute(directory)
manager.save_directory = directory
root.add_child(manager)
manager.save_completed.connect(func(filename, success, error): completions.append([filename, success, error]))
manager.save_completed.connect(func ( filename , success , error ) : completions.append([filename, success, error]))
var world = load("res://world/owniverse/owniverse.gd").new()
world.state.energy = E.subtract(world.state.energy, 1)
world.state.time = preload("res://world/owniverse/simulation_time.gd").advance({"datetime": E.of("1e27")}, 0.000000001, 1)
var data: Dictionary = {"state": world.state, "ui": {"current_page": 1}}
var initial_energy: float = world.state.energy
var initial_energy: Variant = world.state.energy
var global: Node = root.get_node("Global")
var old_plaintext: bool = global.save_plaintext_copy
global.save_plaintext_copy = true
check(manager.request_save("snapshot", "test-password", data), "Snapshot queued")
data.state.energy = 99.0
data.state.energy = 99
data.ui.current_page = 2
var frames: int = 0
var deadline: int = Time.get_ticks_msec() + 10000
while manager.is_saving() and Time.get_ticks_msec() < deadline:
frames += 1
var deadline: int = E.add(Time.get_ticks_msec(), 10000)
while (manager.is_saving() and E.lt(Time.get_ticks_msec(), deadline)):
frames = E.add(frames, 1)
await process_frame
check(not manager.is_saving() and frames > 0, "Frames continue while save is pending")
check(((not manager.is_saving()) and E.gt(frames, 0)), "Frames continue while save is pending")
var saved: Dictionary = manager.load_data("snapshot", "test-password")
check(saved.state.energy == initial_energy and saved.ui.current_page == 1, "Worker owns a deep snapshot")
check((E.eq(saved.state.energy, initial_energy) and E.eq(saved.ui.current_page, 1)), "Worker owns a deep snapshot")
check(E.eq(saved.state.time, world.state.time), "Encrypted save preserves exact ticks at huge age")
var plain: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(directory.path_join("snapshot.adventure.json")))
check(plain.state.energy == initial_energy, "Diagnostic JSON uses same snapshot")
check(completions.size() == 1 and completions[0][1], "Completion reports successful write")
check(E.eq(E.decode(plain.state.energy), initial_energy), "Diagnostic JSON uses same snapshot")
check(E.eq(E.decode(plain.state.time), saved.state.time), "Diagnostic JSON preserves fractional time")
check((E.eq(completions.size(), 1) and completions[0][1]), "Completion reports successful write")
completions.clear()
data.state.energy = 1.0
data.state.energy = 1
manager.request_save("latest", "test-password", data)
data.state.energy = 2.0
data.state.energy = 2
manager.request_save("latest", "test-password", data)
data.state.energy = 3.0
data.state.energy = 3
manager.request_save("latest", "test-password", data)
data.state.energy = 4.0
data.state.energy = 4
manager.flush_saves()
check(completions.size() == 2, "Repeated requests coalesce behind active write")
check(manager.load_data("latest", "test-password").state.energy == 3.0, "Last queued snapshot wins")
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 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": -1, "state": {"unknown": true}}, false)
incompatible.store_var({"format_version": E.neg(1), "state": {"unknown": true}}, false)
incompatible.close()
var original: PackedByteArray = FileAccess.get_file_as_bytes(incompatible_path)
var previous_archive = FileAccess.open(incompatible_path + ".incompatible.bak", FileAccess.WRITE)
var previous_archive = FileAccess.open(E.add(incompatible_path, ".incompatible.bak"), FileAccess.WRITE)
previous_archive.store_string("keep previous backup")
previous_archive.close()
manager.blocked_saves.incompatible = true
check(manager.load_data("incompatible", "test-password").is_empty() and manager.last_error == "", "Unsupported format permits a fresh session")
check(not manager.blocked_saves.has("incompatible") and not FileAccess.file_exists(incompatible_path), "Incompatible file no longer blocks saving")
check(FileAccess.get_file_as_bytes(incompatible_path + ".incompatible.bak.1") == original, "Unsupported save archived without losing bytes")
check(FileAccess.get_file_as_string(incompatible_path + ".incompatible.bak") == "keep previous backup", "Earlier archive preserved")
check((manager.load_data("incompatible", "test-password").is_empty() and E.eq(manager.last_error, "")), "Unsupported format permits a fresh session")
check(((not manager.blocked_saves.has("incompatible")) and (not FileAccess.file_exists(incompatible_path))), "Incompatible file no longer blocks saving")
check(E.eq(FileAccess.get_file_as_bytes(E.add(incompatible_path, ".incompatible.bak.1")), original), "Unsupported save archived without losing bytes")
check(E.eq(FileAccess.get_file_as_string(E.add(incompatible_path, ".incompatible.bak")), "keep previous backup"), "Earlier archive preserved")
check(manager.request_save("incompatible", "test-password", data), "New session can queue a save")
manager.flush_saves()
check(manager.load_data("incompatible", "test-password").state == data.state, "New format saves and reloads after incompatible file")
check(E.eq(manager.load_data("incompatible", "test-password").state, data.state), "New format saves and reloads after incompatible file")
# Expected failures below must leave the last good encrypted file intact.
data.state.energy = -1.0
data.state.energy = E.neg(1)
manager.request_save("latest", "test-password", data)
manager.flush_saves()
check(not completions.back()[1] and manager.last_error.contains("Invalid save"), "Worker validation error reaches main thread")
check(manager.load_data("latest", "test-password").state.energy == 3.0, "Invalid snapshot preserves previous save")
check(((not completions.back()[1]) and manager.last_error.contains("Invalid save")), "Worker validation error reaches main thread")
check(E.eq(manager.load_data("latest", "test-password").state.energy, 3), "Invalid snapshot preserves previous save")
manager.blocked_saves.latest = true
check(not manager.request_save("latest", "test-password", data), "Unreadable save protection also applies to queue")
check((not manager.request_save("latest", "test-password", data)), "Unreadable save protection also applies to queue")
# A valid snapshot exercises an actual I/O failure, not validation.
data.state.energy = 5.0
data.state.energy = 5
manager.request_save("missing/failure", "test-password", data)
manager.flush_saves()
check(manager.last_error.contains("Cannot write"), "I/O error reported without hanging queue")
manager.request_save("shutdown", "test-password", data)
data.state.energy = 6.0
data.state.energy = 6
manager.request_save("shutdown", "test-password", data)
manager.free()
var reader = load("res://autoload/save_manager.gd").new()
reader.save_directory = directory
check(reader.load_data("shutdown", "test-password").state.energy == 6.0, "Teardown drains final pending snapshot")
check(E.eq(reader.load_data("shutdown", "test-password").state.energy, 6), "Teardown drains final pending snapshot")
reader.free()
world.free()
# Exercise the stage entry point too: manager tests alone do not catch a
@@ -100,12 +105,12 @@ func run() -> void:
shared_manager.save_directory = directory
var stage = load("res://stage/stage.gd").new()
stage.pending_page = 2
stage.owniverse.state.energy = 42.0
stage.owniverse.state.energy = 42
stage.save_data()
check(shared_manager.is_saving(), "Stage queues a background save instead of waiting for disk")
stage.owniverse.state.energy = 43.0
stage.owniverse.state.energy = 43
shared_manager.flush_saves()
check(shared_manager.load_data("owniverse", "qwerty").state.energy == 42.0, "Stage save writes an independent snapshot")
check(E.eq(shared_manager.load_data("owniverse", "qwerty").state.energy, 42), "Stage save writes an independent snapshot")
stage.owniverse.free()
stage.free()
shared_manager.save_directory = original_directory
@@ -113,5 +118,5 @@ func run() -> void:
for filename in DirAccess.get_files_at(directory):
DirAccess.remove_absolute(directory.path_join(filename))
DirAccess.remove_absolute(directory)
print("Async save: %d checks, %d failures (validation/block/I/O errors above are intentional)" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Async save: %d checks, %d failures (validation/block/I/O errors above are intentional)" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+27 -26
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -37,21 +38,21 @@ func dispose(world) -> void:
func run() -> void:
var world = fixture()
world.state.batch_origins.LGRB = 10357034.80000000074505806
var boundary: float = 41215289.79999999701976776
for interval in [[boundary - 2.5, boundary + 2.25], [boundary, boundary + 2.0]]:
world.state.batch_origins.LGRB = E.of("10357034.80000000074505806")
var boundary: Variant = E.of("41215289.79999999701976776")
for interval in [[E.subtract(boundary, E.of("2.5")), E.add(boundary, E.of("2.25"))], [boundary, E.add(boundary, 2)]]:
world.state.batches.LGRB = []
world.batches.append_batch(world.state, "LGRB", 100.0, interval[0], interval[1])
var total: float = 0.0
var last: float = -INF
for batch: Dictionary in world.state.batches.LGRB:
total += batch.amount
check(batch.created_from > last, "Batch windows advance monotonically")
check(batch.created_to > batch.created_from, "Positive window duration")
check(batch.amount >= 0.0, "No negative batch amounts")
world.batches.append_batch(world.state, "LGRB", 100, interval[0], interval[1])
var total: Variant = 0
var last: Variant = E.neg(INF)
for batch : Dictionary in world.state.batches.LGRB:
total = E.add(total, batch.amount)
check(E.gt(batch.created_from, last), "Batch windows advance monotonically")
check(E.gt(batch.created_to, batch.created_from), "Positive window duration")
check(E.ge(batch.amount, 0), "No negative batch amounts")
last = batch.created_from
check(total == 100.0, "Boundary correction preserves all objects")
check(world.state.batches.LGRB.size() <= 7, "Finite number of windows")
check(E.eq(total, 100), "Boundary correction preserves all objects")
check(E.le(world.state.batches.LGRB.size(), 7), "Finite number of windows")
dispose(world)
print("Batch boundaries: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Batch boundaries: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+82 -81
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -35,130 +36,130 @@ func dispose(world) -> void:
entity.free()
world.free()
func seed_population(world, id: String, count: float, time: float = 1.0) -> void:
world.state.amount.N = 0.0
func seed_population(world, id: String, count: Variant, time: Variant = 1.0) -> void:
world.state.amount.N = 0
world.state.amount[id] = count
world.state.batches[id] = [{"amount": count, "start_year": time, "end_year": time}]
world.evolution.initialize_life_batches()
func sum_ethics(world, id: String) -> float:
var result: float = 0.0
for n in world.state.ethics.get(id, {}).values(): result += n
func sum_ethics(world, id: String) -> Variant:
var result: Variant = 0
for n in world.state.ethics.get(id, {}).values(): result = E.add(result, n)
return result
func invariants(world) -> void:
var physical: Dictionary = world.ledger.physical_amounts(world.state)
for id in world.state.ethics:
check(sum_ethics(world, id) == physical.get(id, 0.0), "Ethics equal physical population for " + id)
check(E.eq(sum_ethics(world, id), physical.get(id, 0)), E.add("Ethics equal physical population for ", id))
for n in world.state.ethics[id].values():
if n < 0.0 or n != floorf(n): check(false, "Invalid ethic amount")
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Civilization state validates")
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": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Civilization state validates")
func run() -> void:
var global: Node = root.get_node("Global")
var baseline: Dictionary = global.CivilizationRules.duplicate(true)
var world = fixture()
seed_population(world, "C0.2", 60000.0)
world.evolution.evolve_batches(world.state, 1.0)
check(world.state.amount["C0.3"] > 0.0, "C0.2 actually progresses")
check(world.state.amount["C0.1"] > 0.0, "C0.2 actually degrades")
check(world.state.civilization_stats.get("wars", 0.0) == 0.0, "Table disables early wars")
check(world.state.amount["C0.2"] + world.state.amount["C0.3"] + world.state.amount["C0.1"] == 60000.0, "Ordinary evolution conserves population")
check(world.state.stats.total_amount["C0.2"] == 0.0, "Survivors do not count as new civilizations")
check(world.state.civilization_stats.worship > 0.0, "Worship derived from surviving ethics")
seed_population(world, "C0.2", 60000)
world.evolution.evolve_batches(world.state, 1)
check(E.gt(world.state.amount["C0.3"], 0), "C0.2 actually progresses")
check(E.gt(world.state.amount["C0.1"], 0), "C0.2 actually degrades")
check(E.eq(world.state.civilization_stats.get("wars", 0), 0), "Table disables early wars")
check(E.eq(E.add(E.add(world.state.amount["C0.2"], world.state.amount["C0.3"]), world.state.amount["C0.1"]), 60000), "Ordinary evolution conserves population")
check(E.eq(world.state.stats.total_amount["C0.2"], 0), "Survivors do not count as new civilizations")
check(E.gt(world.state.civilization_stats.worship, 0), "Worship derived from surviving ethics")
invariants(world)
var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
world.evolution.evolve_batches(world.state, 2100.0)
restored.evolution.evolve_batches(restored.state, 2100.0)
check(world.state == restored.state, "Save resumes deterministic ethical evolution")
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolution.evolve_batches(world.state, 2100)
restored.evolution.evolve_batches(restored.state, 2100)
check(E.eq(world.state, restored.state), "Save resumes deterministic ethical evolution")
invariants(world)
dispose(restored)
dispose(world)
world = fixture()
world.state.amount.RD = 1e8
seed_population(world, "C2.0", 60000.0)
world.state.amount["C1.9"] = 10000.0
world.state.amount.RD = 100000000
seed_population(world, "C2.0", 60000)
world.state.amount["C1.9"] = 10000
world.evolution.initialize_life_batches()
world.evolution.evolve_batches(world.state, 1.0)
check(world.state.civilization_stats.wars > 0.0, "Wars execute when enabled")
check(world.state.civilization_stats.collateral > 0.0, "Wars affect previous-stage populations")
check(world.state.amount["C2.1"] > 0.0, "Winners and peaceful progress reach next stage")
world.evolution.evolve_batches(world.state, 1)
check(E.gt(world.state.civilization_stats.wars, 0), "Wars execute when enabled")
check(E.gt(world.state.civilization_stats.collateral, 0), "Wars affect previous-stage populations")
check(E.gt(world.state.amount["C2.1"], 0), "Winners and peaceful progress reach next stage")
invariants(world)
dispose(world)
world = fixture()
world.state.amount.RD = 1.0
world.state.binds.RD = {"HP": 5.0, "GG": 5.0, "AB": 5.0, "RP": 5.0}
seed_population(world, "C1.6", 60000.0)
for row: Dictionary in global.CivilizationRules["C1.6"].ethics.values(): row.degradation = "0"
world.evolution.evolve_batches(world.state, 1.0)
world.state.amount.RD = 1
world.state.binds.RD = {"HP": 5, "GG": 5, "AB": 5, "RP": 5}
seed_population(world, "C1.6", 60000)
for row : Dictionary in global.CivilizationRules["C1.6"].ethics.values(): row.degradation = "0"
world.evolution.evolve_batches(world.state, 1)
var after: Dictionary = world.ledger.physical_amounts(world.state)
check(world.state.civilization_stats.colonies == 20.0, "Colonization limited to real targets")
check(after.HP + after.GG + after.AB + after.RP == 0.0, "Colonized objects consumed once")
check(world.state.binds.RD.get("C1.4", 0.0) == 20.0, "Colonies retain target host and use two-stages-earlier level")
var totals: float = 0.0
check(E.eq(world.state.civilization_stats.colonies, 20), "Colonization limited to real targets")
check(E.eq(E.add(E.add(E.add(after.HP, after.GG), after.AB), after.RP), 0), "Colonized objects consumed once")
check(E.eq(world.state.binds.RD.get("C1.4", 0), 20), "Colonies retain target host and use two-stages-earlier level")
var totals: Variant = 0
for id in after:
if world.catalog.entities[id].group_as == "Planet": totals += after[id]
check(totals == 60020.0, "Colonization replaces planets, does not create extra worlds")
if E.eq(world.catalog.entities[id].group_as, "Planet"): totals = E.add(totals, after[id])
check(E.eq(totals, 60020), "Colonization replaces planets, does not create extra worlds")
invariants(world)
dispose(world)
global.CivilizationRules = baseline.duplicate(true)
world = fixture()
world.state.amount.RD = 1e25
world.state.amount.BG = 1e25
world.state.amount.TWD = 1e25
world.state.amount.SNS = 1e25
world.state.amount.MGT = 1e25
world.state.amount.BH25 = 1e25
world.state.amount.SMBH = 1e26
world.state.amount.RD = E.of("1e25")
world.state.amount.BG = E.of("1e25")
world.state.amount.TWD = E.of("1e25")
world.state.amount.SNS = E.of("1e25")
world.state.amount.MGT = E.of("1e25")
world.state.amount.BH25 = E.of("1e25")
world.state.amount.SMBH = E.of("1e26")
for id in world.catalog.entities:
if world.catalog.entities[id].claim != "": world.state.amount[id] = 1e12
if E.ne(world.catalog.entities[id].claim, ""): world.state.amount[id] = E.of("1e12")
# Accelerated deterministic fixture traverses every enabled stage, including Ct.
for id in global.CivilizationRules:
if id == "Ct": continue
if E.eq(id, "Ct"): continue
global.CivilizationRules[id].fights = false
global.CivilizationRules[id].colonizes = false
global.CivilizationRules[id].diplomacy = false
for row: Dictionary in global.CivilizationRules[id].ethics.values():
for row : Dictionary in global.CivilizationRules[id].ethics.values():
row.progress = "1"
row.degradation = "0"
world.catalog.entities[id].lifespan = 1.0
world.catalog.entities[id].lifespan_delta = 1.0
global.CivilizationRules[id].span = 1.0
seed_population(world, "C0.2", 6.0)
world.evolution.evolve_batches(world.state, 1000.0)
check(world.state.amount.Ct == 6.0, "Complete C0.2 -> C4.4 -> Ct chain runs")
check(not world.state.batches.has("Ct"), "Transcendence is terminal")
world.catalog.entities[id].lifespan = 1
world.catalog.entities[id].lifespan_delta = 1
global.CivilizationRules[id].span = 1
seed_population(world, "C0.2", 6)
world.evolution.evolve_batches(world.state, 1000)
check(E.eq(world.state.amount.Ct, 6), "Complete C0.2 -> C4.4 -> Ct chain runs")
check((not world.state.batches.has("Ct")), "Transcendence is terminal")
invariants(world)
dispose(world)
global.CivilizationRules = baseline
world = fixture()
world.state.amount.RD = 10000.0
world.state.amount.SCS = 1.0
world.state.amount["C2.4"] = 1.0
world.state.amount.RD = 10000
world.state.amount.SCS = 1
world.state.amount["C2.4"] = 1
world.evolution.initialize_life_batches()
var span: float = world.state.batches["C2.4"][0].start_year
check(is_equal_approx(span, 20000.0 / 3.6), "Structure bonus shortens newly registered lifespan")
var span: Variant = world.state.batches["C2.4"][0].start_year
check(E.near(span, E.divide(20000, E.of("3.6"))), "Structure bonus shortens newly registered lifespan")
dispose(world)
var evolution = load("res://world/owniverse/civilization_evolution.gd")
var balanced: Dictionary = {"m": 10000.0, "l": 10000.0, "w": 10000.0, "d": 10000.0, "c": 10000.0, "b": 10000.0}
var balanced: Dictionary = {"m": 10000, "l": 10000, "w": 10000, "d": 10000, "c": 10000, "b": 10000}
var neutral: Dictionary = evolution.drift({}, "test", balanced, global.CivilizationRules["C2.0"].ethics)
var diplomatic: Dictionary = evolution.drift({}, "test", balanced, global.CivilizationRules["C2.0"].ethics, true)
check(neutral == balanced, "Ordinary drift preserves balanced ethics")
check(diplomatic.d > diplomatic.w, "Diplomacy uses per-ethic influence")
check(evolution.total(diplomatic) == 60000.0, "Diplomacy conserves population")
check(E.eq(neutral, balanced), "Ordinary drift preserves balanced ethics")
check(E.gt(diplomatic.d, diplomatic.w), "Diplomacy uses per-ethic influence")
check(E.eq(evolution.total(diplomatic), 60000), "Diplomacy conserves population")
world = fixture()
world.state.amount.RD = 1e15
world.state.amount.BG = 1e15
world.state.amount["C3.0"] = 6.0
world.state.amount.RD = E.of("1e15")
world.state.amount.BG = E.of("1e15")
world.state.amount["C3.0"] = 6
world.evolution.initialize_life_batches()
check(world.state.amount["C3.0"] == 0.0 and world.state.amount["C2.9"] == 6.0, "Missing mandatory structures degrade existing civilizations")
check((E.eq(world.state.amount["C3.0"], 0) and E.eq(world.state.amount["C2.9"], 6)), "Missing mandatory structures degrade existing civilizations")
invariants(world)
dispose(world)
print("Civilization evolution: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Civilization evolution: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+69 -68
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -38,97 +39,97 @@ func dispose(world) -> void:
func run() -> void:
var world = fixture()
var global: Node = root.get_node("Global")
check(global.CivilizationRules.size() == 44, "Load all ethical civilization stages")
for row: Dictionary in global.CivilizationRules.values():
check(row.ethics.size() == 6 or row.span == 0.0, "Every ethical stage has six rules")
world.state.amount.RD = 12.0
world.state.amount["C0.2"] = 8.0
world.state.binds.RD = {"C2.0": 12.0, "C1.9": 3.0}
check(E.eq(global.CivilizationRules.size(), 44), "Load all ethical civilization stages")
for row : Dictionary in global.CivilizationRules.values():
check((E.eq(row.ethics.size(), 6) or E.eq(row.span, 0)), "Every ethical stage has six rules")
world.state.amount.RD = 12
world.state.amount["C0.2"] = 8
world.state.binds.RD = {"C2.0": 12, "C1.9": 3}
world.evolution.initialize_life_batches()
check(world.state.amount["C2.0"] == 12.0 and world.state.binds.RD["C2.0"] == 0.0, "Old space civilizations detach without loss")
check(world.state.binds.RD["C1.9"] == 3.0, "Planetary civilizations retain their host")
check(world.state.ethics["C0.2"] == {"m": 2.0, "l": 2.0, "w": 1.0, "d": 1.0, "c": 1.0, "b": 1.0}, "Migration uses equal ethics and deterministic remainder")
check(world.state.ethics["C2.0"].values().all(func(n): return n == 2.0), "Bound civilization ethics count physical population")
check((E.eq(world.state.amount["C2.0"], 12) and E.eq(world.state.binds.RD["C2.0"], 0)), "Old space civilizations detach without loss")
check(E.eq(world.state.binds.RD["C1.9"], 3), "Planetary civilizations retain their host")
check(E.eq(world.state.ethics["C0.2"], {"m": 2, "l": 2, "w": 1, "d": 1, "c": 1, "b": 1}), "Migration uses equal ethics and deterministic remainder")
check(world.state.ethics["C2.0"].values().all(func ( n ) : return E.eq(n, 2)), "Bound civilization ethics count physical population")
var before: Dictionary = world.state.duplicate(true)
world.evolution.initialize_life_batches()
check(world.state == before, "Repeated initialization preserves ethics")
world.state.ethics["C0.2"] = {"m": 8.0, "l": 0.0, "w": 0.0, "d": 0.0, "c": 0.0, "b": 0.0}
check(E.eq(world.state, before), "Repeated initialization preserves ethics")
world.state.ethics["C0.2"] = {"m": 8, "l": 0, "w": 0, "d": 0, "c": 0, "b": 0}
world.evolution.initialize_life_batches()
check(world.state.ethics["C0.2"].m == 8.0, "Existing unequal distribution is retained")
world.state.amount["C0.2"] = 3.0
check(E.eq(world.state.ethics["C0.2"].m, 8), "Existing unequal distribution is retained")
world.state.amount["C0.2"] = 3
world.evolution.initialize_life_batches()
check(world.state.ethics["C0.2"].m == 3.0, "Losses reduce ethics without inventing population")
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Ethics state validates")
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": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Ethics state validates")
var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
restored.evolution.initialize_life_batches()
check(restored.state == world.state, "Save roundtrip preserves ethics and ownership")
check(E.eq(restored.state, world.state), "Save roundtrip preserves ethics and ownership")
dispose(restored)
dispose(world)
world = fixture()
world.state.amount.RD = 10.0
for row: Dictionary in global.CivilizationRules["C1.9"].ethics.values():
world.state.amount.RD = 10
for row : Dictionary in global.CivilizationRules["C1.9"].ethics.values():
row.progress = "1"
row.degradation = "0"
row.colonization = "0"
world.state.binds.RD = {"C1.9": 10.0}
world.state.batches["C1.9"] = [{"amount": 10.0, "start_year": 1.0, "end_year": 1.0}]
world.evolution.evolve_batches(world.state, 1.0)
check(world.state.amount["C2.0"] == 10.0 and world.state.binds.RD.get("C2.0", 0.0) == 0.0, "Space successor does not inherit planetary binding")
world.state.binds.RD = {"C1.9": 10}
world.state.batches["C1.9"] = [{"amount": 10, "start_year": 1, "end_year": 1}]
world.evolution.evolve_batches(world.state, 1)
check((E.eq(world.state.amount["C2.0"], 10) and E.eq(world.state.binds.RD.get("C2.0", 0), 0)), "Space successor does not inherit planetary binding")
dispose(world)
var capacity = load("res://world/owniverse/civilization_state.gd")
world = fixture()
world.state.amount.RD = 10.0
world.state.amount["C2.0"] = 8.0
world.state.amount.RD = 10
world.state.amount["C2.0"] = 8
var budget: Dictionary = capacity.stellar_budget(world, world.state, global.CivilizationRules)
check(capacity.admit_transition(budget, global.CivilizationRules, "C1.9", "C2.0", 5.0) == 2.0, "Growth consumes only remaining stellar capacity")
check(capacity.admit_transition(budget, global.CivilizationRules, "C1.9", "C2.0", 5.0) == 0.0, "A reservation cannot be spent twice")
world.state.amount["C2.0"] = 1.0
check(E.eq(capacity.admit_transition(budget, global.CivilizationRules, "C1.9", "C2.0", 5), 2), "Growth consumes only remaining stellar capacity")
check(E.eq(capacity.admit_transition(budget, global.CivilizationRules, "C1.9", "C2.0", 5), 0), "A reservation cannot be spent twice")
world.state.amount["C2.0"] = 1
budget = capacity.stellar_budget(world, world.state, global.CivilizationRules)
check(capacity.admit_transition(budget, global.CivilizationRules, "C2.0", "C2.1", 1.0) == 1.0, "A transition reuses the source reservation")
check(budget.free.DwStar == 0.0, "C2.1 reserves ten dwarf stars")
world.state.amount["C2.0"] = 0.0
world.state.binds.SC = {"RD": 10.0}
check(E.eq(capacity.admit_transition(budget, global.CivilizationRules, "C2.0", "C2.1", 1), 1), "A transition reuses the source reservation")
check(E.eq(budget.free.DwStar, 0), "C2.1 reserves ten dwarf stars")
world.state.amount["C2.0"] = 0
world.state.binds.SC = {"RD": 10}
budget = capacity.stellar_budget(world, world.state, global.CivilizationRules)
check(budget.capacity.DwStar == 20.0, "Stars inside structures are counted once")
check(E.eq(budget.capacity.DwStar, 20), "Stars inside structures are counted once")
dispose(world)
world = fixture()
world.state.amount.RD = 12.0
world.state.amount["C2.1"] = 2.0
world.state.amount["C2.0"] = 5.0
world.state.amount.RD = 12
world.state.amount["C2.1"] = 2
world.state.amount["C2.0"] = 5
world.evolution.initialize_life_batches()
check(world.state.amount["C2.1"] == 1.0 and world.state.amount["C2.0"] == 2.0, "Shared budget supports advanced civilizations first")
check(world.state.binds.RD["C1.9"] == 4.0, "Excess cascades to supported planetary stage")
check((E.eq(world.state.amount["C2.1"], 1) and E.eq(world.state.amount["C2.0"], 2)), "Shared budget supports advanced civilizations first")
check(E.eq(world.state.binds.RD["C1.9"], 4), "Excess cascades to supported planetary stage")
var snapshot: Dictionary = world.state.duplicate(true)
world.evolution.initialize_life_batches()
check(world.state == snapshot, "Capacity enforcement is idempotent")
world.state.amount.RD = 0.0
check(E.eq(world.state, snapshot), "Capacity enforcement is idempotent")
world.state.amount.RD = 0
world.evolution.initialize_life_batches()
check(world.state.amount["C2.1"] == 0.0 and world.state.amount["C2.0"] == 0.0, "Vanished stars force all unsupported space civilizations down")
check(world.ledger.physical_amounts(world.state)["C1.9"] == 7.0, "Forced degradation conserves civilizations")
check((E.eq(world.state.amount["C2.1"], 0) and E.eq(world.state.amount["C2.0"], 0)), "Vanished stars force all unsupported space civilizations down")
check(E.eq(world.ledger.physical_amounts(world.state)["C1.9"], 7), "Forced degradation conserves civilizations")
dispose(world)
world = fixture()
world.state.amount.LGW = 20.0
world.state.amount.SMBH = 2.0e22
world.state.amount["C4.2"] = 3.0
world.state.amount.LGW = 20
world.state.amount.SMBH = E.of("2.0e22")
world.state.amount["C4.2"] = 3
world.evolution.initialize_life_batches()
check(world.state.amount["C4.2"] == 2.0, "SMBH capacity uses stellar mass equivalent")
check(world.ledger.physical_amounts(world.state)["C1.9"] == 1.0, "Unsupported civilization degrades through missing habitat families")
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Capacity-limited state validates")
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": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Capacity-limited state validates")
dispose(world)
world = fixture()
world.state.amount.RD = 2.0
for row: Dictionary in global.CivilizationRules["C1.9"].ethics.values():
world.state.amount.RD = 2
for row : Dictionary in global.CivilizationRules["C1.9"].ethics.values():
row.progress = "1"
row.degradation = "0"
row.colonization = "0"
world.state.binds.RD = {"C1.9": 5.0}
world.state.batches["C1.9"] = [{"amount": 5.0, "start_year": 1.0, "end_year": 1.0}]
world.evolution.evolve_batches(world.state, 1.0)
check(world.state.amount["C2.0"] == 2.0 and world.state.binds.RD["C1.9"] == 3.0, "Unfunded transition retains the original planetary civilization")
world.state.binds.RD = {"C1.9": 5}
world.state.batches["C1.9"] = [{"amount": 5, "start_year": 1, "end_year": 1}]
world.evolution.evolve_batches(world.state, 1)
check((E.eq(world.state.amount["C2.0"], 2) and E.eq(world.state.binds.RD["C1.9"], 3)), "Unfunded transition retains the original planetary civilization")
dispose(world)
print("Civilization state: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Civilization state: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+39 -38
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -39,39 +40,39 @@ func run() -> void:
var global: Node = root.get_node("Global")
var description = load("res://world/owniverse/item_description.gd")
var world = fixture()
world.state.amount.H = 8.0
world.state.amount.He = 2.0
world.state.amount.H = 8
world.state.amount.He = 2
for locale in ["en", "ru"]:
global.Locale = locale
var text: String = description.render(world, global, "RD")
check(text.contains("[b]") and not text.contains("%value"), "Localized template resolved: " + locale)
check(text.contains("#40cc40"), "Affordable cost green: " + locale)
check(text.contains("4.0000E+10"), "Lifespan substituted: " + locale)
check((text.contains("[b]") and (not text.contains("%value"))), E.add("Localized template resolved: ", locale))
check(text.contains("#40cc40"), E.add("Affordable cost green: ", locale))
check(text.contains("4.0000E+10"), 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"), "Helium metadata and mixture: " + locale)
check((text.contains("He: 20.00% / H: 80.00%") and (not text.contains("%value"))), E.add("Helium metadata and mixture: ", locale))
text = description.render(world, global, "Shovelin", "Force")
check(text.count("]—[/color]") == 4, "Missing force levels use dash: " + locale)
check(E.eq(text.count("]—[/color]"), 4), E.add("Missing force levels use dash: ", locale))
text = description.render(world, global, "YD")
check(text.contains("#ff6060"), "Insufficient cost red: " + locale)
check(text.contains("#ff6060"), E.add("Insufficient cost red: ", locale))
var reordered: Dictionary = {"text": "%value0% / %value1%", "value0": "lifespan", "value1": "amount"}
check(global.render_template(reordered, {"amount": "12", "lifespan": "99"}) == "99 / 12", "Metadata chooses variables, not fixed argument positions")
world.state.amount.RD = 2.0
world.state.binds.G = {"RD": 3.0, "HP": 7.0}
var values: Dictionary = description.variables(world, global, "RD", -2.0)
check(values.amount.contains("2") and values.amount.contains("(5)") and values.amount.contains("#888888"), "Free and total counts with gray binding total")
check(values.amount.contains("#ff6060") and values.amount.contains("−2.0"), "Real-time decrease red")
check(E.eq(global.render_template(reordered, {"amount": "12", "lifespan": "99"}), "99 / 12"), "Metadata chooses variables, not fixed argument positions")
world.state.amount.RD = 2
world.state.binds.G = {"RD": 3, "HP": 7}
var values: Dictionary = description.variables(world, global, "RD", E.neg(2))
check(((values.amount.contains("2") and values.amount.contains("(5)")) and values.amount.contains("#888888")), "Free and total counts with gray binding total")
check((values.amount.contains("#ff6060") and values.amount.contains("−2.0")), "Real-time decrease red")
values = description.variables(world, global, "HP")
check(values.amount == "7", "Planet description includes bound planets")
world.state.amount.EON1 = 2.0
world.state.binds.G.EON2 = 3.0
check(E.eq(values.amount, "7"), "Planet description includes bound planets")
world.state.amount.EON1 = 2
world.state.binds.G.EON2 = 3
var saved_state: Dictionary = world.state.duplicate(true)
var counters: Dictionary = world.presentation.amounts(world.state, world.catalog)
check(counters.HP == 7.0 and counters.LP == 5.0 and counters.Planet == 12.0, "Planet counters include bound objects and aggregate eons")
check(counters.RD == 2.0, "Star counters retain available quantity")
check(world.state == saved_state, "Reading counters does not mutate simulation")
check(world.presentation.amounts(world.state, world.catalog, true).HP == 0.0, "Produced counters remain independent of current population")
values = description.variables(world, global, "RD", 2.0)
check(values.amount.contains("#40cc40") and values.amount.contains("+2.0"), "Increase green")
check(((E.eq(counters.HP, 7) and E.eq(counters.LP, 5)) and E.eq(counters.Planet, 12)), "Planet counters include bound objects and aggregate eons")
check(E.eq(counters.RD, 2), "Star counters retain available quantity")
check(E.eq(world.state, saved_state), "Reading counters does not mutate simulation")
check(E.eq(world.presentation.amounts(world.state, world.catalog, true).HP, 0), "Produced counters remain independent of current population")
values = description.variables(world, global, "RD", 2)
check((values.amount.contains("#40cc40") and values.amount.contains("+2.0")), "Increase green")
var stage_script = load("res://stage/stage.gd")
check(stage_script.can_instantiate(), "Stage integration compiles")
var stage = stage_script.new()
@@ -81,9 +82,9 @@ func run() -> void:
stage.g_item_descriptions = [label]
stage.description_targets[0] = {"id": "RD", "category": "Entity"}
stage._refresh_descriptions()
check(label.bbcode_enabled and label.text.contains("Красный карлик"), "Stage displays localized rich text")
check((label.bbcode_enabled and label.text.contains("Красный карлик")), "Stage displays localized rich text")
stage._update_descriptions(0.25)
world.state.amount.RD += 1.0
world.state.amount.RD = E.add(world.state.amount.RD, 1)
stage._update_descriptions(0.25)
check(label.text.contains("+4.0"), "Selected description refreshes with rate per real second")
global.Locale = "en"
@@ -92,5 +93,5 @@ func run() -> void:
label.free()
stage.free()
dispose(world)
print("Descriptions: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Descriptions: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+76
View File
@@ -0,0 +1,76 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Q = preload("res://world/owniverse/quantity_math.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
func _initialize():
call_deferred("run")
func check(actual, expected: String, label: String):
checks += 1
if str(actual) != expected:
failures += 1
push_error(label + ": " + str(actual) + " != " + expected)
func run():
# Expected strings were generated independently with Python fractions.Fraction.
var cases = JSON.parse_string(FileAccess.get_file_as_string("res://tests/fixtures/exact_oracle.json"))
for row in cases:
var a = E.of(row[0])
var b = E.of(row[1])
check(E.add(a, b), row[2], "sum")
check(E.subtract(a, b), row[3], "difference")
check(E.multiply(a, b), row[4], "product")
check(E.divide(a, b), row[5], "quotient")
check(E.floor_value(E.divide(a, b)), row[6], "floor")
for text in ["9223372036854775807", "-9223372036854775808", "9007199254740993", "1000000000000000000000000001", "-1/10000000000000"]:
var value = E.of(text)
check(E.decode(JSON.parse_string(JSON.stringify(E.encode(value)))), text, "JSON round trip")
check(E.add(E.of("9223372036854775807"), 1), "9223372036854775808", "int64 overflow promoted")
check(E.subtract(E.of("-9223372036854775808"), 1), "-9223372036854775809", "int64 underflow promoted")
check(Q.production_step([E.of("1e27")]), "1", "large integral recipe")
check(Q.production_step([E.of("1e-13")]), "10000000000000", "tiny fractional recipe")
var count = E.of("1000000000000000000000000001")
var allocation = Q.allocate_whole({"a": count, "b": count, "c": count}, count)
check(Q.sum(allocation), str(count), "allocation conserves every unit")
check(allocation.a, "333333333333333333333333334", "deterministic remainder")
var clock = Clock.advance({"datetime": E.of("1e27")}, 0.000000001, 1)
check(E.subtract(clock.datetime, E.of("1e27")), "1/1000000000", "single tick at huge age")
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.production.produce_hs("H", 1)
check(world.state.energy, "999999999999999999999999999", "spend one energy")
var life = world.life.life_products(world.state, world.catalog.entities.EON5, count)
check(Q.sum(life), str(count), "life conservation at 1e27")
var precise = E.divide(count, E.of("100000000000000000000000000000000000003"))
var bounded = E.bounded(precise)
check(E.lt(E.absolute(E.subtract(precise, bounded)), E.of("1e-36")), "true", "bounded fractional error")
check(E.bounded(count), str(count), "integer counts never quantized")
world.state.amount.S = E.of("1e24")
world.state.amount.H = E.of("1e24")
world.state.amount.RD = 1234567
world.state.amount.YD = 765432
world.state.amount.STN = 654321
world.speed = 6
var started: int = Time.get_ticks_msec()
for i in 100:
world.state.amount.RD = E.add(world.state.amount.RD, i + 1)
world.evolute(0.1)
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": 3, "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])
quit(0 if failures == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://wxhre4pgx1lb
+20 -19
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -40,19 +41,19 @@ func run() -> void:
for id in world.catalog.entities:
var expected: Array = []
for key in world.catalog.entities:
if world.catalog.entities[key].group_as == id:
if E.eq(world.catalog.entities[key].group_as, id):
expected.append(key)
check(world.catalog.is_group(id) == (not expected.is_empty()), "Group classification: " + id)
check(world.catalog.members(id) == (expected if not expected.is_empty() else [id]), "Group members: " + id)
check(E.eq(world.catalog.is_group(id), ((not expected.is_empty()))), E.add("Group classification: ", id))
check(E.eq(world.catalog.members(id), ((expected if (not expected.is_empty()) else [id]))), E.add("Group members: ", id))
var members: Array = world.catalog.members("Star")
members.clear()
check(not world.catalog.members("Star").is_empty(), "Returned members are independent")
check((not world.catalog.members("Star").is_empty()), "Returned members are independent")
world.catalog.entities.N.group_as = "TestGroup"
world.catalog.rebuild_group_index()
check(world.catalog.members("TestGroup") == ["N"], "Explicit definition reload")
check(E.eq(world.catalog.members("TestGroup"), ["N"]), "Explicit definition reload")
world.catalog.entities.N.free()
world.catalog.entities.erase("N")
check(not world.catalog.is_group("TestGroup"), "Removed definition invalidates index")
print("Group index: %d checks, %d failures" % [checks, failures])
check((not world.catalog.is_group("TestGroup")), "Removed definition invalidates index")
print(("Group index: %d checks, %d failures" % [checks, failures]))
dispose(world)
quit(1 if failures else 0)
quit((1 if failures else 0))
+65 -64
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -37,87 +38,87 @@ func dispose(world) -> void:
func run() -> void:
var world = fixture()
world.state.amount.N = 0.0
world.state.amount.HP = 40000.0
world.state.amount.N = 0
world.state.amount.HP = 40000
world.evolution.initialize_life_batches()
check(world.state.batches.HP.size() == 1, "Old HP receives life cohort")
check(world.state.batches.HP[0].start_year == 5e6, "HP uses source span")
check(E.eq(world.state.batches.HP.size(), 1), "Old HP receives life cohort")
check(E.eq(world.state.batches.HP[0].start_year, 5000000), "HP uses source span")
var before: Dictionary = world.state.duplicate(true)
world.evolution.initialize_life_batches()
check(world.state == before, "Initialization is idempotent")
world.evolution.evolve_batches(world.state, 5e6 + 5e6 / 40.0)
check(world.state.amount.EON1 == 10.0, "HP abiogenesis follows source probability")
check(world.state.amount.RP == 100.0, "HP degradation produces RP")
check(world.state.amount.HP == 39890.0, "Unchanged planets retry, not disappear")
check(world.state.stats.total_amount.HP == 0.0, "Retry does not inflate creation statistics")
check(world.state.batches.HP.size() > 0, "Survivors get next attempt")
check(E.eq(world.state, before), "Initialization is idempotent")
world.evolution.evolve_batches(world.state, E.add(5000000, E.divide(5000000, 40)))
check(E.eq(world.state.amount.EON1, 10), "HP abiogenesis follows source probability")
check(E.eq(world.state.amount.RP, 100), "HP degradation produces RP")
check(E.eq(world.state.amount.HP, 39890), "Unchanged planets retry, not disappear")
check(E.eq(world.state.stats.total_amount.HP, 0), "Retry does not inflate creation statistics")
check(E.gt(world.state.batches.HP.size(), 0), "Survivors get next attempt")
var physical: Dictionary = world.ledger.physical_amounts(world.state)
check(physical.HP + physical.EON1 + physical.RP == 40000.0, "Life conserves planets")
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Life save validates")
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": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Life save validates")
var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
world.evolution.evolve_batches(world.state, 11e6)
restored.evolution.evolve_batches(restored.state, 11e6)
check(world.state == restored.state, "Life continuation preserves cohorts and carries")
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolution.evolve_batches(world.state, 11000000)
restored.evolution.evolve_batches(restored.state, 11000000)
check(E.eq(world.state, restored.state), "Life continuation preserves cohorts and carries")
dispose(restored)
dispose(world)
world = fixture()
world.state.amount.RD = 1.0
world.state.binds.RD = {"EON5": 40000.0}
world.state.amount.RD = 1
world.state.binds.RD = {"EON5": 40000}
world.evolution.initialize_life_batches()
world.evolution.evolve_batches(world.state, 5e5 + 5e5 / 40.0)
check(world.state.binds.RD["C0.0"] == 10.0, "Intelligence emerges at the same host star")
check(world.state.amount["C0.0"] == 0.0, "Life transition keeps planet bound")
world.evolution.evolve_batches(world.state, E.add(500000, E.divide(500000, 40)))
check(E.eq(world.state.binds.RD["C0.0"], 10), "Intelligence emerges at the same host star")
check(E.eq(world.state.amount["C0.0"], 0), "Life transition keeps planet bound")
check(world.state.batches.has("C0.0"), "New C0.0 starts civilization evolution")
dispose(world)
world = fixture()
var progress: float = 0.0
var degraded: float = 0.0
var progress: Variant = 0
var degraded: Variant = 0
var valid_outcomes: bool = true
for i in 4001:
var products: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1.0)
var total: float = 0.0
var products: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1)
var total: Variant = 0
for value in products.values():
total += value
valid_outcomes = valid_outcomes and value >= 0.0 and value == floorf(value)
valid_outcomes = valid_outcomes and total == 1.0
progress += products.get("EON1", 0.0)
degraded += products.get("RP", 0.0)
total = E.add(total, value)
valid_outcomes = ((valid_outcomes and E.ge(value, 0)) and E.eq(value, E.floor_value(value)))
valid_outcomes = (valid_outcomes and E.eq(total, 1))
progress = E.add(progress, products.get("EON1", 0))
degraded = E.add(degraded, products.get("RP", 0))
check(valid_outcomes, "Every single-planet attempt has whole outcomes and conserves population")
check(progress == 1.0 and degraded == 10.0, "Rare outcomes accumulate deterministically")
check((E.eq(progress, 1) and E.eq(degraded, 10)), "Rare outcomes accumulate deterministically")
dispose(world)
world = fixture()
world.state.time = {"datetime": 123.4, "year": 123.4}
world.state.binds.RD = {"HP": 2.0}
world.state.time = {"datetime": E.of("123.4"), "year": E.of("123.4")}
world.state.binds.RD = {"HP": 2}
world.evolution.initialize_life_batches()
check(world.state.batches.HP[0].created_from == 123.4, "Old bound worlds begin life at load time")
world.stellar.destroy_planets(world.state, "RD", 1.0, 1)
world.evolution.evolve_batches(world.state, 1e7)
check(world.ledger.physical_amounts(world.state).HP == 0.0 and world.state.binds.RD.RP == 2.0, "Destroyed life does not resurrect from cohort")
check(E.eq(world.state.batches.HP[0].created_from, E.of("123.4")), "Old bound worlds begin life at load time")
world.stellar.destroy_planets(world.state, "RD", 1, 1)
world.evolution.evolve_batches(world.state, 10000000)
check((E.eq(world.ledger.physical_amounts(world.state).HP, 0) and E.eq(world.state.binds.RD.RP, 2)), "Destroyed life does not resurrect from cohort")
dispose(world)
world = fixture()
world.state.time = {"datetime": 123.4, "year": 123.4}
world.state.amount["C0.0"] = 40000.0
world.state.time = {"datetime": E.of("123.4"), "year": E.of("123.4")}
world.state.amount["C0.0"] = 40000
world.evolution.initialize_life_batches()
check(world.state.batches["C0.0"][0].created_from == 123.4, "Civilization cohorts begin at the current time")
check(E.eq(world.state.batches["C0.0"][0].created_from, E.of("123.4")), "Civilization cohorts begin at the current time")
before = world.state.duplicate(true)
world.evolution.initialize_life_batches()
check(world.state == before, "Life batch initialization is idempotent")
world.evolution.evolve_batches(world.state, 2173.4)
check(world.state.amount["C0.1"] == 700.0 and world.state.amount.EON5 == 1000.0, "C0.0 progresses and degrades by Unity rates")
check(world.state.amount["C0.0"] == 38300.0, "C0.0 remainder stays alive")
check(E.eq(world.state, before), "Life batch initialization is idempotent")
world.evolution.evolve_batches(world.state, E.of("2173.4"))
check((E.eq(world.state.amount["C0.1"], 700) and E.eq(world.state.amount.EON5, 1000)), "C0.0 progresses and degrades by Unity rates")
check(E.eq(world.state.amount["C0.0"], 38300), "C0.0 remainder stays alive")
dispose(world)
world = fixture()
world.state.amount.RD = 1.0
world.state.binds.RD = {"C0.1": 40000.0}
world.state.amount.RD = 1
world.state.binds.RD = {"C0.1": 40000}
world.evolution.initialize_life_batches()
world.evolution.evolve_batches(world.state, 2050.0)
check(world.state.binds.RD["C0.2"] == 700.0, "C0.1 reaches ethical civilization without losing host")
check(world.state.binds.RD["C0.0"] == 1000.0, "C0.1 degradation follows Unity")
check(root.get_node("SaveManager").validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Extended life save validates")
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": 3, "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 failures == 0 else 1)
print(("Life: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+28 -27
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures += 1
checks = E.add(checks, 1)
if (not value):
failures = E.add(failures, 1)
push_error(message)
func run() -> void:
@@ -21,48 +22,48 @@ func run() -> void:
await process_frame
await process_frame
var world = stage.owniverse
world.state.binds.RD = {"EON1": 10.0, "EON2": 4.0, "C0.0": 3.0}
world.state.amount.EON1 = 2.0
world.state.stats.total_amount.EON1 = 12.0
world.state.stats.total_amount.EON2 = 4.0
world.state.stats.total_amount["C0.0"] = 3.0
world.state.binds.RD = {"EON1": 10, "EON2": 4, "C0.0": 3}
world.state.amount.EON1 = 2
world.state.stats.total_amount.EON1 = 12
world.state.stats.total_amount.EON2 = 4
world.state.stats.total_amount["C0.0"] = 3
var before: Dictionary = world.state.duplicate(true)
stage._refresh_universe_ui()
var counts: Dictionary = world.presentation.amounts(world.state, world.catalog)
check(counts.EON1 == 12.0 and counts.EON2 == 4.0 and counts.LP == 16.0, "Individual eons and their total include bound planets")
check(counts["C0.0"] == 3.0 and counts.C0 == 3.0 and counts.Planet == 19.0, "Civilization detail does not double the planetary total")
check(world.presentation.amounts(world.state, world.catalog, true).EON1 == 12.0 and world.presentation.amounts(world.state, world.catalog, true).LP == 16.0, "Historical counts retain individual and aggregate values")
check(world.state == before, "Presentation does not modify simulation state")
check(((E.eq(counts.EON1, 12) and E.eq(counts.EON2, 4)) and E.eq(counts.LP, 16)), "Individual eons and their total include bound planets")
check(((E.eq(counts["C0.0"], 3) and E.eq(counts.C0, 3)) and E.eq(counts.Planet, 19)), "Civilization detail does not double the planetary total")
check((E.eq(world.presentation.amounts(world.state, world.catalog, true).EON1, 12) and E.eq(world.presentation.amounts(world.state, world.catalog, true).LP, 16)), "Historical counts retain individual and aggregate values")
check(E.eq(world.state, before), "Presentation does not modify simulation state")
var pending: Array = ["EON1", "EON2", "EON3", "C0.0"]
var pending_before: Array = pending.duplicate()
var decision: Dictionary = world.presentation.unlocks(world.state, world.catalog, pending)
check(decision.remaining == ["EON3"], "Only unmet requirements remain pending")
check(E.eq(decision.remaining, ["EON3"]), "Only unmet requirements remain pending")
for id in ["EON1", "EON2", "LP", "C0.0", "C0"]:
check(decision.visible.has(id), "Unlock projection includes detail and alias: " + id)
check(pending == pending_before and world.state == before, "Unlock evaluation leaves inputs untouched")
check(decision.visible.has(id), E.add("Unlock projection includes detail and alias: ", id))
check((E.eq(pending, pending_before) and E.eq(world.state, before)), "Unlock evaluation leaves inputs untouched")
var snapshot: Dictionary = world.presentation.pending_amounts.duplicate()
world.presentation.deferred_updates = true
world.state.amount.EON1 += 1.0
world.state.amount.EON1 = E.add(world.state.amount.EON1, 1)
world.presentation.refresh(world.state, world.catalog)
check(world.presentation.dirty and world.presentation.pending_amounts == snapshot, "Deferred refresh retains pending snapshot")
check((world.presentation.dirty and E.eq(world.presentation.pending_amounts, snapshot)), "Deferred refresh retains pending snapshot")
world.presentation.refresh(world.state, world.catalog, true)
check(not world.presentation.dirty and world.presentation.pending_amounts.EON1 == 13.0, "Forced refresh publishes latest amounts")
world.state.amount.EON1 -= 1.0
check(((not world.presentation.dirty) and E.eq(world.presentation.pending_amounts.EON1, 13)), "Forced refresh publishes latest amounts")
world.state.amount.EON1 = E.subtract(world.state.amount.EON1, 1)
world.presentation.deferred_updates = false
world.presentation.refresh(world.state, world.catalog)
for id in ["EON1", "EON2", "LP", "C0.0", "C0"]:
check(stage.owniverse_items[id].visible, "Unlock both levels: " + id)
check(stage.owniverse_items[id].description.text != "", "Populate the icon counter: " + id)
check(not stage.owniverse_items.EON3.visible, "Unreached eon remains locked")
check(stage.owniverse_items[id].visible, E.add("Unlock both levels: ", id))
check(E.ne(stage.owniverse_items[id].description.text, ""), E.add("Populate the icon counter: ", id))
check((not stage.owniverse_items.EON3.visible), "Unreached eon remains locked")
stage.item_activated(stage.owniverse_items.LP)
check(stage.owniverse_items.EON1.is_visible_in_tree(), "Selecting LP shows its unlocked eons")
stage.item_activated(stage.owniverse_items.C0)
check(stage.owniverse_items["C0.0"].is_visible_in_tree() and not stage.owniverse_items.EON1.is_visible_in_tree(), "Selecting C0 switches the detail panel")
world.state.binds.RD.EON2 = 0.0
check((stage.owniverse_items["C0.0"].is_visible_in_tree() and (not stage.owniverse_items.EON1.is_visible_in_tree())), "Selecting C0 switches the detail panel")
world.state.binds.RD.EON2 = 0
stage._refresh_universe_ui()
check(stage.owniverse_items.EON2.visible and stage.owniverse_items.EON2.description.text == "", "Previously reached stage stays unlocked after extinction with no stale count")
check((stage.owniverse_items.EON2.visible and E.eq(stage.owniverse_items.EON2.description.text, "")), "Previously reached stage stays unlocked after extinction with no stale count")
for entity in world.catalog.entities.values(): entity.free()
world.free()
scene.free()
print("Life icons: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Life icons: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+15 -14
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func run() -> void:
@@ -19,20 +20,20 @@ func run() -> void:
var names: Array = ["batches", "ledger", "production", "automatic", "stellar", "life", "environment", "evolution"]
var references: Array = []
for name in names:
check(first.get(name) != second.get(name), "World has its own " + name)
check(first.get(name).world == first, "Module refers to its owner: " + name)
check(E.ne(first.get(name), second.get(name)), E.add("World has its own ", name))
check(E.eq(first.get(name).world, first), E.add("Module refers to its owner: ", name))
references.append(weakref(first.get(name)))
first.state.amount.H = 123.0
first.presentation.pending_amounts.H = 123.0
check(not second.state.amount.has("H") and second.presentation.pending_amounts.is_empty(), "State and pending presentation are isolated")
first.state.amount.H = 123
first.presentation.pending_amounts.H = 123
check(((not second.state.amount.has("H")) and second.presentation.pending_amounts.is_empty()), "State and pending presentation are isolated")
first.free()
for reference in references:
check(reference.get_ref() == null, "Module is released with its world")
check(second.evolution.world == second, "Other world remains usable")
check(E.eq(reference.get_ref(), null), "Module is released with its world")
check(E.eq(second.evolution.world, second), "Other world remains usable")
var snapshot: Dictionary = second.state.duplicate(true)
second.evolute(NAN)
second.evolute(-1.0)
check(second.state == snapshot, "Entry point rejects invalid time deltas")
second.evolute(E.neg(1))
check(E.eq(second.state, snapshot), "Entry point rejects invalid time deltas")
second.free()
print("Module lifecycle: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0)
print(("Module lifecycle: %d checks, %d failures" % [checks, failures]))
quit((1 if failures else 0))
+27 -26
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -38,21 +39,21 @@ func dispose(world) -> void:
func run() -> void:
var world = fixture()
world.evolution.initialize_life_batches()
world.catalog.entities.N.cost = {"H": 2.0}
world.catalog.entities.N.bind = {"S": 3.0}
world.state.amount.H = 10.0
world.state.amount.S = 0.0
world.state.auto_resources = {"H": 10.0}
world.state.batches.H = [{"amount": 10.0, "start_year": 1.0, "end_year": 2.0}]
world.catalog.entities.N.cost = {"H": 2}
world.catalog.entities.N.bind = {"S": 3}
world.state.amount.H = 10
world.state.amount.S = 0
world.state.auto_resources = {"H": 10}
world.state.batches.H = [{"amount": 10, "start_year": 1, "end_year": 2}]
var before: Dictionary = world.state.duplicate(true)
check(not world.production.try_production(world.state, world.catalog.entities.N, 1.0, true), "Binding failure rejects production")
check(world.state == before, "Failure preserves costs, batches and budget")
world.state.amount.S = 10.0
check((not world.production.try_production(world.state, world.catalog.entities.N, 1, true)), "Binding failure rejects production")
check(E.eq(world.state, before), "Failure preserves costs, batches and budget")
world.state.amount.S = 10
var old_batches: Array = world.state.batches.H
check(world.production.try_production(world.state, world.catalog.entities.N, 2.0, true), "Funded production succeeds")
check(world.state.amount.H == 6.0 and world.state.amount.S == 4.0, "Exact cost and binding")
check(world.state.auto_resources.H == 6.0, "Exact automatic budget")
check(old_batches[0].amount == 10.0 and world.state.batches.H[0].amount == 6.0, "Consumed batches isolated")
print("Transaction: %d checks, %d failures" % [checks, failures])
check(world.production.try_production(world.state, world.catalog.entities.N, 2, true), "Funded production succeeds")
check((E.eq(world.state.amount.H, 6) and E.eq(world.state.amount.S, 4)), "Exact cost and binding")
check(E.eq(world.state.auto_resources.H, 6), "Exact automatic budget")
check((E.eq(old_batches[0].amount, 10) and E.eq(world.state.batches.H[0].amount, 6)), "Consumed batches isolated")
print(("Transaction: %d checks, %d failures" % [checks, failures]))
dispose(world)
quit(1 if failures else 0)
quit((1 if failures else 0))
+33 -32
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Math = preload("res://world/owniverse/quantity_math.gd")
var checks: int = 0
@@ -8,56 +9,56 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func run() -> void:
var Catalog = load("res://world/owniverse/entity_catalog.gd")
check(Math.allocate_whole({}, 10.0) == {}, "Empty allocation")
check(Math.allocate_whole({"b": 1.0, "a": 1.0}, 1.0) == {"a": 1.0, "b": 0.0}, "Stable lexical tie")
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")
for limit in range(30):
var requests: Dictionary = {"a": 3.9, "b": 9.1, "c": -2.0, "d": 7.0}
var requests: Dictionary = {"a": E.of("3.9"), "b": E.of("9.1"), "c": E.neg(2), "d": 7}
var result: Dictionary = Math.allocate_whole(requests, limit)
check(Math.sum(result) == minf(limit, 19.0), "Conserve bounded allocation")
check(E.eq(Math.sum(result), E.minimum(limit, 19)), "Conserve bounded allocation")
for id in result:
check(result[id] == floorf(result[id]) and result[id] >= 0.0 and result[id] <= floorf(maxf(0.0, requests[id])), "Whole bounded branch")
check(Math.normalize_distribution({"a": -1.0, "b": 0.0}) == {}, "Nonpositive weights")
check(Math.normalize_distribution({"a": 2.0, "b": 6.0}) == {"a": 0.25, "b": 0.75}, "Normalize weights")
check(Math.production_step([0.25, 0.1, 2.0]) == 20.0, "Common whole production step")
check(Math.production_step([]) == 1.0, "Free production step")
check(((E.eq(result[id], E.floor_value(result[id])) and E.ge(result[id], 0)) and E.le(result[id], E.floor_value(E.maximum(0, requests[id])))), "Whole bounded branch")
check(E.eq(Math.normalize_distribution({"a": E.neg(1), "b": 0}), {}), "Nonpositive weights")
check(E.eq(Math.normalize_distribution({"a": 2, "b": 6}), {"a": E.of("0.25"), "b": E.of("0.75")}), "Normalize weights")
check(E.eq(Math.production_step([E.of("0.25"), E.of("0.1"), 2]), 20), "Common whole production step")
check(E.eq(Math.production_step([]), 1), "Free production step")
var target: Dictionary = {}
var produced: float = 0.0
var produced: Variant = 0
for i in 10:
produced += Math.accumulate_whole(target, "remainders", "a", 0.1)
check(produced == 1.0, "Fractional production carries")
produced = E.add(produced, Math.accumulate_whole(target, "remainders", "a", E.of("0.1")))
check(E.eq(produced, 1), "Fractional production carries")
var remainders: Dictionary = {}
var totals: Dictionary = {"a": 0.0, "b": 0.0, "c": 0.0}
var totals: Dictionary = {"a": 0, "b": 0, "c": 0}
for i in 30:
var result: Dictionary = Math.split(remainders, "split", 1.0, {"a": 1.0, "b": 1.0, "c": 1.0})
check(Math.sum(result) == 1.0, "Split conserves count")
for id in result: totals[id] += result[id]
check(totals == {"a": 10.0, "b": 10.0, "c": 10.0}, "Rounding debt distributes over time")
produced = 0.0
for i in 10: produced += Math.accumulate_rate(remainders, "rate", 1.0, 0.1)
check(produced == 1.0, "Rate carries")
var result: Dictionary = Math.split(remainders, "split", 1, {"a": 1, "b": 1, "c": 1})
check(E.eq(Math.sum(result), 1), "Split conserves count")
for id in result: totals[id] = E.add(totals[id], result[id])
check(E.eq(totals, {"a": 10, "b": 10, "c": 10}), "Rounding debt distributes over time")
produced = 0
for i in 10: produced = E.add(produced, Math.accumulate_rate(remainders, "rate", 1, E.of("0.1")))
check(E.eq(produced, 1), "Rate carries")
var catalog = Catalog.new()
var rows: Dictionary = root.get_node("Global").OwniverseEntities
catalog.load_definitions(rows)
check(catalog.entities.size() == rows.size(), "Catalog loads all definitions")
check(E.eq(catalog.entities.size(), rows.size()), "Catalog loads all definitions")
for id in catalog.entities:
var entity = catalog.entities[id]
check(catalog.automatic_producers.has(entity) == (entity.product_per_year_type != ""), "Producer index")
check(catalog.unlockable_entities.has(entity) == (not entity.unlock.is_empty()), "Unlock index")
if entity.group_as != "":
check(catalog.groups[entity.group_as].has(id) and catalog.members(entity.group_as).has(id), "Group index")
if entity.event_group != "":
check(E.eq(catalog.automatic_producers.has(entity), (E.ne(entity.product_per_year_type, ""))), "Producer index")
check(E.eq(catalog.unlockable_entities.has(entity), ((not entity.unlock.is_empty()))), "Unlock index")
if E.ne(entity.group_as, ""):
check((catalog.groups[entity.group_as].has(id) and catalog.members(entity.group_as).has(id)), "Group index")
if E.ne(entity.event_group, ""):
check(catalog.event_groups[entity.event_group].has(id), "Event index")
var members: Array = catalog.members("Star")
members.clear()
check(not catalog.members("Star").is_empty(), "Group results are independent")
check((not catalog.members("Star").is_empty()), "Group results are independent")
check(Catalog.new().entities.is_empty(), "Catalog instances are independent")
for entity in catalog.entities.values(): entity.free()
print("Quantity and catalog: %d checks, %d failures" % [checks, failures])
quit(1 if failures else 0)
print(("Quantity and catalog: %d checks, %d failures" % [checks, failures]))
quit((1 if failures else 0))
+39 -38
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var checks: int = 0
var failures: int = 0
@@ -6,20 +7,20 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture(legacy: bool):
var world = load("res://tests/fixtures/legacy_stellar_events.gd" if legacy else "res://world/owniverse/owniverse.gd").new()
var world = load(("res://tests/fixtures/legacy_stellar_events.gd" if legacy else "res://world/owniverse/owniverse.gd")).new()
for id in root.get_node("Global").OwniverseEntities:
world.catalog.entities[id] = load("res://world/owniverse/owniverse_entity.gd").new(root.get_node("Global").OwniverseEntities[id])
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if world.catalog.entities[id].group_as != "":
world.state.amount[world.catalog.entities[id].group_as] = 0.0
world.state.stats.total_amount[world.catalog.entities[id].group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(world.catalog.entities[id].group_as, ""):
world.state.amount[world.catalog.entities[id].group_as] = 0
world.state.stats.total_amount[world.catalog.entities[id].group_as] = 0
return world
func dispose(world) -> void:
@@ -27,52 +28,52 @@ func dispose(world) -> void:
world.free()
func equivalent(a: Variant, b: Variant) -> bool:
if a is Dictionary and b is Dictionary:
if a.size() != b.size(): return false
if ((a is Dictionary) and (b is Dictionary)):
if E.ne(a.size(), b.size()): return false
for key in a:
if not b.has(key) or not equivalent(a[key], b[key]): return false
if ((not b.has(key)) or (not equivalent(a[key], b[key]))): return false
return true
if a is Array and b is Array:
if a.size() != b.size(): return false
if ((a is Array) and (b is Array)):
if E.ne(a.size(), b.size()): return false
for i in a.size():
if not equivalent(a[i], b[i]): return false
if (not equivalent(a[i], b[i])): return false
return true
if (a is float or a is int) and (b is float or b is int):
return absf(float(a) - float(b)) < 1e-6
return a == b
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"))
return E.eq(a, b)
func run() -> void:
for parent in ["LRG", "HRG", "RSG", "RHG"]:
for year in [101.0, 101.5, 104.75, 1767.3, 3005.0]:
for year in [101, E.of("101.5"), E.of("104.75"), E.of("1767.3"), 3005]:
var fast = fixture(false)
var legacy = fixture(true)
fast.state.amount[parent] = 20000.0
fast.state.amount.RD = 100000.0
fast.state.amount.HP = 50.0
fast.state.amount.Met = 100.0
fast.state.binds.RD = {"HP": 2000.0, "C0.1": 100.0}
fast.state.binds[parent] = {"HP": 10.0}
fast.state.batches[parent] = [{"amount": 20000.0, "start_year": 100.3, "end_year": 3000.7}]
fast.state.amount[parent] = 20000
fast.state.amount.RD = 100000
fast.state.amount.HP = 50
fast.state.amount.Met = 100
fast.state.binds.RD = {"HP": 2000, "C0.1": 100}
fast.state.binds[parent] = {"HP": 10}
fast.state.batches[parent] = [{"amount": 20000, "start_year": E.of("100.3"), "end_year": E.of("3000.7")}]
# Existing short-lived populations test integration with new arrivals.
fast.state.amount.SN = 3.0
fast.batches.append_batch(fast.state, "SN", 3.0, 100.5, 100.5)
if year == 104.75: fast.state.energy = 7.0
fast.state.amount.SN = 3
fast.batches.append_batch(fast.state, "SN", 3, E.of("100.5"), E.of("100.5"))
if E.eq(year, E.of("104.75")): fast.state.energy = 7
legacy.state = fast.state.duplicate(true)
fast.evolution.evolve_batches(fast.state, year)
legacy.evolution.evolve_batches(legacy.state, year)
for field in fast.state:
check(equivalent(fast.state[field], legacy.state[field]), "%s at %s: %s matches materialized events" % [parent, year, field])
check(equivalent(fast.state[field], legacy.state[field]), ("%s at %s: %s matches materialized events" % [parent, year, field]))
for id in ["SN", "DSN", "BSN", "HN", "GRB", "SGRB", "LGRB", "HGRB"]:
check(fast.state.batches.get(id, []).size() <= 3, "Only surviving event windows remain")
check(E.le(fast.state.batches.get(id, []).size(), 3), "Only surviving event windows remain")
dispose(fast)
dispose(legacy)
var fast = fixture(false)
var pending: Dictionary = {}
fast.batches.append_batch(fast.state, "SN", 10000.0, 0.25, 1667.25, pending, 1667.25)
check(fast.state.batches.SN.size() <= 2 and pending.SN.size() == 1, "Completed windows aggregate without transient batch allocation")
var total: float = pending.SN[0].amount
for batch in fast.state.batches.SN: total += batch.amount
check(total == 10000.0, "Direct completions plus survivors conserve event count")
fast.batches.append_batch(fast.state, "SN", 10000, E.of("0.25"), E.of("1667.25"), pending, E.of("1667.25"))
check((E.le(fast.state.batches.SN.size(), 2) and E.eq(pending.SN.size(), 1)), "Completed windows aggregate without transient batch allocation")
var total: Variant = pending.SN[0].amount
for batch in fast.state.batches.SN: total = E.add(total, batch.amount)
check(E.eq(total, 10000), "Direct completions plus survivors conserve event count")
dispose(fast)
print("Short-lived events: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Short-lived events: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+155 -154
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var checks: int = 0
var failures: int = 0
@@ -6,9 +7,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -17,16 +18,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -37,226 +38,226 @@ func dispose(world) -> void:
func run() -> void:
var world = fixture()
for id in ["DSN", "SN", "BSN", "HN", "SGRB", "GRB", "LGRB", "HGRB"]:
check(world.stellar.evolution_products(world.state, world.catalog.entities[id], 3.0).blast == 3.0 * world.catalog.entities[id].blast, "Entity blast determines power: " + id)
world.catalog.entities.SN.output_and.blast = 999.0
check(E.eq(world.stellar.evolution_products(world.state, world.catalog.entities[id], 3).blast, E.multiply(3, world.catalog.entities[id].blast)), E.add("Entity blast determines power: ", id))
world.catalog.entities.SN.output_and.blast = 999
world.catalog.entities.SN.blast = 4
check(world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).blast == 8.0, "Imported output cannot override or double blast power")
check(E.eq(world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2).blast, 8), "Imported output cannot override or double blast power")
world.catalog.entities.SN.blast = 0
check(not world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).has("blast"), "Zero entity blast disables external event")
world.catalog.entities.SN.output_or = {"positive": "blast:999", "negative": "blast:999", "chance": 50.0}
check(not world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).has("blast"), "Alternative output cannot restore disabled blast")
check((not world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2).has("blast")), "Zero entity blast disables external event")
world.catalog.entities.SN.output_or = {"positive": "blast:999", "negative": "blast:999", "chance": 50}
check((not world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2).has("blast")), "Alternative output cannot restore disabled blast")
dispose(world)
world = fixture()
var rd = world.catalog.entities.RD
check(rd.planets.HP.chance == 0.01, "Preserve fractional planet probability")
check(rd.planets.GG.amount == 2.0 and rd.planets.GG.chance == 0.08, "Read count and chance separately")
check(rd._parse_planet_string("HP:1:0.01") == {"HP": {"amount": 1.0, "chance": 0.01}}, "Accept a single planet term")
check(E.eq(rd.planets.HP.chance, E.of("0.01")), "Preserve fractional planet probability")
check((E.eq(rd.planets.GG.amount, 2) and E.eq(rd.planets.GG.chance, E.of("0.08"))), "Read count and chance separately")
check(E.eq(rd._parse_planet_string("HP:1:0.01"), {"HP": {"amount": 1, "chance": E.of("0.01")}}), "Accept a single planet term")
check(rd._parse_planet_string("").is_empty(), "Empty planet definitions stay empty")
for id in ["HP", "EON1", "C0.9", "C1.2"]:
check(world.catalog.entities[id].blastability, "Blast vulnerable: " + id)
check(world.catalog.entities[id].blastability, E.add("Blast vulnerable: ", id))
for id in ["RP", "GG", "AB", "C1.3", "C1.4", "C1.5"]:
check(not world.catalog.entities[id].blastability, "Blast protected: " + id)
check((not world.catalog.entities[id].blastability), E.add("Blast protected: ", id))
# Actual GI table: HRG -> SN, with 1% GRB; no random rounding.
var totals: Dictionary = {}
for i in 100:
var products: Dictionary = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 1.0)
var products: Dictionary = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 1)
for id in products:
totals[id] = totals.get(id, 0.0) + products[id]
check(totals.get("SN") == 99.0 and totals.get("GRB") == 1.0, "GI rare outcomes accumulate deterministically")
totals[id] = E.add(totals.get(id, 0), products[id])
check((E.eq(totals.get("SN"), 99) and E.eq(totals.get("GRB"), 1)), "GI rare outcomes accumulate deterministically")
var restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
check(world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 37.0) == restored.stellar.evolution_products(restored.state, restored.catalog.entities.HRG, 37.0), "Evolution carry survives serialization")
restored.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
check(E.eq(world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 37), restored.stellar.evolution_products(restored.state, restored.catalog.entities.HRG, 37)), "Evolution carry survives serialization")
dispose(restored)
# Real RD lifespan, four stars spread over the existing lifespan/40 window.
world.state.amount.N = 0.0
world.state.amount.RD = 4.0
world.batches.append_batch(world.state, "RD", 4.0, 0.0, 0.0)
var lifespan: float = rd.lifespan
var width: float = rd.lifespan_delta
world.state.amount.N = 0
world.state.amount.RD = 4
world.batches.append_batch(world.state, "RD", 4, 0, 0)
var lifespan: Variant = rd.lifespan
var width: Variant = rd.lifespan_delta
world.evolution.evolve_batches(world.state, lifespan)
check(world.state.amount.RD == 4.0, "Window start does not kill the whole batch")
world.evolution.evolve_batches(world.state, lifespan + width / 4.0)
check(world.state.amount.RD == 3.0 and world.state.amount.TWD == 1.0, "One quarter of a batch evolves")
world.evolution.evolve_batches(world.state, lifespan + width / 2.0)
check(world.state.amount.RD == 2.0 and world.state.amount.TWD == 2.0, "Half the batch evolves")
world.evolution.evolve_batches(world.state, lifespan + width)
check(world.state.amount.RD == 0.0 and world.state.amount.TWD == 4.0, "Window end completes evolution")
check(E.eq(world.state.amount.RD, 4), "Window start does not kill the whole batch")
world.evolution.evolve_batches(world.state, E.add(lifespan, E.divide(width, 4)))
check((E.eq(world.state.amount.RD, 3) and E.eq(world.state.amount.TWD, 1)), "One quarter of a batch evolves")
world.evolution.evolve_batches(world.state, E.add(lifespan, E.divide(width, 2)))
check((E.eq(world.state.amount.RD, 2) and E.eq(world.state.amount.TWD, 2)), "Half the batch evolves")
world.evolution.evolve_batches(world.state, E.add(lifespan, width))
check((E.eq(world.state.amount.RD, 0) and E.eq(world.state.amount.TWD, 4)), "Window end completes evolution")
check(world.state.batches.RD.is_empty(), "Completed batch removed")
dispose(world)
# Creation accumulates independently for each star/planet pair.
world = fixture()
world.state.amount.RD = 100.0
world.state.amount.RD = 100
for i in 100:
world.stellar.create_planets(world.state, "RD", 1.0, 0.0, 0.0, 1.0)
check(world.state.binds.RD.HP == 1.0, "100 RD at metallicity 1 produce one HP")
check(world.state.binds.RD.RP == 21.0, "Rocky planet probability retains fractions")
check(world.state.binds.RD.GG == 16.0 and world.state.binds.RD.AB == 120.0, "Planet multiplicity applied")
check(world.state.amount.HP == 0.0 and world.state.stats.total_amount.HP == 1.0, "Created planets are bound, counted once")
world.stellar.create_planets(world.state, "RD", 1, 0, 0, 1)
check(E.eq(world.state.binds.RD.HP, 1), "100 RD at metallicity 1 produce one HP")
check(E.eq(world.state.binds.RD.RP, 21), "Rocky planet probability retains fractions")
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(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Planet carry save validates")
check(E.eq(saves.validate_data({"format_version": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Planet carry save validates")
restored = fixture()
restored.state = bytes_to_var(var_to_bytes(world.state))
world.stellar.create_planets(world.state, "RD", 37.0, 0.0, 0.0, 0.7)
restored.stellar.create_planets(restored.state, "RD", 37.0, 0.0, 0.0, 0.7)
check(world.state == restored.state, "Planet carry continues after save")
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"))
restored.stellar.create_planets(restored.state, "RD", 37, 0, 0, E.of("0.7"))
check(E.eq(world.state, restored.state), "Planet carry continues after save")
dispose(restored)
dispose(world)
# A partially dying dwarf sterilizes HP; survivors and RP move to its heir.
world = fixture()
world.state.amount.RD = 4.0
world.state.binds.RD = {"HP": 8.0, "RP": 4.0, "C1.2": 4.0, "C1.3": 4.0, "GG": 4.0}
world.batches.append_batch(world.state, "RD", 4.0, 0.0, 0.0)
world.evolution.evolve_batches(world.state, lifespan + width / 2.0)
check(world.state.amount.RD == 2.0 and world.state.amount.TWD == 2.0, "Partial star transition with planets")
check(world.state.binds.RD.HP + world.state.binds.TWD.HP == 4.0, "Half of HP sterilized")
check(world.state.binds.RD["C1.3"] == 2.0 and world.state.binds.TWD["C1.3"] == 2.0, "Protected civilization splits between parent and heir")
check(world.state.binds.RD.RP + world.state.binds.TWD.RP == 10.0, "Sterilized planets become RP without loss")
check(world.state.amount.RP == 0.0 and world.state.amount["C1.3"] == 0.0, "Inherited planets never become free")
world.evolution.evolve_batches(world.state, lifespan + width)
check(world.state.binds.RD["C1.3"] == 0.0 and world.state.binds.TWD["C1.3"] == 4.0, "No planets left at extinct parent type")
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Inherited state validates")
world.state.amount.RD = 4
world.state.binds.RD = {"HP": 8, "RP": 4, "C1.2": 4, "C1.3": 4, "GG": 4}
world.batches.append_batch(world.state, "RD", 4, 0, 0)
world.evolution.evolve_batches(world.state, E.add(lifespan, E.divide(width, 2)))
check((E.eq(world.state.amount.RD, 2) and E.eq(world.state.amount.TWD, 2)), "Partial star transition with planets")
check(E.eq(E.add(world.state.binds.RD.HP, world.state.binds.TWD.HP), 4), "Half of HP sterilized")
check((E.eq(world.state.binds.RD["C1.3"], 2) and E.eq(world.state.binds.TWD["C1.3"], 2)), "Protected civilization splits between parent and heir")
check(E.eq(E.add(world.state.binds.RD.RP, world.state.binds.TWD.RP), 10), "Sterilized planets become RP without loss")
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": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Inherited state validates")
dispose(world)
world = fixture()
world.state.amount.WS = 2.0
world.state.binds.WS = {"HP": 4.0, "C1.3": 4.0, "RP": 4.0, "GG": 4.0}
world.batches.append_batch(world.state, "WS", 2.0, 0.0, 0.0)
world.evolution.evolve_batches(world.state, world.catalog.entities.WS.lifespan + world.catalog.entities.WS.lifespan_delta)
world.state.amount.WS = 2
world.state.binds.WS = {"HP": 4, "C1.3": 4, "RP": 4, "GG": 4}
world.batches.append_batch(world.state, "WS", 2, 0, 0)
world.evolution.evolve_batches(world.state, E.add(world.catalog.entities.WS.lifespan, world.catalog.entities.WS.lifespan_delta))
var physical: Dictionary = world.ledger.physical_amounts(world.state)
check(physical.HP == 0.0 and physical["C1.3"] == 0.0 and physical.RP == 0.0 and physical.GG == 0.0, "Level 2 removes all planets, including protected civilization")
check(world.state.amount.LRG == 2.0, "Giant evolves after local destruction")
check((((E.eq(physical.HP, 0) and E.eq(physical["C1.3"], 0)) and E.eq(physical.RP, 0)) and E.eq(physical.GG, 0)), "Level 2 removes all planets, including protected civilization")
check(E.eq(world.state.amount.LRG, 2), "Giant evolves after local destruction")
dispose(world)
world = fixture()
world.state.amount.RD = 10.0
world.state.binds.RD = {"HP": 10.0, "C1.2": 10.0, "C1.3": 10.0, "RP": 0.0}
world.stellar.apply_blast(world.state, 5.0, {"blast": 1.0, "density": 1.0})
check(world.state.binds.RD.HP == 5.0 and world.state.binds.RD["C1.2"] == 5.0, "Blast damages vulnerable worlds proportionally")
check(world.state.binds.RD["C1.3"] == 10.0 and world.state.binds.RD.RP == 10.0, "Blast protects C1.3 and creates RP")
world.stellar.apply_blast(world.state, 1e6, {"blast": 1.0, "density": 1.0})
check(world.state.binds.RD.HP == 0.0 and world.state.binds.RD.RP == 20.0, "Overwhelming blast is capped at available planets")
check(not world.state.amount.has("blast"), "Blast is not a resource")
world.state.amount.RD = 10
world.state.binds.RD = {"HP": 10, "C1.2": 10, "C1.3": 10, "RP": 0}
world.stellar.apply_blast(world.state, 5, {"blast": 1, "density": 1})
check((E.eq(world.state.binds.RD.HP, 5) and E.eq(world.state.binds.RD["C1.2"], 5)), "Blast damages vulnerable worlds proportionally")
check((E.eq(world.state.binds.RD["C1.3"], 10) and E.eq(world.state.binds.RD.RP, 10)), "Blast protects C1.3 and creates RP")
world.stellar.apply_blast(world.state, 1000000, {"blast": 1, "density": 1})
check((E.eq(world.state.binds.RD.HP, 0) and E.eq(world.state.binds.RD.RP, 20)), "Overwhelming blast is capped at available planets")
check((not world.state.amount.has("blast")), "Blast is not a resource")
dispose(world)
world = fixture()
world.state.amount.RD = 1.0
world.state.binds.RD = {"HP": 1.0}
world.state.amount.RD = 1
world.state.binds.RD = {"HP": 1}
for i in 10:
world.stellar.apply_blast(world.state, 1.0, {"blast": 1.0, "density": 0.1})
check(world.state.binds.RD.HP == 0.0 and world.state.binds.RD.RP == 1.0, "Small blast effects accumulate across cycles")
world.stellar.apply_blast(world.state, 1, {"blast": 1, "density": E.of("0.1")})
check((E.eq(world.state.binds.RD.HP, 0) and E.eq(world.state.binds.RD.RP, 1)), "Small blast effects accumulate across cycles")
dispose(world)
# Actual supernova output: one SN gives a neutron star, resources, and an event.
world = fixture()
world.state.amount.SN = 1.0
world.batches.append_batch(world.state, "SN", 1.0, 0.0, 0.0)
world.evolution.evolve_batches(world.state, 2.0)
check(world.state.amount.SN == 0.0 and world.state.amount.LNS == 1.0, "Supernova produces a remnant")
check(world.state.amount.H == 8.0 and world.state.amount.He == 50.0 and world.state.amount.Met == 11.0, "Supernova returns table resources")
check(not world.state.amount.has("blast"), "Supernova dispatches blast output")
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Blast state validates")
world.state.amount.SN = 1
world.batches.append_batch(world.state, "SN", 1, 0, 0)
world.evolution.evolve_batches(world.state, 2)
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": 3, "app_version": "", "ui": {"current_page": 0}, "state": world.state}), ""), "Blast state validates")
dispose(world)
world = fixture()
world.state.amount.H = 1000.0
world.state.amount.Met = 100.0
check(world.production.produce("RD", 100.0) == 100.0, "Manual star creation succeeds")
check(world.state.binds.RD.HP >= 1.0, "Manual creation dispatches planet formation")
check(world.state.amount.H == 900.0, "Planet creation does not duplicate or spend stellar fuel")
world.state.amount.H = 1000
world.state.amount.Met = 100
check(E.eq(world.production.produce("RD", 100), 100), "Manual star creation succeeds")
check(E.ge(world.state.binds.RD.HP, 1), "Manual creation dispatches planet formation")
check(E.eq(world.state.amount.H, 900), "Planet creation does not duplicate or spend stellar fuel")
dispose(world)
world = fixture()
world.state.amount.N = 0.0
world.state.amount.RD = 1.0
world.state.amount.H = 100.0
world.state.amount.Met = 100.0
world.state.auto_resources.H = 100.0
world.evolute(0.1)
check(world.state.amount.RD == 101.0 and world.state.amount.H == 0.0, "Automatic formation uses its resource budget")
check(world.state.binds.RD.HP >= 1.0, "Automatic formation also creates planets")
world.state.amount.N = 0
world.state.amount.RD = 1
world.state.amount.H = 100
world.state.amount.Met = 100
world.state.auto_resources.H = 100
world.evolute(E.of("0.1"))
check((E.eq(world.state.amount.RD, 101) and E.eq(world.state.amount.H, 0)), "Automatic formation uses its resource budget")
check(E.ge(world.state.binds.RD.HP, 1), "Automatic formation also creates planets")
dispose(world)
world = fixture()
world.catalog.entities.N.auto = 1.0
world.catalog.entities.N.auto = 1
# 1000 newly produced automatic H reserve 1 H, enough for 10 HBD.
world.evolute(100.0)
check(world.state.amount.HBD == 10.0, "Brown dwarfs form without existing ordinary stars")
check(world.state.amount.H == 999.0 and world.state.auto_resources.H == 999.0, "Brown dwarfs consume exactly their reserved hydrogen")
check(world.presentation.amounts(world.state, world.catalog).BD == 10.0 and world.state.batches.HBD.size() > 0, "Brown dwarfs appear in counter and evolution batches")
world.catalog.entities.N.product_per_year_amount = 0.0
world.evolute(100.0)
check(world.state.amount.HBD == 10.0, "Stored automatic budget is not taxed repeatedly")
world.evolute(100)
check(E.eq(world.state.amount.HBD, 10), "Brown dwarfs form without existing ordinary stars")
check((E.eq(world.state.amount.H, 999) and E.eq(world.state.auto_resources.H, 999)), "Brown dwarfs consume exactly their reserved hydrogen")
check((E.eq(world.presentation.amounts(world.state, world.catalog).BD, 10) and E.gt(world.state.batches.HBD.size(), 0)), "Brown dwarfs appear in counter and evolution batches")
world.catalog.entities.N.product_per_year_amount = 0
world.evolute(100)
check(E.eq(world.state.amount.HBD, 10), "Stored automatic budget is not taxed repeatedly")
dispose(world)
world = fixture()
world.catalog.entities.N.auto = 1.0
world.state.amount.RD = 1.0
world.evolute(50.0)
check(world.state.amount.HBD == 5.0 and is_equal_approx(world.state.auto_star_deficits.HBD_fuel, 0.5), "Individual dwarfs retain prepaid fractional fuel")
check(world.state.amount.H == 0.0, "Prepaid fuel is not available to ordinary stars")
world.catalog.entities.N.auto = 1
world.state.amount.RD = 1
world.evolute(50)
check((E.eq(world.state.amount.HBD, 5) and E.near(world.state.auto_star_deficits.HBD_fuel, E.of("0.5"))), "Individual dwarfs retain prepaid fractional fuel")
check(E.eq(world.state.amount.H, 0), "Prepaid fuel is not available to ordinary stars")
var resumed = fixture()
resumed.catalog.entities.N.auto = 1.0
resumed.state = bytes_to_var(var_to_bytes(world.state))
world.evolute(50.0)
resumed.evolute(50.0)
check(world.state.amount.HBD == 10.0, "Accumulated quota eventually creates a whole batch")
check(world.state == resumed.state, "Brown dwarf quota survives serialization deterministically")
check(world.state.amount.RD == 1000.0 and world.state.amount.H == 0.0, "Shared automatic hydrogen is spent once")
world.state.batches.HBD = [{"amount": 10.0, "start_year": 101.0, "end_year": 101.0}]
world.evolution.evolve_batches(world.state, 101.0)
check(world.state.amount.HBD == 0.0 and world.state.amount.CBD == 10.0 and world.presentation.amounts(world.state, world.catalog).BD == 10.0, "Cooling preserves aggregate brown dwarf count")
resumed.catalog.entities.N.auto = 1
resumed.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
world.evolute(50)
resumed.evolute(50)
check(E.eq(world.state.amount.HBD, 10), "Accumulated quota eventually creates a whole batch")
check(E.eq(world.state, resumed.state), "Brown dwarf quota survives serialization deterministically")
check((E.eq(world.state.amount.RD, 1000) and E.eq(world.state.amount.H, 0)), "Shared automatic hydrogen is spent once")
world.state.batches.HBD = [{"amount": 10, "start_year": 101, "end_year": 101}]
world.evolution.evolve_batches(world.state, 101)
check(((E.eq(world.state.amount.HBD, 0) and E.eq(world.state.amount.CBD, 10)) and E.eq(world.presentation.amounts(world.state, world.catalog).BD, 10)), "Cooling preserves aggregate brown dwarf count")
dispose(resumed)
dispose(world)
world = fixture()
var saw_individual: bool = false
for i in range(250):
world.state.amount.H += 1.0
world.state.auto_resources.H = world.state.auto_resources.get("H", 0.0) + 1.0
var before: float = world.state.amount.HBD
world.automatic.produce_brown_dwarfs(world.state, 1.0, float(i), float(i + 1))
var born: float = world.state.amount.HBD - before
check(born == 0.0 or born == 1.0, "Small fuel increments produce individual dwarfs")
saw_individual = saw_individual or born == 1.0
world.state.amount.H = E.add(world.state.amount.H, 1)
world.state.auto_resources.H = E.add(world.state.auto_resources.get("H", 0), 1)
var before: Variant = world.state.amount.HBD
world.automatic.produce_brown_dwarfs(world.state, 1, E.of(i), E.of(E.add(i, 1)))
var born: Variant = E.subtract(world.state.amount.HBD, before)
check((E.eq(born, 0) or E.eq(born, 1)), "Small fuel increments produce individual dwarfs")
saw_individual = (saw_individual or E.eq(born, 1))
check(saw_individual, "Random rounding does not wait for ten dwarfs")
resumed = fixture()
resumed.state = bytes_to_var(var_to_bytes(world.state))
resumed.state = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
for i in range(250, 500):
for simulation in [world, resumed]:
simulation.state.amount.H += 1.0
simulation.state.auto_resources.H += 1.0
simulation.automatic.produce_brown_dwarfs(simulation.state, 1.0, float(i), float(i + 1))
check(world.state == resumed.state, "Random sequence and prepaid fuel survive saving")
check(is_equal_approx(world.state.amount.H + world.state.amount.HBD * 0.1 + world.state.auto_star_deficits.HBD_fuel, 500.0), "Individual production conserves fuel including prepaid reserve")
check(is_equal_approx(world.state.amount.HBD + world.state.auto_star_deficits.HBD, 5.0), "Signed remainder preserves the 0.1 percent allocation")
var paused_count: float = world.state.amount.HBD
for i in 10: world.automatic.produce_brown_dwarfs(world.state, 0.0, 500.0, 501.0)
check(world.state.amount.HBD == paused_count, "No repeated random attempts without new fuel")
simulation.state.amount.H = E.add(simulation.state.amount.H, 1)
simulation.state.auto_resources.H = E.add(simulation.state.auto_resources.H, 1)
simulation.automatic.produce_brown_dwarfs(simulation.state, 1, E.of(i), E.of(E.add(i, 1)))
check(E.eq(world.state, resumed.state), "Random sequence and prepaid fuel survive saving")
check(E.near(E.add(E.add(world.state.amount.H, E.multiply(world.state.amount.HBD, E.of("0.1"))), world.state.auto_star_deficits.HBD_fuel), 500), "Individual production conserves fuel including prepaid reserve")
check(E.near(E.add(world.state.amount.HBD, world.state.auto_star_deficits.HBD), 5), "Signed remainder preserves the 0.1 percent allocation")
var paused_count: Variant = world.state.amount.HBD
for i in 10: world.automatic.produce_brown_dwarfs(world.state, 0, 500, 501)
check(E.eq(world.state.amount.HBD, paused_count), "No repeated random attempts without new fuel")
dispose(resumed)
dispose(world)
# Two simultaneous successors share surviving planets in the same 99:1 ratio.
world = fixture()
world.catalog.entities.HRG.destroy_level = 0
world.state.amount.HRG = 100.0
world.state.binds.HRG = {"C1.3": 100.0}
world.state.batches.HRG = [{"start_year": 0.1, "end_year": 0.1, "amount": 100.0}]
world.evolution.evolve_batches(world.state, 0.1)
check(world.state.binds.SN["C1.3"] == 99.0 and world.state.binds.GRB["C1.3"] == 1.0, "Planets split among actual alternative successors")
check(world.state.binds.HRG["C1.3"] == 0.0, "Alternative evolution leaves no orphan bindings")
world.state.amount.HRG = 100
world.state.binds.HRG = {"C1.3": 100}
world.state.batches.HRG = [{"start_year": E.of("0.1"), "end_year": E.of("0.1"), "amount": 100}]
world.evolution.evolve_batches(world.state, E.of("0.1"))
check((E.eq(world.state.binds.SN["C1.3"], 99) and E.eq(world.state.binds.GRB["C1.3"], 1)), "Planets split among actual alternative successors")
check(E.eq(world.state.binds.HRG["C1.3"], 0), "Alternative evolution leaves no orphan bindings")
dispose(world)
world = fixture()
world.state.amount.N = 0.0
world.state.amount.HRG = 1.0
world.state.batches.HRG = [{"start_year": 0.1, "end_year": 0.1, "amount": 1.0}]
world.evolute(3.0)
check(world.state.amount.LNS == 1.0 and world.state.amount.SN == 0.0, "Long step follows multiple stellar generations")
check(world.state.amount.S >= 99.0, "Short-lived newborn supernova emits S before disappearing")
world.state.amount.N = 0
world.state.amount.HRG = 1
world.state.batches.HRG = [{"start_year": E.of("0.1"), "end_year": E.of("0.1"), "amount": 1}]
world.evolute(3)
check((E.eq(world.state.amount.LNS, 1) and E.eq(world.state.amount.SN, 0)), "Long step follows multiple stellar generations")
check(E.ge(world.state.amount.S, 99), "Short-lived newborn supernova emits S before disappearing")
dispose(world)
print("Stellar rules: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("Stellar rules: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+42 -41
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const Clock = preload("res://world/owniverse/simulation_time.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -18,16 +19,16 @@ func fixture():
for id in global.OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(global.OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
world.catalog.entities["N"].life_product = {}
world.catalog.entities["N"].product_per_year_type = "H"
world.catalog.entities["N"].product_per_year_amount = 10.0
world.catalog.entities["N"].auto = 0.0
world.state.amount.N = 1.0
world.catalog.entities["N"].product_per_year_amount = 10
world.catalog.entities["N"].auto = 0
world.state.amount.N = 1
return world
func dispose(world) -> void:
@@ -38,55 +39,55 @@ func dispose(world) -> void:
func run() -> void:
var saves: Node = root.get_node("SaveManager")
var world = fixture()
world.evolute(0.09)
check(world.state.time.year == 0.0, "Do not process before a tenth boundary")
check(world.state.amount.H == 0.0, "No premature production")
world.evolute(0.01)
check(world.state.time.year == 0.1, "Process the first tenth")
check(world.state.amount.H == 1.0, "Production runs at 0.1 year")
world.evolute(0.2)
check(world.state.time.year == 0.3 and world.state.amount.H == 3.0, "Do not reprocess previous tenths")
world.evolute(E.of("0.09"))
check(E.eq(world.state.time.year, 0), "Do not process before a tenth boundary")
check(E.eq(world.state.amount.H, 0), "No premature production")
world.evolute(E.of("0.01"))
check(E.eq(world.state.time.year, E.of("0.1")), "Process the first tenth")
check(E.eq(world.state.amount.H, 1), "Production runs at 0.1 year")
world.evolute(E.of("0.2"))
check((E.eq(world.state.time.year, E.of("0.3")) and E.eq(world.state.amount.H, 3)), "Do not reprocess previous tenths")
var before: Dictionary = world.state.duplicate(true)
for invalid in [0.0, -1.0, NAN, INF, 1e308]:
for invalid in [0, E.neg(1), NAN, INF, E.of("1e308")]:
world.evolute(invalid)
check(world.state == before, "Invalid/overflow interval rejected")
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": world.state}) == "", "Fractional save is valid")
check(E.eq(world.state, before), "Invalid/overflow interval rejected")
check(E.eq(saves.validate_data({"format_version": 3, "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 = 0.25
check(saves.validate_data({"format_version": 2, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}) != "", "Reject inconsistent fractional clock")
invalid_save.time.year = E.of("0.25")
check(E.ne(saves.validate_data({"format_version": 3, "app_version": "", "ui": {"current_page": 0}, "state": invalid_save}), ""), "Reject inconsistent fractional clock")
dispose(world)
for fps in [30, 60, 144]:
world = fixture()
for frame in fps:
world.evolute(1.0 / fps)
check(world.state.time.year == 1.0, "One second at %d fps" % fps)
check(world.state.amount.H == 10.0, "Production across frames at %d fps" % fps)
world.evolute(E.divide(1, fps))
check(E.eq(world.state.time.year, 1), ("One second at %d fps" % fps))
check(E.eq(world.state.amount.H, 10), ("Production across frames at %d fps" % fps))
dispose(world)
world = fixture()
world.evolute(0.05)
world.evolute(E.of("0.05"))
world.speed = 2
world.evolute(0.005)
check(world.state.time.year == 0.1, "Speed change preserves accumulated fraction")
world.evolute(E.of("0.005"))
check(E.eq(world.state.time.year, E.of("0.1")), "Speed change preserves accumulated fraction")
world.speed = 100
var speed_limit: int = root.get_node("Global").SPEED_LIMIT
check(world.speed == speed_limit and world.state.speed_in_year == pow(10.0, speed_limit - 1), "Configured upper speed limit")
world.speed = -10
check(world.speed == 1 and world.state.speed_in_year == 1.0, "Lower speed limit unchanged")
check((E.eq(world.speed, speed_limit) and E.eq(world.state.speed_in_year, E.power(10, E.subtract(speed_limit, 1)))), "Configured upper speed limit")
world.speed = E.neg(10)
check((E.eq(world.speed, 1) and E.eq(world.state.speed_in_year, 1)), "Lower speed limit unchanged")
dispose(world)
world = fixture()
dispose(world)
world = fixture()
world.catalog.entities.N.output_and = {"He": 1.0}
world.catalog.entities.N.output_and = {"He": 1}
world.catalog.entities.N.output_or = {}
world.state.batches.N = [{"start_year": 0.1, "end_year": 0.1, "amount": 1.0}]
world.evolute(0.1)
check(world.state.amount.N == 0.0 and world.state.amount.He == 1.0, "Existing evolution executes at fractional boundary")
world.state.batches.N = [{"start_year": E.of("0.1"), "end_year": E.of("0.1"), "amount": 1}]
world.evolute(E.of("0.1"))
check((E.eq(world.state.amount.N, 0) and E.eq(world.state.amount.He, 1)), "Existing evolution executes at fractional boundary")
dispose(world)
check(Clock.processed_year(0.099) == 0.0, "Do not round a genuine incomplete tenth forward")
check(Clock.processed_year(1e9 + 0.45) == 1e9 + 0.4, "Large ages preserve fractional precision")
print("Time state: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
check(E.eq(Clock.processed_year(E.of("0.099")), 0), "Do not round a genuine incomplete tenth forward")
check(E.eq(Clock.processed_year(E.add(E.of("1e9"), E.of("0.45"))), E.add(E.of("1e9"), E.of("0.4"))), "Large ages preserve fractional precision")
print(("Time state: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+20 -19
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
var checks: int = 0
var failures: int = 0
@@ -7,9 +8,9 @@ func _initialize() -> void:
call_deferred("run")
func check(value: bool, message: String) -> void:
checks += 1
if not value:
failures += 1
checks = E.add(checks, 1)
if (not value):
failures = E.add(failures, 1)
push_error(message)
func run() -> void:
@@ -22,39 +23,39 @@ func run() -> void:
await process_frame
stage.stage_page_scroller.scroll_horizontal = stage.PAGE_WIDTH
await process_frame
stage.owniverse.state.amount.H = 1000.0
stage.owniverse.state.amount.RD = 2.0
stage.owniverse.state.amount.H = 1000
stage.owniverse.state.amount.RD = 2
stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog)
stage.set_owniverse_item_value()
var hydrogen = stage.owniverse_items.H
var dwarf = stage.owniverse_items.RD
check(hydrogen.description.text == "1 000", "Pending quantities reach controls")
check(dwarf.description.text == "2", "Other page receives its current quantity")
check(E.eq(hydrogen.description.text, "1 000"), "Pending quantities reach controls")
check(E.eq(dwarf.description.text, "2"), "Other page receives its current quantity")
check(stage.owniverse.presentation.pending_amounts.is_empty(), "Published queue is consumed by Stage")
stage.owniverse.presentation.deferred_updates = true
stage.owniverse.state.amount.H = 2000.0
stage.owniverse.state.amount.H = 2000
stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog)
check(stage.owniverse.presentation.dirty and stage.owniverse.presentation.pending_amounts.is_empty(), "Deferred refresh marks dirty without republishing")
check((stage.owniverse.presentation.dirty and stage.owniverse.presentation.pending_amounts.is_empty()), "Deferred refresh marks dirty without republishing")
stage.set_owniverse_item_value()
check(hydrogen.description.text == "1 000", "Labels retain last published quantity")
check(E.eq(hydrogen.description.text, "1 000"), "Labels retain last published quantity")
stage.owniverse.presentation.refresh(stage.owniverse.state, stage.owniverse.catalog, true)
stage.set_owniverse_item_value()
check(hydrogen.description.text == "2 000" and not stage.owniverse.presentation.dirty, "Forced refresh publishes latest state")
check((E.eq(hydrogen.description.text, "2 000") and (not stage.owniverse.presentation.dirty)), "Forced refresh publishes latest state")
stage.owniverse.presentation.deferred_updates = false
stage.item_activated(hydrogen)
stage.set_owniverse_item_value()
check(hydrogen.description.text == root.get_node("Global").format_number(stage.owniverse.state.amount.H), "Player production publishes the new quantity")
check(stage.owniverse.state.amount.H > 2000.0, "Production action actually executed")
check(E.eq(hydrogen.description.text, root.get_node("Global").format_number(stage.owniverse.state.amount.H)), "Player production publishes the new quantity")
check(E.gt(stage.owniverse.state.amount.H, 2000), "Production action actually executed")
dwarf.visible = true
stage.stage_page_scroller.scroll_horizontal = 2 * stage.PAGE_WIDTH
stage.stage_page_scroller.scroll_horizontal = E.multiply(2, stage.PAGE_WIDTH)
await process_frame
stage._refresh_universe_ui()
check(dwarf.description.text == "2", "Newly visible page receives cached latest values")
stage.owniverse.state.time.year = 1234.5
check(E.eq(dwarf.description.text, "2"), "Newly visible page receives cached latest values")
stage.owniverse.state.time.year = E.of("1234.5")
stage._refresh_universe_ui()
check(stage.date_time.text == "1 234.5", "Time displays tenths with thousands separator")
check(E.eq(stage.date_time.text, "1 234.5"), "Time displays tenths with thousands separator")
for entity in stage.owniverse.catalog.entities.values(): entity.free()
stage.owniverse.free()
scene.free()
print("UI updates: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("UI updates: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+98 -101
View File
@@ -1,4 +1,5 @@
extends SceneTree
const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd")
@@ -11,9 +12,9 @@ func _initialize() -> void:
call_deferred("run")
func check(condition: bool, message: String) -> void:
checks += 1
if not condition:
failures += 1
checks = E.add(checks, 1)
if (not condition):
failures = E.add(failures, 1)
push_error(message)
func fixture():
@@ -21,11 +22,11 @@ func fixture():
for id in root.get_node("Global").OwniverseEntities:
var entity = load("res://world/owniverse/owniverse_entity.gd").new(root.get_node("Global").OwniverseEntities[id])
world.catalog.entities[id] = entity
world.state.amount[id] = 0.0
world.state.stats.total_amount[id] = 0.0
if entity.group_as != "":
world.state.amount[entity.group_as] = 0.0
world.state.stats.total_amount[entity.group_as] = 0.0
world.state.amount[id] = 0
world.state.stats.total_amount[id] = 0
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = 0
world.state.stats.total_amount[entity.group_as] = 0
return world
func dispose(world) -> void:
@@ -35,116 +36,116 @@ func dispose(world) -> void:
func run() -> void:
var global: Node = root.get_node("Global")
var world = fixture()
world.state.amount.RD = 400.0
world.batches.append_batch(world.state, "RD", 400.0, 0.0, 0.0)
var end: float = world.state.batches.RD[0].end_year
world.environment.modify_world_parameter("Bur", 9.0)
check(is_equal_approx(world.state.batches.RD[0].end_year, end / 10.0), "Burnability accelerates existing cohorts")
var count: float = world.state.batches.RD[0].amount
world.environment.modify_world_parameter("Bur", -9.0)
check(is_equal_approx(world.state.batches.RD[0].end_year, end) and world.state.batches.RD[0].amount == count, "Changing burnability back preserves population and lifetime")
world.environment.modify_world_parameter("Bur", 9.0)
world.batches.append_batch(world.state, "YD", 10.0, 0.0, 0.0)
check(is_equal_approx(world.state.batches.YD[0].start_year, world.catalog.entities.YD.lifespan / 10.0), "New stars use the same burnability")
world.state.time = {"datetime": end / 10.0 - 0.1, "year": end / 10.0 - 0.1}
world.state.amount.RD = 400
world.batches.append_batch(world.state, "RD", 400, 0, 0)
var end: Variant = world.state.batches.RD[0].end_year
world.environment.modify_world_parameter("Bur", 9)
check(E.near(world.state.batches.RD[0].end_year, E.divide(end, 10)), "Burnability accelerates existing cohorts")
var count: Variant = world.state.batches.RD[0].amount
world.environment.modify_world_parameter("Bur", E.neg(9))
check((E.near(world.state.batches.RD[0].end_year, end) and E.eq(world.state.batches.RD[0].amount, count)), "Changing burnability back preserves population and lifetime")
world.environment.modify_world_parameter("Bur", 9)
world.batches.append_batch(world.state, "YD", 10, 0, 0)
check(E.near(world.state.batches.YD[0].start_year, E.divide(world.catalog.entities.YD.lifespan, 10)), "New stars use the same burnability")
world.state.time = {"datetime": E.subtract(E.divide(end, 10), E.of("0.1")), "year": E.subtract(E.divide(end, 10), E.of("0.1"))}
world.batches.withdraw_batches(world.state, "RD", world.state.time.year)
world.environment.modify_world_parameter("Bur", -18.0)
check(world.state.batches.RD[0].end_year >= world.state.time.year, "Slowing a partially evolved cohort keeps its remaining window in the future")
world.environment.modify_world_parameter("Exp", 9.0)
check(world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2.0).blast == 180.0, "Explosion power includes world explosiveness")
var products: Dictionary = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 1000.0)
check(products.get("GRB", 0.0) == 100.0 and products.get("SN", 0.0) == 900.0, "Explosiveness raises rare-event probability without duplicating stars")
world.catalog.entities.HRG.output_or.chance = 50.0
products = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 100.0)
check(products.get("GRB", 0.0) == 100.0 and products.get("SN", 0.0) == 0.0, "Rare-event probability is bounded at one")
world.environment.modify_world_parameter("Bur", E.neg(18))
check(E.ge(world.state.batches.RD[0].end_year, world.state.time.year), "Slowing a partially evolved cohort keeps its remaining window in the future")
world.environment.modify_world_parameter("Exp", 9)
check(E.eq(world.stellar.evolution_products(world.state, world.catalog.entities.SN, 2).blast, 180), "Explosion power includes world explosiveness")
var products: Dictionary = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 1000)
check((E.eq(products.get("GRB", 0), 100) and E.eq(products.get("SN", 0), 900)), "Explosiveness raises rare-event probability without duplicating stars")
world.catalog.entities.HRG.output_or.chance = 50
products = world.stellar.evolution_products(world.state, world.catalog.entities.HRG, 100)
check((E.eq(products.get("GRB", 0), 100) and E.eq(products.get("SN", 0), 0)), "Rare-event probability is bounded at one")
dispose(world)
world = fixture()
var ordinary: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0)
var ordinary: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000)
world.state.life_remainders.clear()
world.environment.modify_world_parameter("Agg", 9.0)
var aggressive: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0)
check(aggressive.EON1 > ordinary.EON1, "Aggression accelerates primitive evolution")
world.environment.modify_world_parameter("Agg", 9)
var aggressive: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000)
check(E.gt(aggressive.EON1, ordinary.EON1), "Aggression accelerates primitive evolution")
world.state.life_remainders.clear()
world.state.world_parameters.Order = 10.0
var orderly: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0)
check(orderly.RP < aggressive.RP, "Order reduces degradation")
world.state.world_parameters.Order = 10
var orderly: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000)
check(E.lt(orderly.RP, aggressive.RP), "Order reduces degradation")
world.state.life_remainders.clear()
world.state.world_parameters.Enlightenment = 10.0
var enlightened: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000.0)
check(enlightened.EON1 > orderly.EON1, "Enlightenment increases primitive progress")
world.state.world_parameters.Enlightenment = 10
var enlightened: Dictionary = world.life.life_products(world.state, world.catalog.entities.HP, 1000000)
check(E.gt(enlightened.EON1, orderly.EON1), "Enlightenment increases primitive progress")
dispose(world)
world = fixture()
var hotter = fixture()
for simulation in [world, hotter]:
simulation.state.amount.RD = 1000000.0
simulation.batches.append_batch(simulation.state, "RD", 1000000.0, 0.0, 0.0)
hotter.environment.modify_world_parameter("Bur", 9.0)
world.evolute(1000.0)
hotter.evolute(1000.0)
check(hotter.state.amount.S > world.state.amount.S, "Burnability changes actual space production")
check(hotter.state.energy + hotter.state.amount.S == Parameters.INITIAL_ENERGY, "Enhanced production still spends the GI energy budget")
simulation.state.amount.RD = 1000000
simulation.batches.append_batch(simulation.state, "RD", 1000000, 0, 0)
hotter.environment.modify_world_parameter("Bur", 9)
world.evolute(1000)
hotter.evolute(1000)
check(E.gt(hotter.state.amount.S, world.state.amount.S), "Burnability changes actual space production")
check(E.eq(E.add(hotter.state.energy, hotter.state.amount.S), Parameters.INITIAL_ENERGY), "Enhanced production still spends the GI energy budget")
dispose(world)
dispose(hotter)
world = fixture()
world.state.amount["C0.2"] = 1000000.0
world.state.ethics["C0.2"] = {"m": 1000000.0}
world.state.amount["C0.2"] = 1000000
world.state.ethics["C0.2"] = {"m": 1000000}
var baseline: Dictionary = world.state.duplicate(true)
var advanced: Dictionary = baseline.duplicate(true)
advanced.world_parameters.Agg = 19.0
advanced.world_parameters.Agg = 19
var next_stage: String = global.CivilizationRules["C0.2"].next
var outcomes: Array = []
for target in [baseline, advanced]:
var expired: Dictionary = {"C0.2": [{"amount": 1000000.0, "from": 0.0, "to": 1.0}]}
var expired: Dictionary = {"C0.2": [{"amount": 1000000, "from": 0, "to": 1}]}
var budget: Dictionary = CivilizationState.stellar_budget(world, target, global.CivilizationRules)
CivilizationEvolution.prepare(world, target, expired, 1.0, budget, global.CivilizationRules)
outcomes.append(expired["C0.2"][0].products.get(next_stage, 0.0))
check(outcomes[1] > outcomes[0], "Aggression changes actual ethical civilization transitions")
CivilizationEvolution.prepare(world, target, expired, 1, budget, global.CivilizationRules)
outcomes.append(expired["C0.2"][0].products.get(next_stage, 0))
check(E.gt(outcomes[1], outcomes[0]), "Aggression changes actual ethical civilization transitions")
dispose(world)
# Trends use ethical proportions and stage weights, not population magnitude.
world = fixture()
var rules: Dictionary = {"C2.0": {"home": "DwStar"}, "C2.1": {"home": "DwStar"}}
world.state.ethics = {"C2.0": {"d": 100.0}, "C2.1": {"d": 100.0}}
world.state.ethics = {"C2.0": {"d": 100}, "C2.1": {"d": 100}}
var source: Dictionary = world.state.duplicate(true)
var single: Dictionary = source.duplicate(true)
var split: Dictionary = source.duplicate(true)
Parameters.advance(source, single, rules, 1.0e6)
for i in 10: Parameters.advance(source, split, rules, 1.0e5)
check(is_equal_approx(single.trends.Integration, 0.018), "Trend integrates elapsed years and doubled stage weights")
Parameters.advance(source, single, rules, 1000000)
for i in 10: Parameters.advance(source, split, rules, 100000)
check(E.near(single.trends.Integration, E.of("0.018")), "Trend integrates elapsed years and doubled stage weights")
for key in Parameters.TRENDS:
check(is_equal_approx(single.trends[key], split.trends[key]), "Trend is independent of frame partition: " + key)
source.ethics["C2.0"].d = 1000000.0
check(E.near(single.trends[key], split.trends[key]), E.add("Trend is independent of frame partition: ", key))
source.ethics["C2.0"].d = 1000000
var larger: Dictionary = world.state.duplicate(true)
Parameters.advance(source, larger, rules, 1.0e6)
check(is_equal_approx(larger.trends.Integration, single.trends.Integration), "Population proportions determine trend influence")
world.state.trends.Integration = 0.99
Parameters.advance(source, world.state, rules, 1.0e8)
check(world.state.civilization_stats["trend:Integration"] == 2.0, "Long steps process every crossed threshold")
check(is_equal_approx(world.state.trends.Integration, 0.79), "Threshold crossing preserves the fractional remainder")
check(world.state.world_parameters.Federation > 1.0 or world.state.world_parameters.Enlightenment > 1.0 or world.state.world_parameters.Order > 1.0, "Integration events change the world")
var restored: Dictionary = bytes_to_var(var_to_bytes(world.state))
Parameters.advance(source, world.state, rules, 1.0e8)
Parameters.advance(source, restored, rules, 1.0e8)
check(world.state == restored, "Trend events resume identically after serialization")
Parameters.advance(source, world.state, rules, 1.0e12)
check(world.state.trends.Integration >= 0.0 and world.state.trends.Integration < 1.0, "Very large intervals finish with a valid trend fraction")
check(world.state.world_parameters.Agg >= 1.0, "Repeated integration events cannot reduce aggression below the scale")
var payload: Dictionary = {"format_version": 2, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
Parameters.advance(source, larger, rules, 1000000)
check(E.near(larger.trends.Integration, single.trends.Integration), "Population proportions determine trend influence")
world.state.trends.Integration = E.of("0.99")
Parameters.advance(source, world.state, rules, 100000000)
check(E.eq(world.state.civilization_stats["trend:Integration"], 2), "Long steps process every crossed threshold")
check(E.near(world.state.trends.Integration, E.of("0.79")), "Threshold crossing preserves the fractional remainder")
check(((E.gt(world.state.world_parameters.Federation, 1) or E.gt(world.state.world_parameters.Enlightenment, 1)) or E.gt(world.state.world_parameters.Order, 1)), "Integration events change the world")
var restored: Dictionary = E.decode(bytes_to_var(var_to_bytes(E.encode(world.state))))
Parameters.advance(source, world.state, rules, 100000000)
Parameters.advance(source, restored, rules, 100000000)
check(E.eq(world.state, restored), "Trend events resume identically after serialization")
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": 3, "app_version": "test", "ui": {"current_page": 1}, "state": world.state}
var manager: Node = root.get_node("SaveManager")
check(manager.validate_data(payload) == "", "New parameters and trends pass save validation")
check(E.eq(manager.validate_data(payload), ""), "New parameters and trends pass save validation")
var old: Dictionary = payload.duplicate(true)
old.state.erase("world_parameters")
old.state.erase("trends")
check(manager.validate_data(old) == "", "Existing current-format saves remain readable")
payload.state.world_parameters.Agg = 20.0
check(manager.validate_data(payload) != "", "Out-of-range scales are rejected")
check(E.eq(manager.validate_data(old), ""), "Existing current-format saves remain readable")
payload.state.world_parameters.Agg = 20
check(E.ne(manager.validate_data(payload), ""), "Out-of-range scales are rejected")
dispose(world)
for sample in [[1e-7, 0.0], [1e-6, 0.5], [1e-5, 1.0], [0.9, 0.0], [0.95, 0.5], [1.0, 1.0]]:
check(is_equal_approx(Parameters.density_display(sample[0]).position, sample[1]), "Density boundary " + str(sample[0]))
for sample in [[E.of("1e-7"), 0], [E.of("1e-6"), E.of("0.5")], [E.of("1e-5"), 1], [E.of("0.9"), 0], [E.of("0.95"), E.of("0.5")], [1, 1]]:
check(E.near(Parameters.density_display(sample[0]).position, sample[1]), E.add("Density boundary ", str(sample[0])))
# The fixture overrides saving/loading; the player's save is never accessed.
var scene = load("res://world/world.tscn").instantiate()
var stage = scene.get_node("Stage")
@@ -153,40 +154,36 @@ func run() -> void:
root.add_child(scene)
await process_frame
await process_frame
stage.owniverse.state.energy = Parameters.INITIAL_ENERGY / 4.0
stage.owniverse.state.world_parameters.Bur = 19.0
stage.owniverse.state.trends.Integration = 0.75
stage.owniverse.state.energy = E.divide(Parameters.INITIAL_ENERGY, 4)
stage.owniverse.state.world_parameters.Bur = 19
stage.owniverse.state.trends.Integration = E.of("0.75")
stage.world_indicators.update(stage.owniverse)
var warning: TextureRect = scene.get_node("Background/WarningBorder")
check(warning.mouse_filter == Control.MOUSE_FILTER_IGNORE, "Warning overlay does not block input")
var environments: Array = [
[1.0e-7, "ff0000ff"], [1.0e-6, "df2020bf"], [1.0e-5, "c040407f"],
[0.1, "00000000"], [0.9, "40a0c07f"], [0.97, "20b0dfbf"], [1.0, "00c0ffff"],
[0.1, "00000000"],
]
check(E.eq(warning.mouse_filter, Control.MOUSE_FILTER_IGNORE), "Warning overlay does not block input")
var environments: Array = [[E.of("1.0e-7"), "ff0000ff"], [E.of("1.0e-6"), "df2020bf"], [E.of("1.0e-5"), "c040407f"], [E.of("0.1"), "00000000"], [E.of("0.9"), "40a0c07f"], [E.of("0.97"), "20b0dfbf"], [1, "00c0ffff"], [E.of("0.1"), "00000000"]]
for sample in environments:
check(Parameters.density_warning_color(sample[0]).is_equal_approx(Color.html(sample[1])), "Unity warning threshold: " + str(sample[0]))
stage.owniverse.state.amount.H = 1e14
check(Parameters.density_warning_color(sample[0]).is_equal_approx(Color.html(sample[1])), E.add("Unity warning threshold: ", str(sample[0])))
stage.owniverse.state.amount.H = E.of("1e14")
stage.world_indicators.update(stage.owniverse)
check(warning.self_modulate.is_equal_approx(Color.html("00c0ffff")), "Compression warning uses current universe density")
stage.owniverse.state.amount.H = 0.0
stage.owniverse.state.amount.S = 1e24
stage.owniverse.state.amount.H = 0
stage.owniverse.state.amount.S = E.of("1e24")
stage.world_indicators.update(stage.owniverse)
check(warning.self_modulate.is_equal_approx(Color.html("ff0000ff")), "Rip warning replaces compression warning")
stage.owniverse.state.amount.S = 0.0
stage.owniverse.state.amount.S = 0
stage.world_indicators.update(stage.owniverse)
check(warning.self_modulate.a == 0.0, "Warning clears when density returns to normal")
check(E.eq(warning.self_modulate.a, 0), "Warning clears when density returns to normal")
for bar in stage.world_indicators.energy_bars:
check(bar.scale.x == 0.25, "Energy fill is synchronized on both pages")
check(E.eq(bar.scale.x, E.of("0.25")), "Energy fill is synchronized on both pages")
var pointer: Control = stage.world_indicators.pointers.Bur
check(is_equal_approx(pointer.position.x + pointer.size.x / 2.0, 304.0), "Burnability pointer reaches the correct scale endpoint")
check(stage.world_indicators.trend_bars[1].scale.y == 0.75, "Trend bar displays its stored level")
check(E.near(E.add(pointer.position.x, E.divide(pointer.size.x, 2)), 304), "Burnability pointer reaches the correct scale endpoint")
check(E.eq(stage.world_indicators.trend_bars[1].scale.y, E.of("0.75")), "Trend bar displays its stored level")
for locale in ["en", "ru"]:
global.Locale = locale
for id in ["Energy", "Density", "Burnability", "Explosiveness", "Aggression", "Trends"]:
var description: String = Descriptions.render(stage.owniverse, global, id, "Indicator")
check(not description.contains("—") and not description.contains("%value"), "Resolved indicator description: " + locale + "/" + id)
check(((not description.contains("—")) and (not description.contains("%value"))), E.add(E.add(E.add("Resolved indicator description: ", locale), "/"), id))
dispose(stage.owniverse)
scene.free()
print("World indicators: %d checks, %d failures" % [checks, failures])
quit(0 if failures == 0 else 1)
print(("World indicators: %d checks, %d failures" % [checks, failures]))
quit((0 if E.eq(failures, 0) else 1))
+28 -39
View File
@@ -1,29 +1,18 @@
extends HBoxContainer
class_name OwniverseAction
const E = preload("res://world/owniverse/math/exact.gd")
signal action_pressed(page_id, amount: float)
signal action_pressed ( page_id , amount : Variant )
@onready var action_labels: Array = [
$ActionControl0/Label,
$ActionControl1/Label,
$ActionControl2/Label,
$ActionControl3/Label,
$ActionControl4/Label,
]
@onready var action_labels : Array = [$ActionControl0/Label, $ActionControl1/Label, $ActionControl2/Label, $ActionControl3/Label, $ActionControl4/Label]
var page_id: int = -1
var page_id: int = E.neg(1)
var current_item_id = ""
var action_values: Dictionary = {
0: 0,
1: 0,
2: 0,
3: 0,
4: 0,
}
var action_values: Dictionary = {0: 0, 1: 0, 2: 0, 3: 0, 4: 0}
func set_actions(item_id: String, value: float, preview: Callable = Callable()) -> void:
func set_actions(item_id: String, value: Variant, preview: Callable = Callable()) -> void:
current_item_id = item_id
var requests: Array = _get_distribution(value)
var unique_requests: Array = []
@@ -31,32 +20,32 @@ func set_actions(item_id: String, value: float, preview: Callable = Callable())
var labels: Dictionary = {}
# Prefer the largest request (including MAX) when previews or rounded
# scientific labels coincide. Keep the request paired with its preview.
for i in range(requests.size() - 1, -1, -1):
if requests[i] <= 0.0: continue
var amount: float = preview.call(requests[i]) if preview.is_valid() else requests[i]
if not is_finite(amount) or amount < 1.0 or amount != floorf(amount): continue
for i in range(E.subtract(requests.size(), 1), E.neg(1), E.neg(1)):
if E.le(requests[i], 0): continue
var amount: Variant = (preview.call(requests[i]) if preview.is_valid() else requests[i])
if (((not E.finite(amount)) or E.lt(amount, 1)) or E.ne(amount, E.floor_value(amount))): continue
var text: String = _format_action_value(amount)
if displayed.has(amount) or labels.has(text): continue
if (E.contains(displayed, amount) or labels.has(text)): continue
labels[text] = true
unique_requests.push_front(requests[i])
displayed.push_front(amount)
while unique_requests.size() < 5:
unique_requests.append(0.0)
displayed.append(0.0)
while E.lt(unique_requests.size(), 5):
unique_requests.append(0)
displayed.append(0)
_set_action_values(unique_requests, displayed)
func _get_distribution(value: float) -> Array:
if value < 1.0 or not is_finite(value):
return [0.0, 0.0, 0.0, 0.0, 0.0]
value = floorf(value)
func _get_distribution(value: Variant) -> Array:
if (E.lt(value, 1) or (not E.finite(value))):
return [0, 0, 0, 0, 0]
value = E.floor_value(value)
var distribution: Array = []
var power: float = 1.0
while power < value:
var power: Variant = 1
while E.lt(power, value):
distribution.append(power)
if distribution.size() > 4: distribution.pop_front()
power *= 10.0
if E.gt(distribution.size(), 4): distribution.pop_front()
power = E.multiply(power, 10)
distribution.append(value)
while distribution.size() < 5: distribution.append(0.0)
while E.lt(distribution.size(), 5): distribution.append(0)
return distribution
func _set_action_values(values: Array, displayed: Array) -> void:
@@ -64,17 +53,17 @@ func _set_action_values(values: Array, displayed: Array) -> void:
action_values[ind] = values[ind]
# Keep the original request for execution; show the exact planned output.
var text: String = _format_action_value(displayed[ind])
if action_labels[ind].text != text:
if E.ne(action_labels[ind].text, text):
action_labels[ind].text = text
action_labels[ind].get_parent().get_node("TextureButton").disabled = values[ind] <= 0.0
action_labels[ind].get_parent().get_node("TextureButton").disabled = E.le(values[ind], 0)
func _format_action_value(value: float) -> String:
if value < 1.0 or not is_finite(value) or value != floorf(value):
func _format_action_value(value: Variant) -> String:
if ((E.lt(value, 1) or (not E.finite(value))) or E.ne(value, E.floor_value(value))):
return ""
return Global.format_number(value)
func _on_action_button_pressed(button_id: int) -> void:
if action_values.get(button_id, 0.0) > 0.0:
if E.gt(action_values.get(button_id, 0), 0):
action_pressed.emit(page_id, action_values[button_id])
+6 -5
View File
@@ -1,4 +1,5 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
const Parameters = preload("res://world/owniverse/world_parameters.gd")
var energy_bars: Array = []
@@ -32,21 +33,21 @@ func _ignore_mouse_on_children(node: Node) -> void:
func update(world) -> void:
var source: Dictionary = world.state
var energy: float = clampf(source.energy / Parameters.INITIAL_ENERGY, 0.0, 1.0)
var energy: float = E.approx(E.clamp_value(E.divide(source.energy, Parameters.INITIAL_ENERGY), 0, 1))
for bar in energy_bars: bar.scale.x = energy
var density_value: float = world.environment.stellar_environment(source).density
var density_value = world.environment.stellar_environment(source).density
var density: Dictionary = Parameters.density_display(density_value)
warning_border.self_modulate = Parameters.density_warning_color(density_value)
for indicator in density_indicators:
for mode in ["Density", "DensityRip", "DensityCrunch"]:
indicator.get_node(mode).visible = mode == density.mode
var pointer: Control = indicator.get_node("Pointer")
pointer.position.x = 16.0 + 288.0 * density.position - pointer.size.x / 2.0
pointer.position.x = 16.0 + 288.0 * E.approx(density.position) - pointer.size.x / 2.0
pointer.self_modulate = Color.BLACK if density.mode == "DensityCrunch" else Color.WHITE
for key in pointers:
var pointer: Control = pointers[key]
pointer.position.x = 16.0 * clampf(Parameters.value(source, key), 1.0, 19.0) - pointer.size.x / 2.0
pointer.position.x = 16.0 * E.approx(E.clamp_value(Parameters.value(source, key), 1, 19)) - pointer.size.x / 2.0
for i in trend_bars.size():
var bar: Control = trend_bars[i]
bar.pivot_offset = Vector2(0.0, bar.size.y)
bar.scale.y = clampf(source.get("trends", {}).get(Parameters.TRENDS[i], 0.0), 0.0, 1.0)
bar.scale.y = E.approx(E.clamp_value(source.get("trends", {}).get(Parameters.TRENDS[i], 0), 0, 1))
+1 -1
View File
@@ -19,7 +19,7 @@ var item_sub_page: int = -1
var item_type = ""
func set_label(value: float) -> void:
func set_label(value) -> void:
var text: String = Global.format_number(value)
if description.text != text:
description.text = text
+1 -1
View File
@@ -5,7 +5,7 @@
- `quantity_math.gd`: нормализация положительных весов, ограниченное целочисленное распределение методом наибольших остатков, распределение с накоплением ошибки округления, накопление дробного выпуска и минимальный целый размер производственной партии.
- `entity_catalog.gd`: загрузка определений, индексы групп и событий, списки автоматических производителей и разблокируемых сущностей.
Математика не обращается к дереву сцены, Global или Owniverse. Методы накопления изменяют переданный словарь остатков; остальные методы возвращают результат. Для распределения `split` количество должно быть целым, веса — неотрицательными. Равные остатки разрешаются по строковому ID. Сохранены прежние точности округления: 1e-12 для выпуска и 1e-10 для событий цивилизаций.
Математика не обращается к дереву сцены, Global или Owniverse. Методы накопления изменяют переданный словарь остатков; остальные методы возвращают результат. Для распределения `split` количество должно быть целым, веса — неотрицательными. Равные остатки разрешаются по строковому ID. Старые поправки `snappedf` удалены: количества считаются целочисленно, дробные вычисления выполняет [модуль точной арифметики](math/README.md). Там же описаны границы точности и формат сохранения 3.
Каталог получает словарь исходных строк через `load_definitions(rows)`, вызываемый один раз для нового экземпляра. Конструктор OwniverseEntity по-прежнему использует настройки Global. Каталог не хранит количества, партии или состояние интерфейса. Определения пока остаются Node: освобождает их владелец мира, как до рефакторинга.
+109 -112
View File
@@ -1,58 +1,58 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
func yearly_products(entity: OwniverseEntity) -> Dictionary:
var products: Dictionary = {}
if not entity.life_product.is_empty() and entity.lifespan > 0.0:
if ((not entity.life_product.is_empty()) and E.gt(entity.lifespan, 0)):
for resource in entity.life_product:
products[resource] = maxf(0.0, entity.life_product[resource]) / entity.lifespan
elif entity.product_per_year_type != "":
products[entity.product_per_year_type] = maxf(0.0, entity.product_per_year_amount)
products[resource] = E.divide(E.maximum(0, entity.life_product[resource]), entity.lifespan)
elif E.ne(entity.product_per_year_type, ""):
products[entity.product_per_year_type] = E.maximum(0, entity.product_per_year_amount)
return products
func produce_for_period(previous: Dictionary, target: Dictionary, quantities: Dictionary,
distribution: Dictionary, from_year: float, to_year: float) -> void:
func produce_for_period(previous: Dictionary, target: Dictionary, quantities: Dictionary, distribution: Dictionary, from_year: Variant, to_year: Variant) -> void:
var basic: Dictionary = {}
var automatic: Dictionary = {}
for id in quantities:
var entity: OwniverseEntity = world.catalog.entities[id]
var object_years: float = world.batches.object_years(previous, id, quantities[id], from_year, to_year)
var auto_share: float = clampf(entity.auto, 0.0, 1.0)
var object_years: Variant = world.batches.object_years(previous, id, quantities[id], from_year, to_year)
var auto_share: Variant = E.clamp_value(entity.auto, 0, 1)
var products: Dictionary = yearly_products(entity)
for product in products:
var amount: float = object_years * products[product] * WorldParameters.production(previous, entity)
basic[product] = basic.get(product, 0.0) + amount * (1.0 - auto_share)
automatic[product] = automatic.get(product, 0.0) + amount * auto_share
var amount: Variant = E.multiply(E.multiply(object_years, products[product]), WorldParameters.production(previous, entity))
basic[product] = E.add(basic.get(product, 0), E.multiply(amount, (E.subtract(1, auto_share))))
automatic[product] = E.add(automatic.get(product, 0), E.multiply(amount, auto_share))
# Basic and automatic output each retain their unproduced remainder.
if not target.has("resource_remainders"):
if (not target.has("resource_remainders")):
target.resource_remainders = {"basic": {}, "automatic": {}}
var pending: Dictionary = target.resource_remainders
var requests: Dictionary = {}
for id in basic:
pending.basic[id] = pending.basic.get(id, 0.0) + basic[id]
pending.automatic[id] = pending.automatic.get(id, 0.0) + automatic[id]
pending.basic[id] = E.add(pending.basic.get(id, 0), basic[id])
pending.automatic[id] = E.add(pending.automatic.get(id, 0), automatic[id])
var totals: Dictionary = {}
for channel in ["basic", "automatic"]:
for id in pending[channel]:
totals[id] = maxf(0.0, pending.basic.get(id, 0.0) + pending.automatic.get(id, 0.0))
totals[id] = E.maximum(0, E.add(pending.basic.get(id, 0), pending.automatic.get(id, 0)))
for id in totals:
if id == "S" or id == "H":
requests[id] = floorf(totals[id])
var funded: Dictionary = QuantityMath.allocate_whole(requests, floorf(maxf(0.0, previous.energy)))
if (E.eq(id, "S") or E.eq(id, "H")):
requests[id] = E.floor_value(totals[id])
var funded: Dictionary = QuantityMath.allocate_whole(requests, E.floor_value(E.maximum(0, previous.energy)))
automatic = {}
target.energy = previous.energy
for id in totals:
var amount: float = floorf(totals[id])
if id == "S" or id == "H":
amount = funded.get(id, 0.0)
target.energy -= amount
var amount: Variant = E.floor_value(totals[id])
if (E.eq(id, "S") or E.eq(id, "H")):
amount = funded.get(id, 0)
target.energy = E.subtract(target.energy, amount)
# Split the actual whole output, retaining the allocation error for next time.
var auto_ratio: float = clampf(pending.automatic.get(id, 0.0) / totals[id], 0.0, 1.0) if totals[id] > 0.0 else 0.0
var auto_amount: float = floorf(amount * auto_ratio)
pending.automatic[id] = pending.automatic.get(id, 0.0) - auto_amount
pending.basic[id] = pending.basic.get(id, 0.0) - (amount - auto_amount)
var auto_ratio: Variant = (E.clamp_value(E.divide(pending.automatic.get(id, 0), totals[id]), 0, 1) if E.gt(totals[id], 0) else 0)
var auto_amount: Variant = E.floor_value(E.multiply(amount, auto_ratio))
pending.automatic[id] = E.subtract(pending.automatic.get(id, 0), auto_amount)
pending.basic[id] = E.subtract(pending.basic.get(id, 0), (E.subtract(amount, auto_amount)))
automatic[id] = auto_amount
amount = world.ledger.credit(target, id, amount)
world.batches.append_batch(target, id, amount, from_year, to_year)
@@ -60,22 +60,22 @@ func produce_for_period(previous: Dictionary, target: Dictionary, quantities: Di
# Keep unused automatic resources across cycles; they are part of free amounts.
var budget: Dictionary = previous.get("auto_resources", {}).duplicate(true)
for id in automatic:
budget[id] = budget.get(id, 0.0) + automatic[id]
budget[id] = E.add(budget.get(id, 0), automatic[id])
for id in budget:
budget[id] = minf(maxf(0.0, budget[id]), world.ledger.available(target, id))
budget[id] = E.minimum(E.maximum(0, budget[id]), world.ledger.available(target, id))
target.auto_resources = budget
produce_brown_dwarfs(target, automatic.get("H", 0.0), from_year, to_year)
produce_brown_dwarfs(target, automatic.get("H", 0), from_year, to_year)
budget = ordinary_star_budget(target)
# One common multiplier preserves the initial count ratios, even under scarcity.
var candidates: Dictionary = {}
for id in distribution:
var entity: OwniverseEntity = world.catalog.entities[id]
if entity.group_as != "Star":
if E.ne(entity.group_as, "Star"):
continue
var has_cost: bool = false
for resource in entity.cost:
if entity.cost[resource] > 0.0:
if E.gt(entity.cost[resource], 0):
has_cost = true
if has_cost:
candidates[id] = distribution[id]
@@ -85,88 +85,87 @@ func produce_for_period(previous: Dictionary, target: Dictionary, quantities: Di
var costs: Dictionary = {}
var needs: Dictionary = {}
for id in candidates:
if auto_capacity(target, world.catalog.entities[id], budget) <= 0.0:
if E.le(auto_capacity(target, world.catalog.entities[id], budget), 0):
continue
for resource in world.catalog.entities[id].cost:
var cost: float = maxf(0.0, world.catalog.entities[id].cost[resource]) * candidates[id]
costs[resource] = costs.get(resource, 0.0) + cost
needs[resource] = needs.get(resource, 0.0) + cost
var cost: Variant = E.multiply(E.maximum(0, world.catalog.entities[id].cost[resource]), candidates[id])
costs[resource] = E.add(costs.get(resource, 0), cost)
needs[resource] = E.add(needs.get(resource, 0), cost)
for resource in world.catalog.entities[id].bind:
needs[resource] = needs.get(resource, 0.0) + maxf(0.0, world.catalog.entities[id].bind[resource]) * candidates[id]
var scale: float = INF
needs[resource] = E.add(needs.get(resource, 0), E.multiply(E.maximum(0, world.catalog.entities[id].bind[resource]), candidates[id]))
var scale: Variant = INF
for resource in costs:
if costs[resource] > 0.0:
scale = minf(scale, budget.get(resource, 0.0) / costs[resource])
if E.gt(costs[resource], 0):
scale = E.minimum(scale, E.divide(budget.get(resource, 0), costs[resource]))
for resource in needs:
if needs[resource] > 0.0:
scale = minf(scale, world.ledger.available(target, resource) / needs[resource])
if E.gt(needs[resource], 0):
scale = E.minimum(scale, E.divide(world.ledger.available(target, resource), needs[resource]))
var concrete_needs: Dictionary = world.ledger.expand_requirements(target, needs)
for resource in concrete_needs:
if concrete_needs[resource] > 0.0:
scale = minf(scale, world.ledger.available(target, resource) / concrete_needs[resource])
if not is_finite(scale) or scale <= 0.0:
if E.gt(concrete_needs[resource], 0):
scale = E.minimum(scale, E.divide(world.ledger.available(target, resource), concrete_needs[resource]))
if ((not E.finite(scale)) or E.le(scale, 0)):
return
# Rare types are served first; signed count deficits compensate later cycles.
if not target.has("auto_star_deficits"):
if (not target.has("auto_star_deficits")):
target.auto_star_deficits = {}
var deficits: Dictionary = target.auto_star_deficits
var order: Array = candidates.keys()
order.sort_custom(func(a: String, b: String) -> bool:
return candidates[a] < candidates[b] if candidates[a] != candidates[b] else a < b)
order.sort_custom(func ( a : String , b : String ) -> bool : return (E.lt(candidates[a], candidates[b]) if E.ne(candidates[a], candidates[b]) else E.lt(a, b)))
var created: Dictionary = {}
var metallicity: float = world.environment.stellar_environment(target).metallicity
var total_created: float = 0.0
var metallicity: Variant = world.environment.stellar_environment(target).metallicity
var total_created: Variant = 0
for id in order:
var entity: OwniverseEntity = world.catalog.entities[id]
var step: float = world.production.production_step(entity)
if not is_finite(step):
var step: Variant = world.production.production_step(entity)
if (not E.finite(step)):
continue
var quota: float = maxf(0.0, candidates[id] * scale + deficits.get(id, 0.0))
var desired: float = floorf(quota / step) * step
var actual: float = minf(desired, auto_capacity(target, entity, budget))
if actual <= 0.0:
var quota: Variant = E.maximum(0, E.add(E.multiply(candidates[id], scale), deficits.get(id, 0)))
var desired: Variant = E.multiply(E.floor_value(E.divide(quota, step)), step)
var actual: Variant = E.minimum(desired, auto_capacity(target, entity, budget))
if E.le(actual, 0):
continue
if not world.production.try_production(target, entity, actual, true):
if (not world.production.try_production(target, entity, actual, true)):
continue
budget = ordinary_star_budget(target)
created[id] = actual
total_created += actual
total_created = E.add(total_created, actual)
for id in candidates:
# Credit only actual production, so retained resources aren't counted twice.
deficits[id] = deficits.get(id, 0.0) + candidates[id] * total_created - created.get(id, 0.0)
deficits[id] = E.subtract(E.add(deficits.get(id, 0), E.multiply(candidates[id], total_created)), created.get(id, 0))
for id in created:
world.ledger.credit(target, id, created[id])
world.batches.append_batch(target, id, created[id], from_year, to_year)
world.stellar.create_planets(target, id, created[id], from_year, to_year, metallicity)
func produce_brown_dwarfs(target: Dictionary, new_hydrogen: float, from_year: float, to_year: float) -> void:
if not world.catalog.entities.has("HBD"):
func produce_brown_dwarfs(target: Dictionary, new_hydrogen: Variant, from_year: Variant, to_year: Variant) -> void:
if (not world.catalog.entities.has("HBD")):
return
var entity: OwniverseEntity = world.catalog.entities.HBD
var cost: float = entity.cost.get("H", 0.0)
if cost <= 0.0:
var cost: Variant = entity.cost.get("H", 0)
if E.le(cost, 0):
return
var quota: float = target.auto_star_deficits.get("HBD", 0.0) + maxf(0.0, new_hydrogen) * 0.001 / cost
var fuel: float = maxf(0.0, target.auto_star_deficits.get("HBD_fuel", 0.0))
var available: float = minf(target.auto_resources.get("H", 0.0), world.ledger.available(target, "H"))
quota = minf(quota, (available + fuel) / cost)
var expected: float = maxf(0.0, snappedf(quota, 1e-9))
var amount: float = floorf(expected)
var quota: Variant = E.add(target.auto_star_deficits.get("HBD", 0), E.divide(E.multiply(E.maximum(0, new_hydrogen), E.of("0.001")), cost))
var fuel: Variant = E.maximum(0, target.auto_star_deficits.get("HBD_fuel", 0))
var available: Variant = E.minimum(target.auto_resources.get("H", 0), world.ledger.available(target, "H"))
quota = E.minimum(quota, E.divide((E.add(available, fuel)), cost))
var expected: Variant = E.maximum(0, quota)
var amount: Variant = E.floor_value(expected)
# Do not retry a fractional chance on frames with no new automatic fuel.
if new_hydrogen > 0.0 and expected > amount:
var random_state: float = target.auto_star_deficits.get("HBD_random", 1234567.0)
random_state = fmod(random_state * 48271.0, 2147483647.0)
if (E.gt(new_hydrogen, 0) and E.gt(expected, amount)):
var random_state: Variant = target.auto_star_deficits.get("HBD_random", 1234567)
random_state = E.remainder(E.multiply(random_state, 48271), E.of("2147483647.0"))
target.auto_star_deficits.HBD_random = random_state
if random_state / 2147483647.0 < expected - amount:
amount += 1.0
amount = minf(amount, floorf(snappedf((available + fuel) / cost, 1e-9)))
var charge: float = ceilf(maxf(0.0, snappedf(amount * cost - fuel, 1e-9)))
if amount > 0.0 and charge <= available:
if E.lt(E.divide(random_state, E.of("2147483647.0")), E.subtract(expected, amount)):
amount = E.add(amount, 1)
amount = E.minimum(amount, E.floor_value(E.divide((E.add(available, fuel)), cost)))
var charge: Variant = E.ceil_value(E.maximum(0, E.subtract(E.multiply(amount, cost), fuel)))
if (E.gt(amount, 0) and E.le(charge, available)):
world.ledger.take_available(target, "H", charge)
target.auto_resources.H = maxf(0.0, target.auto_resources.get("H", 0.0) - charge)
target.auto_star_deficits.HBD_fuel = maxf(0.0, snappedf(fuel + charge - amount * cost, 1e-9))
quota -= amount
target.auto_resources.H = E.maximum(0, E.subtract(target.auto_resources.get("H", 0), charge))
target.auto_star_deficits.HBD_fuel = E.maximum(0, E.subtract(E.add(fuel, charge), E.multiply(amount, cost)))
quota = E.subtract(quota, amount)
world.ledger.credit(target, "HBD", amount)
world.batches.append_batch(target, "HBD", amount, from_year, to_year)
target.auto_star_deficits.HBD = quota
@@ -175,52 +174,50 @@ func produce_brown_dwarfs(target: Dictionary, new_hydrogen: float, from_year: fl
func ordinary_star_budget(target: Dictionary) -> Dictionary:
var budget: Dictionary = target.auto_resources.duplicate()
if world.catalog.entities.has("HBD"):
var reserved: float = maxf(0.0, target.auto_star_deficits.get("HBD", 0.0) * world.catalog.entities.HBD.cost.get("H", 0.0) - target.auto_star_deficits.get("HBD_fuel", 0.0))
budget.H = maxf(0.0, budget.get("H", 0.0) - reserved)
var reserved: Variant = E.maximum(0, E.subtract(E.multiply(target.auto_star_deficits.get("HBD", 0), world.catalog.entities.HBD.cost.get("H", 0)), target.auto_star_deficits.get("HBD_fuel", 0)))
budget.H = E.maximum(0, E.subtract(budget.get("H", 0), reserved))
return budget
func auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictionary) -> float:
var capacity: float = INF
func auto_capacity(source: Dictionary, entity: OwniverseEntity, budget: Dictionary) -> Variant:
var capacity: Variant = INF
var needs: Dictionary = {}
for resource in entity.cost:
var cost: float = maxf(0.0, entity.cost[resource])
var cost: Variant = E.maximum(0, entity.cost[resource])
needs[resource] = cost
if cost > 0.0:
capacity = minf(capacity, floorf(budget.get(resource, 0.0) / cost))
if E.gt(cost, 0):
capacity = E.minimum(capacity, E.floor_value(E.divide(budget.get(resource, 0), cost)))
for resource in entity.bind:
needs[resource] = needs.get(resource, 0.0) + maxf(0.0, entity.bind[resource])
needs[resource] = E.add(needs.get(resource, 0), E.maximum(0, entity.bind[resource]))
for resource in needs:
if needs[resource] > 0.0:
capacity = minf(capacity, floorf(world.ledger.available(source, resource) / needs[resource]))
if E.gt(needs[resource], 0):
capacity = E.minimum(capacity, E.floor_value(E.divide(world.ledger.available(source, resource), needs[resource])))
var concrete: Dictionary = world.ledger.expand_requirements(source, needs)
for resource in concrete:
if concrete[resource] > 0.0:
capacity = minf(capacity, floorf(world.ledger.available(source, resource) / concrete[resource]))
var step: float = world.production.production_step(entity)
return floorf(capacity / step) * step if is_finite(capacity) and is_finite(step) else 0.0
if E.gt(concrete[resource], 0):
capacity = E.minimum(capacity, E.floor_value(E.divide(world.ledger.available(source, resource), concrete[resource])))
var step: Variant = world.production.production_step(entity)
return (E.multiply(E.floor_value(E.divide(capacity, step)), step) if (E.finite(capacity) and E.finite(step)) else 0)
func produce_newborn_lifetime(target: Dictionary, id: String, count: float,
born_from: float, born_to: float, to_year: float, before_batches: Array,
remaining_object_years: float = NAN) -> void:
func produce_newborn_lifetime(target: Dictionary, id: String, count: Variant, born_from: Variant, born_to: Variant, to_year: Variant, before_batches: Array, remaining_object_years: Variant = NAN) -> void:
var products: Dictionary = yearly_products(world.catalog.entities[id])
if products.is_empty() or count <= 0.0:
if (products.is_empty() or E.le(count, 0)):
return
var object_years: float = remaining_object_years if is_finite(remaining_object_years) else count * maxf(0.0, to_year - (born_from + born_to) * 0.5)
var object_years: Variant = (remaining_object_years if E.finite(remaining_object_years) else E.multiply(count, E.maximum(0, E.subtract(to_year, E.multiply((E.add(born_from, born_to)), E.of("0.5"))))))
var previous_amounts: Dictionary = {}
for old: Dictionary in before_batches:
var start: float = old.get("created_from", -INF)
if not previous_amounts.has(start):
previous_amounts[start] = old.amount
for batch: Dictionary in target.batches.get(id, []):
var added: float = batch.amount - previous_amounts.get(batch.get("created_from", INF), 0.0)
object_years -= maxf(0.0, added) * world.batches.expired_years(to_year, batch.start_year, batch.end_year)
object_years = maxf(0.0, object_years)
for old : Dictionary in before_batches:
var start: Variant = old.get("created_from", E.neg(INF))
if (not previous_amounts.has(str(start))):
previous_amounts[str(start)] = old.amount
for batch : Dictionary in target.batches.get(id, []):
var added: Variant = E.subtract(batch.amount, previous_amounts.get(str(batch.get("created_from", INF)), 0))
object_years = E.subtract(object_years, E.multiply(E.maximum(0, added), world.batches.expired_years(to_year, batch.start_year, batch.end_year)))
object_years = E.maximum(0, object_years)
for resource in products:
var output: float = object_years * products[resource] * WorldParameters.production(target, world.catalog.entities[id])
if resource in ["H", "S"]:
output = minf(output, target.energy)
var actual: float = world.ledger.credit(target, resource, output)
if resource in ["H", "S"]:
target.energy = maxf(0.0, target.energy - actual)
var output: Variant = E.multiply(E.multiply(object_years, products[resource]), WorldParameters.production(target, world.catalog.entities[id]))
if E.contains(["H", "S"], resource):
output = E.minimum(output, target.energy)
var actual: Variant = world.ledger.credit(target, resource, output)
if E.contains(["H", "S"], resource):
target.energy = E.maximum(0, E.subtract(target.energy, actual))
+100 -101
View File
@@ -1,4 +1,5 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
@@ -7,43 +8,42 @@ const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const ETHICS = State.ETHICS
const OPPOSITE = {"m": "l", "l": "m", "w": "d", "d": "w", "c": "b", "b": "c"}
static func total(pop: Dictionary) -> float:
static func total(pop: Dictionary) -> Variant:
return QuantityMath.sum(pop)
static func add(to: Dictionary, from: Dictionary) -> void:
for key in from: to[key] = to.get(key, 0.0) + from[key]
for key in from: to[key] = E.add(to.get(key, 0), from[key])
static func take(world, pop: Dictionary, count: float) -> Dictionary:
static func take(world, pop: Dictionary, count: Variant) -> Dictionary:
var taken: Dictionary = QuantityMath.allocate_whole(pop, count)
for key in taken: pop[key] -= taken[key]
for key in taken: pop[key] = E.subtract(pop[key], taken[key])
return taken
static func rate(target: Dictionary, key: String, count: float, probability: float) -> float:
if not target.has("civilization_remainders"): target.civilization_remainders = {}
static func rate(target: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
if (not target.has("civilization_remainders")): target.civilization_remainders = {}
return QuantityMath.accumulate_rate(target.civilization_remainders, key, count, probability)
# 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: float, weights: Dictionary) -> Dictionary:
if count <= 0.0 or QuantityMath.sum(weights) <= 0.0: return {}
if not target.has("civilization_remainders"): target.civilization_remainders = {}
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 (not target.has("civilization_remainders")): target.civilization_remainders = {}
return QuantityMath.split(target.civilization_remainders, key, count, weights)
static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary,
diplomacy: bool = false, colony: bool = false) -> Dictionary:
static func drift(target: Dictionary, key: String, pop: Dictionary, rows: Dictionary, diplomacy: bool = false, colony: bool = false) -> Dictionary:
var weights: Dictionary = {}
for ethic in pop:
var influence: float = float(rows[ethic].diplomacy_rate) if diplomacy else 1.0
var influence: Variant = (E.of(rows[ethic].diplomacy_rate) if diplomacy else 1)
for other in ETHICS:
var weight: float = 9.0 if colony else 1.0
if other == ethic: weight = 960.0 if colony else 196.0
elif other == OPPOSITE[ethic]: weight = 4.0 if colony else 0.0
weights[other] = weights.get(other, 0.0) + pop[ethic] * influence * weight
if total(weights) <= 0.0: return pop.duplicate()
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))
if E.le(total(weights), 0): return pop.duplicate()
return split(target, key, total(pop), weights)
static func emit(plan: Dictionary, destination: String, pop: Dictionary) -> void:
if not plan.outputs.has(destination): plan.outputs[destination] = {}
if (not plan.outputs.has(destination)): plan.outputs[destination] = {}
add(plan.outputs[destination], pop)
static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: Dictionary) -> void:
@@ -51,51 +51,50 @@ static func fall(target: Dictionary, plan: Dictionary, rules: Dictionary, pop: D
var weights: Dictionary = {}
for term in str(rules[plan.id].ethics[ethic].fall).split("^", false):
var pair: PackedStringArray = term.split(":")
weights[pair[0]] = float(pair[1])
if weights.is_empty(): weights[rules[plan.id].previous] = 1.0
var shares: Dictionary = split(target, "fall:" + plan.id + ":" + ethic, pop[ethic], weights)
weights[pair[0]] = E.of(pair[1])
if weights.is_empty(): weights[rules[plan.id].previous] = 1
var shares: Dictionary = split(target, E.add(E.add(E.add("fall:", plan.id), ":"), ethic), pop[ethic], weights)
for stage in shares: emit(plan, stage, {ethic: shares[stage]})
static func progress(world, target: Dictionary, rules: Dictionary, budget: Dictionary,
plan: Dictionary, pop: Dictionary) -> Dictionary:
static func progress(world, target: Dictionary, rules: Dictionary, budget: Dictionary, plan: Dictionary, pop: Dictionary) -> Dictionary:
var successor: String = rules[plan.id].next
var admitted: float = State.admit_transition(budget, rules, plan.id, successor, total(pop))
var admitted: Variant = State.admit_transition(budget, rules, plan.id, successor, total(pop))
var accepted: Dictionary = take(world, pop, admitted)
emit(plan, successor, accepted)
return pop
static func new_plan(id: String, year: float) -> Dictionary:
return {"id": id, "from": year, "to": -INF, "amount": 0.0, "outputs": {}}
static func new_plan(id: String, year: Variant) -> Dictionary:
return {"id": id, "from": year, "to": E.neg(INF), "amount": 0, "outputs": {}}
static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop: Dictionary, count: float, year: float) -> Dictionary:
var old_total: float = total(pop)
static func consume_untriggered(world, target: Dictionary, plan: Dictionary, pop: Dictionary, count: Variant, year: Variant) -> Dictionary:
var old_total: Variant = total(pop)
var removed: Dictionary = take(world, pop, count)
var actual: float = total(removed)
if actual > 0.0:
world.batches.scale_batches(target, plan.id, (old_total - actual) / old_total)
plan.amount += actual
plan.to = maxf(plan.to, year)
var actual: Variant = total(removed)
if E.gt(actual, 0):
world.batches.scale_batches(target, plan.id, E.divide((E.subtract(old_total, actual)), old_total))
plan.amount = E.add(plan.amount, actual)
plan.to = E.maximum(plan.to, year)
return removed
static func prepare(world, target: Dictionary, expired: Dictionary, year: float, budget: Dictionary, rules: Dictionary) -> Dictionary:
static func prepare(world, target: Dictionary, expired: Dictionary, year: Variant, budget: Dictionary, rules: Dictionary) -> Dictionary:
State.reconcile(world, target, rules)
var remaining: Dictionary = target.ethics.duplicate(true)
var plans: Dictionary = {}
var colonies: Array = []
var collateral: Dictionary = {}
var modifiers: Dictionary = WorldParameters.life_modifiers(target)
var order: float = WorldParameters.value(target, "Order")
if not target.has("civilization_stats"): target.civilization_stats = {}
var order: Variant = WorldParameters.value(target, "Order")
if (not target.has("civilization_stats")): target.civilization_stats = {}
var ids: Array = expired.keys()
ids.sort()
for id in ids:
if not rules.has(id) or expired[id].is_empty(): continue
if ((not rules.has(id)) or expired[id].is_empty()): continue
var plan: Dictionary = new_plan(id, year)
plans[id] = plan
for part: Dictionary in expired[id]:
plan.amount += part.amount
plan.from = minf(plan.from, part.from)
plan.to = maxf(plan.to, part.to)
for part : Dictionary in expired[id]:
plan.amount = E.add(plan.amount, part.amount)
plan.from = E.minimum(plan.from, part.from)
plan.to = E.maximum(plan.to, part.to)
var triggered: Dictionary = take(world, remaining[id], plan.amount)
var successes: Dictionary = {}
var failures: Dictionary = {}
@@ -103,54 +102,54 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float,
var settlers: Dictionary = {}
for ethic in triggered:
var row: Dictionary = rules[id].ethics[ethic]
var p: float = maxf(0.0, float(row.progress) * modifiers.progress)
var d: float = maxf(0.0, float(row.degradation) * modifiers.degradation)
var scale: float = maxf(1.0, p + d)
var pro: float = rate(target, id + ":" + ethic + ":pro", triggered[ethic], p / scale)
var deg: float = rate(target, id + ":" + ethic + ":deg", triggered[ethic], d / scale)
var p: Variant = E.maximum(0, E.multiply(E.of(row.progress), modifiers.progress))
var d: Variant = E.maximum(0, E.multiply(E.of(row.degradation), modifiers.degradation))
var scale: Variant = E.maximum(1, E.add(p, d))
var pro: Variant = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":pro"), triggered[ethic], E.divide(p, scale))
var deg: Variant = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":deg"), triggered[ethic], E.divide(d, scale))
var bounded: Dictionary = QuantityMath.allocate_whole({"pro": pro, "deg": deg}, triggered[ethic])
if rules[id].fights: warriors[ethic] = rate(target, id + ":" + ethic + ":war", triggered[ethic], minf(1.0, float(row.war) * modifiers.war))
if rules[id].colonizes: settlers[ethic] = rate(target, id + ":" + ethic + ":col", triggered[ethic], minf(1.0, float(row.colonization) * modifiers.colonization))
if rules[id].fights: warriors[ethic] = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":war"), triggered[ethic], E.minimum(1, E.multiply(E.of(row.war), modifiers.war)))
if rules[id].colonizes: settlers[ethic] = rate(target, E.add(E.add(E.add(id, ":"), ethic), ":col"), triggered[ethic], E.minimum(1, E.multiply(E.of(row.colonization), modifiers.colonization)))
successes[ethic] = bounded.pro
failures[ethic] = bounded.deg
triggered[ethic] -= bounded.pro + bounded.deg
triggered[ethic] = E.subtract(triggered[ethic], E.add(bounded.pro, bounded.deg))
add(triggered, progress(world, target, rules, budget, plan, successes))
fall(target, plan, rules, failures)
if rules[id].fights:
for ethic in warriors: warriors[ethic] = minf(warriors[ethic], triggered[ethic])
var wars: float = minf(total(warriors), floorf((total(triggered) + total(remaining[id])) / 2.0))
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)
for ethic in warriors: triggered[ethic] -= warriors[ethic]
var outcomes: Dictionary = split(target, id + ":wars", wars, {"deg": 1.0, "pro25": 2.0, "pro50": 3.0, "pro75": 3.0})
var win_expected: float = outcomes.get("pro25", 0.0) * 0.25 + outcomes.get("pro50", 0.0) * 0.5 + outcomes.get("pro75", 0.0) * 0.75
var loss_expected: float = outcomes.get("deg", 0.0) * 2.0 + outcomes.get("pro25", 0.0) + outcomes.get("pro50", 0.0) * 1.5 + outcomes.get("pro75", 0.0) * 2.0
var winners: Dictionary = take(world, warriors, rate(target, id + ":wins", win_expected, 1.0))
var attackers_lost: Dictionary = take(world, warriors, rate(target, id + ":attackers_lost", outcomes.get("deg", 0.0) / order, 1.0))
for ethic in warriors: triggered[ethic] = E.subtract(triggered[ethic], warriors[ethic])
var outcomes: Dictionary = split(target, E.add(id, ":wars"), wars, {"deg": 1, "pro25": 2, "pro50": 3, "pro75": 3})
var win_expected: Variant = E.add(E.add(E.multiply(outcomes.get("pro25", 0), E.of("0.25")), E.multiply(outcomes.get("pro50", 0), E.of("0.5"))), E.multiply(outcomes.get("pro75", 0), E.of("0.75")))
var loss_expected: Variant = E.add(E.add(E.add(E.multiply(outcomes.get("deg", 0), 2), outcomes.get("pro25", 0)), E.multiply(outcomes.get("pro50", 0), E.of("1.5"))), E.multiply(outcomes.get("pro75", 0), 2))
var winners: Dictionary = take(world, warriors, rate(target, E.add(id, ":wins"), win_expected, 1))
var attackers_lost: Dictionary = take(world, warriors, rate(target, E.add(id, ":attackers_lost"), E.divide(outcomes.get("deg", 0), order), 1))
add(triggered, progress(world, target, rules, budget, plan, winners))
fall(target, plan, rules, attackers_lost)
var losses: float = minf(rate(target, id + ":loss", loss_expected / order, 1.0), total(triggered) + total(remaining[id]))
var losses: Variant = E.minimum(rate(target, E.add(id, ":loss"), E.divide(loss_expected, order), 1), E.add(total(triggered), total(remaining[id])))
var sides: Dictionary = QuantityMath.allocate_whole({"due": total(triggered), "later": total(remaining[id])}, losses)
var victims: Dictionary = take(world, triggered, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
fall(target, plan, rules, victims)
add(triggered, warriors)
var damage: float = rate(target, id + ":collateral", (outcomes.get("pro50", 0.0) * 10.0 + outcomes.get("pro75", 0.0) * 30.0) / order, 1.0)
collateral[rules[id].previous] = collateral.get(rules[id].previous, 0.0) + damage
target.civilization_stats.wars = target.civilization_stats.get("wars", 0.0) + wars
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)
emit(plan, id, triggered)
if total(settlers) > 0.0: colonies.append({"stage": rules[id].colony, "source": id, "population": settlers})
if E.gt(total(settlers), 0): colonies.append({"stage": rules[id].colony, "source": id, "population": settlers})
# Collateral is applied after all stages planned their own attempts.
for id in collateral:
if not rules.has(id): continue
if not plans.has(id): plans[id] = new_plan(id, year)
if (not rules.has(id)): continue
if (not plans.has(id)): plans[id] = new_plan(id, year)
var plan: Dictionary = plans[id]
var retry: Dictionary = plan.outputs.get(id, {})
var amount: float = minf(collateral[id], total(retry) + total(remaining[id]))
var amount: Variant = E.minimum(collateral[id], E.add(total(retry), total(remaining[id])))
var sides: Dictionary = QuantityMath.allocate_whole({"due": total(retry), "later": total(remaining[id])}, amount)
var victims: Dictionary = take(world, retry, sides.due)
add(victims, consume_untriggered(world, target, plan, remaining[id], sides.later, year))
fall(target, plan, rules, victims)
target.civilization_stats.collateral = target.civilization_stats.get("collateral", 0.0) + total(victims)
target.civilization_stats.collateral = E.add(target.civilization_stats.get("collateral", 0), total(victims))
var incoming: Dictionary = {}
var planetary: Dictionary = {}
for id in plans:
@@ -159,70 +158,70 @@ static func prepare(world, target: Dictionary, expired: Dictionary, year: float,
for destination in plan.outputs:
var pop: Dictionary = plan.outputs[destination]
if rules.has(destination):
if destination != "Ct":
pop = drift(target, id + ":" + destination + ":drift", pop, rules[id].ethics, destination == id and rules[id].diplomacy)
if not incoming.has(destination): incoming[destination] = {}
if E.ne(destination, "Ct"):
pop = drift(target, E.add(E.add(E.add(id, ":"), destination), ":drift"), pop, rules[id].ethics, (E.eq(destination, id) and rules[id].diplomacy))
if (not incoming.has(destination)): incoming[destination] = {}
add(incoming[destination], pop)
products[destination] = total(pop)
if State.is_space_civilization(id, rules) and world.catalog.entities[destination].group_as == "Planet":
planetary[destination] = planetary.get(destination, 0.0) + total(pop)
expired[id] = [{"amount": plan.amount, "from": plan.from, "to": maxf(plan.from, plan.to), "products": products}]
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}]
return {"remaining": remaining, "incoming": incoming, "planetary": planetary, "colonies": colonies}
static func finish(world, target: Dictionary, transaction: Dictionary, year: float, rules: Dictionary) -> void:
static func finish(world, target: Dictionary, transaction: Dictionary, year: Variant, rules: Dictionary) -> void:
target.ethics = transaction.remaining
for id in transaction.incoming:
if not target.ethics.has(id): target.ethics[id] = {}
if (not target.ethics.has(id)): target.ethics[id] = {}
add(target.ethics[id], transaction.incoming[id])
# Space civilizations that fall back to planetary stages need a stellar host.
var physical: Dictionary = world.ledger.physical_amounts(target)
var hosts: Dictionary = {}
for id in physical:
if world.catalog.entities[id].event_group == "DwStar" and physical[id] > 0.0: hosts[id] = physical[id]
if (E.eq(world.catalog.entities[id].event_group, "DwStar") and E.gt(physical[id], 0)): hosts[id] = physical[id]
for id in transaction.planetary:
var shares: Dictionary = split(target, "return:" + id, minf(transaction.planetary[id], target.amount.get(id, 0.0)), hosts)
var shares: Dictionary = split(target, E.add("return:", id), E.minimum(transaction.planetary[id], target.amount.get(id, 0)), hosts)
for owner in shares:
if not target.binds.has(owner): target.binds[owner] = {}
target.binds[owner][id] = target.binds[owner].get(id, 0.0) + shares[owner]
target.amount[id] -= shares[owner]
if (not target.binds.has(owner)): target.binds[owner] = {}
target.binds[owner][id] = E.add(target.binds[owner].get(id, 0), shares[owner])
target.amount[id] = E.subtract(target.amount[id], shares[owner])
colonize(world, target, transaction.colonies, year, rules)
State.reconcile(world, target, rules)
var worship: float = 0.0
var worship: Variant = 0
for id in rules:
if not rules[id].worships: continue
for ethic in target.ethics[id]: worship += target.ethics[id][ethic] * float(rules[id].ethics[ethic].worship)
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
static func colonize(world, target: Dictionary, colonies: Array, year: float, rules: Dictionary) -> void:
static func colonize(world, target: Dictionary, colonies: Array, year: Variant, rules: Dictionary) -> void:
var physical: Dictionary = world.ledger.physical_amounts(target)
var stock: Dictionary = {}
for id in ["GG", "AB", "RP", "HP"]: stock[id] = physical.get(id, 0.0)
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():
var colony: Dictionary = colonies[i]
var population: Dictionary = QuantityMath.allocate_whole(colony.population, granted[str(i)])
population = drift(target, colony.source + ":colonies", population, rules[colony.source].ethics, false, true)
var count: float = total(population)
population = drift(target, E.add(colony.source, ":colonies"), population, rules[colony.source].ethics, false, true)
var count: Variant = total(population)
var targets: Dictionary = QuantityMath.allocate_whole(stock, count)
for id in targets:
var amount: float = targets[id]
if amount <= 0.0: continue
var slots: Dictionary = {"": target.amount.get(id, 0.0)}
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0.0)
var amount: Variant = targets[id]
if E.le(amount, 0): continue
var slots: Dictionary = {"": target.amount.get(id, 0)}
for owner in target.binds: slots[owner] = target.binds[owner].get(id, 0)
var taken: Dictionary = QuantityMath.allocate_whole(slots, amount)
world.batches.scale_batches(target, id, 1.0 - amount / stock[id])
stock[id] -= amount
world.batches.scale_batches(target, id, E.subtract(1, E.divide(amount, stock[id])))
stock[id] = E.subtract(stock[id], amount)
for owner in taken:
if taken[owner] <= 0.0: continue
if owner == "": target.amount[id] -= taken[owner]
else: target.binds[owner][id] -= taken[owner]
var created: float = world.ledger.credit(target, colony.stage, taken[owner])
if owner != "":
target.amount[colony.stage] -= created
target.binds[owner][colony.stage] = target.binds[owner].get(colony.stage, 0.0) + created
if E.le(taken[owner], 0): continue
if E.eq(owner, ""): target.amount[id] = E.subtract(target.amount[id], taken[owner])
else: target.binds[owner][id] = E.subtract(target.binds[owner][id], taken[owner])
var created: Variant = world.ledger.credit(target, colony.stage, taken[owner])
if E.ne(owner, ""):
target.amount[colony.stage] = E.subtract(target.amount[colony.stage], created)
target.binds[owner][colony.stage] = E.add(target.binds[owner].get(colony.stage, 0), created)
world.batches.append_batch(target, colony.stage, amount, year, year)
if not target.ethics.has(colony.stage): target.ethics[colony.stage] = {}
if (not target.ethics.has(colony.stage)): target.ethics[colony.stage] = {}
add(target.ethics[colony.stage], population)
target.civilization_stats.colonies = target.civilization_stats.get("colonies", 0.0) + count
target.civilization_stats.colonies = E.add(target.civilization_stats.get("colonies", 0), count)
+102 -105
View File
@@ -1,41 +1,42 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
const ETHICS = ["m", "l", "w", "d", "c", "b"]
static func is_space_civilization(id: String, rules: Dictionary) -> bool:
return rules.has(id) and str(rules[id].home) != ""
return (rules.has(id) and E.ne(str(rules[id].home), ""))
# Preserve any existing ethics. New populations start equally distributed;
# reductions (including blasts) are proportional and never leave negative counts.
static func reconcile(world, target: Dictionary, rules: Dictionary) -> void:
if not target.has("ethics"):
if (not target.has("ethics")):
target.ethics = {}
var physical: Dictionary = world.ledger.physical_amounts(target)
for id in rules:
var count: float = physical.get(id, 0.0)
var count: Variant = physical.get(id, 0)
var current: Dictionary = target.ethics.get(id, {})
var total: float = 0.0
var total: Variant = 0
for ethic in ETHICS:
current[ethic] = maxf(0.0, current.get(ethic, 0.0))
total += current[ethic]
if total > count:
current[ethic] = E.maximum(0, current.get(ethic, 0))
total = E.add(total, current[ethic])
if E.gt(total, count):
current = QuantityMath.allocate_whole(current, count)
elif total < count:
var missing: float = count - total
var base: float = floorf(missing / ETHICS.size())
var rest: int = int(missing - base * ETHICS.size())
elif E.lt(total, count):
var missing: Variant = E.subtract(count, total)
var base: Variant = E.floor_value(E.divide(missing, ETHICS.size()))
var rest: int = E.integer(E.subtract(missing, E.multiply(base, ETHICS.size())))
for i in ETHICS.size():
current[ETHICS[i]] += base + (1.0 if i < rest else 0.0)
current[ETHICS[i]] = E.add(current[ETHICS[i]], E.add(base, ((1 if E.lt(i, rest) else 0))))
target.ethics[id] = current
static func detach_space_civilizations(target: Dictionary, rules: Dictionary) -> void:
for held: Dictionary in target.binds.values():
for held : Dictionary in target.binds.values():
for id in held:
if is_space_civilization(id, rules):
target.amount[id] = target.amount.get(id, 0.0) + held[id]
held[id] = 0.0
target.amount[id] = E.add(target.amount.get(id, 0), held[id])
held[id] = 0
# A shared budget per stellar family, including stars bound into structures.
# Reservations affect capacity, not the physical stock or its production.
@@ -45,65 +46,64 @@ static func stellar_budget(world, target: Dictionary, rules: Dictionary) -> Dict
for id in physical:
var entity = world.catalog.entities[id]
for home in ["DwStar", "GiStar", "WD", "NS", "MGT", "BH"]:
if entity.event_group == home or entity.count_as == home or id == home:
capacity[home] = capacity.get(home, 0.0) + physical[id]
capacity["SMBH"] = maxf(0.0, target.amount.get("SMBH", 0.0))
for held: Dictionary in target.binds.values():
capacity["SMBH"] += maxf(0.0, held.get("SMBH", 0.0))
if ((E.eq(entity.event_group, home) or E.eq(entity.count_as, home)) or E.eq(id, home)):
capacity[home] = E.add(capacity.get(home, 0), physical[id])
capacity["SMBH"] = E.maximum(0, target.amount.get("SMBH", 0))
for held : Dictionary in target.binds.values():
capacity["SMBH"] = E.add(capacity["SMBH"], E.maximum(0, held.get("SMBH", 0)))
var populations: Dictionary = {}
var requested: Dictionary = {}
for id in rules:
if not is_space_civilization(id, rules):
if (not is_space_civilization(id, rules)):
continue
populations[id] = physical.get(id, 0.0)
populations[id] = physical.get(id, 0)
var home: String = rules[id].home
requested[home] = requested.get(home, 0.0) + populations[id] * maxf(1.0, rules[id].stars)
requested[home] = E.add(requested.get(home, 0), E.multiply(populations[id], E.maximum(1, rules[id].stars)))
var free: Dictionary = {}
for home in capacity:
free[home] = maxf(0.0, capacity[home] - requested.get(home, 0.0))
free[home] = E.maximum(0, E.subtract(capacity[home], requested.get(home, 0)))
return {"capacity": capacity, "requested": requested, "free": free, "populations": populations, "habitats": habitat_budget(world, target, rules)}
# Account for the source's existing reservation when changing stages. A failed
# transition retains its population at the source, rather than losing objects.
static func admit_transition(budget: Dictionary, rules: Dictionary, source: String,
destination: String, requested: float) -> float:
if source == destination or not is_space_civilization(destination, rules):
static func admit_transition(budget: Dictionary, rules: Dictionary, source: String, destination: String, requested: Variant) -> Variant:
if (E.eq(source, destination) or (not is_space_civilization(destination, rules))):
return requested
var home: String = rules[destination].home
var cost: float = maxf(1.0, rules[destination].stars)
var cost: Variant = E.maximum(1, rules[destination].stars)
var old_home: String = ""
var old_cost: float = 0.0
var old_cost: Variant = 0
if is_space_civilization(source, rules):
old_home = rules[source].home
old_cost = maxf(1.0, rules[source].stars)
old_cost = E.maximum(1, rules[source].stars)
# Existing overcapacity is handled by the separate population policy.
if budget.requested.get(home, 0.0) > budget.capacity.get(home, 0.0):
return 0.0
var net_cost: float = cost - old_cost if old_home == home else cost
var admitted: float = floorf(maxf(0.0, requested))
if net_cost > 0.0:
admitted = minf(admitted, floorf(budget.free.get(home, 0.0) / net_cost))
if E.gt(budget.requested.get(home, 0), budget.capacity.get(home, 0)):
return 0
var net_cost: Variant = (E.subtract(cost, old_cost) if E.eq(old_home, home) else cost)
var admitted: Variant = E.floor_value(E.maximum(0, requested))
if E.gt(net_cost, 0):
admitted = E.minimum(admitted, E.floor_value(E.divide(budget.free.get(home, 0), net_cost)))
if budget.has("habitats"):
var housing: Dictionary = budget.habitats
var new_req: Dictionary = housing.requirements.get(destination, {})
var old_req: Dictionary = housing.requirements.get(source, {})
if not new_req.is_empty():
var net_units: float = new_req.units - (old_req.units if old_req.get("id", "") == new_req.id else 0.0)
if net_units > 0.0:
var free_units: float = maxf(0.0, housing.capacity.get(new_req.id, 0.0) - housing.requested.get(new_req.id, 0.0))
admitted = minf(admitted, floorf(free_units / net_units))
if not old_req.is_empty(): housing.requested[old_req.id] = maxf(0.0, housing.requested.get(old_req.id, 0.0) - admitted * old_req.units)
if not new_req.is_empty(): housing.requested[new_req.id] = housing.requested.get(new_req.id, 0.0) + admitted * new_req.units
if old_home != "":
budget.requested[old_home] = maxf(0.0, budget.requested.get(old_home, 0.0) - admitted * old_cost)
budget.free[old_home] = maxf(0.0, budget.capacity.get(old_home, 0.0) - budget.requested[old_home])
budget.requested[home] = budget.requested.get(home, 0.0) + admitted * cost
budget.free[home] = maxf(0.0, budget.capacity.get(home, 0.0) - budget.requested[home])
if (not new_req.is_empty()):
var net_units: Variant = E.subtract(new_req.units, ((old_req.units if E.eq(old_req.get("id", ""), new_req.id) else 0)))
if E.gt(net_units, 0):
var free_units: Variant = E.maximum(0, E.subtract(housing.capacity.get(new_req.id, 0), housing.requested.get(new_req.id, 0)))
admitted = E.minimum(admitted, E.floor_value(E.divide(free_units, net_units)))
if (not old_req.is_empty()): housing.requested[old_req.id] = E.maximum(0, E.subtract(housing.requested.get(old_req.id, 0), E.multiply(admitted, old_req.units)))
if (not new_req.is_empty()): housing.requested[new_req.id] = E.add(housing.requested.get(new_req.id, 0), E.multiply(admitted, new_req.units))
if E.ne(old_home, ""):
budget.requested[old_home] = E.maximum(0, E.subtract(budget.requested.get(old_home, 0), E.multiply(admitted, old_cost)))
budget.free[old_home] = E.maximum(0, E.subtract(budget.capacity.get(old_home, 0), budget.requested[old_home]))
budget.requested[home] = E.add(budget.requested.get(home, 0), E.multiply(admitted, cost))
budget.free[home] = E.maximum(0, E.subtract(budget.capacity.get(home, 0), budget.requested[home]))
return admitted
# 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: float) -> void:
static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year: Variant) -> void:
detach_space_civilizations(target, rules)
reconcile(world, target, rules)
var budget: Dictionary = stellar_budget(world, target, rules)
@@ -114,50 +114,50 @@ static func enforce_capacity(world, target: Dictionary, rules: Dictionary, year:
if is_space_civilization(id, rules):
stages.append(id)
stages.sort()
for index in range(stages.size() - 1, -1, -1):
for index in range(E.subtract(stages.size(), 1), E.neg(1), E.neg(1)):
var id: String = stages[index]
var home: String = rules[id].home
var cost: float = maxf(1.0, rules[id].stars)
var count: float = target.amount.get(id, 0.0)
var supported: float = minf(count, floorf(remaining.get(home, 0.0) / cost))
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():
supported = minf(supported, floorf(remaining_homes.get(requirement.id, 0.0) / requirement.units))
remaining_homes[requirement.id] = maxf(0.0, remaining_homes.get(requirement.id, 0.0) - supported * requirement.units)
remaining[home] = maxf(0.0, remaining.get(home, 0.0) - supported * cost)
var excess: float = count - supported
if excess <= 0.0:
if (not requirement.is_empty()):
supported = E.minimum(supported, E.floor_value(E.divide(remaining_homes.get(requirement.id, 0), requirement.units)))
remaining_homes[requirement.id] = E.maximum(0, E.subtract(remaining_homes.get(requirement.id, 0), E.multiply(supported, requirement.units)))
remaining[home] = E.maximum(0, E.subtract(remaining.get(home, 0), E.multiply(supported, cost)))
var excess: Variant = E.subtract(count, supported)
if E.le(excess, 0):
continue
var previous: String = stages[index - 1] if index > 0 else "C1.9"
world.batches.scale_batches(target, id, supported / count)
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
world.ledger.credit(target, previous, excess)
world.batches.append_batch(target, previous, excess, year, year)
var removed: Dictionary = QuantityMath.allocate_whole(target.ethics[id], excess)
if not target.ethics.has(previous):
if (not target.ethics.has(previous)):
target.ethics[previous] = {}
for ethic in ETHICS:
target.ethics[id][ethic] -= removed[ethic]
target.ethics[previous][ethic] = target.ethics[previous].get(ethic, 0.0) + removed[ethic]
if previous == "C1.9":
target.ethics[id][ethic] = E.subtract(target.ethics[id][ethic], removed[ethic])
target.ethics[previous][ethic] = E.add(target.ethics[previous].get(ethic, 0), removed[ethic])
if E.eq(previous, "C1.9"):
var stars: Dictionary = {}
var physical: Dictionary = world.ledger.physical_amounts(target)
for star in physical:
if world.catalog.entities[star].event_group == "DwStar" and physical[star] > 0.0:
if (E.eq(world.catalog.entities[star].event_group, "DwStar") and E.gt(physical[star], 0)):
stars[star] = physical[star]
var weights: Dictionary = QuantityMath.normalize_distribution(stars)
# Worlds are aggregates: a star may host several inhabited planets.
var assigned: float = 0.0
var assigned: Variant = 0
var owners: Array = weights.keys()
owners.sort()
for i in owners.size():
var owner: String = owners[i]
var amount: float = floorf(excess * weights[owner]) if i + 1 < owners.size() else excess - assigned
assigned += amount
if not target.binds.has(owner):
var amount: Variant = (E.floor_value(E.multiply(excess, weights[owner])) if E.lt(E.add(i, 1), owners.size()) else E.subtract(excess, assigned))
assigned = E.add(assigned, amount)
if (not target.binds.has(owner)):
target.binds[owner] = {}
target.binds[owner][previous] = target.binds[owner].get(previous, 0.0) + amount
target.amount[previous] -= amount
target.binds[owner][previous] = E.add(target.binds[owner].get(previous, 0), amount)
target.amount[previous] = E.subtract(target.amount[previous], amount)
reconcile(world, target, rules)
assign_homes(world, target, rules)
@@ -168,13 +168,10 @@ static func habitat_budget(world, target: Dictionary, rules: Dictionary) -> Dict
var structures: Array = []
var capacity: Dictionary = {}
for id in world.catalog.entities:
if world.catalog.entities[id].claim != "":
if E.ne(world.catalog.entities[id].claim, ""):
structures.append(id)
capacity[id] = physical.get(id, 0.0)
structures.sort_custom(func(a, b):
var left: float = world.catalog.entities[a].civ_bonus
var right: float = world.catalog.entities[b].civ_bonus
return left > right if left != right else str(a) < str(b))
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)
@@ -182,23 +179,23 @@ static func habitat_budget(world, target: Dictionary, rules: Dictionary) -> Dict
var requirements: Dictionary = {}
var anchors: Dictionary = {}
var anchor: String = ""
var units: float = 1.0
var units: Variant = 1
for id in stages:
var direct: String = ""
for structure in structures:
if world.catalog.entities[structure].claim == id: direct = structure
if direct != "":
if E.eq(world.catalog.entities[structure].claim, id): direct = structure
if E.ne(direct, ""):
anchor = direct
units = 1.0
units = 1
else:
units *= 10.0
units = E.multiply(units, 10)
anchors[id] = anchor
if id.begins_with("C3.") or id.begins_with("C4."):
if (id.begins_with("C3.") or id.begins_with("C4.")):
requirements[id] = {"id": anchor, "units": units}
var requested: Dictionary = {}
for id in requirements:
var req: Dictionary = requirements[id]
requested[req.id] = requested.get(req.id, 0.0) + physical.get(id, 0.0) * req.units
requested[req.id] = E.add(requested.get(req.id, 0), E.multiply(physical.get(id, 0), req.units))
return {"capacity": capacity, "requested": requested, "requirements": requirements, "structures": structures, "anchors": anchors}
static func assign_homes(world, target: Dictionary, rules: Dictionary) -> void:
@@ -209,29 +206,29 @@ static func assign_homes(world, target: Dictionary, rules: Dictionary) -> void:
stages.sort()
stages.reverse()
for id in stages:
var left: float = target.amount.get(id, 0.0)
var left: Variant = target.amount.get(id, 0)
var homes: Dictionary = {}
var allowed: bool = budget.anchors[id] == ""
var allowed: bool = E.eq(budget.anchors[id], "")
for structure in budget.structures:
if structure == budget.anchors[id]: allowed = true
if not allowed or budget.capacity[structure] <= 0.0: continue
var amount: float = left
if world.catalog.entities[structure].claim == id:
amount = minf(amount, maxf(0.0, budget.capacity[structure] - used.get(structure, 0.0)))
used[structure] = used.get(structure, 0.0) + amount
if amount > 0.0: homes[structure] = amount
left -= amount
if left <= 0.0: break
if E.eq(structure, budget.anchors[id]): allowed = true
if ((not allowed) or E.le(budget.capacity[structure], 0)): continue
var amount: Variant = left
if E.eq(world.catalog.entities[structure].claim, id):
amount = E.minimum(amount, E.maximum(0, E.subtract(budget.capacity[structure], used.get(structure, 0))))
used[structure] = E.add(used.get(structure, 0), amount)
if E.gt(amount, 0): homes[structure] = amount
left = E.subtract(left, amount)
if E.le(left, 0): break
target.civilization_homes[id] = homes
static func effective_span(world, target: Dictionary, id: String, rules: Dictionary) -> float:
if not rules.has(id): return world.catalog.entities[id].lifespan
var base: float = rules[id].span
if not is_space_civilization(id, rules): return base
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: float = target.amount.get(id, 0.0)
if population <= 0.0: return base
var weighted: float = population
var population: Variant = target.amount.get(id, 0)
if E.le(population, 0): return base
var weighted: Variant = population
for home in target.civilization_homes.get(id, {}):
weighted += target.civilization_homes[id][home] * (maxf(0.0, world.catalog.entities[home].civ_bonus) - 1.0)
return base * population / weighted if weighted > 0.0 else base
weighted = E.add(weighted, E.multiply(target.civilization_homes[id][home], (E.subtract(E.maximum(0, world.catalog.entities[home].civ_bonus), 1))))
return (E.divide(E.multiply(base, population), weighted) if E.gt(weighted, 0) else base)
+56 -55
View File
@@ -1,4 +1,5 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationEvolution = preload("res://world/owniverse/civilization_evolution.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
@@ -12,26 +13,26 @@ func initialize_life_batches() -> void:
for id in world.catalog.entities:
if world.catalog.entities[id].life_rule.is_empty():
continue
var tracked: float = 0.0
for batch: Dictionary in world.state.batches.get(id, []):
tracked += batch.amount
world.batches.append_batch(world.state, id, maxf(0.0, physical.get(id, 0.0) - tracked), world.state.time.datetime, world.state.time.datetime)
var tracked: Variant = 0
for batch : Dictionary in world.state.batches.get(id, []):
tracked = E.add(tracked, batch.amount)
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: float) -> void:
func evolute_to_datetime(datetime: Variant, clock: Dictionary = {}) -> void:
# Reconcile only when simulation advances.
# Fractional frame updates cannot change capacity or populations.
var processed_year: float = SimulationTime.processed_year(datetime)
if processed_year > world.state.time.year:
var processed_year: Variant = SimulationTime.processed_year(datetime)
if E.gt(processed_year, world.state.time.year):
initialize_life_batches()
var previous: Dictionary = world.state
var next: Dictionary = previous.duplicate_deep()
next.version = SimulationTime.STATE_VERSION
next.speed = clampi(int(previous.speed), 1, Global.SPEED_LIMIT)
next.speed_in_year = pow(10.0, next.speed - 1)
var from_year: float = previous.time.year
var to_year: float = processed_year
if to_year > from_year:
next.speed = clampi(E.integer(previous.speed), 1, Global.SPEED_LIMIT)
next.speed_in_year = E.power(10, E.subtract(next.speed, 1))
var from_year: Variant = previous.time.year
var to_year: Variant = processed_year
if E.gt(to_year, from_year):
var quantities: Dictionary = world.ledger.physical_amounts(previous)
var distribution: Dictionary = QuantityMath.normalize_distribution(quantities)
next.distribution = distribution
@@ -40,23 +41,23 @@ func evolute_to_datetime(datetime: float) -> void:
evolve_batches(next, to_year)
world.ledger.refresh_groups(next)
world.ledger.clamp_auto_resources(next)
WorldParameters.advance(previous, next, Global.CivilizationRules, to_year - from_year)
next.time = {"datetime": datetime, "year": to_year}
WorldParameters.advance(previous, next, Global.CivilizationRules, E.subtract(to_year, from_year))
next.time = clock if not clock.is_empty() else {"datetime": datetime, "year": to_year}
# Publish only after every phase has finished; previous stays untouched.
world.state = next
if to_year > from_year:
world.state = E.bound_state(next)
if E.gt(to_year, from_year):
world.presentation.refresh(world.state, world.catalog)
func evolve_batches(target: Dictionary, to_year: float) -> void:
func evolve_batches(target: Dictionary, to_year: Variant) -> void:
# A long cycle may contain several generations; preserve actual birth intervals.
var completed: Dictionary = {}
while evolve_batch_generation(target, to_year, completed):
pass
func evolve_batch_generation(target: Dictionary, to_year: float, completed: Dictionary = {}) -> bool:
func evolve_batch_generation(target: Dictionary, to_year: Variant, completed: Dictionary = { }) -> bool:
# Apply direct completions at the original next-generation boundary, so
# binding transfers, the blast environment and capacity checks keep order.
var completed_now: Dictionary = completed.duplicate(true)
@@ -65,81 +66,81 @@ func evolve_batch_generation(target: Dictionary, to_year: float, completed: Dict
var expired: Dictionary = {}
var preview: Dictionary = {"batches": {}}
for id in target.batches:
if world.catalog.entities.has(id) and world.catalog.entities[id].destroy_level > 0:
if (world.catalog.entities.has(id) and E.gt(world.catalog.entities[id].destroy_level, 0)):
preview.batches[id] = target.batches[id].duplicate(true)
for id in target.batches.keys():
if not world.catalog.entities.has(id) or world.catalog.entities[id].destroy_level <= 0:
if ((not world.catalog.entities.has(id)) or E.le(world.catalog.entities[id].destroy_level, 0)):
continue
var dying: float = 0.0
for part: Dictionary in completed_now.get(id, []):
dying += part.amount
for part: Dictionary in world.batches.withdraw_batches(preview, id, to_year):
dying += part.amount
if quantities.get(id, 0.0) > 0.0:
world.stellar.destroy_planets(target, id, dying / quantities[id], world.catalog.entities[id].destroy_level)
var dying: Variant = 0
for part : Dictionary in completed_now.get(id, []):
dying = E.add(dying, part.amount)
for part : Dictionary in world.batches.withdraw_batches(preview, id, to_year):
dying = E.add(dying, part.amount)
if E.gt(quantities.get(id, 0), 0):
world.stellar.destroy_planets(target, id, E.divide(dying, quantities[id]), world.catalog.entities[id].destroy_level)
quantities = world.ledger.physical_amounts(target)
# Withdraw all types before creating successors: iteration order cannot affect them.
for id in target.batches.keys():
if world.catalog.entities.has(id):
expired[id] = completed_now.get(id, []) + world.batches.withdraw_batches(target, id, to_year)
expired[id] = E.add(completed_now.get(id, []), world.batches.withdraw_batches(target, id, to_year))
var has_expired: bool = false
for parts: Array in expired.values():
if not parts.is_empty():
for parts : Array in expired.values():
if (not parts.is_empty()):
has_expired = true
break
if not has_expired:
if (not has_expired):
return false
var fractions: Dictionary = {}
var outputs: Dictionary = {}
var star_budget: Dictionary = CivilizationState.stellar_budget(world, target, Global.CivilizationRules)
var civilization_transaction: Dictionary = CivilizationEvolution.prepare(world, target, expired, to_year, star_budget, Global.CivilizationRules)
for id in expired:
var total: float = 0.0
for part: Dictionary in expired[id]:
total += part.amount
fractions[id] = clampf(total / quantities[id], 0.0, 1.0) if quantities.get(id, 0.0) > 0.0 else 0.0
var total: Variant = 0
for part : Dictionary in expired[id]:
total = E.add(total, part.amount)
fractions[id] = (E.clamp_value(E.divide(total, quantities[id]), 0, 1) if E.gt(quantities.get(id, 0), 0) else 0)
var combined: Dictionary = {}
for part: Dictionary in expired[id]:
if not part.has("products"):
for part : Dictionary in expired[id]:
if (not part.has("products")):
part.products = world.stellar.evolution_products(target, world.catalog.entities[id], part.amount)
for successor in part.products.keys():
if Global.CivilizationRules.has(id):
continue
var requested: float = part.products[successor]
var admitted: float = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
var requested: Variant = part.products[successor]
var admitted: Variant = CivilizationState.admit_transition(star_budget, Global.CivilizationRules, id, successor, requested)
part.products[successor] = admitted
if admitted < requested:
part.products[id] = part.products.get(id, 0.0) + requested - admitted
if E.lt(admitted, requested):
part.products[id] = E.subtract(E.add(part.products.get(id, 0), requested), admitted)
for successor in part.products:
combined[successor] = combined.get(successor, 0.0) + part.products[successor]
combined[successor] = E.add(combined.get(successor, 0), part.products[successor])
outputs[id] = {}
if total > 0.0:
if E.gt(total, 0):
for successor in combined:
outputs[id][successor] = combined[successor] / total
outputs[id][successor] = E.divide(combined[successor], total)
var incoming: Dictionary = world.ledger.transfer_evolution(target, fractions, outputs)
var new_owners: Dictionary = {}
var blast_power: float = 0.0
var blast_power: Variant = 0
var environment: Dictionary = world.environment.stellar_environment(target)
for id in expired:
for part: Dictionary in expired[id]:
for part : Dictionary in expired[id]:
for successor in part.products:
var amount: float = part.products[successor]
if successor == "blast":
blast_power += amount
var amount: Variant = part.products[successor]
if E.eq(successor, "blast"):
blast_power = E.add(blast_power, amount)
continue
if not world.catalog.entities.has(successor):
if (not world.catalog.entities.has(successor)):
continue
if successor == id and (not world.catalog.entities[id].life_rule.is_empty() or Global.CivilizationRules.has(id)):
target.amount[id] = target.amount.get(id, 0.0) + amount
if (E.eq(successor, id) and (((not world.catalog.entities[id].life_rule.is_empty()) or Global.CivilizationRules.has(id)))):
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 cutoff: float = to_year if world.stellar.uses_short_lived_fast_path(successor) else -INF
var integral: float = world.batches.append_batch(target, successor, amount, part.from, part.to, completed, cutoff)
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)
new_owners[successor] = new_owners.get(successor, 0.0) + amount
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)
+43 -54
View File
@@ -1,101 +1,90 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
const GREEN = "#40cc40"
const RED = "#ff6060"
const GREY = "#888888"
const Parameters = preload("res://world/owniverse/world_parameters.gd")
static func number(value: float, digits: int = 0) -> String:
if not is_finite(value):
return "—"
if absf(value) >= 1e5:
var exponent: int = int(floor(log(absf(value)) / log(10.0)))
return "%.4fE%+d" % [value / pow(10.0, exponent), exponent]
return "%.*f" % [digits, value]
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)
static func colored(text: String, color: String) -> String:
return "[color=" + color + "]" + text + "[/color]"
return E.add(E.add(E.add(E.add("[color=", color), "]"), text), "[/color]")
static func matches(world, id: String) -> Array:
var result: Array = []
for key in world.catalog.entities:
var entity = world.catalog.entities[key]
if key == id or entity.count_as == id or entity.group_as == id:
if not world.catalog.is_group(key):
if ((E.eq(key, id) or E.eq(entity.count_as, id)) or E.eq(entity.group_as, id)):
if (not world.catalog.is_group(key)):
result.append(key)
return result
static func quantities(world, id: String) -> Dictionary:
var free: float = 0.0
var total: float = 0.0
var free: Variant = 0
var total: Variant = 0
var physical: Dictionary = world.ledger.physical_amounts(world.state)
for key in matches(world, id):
free += world.state.amount.get(key, 0.0)
total += physical.get(key, 0.0)
if id == "SMBH":
free = world.state.amount.get(id, 0.0)
free = E.add(free, world.state.amount.get(key, 0))
total = E.add(total, physical.get(key, 0))
if E.eq(id, "SMBH"):
free = world.state.amount.get(id, 0)
total = free
return {"free": free, "total": total}
static func costs(world, global: Node, id: String) -> String:
if not world.catalog.entities.has(id):
if (not world.catalog.entities.has(id)):
return ""
var lines: PackedStringArray = []
for requirements in [world.catalog.entities[id].cost, world.catalog.entities[id].bind]:
for resource in requirements:
var required: float = requirements[resource]
if required < 0.0:
var required: Variant = requirements[resource]
if E.lt(required, 0):
continue
var available: float = floorf(world.ledger.available(world.state, resource))
var available: Variant = E.floor_value(world.ledger.available(world.state, resource))
var name: String = str(global.get_template(resource, "Entity", "Name").get("text", resource))
lines.append(colored(name + ": " + number(required) + " (" + number(available) + ")", GREEN if available >= required else RED))
lines.append(colored(E.add(E.add(E.add(E.add(E.add(name, ": "), number(required)), " ("), number(available)), ")"), (GREEN if E.ge(available, required) else RED)))
return "\n".join(lines)
static func variables(world, global: Node, id: String, rate: float = 0.0) -> Dictionary:
static func variables(world, global: Node, id: String, rate: Variant = 0.0) -> Dictionary:
var counts: Dictionary = quantities(world, id)
var show_total: bool = false
for key in matches(world, id):
show_total = show_total or world.catalog.entities[key].group_as == "Planet"
var amount: String = number(counts.total if show_total else counts.free) if world.catalog.entities.has(id) else "—"
if not show_total and counts.total > counts.free:
amount += " " + colored("(" + number(counts.total) + ")", GREY)
if rate != 0.0:
amount += " | " + colored(("−" if rate < 0.0 else "+") + number(absf(rate), 1), RED if rate < 0.0 else GREEN)
show_total = (show_total or E.eq(world.catalog.entities[key].group_as, "Planet"))
var amount: String = (number((counts.total if show_total else counts.free)) if world.catalog.entities.has(id) else "—")
if ((not show_total) and E.gt(counts.total, counts.free)):
amount = E.add(amount, E.add(" ", colored(E.add(E.add("(", number(counts.total)), ")"), GREY)))
if E.ne(rate, 0):
amount = E.add(amount, E.add(" | ", colored(E.add((("−" if E.lt(rate, 0) else "+")), number(E.absolute(rate), 1)), (RED if E.lt(rate, 0) else GREEN))))
var physical: Dictionary = world.ledger.physical_amounts(world.state)
var voids: float = 0.0
var voids: Variant = 0
for key in physical:
voids += physical[key] * world.catalog.entities[key].voids
var he: float = world.state.amount.get("He", 0.0)
var h: float = world.state.amount.get("H", 0.0)
voids = E.add(voids, E.multiply(physical[key], world.catalog.entities[key].voids))
var he: Variant = world.state.amount.get("He", 0)
var h: Variant = world.state.amount.get("H", 0)
var mixture: String = "—"
if he + h > 0.0:
mixture = "He: " + number(100.0 * he / (he + h), 2) + "% / H: " + number(100.0 * h / (he + h), 2) + "%"
if E.gt(E.add(he, h), 0):
mixture = E.add(E.add(E.add(E.add("He: ", number(E.divide(E.multiply(100, he), (E.add(he, h))), 2)), "% / H: "), number(E.divide(E.multiply(100, h), (E.add(he, h))), 2)), "%")
var environment: Dictionary = world.environment.stellar_environment(world.state)
return {
"amount": amount, "colored costs": costs(world, global, id),
"lifespan": number(world.catalog.entities[id].lifespan) if world.catalog.entities.has(id) else "—",
"voids": number(voids), "he / h": "\n" + mixture,
"metallicity": number(environment.metallicity * 100.0, 2) + "%",
"энергия": number(world.state.energy), "плотность": number(environment.density, 6),
"лет в секунду": number(world.state.speed_in_year),
"amount1": "—", "amount2": "—", "amount3": "—", "amount4": "—",
}
return {"amount": amount, "colored costs": costs(world, global, id), "lifespan": (number(world.catalog.entities[id].lifespan) if world.catalog.entities.has(id) else "—"), "voids": number(voids), "he / h": E.add("\n", mixture), "metallicity": E.add(number(E.multiply(environment.metallicity, 100), 2), "%"), "энергия": number(world.state.energy), "плотность": number(environment.density, 6), "лет в секунду": number(world.state.speed_in_year), "amount1": "—", "amount2": "—", "amount3": "—", "amount4": "—"}
static func render(world, global: Node, id: String, category: String = "Entity", rate: float = 0.0) -> String:
static func render(world, global: Node, id: String, category: String = "Entity", rate: Variant = 0.0) -> String:
var values: Dictionary = variables(world, global, id, rate)
if category == "Indicator":
if id == "Density":
if E.eq(category, "Indicator"):
if E.eq(id, "Density"):
id = Parameters.density_display(world.environment.stellar_environment(world.state).density).mode
var scales: Dictionary = {"Burnability": "Bur", "Explosiveness": "Exp", "Aggression": "Agg"}
if scales.has(id):
var level: float = Parameters.value(world.state, scales[id])
values.amount = number(level, 1 if level != floorf(level) else 0)
values.scale = global.render_template(global.get_template("Scale" + str(clampi(roundi(level), 1, 19)), "Indicator", "Scale"), {})
if id == "Trends":
var level: Variant = Parameters.value(world.state, scales[id])
values.amount = number(level, (1 if E.ne(level, E.floor_value(level)) else 0))
values.scale = global.render_template(global.get_template(E.add("Scale", str(clampi(E.integer(E.round_value(level)), 1, 19))), "Indicator", "Scale"), {})
if E.eq(id, "Trends"):
for trend in Parameters.TRENDS:
values[trend.to_lower()] = number(world.state.get("trends", {}).get(trend, 0.0) * 100.0, 2) + "%"
if id == "Energy":
values["энергия"] = number(world.state.energy) + " (" + number(clampf(world.state.energy / Parameters.INITIAL_ENERGY, 0.0, 1.0) * 100.0, 2) + "%)"
values[trend.to_lower()] = E.add(number(E.multiply(world.state.get("trends", {}).get(trend, 0), 100), 2), "%")
if E.eq(id, "Energy"):
values["энергия"] = E.add(E.add(E.add(number(world.state.energy), " ("), number(E.multiply(E.clamp_value(E.divide(world.state.energy, Parameters.INITIAL_ENERGY), 0, 1), 100), 2)), "%)")
var title: String = global.render_template(global.get_template(id, category, "Name"), values)
var body: String = global.render_template(global.get_template(id, category, "Description"), values)
return "[b]" + (title if title != "" else id) + "[/b]\n" + body
return E.add(E.add(E.add("[b]", ((title if E.ne(title, "") else id))), "[/b]\n"), body)
+22 -21
View File
@@ -1,54 +1,55 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
## Read-only projections of simulation state. No controls or simulation mutations.
var pending_amounts: Dictionary[String, float] = {}
var pending_amounts: Dictionary [ String , Variant ] = {}
var deferred_updates: bool = false
var dirty: bool = true
func refresh(state: Dictionary, catalog, force: bool = false) -> void:
dirty = true
if deferred_updates and not force:
if (deferred_updates and (not force)):
return
pending_amounts = amounts(state, catalog)
dirty = false
## Aliases are presentation-only; group counters are not counted twice.
static func amounts(state: Dictionary, catalog, total_produced: bool = false) -> Dictionary[String, float]:
var source: Dictionary = state.stats.total_amount if total_produced else state.amount
if not total_produced:
static func amounts(state: Dictionary, catalog, total_produced: bool = false) -> Dictionary [ String, Variant ]:
var source: Dictionary = (state.stats.total_amount if total_produced else state.amount)
if (not total_produced):
source = source.duplicate()
var physical: Dictionary = {}
for id in catalog.entities:
if not catalog.is_group(id):
physical[id] = maxf(0.0, state.amount.get(id, 0.0))
for bindings: Dictionary in state.binds.values():
if (not catalog.is_group(id)):
physical[id] = E.maximum(0, state.amount.get(id, 0))
for bindings : Dictionary in state.binds.values():
for id in bindings:
if physical.has(id):
physical[id] += maxf(0.0, bindings[id])
var planets: float = 0.0
physical[id] = E.add(physical[id], E.maximum(0, bindings[id]))
var planets: Variant = 0
for id in physical:
if catalog.entities[id].group_as == "Planet":
if E.eq(catalog.entities[id].group_as, "Planet"):
source[id] = physical[id]
planets += physical[id]
planets = E.add(planets, physical[id])
source["Planet"] = planets
var result: Dictionary[String, float] = {}
var result: Dictionary [ String , Variant ] = {}
for id in source:
result[id] = 0.0
result[id] = 0
for id in source:
# group_as counters are already sums and must not be counted a second time.
if catalog.is_group(id):
continue
var item_id: String = id
if catalog.entities.has(id) and catalog.entities[id].count_as != "":
if (catalog.entities.has(id) and E.ne(catalog.entities[id].count_as, "")):
item_id = catalog.entities[id].count_as
# Detailed icons need their own count as well as the summary alias.
# Aggregate only from source, never from these presentation entries.
if item_id != id:
result[id] = result.get(id, 0.0) + float(source[id])
result[item_id] = result.get(item_id, 0.0) + float(source[id])
if E.ne(item_id, id):
result[id] = E.add(result.get(id, 0), E.of(source[id]))
result[item_id] = E.add(result.get(item_id, 0), E.of(source[id]))
for id in source:
if catalog.is_group(id):
result[id] = float(source[id])
result[id] = E.of(source[id])
return result
@@ -63,12 +64,12 @@ static func unlocks(state: Dictionary, catalog, locked: Array) -> Dictionary:
var entity = catalog.entities[id]
var eligible: bool = true
for requirement in entity.unlock:
if maxf(state.stats.total_amount.get(requirement, 0.0), display_totals.get(requirement, 0.0)) < entity.unlock[requirement]:
if E.lt(E.maximum(state.stats.total_amount.get(requirement, 0), display_totals.get(requirement, 0)), entity.unlock[requirement]):
eligible = false
break
if eligible:
for item_id in [id, entity.count_as]:
if item_id != "" and not visible.has(item_id):
if (E.ne(item_id, "") and (not visible.has(item_id))):
visible.append(item_id)
else:
remaining.append(id)
+29 -31
View File
@@ -1,58 +1,56 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
func life_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary:
func life_products(target: Dictionary, entity: OwniverseEntity, count: Variant) -> Dictionary:
var rule: Dictionary = entity.life_rule
var pending: Dictionary = {}
var modifiers: Dictionary = WorldParameters.life_modifiers(target)
var probabilities: Dictionary = {
"progress": float(rule.progress) * modifiers.progress * WorldParameters.value(target, "Enlightenment"),
"degradation": float(rule.degradation) * modifiers.degradation,
}
var probability_sum: float = maxf(1.0, probabilities.progress + probabilities.degradation)
if not target.has("life_remainders"):
var probabilities: Dictionary = {"progress": E.multiply(E.multiply(E.of(rule.progress), modifiers.progress), WorldParameters.value(target, "Enlightenment")), "degradation": E.multiply(E.of(rule.degradation), modifiers.degradation)}
var probability_sum: Variant = E.maximum(1, E.add(probabilities.progress, probabilities.degradation))
if (not target.has("life_remainders")):
target.life_remainders = {}
for channel in ["progress", "degradation"]:
var key: String = entity.id + ":" + channel
var expected: float = count * probabilities[channel] / probability_sum + target.life_remainders.get(key, 0.0)
var key: String = E.add(E.add(entity.id, ":"), channel)
var expected: Variant = E.add(E.divide(E.multiply(count, probabilities[channel]), probability_sum), target.life_remainders.get(key, 0))
target.life_remainders[key] = expected
pending[channel] = floorf(snappedf(expected, 1.0e-12))
pending[channel] = E.floor_value(expected)
var assigned: Dictionary = QuantityMath.allocate_whole(pending, count)
for channel in assigned:
var key: String = entity.id + ":" + channel
target.life_remainders[key] = maxf(0.0, target.life_remainders[key] - assigned[channel])
var result: Dictionary = {entity.id: count - assigned.progress - assigned.degradation}
var key: String = E.add(E.add(entity.id, ":"), channel)
target.life_remainders[key] = E.maximum(0, E.subtract(target.life_remainders[key], assigned[channel]))
var result: Dictionary = {entity.id: E.subtract(E.subtract(count, assigned.progress), assigned.degradation)}
result[rule.next] = assigned.progress
var weights: Dictionary = {}
var weight_sum: float = 0.0
var weight_sum: Variant = 0
for term in str(rule.fall).split("^"):
var pair: PackedStringArray = term.split(":")
weights[pair[0]] = float(pair[1])
weight_sum += float(pair[1])
weights[pair[0]] = E.of(pair[1])
weight_sum = E.add(weight_sum, E.of(pair[1]))
var requests: Dictionary = {}
for id in weights:
var key: String = entity.id + ":fall:" + id
var expected: float = assigned.degradation * weights[id] / weight_sum + target.life_remainders.get(key, 0.0)
var key: String = E.add(E.add(entity.id, ":fall:"), id)
var expected: Variant = E.add(E.divide(E.multiply(assigned.degradation, weights[id]), weight_sum), target.life_remainders.get(key, 0))
target.life_remainders[key] = expected
requests[id] = maxf(0.0, floorf(snappedf(expected, 1.0e-12)))
requests[id] = E.maximum(0, E.floor_value(expected))
var falls: Dictionary = QuantityMath.allocate_whole(requests, assigned.degradation)
var distributed: float = 0.0
var distributed: Variant = 0
for id in falls:
distributed += falls[id]
result[id] = result.get(id, 0.0) + falls[id]
var key: String = entity.id + ":fall:" + id
target.life_remainders[key] -= falls[id]
distributed = E.add(distributed, falls[id])
result[id] = E.add(result.get(id, 0), falls[id])
var key: String = E.add(E.add(entity.id, ":fall:"), id)
target.life_remainders[key] = E.subtract(target.life_remainders[key], falls[id])
# Fill sub-unit fall quotas deterministically, retaining signed allocation errors.
while distributed < assigned.degradation:
while E.lt(distributed, assigned.degradation):
var selected: String = ""
var largest: float = -INF
var largest: Variant = E.neg(INF)
for id in weights:
var rest: float = target.life_remainders[entity.id + ":fall:" + id]
if rest > largest:
var rest: Variant = target.life_remainders[E.add(E.add(entity.id, ":fall:"), id)]
if E.gt(rest, largest):
largest = rest
selected = id
result[selected] = result.get(selected, 0.0) + 1.0
target.life_remainders[entity.id + ":fall:" + selected] -= 1.0
distributed += 1.0
result[selected] = E.add(result.get(selected, 0), 1)
target.life_remainders[E.add(E.add(entity.id, ":fall:"), selected)] = E.subtract(target.life_remainders[E.add(E.add(entity.id, ":fall:"), selected)], 1)
distributed = E.add(distributed, 1)
return result
+83 -84
View File
@@ -1,10 +1,10 @@
extends "res://world/owniverse/simulation_module.gd"
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")
func try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
automatic: bool = false) -> bool:
if amount <= 0.0 or amount != floorf(amount):
func try_production(target: Dictionary, entity: OwniverseEntity, amount: Variant, automatic: bool = false) -> bool:
if (E.le(amount, 0) or E.ne(amount, E.floor_value(amount))):
return false
var trial: Dictionary = target.duplicate()
trial.amount = target.amount.duplicate()
@@ -13,26 +13,26 @@ func try_production(target: Dictionary, entity: OwniverseEntity, amount: float,
trial.batches = target.batches.duplicate()
var copied_batches: Dictionary = {}
for resource in entity.cost:
var cost: float = roundf(maxf(0.0, entity.cost[resource]) * amount)
if automatic and trial.auto_resources.get(resource, 0.0) < cost:
var cost: Variant = E.round_value(E.multiply(E.maximum(0, entity.cost[resource]), amount))
if (automatic and E.lt(trial.auto_resources.get(resource, 0), cost)):
return false
var before: Dictionary = world.ledger.physical_amounts(trial)
var taken: Dictionary = world.ledger.take_available(trial, resource, cost)
var total: float = 0.0
var total: Variant = 0
for quantity in taken.values():
total += quantity
if total != cost:
total = E.add(total, quantity)
if E.ne(total, cost):
return false
if automatic:
trial.auto_resources[resource] = maxf(0.0, trial.auto_resources.get(resource, 0.0) - cost)
trial.auto_resources[resource] = E.maximum(0, E.subtract(trial.auto_resources.get(resource, 0), cost))
for id in taken:
# Isolate only batches actually touched by this transaction.
if trial.batches.has(id) and not copied_batches.has(id):
if (trial.batches.has(id) and (not copied_batches.has(id))):
trial.batches[id] = trial.batches[id].duplicate(true)
copied_batches[id] = true
var factor: float = 1.0 - taken[id] / before[id] if before.get(id, 0.0) > 0.0 else 0.0
var factor: Variant = (E.subtract(1, E.divide(taken[id], before[id])) if E.gt(before.get(id, 0), 0) else 0)
world.batches.scale_batches(trial, id, factor)
if not world.ledger.bind_new_owners(trial, {entity.id: amount}, true):
if (not world.ledger.bind_new_owners(trial, {entity.id: amount}, true)):
return false
world.ledger.refresh_groups(trial)
world.ledger.clamp_auto_resources(trial)
@@ -46,72 +46,71 @@ func smbh_sources() -> Dictionary:
var masses: Dictionary = {}
for id in world.catalog.entities:
var entity: OwniverseEntity = world.catalog.entities[id]
if entity.count_as == "BH" and entity.mass > 0.0:
masses[id] = floorf(maxf(0.0, world.state.amount.get(id, 0.0))) * entity.mass
if (E.eq(entity.count_as, "BH") and E.gt(entity.mass, 0)):
masses[id] = E.multiply(E.floor_value(E.maximum(0, world.state.amount.get(id, 0))), entity.mass)
return masses
func smbh_available() -> float:
var mass: float = 0.0
func smbh_available() -> Variant:
var mass: Variant = 0
for value in smbh_sources().values():
mass += value
return floorf(mass)
mass = E.add(mass, value)
return E.floor_value(mass)
func plan_smbh(desired_amount: float) -> Dictionary:
var empty: Dictionary = {"amount": 0.0, "taken": {}}
if desired_amount <= 0.0 or not is_finite(desired_amount):
func plan_smbh(desired_amount: Variant) -> Dictionary:
var empty: Dictionary = {"amount": 0, "taken": {}}
if (E.le(desired_amount, 0) or (not E.finite(desired_amount))):
return empty
var total_mass: float = smbh_available()
if total_mass <= 0.0:
var total_mass: Variant = smbh_available()
if E.le(total_mass, 0):
return empty
var requested: float = minf(floorf(desired_amount), total_mass)
if requested <= 0.0:
var requested: Variant = E.minimum(E.floor_value(desired_amount), total_mass)
if E.le(requested, 0):
return empty
var masses: Dictionary = smbh_sources()
var distribution: Dictionary = QuantityMath.normalize_distribution(masses)
var taken: Dictionary = {}
var order: Array = []
var consumed_mass: float = 0.0
var consumed_mass: Variant = 0
for id in distribution:
var expected: float = requested * distribution[id] / world.catalog.entities[id].mass
taken[id] = minf(floorf(expected), floorf(world.state.amount[id]))
consumed_mass += taken[id] * world.catalog.entities[id].mass
order.append({"id": id, "remainder": expected - taken[id]})
order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.remainder > b.remainder if a.remainder != b.remainder else str(a.id) < str(b.id))
var expected: Variant = E.divide(E.multiply(requested, distribution[id]), world.catalog.entities[id].mass)
taken[id] = E.minimum(E.floor_value(expected), E.floor_value(world.state.amount[id]))
consumed_mass = E.add(consumed_mass, E.multiply(taken[id], world.catalog.entities[id].mass))
order.append({"id": id, "remainder": E.subtract(expected, taken[id])})
order.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return (E.gt(a.remainder, b.remainder) if E.ne(a.remainder, b.remainder) else E.lt(str(a.id), str(b.id))))
# Consume whole BH only; all excess mass becomes SMBH, never disappears.
for candidate: Dictionary in order:
if consumed_mass >= requested:
for candidate : Dictionary in order:
if E.ge(consumed_mass, requested):
break
var id: String = candidate.id
if taken[id] < floorf(world.state.amount[id]):
taken[id] += 1.0
consumed_mass += world.catalog.entities[id].mass
if consumed_mass <= 0.0 or consumed_mass != floorf(consumed_mass):
if E.lt(taken[id], E.floor_value(world.state.amount[id])):
taken[id] = E.add(taken[id], 1)
consumed_mass = E.add(consumed_mass, world.catalog.entities[id].mass)
if (E.le(consumed_mass, 0) or E.ne(consumed_mass, E.floor_value(consumed_mass))):
return empty
return {"amount": consumed_mass, "taken": taken}
func produce_smbh(desired_amount: float) -> float:
func produce_smbh(desired_amount: Variant) -> Variant:
var plan: Dictionary = plan_smbh(desired_amount)
var consumed_mass: float = plan.amount
var consumed_mass: Variant = plan.amount
var taken: Dictionary = plan.taken
if consumed_mass <= 0.0:
return 0.0
if E.le(consumed_mass, 0):
return 0
var next: Dictionary = world.state.duplicate(true)
var physical: Dictionary = world.ledger.physical_amounts(world.state)
for id in taken:
if taken[id] <= 0.0:
if E.le(taken[id], 0):
continue
next.amount[id] -= taken[id]
var fraction: float = taken[id] / physical[id]
world.batches.scale_batches(next, id, 1.0 - fraction)
next.amount[id] = E.subtract(next.amount[id], taken[id])
var fraction: Variant = E.divide(taken[id], physical[id])
world.batches.scale_batches(next, id, E.subtract(1, fraction))
# Release the consumed owners' share of any resources they held.
for resource in next.binds.get(id, {}):
var released: float = next.binds[id][resource] * fraction
next.binds[id][resource] -= released
next.amount[resource] = next.amount.get(resource, 0.0) + released
var released: Variant = E.multiply(next.binds[id][resource], fraction)
next.binds[id][resource] = E.subtract(next.binds[id][resource], released)
next.amount[resource] = E.add(next.amount.get(resource, 0), released)
world.ledger.credit(next, "SMBH", consumed_mass)
world.batches.append_batch(next, "SMBH", consumed_mass, world.state.time.datetime, world.state.time.datetime)
world.ledger.round_object_allocations(next)
@@ -122,28 +121,28 @@ func produce_smbh(desired_amount: float) -> float:
return consumed_mass
func produce(item_id: String, desired_amount: float) -> float:
if item_id == "SMBH":
func produce(item_id: String, desired_amount: Variant) -> Variant:
if E.eq(item_id, "SMBH"):
return produce_smbh(desired_amount)
# Use special production for S and H
if item_id == "S" or item_id == "H":
if (E.eq(item_id, "S") or E.eq(item_id, "H")):
return produce_hs(item_id, desired_amount)
var entity: OwniverseEntity = world.catalog.entities[item_id]
if desired_amount <= 0:
return 0.0
if E.le(desired_amount, 0):
return 0
var max_producible = floorf(calculate_max_producible(entity, desired_amount))
var max_producible = E.floor_value(calculate_max_producible(entity, desired_amount))
var budget: Dictionary = CivilizationState.stellar_budget(world, world.state, Global.CivilizationRules)
max_producible = CivilizationState.admit_transition(budget, Global.CivilizationRules, "", item_id, max_producible)
if max_producible <= 0:
return 0.0
if E.le(max_producible, 0):
return 0
var metallicity: float = world.environment.stellar_environment(world.state).metallicity
if not try_production(world.state, entity, max_producible):
return 0.0
var metallicity: Variant = world.environment.stellar_environment(world.state).metallicity
if (not try_production(world.state, entity, max_producible)):
return 0
# Add produced entities
world.ledger.process_entity(item_id, max_producible)
@@ -154,49 +153,49 @@ func produce(item_id: String, desired_amount: float) -> float:
return max_producible
func produce_hs(entity_id: String, value: float) -> float:
if value <= 0:
return 0.0
func produce_hs(entity_id: String, value: Variant) -> Variant:
if E.le(value, 0):
return 0
var to_produce: float = floorf(value)
to_produce = minf(to_produce, floorf(world.state.energy))
var to_produce: Variant = E.floor_value(value)
to_produce = E.minimum(to_produce, E.floor_value(world.state.energy))
if to_produce <= 0:
return 0.0
if E.le(to_produce, 0):
return 0
world.state.energy -= to_produce
world.state.energy = E.subtract(world.state.energy, to_produce)
world.ledger.process_entity(entity_id, to_produce)
return to_produce
func production_step(entity: OwniverseEntity) -> float:
return QuantityMath.production_step(entity.cost.values() + entity.bind.values())
func production_step(entity: OwniverseEntity) -> Variant:
return QuantityMath.production_step(E.add(entity.cost.values(), entity.bind.values()))
func calculate_max_producible(entity: OwniverseEntity, desired_amount: float = 1.0E150) -> float:
if entity.id == "SMBH":
func calculate_max_producible(entity: OwniverseEntity, desired_amount: Variant = 1.0E150) -> Variant:
if E.eq(entity.id, "SMBH"):
return plan_smbh(desired_amount).amount
var requirements: Dictionary = {}
for resource in entity.cost:
requirements[resource] = maxf(0.0, entity.cost[resource])
requirements[resource] = E.maximum(0, entity.cost[resource])
for resource in entity.bind:
requirements[resource] = requirements.get(resource, 0.0) + maxf(0.0, entity.bind[resource])
requirements[resource] = E.add(requirements.get(resource, 0), E.maximum(0, entity.bind[resource]))
var has_requirements: bool = false
for quantity in requirements.values():
if quantity > 0.0:
if E.gt(quantity, 0):
has_requirements = true
break
# No recipe means unavailable for production, not unlimited free objects.
if not has_requirements:
return 0.0
var available: float = maxf(0.0, desired_amount)
if (not has_requirements):
return 0
var available: Variant = E.maximum(0, desired_amount)
for resource in requirements:
if requirements[resource] > 0.0:
available = minf(available, floorf(world.ledger.available(world.state, resource) / requirements[resource]))
if E.gt(requirements[resource], 0):
available = E.minimum(available, E.floor_value(E.divide(world.ledger.available(world.state, resource), requirements[resource])))
var concrete: Dictionary = world.ledger.expand_requirements(world.state, requirements)
for resource in concrete:
if concrete[resource] > 0.0:
available = minf(available, floorf(world.ledger.available(world.state, resource) / concrete[resource]))
var step: float = production_step(entity)
return floorf(available / step) * step if is_finite(step) else 0.0
if E.gt(concrete[resource], 0):
available = E.minimum(available, E.floor_value(E.divide(world.ledger.available(world.state, resource), concrete[resource])))
var step: Variant = production_step(entity)
return (E.multiply(E.floor_value(E.divide(available, step)), step) if E.finite(step) else 0)
+82
View File
@@ -0,0 +1,82 @@
# Числа симуляции
Количество объектов, запасы, энергия, расходы, привязки и статистика считаются
целочисленно, без перехода через `float`. Значения до `10^27` сохраняют каждую
единицу; промежуточные произведения могут превышать этот диапазон.
`big_integer.gd` хранит модуль числа массивом разрядов по основанию `10^9`,
младший разряд первым. Переносы при умножении не переполняют `int64`.
Деление использует нормализованную оценку цифры частного.
`exact.gd` — единый API арифметики. Малые значения остаются обычными `int`,
большие числа и дроби представлены `exact_value.gd`: знак, целый числитель,
целый знаменатель. Дроби сокращаются. Операции создают новые значения;
поля `ExactValue` и массивы его разрядов нельзя изменять после создания.
```gdscript
const E = preload("res://world/owniverse/math/exact.gd")
var stock = E.of("1e27") # Строка, не литерал float 1e27!
stock = E.subtract(stock, 1) # 999999999999999999999999999
var price = E.of("0.125") # Точно 1/8
var cost = E.multiply(price, 8) # Точно 1
if E.ge(stock, cost):
stock = E.subtract(stock, cost)
```
Использовать `E.add/subtract/multiply/divide`, `E.eq/lt/le/gt/ge`,
`E.floor_value/ceil_value/round_value`. Перегрузки операторов в GDScript нет:
обычные `+`, `==`, `min`, `float(...)` для чисел симуляции не подходят.
Сигнатуры принимают `Variant`, а не `float`. `E.integer` допускается только
для значений в диапазоне `int64`, например индексов и состояния генератора.
Для поиска чисел в массиве используется `E.contains`, поскольку сравнение
объектов по ссылке не является сравнением числовых значений.
## Границы точности
- Десятичные параметры CSV разбираются из строк точно. Стоимости, проверка
доступности и размер целой производственной партии не округляются.
- Суммы распределений и переносы целых объектов сохраняются точно.
- Промежуточная рациональная арифметика точна. Перед публикацией следующего
состояния знаменатели сохранённых дробей ограничиваются `10^36`.
Если знаменатель больше, значение усекается к нулю с ошибкой меньше
`10^-36`. Целые числа эта операция не меняет. Это предотвращает рост
знаменателей при многократном накоплении меняющихся долей. Такая точность
относится к каждой операции ограничения, а не является обещанием нулевой
суммарной ошибки при бесконечном числе циклов.
- Логарифмы, корни, нецелые степени и нормальная случайная выборка остаются
приближёнными. Результаты трансцендентных функций переводятся обратно в
рациональные коэффициенты с 12 знаками после точки. Статистическая выборка
не обеспечивает равномерный выбор каждого отдельного целого около `10^27`;
последующий учёт выбранного количества и остатка точен.
- Возраст хранится в целых тиках (`10^9` тиков на год). Малый остаток кадрового
времени остаётся `float` и переносится между кадрами; большой возраст
никогда не складывается с малым приращением через `float`.
- Интерфейс преобразует в `float` только координаты индикаторов. Текст чисел,
доступное количество и значения кнопок формируются из точных чисел.
`E.of(float)` сохраняет фактическое двоичное значение входного числа: он
не может восстановить единицы, уже потерянные предыдущими вычислениями.
## Сохранение
Формат сохранения — **3**, версия состояния — **0.2.0**. На главном потоке
`E.encode` строит независимый снимок, затем существующий фоновый писатель
сохраняет его. Большие целые и дроби кодируются как `{"$exact": "число"}`;
для дробей используется строка `числитель/знаменатель`. Целые за пределами
точного диапазона JSON/float тоже кодируются строкой. Зашифрованный файл и
диагностический JSON используют один снимок. После проверки `E.decode`
восстанавливает числа, без загрузки сериализованных объектов GDScript.
Миграции старой арифметики нет. Старый формат несовместим: существующий
механизм загрузки сохраняет его в `.incompatible.bak` и начинает новую игру.
Старое сохранение игрока не используется тестами и не изменялось при разработке.
## Проверки
`godot --headless --path Owniverse.GI --script res://tests/test_exact_math.gd`
Независимый эталон `tests/fixtures/exact_oracle.json` получен через Python
`fractions.Fraction` (120 пар, seed 4321). Тест сравнивает строковые результаты
суммы, разности, произведения, деления и округления вниз. Дополнительно
проверяются границы `int64`, JSON, рецепты, распределения, жизнь при `10^27`,
единичный расход энергии, тик времени и 100 циклов с меняющимися долями звёзд.
+102
View File
@@ -0,0 +1,102 @@
extends RefCounted
## Unsigned little-endian base 10^9 arithmetic. Inputs are never mutated.
const BASE: int = 1000000000
static func trim(a: Array) -> Array:
while a.size() > 1 and a.back() == 0: a.pop_back()
return a
static func parse(text: String) -> Array:
var result: Array = []
var end: int = text.length()
while end > 0:
var start: int = maxi(0, end - 9)
result.append(int(text.substr(start, end - start)))
end = start
return trim(result if not result.is_empty() else [0])
static func decimal(a: Array) -> String:
var result: String = str(a.back())
for i in range(a.size() - 2, -1, -1): result += "%09d" % a[i]
return result
static func compare(a: Array, b: Array) -> int:
if a.size() != b.size(): return 1 if a.size() > b.size() else -1
for i in range(a.size() - 1, -1, -1):
if a[i] != b[i]: return 1 if a[i] > b[i] else -1
return 0
static func add(a: Array, b: Array) -> Array:
var result: Array = []
var carry: int = 0
for i in maxi(a.size(), b.size()):
var value: int = carry + (a[i] if i < a.size() else 0) + (b[i] if i < b.size() else 0)
result.append(value % BASE)
carry = value / BASE
if carry: result.append(carry)
return result
static func subtract(a: Array, b: Array) -> Array:
assert(compare(a, b) >= 0, "Unsigned subtraction underflow")
var result: Array = []
var borrow: int = 0
for i in a.size():
var value: int = a[i] - borrow - (b[i] if i < b.size() else 0)
borrow = 1 if value < 0 else 0
result.append(value + BASE if borrow else value)
return trim(result)
static func multiply(a: Array, b: Array) -> Array:
var result: Array = []
result.resize(a.size() + b.size())
result.fill(0)
for i in a.size():
var carry: int = 0
for j in b.size():
# < BASE^2 + 2*BASE: comfortably inside signed int64.
var value: int = a[i] * b[j] + result[i + j] + carry
result[i + j] = value % BASE
carry = value / BASE
result[i + b.size()] = carry
return trim(result)
static func divmod(a: Array, b: Array) -> Array:
assert(b != [0], "Division by zero")
if compare(a, b) < 0: return [[0], a.duplicate()]
if b.size() == 1:
var quotient: Array = []
quotient.resize(a.size())
var carry: int = 0
for i in range(a.size() - 1, -1, -1):
var value: int = carry * BASE + a[i]
quotient[i] = value / b[0]
carry = value % b[0]
return [trim(quotient), [carry]]
# Normalize the leading divisor limb so the two-limb quotient estimate
# needs at most two downward corrections (Knuth's division estimate).
var factor: int = BASE / (b.back() + 1)
a = multiply(a, [factor])
b = multiply(b, [factor])
var quotient: Array = []
quotient.resize(a.size())
var remainder: Array = [0]
for i in range(a.size() - 1, -1, -1):
remainder.push_front(a[i])
trim(remainder)
var high: int = remainder[b.size()] if remainder.size() > b.size() else 0
var low: int = remainder[b.size() - 1] if remainder.size() >= b.size() else 0
var estimate: int = mini(BASE - 1, (high * BASE + low) / b.back())
var product: Array = multiply(b, [estimate])
while compare(product, remainder) > 0:
estimate -= 1
product = subtract(product, b)
quotient[i] = estimate
remainder = subtract(remainder, product)
return [trim(quotient), divmod(remainder, [factor])[0]]
static func gcd(a: Array, b: Array) -> Array:
while b != [0]:
var remainder: Array = divmod(a, b)[1]
a = b
b = remainder
return a
+1
View File
@@ -0,0 +1 @@
uid://6wfbh3vq5smd
+322
View File
@@ -0,0 +1,322 @@
extends RefCounted
## Exact signed rational arithmetic. Native small 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 CARRY_SCALE: Array = [0, 0, 0, 0, 1] # 10^36
static func make(n: Array, d: Array = [1], polarity: int = 1):
assert(d != [0], "Zero denominator")
if n == [0]: return 0
if d != [1]:
var common: Array = Big.gcd(n, d)
n = Big.divmod(n, common)[0]
d = Big.divmod(d, common)[0]
if d == [1] and (n.size() == 1 or (n.size() == 2 and n[1] < SMALL)):
return polarity * (n[0] + (n[1] * SMALL if n.size() == 2 else 0))
if d == [1] and n.size() == 3:
var limit: Array = [854775807 if polarity > 0 else 854775808, 223372036, 9]
if Big.compare(n, limit) <= 0:
return (("-" if polarity < 0 else "") + Big.decimal(n)).to_int()
var result = Value.new()
result.numerator = n
result.denominator = d
result.polarity = polarity
return result
static func of(value):
if value is Value or value is int: return value
if value is float:
if not is_finite(value): return value
if value == floorf(value) and absf(value) < 1e18: return int(value)
# Imports the actual binary float, not an invented decimal rounding.
var data := PackedByteArray()
data.resize(8)
data.encode_double(0, value)
var bits: int = data.decode_s64(0)
var exponent: int = (bits >> 52) & 2047
var mantissa: int = bits & 4503599627370495
if exponent: mantissa += 4503599627370496
var power: int = exponent - 1075 if exponent else -1074
var n: Array = Big.parse(str(mantissa))
var d: Array = [1]
for i in absi(power):
if power > 0: n = Big.multiply(n, [2])
else: d = Big.multiply(d, [2])
return make(n, d, -1 if value < 0 else 1)
var text: String = str(value).strip_edges().to_lower()
if text == "": return 0
if "/" in text:
var terms = text.split("/")
assert(terms.size() == 2)
return divide(of(terms[0]), of(terms[1]))
var polarity: int = -1 if text.begins_with("-") else 1
text = text.trim_prefix("-").trim_prefix("+")
var terms = text.split("e")
var power: int = int(terms[1]) if terms.size() == 2 else 0
text = terms[0]
var point: int = text.find(".")
if point >= 0: power -= text.length() - point - 1
text = text.replace(".", "").replace("_", "")
assert(text.is_valid_int(), "Invalid exact number: " + str(value))
assert(absi(power) <= 10000, "Unreasonable decimal exponent")
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 [Big.parse(str(value).trim_prefix("-")), [1], -1 if value < 0 else 1]
return [value.numerator, value.denominator, value.polarity]
static func numeric(value) -> bool:
return value is int or value is float or value is Value
static func finite(value) -> bool:
return value is Value or value is int or (value is float and is_finite(value))
static func approx(value) -> float:
if not value is Value: return float(value)
var a: String = Big.decimal(value.numerator)
var b: String = Big.decimal(value.denominator)
return value.polarity * float(a.left(16)) / float(b.left(16)) * pow(10.0, maxi(0, a.length() - 16) - maxi(0, b.length() - 16))
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):
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 = parts(a)
var y: Array = parts(b)
if x[2] != y[2]: return 1 if x[2] > y[2] else -1
return x[2] * Big.compare(Big.multiply(x[0], y[1]), Big.multiply(y[0], x[1]))
static func eq(a, b) -> bool:
if a == null: return b == null
if b == null: return false
if numeric(a) and numeric(b):
if (a is float and is_nan(a)) or (b is float and is_nan(b)): return false
return compare(a, b) == 0
if a is Dictionary and b is Dictionary:
if a.size() != b.size(): return false
for k in a:
if not b.has(k) or not eq(a[k], b[k]): return false
return true
if a is Array and b is Array:
if a.size() != b.size(): return false
for i in a.size():
if not eq(a[i], b[i]): return false
return true
if (a is String or a is StringName) and (b is String or b is StringName):
return str(a) == str(b)
if typeof(a) != typeof(b): return false
return a == b
static func ne(a, b) -> bool: return not eq(a, b)
static func lt(a, b) -> bool: return compare(a, b) < 0 if numeric(a) and numeric(b) else a < b
static func le(a, b) -> bool: return compare(a, b) <= 0 if numeric(a) and numeric(b) else a <= b
static func gt(a, b) -> bool: return compare(a, b) > 0 if numeric(a) and numeric(b) else a > b
static func ge(a, b) -> bool: return compare(a, b) >= 0 if numeric(a) and numeric(b) else a >= b
static func minimum(a, b): return a if le(a, b) else b
static func maximum(a, b): return a if ge(a, b) else b
static func clamp_value(a, low, high): return minimum(maximum(a, low), high)
static func neg(a): return subtract(0, a)
static func absolute(a): return neg(a) if lt(a, 0) else a
static func add(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 <= MAX_INT - b) or (b < 0 and a >= MIN_INT - b): return a + b
if not finite(a) or not finite(b): return approx(a) + approx(b)
if a is int and a == 0: return of(b)
if b is int and b == 0: return of(a)
var x: Array = parts(a)
var y: Array = parts(b)
var common: Array = Big.gcd(x[1], y[1])
var xd: Array = Big.divmod(x[1], common)[0]
var yd: Array = Big.divmod(y[1], common)[0]
var left: Array = Big.multiply(x[0], yd)
var right: Array = Big.multiply(y[0], xd)
var denominator: Array = Big.multiply(xd, y[1])
if x[2] == y[2]: return make(Big.add(left, right), denominator, x[2])
var order: int = Big.compare(left, right)
return make(Big.subtract(left, right) if order >= 0 else Big.subtract(right, left), denominator, x[2] if order >= 0 else y[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)
if b is int and b == 0: return of(a)
var y: Array = parts(b)
return add(a, make(y[0], y[1], -y[2]))
static func multiply(a, b):
if not numeric(a) or not numeric(b): return a * b
if not finite(a) or not finite(b): return approx(a) * approx(b)
if (a is int and a == 0) or (b is int and b == 0): return 0
if a is int and a == 1: return of(b)
if b is int and b == 1: return of(a)
if a is int and b is int and a != MIN_INT and b != MIN_INT:
if absi(a) <= MAX_INT / absi(b): return a * b
var x: Array = parts(a)
var y: Array = parts(b)
var g1: Array = Big.gcd(x[0], y[1])
var g2: Array = Big.gcd(y[0], x[1])
return make(Big.multiply(Big.divmod(x[0], g1)[0], Big.divmod(y[0], g2)[0]), Big.multiply(Big.divmod(x[1], g2)[0], Big.divmod(y[1], g1)[0]), x[2] * y[2])
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)
if b is int and b == 1: return of(a)
if a is int and b is int and b != 0 and not (a == MIN_INT and b == -1):
if a % b == 0: return a / b
var y: Array = parts(b)
assert(y[0] != [0], "Exact division by zero")
return multiply(a, make(y[1], y[0], y[2]))
static func floor_value(a):
if not finite(a): return a
if a is int: return a
var x: Array = parts(a)
var qr: Array = Big.divmod(x[0], x[1])
if x[2] < 0 and qr[1] != [0]: qr[0] = Big.add(qr[0], [1])
return make(qr[0], [1], 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"))))
static func remainder(a, b): return subtract(a, multiply(truncate(divide(a, b)), 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
## Bound persistent fractions, never integer quantities or recipe arithmetic.
## Truncation towards zero changes a value by less than 10^-36.
static func bounded(value):
if not value is Value or Big.compare(value.denominator, CARRY_SCALE) <= 0:
return value
var units: Array = Big.divmod(Big.multiply(value.numerator, CARRY_SCALE), value.denominator)[0]
return make(units, CARRY_SCALE, value.polarity)
## Only call on a newly owned state snapshot, never on a published old state.
static func bound_state(value):
if value is Value: return bounded(value)
if value is Dictionary:
for key in value: value[key] = bound_state(value[key])
elif value is Array:
for i in value.size(): value[i] = bound_state(value[i])
return value
static func power(a, b):
if finite(b) and eq(b, floor_value(b)) and le(absolute(b), 10000):
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 divide(1, result) if lt(b, 0) else result
return coefficient(pow(approx(a), approx(b)))
static func coefficient(value: float):
# Only transcendental/RNG boundaries are approximate: 12 decimal places.
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 near(a, b) -> bool:
return le(absolute(subtract(a, b)), multiply(of("0.00001"), maximum(1, maximum(absolute(a), absolute(b)))))
static func contains(container, value) -> bool:
if container is Array:
for item in container:
if eq(item, value): return true
return false
return value in container
static func fixed(value, digits: int = 0) -> String:
var scale = power(10, digits)
var rounded = round_value(multiply(value, scale))
var text: String = str(absolute(rounded))
if digits:
text = text.lpad(digits + 1, "0")
text = text.left(text.length() - digits) + "." + text.right(digits)
return ("-" if lt(rounded, 0) else "") + text
static func scientific(value, digits: int = 4) -> String:
if eq(value, 0): return fixed(0, digits) + "E+0"
var terms: Array = parts(absolute(value))
var exponent: int = Big.decimal(terms[0]).length() - Big.decimal(terms[1]).length()
if lt(absolute(value), power(10, exponent)): exponent -= 1
var mantissa = divide(value, power(10, exponent))
if ge(absolute(round_value(multiply(mantissa, power(10, digits)))), multiply(10, power(10, digits))):
mantissa = divide(mantissa, 10)
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)
var text: String = fixed(absolute(value), digits)
var parts_text: PackedStringArray = text.split(".")
var integer_text: String = parts_text[0]
var result: String = ""
while integer_text.length() > 3:
result = " " + integer_text.right(3) + result
integer_text = integer_text.left(integer_text.length() - 3)
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: return {"$exact": str(value)}
if value is int and (value > 9007199254740991 or value < -9007199254740991):
return {"$exact": str(value)}
if value is Dictionary:
var result: Dictionary = {}
for k in value: result[k] = encode(value[k])
return result
if value is Array:
var result: Array = []
for item in value: result.append(encode(item))
return result
return value
static func decode(value):
if value is Dictionary:
if value.size() == 1 and value.has("$exact"): return of(value["$exact"])
var result: Dictionary = {}
for k in value: result[k] = decode(value[k])
return result
if value is Array:
var result: Array = []
for item in value: result.append(decode(item))
return result
return value
static func valid_encoded(value) -> bool:
if value is Dictionary:
if value.has("$exact"):
if value.size() != 1 or not value["$exact"] is String: return false
var text: String = value["$exact"]
if text.length() > 4096: return false
var terms: PackedStringArray = text.split("/")
if terms.size() > 2: 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 terms.size() == 2:
if terms[1].is_empty(): return false
for ch in terms[1]:
if ch < "0" or ch > "9": return false
if Big.parse(terms[1]) == [0]: return false
return true
for k in value:
if not valid_encoded(value[k]): return false
if value is Array:
for item in value:
if not valid_encoded(item): return false
return true
+1
View File
@@ -0,0 +1 @@
uid://cfxfoprykygry
+9
View File
@@ -0,0 +1,9 @@
extends RefCounted
## Immutable by contract; arithmetic creates new values. Signed reduced fraction.
var polarity: int = 1
var numerator: Array = [0]
var denominator: Array = [1]
func _to_string() -> String:
const Big = preload("res://world/owniverse/math/big_integer.gd")
return ("-" if polarity < 0 else "") + Big.decimal(numerator) + ("/" + Big.decimal(denominator) if denominator != [1] else "")
+1
View File
@@ -0,0 +1 @@
uid://qv2rdp4s0d36
+111 -123
View File
@@ -1,181 +1,169 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const CivilizationState = preload("res://world/owniverse/civilization_state.gd")
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
func scale_batches(target: Dictionary, id: String, factor: float) -> void:
func scale_batches(target: Dictionary, id: String, factor: Variant) -> void:
var batches: Array = target.batches.get(id, [])
var total: float = 0.0
var assigned: float = 0.0
var total: Variant = 0
var assigned: Variant = 0
var shares: Array = []
for batch: Dictionary in batches:
var expected: float = batch.amount * maxf(0.0, factor)
total += expected
batch.amount = floorf(expected)
assigned += batch.amount
shares.append({"batch": batch, "rest": expected - batch.amount})
shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest)
for share: Dictionary in shares:
if assigned < roundf(total):
share.batch.amount += 1.0
assigned += 1.0
share.batch.withdraw_remainder = minf(share.batch.get("withdraw_remainder", 0.0), share.batch.amount)
for batch : Dictionary in batches:
var expected: Variant = E.multiply(batch.amount, E.maximum(0, factor))
total = E.add(total, expected)
batch.amount = E.floor_value(expected)
assigned = E.add(assigned, batch.amount)
shares.append({"batch": batch, "rest": E.subtract(expected, batch.amount)})
shares.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return E.gt(a.rest, b.rest))
for share : Dictionary in shares:
if E.lt(assigned, E.round_value(total)):
share.batch.amount = E.add(share.batch.amount, 1)
assigned = E.add(assigned, 1)
share.batch.withdraw_remainder = E.minimum(share.batch.get("withdraw_remainder", 0), share.batch.amount)
func append_batch(target: Dictionary, id: String, amount: float,
created_from: float, created_to: float, completed: Dictionary = {},
completed_before: float = -INF) -> float:
if amount <= 0.0 or not world.catalog.entities.has(id) or world.catalog.entities[id].lifespan <= 0.0:
func append_batch(target: Dictionary, id: String, amount: Variant, created_from: Variant, created_to: Variant, completed: Dictionary = { }, completed_before: Variant = - INF) -> Variant:
if ((E.le(amount, 0) or (not world.catalog.entities.has(id))) or E.le(world.catalog.entities[id].lifespan, 0)):
return NAN
if created_to < created_from:
if E.lt(created_to, created_from):
return NAN
if not target.batches.has(id):
if (not target.batches.has(id)):
target.batches[id] = []
if not target.has("batch_origins"):
if (not target.has("batch_origins")):
target.batch_origins = {}
var batches: Array = target.batches[id]
var lifespan: float = CivilizationState.effective_span(world, target, id, Global.CivilizationRules)
if world.catalog.entities[id].group_as == "Star":
lifespan /= WorldParameters.effective(target, "Bur")
var batch_duration: float = lifespan / Global.batch_count if lifespan > 10.0 else lifespan
if batch_duration <= 0.0:
var lifespan: Variant = CivilizationState.effective_span(world, target, id, Global.CivilizationRules)
if E.eq(world.catalog.entities[id].group_as, "Star"):
lifespan = E.divide(lifespan, WorldParameters.effective(target, "Bur"))
var batch_duration: Variant = (E.divide(lifespan, Global.batch_count) if E.gt(lifespan, 10) else lifespan)
if E.le(batch_duration, 0):
return NAN
if not target.batch_origins.has(id):
target.batch_origins[id] = batches[0].get("created_from", created_from) if not batches.is_empty() else created_from
var origin: float = target.batch_origins[id]
var cursor: float = created_from
var duration: float = created_to - created_from
var remaining_amount: float = amount
var completed_amount: float = 0.0
var completed_from: float = INF
var completed_to: float = -INF
# Subtract virtual windows in their original order to preserve floating-point
# rounding of lifetime production, rather than subtracting one large sum.
var remaining_object_years: float = amount * maxf(0.0, completed_before - (created_from + created_to) * 0.5) if is_finite(completed_before) else NAN
# Compute the index once. Recomputing floor from a rounded boundary can
# select the preceding window forever (e.g. fractional origins at large ages).
var window_index: float = floorf((cursor - origin) / batch_duration)
if origin + (window_index + 1.0) * batch_duration <= cursor:
window_index += 1.0
if (not target.batch_origins.has(id)):
target.batch_origins[id] = (batches[0].get("created_from", created_from) if (not batches.is_empty()) else created_from)
var origin: Variant = target.batch_origins[id]
var cursor: Variant = created_from
var duration: Variant = E.subtract(created_to, created_from)
var remaining_amount: Variant = amount
var completed_amount: Variant = 0
var completed_from: Variant = INF
var completed_to: Variant = E.neg(INF)
# Subtract each completed window's lifetime production exactly.
var remaining_object_years: Variant = (E.multiply(amount, E.maximum(0, E.subtract(completed_before, E.multiply((E.add(created_from, created_to)), E.of("0.5"))))) if E.finite(completed_before) else NAN)
# Advance an exact window index rather than repeatedly finding a boundary.
var window_index: Variant = E.floor_value(E.divide((E.subtract(cursor, origin)), batch_duration))
if E.le(E.add(origin, E.multiply((E.add(window_index, 1)), batch_duration)), cursor):
window_index = E.add(window_index, 1)
var batches_by_start: Dictionary = {}
for existing: Dictionary in batches:
var start: float = existing.get("created_from", -INF)
if not batches_by_start.has(start):
batches_by_start[start] = []
batches_by_start[start].append(existing)
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:
var window_start: float = origin + window_index * batch_duration
var window_end: float = origin + (window_index + 1.0) * batch_duration
if window_end <= cursor:
push_error("Batch window exceeds floating-point time precision: " + id)
var window_start: Variant = E.add(origin, E.multiply(window_index, batch_duration))
var window_end: Variant = E.add(origin, E.multiply((E.add(window_index, 1)), batch_duration))
if E.le(window_end, cursor):
push_error("Batch window does not advance: " + id)
return remaining_object_years
var part_end: float = minf(created_to, window_end)
var part_amount: float = remaining_amount
if duration > 0.0 and part_end < created_to:
part_amount = floorf(amount * (part_end - created_from) / duration) - (amount - remaining_amount)
if window_end + lifespan <= completed_before:
var part_end: Variant = E.minimum(created_to, window_end)
var part_amount: Variant = remaining_amount
if (E.gt(duration, 0) and E.lt(part_end, created_to)):
part_amount = E.subtract(E.floor_value(E.divide(E.multiply(amount, (E.subtract(part_end, created_from))), duration)), (E.subtract(amount, remaining_amount)))
if E.le(E.add(window_end, lifespan), completed_before):
# Retain the original integer allocation and lifetime integral, but
# do not allocate thousands of already-dead batch dictionaries.
completed_amount += part_amount
completed_from = minf(completed_from, window_start + lifespan)
completed_to = maxf(completed_to, window_end + lifespan)
remaining_object_years -= part_amount * expired_years(completed_before, window_start + lifespan, window_end + lifespan)
completed_amount = E.add(completed_amount, part_amount)
completed_from = E.minimum(completed_from, E.add(window_start, lifespan))
completed_to = E.maximum(completed_to, E.add(window_end, lifespan))
remaining_object_years = E.subtract(remaining_object_years, E.multiply(part_amount, expired_years(completed_before, E.add(window_start, lifespan), E.add(window_end, lifespan))))
else:
var batch: Dictionary = {}
for existing: Dictionary in batches_by_start.get(window_start, []):
if existing.get("created_from", -INF) == window_start and existing.start_year == window_start + lifespan and existing.end_year == window_end + lifespan:
for existing : Dictionary in batches_by_start.get(str(window_start), []):
if ((E.eq(existing.get("created_from", E.neg(INF)), window_start) and E.eq(existing.start_year, E.add(window_start, lifespan))) and E.eq(existing.end_year, E.add(window_end, lifespan))):
batch = existing
break
if batch.is_empty():
batch = {
"created_from": window_start,
"created_to": window_end,
"start_year": window_start + lifespan,
"end_year": window_end + lifespan,
"amount": 0.0,
}
batch = {"created_from": window_start, "created_to": window_end, "start_year": E.add(window_start, lifespan), "end_year": E.add(window_end, lifespan), "amount": 0}
batches.append(batch)
if not batches_by_start.has(window_start):
batches_by_start[window_start] = []
batches_by_start[window_start].append(batch)
batch.amount += part_amount
remaining_amount -= part_amount
if duration == 0.0 or part_end >= created_to:
if (not batches_by_start.has(str(window_start))):
batches_by_start[str(window_start)] = []
batches_by_start[str(window_start)].append(batch)
batch.amount = E.add(batch.amount, part_amount)
remaining_amount = E.subtract(remaining_amount, part_amount)
if (E.eq(duration, 0) or E.ge(part_end, created_to)):
break
cursor = window_end
window_index += 1.0
batches.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.created_from < b.created_from)
if completed_amount > 0.0:
if not completed.has(id): completed[id] = []
window_index = E.add(window_index, 1)
batches.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return E.lt(a.created_from, b.created_from))
if E.gt(completed_amount, 0):
if (not completed.has(id)): completed[id] = []
completed[id].append({"amount": completed_amount, "from": completed_from, "to": completed_to})
return remaining_object_years
func withdraw_batches(target: Dictionary, id: String, to_year: float) -> Array:
func withdraw_batches(target: Dictionary, id: String, to_year: Variant) -> Array:
var expired: Array = []
var remaining: Array = []
for batch: Dictionary in target.batches.get(id, []):
var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year))
var end: float = batch.end_year
var amount: float = batch.amount
if amount <= 0.0:
for batch : Dictionary in target.batches.get(id, []):
var start: Variant = E.maximum(batch.start_year, batch.get("processed_until", batch.start_year))
var end: Variant = batch.end_year
var amount: Variant = batch.amount
if E.le(amount, 0):
continue
if to_year < start or (to_year == start and end > start):
if (E.lt(to_year, start) or ((E.eq(to_year, start) and E.gt(end, start)))):
remaining.append(batch)
continue
var consumed: float = amount
var until: float = minf(to_year, end)
if to_year < end:
var carry: float = batch.get("withdraw_remainder", 0.0)
var expected: float = carry + (amount - carry) * (to_year - start) / (end - start)
consumed = floorf(expected)
batch.withdraw_remainder = expected - consumed
batch.amount = maxf(0.0, amount - consumed)
var consumed: Variant = amount
var until: Variant = E.minimum(to_year, end)
if E.lt(to_year, end):
var carry: Variant = batch.get("withdraw_remainder", 0)
var expected: Variant = E.add(carry, E.divide(E.multiply((E.subtract(amount, carry)), (E.subtract(to_year, start))), (E.subtract(end, start))))
consumed = E.floor_value(expected)
batch.withdraw_remainder = E.subtract(expected, consumed)
batch.amount = E.maximum(0, E.subtract(amount, consumed))
batch.processed_until = to_year
remaining.append(batch)
if consumed > 0.0:
if E.gt(consumed, 0):
expired.append({"amount": consumed, "from": start, "to": until})
target.batches[id] = remaining
return expired
func object_years(source: Dictionary, id: String, quantity: float,
from_year: float, to_year: float) -> float:
var result: float = quantity * SimulationTime.elapsed_years(from_year, to_year)
for batch: Dictionary in source.batches.get(id, []):
var start: float = maxf(batch.start_year, batch.get("processed_until", batch.start_year))
var end: float = batch.end_year
result -= batch.amount * (expired_years(to_year, start, end) - expired_years(from_year, start, end))
return maxf(0.0, result)
func object_years(source: Dictionary, id: String, quantity: Variant, from_year: Variant, to_year: Variant) -> Variant:
var result: Variant = E.multiply(quantity, SimulationTime.elapsed_years(from_year, to_year))
for batch : Dictionary in source.batches.get(id, []):
var start: Variant = E.maximum(batch.start_year, batch.get("processed_until", batch.start_year))
var end: Variant = batch.end_year
result = E.subtract(result, E.multiply(batch.amount, (E.subtract(expired_years(to_year, start, end), expired_years(from_year, start, end)))))
return E.maximum(0, result)
func expired_years(year: float, start: float, end: float) -> float:
if year <= start:
return 0.0
if end <= start:
return year - start
if year >= end:
return year - end + (end - start) * 0.5
return (year - start) * (year - start) / (2.0 * (end - start))
func expired_years(year: Variant, start: Variant, end: Variant) -> Variant:
if E.le(year, start):
return 0
if E.le(end, start):
return E.subtract(year, start)
if E.ge(year, end):
return E.add(E.subtract(year, end), E.multiply((E.subtract(end, start)), E.of("0.5")))
return E.divide(E.multiply((E.subtract(year, start)), (E.subtract(year, start))), (E.multiply(2, (E.subtract(end, start)))))
func consume_from_batches(entity: OwniverseEntity, current_year: float) -> float:
var total: float = 0.0
func consume_from_batches(entity: OwniverseEntity, current_year: Variant) -> Variant:
var total: Variant = 0
var physical: Dictionary = world.ledger.physical_amounts(world.state)
for part: Dictionary in withdraw_batches(world.state, entity.id, current_year):
total += part.amount
var fraction: float = clampf(total / physical[entity.id], 0.0, 1.0) if physical.get(entity.id, 0.0) > 0.0 else 0.0
world.state.amount[entity.id] = world.state.amount.get(entity.id, 0.0) * (1.0 - fraction)
for part : Dictionary in withdraw_batches(world.state, entity.id, current_year):
total = E.add(total, part.amount)
var fraction: Variant = (E.clamp_value(E.divide(total, physical[entity.id]), 0, 1) if E.gt(physical.get(entity.id, 0), 0) else 0)
world.state.amount[entity.id] = E.multiply(world.state.amount.get(entity.id, 0), (E.subtract(1, fraction)))
for owner_id in world.state.binds:
if world.state.binds[owner_id].has(entity.id):
world.state.binds[owner_id][entity.id] *= 1.0 - fraction
world.state.binds[owner_id][entity.id] = E.multiply(world.state.binds[owner_id][entity.id], E.subtract(1, fraction))
world.ledger.round_object_allocations(world.state)
world.ledger.refresh_groups(world.state)
world.presentation.refresh(world.state, world.catalog)
return total
func add_entity_to_batch(entity_id: String, amount: float, current_year: float) -> void:
func add_entity_to_batch(entity_id: String, amount: Variant, current_year: Variant) -> void:
append_batch(world.state, entity_id, amount, current_year, current_year)
+114 -121
View File
@@ -1,23 +1,24 @@
extends "res://world/owniverse/simulation_module.gd"
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")
func physical_amounts(source: Dictionary) -> Dictionary:
var amounts: Dictionary = {}
for id in world.catalog.entities:
if not world.catalog.is_group(id):
amounts[id] = maxf(0.0, source.amount.get(id, 0.0))
for bindings: Dictionary in source.binds.values():
if (not world.catalog.is_group(id)):
amounts[id] = E.maximum(0, source.amount.get(id, 0))
for bindings : Dictionary in source.binds.values():
for id in bindings:
if amounts.has(id):
amounts[id] += maxf(0.0, bindings[id])
amounts[id] = E.add(amounts[id], E.maximum(0, bindings[id]))
return amounts
func available(source: Dictionary, id: String) -> float:
var amount: float = 0.0
func available(source: Dictionary, id: String) -> Variant:
var amount: Variant = 0
for member in world.catalog.members(id):
amount += maxf(0.0, source.amount.get(member, 0.0))
amount = E.add(amount, E.maximum(0, source.amount.get(member, 0)))
return amount
@@ -26,42 +27,41 @@ func expand_requirements(source: Dictionary, requirements: Dictionary) -> Dictio
for id in requirements:
var weights: Dictionary = {}
for member in world.catalog.members(id):
weights[member] = source.amount.get(member, 0.0)
weights[member] = source.amount.get(member, 0)
weights = QuantityMath.normalize_distribution(weights)
for member in weights:
expanded[member] = expanded.get(member, 0.0) + maxf(0.0, requirements[id]) * weights[member]
expanded[member] = E.add(expanded.get(member, 0), E.multiply(E.maximum(0, requirements[id]), weights[member]))
return expanded
func take_available(target: Dictionary, id: String, amount: float) -> Dictionary:
func take_available(target: Dictionary, id: String, amount: Variant) -> Dictionary:
var quantities: Dictionary = {}
for member in world.catalog.members(id):
quantities[member] = target.amount.get(member, 0.0)
quantities[member] = target.amount.get(member, 0)
var distribution: Dictionary = QuantityMath.normalize_distribution(quantities)
var taken: Dictionary = {}
var total: float = minf(amount, available(target, id))
var discrete: bool = not distribution.is_empty()
var total: Variant = E.minimum(amount, available(target, id))
var discrete: bool = (not distribution.is_empty())
for member in distribution:
discrete = discrete and is_discrete(member)
discrete = (discrete and is_discrete(member))
if discrete:
total = floorf(total)
total = E.floor_value(total)
var shares: Array = []
var assigned: float = 0.0
var assigned: Variant = 0
for member in distribution:
var expected: float = total * distribution[member]
taken[member] = floorf(expected) if discrete else expected
assigned += taken[member]
shares.append({"id": member, "rest": expected - taken[member]})
var expected: Variant = E.multiply(total, distribution[member])
taken[member] = (E.floor_value(expected) if discrete else expected)
assigned = E.add(assigned, taken[member])
shares.append({"id": member, "rest": E.subtract(expected, taken[member])})
if discrete:
shares.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
for share: Dictionary in shares:
if assigned >= total:
shares.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 share : Dictionary in shares:
if E.ge(assigned, total):
break
taken[share.id] += 1.0
assigned += 1.0
taken[share.id] = E.add(taken[share.id], 1)
assigned = E.add(assigned, 1)
for member in taken:
target.amount[member] = maxf(0.0, target.amount.get(member, 0.0) - taken[member])
target.amount[member] = E.maximum(0, E.subtract(target.amount.get(member, 0), taken[member]))
return taken
@@ -69,45 +69,44 @@ func is_discrete(_id: String) -> bool:
return true
func credit(target: Dictionary, id: String, amount: float) -> float:
if amount <= 0.0:
return 0.0
func credit(target: Dictionary, id: String, amount: Variant) -> Variant:
if E.le(amount, 0):
return 0
if is_discrete(id):
if not target.has("production_remainders"):
if (not target.has("production_remainders")):
target.production_remainders = {}
var expected: float = amount + target.production_remainders.get(id, 0.0)
amount = floorf(expected)
target.production_remainders[id] = expected - amount
target.amount[id] = target.amount.get(id, 0.0) + amount
target.stats.total_amount[id] = target.stats.total_amount.get(id, 0.0) + amount
if world.catalog.entities.has(id) and world.catalog.entities[id].group_as != "" and world.catalog.entities[id].group_as != id:
var expected: Variant = E.add(amount, target.production_remainders.get(id, 0))
amount = E.floor_value(expected)
target.production_remainders[id] = E.subtract(expected, amount)
target.amount[id] = E.add(target.amount.get(id, 0), amount)
target.stats.total_amount[id] = E.add(target.stats.total_amount.get(id, 0), amount)
if ((world.catalog.entities.has(id) and E.ne(world.catalog.entities[id].group_as, "")) and E.ne(world.catalog.entities[id].group_as, id)):
var group: String = world.catalog.entities[id].group_as
target.stats.total_amount[group] = target.stats.total_amount.get(group, 0.0) + amount
target.stats.total_amount[group] = E.add(target.stats.total_amount.get(group, 0), amount)
return amount
func round_object_allocations(target: Dictionary) -> void:
var physical: Dictionary = physical_amounts(target)
for id in physical:
if not is_discrete(id):
if (not is_discrete(id)):
continue
var slots: Array = [ {"owner": "", "value": target.amount.get(id, 0.0)}]
var slots: Array = [{"owner": "", "value": target.amount.get(id, 0)}]
for owner_id in target.binds:
if target.binds[owner_id].has(id):
slots.append({"owner": owner_id, "value": target.binds[owner_id][id]})
var assigned: float = 0.0
for slot: Dictionary in slots:
slot.whole = floorf(slot.value)
slot.rest = slot.value - slot.whole
assigned += slot.whole
slots.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest if a.rest != b.rest else str(a.owner) < str(b.owner))
var left: float = roundf(physical[id]) - assigned
for slot: Dictionary in slots:
if left > 0.0:
slot.whole += 1.0
left -= 1.0
if slot.owner == "":
var assigned: Variant = 0
for slot : Dictionary in slots:
slot.whole = E.floor_value(slot.value)
slot.rest = E.subtract(slot.value, slot.whole)
assigned = E.add(assigned, slot.whole)
slots.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.owner), str(b.owner))))
var left: Variant = E.subtract(E.round_value(physical[id]), assigned)
for slot : Dictionary in slots:
if E.gt(left, 0):
slot.whole = E.add(slot.whole, 1)
left = E.subtract(left, 1)
if E.eq(slot.owner, ""):
target.amount[id] = slot.whole
else:
target.binds[slot.owner][id] = slot.whole
@@ -117,48 +116,42 @@ func refresh_groups(target: Dictionary) -> void:
var groups: Dictionary = {}
for id in world.catalog.entities:
var group: String = world.catalog.entities[id].group_as
if group != "":
groups[group] = groups.get(group, 0.0) + target.amount.get(id, 0.0)
if E.ne(group, ""):
groups[group] = E.add(groups.get(group, 0), target.amount.get(id, 0))
for group in groups:
target.amount[group] = groups[group]
func clamp_auto_resources(target: Dictionary) -> void:
for id in target.get("auto_resources", {}):
target.auto_resources[id] = minf(target.auto_resources[id], available(target, id))
target.auto_resources[id] = E.minimum(target.auto_resources[id], available(target, id))
func bind_full(target: Dictionary, amounts: Dictionary) -> bool:
# Binding only mutates free amounts and owners' holdings.
var trial: Dictionary = {
"amount": target.amount.duplicate(),
"binds": target.binds.duplicate(true),
}
var trial: Dictionary = {"amount": target.amount.duplicate(), "binds": target.binds.duplicate(true)}
var requests: Array = []
for owner_id in amounts:
if not world.catalog.entities.has(owner_id):
if (not world.catalog.entities.has(owner_id)):
return false
for id in world.catalog.entities[owner_id].bind:
var quantity: float = amounts[owner_id] * maxf(0.0, world.catalog.entities[owner_id].bind[id])
if not is_equal_approx(quantity, roundf(quantity)):
var quantity: Variant = E.multiply(amounts[owner_id], E.maximum(0, world.catalog.entities[owner_id].bind[id]))
if (not E.eq(quantity, E.round_value(quantity))):
return false
if quantity > 0.0:
requests.append({"owner": owner_id, "id": id, "amount": roundf(quantity), "members": world.catalog.members(id).size()})
requests.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
if a.members != b.members:
return a.members < b.members
return str(a.id) < str(b.id) if a.id != b.id else str(a.owner) < str(b.owner))
for request: Dictionary in requests:
if E.gt(quantity, 0):
requests.append({"owner": owner_id, "id": id, "amount": E.round_value(quantity), "members": world.catalog.members(id).size()})
requests.sort_custom(func ( a : Dictionary , b : Dictionary ) -> bool : return (E.lt(a.members, b.members) if E.ne(a.members, b.members) else ((E.lt(str(a.id), str(b.id)) if E.ne(a.id, b.id) else E.lt(str(a.owner), str(b.owner))))))
for request : Dictionary in requests:
var taken: Dictionary = take_available(trial, request.id, request.amount)
var total: float = 0.0
var total: Variant = 0
for quantity in taken.values():
total += quantity
if total != request.amount:
total = E.add(total, quantity)
if E.ne(total, request.amount):
return false
if not trial.binds.has(request.owner):
if (not trial.binds.has(request.owner)):
trial.binds[request.owner] = {}
for id in taken:
trial.binds[request.owner][id] = trial.binds[request.owner].get(id, 0.0) + taken[id]
trial.binds[request.owner][id] = E.add(trial.binds[request.owner].get(id, 0), taken[id])
target.amount = trial.amount
target.binds = trial.binds
return true
@@ -169,51 +162,51 @@ func bind_new_owners(target: Dictionary, amounts: Dictionary, require_full: bool
return bind_full(target, amounts)
var requests: Dictionary = {}
for owner_id in amounts:
if not world.catalog.entities.has(owner_id):
if (not world.catalog.entities.has(owner_id)):
continue
for id in world.catalog.entities[owner_id].bind:
var quantity: float = amounts[owner_id] * maxf(0.0, world.catalog.entities[owner_id].bind[id])
var quantity: Variant = E.multiply(amounts[owner_id], E.maximum(0, world.catalog.entities[owner_id].bind[id]))
var weights: Dictionary = {}
for member in world.catalog.members(id):
weights[member] = target.amount.get(member, 0.0)
weights[member] = target.amount.get(member, 0)
weights = QuantityMath.normalize_distribution(weights)
for member in weights:
if not requests.has(member):
if (not requests.has(member)):
requests[member] = {}
requests[member][owner_id] = requests[member].get(owner_id, 0.0) + quantity * weights[member]
requests[member][owner_id] = E.add(requests[member].get(owner_id, 0), E.multiply(quantity, weights[member]))
for id in requests:
var requested: float = 0.0
var requested: Variant = 0
for quantity in requests[id].values():
requested += quantity
var available: float = available(target, id)
var factor: float = minf(1.0, available / requested) if requested > 0.0 else 0.0
requested = E.add(requested, quantity)
var available: Variant = available(target, id)
var factor: Variant = (E.minimum(1, E.divide(available, requested)) if E.gt(requested, 0) else 0)
for owner_id in requests[id]:
var quantity: float = requests[id][owner_id] * factor
if not target.binds.has(owner_id):
var quantity: Variant = E.multiply(requests[id][owner_id], factor)
if (not target.binds.has(owner_id)):
target.binds[owner_id] = {}
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + quantity
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - quantity)
target.binds[owner_id][id] = E.add(target.binds[owner_id].get(id, 0), quantity)
target.amount[id] = E.maximum(0, E.subtract(target.amount.get(id, 0), quantity))
return true
func apply_costs(entity: OwniverseEntity, amount: float) -> void:
func apply_costs(entity: OwniverseEntity, amount: Variant) -> void:
var costs: Dictionary = {}
for resource in entity.cost:
costs[resource] = roundf(maxf(0.0, entity.cost[resource]) * amount)
costs[resource] = E.round_value(E.multiply(E.maximum(0, entity.cost[resource]), amount))
for resource in costs:
var quantities: Dictionary = physical_amounts(world.state)
var taken: Dictionary = take_available(world.state, resource, costs[resource])
for id in taken:
var factor: float = 1.0 - taken[id] / quantities[id] if quantities.get(id, 0.0) > 0.0 else 0.0
var factor: Variant = (E.subtract(1, E.divide(taken[id], quantities[id])) if E.gt(quantities.get(id, 0), 0) else 0)
world.batches.scale_batches(world.state, id, factor)
refresh_groups(world.state)
clamp_auto_resources(world.state)
world.presentation.refresh(world.state, world.catalog)
func apply_binds(item_id: String, _entity: OwniverseEntity, amount: float) -> bool:
if not bind_new_owners(world.state, {item_id: amount}, true):
func apply_binds(item_id: String, _entity: OwniverseEntity, amount: Variant) -> bool:
if (not bind_new_owners(world.state, {item_id: amount}, true)):
return false
refresh_groups(world.state)
clamp_auto_resources(world.state)
@@ -222,20 +215,20 @@ func apply_binds(item_id: String, _entity: OwniverseEntity, amount: float) -> bo
return true
func process_entity(entity_id: String, amount: float) -> void:
if amount == 0:
func process_entity(entity_id: String, amount: Variant) -> void:
if E.eq(amount, 0):
return
# Update state amount
world.state.amount[entity_id] = world.state.amount.get(entity_id, 0.0) + amount
world.state.stats.total_amount[entity_id] = world.state.stats.total_amount.get(entity_id, 0.0) + maxf(0.0, amount)
world.state.amount[entity_id] = E.add(world.state.amount.get(entity_id, 0), amount)
world.state.stats.total_amount[entity_id] = E.add(world.state.stats.total_amount.get(entity_id, 0), E.maximum(0, amount))
# Handle group updates if entity belongs to a group
var entity = world.catalog.entities[entity_id]
if entity.group_as != "":
world.state.amount[entity.group_as] = world.state.amount.get(entity.group_as, 0.0) + amount
world.state.stats.total_amount[entity.group_as] = world.state.stats.total_amount.get(entity.group_as, 0.0) + maxf(0.0, amount)
if E.ne(entity.group_as, ""):
world.state.amount[entity.group_as] = E.add(world.state.amount.get(entity.group_as, 0), amount)
world.state.stats.total_amount[entity.group_as] = E.add(world.state.stats.total_amount.get(entity.group_as, 0), E.maximum(0, amount))
world.presentation.refresh(world.state, world.catalog)
@@ -245,33 +238,33 @@ func transfer_evolution(target: Dictionary, fractions: Dictionary, outputs: Dict
var bindings: Dictionary = target.binds.duplicate(true)
var incoming: Dictionary = {}
for owner_id in bindings:
var owner_survival: float = 1.0 - fractions.get(owner_id, 0.0)
var owner_survival: Variant = E.subtract(1, fractions.get(owner_id, 0))
target.binds[owner_id] = {}
for id in bindings[owner_id]:
var held: float = bindings[owner_id][id]
var released: float = held * (1.0 - owner_survival)
target.amount[id] = target.amount.get(id, 0.0) + released
var heirs: Dictionary = world.stellar.stellar_successors(outputs.get(owner_id, {})) if world.catalog.entities.has(id) and world.catalog.entities[id].group_as == "Planet" else {}
var fraction: float = fractions.get(id, 0.0)
var held: Variant = bindings[owner_id][id]
var released: Variant = E.multiply(held, (E.subtract(1, owner_survival)))
target.amount[id] = E.add(target.amount.get(id, 0), released)
var heirs: Dictionary = (world.stellar.stellar_successors(outputs.get(owner_id, {})) if (world.catalog.entities.has(id) and E.eq(world.catalog.entities[id].group_as, "Planet")) else {})
var fraction: Variant = fractions.get(id, 0)
for heir in heirs:
if not incoming.has(heir):
if (not incoming.has(heir)):
incoming[heir] = {}
incoming[heir][id] = incoming[heir].get(id, 0.0) + released * (1.0 - fraction) * heirs[heir]
incoming[heir][id] = E.add(incoming[heir].get(id, 0), E.multiply(E.multiply(released, (E.subtract(1, fraction))), heirs[heir]))
for child in outputs.get(id, {}):
if world.catalog.entities.has(child) and not CivilizationState.is_space_civilization(child, Global.CivilizationRules):
incoming[heir][child] = incoming[heir].get(child, 0.0) + released * fraction * heirs[heir] * outputs[id][child]
var remaining: float = held * owner_survival * (1.0 - fraction)
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + remaining
if (world.catalog.entities.has(child) and (not CivilizationState.is_space_civilization(child, Global.CivilizationRules))):
incoming[heir][child] = E.add(incoming[heir].get(child, 0), E.multiply(E.multiply(E.multiply(released, fraction), heirs[heir]), outputs[id][child]))
var remaining: Variant = E.multiply(E.multiply(held, owner_survival), (E.subtract(1, fraction)))
target.binds[owner_id][id] = E.add(target.binds[owner_id].get(id, 0), remaining)
for successor in outputs.get(id, {}):
if not world.catalog.entities.has(successor) or CivilizationState.is_space_civilization(successor, Global.CivilizationRules):
if ((not world.catalog.entities.has(successor)) or CivilizationState.is_space_civilization(successor, Global.CivilizationRules)):
continue
var transferred: float = held * owner_survival * fraction * maxf(0.0, outputs[id][successor])
if not incoming.has(owner_id):
var transferred: Variant = E.multiply(E.multiply(E.multiply(held, owner_survival), fraction), E.maximum(0, outputs[id][successor]))
if (not incoming.has(owner_id)):
incoming[owner_id] = {}
incoming[owner_id][successor] = incoming[owner_id].get(successor, 0.0) + transferred
incoming[owner_id][successor] = E.add(incoming[owner_id].get(successor, 0), transferred)
for id in fractions:
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) * (1.0 - fractions[id]))
target.amount[id] = E.maximum(0, E.multiply(target.amount.get(id, 0), (E.subtract(1, fractions[id]))))
return incoming
@@ -279,9 +272,9 @@ func transfer_evolution(target: Dictionary, fractions: Dictionary, outputs: Dict
## Commit reservations after newly evolved objects have been credited.
func accept_transfers(target: Dictionary, incoming: Dictionary) -> void:
for owner_id in incoming:
if not target.binds.has(owner_id):
if (not target.binds.has(owner_id)):
target.binds[owner_id] = {}
for id in incoming[owner_id]:
var transferred: float = minf(incoming[owner_id][id], target.amount.get(id, 0.0))
target.binds[owner_id][id] = target.binds[owner_id].get(id, 0.0) + transferred
target.amount[id] = maxf(0.0, target.amount.get(id, 0.0) - transferred)
var transferred: Variant = E.minimum(incoming[owner_id][id], target.amount.get(id, 0))
target.binds[owner_id][id] = E.add(target.binds[owner_id].get(id, 0), transferred)
target.amount[id] = E.maximum(0, E.subtract(target.amount.get(id, 0), transferred))
+10 -15
View File
@@ -1,5 +1,6 @@
extends Node
class_name Owniverse
const E = preload("res://world/owniverse/math/exact.gd")
## Owns the state and modules. Rules live in the modules below.
const SimulationTime = preload("res://world/owniverse/simulation_time.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
@@ -16,10 +17,7 @@ var evolution = preload("res://world/owniverse/evolution_coordinator.gd").new(se
var state: Dictionary = {
'version': SimulationTime.STATE_VERSION,
'time': {
'datetime': 0.0,
'year': 0.0,
},
'time': {'datetime': 0, 'year': 0},
'speed': 1,
'speed_in_year': 1,
'energy': WorldParameters.INITIAL_ENERGY,
@@ -43,22 +41,19 @@ var state: Dictionary = {
'civilization_stats': {},
'civilization_homes': {},
'ethics': {},
'stats': {
'total_amount': {},
}
'stats': {'total_amount': {}},
}
var speed: int:
var speed: int :
get:
return state.speed
set(value):
state.speed = clampi(value, 1, Global.SPEED_LIMIT)
state.speed_in_year = pow(10.0, state.speed - 1)
state.speed_in_year = E.power(10, E.subtract(state.speed, 1))
func evolute(delta: float) -> void:
if delta <= 0.0 or not is_finite(delta):
func evolute(delta: Variant) -> void:
if (E.le(delta, 0) or (not E.finite(delta))):
return
var datetime: float = state.time.datetime + delta * pow(10.0, speed - 1)
if not is_finite(datetime) or not is_finite(datetime * 10.0):
return
evolution.evolute_to_datetime(datetime)
var clock: Dictionary = SimulationTime.advance(state.time, E.approx(delta), speed)
if not E.finite(clock.datetime): return
evolution.evolute_to_datetime(clock.datetime, clock)
+35 -34
View File
@@ -1,23 +1,24 @@
extends Node
class_name OwniverseEntity
const E = preload("res://world/owniverse/math/exact.gd")
var id: String
var is_visible: bool = false
var unlock: Dictionary = {}
var cost: Dictionary = {}
var bind: Dictionary = {}
var mass: float
var mass: Variant
var space: int
var life_rule: Dictionary = {}
var lifespan: float
var lifespan_delta: float
var auto: float
var gamma: float
var lifespan: Variant
var lifespan_delta: Variant
var auto: Variant
var gamma: Variant
var blast: int
var blastability: bool = false
var life_product: Dictionary = {}
var product_per_year_type: String = ""
var product_per_year_amount: float = 0
var product_per_year_amount: Variant = 0
var voids: int
var output_or: Dictionary = {}
var output_and: Dictionary = {}
@@ -27,49 +28,49 @@ var planets: Dictionary = {}
var count_as: String
var group_as: String
var event_group: String
var space_bonus: float
var civ_bonus: float
var space_bonus: Variant
var civ_bonus: Variant
var claim: String
var amount: float = 0.0
var amount: Variant = 0
func _init(values: Dictionary) -> void:
id = values.id
is_visible = values.is_visible == "1"
is_visible = E.eq(values.is_visible, "1")
unlock = _parse_and_string(values.unlock)
cost = _parse_and_string(values.cost)
bind = _parse_and_string(values.bind)
#print(id, " | ", cost, " | ", bind)
mass = float(values.mass)
space = int(values.space)
mass = E.of(values.mass)
space = E.integer(values.space)
life_rule = Global.LifeRules.get(id, {}).duplicate(true)
if Global.CivilizationRules.has(id):
life_rule = {"ethical": true, "span": Global.CivilizationRules[id].span}
lifespan = float(life_rule.span) if not life_rule.is_empty() else float(values.lifespan)
lifespan_delta = lifespan / Global.batch_count if lifespan > 10 else lifespan
auto = float(values.auto)
gamma = float(values.gamma)
blast = int(values.blast)
blastability = str(values.get("blastability", "")) == "1"
lifespan = (E.of(life_rule.span) if (not life_rule.is_empty()) else E.of(values.lifespan))
lifespan_delta = (E.divide(lifespan, Global.batch_count) if E.gt(lifespan, 10) else lifespan)
auto = E.of(values.auto)
gamma = E.of(values.gamma if str(values.gamma).is_valid_float() else "0")
blast = E.integer(values.blast)
blastability = E.eq(str(values.get("blastability", "")), "1")
life_product = _parse_and_string(values.life_product)
for key in life_product.keys():
product_per_year_type = key
product_per_year_amount = life_product[key] / lifespan if lifespan != 0 else 0
product_per_year_amount = (E.divide(life_product[key], lifespan) if E.ne(lifespan, 0) else 0)
#print(id, " | ", lifespan_delta, " | ", lifespan)
voids = int(values.voids)
voids = E.integer(values.voids)
output_or = _parse_or_string(values.output)
output_and = _parse_and_string(values.output)
destroy_level = int(values.destroy_level)
destroy_level = E.integer(values.destroy_level)
degradation = _parse_and_string(values.degradation)
planets = _parse_planet_string(values.planets)
count_as = values.count_as
group_as = values.group_as
event_group = values.event_group
space_bonus = float(values.space_bonus)
civ_bonus = float(values.civ_bonus)
space_bonus = E.of(values.space_bonus)
civ_bonus = E.of(values.civ_bonus)
claim = values.claim
@@ -78,7 +79,7 @@ func _parse_or_string(source_line: String) -> Dictionary:
if source_line.contains("^"):
var first_split = source_line.split("^")
var second_split = first_split[1].split("%")
var probability = float(second_split[1])
var probability = E.of(second_split[1])
result["chance"] = probability
result["positive"] = second_split[0]
result["negative"] = first_split[0]
@@ -92,8 +93,8 @@ func _parse_and_string(source_line: String) -> Dictionary:
for elem in first_split:
var second_split = elem.split(":")
if len(second_split) == 2:
result[second_split[0]] = float(second_split[1])
if E.eq(len(second_split), 2):
result[second_split[0]] = E.of(second_split[1])
return result
@@ -101,16 +102,16 @@ func _parse_planet_string(source_line: String) -> Dictionary:
var result = {}
for elem in source_line.split("&", false):
var parts: PackedStringArray = elem.strip_edges().split(":")
if parts.size() != 3 or parts[0].strip_edges().is_empty():
push_warning("Invalid planet definition for %s: %s" % [id, elem])
if (E.ne(parts.size(), 3) or parts[0].strip_edges().is_empty()):
push_warning(("Invalid planet definition for %s: %s" % [id, elem]))
continue
if not parts[1].is_valid_float() or not parts[2].is_valid_float():
push_warning("Invalid planet numbers for %s: %s" % [id, elem])
if ((not parts[1].is_valid_float()) or (not parts[2].is_valid_float())):
push_warning(("Invalid planet numbers for %s: %s" % [id, elem]))
continue
var count: float = float(parts[1])
var chance: float = float(parts[2])
if not is_finite(count) or not is_finite(chance) or count < 0.0 or count != floorf(count) or chance < 0.0 or chance > 1.0:
push_warning("Planet values out of range for %s: %s" % [id, elem])
var count: Variant = E.of(parts[1])
var chance: Variant = E.of(parts[2])
if ((((((not E.finite(count)) or (not E.finite(chance))) or E.lt(count, 0)) or E.ne(count, E.floor_value(count))) or E.lt(chance, 0)) or E.gt(chance, 1)):
push_warning(("Planet values out of range for %s: %s" % [id, elem]))
continue
# Planet chances are fractions (0.01 = 1%), unlike output_or percentages.
result[parts[0].strip_edges()] = {"amount": count, "chance": chance}
+57 -66
View File
@@ -1,60 +1,51 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
## Quantity arithmetic without scene tree or global settings.
static func accumulate_whole(target: Dictionary, field: String, key: String, value: float) -> float:
if not target.has(field):
static func accumulate_whole(target: Dictionary, field: String, key: String, value: Variant) -> Variant:
if (not target.has(field)):
target[field] = {}
var expected: float = maxf(0.0, value) + target[field].get(key, 0.0)
var whole: float = floorf(snappedf(expected, 1.0e-12))
target[field][key] = maxf(0.0, expected - whole)
var expected: Variant = E.add(E.maximum(0, value), target[field].get(key, 0))
var whole: Variant = E.floor_value(expected)
target[field][key] = E.maximum(0, E.subtract(expected, whole))
return whole
static func allocate_whole(requests: Dictionary, limit: float) -> Dictionary:
var total: float = 0.0
static func allocate_whole(requests: Dictionary, limit: Variant) -> Dictionary:
var total: Variant = 0
for amount in requests.values():
total += floorf(maxf(0.0, amount))
var available: float = floorf(minf(maxf(0.0, limit), total))
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: float = 0.0
var assigned: Variant = 0
for id in requests:
var expected: float = floorf(maxf(0.0, requests[id])) * available / total if total > 0.0 else 0.0
result[id] = floorf(expected)
assigned += result[id]
order.append({"id": id, "rest": expected - result[id]})
order.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
for item: Dictionary in order:
if assigned >= available:
var expected: Variant = (E.divide(E.multiply(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 result[item.id] < floorf(requests[item.id]):
result[item.id] += 1.0
assigned += 1.0
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 production_step(costs: Array) -> float:
var step: float = 1.0
for value in costs:
var cost: float = maxf(0.0, value)
if cost == 0.0:
continue
var decimal: String = String.num(cost, 12)
var point: int = decimal.find(".")
var digits: int = decimal.length() - point - 1 if point >= 0 else 0
var denominator: float = pow(10.0, digits)
var numerator: float = roundf(cost * denominator)
if numerator == 0.0:
return INF
denominator /= whole_gcd(numerator, denominator)
step = step / whole_gcd(step, denominator) * denominator
static func production_step(costs: Array):
var step = 1
for cost in costs:
if E.le(cost, 0): continue
var denominator = E.make(E.parts(cost)[1])
step = E.multiply(E.divide(step, whole_gcd(step, denominator)), denominator)
return step
static func whole_gcd(a: float, b: float) -> float:
while b > 0.0:
var remainder: float = fmod(a, b)
static func whole_gcd(a: Variant, b: Variant) -> Variant:
while E.gt(b, 0):
var remainder: Variant = E.remainder(a, b)
a = b
b = remainder
return a
@@ -62,50 +53,50 @@ static func whole_gcd(a: float, b: float) -> float:
static func normalize_distribution(values: Dictionary) -> Dictionary:
var normalized_distribution: Dictionary = {}
var distribution_total: float = 0
var distribution_total: Variant = 0
for id in values:
if values[id] > 0:
distribution_total += values[id]
if E.gt(values[id], 0):
distribution_total = E.add(distribution_total, values[id])
normalized_distribution[id] = values[id]
for id in normalized_distribution:
normalized_distribution[id] /= distribution_total
normalized_distribution[id] = E.divide(normalized_distribution[id], distribution_total)
return normalized_distribution
static func sum(pop: Dictionary) -> float:
var result: float = 0.0
for n in pop.values(): result += n
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: float, weights: Dictionary) -> Dictionary:
static func split(remainders: Dictionary, key: String, count: Variant, weights: Dictionary) -> Dictionary:
var result: Dictionary = {}
var sum_weights: float = sum(weights)
if count <= 0.0 or sum_weights <= 0.0: return result
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: float = 0.0
var positive_sum: Variant = 0
for id in weights:
expected[id] = count * weights[id] / sum_weights + remainders.get(key + ":" + id, 0.0)
positive_sum += maxf(0.0, expected[id])
expected[id] = E.add(E.divide(E.multiply(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: float = 0.0
var assigned: Variant = 0
for id in weights:
var normalized: float = maxf(0.0, expected[id]) * count / positive_sum if positive_sum > 0.0 else 0.0
result[id] = floorf(normalized)
assigned += result[id]
order.append({"id": id, "rest": normalized - result[id]})
order.sort_custom(func(a, b): return a.rest > b.rest if a.rest != b.rest else str(a.id) < str(b.id))
var normalized: Variant = (E.divide(E.multiply(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 assigned >= count: break
result[entry.id] += 1.0
assigned += 1.0
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[key + ":" + id] = clampf(expected[id] - result[id], -1.0, 1.0)
remainders[E.add(E.add(key, ":"), id)] = E.clamp_value(E.subtract(expected[id], result[id]), E.neg(1), 1)
return result
static func accumulate_rate(remainders: Dictionary, key: String, count: float, probability: float) -> float:
var expected: float = count * maxf(0.0, probability) + remainders.get(key, 0.0)
var actual: float = floorf(snappedf(expected, 1e-10))
remainders[key] = maxf(0.0, expected - actual)
static func accumulate_rate(remainders: Dictionary, key: String, count: Variant, probability: Variant) -> Variant:
var expected: Variant = E.add(E.multiply(count, E.maximum(0, probability)), remainders.get(key, 0))
var actual: Variant = E.floor_value(expected)
remainders[key] = E.maximum(0, E.subtract(expected, actual))
return actual
+16 -10
View File
@@ -1,13 +1,19 @@
extends RefCounted
## datetime is accumulated time; year is the last processed tenth of a year.
const STATE_VERSION: String = "0.1.1"
const E = preload("res://world/owniverse/math/exact.gd")
const STATE_VERSION: String = "0.2.0"
const TICKS_PER_YEAR: int = 1000000000
static func processed_year(datetime: float) -> float:
# Remove only sub-nanotick accumulation noise at decimal boundaries.
# A relative epsilon would incorrectly advance large ages by whole years.
return floorf(snappedf(datetime * 10.0, 1e-9)) / 10.0
static func processed_year(datetime):
return E.divide(E.floor_value(E.multiply(datetime, 10)), 10)
static func elapsed_years(from_year: float, to_year: float) -> float:
# Subtract integer tenths before returning to float years. Otherwise 0.3-0.2
# can fall below 0.1 and postpone a whole unit of resource production.
return (roundf(to_year * 10.0) - roundf(from_year * 10.0)) / 10.0
static func elapsed_years(from_year, to_year):
return E.subtract(to_year, from_year)
## Frame fractions stay small; the universe age is an exact integer tick count.
static func advance(time: Dictionary, delta: float, speed: int) -> Dictionary:
var ticks = E.round_value(E.multiply(time.datetime, TICKS_PER_YEAR))
var increment: float = delta * pow(10.0, speed - 1) * TICKS_PER_YEAR + float(time.get("tick_remainder", 0.0))
var whole: float = roundf(increment) if absf(increment - roundf(increment)) < 1e-6 else floorf(increment)
ticks = E.add(ticks, E.of(whole))
var datetime = E.divide(ticks, TICKS_PER_YEAR)
return {"datetime": datetime, "year": processed_year(datetime), "ticks": ticks, "tick_remainder": increment - whole}
+50 -51
View File
@@ -1,118 +1,117 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
const QuantityMath = preload("res://world/owniverse/quantity_math.gd")
## Testable optimization switch; both paths preserve the same event order.
var direct_completions: bool = true
func add_output_terms(outputs: Dictionary, terms: String, count: float) -> void:
if count <= 0.0:
func add_output_terms(outputs: Dictionary, terms: String, count: Variant) -> void:
if E.le(count, 0):
return
for term in terms.split("&"):
var parts: PackedStringArray = term.strip_edges().split(":")
var id: String = parts[0].strip_edges()
var amount: float = float(parts[1]) if parts.size() == 2 else 1.0
if id != "" and amount > 0.0:
outputs[id] = outputs.get(id, 0.0) + count * amount
var amount: Variant = (E.of(parts[1]) if E.eq(parts.size(), 2) else 1)
if (E.ne(id, "") and E.gt(amount, 0)):
outputs[id] = E.add(outputs.get(id, 0), E.multiply(count, amount))
func evolution_products(target: Dictionary, entity: OwniverseEntity, count: float) -> Dictionary:
if not entity.life_rule.is_empty():
func evolution_products(target: Dictionary, entity: OwniverseEntity, count: Variant) -> Dictionary:
if (not entity.life_rule.is_empty()):
return world.life.life_products(target, entity, count)
var outputs: Dictionary = {}
for id in entity.output_and:
outputs[id] = count * maxf(0.0, entity.output_and[id])
if not entity.output_or.is_empty():
outputs[id] = E.multiply(count, E.maximum(0, entity.output_and[id]))
if (not entity.output_or.is_empty()):
# The parser stores the percentage after '%', e.g. 0.1 means 0.1 percent.
var chance: float = clampf(entity.output_or.get("chance", 0.0) / 100.0 * WorldParameters.effective(target, "Exp"), 0.0, 1.0)
if not target.has("evolution_remainders"):
var chance: Variant = E.clamp_value(E.multiply(E.divide(entity.output_or.get("chance", 0), 100), WorldParameters.effective(target, "Exp")), 0, 1)
if (not target.has("evolution_remainders")):
target.evolution_remainders = {}
var expected: float = count * chance + target.evolution_remainders.get(entity.id, 0.0)
var positive: float = minf(count, floorf(snappedf(expected, 1.0e-12)))
target.evolution_remainders[entity.id] = maxf(0.0, expected - positive)
var expected: Variant = E.add(E.multiply(count, chance), target.evolution_remainders.get(entity.id, 0))
var positive: Variant = E.minimum(count, E.floor_value(expected))
target.evolution_remainders[entity.id] = E.maximum(0, E.subtract(expected, positive))
add_output_terms(outputs, str(entity.output_or.get("positive", "")), positive)
add_output_terms(outputs, str(entity.output_or.get("negative", "")), count - positive)
add_output_terms(outputs, str(entity.output_or.get("negative", "")), E.subtract(count, positive))
# Entity.blast is authoritative even if imported output still contains
# blast:N. Zero disables the event; never add both representations.
outputs.erase("blast")
if entity.blast > 0:
outputs["blast"] = count * entity.blast * WorldParameters.effective(target, "Exp")
if E.gt(entity.blast, 0):
outputs["blast"] = E.multiply(E.multiply(count, entity.blast), WorldParameters.effective(target, "Exp"))
return outputs
func create_planets(target: Dictionary, star_id: String, count: float,
from_year: float, to_year: float, metallicity: float) -> void:
if count <= 0.0 or world.catalog.entities[star_id].planets.is_empty():
func create_planets(target: Dictionary, star_id: String, count: Variant, from_year: Variant, to_year: Variant, metallicity: Variant) -> void:
if (E.le(count, 0) or world.catalog.entities[star_id].planets.is_empty()):
return
if not target.binds.has(star_id):
if (not target.binds.has(star_id)):
target.binds[star_id] = {}
for id in world.catalog.entities[star_id].planets:
if not world.catalog.entities.has(id):
if (not world.catalog.entities.has(id)):
continue
var rule: Dictionary = world.catalog.entities[star_id].planets[id]
var amount: float = QuantityMath.accumulate_whole(target, "planet_remainders", star_id + ":" + id,
count * rule.amount * rule.chance * maxf(0.0, metallicity))
var amount: Variant = QuantityMath.accumulate_whole(target, "planet_remainders", E.add(E.add(star_id, ":"), id), E.multiply(E.multiply(E.multiply(count, rule.amount), rule.chance), E.maximum(0, metallicity)))
amount = world.ledger.credit(target, id, amount)
target.amount[id] -= amount
target.binds[star_id][id] = target.binds[star_id].get(id, 0.0) + amount
target.amount[id] = E.subtract(target.amount[id], amount)
target.binds[star_id][id] = E.add(target.binds[star_id].get(id, 0), amount)
world.batches.append_batch(target, id, amount, from_year, to_year)
func stellar_successors(products: Dictionary) -> Dictionary:
var weights: Dictionary = {}
for id in products:
if world.catalog.entities.has(id) and (world.catalog.entities[id].group_as in ["Star", "BH"] or world.catalog.entities[id].event_group == "SNGRBB" or world.catalog.entities[id].count_as == "BD"):
if (world.catalog.entities.has(id) and (((E.contains(["Star", "BH"], world.catalog.entities[id].group_as) or E.eq(world.catalog.entities[id].event_group, "SNGRBB")) or E.eq(world.catalog.entities[id].count_as, "BD")))):
weights[id] = products[id]
return QuantityMath.normalize_distribution(weights)
func destroy_planets(target: Dictionary, owner_id: String, ratio: float, level: int, cause: String = "local") -> void:
if ratio <= 0.0 or level not in [1, 2]:
func destroy_planets(target: Dictionary, owner_id: String, ratio: Variant, level: int, cause: String = "local") -> void:
if (E.le(ratio, 0) or not E.contains([1, 2], level)):
return
var held: Dictionary = target.binds.get(owner_id, {})
var physical: Dictionary = world.ledger.physical_amounts(target)
var rocky: float = 0.0
var rocky: Variant = 0
for id in held.keys():
if not world.catalog.entities.has(id) or world.catalog.entities[id].group_as != "Planet":
if ((not world.catalog.entities.has(id)) or E.ne(world.catalog.entities[id].group_as, "Planet")):
continue
if level == 1 and not world.catalog.entities[id].blastability:
if (E.eq(level, 1) and (not world.catalog.entities[id].blastability)):
continue
var key: String = cause + ":" + str(level) + ":" + owner_id + ":" + id
var destroyed: float = minf(held[id], QuantityMath.accumulate_whole(target, "destruction_remainders", key, held[id] * minf(1.0, ratio)))
if destroyed == held[id]:
target.destruction_remainders[key] = 0.0
if destroyed <= 0.0:
var key: String = E.add(E.add(E.add(E.add(E.add(E.add(cause, ":"), str(level)), ":"), owner_id), ":"), id)
var destroyed: Variant = E.minimum(held[id], QuantityMath.accumulate_whole(target, "destruction_remainders", key, E.multiply(held[id], E.minimum(1, ratio))))
if E.eq(destroyed, held[id]):
target.destruction_remainders[key] = 0
if E.le(destroyed, 0):
continue
held[id] -= destroyed
world.batches.scale_batches(target, id, 1.0 - destroyed / physical[id])
if level == 1:
rocky += destroyed
if rocky > 0.0:
held[id] = E.subtract(held[id], destroyed)
world.batches.scale_batches(target, id, E.subtract(1, E.divide(destroyed, physical[id])))
if E.eq(level, 1):
rocky = E.add(rocky, destroyed)
if E.gt(rocky, 0):
rocky = world.ledger.credit(target, "RP", rocky)
target.amount.RP -= rocky
held.RP = held.get("RP", 0.0) + rocky
target.amount.RP = E.subtract(target.amount.RP, rocky)
held.RP = E.add(held.get("RP", 0), rocky)
func apply_blast(target: Dictionary, power: float, environment: Dictionary) -> void:
var hits: float = QuantityMath.accumulate_whole(target, "blast_remainders", "hits", power * environment.blast)
func apply_blast(target: Dictionary, power: Variant, environment: Dictionary) -> void:
var hits: Variant = QuantityMath.accumulate_whole(target, "blast_remainders", "hits", E.multiply(power, environment.blast))
var quantities: Dictionary = world.ledger.physical_amounts(target)
var weights: Dictionary = {}
for id in world.catalog.entities:
if not world.catalog.entities[id].planets.is_empty() and quantities.get(id, 0.0) > 0.0:
if ((not world.catalog.entities[id].planets.is_empty()) and E.gt(quantities.get(id, 0), 0)):
weights[id] = quantities[id]
var distribution: Dictionary = QuantityMath.normalize_distribution(weights)
# Fractional exposure is deterministic; destruction itself remains whole.
for id in distribution:
destroy_planets(target, id, hits * distribution[id] / quantities[id] * environment.density, 1, "blast")
destroy_planets(target, id, E.multiply(E.divide(E.multiply(hits, distribution[id]), quantities[id]), environment.density), 1, "blast")
func uses_short_lived_fast_path(id: String) -> bool:
if not direct_completions: return false
if (not direct_completions): return false
# Only terminal stellar events with linear outputs and no living successors.
var entity: OwniverseEntity = world.catalog.entities[id]
if entity.event_group != "SNGRBB" or not entity.output_or.is_empty() or not entity.life_rule.is_empty():
if ((E.ne(entity.event_group, "SNGRBB") or (not entity.output_or.is_empty())) or (not entity.life_rule.is_empty())):
return false
for successor in entity.output_and:
if world.catalog.entities.has(successor) and world.catalog.entities[successor].lifespan > 0.0:
if (world.catalog.entities.has(successor) and E.gt(world.catalog.entities[successor].lifespan, 0)):
return false
return true
+24 -27
View File
@@ -1,42 +1,39 @@
extends "res://world/owniverse/simulation_module.gd"
const E = preload("res://world/owniverse/math/exact.gd")
const WorldParameters = preload("res://world/owniverse/world_parameters.gd")
func stellar_environment(source: Dictionary) -> Dictionary:
var quantities: Dictionary = world.ledger.physical_amounts(source)
# SMBH is a self-group in GI; its quantity stores mass, not object count.
var mass: float = source.amount.get("SMBH", 0.0)
var volume: float = quantities.get("S", 0.0)
var mass: Variant = source.amount.get("SMBH", 0)
var volume: Variant = quantities.get("S", 0)
for id in quantities:
var entity: OwniverseEntity = world.catalog.entities[id]
if id in ["H", "He", "Met", "SMBH"]:
mass += quantities[id]
if E.contains(["H", "He", "Met", "SMBH"], id):
mass = E.add(mass, quantities[id])
else:
mass += quantities[id] * maxf(0.0, entity.mass)
volume += quantities[id] * maxf(0.0, entity.space)
volume += quantities[id] * maxf(0.0, entity.voids) * 1.0e22
var metallicity: float = 0.0
if mass > 0.0:
metallicity = maxf(0.0, 0.4 * log(100.0 * source.amount.get("Met", 0.0) / mass + 0.11) + 0.88)
var base_mass: float = 65536.0 * (pow(mass, 0.1) if mass > 0.0 and mass < 1.0e10 else (10.0 if mass >= 1.0e10 else 1.0))
return {
"metallicity": metallicity,
"density": 1.23456e-7 * sqrt(base_mass + mass) / pow(maxf(1.0, volume), 1.0 / 6.0),
"blast": clampf(source.amount.get("S", 0.0) / volume, 0.0, 1.0) if volume > 0.0 else 1.0,
}
mass = E.add(mass, E.multiply(quantities[id], E.maximum(0, entity.mass)))
volume = E.add(volume, E.multiply(quantities[id], E.maximum(0, entity.space)))
volume = E.add(volume, E.multiply(E.multiply(quantities[id], E.maximum(0, entity.voids)), E.of("1.0e22")))
var metallicity: Variant = 0
if E.gt(mass, 0):
metallicity = E.maximum(0, E.add(E.multiply(E.of("0.4"), E.logarithm(E.add(E.divide(E.multiply(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)))))
return {"metallicity": metallicity, "density": E.divide(E.multiply(E.of("1.23456e-7"), E.square_root(E.add(base_mass, mass))), E.power(E.maximum(1, volume), E.divide(1, 6))), "blast": (E.clamp_value(E.divide(source.amount.get("S", 0), volume), 0, 1) if E.gt(volume, 0) else 1)}
func modify_world_parameter(key: String, change: float) -> void:
if key not in ["Bur", "Exp", "Agg"] or not is_finite(change): return
var old_multiplier: float = WorldParameters.effective(world.state, key)
if not world.state.has("world_parameters"): world.state.world_parameters = WorldParameters.defaults()
world.state.world_parameters[key] = clampf(WorldParameters.value(world.state, key) + change, 1.0, 19.0)
if key == "Bur":
var ratio: float = old_multiplier / WorldParameters.effective(world.state, key)
var now: float = world.state.time.year
func modify_world_parameter(key: String, change: Variant) -> void:
if (not E.contains(["Bur", "Exp", "Agg"], key) or (not E.finite(change))): return
var old_multiplier: Variant = WorldParameters.effective(world.state, key)
if (not world.state.has("world_parameters")): world.state.world_parameters = WorldParameters.defaults()
world.state.world_parameters[key] = E.clamp_value(E.add(WorldParameters.value(world.state, key), change), 1, 19)
if E.eq(key, "Bur"):
var ratio: Variant = E.divide(old_multiplier, WorldParameters.effective(world.state, key))
var now: Variant = world.state.time.year
for id in world.state.batches:
if not world.catalog.entities.has(id) or world.catalog.entities[id].group_as != "Star": continue
for batch: Dictionary in world.state.batches[id]:
if ((not world.catalog.entities.has(id)) or E.ne(world.catalog.entities[id].group_as, "Star")): continue
for batch : Dictionary in world.state.batches[id]:
# Only future survival time changes. Birth dates and the fractional
# withdrawal carry remain intact; already elapsed time is not replayed.
for field in ["start_year", "end_year", "processed_until"]:
if batch.has(field): batch[field] = now + maxf(0.0, batch[field] - now) * ratio
if batch.has(field): batch[field] = E.add(now, E.multiply(E.maximum(0, E.subtract(batch[field], now)), ratio))
+67 -73
View File
@@ -1,112 +1,106 @@
extends RefCounted
const E = preload("res://world/owniverse/math/exact.gd")
const INITIAL_ENERGY = 1.0e27
static var INITIAL_ENERGY = E.of("1.0e27")
const TRENDS = ["Antitheism", "Integration", "Xenocide", "Reconstruction", "Spirituality"]
const WEIGHTS = {
"m": [0, 2, 2, 0, 6], "l": [6, 1, 1, 2, 0],
"w": [2, 0, 6, 0, 2], "d": [0, 6, 0, 1, 3],
"c": [1, 3, 3, 3, 0], "b": [3, 0, 0, 6, 1],
}
const WEIGHTS = {"m": [0, 2, 2, 0, 6], "l": [6, 1, 1, 2, 0], "w": [2, 0, 6, 0, 2], "d": [0, 6, 0, 1, 3], "c": [1, 3, 3, 3, 0], "b": [3, 0, 0, 6, 1]}
static func defaults() -> Dictionary:
return {"Bur": 10.0, "Exp": 10.0, "Agg": 10.0,
"Federation": 1.0, "Enlightenment": 1.0, "Order": 1.0}
return {"Bur": 10, "Exp": 10, "Agg": 10, "Federation": 1, "Enlightenment": 1, "Order": 1}
static func trend_defaults() -> Dictionary:
return {"Antitheism": 0.0, "Integration": 0.0, "Xenocide": 0.0,
"Reconstruction": 0.0, "Spirituality": 0.0}
return {"Antitheism": 0, "Integration": 0, "Xenocide": 0, "Reconstruction": 0, "Spirituality": 0}
static func value(source: Dictionary, key: String) -> float:
return source.get("world_parameters", {}).get(key, defaults().get(key, 1.0))
static func value(source: Dictionary, key: String) -> Variant:
return source.get("world_parameters", {}).get(key, defaults().get(key, 1))
static func effective(source: Dictionary, key: String) -> float:
return pow(10.0, (clampf(value(source, key), 1.0, 19.0) - 10.0) / 9.0)
static func effective(source: Dictionary, key: String) -> Variant:
return E.power(10, E.divide((E.subtract(E.clamp_value(value(source, key), 1, 19), 10)), 9))
static func life_modifiers(source: Dictionary) -> Dictionary:
var aggression: float = effective(source, "Agg")
return {"progress": aggression,
"degradation": (1.0 - (1.0 - aggression) / 20.0) / value(source, "Order"),
"war": 1.0 - (1.0 - aggression) / 5.0,
"colonization": 1.0 - (1.0 - aggression) / 2.0}
var aggression: Variant = effective(source, "Agg")
return {"progress": aggression, "degradation": E.divide((E.subtract(1, E.divide((E.subtract(1, aggression)), 20))), value(source, "Order")), "war": E.subtract(1, E.divide((E.subtract(1, aggression)), 5)), "colonization": E.subtract(1, E.divide((E.subtract(1, aggression)), 2))}
static func production(source: Dictionary, entity) -> float:
if entity.event_group == "SNGRBB":
static func production(source: Dictionary, entity) -> Variant:
if E.eq(entity.event_group, "SNGRBB"):
return effective(source, "Exp")
if entity.group_as == "Star":
var burn: float = effective(source, "Bur")
return sqrt(burn) if burn > 1.0 else burn
return 1.0
if E.eq(entity.group_as, "Star"):
var burn: Variant = effective(source, "Bur")
return (E.square_root(burn) if E.gt(burn, 1) else burn)
return 1
static func density_display(density: float) -> Dictionary:
if density <= 1.0e-5:
static func density_display(density: Variant) -> Dictionary:
if E.le(density, E.of("1.0e-5")):
# Unity had log10(density) - 7 here; +7 maps 1e-7..1e-5 to 0..1.
return {"mode": "DensityRip", "position": clampf((log(maxf(density, 1e-7)) / log(10.0) + 7.0) / 2.0, 0.0, 1.0)}
if density >= 0.9:
return {"mode": "DensityCrunch", "position": clampf((density - 0.9) * 10.0, 0.0, 1.0)}
return {"mode": "Density", "position": clampf(log(density * 1.0e5) / log(9.0e4), 0.0, 1.0)}
return {"mode": "DensityRip", "position": E.clamp_value(E.divide((E.add(E.divide(E.logarithm(E.maximum(density, E.of("1e-7"))), E.logarithm(10)), 7)), 2), 0, 1)}
if E.ge(density, E.of("0.9")):
return {"mode": "DensityCrunch", "position": E.clamp_value(E.multiply((E.subtract(density, E.of("0.9"))), 10), 0, 1)}
return {"mode": "Density", "position": E.clamp_value(E.divide(E.logarithm(E.multiply(density, 100000)), E.logarithm(90000)), 0, 1)}
## Unity ApocalypseCheck thresholds and WarningUI colors; visual warning only.
static func density_warning_color(density: float) -> Color:
if density >= 1.0: return Color.html("00c0ffff")
if density >= 0.97: return Color.html("20b0dfbf")
if density >= 0.9: return Color.html("40a0c07f")
if density <= 1.0e-7: return Color.html("ff0000ff")
if density <= 1.0e-6: return Color.html("df2020bf")
if density <= 1.0e-5: return Color.html("c040407f")
return Color(0.0, 0.0, 0.0, 0.0)
static func density_warning_color(density: Variant) -> Color:
if E.ge(density, 1): return Color.html("00c0ffff")
if E.ge(density, E.of("0.97")): return Color.html("20b0dfbf")
if E.ge(density, E.of("0.9")): return Color.html("40a0c07f")
if E.le(density, E.of("1.0e-7")): return Color.html("ff0000ff")
if E.le(density, E.of("1.0e-6")): return Color.html("df2020bf")
if E.le(density, E.of("1.0e-5")): return Color.html("c040407f")
return Color(0, 0, 0, 0)
## Integrate the population present at the start of the simulation interval.
## Thresholds use whole event counts; no per-year loop at high speeds.
static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary, years: float) -> void:
if years <= 0.0: return
if not target.has("world_parameters"): target.world_parameters = defaults()
if not target.has("trends"): target.trends = trend_defaults()
var rates: Array = [0.0, 0.0, 0.0, 0.0, 0.0]
static func advance(previous: Dictionary, target: Dictionary, rules: Dictionary, years: Variant) -> void:
if E.le(years, 0): return
if (not target.has("world_parameters")): target.world_parameters = defaults()
if (not target.has("trends")): target.trends = trend_defaults()
var rates: Array = [0, 0, 0, 0, 0]
var stages: Array = []
for id in rules:
if str(rules[id].home) != "": stages.append(id)
if E.ne(str(rules[id].home), ""): stages.append(id)
stages.sort()
var weight: float = 1.0
var weight: Variant = 1
for id in stages:
var population: Dictionary = previous.get("ethics", {}).get(id, {})
var count: float = 0.0
for n in population.values(): count += n
if count > 0.0:
var count: Variant = 0
for n in population.values(): count = E.add(count, n)
if E.gt(count, 0):
for ethic in population:
for i in TRENDS.size():
rates[i] += population[ethic] / count * WEIGHTS[ethic][i] * weight
weight *= 2.0
rates[i] = E.add(rates[i], E.multiply(E.multiply(E.divide(population[ethic], count), WEIGHTS[ethic][i]), weight))
weight = E.multiply(weight, 2)
for i in TRENDS.size():
var name: String = TRENDS[i]
var accumulated: float = target.trends[name] + rates[i] * years * 1.0e-9
var events: float = floorf(snappedf(accumulated, 1.0e-12))
target.trends[name] = maxf(0.0, accumulated - events)
if events <= 0.0: continue
var key: String = "trend:" + name
target.civilization_stats[key] = target.civilization_stats.get(key, 0.0) + events
if name == "Integration": _integration(target, events)
var accumulated: Variant = E.add(target.trends[name], E.multiply(E.multiply(rates[i], years), E.of("1.0e-9")))
var events: Variant = E.floor_value(accumulated)
target.trends[name] = E.maximum(0, E.subtract(accumulated, events))
if E.le(events, 0): continue
var key: String = E.add("trend:", name)
target.civilization_stats[key] = E.add(target.civilization_stats.get(key, 0), events)
if E.eq(name, "Integration"): _integration(target, events)
static func _integration(target: Dictionary, events: float) -> void:
static func _integration(target: Dictionary, events: Variant) -> void:
# Equal-probability outcomes in Unity. Use a saved RNG and bounded aggregate
# sampling for large steps, so thousands of triggers do not stall a frame.
var rng := RandomNumberGenerator.new()
var rng = RandomNumberGenerator.new()
rng.seed = 76123
if target.civilization_stats.has("trend_rng"):
rng.state = int(target.civilization_stats.trend_rng)
var counts: Array = [0.0, 0.0, 0.0]
if events <= 256.0:
for i in int(events): counts[rng.randi_range(0, 2)] += 1.0
rng.state = E.integer(target.civilization_stats.trend_rng)
var counts: Array = [0, 0, 0]
if E.le(events, 256):
for i in E.integer(events):
var chosen: int = rng.randi_range(0, 2)
counts[chosen] = E.add(counts[chosen], 1)
else:
counts[0] = clampf(roundf(rng.randfn(events / 3.0, sqrt(events * 2.0 / 9.0))), 0.0, events)
var rest: float = events - counts[0]
counts[1] = clampf(roundf(rng.randfn(rest / 2.0, sqrt(rest / 4.0))), 0.0, rest)
counts[2] = rest - counts[1]
counts[0] = E.clamp_value(E.round_value(E.of(rng.randfn(E.approx(E.divide(events, 3)), E.approx(E.square_root(E.divide(E.multiply(events, 2), 9)))))), 0, events)
var rest: Variant = E.subtract(events, counts[0])
counts[1] = E.clamp_value(E.round_value(E.of(rng.randfn(E.approx(E.divide(rest, 2)), E.approx(E.square_root(E.divide(rest, 4)))))), 0, rest)
counts[2] = E.subtract(rest, counts[1])
target.civilization_stats.trend_rng = rng.state
var parameters: Dictionary = target.world_parameters
parameters.Agg = maxf(1.0, parameters.Agg - counts[0] - counts[1])
parameters.Federation += counts[0] * 0.1
parameters.Enlightenment += counts[1] * 0.2
parameters.Order += counts[2] * 0.1
parameters.Agg = E.maximum(1, E.subtract(E.subtract(parameters.Agg, counts[0]), counts[1]))
parameters.Federation = E.add(parameters.Federation, E.multiply(counts[0], E.of("0.1")))
parameters.Enlightenment = E.add(parameters.Enlightenment, E.multiply(counts[1], E.of("0.2")))
parameters.Order = E.add(parameters.Order, E.multiply(counts[2], E.of("0.1")))
for i in 3:
var key: String = ["Federation", "Enlightenment", "Order"][i]
target.civilization_stats[key] = target.civilization_stats.get(key, 0.0) + counts[i]
target.civilization_stats[key] = E.add(target.civilization_stats.get(key, 0), counts[i])
+13 -8
View File
@@ -99,6 +99,7 @@ size_flags_vertical = 3
script = ExtResource("3_k83x5")
[node name="World" type="VBoxContainer" parent="Stage" unique_id=1878643860]
visible = false
layout_mode = 1
anchors_preset = 10
anchor_right = 1.0
@@ -1058,10 +1059,9 @@ texture = ExtResource("61_d36xh")
stretch_mode = 3
[node name="Credits" type="VBoxContainer" parent="Stage" unique_id=785042592]
visible = false
layout_mode = 0
offset_right = 40.0
offset_bottom = 40.0
offset_right = 1080.0
offset_bottom = 1920.0
script = ExtResource("62_o2i0n")
[node name="TopMenu" type="HBoxContainer" parent="Stage/Credits" unique_id=1983527467]
@@ -1088,15 +1088,20 @@ texture = ExtResource("62_vu83j")
custom_minimum_size = Vector2(1080, 0)
layout_mode = 2
[node name="Highlight" type="TextureRect" parent="Stage/Credits/Logo" unique_id=1035898922]
visible = false
layout_mode = 2
texture = ExtResource("64_rur7c")
[node name="Lamp" type="TextureButton" parent="Stage/Credits/Logo" unique_id=1059180893]
layout_mode = 2
texture_normal = ExtResource("65_y1rwn")
[node name="Highlight" type="TextureRect" parent="Stage/Credits/Logo" unique_id=1035898922]
visible = false
layout_mode = 2
mouse_filter = 2
texture = ExtResource("64_rur7c")
[node name="Control" type="Control" parent="Stage/Credits" unique_id=1835435348]
layout_mode = 2
size_flags_vertical = 3
[node name="ItemAmountUpdate" type="Timer" parent="Stage" unique_id=1953928024]
wait_time = 0.2
autostart = true