From 00b6192d19cb6d15a4f29755d6236f7016529ee8 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 8 Oct 2026 22:19:29 +0300 Subject: [PATCH] Refactor production request handling: implement queuing mechanism and improve production application logic --- stage/stage.gd | 7 +++- tests/test_action_values.gd | 4 ++ tests/test_frame_evolution.gd | 9 +++-- tests/test_production_transaction.gd | 8 ++++ ui/actions/actions.gd | 7 ++++ world/owniverse/object_ledger.gd | 56 +++++++++++++++++----------- world/owniverse/owniverse.gd | 55 +++++++++++++++++++++------ 7 files changed, 107 insertions(+), 39 deletions(-) diff --git a/stage/stage.gd b/stage/stage.gd index 61bee4c..683192b 100644 --- a/stage/stage.gd +++ b/stage/stage.gd @@ -315,7 +315,11 @@ func _page_action_pressed(page_id: int, action_amount) -> void: var item_id: String = g_active_items[page_id].item_id if E.gt(action_amount, 0): - owniverse.request_production(item_id, action_amount) + if owniverse.request_production(item_id, action_amount): + page_actions[page_id].mark_pending(action_amount) + +func _on_owniverse_production_applied(_item_id: String) -> void: + active_actions_dirty = true #endregion @@ -345,6 +349,7 @@ func _enable_interface_sounds() -> void: func create_new_universe() -> void: var previous: Owniverse = owniverse owniverse = Owniverse.new() + owniverse.production_applied.connect(_on_owniverse_production_applied) var inited_result: Dictionary = init_enitities() locked_items = inited_result.locked_items diff --git a/tests/test_action_values.gd b/tests/test_action_values.gd index 5b0f4cc..e373751 100644 --- a/tests/test_action_values.gd +++ b/tests/test_action_values.gd @@ -38,6 +38,10 @@ func run() -> void: 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") + actions.mark_pending(123) + check(actions.action_labels[2].text == "…" and actions.action_labels[2].get_parent().get_node("TextureButton").disabled, "Queued action is visibly pending and cannot be duplicated") + actions.set_actions("step", 123, func ( request ) : return E.multiply(E.floor_value(E.divide(request, 5)), 5)) + check(actions.action_labels[2].text == "120" and not actions.action_labels[2].get_parent().get_node("TextureButton").disabled, "Action refresh clears pending state") var emitted: Array = [] actions.action_pressed.connect(func ( _page , amount ) : emitted.append(amount)) actions._on_action_button_pressed(2) diff --git a/tests/test_frame_evolution.gd b/tests/test_frame_evolution.gd index 010f7db..faf23c8 100644 --- a/tests/test_frame_evolution.gd +++ b/tests/test_frame_evolution.gd @@ -38,13 +38,14 @@ func run(): sliced.evolute_frame(0.2, self) check(sliced.frame_busy, "Heavy transaction yields before publishing") check(E.eq(sliced.state.time.year, 0), "Published clock stays at completed transaction") - sliced.request_production("H", 1, true) + check(sliced.request_production("H", 1, true), "Production requested during a frame is queued") + check(sliced.frame_checkpoint().productions.size() == 1, "Queued production remains part of the save checkpoint") check(E.eq(sliced.state.amount.H, initial_h), "Player action waits for transaction commit") sliced.evolute_frame(0.01, self) var requested: Dictionary = sliced.frame_clock.duplicate() sliced.speed = 3 check(sliced.speed == 3 and sliced.state.speed == 6, "Speed change is visible and cannot mutate an unfinished transaction") - while sliced.frame_busy: await process_frame + while sliced.frame_busy or sliced.production_queue_busy: await process_frame direct.evolute(0.2) direct.production.produce_hs("H", 1) direct.speed = 3 @@ -54,7 +55,7 @@ func run(): sliced.evolute_frame(0.189, self) var second_clock: Dictionary = Clock.advance(Clock.advance(requested, 0.001, 3), 0.189, 3) var active: Dictionary = sliced.frame_active_clock.duplicate() - while sliced.frame_busy: await process_frame + while sliced.frame_busy or sliced.production_queue_busy: await process_frame direct.evolution.evolute_to_datetime(active.datetime, active) check(E.eq(direct.state, sliced.state), "Bounded frame transaction matches its exact target clock") check(E.eq(sliced.frame_clock, second_clock), "All elapsed time during a busy frame remains in the requested clock") @@ -77,7 +78,7 @@ func run(): expected.state = published.duplicate(true) resumed.restore_frame_checkpoint(checkpoint) resumed.evolute_frame(0.0, self) - while resumed.frame_busy: await process_frame + while resumed.frame_busy or resumed.production_queue_busy: await process_frame expected.evolution.evolute_to_datetime(expected_clock.datetime, expected_clock) expected.production.produce_hs("H", 123) expected.speed = 3 diff --git a/tests/test_production_transaction.gd b/tests/test_production_transaction.gd index 96b389f..8076c6f 100644 --- a/tests/test_production_transaction.gd +++ b/tests/test_production_transaction.gd @@ -72,6 +72,14 @@ func run() -> void: 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") + + for member in world.catalog.members("Star"): world.state.amount[member] = 0 + var star_stocks: Array = ["1234567890123456789", "876543210987654321", "3141592653589793238", "2718281828459045235", "1414213562373095048", "1732050807568877293", "2236067977499789696", "2449489742783178098", "3162277660168379331"] + for i in star_stocks.size(): world.state.amount[world.catalog.members("Star")[i]] = E.of(star_stocks[i]) + var scd_request: Variant = E.of("1e15") + var scd_created: Variant = world.production.produce("SCD", scd_request) + check(E.eq(scd_created, scd_request), "Large SCD production distributes a whole star quota exactly") + check(E.eq(world.state.amount.SCD, scd_request), "Large SCD creation is committed") print(("Transaction: %d checks, %d failures" % [checks, failures])) dispose(world) quit((1 if failures else 0)) diff --git a/ui/actions/actions.gd b/ui/actions/actions.gd index 8a14dac..5cc59c7 100644 --- a/ui/actions/actions.gd +++ b/ui/actions/actions.gd @@ -58,6 +58,13 @@ func _set_action_values(values: Array, displayed: Array) -> void: action_labels[ind].text = text action_labels[ind].get_parent().get_node("TextureButton").disabled = E.le(values[ind], 0) +func mark_pending(amount: Variant) -> void: + for ind in range(5): + if not E.eq(action_values[ind], amount): continue + action_labels[ind].text = "…" + action_labels[ind].get_parent().get_node("TextureButton").disabled = true + return + 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))): diff --git a/world/owniverse/object_ledger.gd b/world/owniverse/object_ledger.gd index 7bc61ea..1d240da 100644 --- a/world/owniverse/object_ledger.gd +++ b/world/owniverse/object_ledger.gd @@ -63,29 +63,41 @@ func expand_requirements(source: Dictionary, requirements: Dictionary) -> Dictio 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) - var distribution: Dictionary = QuantityMath.normalize_distribution(quantities) + quantities[member] = E.maximum(0, target.amount.get(member, 0)) var taken: Dictionary = {} - 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)) - if discrete: - total = E.floor_value(total) - var shares: Array = [] - var assigned: Variant = 0 - for member in distribution: - 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 (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] = E.add(taken[share.id], 1) - assigned = E.add(assigned, 1) + var stock_total: Variant = 0 + for quantity in quantities.values(): stock_total = E.add(stock_total, quantity) + var total: Variant = E.minimum(amount, stock_total) + var discrete: bool = true + for member in quantities: + discrete = discrete and is_discrete(member) + if discrete: total = E.floor_value(total) + if E.le(total, 0) or E.le(stock_total, 0): return taken + if E.eq(total, stock_total): + taken = quantities + elif discrete: + var shares: Array = [] + var assigned: Variant = 0 + for member in quantities: + var expected: Variant = E.mul_div(total, quantities[member], stock_total) + var whole: Variant = E.floor_value(expected) + taken[member] = whole + assigned = E.add(assigned, whole) + shares.append({"id": member, "rest": E.subtract(expected, whole)}) + 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))) + var missing: Variant = E.subtract(total, assigned) + for share: Dictionary in shares: + if E.le(missing, 0): break + var member: String = share.id + if E.lt(taken[member], quantities[member]): + taken[member] = E.add(taken[member], 1) + missing = E.subtract(missing, 1) + assert(E.eq(missing, 0), "Discrete resource allocation must exhaust its requested amount") + else: + var distribution: Dictionary = QuantityMath.normalize_distribution(quantities) + for member in distribution: + taken[member] = E.multiply(total, distribution[member]) + for member in taken: target.amount[member] = E.maximum(0, E.subtract(target.amount.get(member, 0), taken[member])) return taken diff --git a/world/owniverse/owniverse.gd b/world/owniverse/owniverse.gd index 3aa4cb5..0cc64b7 100644 --- a/world/owniverse/owniverse.gd +++ b/world/owniverse/owniverse.gd @@ -1,5 +1,6 @@ extends Node class_name Owniverse +signal production_applied(id: String) 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") @@ -97,10 +98,13 @@ var frame_clock: Dictionary = {} var frame_budget: RefCounted var _requested_speed: int = 0 var _queued_productions: Array = [] +var production_queue_busy: bool = false +var _production_queue_cancelled: bool = false var frame_active_clock: Dictionary = {} var _resume_clock: Dictionary = {} var frame_elapsed_seconds: float = 0.0 const frame_step_seconds: float = 0.2 +const production_slice_usec: int = 4000 ## Keep the requested clock advancing while a transaction spans render frames. func evolute_frame(delta: float, tree: SceneTree) -> void: @@ -108,8 +112,11 @@ func evolute_frame(delta: float, tree: SceneTree) -> void: if frame_clock.is_empty(): frame_clock = state.time.duplicate() frame_clock = SimulationTime.advance(frame_clock, delta, speed) frame_elapsed_seconds += delta - if frame_busy: return + if frame_busy or production_queue_busy: return var next_clock: Dictionary = _resume_clock + if next_clock.is_empty() and not _queued_productions.is_empty(): + _start_production_drain(tree) + return if next_clock.is_empty(): # Cadence limits starts; each transaction consumes the whole pending interval. if frame_elapsed_seconds + 1e-12 < frame_step_seconds: return @@ -121,13 +128,37 @@ func evolute_frame(delta: float, tree: SceneTree) -> void: _resume_clock = {} evolution.run_frame(frame_active_clock, frame_budget) -func request_production(id: String, amount: Variant, resource: bool = false) -> void: - if frame_busy: +func request_production(id: String, amount: Variant, resource: bool = false) -> bool: + if frame_busy or production_queue_busy: _queued_productions.append({"id": id, "amount": amount, "resource": resource}) - elif resource: - production.produce_hs(id, amount) + return true + _apply_production({"id": id, "amount": amount, "resource": resource}) + return false + +func _apply_production(command: Dictionary) -> void: + if command.resource: + production.produce_hs(command.id, command.amount) else: - production.produce(id, amount) + production.produce(command.id, command.amount) + production_applied.emit(command.id) + +func _start_production_drain(tree: SceneTree) -> void: + if production_queue_busy or _queued_productions.is_empty(): return + production_queue_busy = true + _production_queue_cancelled = false + _drain_productions(tree) + +func _drain_productions(tree: SceneTree) -> void: + var keep_alive: Owniverse = self + while not _queued_productions.is_empty(): + var slice_started: int = Time.get_ticks_usec() + while not _queued_productions.is_empty() and Time.get_ticks_usec() - slice_started < production_slice_usec: + var command: Dictionary = _queued_productions.pop_front() + _apply_production(command) + if _queued_productions.is_empty(): break + await tree.process_frame + if keep_alive._production_queue_cancelled: return + production_queue_busy = false ## Persist work requests, never the partially calculated transaction. func frame_checkpoint() -> Dictionary: @@ -151,14 +182,14 @@ func finish_frame() -> void: if _requested_speed != 0: speed = _requested_speed _requested_speed = 0 - var productions: Array = _queued_productions - _queued_productions = [] - for command in productions: - request_production(command.id, command.amount, command.resource) + if not _queued_productions.is_empty(): + _start_production_drain(frame_budget.tree) func _notification(what: int) -> void: - if what == NOTIFICATION_PREDELETE and frame_budget != null: - frame_budget.cancelled = true + if what == NOTIFICATION_PREDELETE: + _production_queue_cancelled = true + if frame_budget != null: + frame_budget.cancelled = true var speed: int : get: