Connect paid food purchasing, kitchen production, and porter deliveries
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
extends SceneTree
|
||||
|
||||
## A real inn from the default 5,000 G / 50 materials. No inserted guests,
|
||||
## inventory, employees, completed construction, or free resource commands.
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _ids: Dictionary = {}
|
||||
var _saw_loaded := false
|
||||
var _saw_movement := false
|
||||
var _saw_batch := false
|
||||
var _save_checked := false
|
||||
var _last_porter_cell := Vector3i.ZERO
|
||||
var _save_path := "user://tests/normal_supply_%d/cargo" % OS.get_process_id()
|
||||
var _main: Control
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_runtime.initialize(_clock, _state, 424242)
|
||||
_clock.set_paused(true)
|
||||
root.size = Vector2i(1440, 810)
|
||||
_main = load("res://scenes/main/main.tscn").instantiate() as Control
|
||||
_main.set("manual_save_path", "user://tests/supply_normal_%d/manual" % OS.get_process_id())
|
||||
root.add_child(_main)
|
||||
await process_frame
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
_expect(_state.get_resource(&"gold") == 5000 and _state.get_resource(&"materials") == 50, "ordinary starting resources")
|
||||
for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)], [&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
if not _command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}):
|
||||
_finish()
|
||||
return
|
||||
await _advance(180)
|
||||
_command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)})
|
||||
_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 1, 0)})
|
||||
await _advance(60)
|
||||
_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 2, 0)})
|
||||
await _advance(10)
|
||||
for x: int in [-5, -6]:
|
||||
_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(x, 2, 0)})
|
||||
await _advance(10)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
_ids[facility.definition_id] = facility.instance_id
|
||||
for assignment: Array in [[&"staff_akane", &"reception"], [&"staff_gina", &"warehouse"], [&"staff_chika", &"kitchen"], [&"staff_iris", &"cafeteria"], [&"staff_juno", &"cafeteria"]]:
|
||||
if not _command(&"hire_staff_candidate", {&"candidate_id": assignment[0]}) or not _command(&"set_staff_work_assignment", {&"staff_id": assignment[0], &"facility_id": _ids[assignment[1]], &"template_id": &"DAY"}):
|
||||
_finish()
|
||||
return
|
||||
_expect(_state.get_resource(&"materials") == 4, "all buildings and the staff door fit within the starting material budget")
|
||||
var warehouse: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"warehouse"])
|
||||
var cash_before: int = _state.get_resource(&"gold")
|
||||
_command(&"purchase_supplies", {&"warehouse_id": warehouse.instance_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
|
||||
_expect(_state.get_resource(&"gold") == cash_before - 120 and _runtime.inventory_service.total_quantity(warehouse.local_inventory_id, SupplyCatalog.RAW_FOOD) == 40, "40 raw food costs exactly 120 G and arrives only in the warehouse")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8})
|
||||
await _advance(120)
|
||||
_expect(_saw_movement and _saw_loaded, "an employed porter walks the real route and carries purchased food")
|
||||
_expect(_saw_batch, "the kitchen starts autonomous production without a guest request")
|
||||
_expect(_save_checked, "a mid-delivery disk save restores ownership and replays exactly")
|
||||
_expect(_completed_deliveries(SupplyCatalog.RAW_FOOD) > 0 and _completed_deliveries(SupplyCatalog.BASIC_MEAL) > 0, "both warehouse-to-kitchen and kitchen-to-cafeteria deliveries complete")
|
||||
_expect(_sales(&"cafeteria") > 0, "natural guests pay for meals made from purchased food")
|
||||
await _capture()
|
||||
if OS.get_environment("THE_TREE_SUPPLY_PREVIEW") == "1":
|
||||
_start_preview()
|
||||
return
|
||||
if OS.get_environment("THE_TREE_SUPPLY_CAPTURE_ONLY") == "1":
|
||||
_finish()
|
||||
return
|
||||
for day: int in range(1, 4):
|
||||
await _advance(360 + 1440 * day - _clock.absolute_minute)
|
||||
var summaries: Array = _runtime.operations_coordinator.get_day_summaries()
|
||||
_expect(summaries.size() == day and bool(summaries[-1].get(&"balanced", false)), "business day %d closes with a reconciled ledger" % day)
|
||||
_expect(_state.get_resource(&"gold") >= 0, "day %d retains cash after real payroll and purchases" % day)
|
||||
if day < 3:
|
||||
_command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40})
|
||||
_expect(_sales(&"standard_room") > 0, "the same inn earns lodging revenue")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "three-day supply history remains restorable")
|
||||
var evidence := {"checks": _checks, "failures": _failures, "meal_sales": _sales(&"cafeteria"), "lodging_sales": _sales(&"standard_room"), "raw_deliveries": _completed_deliveries(SupplyCatalog.RAW_FOOD), "meal_deliveries": _completed_deliveries(SupplyCatalog.BASIC_MEAL), "cash": _state.get_resource(&"gold"), "closes": _runtime.operations_coordinator.get_day_summaries(), "supply": _runtime.get_supply_management_report()}
|
||||
var evidence_path := ProjectSettings.globalize_path("res://artifacts/supply-chain/normal-play-report.json")
|
||||
DirAccess.make_dir_recursive_absolute(evidence_path.get_base_dir())
|
||||
var file := FileAccess.open(evidence_path, FileAccess.WRITE)
|
||||
if file != null:
|
||||
file.store_string(JSON.stringify(evidence, "\t"))
|
||||
print("NORMAL_SUPPLY_REPORT ", JSON.stringify({"cash": evidence["cash"], "meal_sales": evidence["meal_sales"], "raw_deliveries": evidence["raw_deliveries"], "meal_deliveries": evidence["meal_deliveries"]}))
|
||||
_finish()
|
||||
|
||||
|
||||
func _advance(minutes: int) -> void:
|
||||
for index: int in maxi(minutes, 0):
|
||||
var before: int = _clock.absolute_minute
|
||||
_clock.advance_minutes(1)
|
||||
if _clock.absolute_minute != before + 1:
|
||||
_expect(false, "clock must progress: %s" % _runtime.get_operations_report())
|
||||
return
|
||||
var porter: StaffRuntime = _runtime.staff_service.get_staff(&"staff_gina")
|
||||
if porter != null:
|
||||
_saw_movement = _saw_movement or porter.current_cell != _last_porter_cell
|
||||
_last_porter_cell = porter.current_cell
|
||||
if not porter.carried_inventory_id.is_empty():
|
||||
_saw_loaded = true
|
||||
if not _save_checked:
|
||||
_check_loaded_save()
|
||||
if _ids.has(&"kitchen"):
|
||||
_saw_batch = _saw_batch or not _runtime.kitchen_production.get_report(_ids[&"kitchen"]).get(&"active_batch", {}).is_empty()
|
||||
if index % 120 == 119:
|
||||
print("SUPPLY_PROGRESS ", _clock.formatted_time(), " meals=", _sales(&"cafeteria"))
|
||||
await process_frame
|
||||
|
||||
|
||||
func _check_loaded_save() -> void:
|
||||
_save_checked = true
|
||||
var saved: Dictionary = _runtime.save_to_path(_save_path)
|
||||
_expect(bool(saved.get(&"ok", false)), "mid-delivery save writes to an isolated slot")
|
||||
var before: Dictionary = _runtime.create_snapshot()
|
||||
var loaded: Dictionary = _runtime.load_from_path(_save_path)
|
||||
_expect(bool(loaded.get(&"ok", false)), "mid-delivery disk restore: %s" % loaded)
|
||||
var normalized: Dictionary = _runtime.create_snapshot()
|
||||
_clock.advance_minutes(5)
|
||||
var expected: Dictionary = _runtime.create_snapshot()
|
||||
_expect(_runtime.restore_snapshot(normalized), "mid-delivery checkpoint rewinds")
|
||||
_clock.advance_minutes(5)
|
||||
_expect(_runtime.create_snapshot() == expected, "mid-delivery replay preserves exact inventory, tasks, positions, and ledger")
|
||||
_expect(_runtime.restore_snapshot(before), "resume the observed minute after replay verification")
|
||||
|
||||
|
||||
func _completed_deliveries(item: StringName) -> int:
|
||||
var count := 0
|
||||
for request: LogisticsRequest in _runtime.logistics_requests.values():
|
||||
if String(request.request_id).begins_with("supply.") and request.state == LogisticsRequest.State.COMPLETED and request.item_requirements.has(item):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _sales(definition_id: StringName) -> int:
|
||||
var total := 0
|
||||
for entry: LedgerEntry in _runtime.economy_service.get_entries():
|
||||
if entry.category == &"SERVICE_SALE" and entry.source_entity_id == _ids.get(definition_id, &"missing"):
|
||||
total += entry.amount
|
||||
return total
|
||||
|
||||
|
||||
func _command(type: StringName, payload: Dictionary) -> bool:
|
||||
var result: CommandResult = _runtime.submit_command(type, payload)
|
||||
_expect(result.is_success(), "%s %s: %s" % [type, payload, result.message])
|
||||
return result.is_success()
|
||||
|
||||
|
||||
func _capture() -> void:
|
||||
if OS.get_environment("THE_TREE_SUPPLY_CAPTURE") != "1":
|
||||
return
|
||||
await process_frame
|
||||
_main.call("_activate_management_mode", &"product")
|
||||
var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
|
||||
for tab: StringName in [&"purchase", &"kitchen", &"delivery"]:
|
||||
panel.select_tab(tab)
|
||||
await process_frame
|
||||
await RenderingServer.frame_post_draw
|
||||
var path := ProjectSettings.globalize_path("res://artifacts/supply-chain/%s.png" % tab)
|
||||
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
|
||||
root.get_texture().get_image().save_png(path)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error("FAIL: " + message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
SaveService.delete_save(_save_path)
|
||||
print("Normal supply chain: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures > 0 else 0)
|
||||
|
||||
|
||||
func _start_preview() -> void:
|
||||
if _failures > 0:
|
||||
_finish()
|
||||
return
|
||||
SaveService.delete_save(_save_path)
|
||||
root.title = "The Tree · 仕入れと厨房の試遊(保存なし)"
|
||||
for node: Node in _main.find_children("*", "Button", true, false):
|
||||
var button := node as Button
|
||||
for connection: Dictionary in button.pressed.get_connections():
|
||||
var callback: Callable = connection[&"callable"]
|
||||
if callback.get_method() in [&"_save_manual", &"_load_manual"]:
|
||||
button.disabled = true
|
||||
button.tooltip_text = "この試遊は保存しません。"
|
||||
_main.call("_activate_management_mode", &"product")
|
||||
var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel
|
||||
panel.select_tab(&"kitchen")
|
||||
_main.call("_set_status", "通常予算から育てた宿の試遊です。Spaceで再開し、商品タブで仕入れ・調理・配送を確認できます(保存なし)。", true, true)
|
||||
_clock.set_paused(true)
|
||||
print("SUPPLY_PREVIEW_READY checks=%d failures=0" % _checks)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bb4vjqk7okwwa
|
||||
@@ -0,0 +1,315 @@
|
||||
extends SceneTree
|
||||
|
||||
## Real construction, hired porter and purchased stock; failure injection is
|
||||
## limited to route closures, capacity pressure and expired cargo.
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _source: StringName
|
||||
var _destination: StringName
|
||||
var _loaded: Dictionary
|
||||
var _reserved: Dictionary
|
||||
const REQUEST := &"supply.bridge_test"
|
||||
const PORTER := &"staff_gina"
|
||||
|
||||
|
||||
class LateFailInventory extends InventoryService:
|
||||
var fail_delivery := true
|
||||
|
||||
func transfer_available(source_inventory_id: StringName, destination_inventory_id: StringName,
|
||||
item_id: StringName, quantity: int, operation_id: StringName = &"", absolute_minute: int = -1) -> Dictionary:
|
||||
var result := super.transfer_available(source_inventory_id, destination_inventory_id, item_id, quantity, operation_id, absolute_minute)
|
||||
if fail_delivery and String(source_inventory_id).begins_with("cargo:") and result[&"ok"]:
|
||||
return {&"ok": false, &"code": &"INJECTED_LATE_FAILURE"}
|
||||
return result
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("Supply bridge: real movement, cargo conservation and recovery")
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_expect(_runtime.initialize(_clock, _state, 653322)[&"valid"], "Runtime initializes")
|
||||
_clock.set_paused(true)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]:
|
||||
_expect(_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}), "build %s" % row[0])
|
||||
_clock.advance_minutes(180)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse": _source = facility.instance_id
|
||||
if facility.definition_id == &"kitchen": _destination = facility.instance_id
|
||||
if _source.is_empty() or _destination.is_empty():
|
||||
_expect(false, "normal construction completes")
|
||||
_finish()
|
||||
return
|
||||
_expect(_command(&"hire_staff_candidate", {&"candidate_id": PORTER}), "hire the catalogue porter")
|
||||
_expect(_command(&"set_staff_work_assignment", {&"staff_id": PORTER, &"facility_id": _source, &"template_id": &"DAY"}), "assign porter to a repeating real shift")
|
||||
_expect(_command(&"purchase_supplies", {&"warehouse_id": _source, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}), "purchase the source stock through the ledger")
|
||||
_expect(_command(&"create_logistics_request", {&"request_id": REQUEST, &"source_inventory_id": _inventory(_source), &"destination_inventory_id": _inventory(_destination), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}), "create the reserved supply request")
|
||||
_expect(_quantity(_inventory(_destination)) == 0, "request creation does not deliver anything")
|
||||
_test_manual_dispatch_boundary()
|
||||
_reserved = _runtime.create_snapshot()
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().assigned_carrier_id == PORTER and _worker().current_cell.distance_to(Vector3i.ZERO) <= 1.0, "dispatcher selects the actual employee without teleporting")
|
||||
var start := _worker().current_cell
|
||||
_wait_for(LogisticsRequest.State.DELIVERING, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_cell != start, "porter walks to the source before pickup")
|
||||
if _request().state != LogisticsRequest.State.DELIVERING:
|
||||
_finish()
|
||||
return
|
||||
_expect(_quantity(_inventory(_source)) == 0 and _quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0, "pickup moves all eight units into owned cargo only")
|
||||
_expect(_crosslinks(), "loaded Request, Task, reservations and cargo agree")
|
||||
_loaded = _runtime.create_snapshot()
|
||||
_expect(_runtime.restore_snapshot(_loaded), "mid-delivery Runtime snapshot restores")
|
||||
_test_ownership_and_replay()
|
||||
_test_capacity_wait()
|
||||
_test_route_recovery()
|
||||
_test_unavailable_and_expiry()
|
||||
_test_restore_guards()
|
||||
_test_pickup_retry()
|
||||
_test_unavailable_before_pickup()
|
||||
_test_partial_expiry_return()
|
||||
_test_shift_boundary()
|
||||
_test_late_delivery_failure()
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_ownership_and_replay() -> void:
|
||||
var owned := _owned_snapshot()
|
||||
var manual: CommandResult = _runtime.submit_command(&"advance_logistics_request", {&"request_id": REQUEST})
|
||||
_expect(not manual.is_success() and manual.details.get(&"reason") == &"SUPPLY_DRIVER_REQUIRED", "external advance cannot bypass real arrival")
|
||||
_expect(_owned_snapshot() == owned, "rejected manual advance leaves inventory, Task and carrier ownership unchanged")
|
||||
_expect(_runtime.restore_snapshot(_loaded), "rewind the rejected command audit before deterministic replay")
|
||||
var task_id := _worker().current_task_id
|
||||
_expect(not _runtime.staff_service.cancel_task(task_id).is_success(), "ordinary cancellation cannot orphan loaded cargo")
|
||||
_expect(not _runtime.staff_service.remove_staff(PORTER).is_success(), "loaded porter cannot be removed")
|
||||
_expect(not _runtime.staff_service.retire_staff(PORTER).is_success() and not _worker().is_retiring, "retirement rejects atomically while hauling")
|
||||
_expect(not _runtime.staff_service.progress_task(task_id, 1, _clock.absolute_minute + 2).is_success(), "generic Task progress cannot bypass delivery")
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8, "arrival transfers the purchased stock into kitchen storage")
|
||||
_expect(_quantity(_cargo()) == 0 and _worker().current_task_id.is_empty() and _worker().carried_inventory_id.is_empty(), "completion clears cargo and employee ownership together")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "completion releases exact dispatcher weight")
|
||||
var completed: Dictionary = _runtime.create_snapshot()
|
||||
var end_minute: int = _clock.absolute_minute
|
||||
_expect(_runtime.restore_snapshot(_loaded), "rewind to a loaded employee")
|
||||
_clock.advance_minutes(end_minute - _clock.absolute_minute)
|
||||
_expect(_runtime.create_snapshot() == completed, "loaded save replay produces identical inventory, Tasks, positions and accounts")
|
||||
|
||||
|
||||
func _test_manual_dispatch_boundary() -> void:
|
||||
var owned := _owned_snapshot()
|
||||
var manual: CommandResult = _runtime.submit_command(&"dispatch_logistics", {&"carriers": {&"synthetic_porter": {&"capacity": 100, &"current_load": 0, &"path_minutes": 0, &"role": &"logistics"}}})
|
||||
_expect(manual.is_success() and manual.details[&"assignments"].is_empty(), "external synthetic carrier cannot claim a managed supply request")
|
||||
_expect(_owned_snapshot() == owned, "manual dispatch does not change reserved stock, Staff Task or request ownership")
|
||||
|
||||
|
||||
func _owned_snapshot() -> Dictionary:
|
||||
return {&"inventory": _runtime.inventory_service.create_snapshot(), &"staff": _runtime.staff_service.create_snapshot(), &"dispatcher": _runtime.logistics_dispatcher.create_snapshot(), &"request": _request().create_snapshot()}
|
||||
|
||||
|
||||
func _test_capacity_wait() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore capacity scenario")
|
||||
_runtime.inventory_service.add_stack(_inventory(_destination), SupplyCatalog.BASIC_MEAL, 40, 100, -1, _clock.absolute_minute)
|
||||
_clock.advance_minutes(30)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _request().state_reason == "DESTINATION_FULL", "capacity becoming full after pickup pauses delivery")
|
||||
_expect(_quantity(_cargo()) == 8 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 8, "full destination retains all cargo and weight ownership")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "waiting loaded cargo is restorable")
|
||||
_runtime.inventory_service.transfer_available(_inventory(_destination), &"test.capacity_sink", SupplyCatalog.BASIC_MEAL, 40, &"", _clock.absolute_minute)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 10)
|
||||
_expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "freeing storage resumes once without losing or duplicating stock")
|
||||
|
||||
|
||||
func _test_route_recovery() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore route scenario")
|
||||
var target := _task().target_cell
|
||||
_runtime.path_service.set_cell_temporary_block(target, true)
|
||||
_clock.advance_minutes(40)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _quantity(_cargo()) == 8, "blocked delivery route never releases carried stock")
|
||||
_expect(_worker().current_task_id == _task().task_id and _crosslinks(), "stuck recovery keeps employee and Task linked")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "route-blocked cargo saves and restores")
|
||||
_runtime.path_service.set_cell_temporary_block(target, false)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8, "restoring the real route resumes delivery")
|
||||
|
||||
|
||||
func _test_unavailable_and_expiry() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore unavailable scenario")
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
var cell := _worker().current_cell
|
||||
_clock.advance_minutes(3)
|
||||
_expect(_worker().current_cell == cell and _quantity(_cargo()) == 8, "incapacitated porter does not move or lose cargo")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "incapacitated cargo owner is restorable")
|
||||
_worker().health = 100
|
||||
_worker().transition_to(StaffRuntime.State.MOVING_TO_TASK, &"TEST_RECOVERED", _clock.absolute_minute)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED, "employee recovery resumes the retained delivery")
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore spoilage scenario")
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
var inventory_snapshot: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
for owner: Dictionary in inventory_snapshot[&"inventories"]:
|
||||
if owner[&"owner_inventory_id"] == String(_cargo()):
|
||||
for stack: Dictionary in owner[&"stacks"]:
|
||||
stack[&"expires_at"] = _clock.absolute_minute + 1
|
||||
_expect(_runtime.inventory_service.restore_snapshot(inventory_snapshot), "inject a cargo expiry boundary")
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().state == LogisticsRequest.State.CANCELLED and _quantity(_cargo()) == 0, "expired cargo enters inventory waste and closes the impossible delivery")
|
||||
_expect(_worker().state == StaffRuntime.State.INCAPACITATED and _worker().current_task_id.is_empty(), "spoilage cleanup preserves incapacitation while releasing the Task")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "spoilage releases weight without a ghost assignment")
|
||||
|
||||
|
||||
func _test_restore_guards() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore crosslink scenario")
|
||||
var stable: Dictionary = _runtime.create_snapshot()
|
||||
var bad := stable.duplicate(true)
|
||||
for task: Dictionary in bad[&"systems"][&"runtime"][&"staff"][&"tasks"]:
|
||||
if task[&"task_type"] == String(SupplyLogisticsBridge.DELIVERY_TYPE):
|
||||
task[&"target_cell"] = [99, 99, 1]
|
||||
_expect(not _runtime.restore_snapshot(bad) and _runtime.create_snapshot() == stable, "restore rejects a remote delivery target atomically")
|
||||
var worker := _worker()
|
||||
worker.carried_inventory_id = &"cargo:somebody_else"
|
||||
_expect(not _crosslinks(), "crosslink validator rejects stolen cargo ownership")
|
||||
worker.carried_inventory_id = _cargo()
|
||||
var task := _task()
|
||||
var saved_type := task.task_type
|
||||
task.task_type = SupplyLogisticsBridge.PICKUP_TYPE
|
||||
_expect(not _crosslinks(), "crosslink validator rejects pickup Task paired with loaded request")
|
||||
task.task_type = saved_type
|
||||
_expect(_crosslinks(), "valid ownership survives rejected candidates")
|
||||
|
||||
|
||||
func _test_pickup_retry() -> void:
|
||||
_expect(_runtime.restore_snapshot(_reserved), "restore before pickup dispatch")
|
||||
_clock.advance_minutes(1)
|
||||
var old_task := _worker().current_task_id
|
||||
var stopped: Dictionary = _runtime.supply_logistics.handle_path_stuck(old_task, _clock.absolute_minute)
|
||||
_expect(stopped[&"ok"] and _request().state == LogisticsRequest.State.BLOCKED, "unloaded route failure blocks the request and cancels the approach")
|
||||
_expect(_runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8 and _worker().current_task_id.is_empty(), "failed approach releases source reservation and employee together")
|
||||
_expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "failed approach retains no dispatcher load")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "blocked unloaded request restores")
|
||||
_wait_for(LogisticsRequest.State.DELIVERING, 40)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_task_id != old_task, "retries reserve stock again and create a fresh attempt Task")
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "retry delivers the same stock exactly once")
|
||||
|
||||
|
||||
func _test_unavailable_before_pickup() -> void:
|
||||
_expect(_runtime.restore_snapshot(_reserved), "restore unloaded health failure")
|
||||
_clock.advance_minutes(1)
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().state == LogisticsRequest.State.BLOCKED and _worker().current_task_id.is_empty(), "incapacitation before pickup releases approach ownership")
|
||||
_expect(_worker().state == StaffRuntime.State.INCAPACITATED and _runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8, "unloaded health failure preserves health state and releases reserved food")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()) and _crosslinks(), "unloaded health failure saves without expired reservation links")
|
||||
|
||||
|
||||
func _test_partial_expiry_return() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore partial-spoilage scenario")
|
||||
var inventory: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
for owner: Dictionary in inventory[&"inventories"]:
|
||||
if owner[&"owner_inventory_id"] == String(_cargo()):
|
||||
owner[&"stacks"][0][&"quantity"] = 4
|
||||
_expect(_runtime.inventory_service.restore_snapshot(inventory), "split loaded cargo into two expiry lots")
|
||||
_runtime.inventory_service.add_stack(_cargo(), SupplyCatalog.RAW_FOOD, 4, 100, _clock.absolute_minute + 1, _clock.absolute_minute)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _task().task_type == SupplyLogisticsBridge.RETURN_TYPE and _quantity(_cargo()) == 4, "partial expiry retargets the surviving cargo to its source")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "returning cargo saves its destination in the existing Task")
|
||||
_worker().health = 0
|
||||
_worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_crosslinks(), "unavailability reason cannot erase return destination")
|
||||
_worker().health = 100
|
||||
_worker().transition_to(StaffRuntime.State.MOVING_TO_TASK, &"RECOVERED", _clock.absolute_minute)
|
||||
_wait_for(LogisticsRequest.State.CANCELLED, 40)
|
||||
_expect(_request().state_reason == "CARGO_EXPIRED_RETURNED" and _quantity(_inventory(_source)) == 4 and _quantity(_inventory(_destination)) == 0, "return unloads only unspoiled stock at the original warehouse")
|
||||
_expect(_quantity(_cargo()) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "partial-expiry return releases all cargo ownership")
|
||||
|
||||
|
||||
func _test_shift_boundary() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore end-of-shift scenario")
|
||||
var target := _task().target_cell
|
||||
_runtime.path_service.set_cell_temporary_block(target, true)
|
||||
_clock.advance_minutes(1081 - _clock.absolute_minute)
|
||||
_expect(_worker().is_overtime and not _worker().current_task_id.is_empty() and _quantity(_cargo()) == 8, "18:00 shift end retains loaded non-interruptible work")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "overtime delivery is restorable")
|
||||
_runtime.path_service.set_cell_temporary_block(target, false)
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 40)
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_worker().state == StaffRuntime.State.OFF_DUTY and _worker().current_task_id.is_empty() and _quantity(_inventory(_destination)) == 8, "employee ends the shift after safe unloading")
|
||||
|
||||
|
||||
func _test_late_delivery_failure() -> void:
|
||||
_expect(_runtime.restore_snapshot(_loaded), "restore delivery transaction failure")
|
||||
var fault := LateFailInventory.new()
|
||||
_expect(fault.restore_snapshot(_runtime.inventory_service.create_snapshot()), "install failure after inventory transfer has already mutated")
|
||||
_runtime.inventory_service = fault
|
||||
_clock.advance_minutes(30)
|
||||
_expect(_request().state == LogisticsRequest.State.DELIVERING and _request().state_reason == "DELIVERY_RETRY", "late inventory failure rolls the request back to owned delivery")
|
||||
_expect(_quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 8, "failed delivery reverses all physical transfer and dispatcher changes")
|
||||
_expect(_crosslinks() and not _worker().current_task_id.is_empty(), "failed delivery preserves the employee and Task pair")
|
||||
fault.fail_delivery = false
|
||||
_wait_for(LogisticsRequest.State.COMPLETED, 10)
|
||||
_expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "clearing transient failure retries the same operation exactly once")
|
||||
_expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "transaction retry leaves a restorable Runtime")
|
||||
|
||||
|
||||
func _wait_for(state: int, minutes: int) -> void:
|
||||
for index: int in minutes:
|
||||
if _request().state == state: return
|
||||
_clock.advance_minutes(1)
|
||||
|
||||
|
||||
func _command(type: StringName, payload: Dictionary) -> bool:
|
||||
var result: CommandResult = _runtime.submit_command(type, payload)
|
||||
if not result.is_success(): print(result.message, result.details)
|
||||
return result.is_success()
|
||||
|
||||
|
||||
func _inventory(id: StringName) -> StringName:
|
||||
return _runtime.facility_service.get_instance(id).local_inventory_id
|
||||
|
||||
|
||||
func _request() -> LogisticsRequest:
|
||||
return _runtime.logistics_requests[REQUEST]
|
||||
|
||||
|
||||
func _worker() -> StaffRuntime:
|
||||
return _runtime.staff_service.get_staff(PORTER)
|
||||
|
||||
|
||||
func _task() -> StaffTask:
|
||||
return _runtime.staff_service.get_task(_worker().current_task_id)
|
||||
|
||||
|
||||
func _cargo() -> StringName:
|
||||
return StringName("cargo:%s" % PORTER)
|
||||
|
||||
|
||||
func _quantity(inventory_id: StringName) -> int:
|
||||
return _runtime.inventory_service.total_quantity(inventory_id, SupplyCatalog.RAW_FOOD)
|
||||
|
||||
|
||||
func _crosslinks() -> bool:
|
||||
return SupplyLogisticsBridge.validate_crosslinks(_runtime.logistics_requests, _runtime.staff_service, _runtime.inventory_service, _runtime.facility_service)
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("%s: %d checks / %d failures" % ["PASS" if _failures == 0 else "FAIL", _checks, _failures])
|
||||
quit(_failures)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dbj5dk0uqj0ay
|
||||
@@ -0,0 +1,337 @@
|
||||
extends SceneTree
|
||||
|
||||
## Runtime-level purchasing and replenishment. All physical stock is purchased
|
||||
## and cooked normally; failure fixtures cover payment, closed routes and expiry.
|
||||
class RejectingPaymentEconomy:
|
||||
extends EconomyService
|
||||
|
||||
func transfer(_from: StringName, _to: StringName, _amount: int,
|
||||
_minute: int = -1, _category: StringName = DEFAULT_CATEGORY,
|
||||
_source: StringName = &"", _key: StringName = &"", _memo: StringName = &"",
|
||||
_related: StringName = &"", _notify := true) -> CommandResult:
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Injected payment refusal")
|
||||
|
||||
|
||||
const PORTER := &"staff_gina"
|
||||
const COOK := &"staff_chika"
|
||||
const RETURN_REQUEST := &"supply.management_return"
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _completed_groups := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _ids: Dictionary = {}
|
||||
var _built: Dictionary = {}
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_runtime = root.get_node("SimulationRuntime")
|
||||
_clock = root.get_node("GameClock")
|
||||
_state = root.get_node("GameState")
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
_expect(_runtime.initialize(_clock, _state, 553221)[&"valid"], "Runtime initializes")
|
||||
_clock.set_paused(true)
|
||||
_runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false})
|
||||
for row: Array in [
|
||||
[&"warehouse", Vector3i(-8, 0, 1)], [ &"kitchen", Vector3i(2, 0, 1)],
|
||||
[&"cafeteria", Vector3i(2, 0, 0)], [&"staff_door", Vector3i(0, 0, 1)],
|
||||
]:
|
||||
_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]})
|
||||
_clock.advance_minutes(180)
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
_ids[facility.definition_id] = facility.instance_id
|
||||
var completed := 0
|
||||
for job: ConstructionJob in _runtime.construction_service.get_jobs():
|
||||
if job.state == ConstructionJob.State.COMPLETED:
|
||||
completed += 1
|
||||
_expect(_ids.has(&"warehouse") and _ids.has(&"kitchen") and _ids.has(&"cafeteria") and completed == 4,
|
||||
"normal starting budget completes three supply facilities and the staff door")
|
||||
if not _ids.has(&"warehouse") or not _ids.has(&"kitchen") or not _ids.has(&"cafeteria"):
|
||||
_finish()
|
||||
return
|
||||
_built = _runtime.create_snapshot()
|
||||
_test_paid_purchase_and_rejection()
|
||||
_test_disabled_kitchen_still_delivers()
|
||||
_test_multiple_sources_choose_connected_nearest()
|
||||
_test_returning_cargo_reserves_actual_space()
|
||||
_expect(_completed_groups == 4, "all four regression scenarios reach their final assertions")
|
||||
_finish()
|
||||
|
||||
|
||||
func _test_paid_purchase_and_rejection() -> void:
|
||||
var cash: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var quote: Dictionary = _runtime.preview_supply_purchase(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, 8)
|
||||
_expect(bool(quote[&"ok"]), "unassigned completed warehouse accepts a paid purchase")
|
||||
var result := _purchase(8)
|
||||
_expect(result.is_success() and _quantity(&"warehouse", SupplyCatalog.RAW_FOOD) == 8,
|
||||
"successful purchase creates exactly eight physical units")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash - int(quote[&"total_cost"])
|
||||
and _state.get_resource(&"gold") == cash - int(quote[&"total_cost"]),
|
||||
"ledger and player cash both reflect the exact purchase price")
|
||||
var entries: Array = _runtime.economy_service.get_transaction_entries(StringName(result.details.get(&"transaction_id", &"")))
|
||||
_expect(entries.size() == 1 and entries[0].amount == int(quote[&"total_cost"])
|
||||
and entries[0].source_entity_id == _ids[&"warehouse"], "purchase records one expense attributed to its warehouse")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 0}, &"INVALID_QUANTITY")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 100}, &"STORAGE_FULL")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1000}, &"INSUFFICIENT_FUNDS")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 1}, &"ITEM_NOT_PURCHASABLE")
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"kitchen"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"NOT_WAREHOUSE")
|
||||
_runtime.facility_service.set_operational_status(_ids[&"warehouse"], {&"operator_open": false}, _clock.absolute_minute)
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"FACILITY_CLOSED")
|
||||
_runtime.facility_service.set_operational_status(_ids[&"warehouse"], {&"operator_open": true}, _clock.absolute_minute)
|
||||
# Exercise the rollback after stock has been inserted, rather than only
|
||||
# testing quote failures that reject before the first inventory write.
|
||||
var original_economy: EconomyService = _runtime.economy_service
|
||||
var rejecting := RejectingPaymentEconomy.new()
|
||||
_expect(rejecting.restore_snapshot(original_economy.create_snapshot()), "payment-failure fixture retains all real balances")
|
||||
_runtime.economy_service = rejecting
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"PAYMENT_FAILED")
|
||||
_runtime.economy_service = original_economy
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _test_disabled_kitchen_still_delivers() -> void:
|
||||
_expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for production")
|
||||
_hire_and_assign(PORTER, &"warehouse")
|
||||
_hire_and_assign(COOK, &"kitchen")
|
||||
_expect(_purchase(8).is_success(), "purchase ingredients for real production")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 4})
|
||||
for minute: int in 100:
|
||||
if _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) >= 4:
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) == 4, "paid food is transported and cooked into one real batch")
|
||||
if _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) != 4:
|
||||
return
|
||||
_expect(_meal_requests().is_empty(), "production finishes before the following logistics tick creates a meal request")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": false, &"target_stock": 4})
|
||||
# Preserve the legacy compatibility boundary: a kitchen with no explicit
|
||||
# production plan must not suddenly become an automatic source.
|
||||
_expect(_runtime.kitchen_production.remove_plan(_ids[&"kitchen"])[&"ok"], "completed disabled kitchen can model a legacy unconfigured source")
|
||||
_runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute)
|
||||
_expect(_meal_requests().is_empty(), "unconfigured kitchen stock does not trigger unsolicited automatic delivery")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": false, &"target_stock": 4})
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_meal_requests().size() == 1 and not _runtime.kitchen_production.get_plan(_ids[&"kitchen"])[&"enabled"],
|
||||
"an explicitly disabled production plan still dispatches its completed meals")
|
||||
_expect(_quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 0
|
||||
and _runtime.inventory_service.available_quantity(_inventory(&"kitchen"), SupplyCatalog.BASIC_MEAL) == 0,
|
||||
"meal request reserves stock without instantly moving it to the dining room")
|
||||
_runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute)
|
||||
_runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute)
|
||||
_expect(_meal_requests().size() == 1, "repeated replenishment cannot duplicate the already reserved meal shipment")
|
||||
for minute: int in 100:
|
||||
if _quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 4:
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 4
|
||||
and _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) == 0,
|
||||
"real porter completes the outgoing shipment while kitchen production remains off")
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _test_multiple_sources_choose_connected_nearest() -> void:
|
||||
_expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for competing suppliers")
|
||||
_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(6, 0, 1)})
|
||||
_clock.advance_minutes(180)
|
||||
var near_id: StringName = &""
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"warehouse" and facility.instance_id != _ids[&"warehouse"]:
|
||||
near_id = facility.instance_id
|
||||
_expect(not near_id.is_empty(), "starting budget also completes a second, closer warehouse")
|
||||
if near_id.is_empty():
|
||||
return
|
||||
_expect(_purchase(8).is_success(), "older distant supplier holds eight paid units")
|
||||
_command(&"purchase_supplies", {&"warehouse_id": near_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8})
|
||||
var near: FacilityInstance = _runtime.facility_service.get_instance(near_id)
|
||||
var kitchen: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"kitchen"])
|
||||
var near_route: Dictionary = _runtime.supply_logistics.find_facility_access_path(Vector3i(6, 0, 1), kitchen)
|
||||
var far_route: Dictionary = _runtime.supply_logistics.find_facility_access_path(Vector3i(-5, 0, 1), kitchen)
|
||||
_expect(bool(near_route[&"reachable"]) and bool(far_route[&"reachable"])
|
||||
and int(near_route[&"steps"]) < int(far_route[&"steps"])
|
||||
and String(near_id) > String(_ids[&"warehouse"]),
|
||||
"both actual routes connect, and the shorter route belongs to the later warehouse ID")
|
||||
var stocked: Dictionary = _runtime.create_snapshot()
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8})
|
||||
_clock.advance_minutes(1)
|
||||
var requests := _raw_requests_for_kitchen()
|
||||
_expect(requests.size() == 1 and int(requests[0].item_requirements[SupplyCatalog.RAW_FOOD]) == 4,
|
||||
"two stocked suppliers create only one four-unit shipment in the first logistics tick")
|
||||
_expect(not requests.is_empty() and requests[0].source_inventory_id == near.local_inventory_id,
|
||||
"connected nearby supplier wins over the older, farther warehouse")
|
||||
_expect(_runtime.inventory_service.available_quantity(near.local_inventory_id, SupplyCatalog.RAW_FOOD) == 4
|
||||
and _runtime.inventory_service.available_quantity(_inventory(&"warehouse"), SupplyCatalog.RAW_FOOD) == 8
|
||||
and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 4,
|
||||
"exactly four units are reserved once and counted once as incoming")
|
||||
_clock.advance_minutes(1)
|
||||
requests = _raw_requests_for_kitchen()
|
||||
_expect(requests.size() == 2 and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 8,
|
||||
"next tick adds at most one batch to cover the remaining four-unit deficit")
|
||||
_clock.advance_minutes(1)
|
||||
_expect(_raw_requests_for_kitchen().size() == 2
|
||||
and _runtime.inventory_service.available_quantity(_inventory(&"warehouse"), SupplyCatalog.RAW_FOOD) == 8,
|
||||
"incoming stock at target stops extra requests and leaves other supplier stock untouched")
|
||||
# Geometric proximity must not beat an actual connection. Closing every
|
||||
# access cell of the near warehouse leaves the far route into the kitchen.
|
||||
_expect(_runtime.restore_snapshot(stocked), "rewind to the two unreserved suppliers")
|
||||
near = _runtime.facility_service.get_instance(near_id)
|
||||
kitchen = _runtime.facility_service.get_instance(_ids[&"kitchen"])
|
||||
for cell: Vector3i in near.occupied_cells:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
||||
if _runtime.path_service.has_cell(cell + delta):
|
||||
_runtime.path_service.set_cell_temporary_block(cell + delta, true)
|
||||
far_route = _runtime.supply_logistics.find_facility_access_path(Vector3i(-5, 0, 1), kitchen)
|
||||
near_route = _runtime.supply_logistics.find_facility_access_path(Vector3i(6, 0, 1), kitchen)
|
||||
_expect(bool(far_route[&"reachable"]) and not bool(near_route[&"reachable"]),
|
||||
"route closure really disconnects the nearby supplier while preserving the far route")
|
||||
_command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8})
|
||||
_clock.advance_minutes(1)
|
||||
requests = _raw_requests_for_kitchen()
|
||||
_expect(requests.size() == 1 and requests[0].source_inventory_id == _inventory(&"warehouse")
|
||||
and int(requests[0].item_requirements[SupplyCatalog.RAW_FOOD]) == 4,
|
||||
"unreachable near stock cannot steal the single shipment from a connected supplier")
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _test_returning_cargo_reserves_actual_space() -> void:
|
||||
_expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for returning cargo")
|
||||
_hire_and_assign(PORTER, &"warehouse")
|
||||
_expect(_purchase(2).is_success() and _purchase(6).is_success(), "two paid stacks provide an eight-unit shipment")
|
||||
_command(&"create_logistics_request", {
|
||||
&"request_id": RETURN_REQUEST, &"source_inventory_id": _inventory(&"warehouse"),
|
||||
&"destination_inventory_id": _inventory(&"kitchen"), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8},
|
||||
})
|
||||
var request: LogisticsRequest = _runtime.logistics_requests[RETURN_REQUEST]
|
||||
for minute: int in 60:
|
||||
if request.state == LogisticsRequest.State.DELIVERING:
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
var cargo_id := StringName("cargo:%s" % PORTER)
|
||||
_expect(request.state == LogisticsRequest.State.DELIVERING
|
||||
and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 8,
|
||||
"actual pickup puts purchased goods in the employee's cargo")
|
||||
if request.state != LogisticsRequest.State.DELIVERING:
|
||||
return
|
||||
var inventory_snapshot: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
var injected := false
|
||||
for inventory: Dictionary in inventory_snapshot[&"inventories"]:
|
||||
if inventory[&"owner_inventory_id"] != String(cargo_id):
|
||||
continue
|
||||
for stack: Dictionary in inventory[&"stacks"]:
|
||||
if int(stack[&"quantity"]) == 2:
|
||||
stack[&"expires_at"] = _clock.absolute_minute + 1
|
||||
injected = true
|
||||
_expect(injected, "the original two-unit paid stack remains identifiable inside real cargo")
|
||||
_expect(_runtime.inventory_service.restore_snapshot(inventory_snapshot), "inject expiry of only the two-unit paid stack")
|
||||
_clock.advance_minutes(1)
|
||||
_expect(request.state == LogisticsRequest.State.DELIVERING and request.state_reason == "RETURNING_EXPIRED_CARGO"
|
||||
and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 6,
|
||||
"partial expiry starts a real return of the six surviving units")
|
||||
if request.state != LogisticsRequest.State.DELIVERING or request.state_reason != "RETURNING_EXPIRED_CARGO":
|
||||
return
|
||||
for reason: String in ["RETURNING_EXPIRED_CARGO", "EXPIRED_CARGO_RETURN_BLOCKED", "CARRIER_UNAVAILABLE"]:
|
||||
# Both bridge states retain the same real cargo. Capacity accounting
|
||||
# must follow that cargo even if the return route becomes blocked.
|
||||
request.state_reason = reason
|
||||
var quote: Dictionary = _runtime.preview_supply_purchase(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, 94)
|
||||
_expect(bool(quote[&"ok"]) and int(quote[&"max_quantity"]) == 94,
|
||||
"%s reserves six actual returning units, not the original eight" % reason)
|
||||
_expect(_incoming(&"warehouse", SupplyCatalog.RAW_FOOD) == 6 and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 0,
|
||||
"%s counts returning goods only at the true receiving warehouse" % reason)
|
||||
_assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 95}, &"STORAGE_FULL")
|
||||
request.state_reason = "RETURNING_EXPIRED_CARGO"
|
||||
_expect(_purchase(94).is_success(), "buyer may use the exact free space left after reserving the return")
|
||||
for minute: int in 30:
|
||||
if request.is_terminal():
|
||||
break
|
||||
_clock.advance_minutes(1)
|
||||
_expect(request.state == LogisticsRequest.State.CANCELLED and request.state_reason == "CARGO_EXPIRED_RETURNED",
|
||||
"capacity-aware purchase leaves enough room for the real return to complete")
|
||||
_expect(_quantity(&"warehouse", SupplyCatalog.RAW_FOOD) == 100
|
||||
and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 0
|
||||
and _incoming(&"warehouse", SupplyCatalog.RAW_FOOD) == 0,
|
||||
"return fills warehouse exactly once and clears incoming cargo")
|
||||
_expect(_runtime.inventory_service.validate_invariants()[&"ok"]
|
||||
and SupplyLogisticsBridge.validate_crosslinks(_runtime.logistics_requests, _runtime.staff_service, _runtime.inventory_service, _runtime.facility_service),
|
||||
"completed return preserves inventory and carrier ownership invariants")
|
||||
_completed_groups += 1
|
||||
|
||||
|
||||
func _assert_purchase_rejected(payload: Dictionary, reason: StringName) -> void:
|
||||
var inventory_before: Dictionary = _runtime.inventory_service.create_snapshot()
|
||||
var economy_before: Dictionary = _runtime.economy_service.create_snapshot()
|
||||
var gold_before: int = _state.get_resource(&"gold")
|
||||
var result: CommandResult = _runtime.submit_command(&"purchase_supplies", payload)
|
||||
_expect(not result.is_success() and result.details.get(&"reason") == reason, "purchase rejects %s" % reason)
|
||||
_expect(_runtime.inventory_service.create_snapshot() == inventory_before
|
||||
and _runtime.economy_service.create_snapshot() == economy_before and _state.get_resource(&"gold") == gold_before,
|
||||
"%s leaves full inventory, ledger and player cash unchanged" % reason)
|
||||
|
||||
|
||||
func _command(type: StringName, payload: Dictionary) -> CommandResult:
|
||||
var result: CommandResult = _runtime.submit_command(type, payload)
|
||||
_expect(result.is_success(), "%s succeeds: %s" % [type, result.details])
|
||||
return result
|
||||
|
||||
|
||||
func _purchase(quantity: int) -> CommandResult:
|
||||
return _runtime.submit_command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": quantity})
|
||||
|
||||
|
||||
func _hire_and_assign(staff_id: StringName, facility: StringName) -> void:
|
||||
_command(&"hire_staff_candidate", {&"candidate_id": staff_id})
|
||||
_command(&"set_staff_work_assignment", {&"staff_id": staff_id, &"facility_id": _ids[facility], &"template_id": &"DAY"})
|
||||
|
||||
|
||||
func _inventory(definition: StringName) -> StringName:
|
||||
return _runtime.facility_service.get_instance(_ids[definition]).local_inventory_id
|
||||
|
||||
|
||||
func _quantity(definition: StringName, item: StringName) -> int:
|
||||
return _runtime.inventory_service.total_quantity(_inventory(definition), item)
|
||||
|
||||
|
||||
func _incoming(definition: StringName, item: StringName) -> int:
|
||||
var report: Dictionary = _runtime.get_supply_management_report()
|
||||
var group := &"warehouses" if definition == &"warehouse" else &"kitchens"
|
||||
for row: Dictionary in report[group]:
|
||||
if StringName(row[&"facility_id"]) != _ids[definition]:
|
||||
continue
|
||||
for stock: Dictionary in row[&"stock"]:
|
||||
if StringName(stock[&"item_id"]) == item:
|
||||
return int(stock[&"in_transit"])
|
||||
return -1
|
||||
|
||||
|
||||
func _meal_requests() -> Array[LogisticsRequest]:
|
||||
var requests: Array[LogisticsRequest] = []
|
||||
for request: LogisticsRequest in _runtime.logistics_requests.values():
|
||||
if request.item_requirements.has(SupplyCatalog.BASIC_MEAL):
|
||||
requests.append(request)
|
||||
return requests
|
||||
|
||||
|
||||
func _raw_requests_for_kitchen() -> Array[LogisticsRequest]:
|
||||
var requests: Array[LogisticsRequest] = []
|
||||
for request: LogisticsRequest in _runtime.logistics_requests.values():
|
||||
if request.destination_inventory_id == _inventory(&"kitchen") and request.item_requirements.has(SupplyCatalog.RAW_FOOD):
|
||||
requests.append(request)
|
||||
return requests
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
|
||||
|
||||
func _finish() -> void:
|
||||
print("Supply management service: %d checks, %d failures" % [_checks, _failures])
|
||||
quit(1 if _failures > 0 else 0)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dmfdmeee0ev61
|
||||
@@ -0,0 +1,92 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("P1: Runtime v7 supply save migration")
|
||||
var source := _fixture(7301)
|
||||
var first := _fixture(7302)
|
||||
var second := _fixture(7303)
|
||||
var source_runtime: Variant = source[&"runtime"]
|
||||
var first_runtime: Variant = first[&"runtime"]
|
||||
var second_runtime: Variant = second[&"runtime"]
|
||||
source[&"clock"].advance_minutes(3)
|
||||
var canonical: Dictionary = source_runtime.create_snapshot()
|
||||
var canonical_data: Dictionary = canonical[&"systems"][&"runtime"]
|
||||
_expect(canonical_data[&"runtime_schema_version"] == 7, "current Runtime emits schema v7")
|
||||
_expect(canonical_data[&"kitchen_production"][&"current_minute"] == source[&"clock"].absolute_minute, "production clock follows Runtime clock without existing kitchens")
|
||||
var legacy := canonical.duplicate(true)
|
||||
var legacy_data: Dictionary = legacy[&"systems"][&"runtime"]
|
||||
legacy_data[&"runtime_schema_version"] = 6
|
||||
legacy_data.erase(&"kitchen_production")
|
||||
_expect(legacy_data.has(&"facility_infrastructure") and legacy_data.has(&"route_projection"), "v6 fixture retains infrastructure introduced in v6")
|
||||
_expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v7")
|
||||
var migrated: Dictionary = first_runtime.create_snapshot()
|
||||
_expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 7, "v6 migration re-emits current schema")
|
||||
var kitchen: Dictionary = migrated[&"systems"][&"runtime"][&"kitchen_production"]
|
||||
_expect(kitchen[&"plans"].is_empty() and kitchen[&"active_batches"].is_empty(), "legacy migration does not infer kitchen production plans or batches")
|
||||
_expect(kitchen[&"current_minute"] == source[&"clock"].absolute_minute, "legacy production clock binds to the restored Runtime minute")
|
||||
_expect(migrated == canonical, "v6 migration preserves non-supply state exactly")
|
||||
_expect(second_runtime.restore_snapshot(legacy) and second_runtime.create_snapshot() == migrated, "v6 migration is deterministic across Runtime instances")
|
||||
_expect(first_runtime.restore_snapshot(migrated) and first_runtime.create_snapshot() == migrated, "canonical v7 round trip is exact")
|
||||
var missing_supply := canonical.duplicate(true)
|
||||
missing_supply[&"systems"][&"runtime"].erase(&"kitchen_production")
|
||||
_expect(_rejects_atomically(first_runtime, missing_supply), "v7 cannot omit its supply aggregate")
|
||||
var relabelled := canonical.duplicate(true)
|
||||
relabelled[&"systems"][&"runtime"][&"runtime_schema_version"] = 6
|
||||
_expect(_rejects_atomically(first_runtime, relabelled), "relabelled v7 with retained kitchen state cannot masquerade as v6")
|
||||
var bad_clock := canonical.duplicate(true)
|
||||
bad_clock[&"systems"][&"runtime"][&"kitchen_production"][&"current_minute"] -= 1
|
||||
_expect(_rejects_atomically(first_runtime, bad_clock), "production and Runtime clock disagreement is rejected")
|
||||
var bad_version := canonical.duplicate(true)
|
||||
bad_version[&"systems"][&"runtime"][&"kitchen_production"][&"version"] = 2
|
||||
_expect(_rejects_atomically(first_runtime, bad_version), "unknown nested production schema is rejected")
|
||||
var stock: Dictionary = source_runtime.inventory_service.add_stack(&"supplier.legacy", SupplyCatalog.RAW_FOOD, 4)
|
||||
_expect(stock[&"ok"], "orphan migration fixture has raw food")
|
||||
var hold: Dictionary = source_runtime.inventory_service.reserve_item(
|
||||
&"supplier.legacy", SupplyCatalog.RAW_FOOD, 4,
|
||||
&"kitchen.batch:00000001", SupplyCatalog.PRODUCTION_PURPOSE, source[&"clock"].absolute_minute
|
||||
)
|
||||
_expect(hold[&"ok"], "orphan fixture creates a production reservation")
|
||||
var orphaned: Dictionary = source_runtime.create_snapshot()
|
||||
orphaned[&"systems"][&"runtime"][&"runtime_schema_version"] = 6
|
||||
orphaned[&"systems"][&"runtime"].erase(&"kitchen_production")
|
||||
_expect(_rejects_atomically(first_runtime, orphaned), "legacy label cannot hide input holds for a discarded production batch")
|
||||
for fixture: Dictionary in [source, first, second]:
|
||||
fixture[&"runtime"].free()
|
||||
fixture[&"clock"].free()
|
||||
fixture[&"state"].free()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _fixture(seed: int) -> Dictionary:
|
||||
var clock: Variant = load("res://autoload/game_clock.gd").new()
|
||||
var state: Variant = load("res://autoload/game_state.gd").new()
|
||||
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
_expect(runtime.initialize(clock, state, seed)[&"valid"], "migration fixture initializes")
|
||||
clock.set_paused(true)
|
||||
return {&"clock": clock, &"state": state, &"runtime": runtime}
|
||||
|
||||
|
||||
func _rejects_atomically(runtime: Variant, candidate: Dictionary) -> bool:
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
return not runtime.restore_snapshot(candidate) and runtime.create_snapshot() == before
|
||||
|
||||
|
||||
func _expect(condition: bool, message: String) -> void:
|
||||
_checks += 1
|
||||
if not condition:
|
||||
_failures += 1
|
||||
push_error(message)
|
||||
@@ -0,0 +1 @@
|
||||
uid://t3p5aqdc4g5h
|
||||
Reference in New Issue
Block a user