338 lines
19 KiB
GDScript
338 lines
19 KiB
GDScript
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)
|