Connect paid food purchasing, kitchen production, and porter deliveries
This commit is contained in:
@@ -711,7 +711,11 @@ func get_stack(stack_id: StringName) -> Dictionary:
|
||||
|
||||
func get_stacks(owner_inventory_id: StringName = &"") -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
var owners := _sorted_names(_inventories.keys()) if owner_inventory_id == &"" else [owner_inventory_id]
|
||||
var owners: Array[StringName] = []
|
||||
if owner_inventory_id == &"":
|
||||
owners = _sorted_names(_inventories.keys())
|
||||
else:
|
||||
owners.append(owner_inventory_id)
|
||||
for owner: StringName in owners:
|
||||
if not _inventories.has(owner):
|
||||
continue
|
||||
|
||||
@@ -48,6 +48,7 @@ var _dispatcher := TaskDispatcherType.new()
|
||||
var _item_reserver := Callable()
|
||||
var _item_releaser := Callable()
|
||||
var _travel_estimator := Callable()
|
||||
var _external_task_driver := Callable()
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
@@ -62,6 +63,49 @@ func configure_callbacks(
|
||||
return self
|
||||
|
||||
|
||||
## External operations own their work-completion transaction while retaining
|
||||
## ordinary Staff movement, shifts, and bidirectional Task ownership.
|
||||
func configure_external_task_driver(driver: Callable = Callable()) -> void:
|
||||
_external_task_driver = driver
|
||||
|
||||
|
||||
func is_externally_driven_task(task_id: StringName) -> bool:
|
||||
return _external_task_driver.is_valid() and bool(_external_task_driver.call(task_id))
|
||||
|
||||
|
||||
func assign_task_to_staff(task_id: StringName, staff_id: StringName, absolute_minute: int) -> CommandResult:
|
||||
var task := get_task(task_id)
|
||||
var staff := get_staff(staff_id)
|
||||
if task == null or staff == null or not task.is_dispatchable():
|
||||
return _conflict("Task or employee is unavailable for assignment.")
|
||||
if not task.required_items.is_empty() or not task.reservation_ids.is_empty():
|
||||
return _conflict("Explicit assignment requires externally reserved operation inputs.")
|
||||
if not staff.is_available_for_dispatch() or not staff.meets_task_requirements(task):
|
||||
return _conflict("Employee is busy or does not meet the Task requirements.")
|
||||
if not task.reserve(staff_id, [], absolute_minute):
|
||||
return _conflict("Task could not reserve its employee.")
|
||||
if not staff.reserve_task(task_id, absolute_minute):
|
||||
task.reopen(&"ASSIGNMENT_ROLLED_BACK", absolute_minute, true)
|
||||
return _conflict("Employee could not reserve its Task.")
|
||||
task_assigned.emit(task_id, staff_id)
|
||||
return _success({&"task_id": task_id, &"staff_id": staff_id})
|
||||
|
||||
|
||||
func retarget_external_task(task_id: StringName, target_cell: Vector3i, absolute_minute: int) -> CommandResult:
|
||||
var task := get_task(task_id)
|
||||
if task == null or not is_externally_driven_task(task_id) or task.is_terminal():
|
||||
return _conflict("Only an active externally driven Task can be retargeted.")
|
||||
var staff := get_staff(task.assigned_staff_id)
|
||||
if staff == null or staff.current_task_id != task_id:
|
||||
return _conflict("Task and employee ownership differ.")
|
||||
task.target_cell = target_cell
|
||||
task.state = StaffTaskType.State.MOVING
|
||||
task.progress_minutes = 0
|
||||
task.state_changed_at = absolute_minute
|
||||
staff.transition_to(StaffRuntimeType.State.MOVING_TO_TASK, &"TASK_RETARGETED", absolute_minute)
|
||||
return _success({&"task_id": task_id})
|
||||
|
||||
|
||||
func register_staff(value: Variant) -> CommandResult:
|
||||
if value == null or not value is StaffRuntime:
|
||||
return _invalid("Staff runtime is missing or has an invalid type.")
|
||||
@@ -678,8 +722,11 @@ func begin_task(task_id: StringName, absolute_minute: int = -1) -> CommandResult
|
||||
func progress_task(
|
||||
task_id: StringName,
|
||||
minutes: int = 1,
|
||||
absolute_minute: int = -1
|
||||
absolute_minute: int = -1,
|
||||
externally_driven := false
|
||||
) -> CommandResult:
|
||||
if is_externally_driven_task(task_id) and not externally_driven:
|
||||
return _conflict("Task completion belongs to its external operation.")
|
||||
var task := get_task(task_id)
|
||||
if task == null:
|
||||
return _not_found("Task", task_id)
|
||||
@@ -755,6 +802,8 @@ func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]:
|
||||
&"action": &"BEGAN_WORK",
|
||||
})
|
||||
StaffTaskType.State.WORKING:
|
||||
if is_externally_driven_task(task_id):
|
||||
continue
|
||||
var progress_result := progress_task(task.task_id, 1, absolute_minute)
|
||||
if progress_result.is_success():
|
||||
actions.append({
|
||||
@@ -773,8 +822,11 @@ func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]:
|
||||
func cancel_task(
|
||||
task_id: StringName,
|
||||
reason: StringName = &"CANCELLED",
|
||||
absolute_minute: int = -1
|
||||
absolute_minute: int = -1,
|
||||
externally_driven := false
|
||||
) -> CommandResult:
|
||||
if is_externally_driven_task(task_id) and not externally_driven:
|
||||
return _conflict("External operation must release its cargo before cancellation.")
|
||||
var task := get_task(task_id)
|
||||
if task == null:
|
||||
return _not_found("Task", task_id)
|
||||
@@ -801,6 +853,8 @@ func remove_staff(
|
||||
var staff := get_staff(staff_id)
|
||||
if staff == null:
|
||||
return _not_found("Staff", staff_id)
|
||||
if is_externally_driven_task(staff.current_task_id):
|
||||
return _conflict("Finish the employee external operation before removal.")
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
if not staff.current_task_id.is_empty():
|
||||
var task := get_task(staff.current_task_id)
|
||||
@@ -842,6 +896,8 @@ func retire_staff(staff_id: StringName, absolute_minute: int = -1) -> CommandRes
|
||||
var staff := get_staff(staff_id)
|
||||
if staff == null:
|
||||
return _not_found("Staff", staff_id)
|
||||
if is_externally_driven_task(staff.current_task_id):
|
||||
return _conflict("Finish the employee external operation before retirement.")
|
||||
staff.is_retiring = true
|
||||
return remove_staff(staff_id, &"RETIRED", true, absolute_minute)
|
||||
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
class_name KitchenProductionService
|
||||
extends RefCounted
|
||||
|
||||
const Catalog = preload("res://scripts/supply/supply_catalog.gd")
|
||||
const SNAPSHOT_VERSION := 1
|
||||
|
||||
var current_minute := 0
|
||||
var last_error := ""
|
||||
var last_restore_error := ""
|
||||
|
||||
var _inventory: InventoryService
|
||||
var _facilities: FacilityService
|
||||
var _registry: DefinitionRegistry
|
||||
var _plans: Dictionary = {}
|
||||
var _active_batches: Dictionary = {}
|
||||
var _next_batch_sequence := 1
|
||||
|
||||
|
||||
func configure(
|
||||
inventory: InventoryService,
|
||||
facilities: FacilityService,
|
||||
registry: DefinitionRegistry,
|
||||
minute: int
|
||||
) -> void:
|
||||
_inventory = inventory
|
||||
_facilities = facilities
|
||||
_registry = registry
|
||||
current_minute = maxi(minute, 0)
|
||||
|
||||
|
||||
func set_plan(facility_id: StringName, enabled: bool, target_stock: int = Catalog.KITCHEN_TARGET_DEFAULT) -> Dictionary:
|
||||
if _kitchen(facility_id) == null:
|
||||
return _failure(&"INVALID_FACILITY", "Production plans require an existing kitchen.")
|
||||
if not _valid_target(target_stock):
|
||||
return _failure(&"INVALID_TARGET", "Target must be 4, 8, 12, or 16 meals.")
|
||||
_plans[facility_id] = {
|
||||
&"facility_id": String(facility_id), &"enabled": enabled, &"target_stock": target_stock,
|
||||
}
|
||||
last_error = ""
|
||||
return {&"ok": true, &"plan": get_plan(facility_id)}
|
||||
|
||||
|
||||
func get_plan(facility_id: StringName) -> Dictionary:
|
||||
if _plans.has(facility_id):
|
||||
return (_plans[facility_id] as Dictionary).duplicate(true)
|
||||
if _kitchen(facility_id) == null:
|
||||
return {}
|
||||
return {&"facility_id": String(facility_id), &"enabled": false, &"target_stock": Catalog.KITCHEN_TARGET_DEFAULT}
|
||||
|
||||
|
||||
func get_plans() -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for facility_id: StringName in _sorted_ids(_plans):
|
||||
result.append(get_plan(facility_id))
|
||||
return result
|
||||
|
||||
|
||||
## Also works after a successful facility removal, when the instance no longer
|
||||
## exists. Runtime checks enabled plans and batches before authorizing removal.
|
||||
func remove_plan(facility_id: StringName) -> Dictionary:
|
||||
if _active_batches.has(facility_id):
|
||||
return {&"ok": false, &"reason": &"ACTIVE_BATCH"}
|
||||
if _plans.has(facility_id) and bool(_plans[facility_id][&"enabled"]):
|
||||
return {&"ok": false, &"reason": &"PLAN_ENABLED"}
|
||||
var existed := _plans.erase(facility_id)
|
||||
return {&"ok": true, &"reason": &"REMOVED" if existed else &"NO_PLAN"}
|
||||
|
||||
|
||||
func get_report(facility_id: StringName) -> Dictionary:
|
||||
var facility := _kitchen(facility_id)
|
||||
if facility == null:
|
||||
return {}
|
||||
var plan := get_plan(facility_id)
|
||||
var batch: Dictionary = _active_batches.get(facility_id, {})
|
||||
var state := _production_state(facility, plan, batch, current_minute)
|
||||
return {
|
||||
&"facility_id": String(facility_id), &"inventory_id": String(facility.local_inventory_id),
|
||||
&"enabled": plan[&"enabled"], &"target_stock": plan[&"target_stock"],
|
||||
&"state": state, &"status": state,
|
||||
&"raw_stock": _inventory.available_quantity(facility.local_inventory_id, Catalog.RAW_FOOD, current_minute),
|
||||
&"meal_stock": _usable_quantity(facility.local_inventory_id, Catalog.BASIC_MEAL, current_minute),
|
||||
&"progress_minutes": int(batch.get(&"progress_minutes", 0)),
|
||||
&"duration_minutes": Catalog.PRODUCTION_MINUTES,
|
||||
&"active_batch": batch.duplicate(true),
|
||||
}
|
||||
|
||||
|
||||
## Runtime calls this after projecting current staffing/power/access. A batch
|
||||
## started at t first earns work at t+1; interruptions retain earned work.
|
||||
func advance_one_minute(minute: int) -> Dictionary:
|
||||
if not _configured():
|
||||
return _failure(&"NOT_CONFIGURED", "Inventory, facilities, and registry must be configured.")
|
||||
if minute == current_minute:
|
||||
return {&"ok": true, &"replay": true, &"events": []}
|
||||
if minute != current_minute + 1:
|
||||
return _failure(&"INVALID_MINUTE", "Production must advance one minute at a time.")
|
||||
current_minute = minute
|
||||
var events: Array[Dictionary] = []
|
||||
for facility_id: StringName in _sorted_ids(_plans):
|
||||
var facility := _kitchen(facility_id)
|
||||
var batch: Dictionary = _active_batches.get(facility_id, {})
|
||||
if facility == null:
|
||||
if not batch.is_empty():
|
||||
_release_batch(batch)
|
||||
_active_batches.erase(facility_id)
|
||||
events.append(_event(&"BATCH_FAILED", facility_id, batch, &"FACILITY_REMOVED"))
|
||||
_plans.erase(facility_id)
|
||||
continue
|
||||
# Expiry also closes reservations while a plan or facility is paused.
|
||||
if not batch.is_empty() and not _batch_links_valid(batch, minute):
|
||||
_release_batch(batch)
|
||||
_active_batches.erase(facility_id)
|
||||
events.append(_event(&"BATCH_FAILED", facility_id, batch, &"INPUT_UNAVAILABLE"))
|
||||
continue
|
||||
var plan: Dictionary = _plans[facility_id]
|
||||
var state := _production_state(facility, plan, batch, minute)
|
||||
if batch.is_empty():
|
||||
if state == &"READY":
|
||||
var result := _start_batch(facility, minute)
|
||||
if bool(result[&"ok"]):
|
||||
events.append(_event(&"BATCH_STARTED", facility_id, _active_batches[facility_id]))
|
||||
continue
|
||||
if state != &"PRODUCING":
|
||||
continue
|
||||
batch[&"progress_minutes"] = mini(int(batch[&"progress_minutes"]) + 1, Catalog.PRODUCTION_MINUTES)
|
||||
if int(batch[&"progress_minutes"]) == Catalog.PRODUCTION_MINUTES:
|
||||
var result := _complete_batch(batch, minute)
|
||||
if bool(result[&"ok"]):
|
||||
_active_batches.erase(facility_id)
|
||||
events.append(_event(&"BATCH_COMPLETED", facility_id, batch))
|
||||
else:
|
||||
# Atomic inventory failure keeps the reservation and full progress.
|
||||
events.append(_event(&"BATCH_WAITING", facility_id, batch, result.get(&"code", &"INVENTORY_ERROR")))
|
||||
last_error = ""
|
||||
return {&"ok": true, &"events": events}
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var batches: Array[Dictionary] = []
|
||||
for facility_id: StringName in _sorted_ids(_active_batches):
|
||||
batches.append((_active_batches[facility_id] as Dictionary).duplicate(true))
|
||||
return {
|
||||
&"version": SNAPSHOT_VERSION, &"current_minute": current_minute,
|
||||
&"next_batch_sequence": _next_batch_sequence,
|
||||
&"plans": get_plans(), &"active_batches": batches,
|
||||
}
|
||||
|
||||
|
||||
## Restore is transactional and read-only with respect to inventory. Inventory
|
||||
## and FacilityService must already contain their restored state.
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
last_restore_error = ""
|
||||
if not _configured() or not _keys_match(snapshot, [&"version", &"current_minute", &"next_batch_sequence", &"plans", &"active_batches"]):
|
||||
return _restore_failure("Missing dependencies or invalid snapshot fields.")
|
||||
if not _integer(snapshot[&"version"], SNAPSHOT_VERSION, SNAPSHOT_VERSION) \
|
||||
or not _integer(snapshot[&"current_minute"], 0) \
|
||||
or not _integer(snapshot[&"next_batch_sequence"], 1) \
|
||||
or not snapshot[&"plans"] is Array or not snapshot[&"active_batches"] is Array:
|
||||
return _restore_failure("Invalid snapshot version, clock, sequence, or collections.")
|
||||
var candidate_plans: Dictionary = {}
|
||||
for value: Variant in snapshot[&"plans"]:
|
||||
if not value is Dictionary or not _keys_match(value, [&"facility_id", &"enabled", &"target_stock"]):
|
||||
return _restore_failure("Invalid production plan fields.")
|
||||
var plan: Dictionary = value
|
||||
if not _text(plan[&"facility_id"]) or not plan[&"enabled"] is bool \
|
||||
or not _integer(plan[&"target_stock"], 4, Catalog.KITCHEN_TARGET_MAX) \
|
||||
or not _valid_target(int(plan[&"target_stock"])):
|
||||
return _restore_failure("Invalid production plan values.")
|
||||
var facility_id := StringName(plan[&"facility_id"])
|
||||
if candidate_plans.has(facility_id) or _kitchen(facility_id) == null:
|
||||
return _restore_failure("Duplicate plan or unknown kitchen.")
|
||||
candidate_plans[facility_id] = plan.duplicate(true)
|
||||
var candidate_batches: Dictionary = {}
|
||||
var seen_batches: Dictionary = {}
|
||||
for value: Variant in snapshot[&"active_batches"]:
|
||||
if not value is Dictionary or not _valid_batch_shape(value, int(snapshot[&"current_minute"]), int(snapshot[&"next_batch_sequence"])):
|
||||
return _restore_failure("Invalid active batch.")
|
||||
var batch: Dictionary = value
|
||||
var facility_id := StringName(batch[&"facility_id"])
|
||||
if not candidate_plans.has(facility_id) or candidate_batches.has(facility_id) or seen_batches.has(batch[&"batch_id"]):
|
||||
return _restore_failure("Duplicate batch or missing production plan.")
|
||||
seen_batches[batch[&"batch_id"]] = true
|
||||
candidate_batches[facility_id] = batch.duplicate(true)
|
||||
if not _crosslinks_valid(candidate_batches, int(snapshot[&"current_minute"]), int(snapshot[&"next_batch_sequence"])):
|
||||
return _restore_failure("Kitchen input reservations do not match the restored inventory.")
|
||||
_plans = candidate_plans
|
||||
_active_batches = candidate_batches
|
||||
_next_batch_sequence = int(snapshot[&"next_batch_sequence"])
|
||||
current_minute = int(snapshot[&"current_minute"])
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func validate_crosslinks() -> bool:
|
||||
return _configured() and _crosslinks_valid(_active_batches, current_minute, _next_batch_sequence)
|
||||
|
||||
|
||||
func _start_batch(facility: FacilityInstance, minute: int) -> Dictionary:
|
||||
var batch_id := StringName("kitchen.batch:%08d" % _next_batch_sequence)
|
||||
var reserved := _inventory.reserve_item(
|
||||
facility.local_inventory_id, Catalog.RAW_FOOD, Catalog.BATCH_INPUT,
|
||||
batch_id, Catalog.PRODUCTION_PURPOSE, minute
|
||||
)
|
||||
if not bool(reserved[&"ok"]):
|
||||
return reserved
|
||||
_next_batch_sequence += 1
|
||||
var reservation_ids: Array[String] = []
|
||||
for reservation_id: StringName in reserved[&"reservation_ids"]:
|
||||
reservation_ids.append(String(reservation_id))
|
||||
var definition := _registry.get_facility(facility.definition_id)
|
||||
_active_batches[facility.instance_id] = {
|
||||
&"batch_id": String(batch_id), &"facility_id": String(facility.instance_id),
|
||||
&"inventory_id": String(facility.local_inventory_id), &"started_at": minute,
|
||||
&"progress_minutes": 0, &"quality": clampi(definition.base_quality, 0, 100),
|
||||
&"reservation_ids": reservation_ids,
|
||||
}
|
||||
return {&"ok": true}
|
||||
|
||||
|
||||
func _complete_batch(batch: Dictionary, minute: int) -> Dictionary:
|
||||
if not _has_output_space(batch):
|
||||
return {&"ok": false, &"code": &"OUTPUT_FULL"}
|
||||
var before := _inventory.create_snapshot()
|
||||
var consumed := _inventory.consume_reservations(_reservation_ids(batch), &"", minute)
|
||||
if not bool(consumed[&"ok"]):
|
||||
return consumed
|
||||
var item := Catalog.get_item(Catalog.BASIC_MEAL)
|
||||
var produced := _inventory.add_stack(
|
||||
StringName(batch[&"inventory_id"]), Catalog.BASIC_MEAL, Catalog.BATCH_OUTPUT,
|
||||
int(batch[&"quality"]), minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"])
|
||||
)
|
||||
if not bool(produced[&"ok"]):
|
||||
_inventory.restore_snapshot(before)
|
||||
return produced
|
||||
|
||||
|
||||
func _production_state(facility: FacilityInstance, plan: Dictionary, batch: Dictionary, minute: int) -> StringName:
|
||||
if not bool(plan[&"enabled"]): return &"DISABLED"
|
||||
if facility.durability <= 0: return &"BROKEN"
|
||||
if not facility.access_available: return &"NO_ACCESS"
|
||||
if not facility.power_available: return &"NO_POWER"
|
||||
if facility.active_staff_ids.size() < maxi(facility.staff_min, 1): return &"NO_STAFF"
|
||||
if not facility.operator_open or not facility.is_within_opening_hours(minute): return &"CLOSED"
|
||||
if not batch.is_empty():
|
||||
if not _batch_links_valid(batch, minute): return &"INPUT_UNAVAILABLE"
|
||||
if int(batch[&"progress_minutes"]) >= Catalog.PRODUCTION_MINUTES and not _has_output_space(batch):
|
||||
return &"OUTPUT_FULL"
|
||||
return &"PRODUCING"
|
||||
if _usable_quantity(facility.local_inventory_id, Catalog.BASIC_MEAL, minute) + Catalog.BATCH_OUTPUT > int(plan[&"target_stock"]):
|
||||
return &"TARGET_REACHED"
|
||||
if _inventory.available_quantity(facility.local_inventory_id, Catalog.RAW_FOOD, minute) < Catalog.BATCH_INPUT:
|
||||
return &"NO_INPUT"
|
||||
return &"READY"
|
||||
|
||||
|
||||
func _has_output_space(batch: Dictionary) -> bool:
|
||||
var consumed_weight := 0
|
||||
for reservation_id: StringName in _reservation_ids(batch):
|
||||
var reservation := _inventory.get_reservation(reservation_id)
|
||||
if reservation == null: return false
|
||||
var stack := _inventory.get_stack(reservation.stack_id)
|
||||
if stack.is_empty(): return false
|
||||
consumed_weight += reservation.quantity * int(stack[&"unit_weight"])
|
||||
var after_weight := Catalog.inventory_weight(_inventory, StringName(batch[&"inventory_id"])) - consumed_weight + Catalog.BATCH_OUTPUT
|
||||
return after_weight <= Catalog.storage_capacity(&"kitchen")
|
||||
|
||||
|
||||
func _batch_links_valid(batch: Dictionary, minute: int) -> bool:
|
||||
var facility := _kitchen(StringName(batch[&"facility_id"]))
|
||||
if facility == null or facility.local_inventory_id != StringName(batch[&"inventory_id"]): return false
|
||||
var quantity := 0
|
||||
var allocated_by_stack: Dictionary = {}
|
||||
for reservation_id: StringName in _reservation_ids(batch):
|
||||
var reservation := _inventory.get_reservation(reservation_id)
|
||||
if reservation == null or not reservation.is_active() or reservation.is_expired(minute) or reservation.quantity <= 0: return false
|
||||
if reservation.owner_inventory_id != facility.local_inventory_id \
|
||||
or reservation.item_id != Catalog.RAW_FOOD or reservation.requester_id != StringName(batch[&"batch_id"]) \
|
||||
or reservation.purpose != Catalog.PRODUCTION_PURPOSE or reservation.created_at != int(batch[&"started_at"]): return false
|
||||
var stack := _inventory.get_stack(reservation.stack_id)
|
||||
if stack.is_empty() or StringName(stack[&"owner_inventory_id"]) != facility.local_inventory_id \
|
||||
or StringName(stack[&"item_id"]) != Catalog.RAW_FOOD \
|
||||
or int(stack[&"reserved_quantity"]) < reservation.quantity or int(stack[&"quantity"]) < reservation.quantity: return false
|
||||
if int(stack[&"expires_at"]) >= 0 and int(stack[&"expires_at"]) <= minute: return false
|
||||
allocated_by_stack[reservation.stack_id] = int(allocated_by_stack.get(reservation.stack_id, 0)) + reservation.quantity
|
||||
if int(allocated_by_stack[reservation.stack_id]) > int(stack[&"reserved_quantity"]): return false
|
||||
quantity += reservation.quantity
|
||||
return quantity == Catalog.BATCH_INPUT
|
||||
|
||||
|
||||
func _crosslinks_valid(batches: Dictionary, minute: int, next_sequence: int) -> bool:
|
||||
var linked: Dictionary = {}
|
||||
for batch: Dictionary in batches.values():
|
||||
if not _batch_links_valid(batch, minute): return false
|
||||
for reservation_id: StringName in _reservation_ids(batch):
|
||||
if linked.has(reservation_id): return false
|
||||
linked[reservation_id] = true
|
||||
# Inventory retains terminal reservations. Their requester IDs are durable
|
||||
# evidence that a batch number was already used, even after completion,
|
||||
# failure, or removal of the kitchen's plan.
|
||||
for reservation: InventoryReservation in _inventory.get_reservations():
|
||||
if reservation.purpose != Catalog.PRODUCTION_PURPOSE:
|
||||
continue
|
||||
var sequence := _batch_sequence(reservation.requester_id)
|
||||
if sequence < 1 or sequence >= next_sequence:
|
||||
return false
|
||||
if reservation.is_active() and not linked.has(reservation.reservation_id):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _release_batch(batch: Dictionary) -> void:
|
||||
for reservation_id: StringName in _reservation_ids(batch):
|
||||
var reservation := _inventory.get_reservation(reservation_id)
|
||||
# Only release this batch's own holds if external state was corrupted.
|
||||
if reservation != null and reservation.requester_id == StringName(batch[&"batch_id"]) \
|
||||
and reservation.purpose == Catalog.PRODUCTION_PURPOSE:
|
||||
_inventory.release_reservation(reservation_id, &"", current_minute)
|
||||
|
||||
|
||||
func _kitchen(facility_id: StringName) -> FacilityInstance:
|
||||
if not _configured(): return null
|
||||
var facility := _facilities.get_instance(facility_id)
|
||||
if facility == null or facility.definition_id != &"kitchen" or facility.local_inventory_id == &"": return null
|
||||
if _registry.get_facility(facility.definition_id) == null: return null
|
||||
return facility
|
||||
|
||||
|
||||
func _configured() -> bool:
|
||||
return _inventory != null and _facilities != null and _registry != null
|
||||
|
||||
|
||||
func _usable_quantity(inventory_id: StringName, item_id: StringName, minute: int) -> int:
|
||||
var quantity := 0
|
||||
for stack: Dictionary in _inventory.get_stacks(inventory_id):
|
||||
if StringName(stack[&"item_id"]) == item_id and (int(stack[&"expires_at"]) < 0 or int(stack[&"expires_at"]) > minute):
|
||||
quantity += int(stack[&"quantity"])
|
||||
return quantity
|
||||
|
||||
|
||||
func _valid_batch_shape(batch: Dictionary, minute: int, next_sequence: int) -> bool:
|
||||
if not _keys_match(batch, [&"batch_id", &"facility_id", &"inventory_id", &"started_at", &"progress_minutes", &"quality", &"reservation_ids"]): return false
|
||||
for key: StringName in [&"batch_id", &"facility_id", &"inventory_id"]:
|
||||
if not _text(batch[key]): return false
|
||||
if not _integer(batch[&"started_at"], 0, minute) \
|
||||
or not _integer(batch[&"progress_minutes"], 0, Catalog.PRODUCTION_MINUTES) \
|
||||
or int(batch[&"progress_minutes"]) > minute - int(batch[&"started_at"]) \
|
||||
or not _integer(batch[&"quality"], 0, 100) or not batch[&"reservation_ids"] is Array:
|
||||
return false
|
||||
var sequence := _batch_sequence(batch[&"batch_id"])
|
||||
if sequence < 1 or sequence >= next_sequence: return false
|
||||
var seen: Dictionary = {}
|
||||
for reservation_id: Variant in batch[&"reservation_ids"]:
|
||||
if not _text(reservation_id) or seen.has(StringName(reservation_id)): return false
|
||||
seen[StringName(reservation_id)] = true
|
||||
return not seen.is_empty()
|
||||
|
||||
|
||||
func _batch_sequence(value: Variant) -> int:
|
||||
if not _text(value): return -1
|
||||
var batch_id := String(value)
|
||||
var sequence_text := batch_id.trim_prefix("kitchen.batch:")
|
||||
if not sequence_text.is_valid_int(): return -1
|
||||
var sequence := sequence_text.to_int()
|
||||
if sequence < 1 or batch_id != "kitchen.batch:%08d" % sequence: return -1
|
||||
return sequence
|
||||
|
||||
|
||||
func _reservation_ids(batch: Dictionary) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for value: Variant in batch[&"reservation_ids"]:
|
||||
ids.append(StringName(value))
|
||||
return ids
|
||||
|
||||
|
||||
func _valid_target(target: int) -> bool:
|
||||
return target >= Catalog.BATCH_OUTPUT and target <= Catalog.KITCHEN_TARGET_MAX and target % Catalog.BATCH_OUTPUT == 0
|
||||
|
||||
|
||||
func _integer(value: Variant, minimum: int, maximum: int = 9223372036854775807) -> bool:
|
||||
return typeof(value) == TYPE_INT and value >= minimum and value <= maximum
|
||||
|
||||
|
||||
func _text(value: Variant) -> bool:
|
||||
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and not String(value).strip_edges().is_empty()
|
||||
|
||||
|
||||
func _keys_match(value: Dictionary, expected: Array) -> bool:
|
||||
if value.size() != expected.size(): return false
|
||||
for key: StringName in expected:
|
||||
if not value.has(key): return false
|
||||
return true
|
||||
|
||||
|
||||
func _sorted_ids(values: Dictionary) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for value: Variant in values.keys(): ids.append(StringName(value))
|
||||
ids.sort()
|
||||
return ids
|
||||
|
||||
|
||||
func _event(kind: StringName, facility_id: StringName, batch: Dictionary, reason: StringName = &"") -> Dictionary:
|
||||
return {
|
||||
&"type": kind, &"facility_id": String(facility_id), &"batch_id": batch[&"batch_id"],
|
||||
&"minute": current_minute, &"reason": reason,
|
||||
&"output_quantity": Catalog.BATCH_OUTPUT if kind == &"BATCH_COMPLETED" else 0,
|
||||
}
|
||||
|
||||
|
||||
func _failure(code: StringName, message: String) -> Dictionary:
|
||||
last_error = message
|
||||
return {&"ok": false, &"code": code, &"message": message, &"events": []}
|
||||
|
||||
|
||||
func _restore_failure(message: String) -> bool:
|
||||
last_restore_error = message
|
||||
return false
|
||||
@@ -0,0 +1 @@
|
||||
uid://brvl7twad1pru
|
||||
@@ -0,0 +1,61 @@
|
||||
class_name SupplyCatalog
|
||||
extends RefCounted
|
||||
|
||||
## Explicit P1 tuning values. The existing raw_food -> basic_meal IDs and
|
||||
## recipe remain compatible with facility data; cooking points/tableware are
|
||||
## a later specification change, not silently introduced by this catalogue.
|
||||
const RAW_FOOD := &"ingredient/raw_food"
|
||||
const BASIC_MEAL := &"ingredient/basic_meal"
|
||||
const RAW_TARGET := 8
|
||||
const KITCHEN_TARGET_DEFAULT := 8
|
||||
const KITCHEN_TARGET_MAX := 16
|
||||
const BATCH_INPUT := 4
|
||||
const BATCH_OUTPUT := 4
|
||||
const PRODUCTION_MINUTES := 15
|
||||
const PRODUCTION_PURPOSE := &"kitchen_production"
|
||||
|
||||
|
||||
static func get_item(item_id: StringName) -> Dictionary:
|
||||
match item_id:
|
||||
RAW_FOOD:
|
||||
return {
|
||||
&"item_id": RAW_FOOD, &"display_name": "調理用食材",
|
||||
&"unit_price": 3, &"unit_weight": 1, &"shelf_life_minutes": 1440,
|
||||
&"purchasable": true,
|
||||
}
|
||||
BASIC_MEAL:
|
||||
return {
|
||||
&"item_id": BASIC_MEAL, &"display_name": "基本料理",
|
||||
&"unit_price": 0, &"unit_weight": 1, &"shelf_life_minutes": 720,
|
||||
&"purchasable": false,
|
||||
}
|
||||
return {}
|
||||
|
||||
|
||||
static func get_purchase_items() -> Array[Dictionary]:
|
||||
return [get_item(RAW_FOOD)]
|
||||
|
||||
|
||||
static func storage_capacity(definition_id: StringName) -> int:
|
||||
match definition_id:
|
||||
&"warehouse": return 100
|
||||
&"kitchen": return 40
|
||||
&"cafeteria": return 24
|
||||
return 0
|
||||
|
||||
|
||||
static func get_recipe() -> Dictionary:
|
||||
return {
|
||||
&"input_item_id": RAW_FOOD, &"input_quantity": BATCH_INPUT,
|
||||
&"output_item_id": BASIC_MEAL, &"output_quantity": BATCH_OUTPUT,
|
||||
&"duration_minutes": PRODUCTION_MINUTES, &"minimum_staff": 1,
|
||||
}
|
||||
|
||||
|
||||
## All physical stacks, including reserved/expired goods, occupy storage until
|
||||
## the inventory owner actually removes them.
|
||||
static func inventory_weight(inventory: InventoryService, inventory_id: StringName) -> int:
|
||||
var total := 0
|
||||
for stack: Dictionary in inventory.get_stacks(inventory_id):
|
||||
total += int(stack[&"quantity"]) * int(stack[&"unit_weight"])
|
||||
return total
|
||||
@@ -0,0 +1 @@
|
||||
uid://uqlj4hkioxck
|
||||
@@ -0,0 +1,560 @@
|
||||
class_name SupplyLogisticsBridge
|
||||
extends RefCounted
|
||||
|
||||
## Execution state lives in LogisticsRequest, StaffTask, and InventoryService.
|
||||
## This adapter owns no additional save payload and never moves an actor itself.
|
||||
const REQUEST_PREFIX := "supply."
|
||||
const PICKUP_TYPE := &"supply_pickup"
|
||||
const DELIVERY_TYPE := &"supply_delivery"
|
||||
const RETURN_TYPE := &"supply_return"
|
||||
const RETRY_MINUTES := 5
|
||||
const RETURNING := "RETURNING_EXPIRED_CARGO"
|
||||
|
||||
var _runtime: Variant
|
||||
var last_report: Dictionary = {}
|
||||
|
||||
|
||||
func configure(runtime: Variant) -> SupplyLogisticsBridge:
|
||||
_runtime = runtime
|
||||
_runtime.staff_service.configure_external_task_driver(Callable(self, "owns_task"))
|
||||
return self
|
||||
|
||||
|
||||
func owns_task(task_id: StringName) -> bool:
|
||||
if _runtime == null:
|
||||
return false
|
||||
var task: StaffTask = _runtime.staff_service.get_task(task_id)
|
||||
return task != null and task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] \
|
||||
and String(task.target_entity_id).begins_with(REQUEST_PREFIX)
|
||||
|
||||
|
||||
func can_move_task(task_id: StringName) -> bool:
|
||||
if not owns_task(task_id):
|
||||
return true
|
||||
var task: StaffTask = _runtime.staff_service.get_task(task_id)
|
||||
var staff: StaffRuntime = _runtime.staff_service.get_staff(task.assigned_staff_id)
|
||||
if staff == null or not staff.can_take_shift():
|
||||
return false
|
||||
# Partial spoilage is a return obligation. Do not keep walking to the
|
||||
# customer while the bridge is still searching for a safe return route.
|
||||
return task.task_type != DELIVERY_TYPE or not is_returning(task.target_entity_id)
|
||||
|
||||
|
||||
func tick(absolute_minute: int) -> Dictionary:
|
||||
if _runtime == null:
|
||||
return {&"ok": false, &"reason": &"NOT_CONFIGURED"}
|
||||
var actions: Array[Dictionary] = []
|
||||
for request_id: StringName in _request_ids(_runtime.logistics_requests):
|
||||
var request: LogisticsRequest = _runtime.logistics_requests[request_id]
|
||||
if request.is_terminal():
|
||||
continue
|
||||
if request.state == LogisticsRequest.State.BLOCKED:
|
||||
if absolute_minute >= request.state_changed_at + RETRY_MINUTES:
|
||||
_rearm(request, absolute_minute)
|
||||
continue
|
||||
if request.state == LogisticsRequest.State.RESERVED:
|
||||
if not _reserved_stock_valid(request):
|
||||
_block_before_pickup(request, "SOURCE_STOCK_EXPIRED", absolute_minute)
|
||||
else:
|
||||
_dispatch(request, absolute_minute)
|
||||
continue
|
||||
var task := _active_task(_runtime.staff_service, request.request_id)
|
||||
var staff: StaffRuntime = _runtime.staff_service.get_staff(request.assigned_carrier_id)
|
||||
if task == null or staff == null or staff.current_task_id != task.task_id:
|
||||
request.state_reason = "CARRIER_TASK_MISSING"
|
||||
continue
|
||||
if request.state == LogisticsRequest.State.DELIVERING and _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)).is_empty():
|
||||
_cancel_empty_cargo(request, staff, task, "CARGO_EXPIRED", absolute_minute)
|
||||
continue
|
||||
if not staff.can_take_shift():
|
||||
if request.state in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP]:
|
||||
_block_before_pickup(request, "CARRIER_UNAVAILABLE", absolute_minute)
|
||||
else:
|
||||
request.state_reason = "CARRIER_UNAVAILABLE"
|
||||
continue
|
||||
if request.state in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP]:
|
||||
if not _reserved_stock_valid(request):
|
||||
_block_before_pickup(request, "SOURCE_STOCK_EXPIRED", absolute_minute)
|
||||
elif _arrived_for_work(staff, task, absolute_minute):
|
||||
if _pickup(request, staff, task, absolute_minute):
|
||||
actions.append({&"request_id": request_id, &"action": &"PICKED_UP"})
|
||||
elif request.state == LogisticsRequest.State.DELIVERING:
|
||||
var cargo := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id))
|
||||
if cargo.is_empty():
|
||||
_cancel_empty_cargo(request, staff, task, "CARGO_EXPIRED", absolute_minute)
|
||||
continue
|
||||
if not _quantities_cover(cargo, request.item_requirements) and task.task_type != RETURN_TYPE:
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
var route := _access_path(staff.current_cell, source)
|
||||
if not bool(route.get(&"reachable", false)):
|
||||
request.state_reason = "EXPIRED_CARGO_RETURN_BLOCKED"
|
||||
continue
|
||||
if not _runtime.staff_service.retarget_external_task(task.task_id, route[&"cell"], absolute_minute).is_success():
|
||||
continue
|
||||
task.task_type = RETURN_TYPE
|
||||
request.state_reason = RETURNING
|
||||
continue
|
||||
if _arrived_for_work(staff, task, absolute_minute):
|
||||
if _deliver(request, staff, task, absolute_minute):
|
||||
actions.append({&"request_id": request_id, &"action": &"DELIVERED"})
|
||||
last_report = {&"ok": true, &"actions": actions}
|
||||
return last_report.duplicate(true)
|
||||
|
||||
|
||||
func handle_path_stuck(task_id: StringName, absolute_minute: int) -> Dictionary:
|
||||
if not owns_task(task_id):
|
||||
return {&"ok": false, &"action": &"NOT_SUPPLY_TASK"}
|
||||
var task: StaffTask = _runtime.staff_service.get_task(task_id)
|
||||
var request: LogisticsRequest = _runtime.logistics_requests.get(task.target_entity_id)
|
||||
if request == null:
|
||||
return {&"ok": false, &"action": &"REQUEST_MISSING"}
|
||||
if request.state == LogisticsRequest.State.DELIVERING:
|
||||
# Loaded cargo stays with its actor, even when no safe return route exists.
|
||||
# The normal Path loop retries after topology is repaired.
|
||||
request.retry_count += 1
|
||||
if task.task_type != RETURN_TYPE:
|
||||
request.state_reason = "ROUTE_BLOCKED"
|
||||
task.state_reason = &"ROUTE_BLOCKED"
|
||||
return {&"ok": true, &"action": &"SUPPLY_CARGO_HELD"}
|
||||
return {&"ok": _block_before_pickup(request, "ROUTE_BLOCKED", absolute_minute), &"action": &"SUPPLY_PICKUP_RETRY"}
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
var rows: Array[Dictionary] = []
|
||||
if _runtime == null:
|
||||
return {&"requests": rows, &"deliveries": rows.duplicate(true)}
|
||||
for request_id: StringName in _request_ids(_runtime.logistics_requests):
|
||||
var request: LogisticsRequest = _runtime.logistics_requests[request_id]
|
||||
var task := _active_task(_runtime.staff_service, request_id)
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
||||
var worker: StaffRuntime = _runtime.staff_service.get_staff(request.assigned_carrier_id)
|
||||
rows.append({
|
||||
&"source_facility_id": source.instance_id if source != null else &"",
|
||||
&"destination_facility_id": destination.instance_id if destination != null else &"",
|
||||
&"carrier_name": String(_runtime.mvp_content_catalog.get_entry(&"staff_candidates", worker.staff_id).get(&"display_name", worker.staff_id)) if worker != null else "",
|
||||
&"reason_codes": [] if request.state_reason.is_empty() else [StringName(request.state_reason)],
|
||||
&"request_id": request_id, &"state": LogisticsRequest.State.keys()[request.state],
|
||||
&"reason": request.state_reason, &"carrier_id": request.assigned_carrier_id,
|
||||
&"source_inventory_id": request.source_inventory_id,
|
||||
&"destination_inventory_id": request.destination_inventory_id,
|
||||
&"item_requirements": request.item_requirements.duplicate(true), &"weight": request.total_weight,
|
||||
&"retry_count": request.retry_count, &"task_id": task.task_id if task != null else &"",
|
||||
})
|
||||
var active: Array[Dictionary] = []
|
||||
var recent: Array[Dictionary] = []
|
||||
for row: Dictionary in rows:
|
||||
var request: LogisticsRequest = _runtime.logistics_requests[row[&"request_id"]]
|
||||
if request.is_terminal(): recent.append(row)
|
||||
else: active.append(row)
|
||||
recent.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
var left: LogisticsRequest = _runtime.logistics_requests[a[&"request_id"]]
|
||||
var right: LogisticsRequest = _runtime.logistics_requests[b[&"request_id"]]
|
||||
if left.state_changed_at != right.state_changed_at: return left.state_changed_at > right.state_changed_at
|
||||
return String(left.request_id) > String(right.request_id))
|
||||
active.append_array(recent.slice(0, 10))
|
||||
return {&"requests": rows, &"deliveries": active}
|
||||
|
||||
|
||||
func is_returning(request_id: StringName) -> bool:
|
||||
if _runtime == null:
|
||||
return false
|
||||
var request: LogisticsRequest = _runtime.logistics_requests.get(request_id)
|
||||
if request == null or request.state != LogisticsRequest.State.DELIVERING:
|
||||
return false
|
||||
var task := _active_task(_runtime.staff_service, request_id)
|
||||
if task != null and task.task_type == RETURN_TYPE:
|
||||
return true
|
||||
return not _quantities_cover(_cargo_quantities(_runtime.inventory_service, _cargo_id(request.assigned_carrier_id)), request.item_requirements)
|
||||
|
||||
|
||||
func _dispatch(request: LogisticsRequest, minute: int) -> void:
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
||||
if source == null or destination == null or not source.operator_open or not destination.operator_open:
|
||||
request.state_reason = "FACILITY_UNAVAILABLE"
|
||||
return
|
||||
if not _has_storage_space(destination, request.total_weight):
|
||||
request.state_reason = "DESTINATION_FULL"
|
||||
return
|
||||
var carriers: Dictionary = {}
|
||||
var source_cells: Dictionary = {}
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id)
|
||||
if staff.role_id != &"porter" or not staff.is_available_for_dispatch() \
|
||||
or shift == null or not shift.contains_minute(minute) \
|
||||
or _runtime.logistics_dispatcher.get_reserved_load(staff.staff_id) > 0 \
|
||||
or not _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)).is_empty():
|
||||
continue
|
||||
var access := _access_path(staff.current_cell, source)
|
||||
if not bool(access.get(&"reachable", false)):
|
||||
continue
|
||||
var onward := _access_path(access[&"cell"], destination)
|
||||
if not bool(onward.get(&"reachable", false)):
|
||||
continue
|
||||
carriers[staff.staff_id] = {&"capacity": mini(20, 10 + int(staff.skills.get(&"operation", 0)) / 10),
|
||||
&"current_load": 0, &"path_minutes": int(access[&"steps"]), &"position": staff.current_cell, &"role": &"logistics"}
|
||||
source_cells[staff.staff_id] = access[&"cell"]
|
||||
if carriers.is_empty():
|
||||
request.state_reason = "NO_AVAILABLE_PORTER_OR_ROUTE"
|
||||
return
|
||||
var before := _capture(request)
|
||||
var assigned: Array = _runtime.logistics_dispatcher.dispatch([request], carriers, minute)
|
||||
if assigned.is_empty():
|
||||
request.state_reason = "CARRIER_CAPACITY_EXCEEDED"
|
||||
return
|
||||
var staff_id := request.assigned_carrier_id
|
||||
var task := _create_task(request, PICKUP_TYPE, source_cells[staff_id], minute)
|
||||
if task == null or not _runtime.staff_service.assign_task_to_staff(task.task_id, staff_id, minute).is_success():
|
||||
_rollback(request, before)
|
||||
request.state_reason = "TASK_ASSIGNMENT_FAILED"
|
||||
return
|
||||
request.state_reason = ""
|
||||
|
||||
|
||||
func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, minute: int) -> bool:
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
if source == null or not _cell_touches_facility(staff.current_cell, source):
|
||||
return _block_before_pickup(request, "INVALID_PICKUP_ACCESS", minute) and false
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
||||
if destination == null or not destination.operator_open or not _has_storage_space(destination, request.total_weight):
|
||||
return _block_before_pickup(request, "DESTINATION_FULL_OR_CLOSED", minute) and false
|
||||
var route := _access_path(staff.current_cell, destination)
|
||||
if not bool(route.get(&"reachable", false)):
|
||||
return _block_before_pickup(request, "NO_DELIVERY_ROUTE", minute) and false
|
||||
var before := _capture(request)
|
||||
if request.state == LogisticsRequest.State.ASSIGNED and not _advance_request(request, minute):
|
||||
_rollback(request, before)
|
||||
return false
|
||||
if not _advance_request(request, minute):
|
||||
_rollback(request, before)
|
||||
return _block_before_pickup(request, "PICKUP_FAILED", minute) and false
|
||||
if not _runtime.staff_service.progress_task(task.task_id, 1, minute, true).is_success():
|
||||
_rollback(request, before)
|
||||
return false
|
||||
staff.carried_inventory_id = _cargo_id(staff.staff_id)
|
||||
var delivery := _create_task(request, DELIVERY_TYPE, route[&"cell"], minute)
|
||||
if delivery == null or not _runtime.staff_service.assign_task_to_staff(delivery.task_id, staff.staff_id, minute).is_success():
|
||||
_rollback(request, before)
|
||||
return false
|
||||
request.state_reason = ""
|
||||
return true
|
||||
|
||||
|
||||
func _deliver(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, minute: int) -> bool:
|
||||
var returning := task.task_type == RETURN_TYPE
|
||||
var destination_id := request.source_inventory_id if returning else request.destination_inventory_id
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, destination_id)
|
||||
if destination == null or not _cell_touches_facility(staff.current_cell, destination):
|
||||
request.state_reason = "INVALID_DELIVERY_ACCESS"
|
||||
return false
|
||||
var quantities := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id))
|
||||
if destination == null or not destination.operator_open or not _has_storage_space(destination, SupplyCatalog.inventory_weight(_runtime.inventory_service, _cargo_id(staff.staff_id))):
|
||||
if not returning:
|
||||
request.state_reason = "DESTINATION_FULL"
|
||||
return false
|
||||
var before := _capture(request)
|
||||
if returning:
|
||||
var transfers: Array[Dictionary] = []
|
||||
for item_id: StringName in _sorted_keys(quantities):
|
||||
transfers.append({&"source_inventory_id": _cargo_id(staff.staff_id), &"destination_inventory_id": destination_id,
|
||||
&"item_id": item_id, &"quantity": int(quantities[item_id]), &"operation_id": StringName("supply.return.%s.%s" % [request.request_id, item_id])})
|
||||
if not _runtime.inventory_service.transfer_available_batch(transfers, minute)[&"ok"] \
|
||||
or not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, staff.staff_id, request.total_weight):
|
||||
_rollback(request, before)
|
||||
return false
|
||||
request.cancel("CARGO_EXPIRED_RETURNED", minute)
|
||||
else:
|
||||
if not _advance_request(request, minute):
|
||||
_rollback(request, before)
|
||||
request.state_reason = "DELIVERY_RETRY"
|
||||
return false
|
||||
if not _runtime.staff_service.progress_task(task.task_id, 1, minute, true).is_success():
|
||||
_rollback(request, before)
|
||||
return false
|
||||
staff.carried_inventory_id = &""
|
||||
return true
|
||||
|
||||
|
||||
func _block_before_pickup(request: LogisticsRequest, reason: String, minute: int) -> bool:
|
||||
var before := _capture(request)
|
||||
var task := _active_task(_runtime.staff_service, request.request_id)
|
||||
var carrier_id := request.assigned_carrier_id
|
||||
var worker: StaffRuntime = _runtime.staff_service.get_staff(carrier_id)
|
||||
var unavailable_state := worker.state if worker != null and not worker.can_take_shift() else -1
|
||||
for reservation_id: StringName in request.reservation_ids:
|
||||
var reservation: InventoryReservation = _runtime.inventory_service.get_reservation(reservation_id)
|
||||
if reservation != null and reservation.is_active() \
|
||||
and not _runtime.inventory_service.release_reservation(reservation_id, &"", minute)[&"ok"]:
|
||||
_rollback(request, before)
|
||||
return false
|
||||
if not carrier_id.is_empty() and not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, carrier_id, request.total_weight):
|
||||
_rollback(request, before)
|
||||
return false
|
||||
if task != null and not _runtime.staff_service.cancel_task(task.task_id, StringName(reason), minute, true).is_success():
|
||||
_rollback(request, before)
|
||||
return false
|
||||
if unavailable_state >= 0:
|
||||
worker.transition_to(unavailable_state, StringName(reason), minute)
|
||||
request.mark_blocked(reason, minute)
|
||||
return true
|
||||
|
||||
|
||||
func _rearm(request: LogisticsRequest, minute: int) -> void:
|
||||
if request.deadline >= 0 and request.deadline <= minute:
|
||||
request.cancel("DEADLINE_EXPIRED", minute)
|
||||
return
|
||||
var before := _capture(request)
|
||||
var ids: Array[StringName] = []
|
||||
var stacks: Array[StringName] = []
|
||||
var weight := 0
|
||||
for item_id: StringName in _sorted_keys(request.item_requirements):
|
||||
var result: Dictionary = _runtime.inventory_service.reserve_item(request.source_inventory_id, item_id,
|
||||
int(request.item_requirements[item_id]), request.request_id, &"logistics", minute, request.deadline,
|
||||
StringName("supply.retry.%s.%d.%s" % [request.request_id, minute, item_id]))
|
||||
if not bool(result.get(&"ok", false)):
|
||||
_rollback(request, before)
|
||||
request.state_changed_at = minute
|
||||
request.state_reason = "WAITING_FOR_SOURCE_STOCK"
|
||||
return
|
||||
for id: StringName in result[&"reservation_ids"]:
|
||||
ids.append(id)
|
||||
var reservation: InventoryReservation = _runtime.inventory_service.get_reservation(id)
|
||||
stacks.append(reservation.stack_id)
|
||||
weight += reservation.quantity * int(_runtime.inventory_service.get_stack(reservation.stack_id)[&"unit_weight"])
|
||||
if weight != request.total_weight or not request.mark_reserved(ids, stacks, minute):
|
||||
_rollback(request, before)
|
||||
request.state_changed_at = minute
|
||||
request.state_reason = "SOURCE_WEIGHT_CHANGED"
|
||||
|
||||
|
||||
func _cancel_empty_cargo(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, reason: String, minute: int) -> void:
|
||||
var before := _capture(request)
|
||||
var unavailable_state := staff.state if not staff.can_take_shift() else -1
|
||||
if not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, staff.staff_id, request.total_weight) \
|
||||
or not _runtime.staff_service.cancel_task(task.task_id, StringName(reason), minute, true).is_success():
|
||||
_rollback(request, before)
|
||||
return
|
||||
staff.carried_inventory_id = &""
|
||||
if unavailable_state >= 0:
|
||||
staff.transition_to(unavailable_state, StringName(reason), minute)
|
||||
request.cancel(reason, minute)
|
||||
|
||||
|
||||
func _create_task(request: LogisticsRequest, type: StringName, cell: Vector3i, minute: int) -> StaffTask:
|
||||
var leg := "pickup" if type == PICKUP_TYPE else "delivery"
|
||||
var task_id := StringName("supply.%s.%s.%d" % [leg, request.request_id, request.retry_count])
|
||||
var result: CommandResult = _runtime.staff_service.create_task(type, request.priority, minute, {
|
||||
&"target_entity_id": request.request_id, &"target_cell": cell, &"required_role_tags": [&"porter"],
|
||||
&"task_tags": [&"logistics"], &"estimated_minutes": 1, &"interrupt_level": 0,
|
||||
}, task_id)
|
||||
return _runtime.staff_service.get_task(task_id) if result.is_success() else null
|
||||
|
||||
|
||||
func _advance_request(request: LogisticsRequest, minute: int) -> bool:
|
||||
var command := SimulationCommand.new()
|
||||
command.command_id = 0
|
||||
command.command_type = &"advance_logistics_request"
|
||||
command.payload = {&"request_id": request.request_id}
|
||||
command.issued_tick = minute
|
||||
command.execute_tick = minute
|
||||
return _runtime._execute_advance_logistics_request(command).is_success()
|
||||
|
||||
|
||||
func _capture(request: LogisticsRequest) -> Dictionary:
|
||||
return {&"request": request.create_snapshot(), &"staff": _runtime.staff_service.create_snapshot(),
|
||||
&"inventory": _runtime.inventory_service.create_snapshot(), &"dispatcher": _runtime.logistics_dispatcher.create_snapshot(),
|
||||
&"events": _runtime.domain_events.create_snapshot(), &"ids": _runtime.id_generator.create_snapshot()}
|
||||
|
||||
|
||||
func _rollback(request: LogisticsRequest, before: Dictionary) -> void:
|
||||
_runtime.staff_service.restore_snapshot(before[&"staff"])
|
||||
_runtime.inventory_service.restore_snapshot(before[&"inventory"])
|
||||
_runtime.logistics_dispatcher.restore_snapshot(before[&"dispatcher"])
|
||||
_runtime.domain_events.restore_snapshot(before[&"events"])
|
||||
_runtime.id_generator.restore_snapshot(before[&"ids"])
|
||||
var restored := LogisticsRequest.from_snapshot(before[&"request"])
|
||||
# Keep the Request identity while callers finish handling this transition.
|
||||
for property: Dictionary in restored.get_property_list():
|
||||
var name := StringName(property[&"name"])
|
||||
if name in [&"RefCounted", &"script"] or not (int(property[&"usage"]) & PROPERTY_USAGE_SCRIPT_VARIABLE):
|
||||
continue
|
||||
request.set(name, restored.get(name))
|
||||
_runtime.logistics_requests[request.request_id] = request
|
||||
|
||||
|
||||
func _reserved_stock_valid(request: LogisticsRequest) -> bool:
|
||||
return _reservation_quantities_valid(request, _runtime.inventory_service, true)
|
||||
|
||||
|
||||
func _has_storage_space(facility: FacilityInstance, incoming_weight: int) -> bool:
|
||||
var capacity := SupplyCatalog.storage_capacity(facility.definition_id)
|
||||
return capacity > 0 and SupplyCatalog.inventory_weight(_runtime.inventory_service, facility.local_inventory_id) + incoming_weight <= capacity
|
||||
|
||||
|
||||
func find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
||||
return _access_path(start, facility)
|
||||
|
||||
|
||||
func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary:
|
||||
if facility == null:
|
||||
return {&"reachable": false}
|
||||
var cells: Dictionary = {}
|
||||
for cell: Vector3i in facility.occupied_cells:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
||||
if _runtime.path_service.has_cell(cell + delta):
|
||||
cells[cell + delta] = true
|
||||
if cells.is_empty() and _runtime.path_service.has_cell(facility.origin):
|
||||
cells[facility.origin] = true
|
||||
var ordered: Array = cells.keys()
|
||||
ordered.sort_custom(func(a: Vector3i, b: Vector3i) -> bool:
|
||||
if a.z != b.z: return a.z < b.z
|
||||
if a.y != b.y: return a.y < b.y
|
||||
return a.x < b.x)
|
||||
var best: Array[Vector3i] = []
|
||||
for cell: Vector3i in ordered:
|
||||
var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"])
|
||||
if not path.is_empty() and (best.is_empty() or path.size() < best.size()):
|
||||
best = path
|
||||
return {&"reachable": not best.is_empty(), &"cell": best[-1] if not best.is_empty() else facility.origin, &"steps": maxi(0, best.size() - 1)}
|
||||
|
||||
|
||||
static func _arrived_for_work(staff: StaffRuntime, task: StaffTask, minute: int) -> bool:
|
||||
return task.state == StaffTask.State.WORKING and task.state_changed_at < minute and staff.current_cell == task.target_cell
|
||||
|
||||
|
||||
static func _cargo_id(staff_id: StringName) -> StringName:
|
||||
return StringName("cargo:%s" % staff_id)
|
||||
|
||||
|
||||
static func _request_ids(requests: Dictionary) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for id: StringName in _sorted_keys(requests):
|
||||
if String(id).begins_with(REQUEST_PREFIX):
|
||||
ids.append(id)
|
||||
return ids
|
||||
|
||||
|
||||
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
|
||||
var ids: Array[StringName] = []
|
||||
for key: Variant in source:
|
||||
ids.append(StringName(key))
|
||||
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return ids
|
||||
|
||||
|
||||
static func _active_task(staff: StaffService, request_id: StringName) -> StaffTask:
|
||||
for task: StaffTask in staff.get_tasks():
|
||||
if task.target_entity_id == request_id and task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] and not task.is_terminal():
|
||||
return task
|
||||
return null
|
||||
|
||||
|
||||
static func _facility_for_inventory(facilities: FacilityService, inventory_id: StringName) -> FacilityInstance:
|
||||
for facility: FacilityInstance in facilities.get_instances():
|
||||
if facility.local_inventory_id == inventory_id:
|
||||
return facility
|
||||
return null
|
||||
|
||||
|
||||
static func _cargo_quantities(inventory: InventoryService, cargo_id: StringName) -> Dictionary:
|
||||
var quantities: Dictionary = {}
|
||||
for stack: Dictionary in inventory.get_stacks(cargo_id):
|
||||
var item_id := StringName(stack[&"item_id"])
|
||||
quantities[item_id] = int(quantities.get(item_id, 0)) + int(stack[&"quantity"])
|
||||
return quantities
|
||||
|
||||
|
||||
static func _quantities_cover(actual: Dictionary, requested: Dictionary) -> bool:
|
||||
for item_id: StringName in _sorted_keys(requested):
|
||||
if int(actual.get(item_id, 0)) < int(requested[item_id]):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _reservation_quantities_valid(request: LogisticsRequest, inventory: InventoryService, active: bool) -> bool:
|
||||
var totals: Dictionary = {}
|
||||
var weight := 0
|
||||
var seen: Dictionary = {}
|
||||
if request.reservation_ids.is_empty():
|
||||
return false
|
||||
for id: StringName in request.reservation_ids:
|
||||
if seen.has(id):
|
||||
return false
|
||||
seen[id] = true
|
||||
var reservation := inventory.get_reservation(id)
|
||||
if reservation == null or reservation.requester_id != request.request_id \
|
||||
or reservation.owner_inventory_id != request.source_inventory_id:
|
||||
return false
|
||||
if active and (not reservation.is_active() or reservation.is_expired(inventory.current_minute) \
|
||||
or inventory.is_stack_expired(reservation.stack_id, inventory.current_minute)):
|
||||
return false
|
||||
if not active and reservation.state != InventoryReservation.State.CONSUMED:
|
||||
return false
|
||||
totals[reservation.item_id] = int(totals.get(reservation.item_id, 0)) + reservation.quantity
|
||||
if active:
|
||||
var stack := inventory.get_stack(reservation.stack_id)
|
||||
if stack.is_empty():
|
||||
return false
|
||||
weight += reservation.quantity * int(stack[&"unit_weight"])
|
||||
return totals == request.item_requirements and (not active or weight == request.total_weight)
|
||||
|
||||
|
||||
static func validate_crosslinks(requests: Dictionary, staff: StaffService, inventory: InventoryService, facilities: FacilityService) -> bool:
|
||||
var carrier_owners: Dictionary = {}
|
||||
var active_task_ids: Dictionary = {}
|
||||
for id: StringName in _request_ids(requests):
|
||||
var request: LogisticsRequest = requests[id]
|
||||
var source := _facility_for_inventory(facilities, request.source_inventory_id)
|
||||
var destination := _facility_for_inventory(facilities, request.destination_inventory_id)
|
||||
if not String(request.source_inventory_id).begins_with("facility.inventory:") or not String(request.destination_inventory_id).begins_with("facility.inventory:"):
|
||||
return false
|
||||
if not request.is_terminal() and (source == null or destination == null):
|
||||
return false
|
||||
var task := _active_task(staff, id)
|
||||
if request.state in [LogisticsRequest.State.RESERVED, LogisticsRequest.State.BLOCKED] or request.is_terminal():
|
||||
if task != null:
|
||||
return false
|
||||
if request.state == LogisticsRequest.State.RESERVED and not _reservation_quantities_valid(request, inventory, true):
|
||||
return false
|
||||
continue
|
||||
if request.state not in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP, LogisticsRequest.State.DELIVERING]:
|
||||
return false
|
||||
var worker := staff.get_staff(request.assigned_carrier_id)
|
||||
if worker == null or worker.role_id != &"porter" or carrier_owners.has(worker.staff_id) \
|
||||
or task == null or task.assigned_staff_id != worker.staff_id or worker.current_task_id != task.task_id \
|
||||
or task.state not in [StaffTask.State.RESERVED, StaffTask.State.MOVING, StaffTask.State.WORKING] \
|
||||
or task.interrupt_level != 0 or not task.required_items.is_empty() or not task.reservation_ids.is_empty():
|
||||
return false
|
||||
carrier_owners[worker.staff_id] = id
|
||||
active_task_ids[task.task_id] = true
|
||||
var loaded := request.state == LogisticsRequest.State.DELIVERING
|
||||
var endpoint := source if not loaded or task.task_type == RETURN_TYPE else destination
|
||||
if not _cell_touches_facility(task.target_cell, endpoint):
|
||||
return false
|
||||
if (loaded and task.task_type not in [DELIVERY_TYPE, RETURN_TYPE]) or (not loaded and task.task_type != PICKUP_TYPE) \
|
||||
or not String(task.task_id).begins_with("supply.%s.%s." % ["delivery" if loaded else "pickup", id]):
|
||||
return false
|
||||
if not _reservation_quantities_valid(request, inventory, not loaded):
|
||||
return false
|
||||
var cargo := _cargo_quantities(inventory, _cargo_id(worker.staff_id))
|
||||
if loaded:
|
||||
if worker.carried_inventory_id != _cargo_id(worker.staff_id) or cargo.is_empty():
|
||||
return false
|
||||
for item_id: StringName in _sorted_keys(cargo):
|
||||
if int(cargo[item_id]) > int(request.item_requirements.get(item_id, 0)):
|
||||
return false
|
||||
elif not cargo.is_empty() or not worker.carried_inventory_id.is_empty():
|
||||
return false
|
||||
for task: StaffTask in staff.get_tasks():
|
||||
if task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] and not task.is_terminal() \
|
||||
and (not requests.has(task.target_entity_id) or not active_task_ids.has(task.task_id)):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _cell_touches_facility(cell: Vector3i, facility: FacilityInstance) -> bool:
|
||||
for occupied: Vector3i in facility.occupied_cells:
|
||||
if cell.z == occupied.z and absi(cell.x - occupied.x) + absi(cell.y - occupied.y) <= 1:
|
||||
return true
|
||||
return cell == facility.origin
|
||||
@@ -0,0 +1 @@
|
||||
uid://due4h10y45t4w
|
||||
@@ -0,0 +1,253 @@
|
||||
class_name SupplyManagementService
|
||||
extends RefCounted
|
||||
|
||||
## Player commands and deterministic replenishment. Vendor purchases arrive in
|
||||
## the selected warehouse; all transfers inside the tree use real porter tasks.
|
||||
|
||||
func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary:
|
||||
var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var item := SupplyCatalog.get_item(item_id)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
var quote := {&"ok": false, &"reason": &"", &"total_cost": 0, &"cash_after": cash, &"quantity_after": 0, &"max_quantity": 0}
|
||||
if facility == null or facility.definition_id != &"warehouse":
|
||||
quote[&"reason"] = &"NOT_WAREHOUSE"
|
||||
return quote
|
||||
if item.is_empty() or not bool(item.get(&"purchasable", false)):
|
||||
quote[&"reason"] = &"ITEM_NOT_PURCHASABLE"
|
||||
return quote
|
||||
var price := int(item[&"unit_price"])
|
||||
var space: int = maxi(SupplyCatalog.storage_capacity(facility.definition_id) - SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id) - _incoming_weight(runtime, facility.local_inventory_id), 0)
|
||||
quote[&"max_quantity"] = mini(space / int(item[&"unit_weight"]), maxi(cash, 0) / price)
|
||||
quote[&"quantity_after"] = runtime.inventory_service.total_quantity(facility.local_inventory_id, item_id) + maxi(quantity, 0)
|
||||
quote[&"total_cost"] = maxi(quantity, 0) * price
|
||||
quote[&"cash_after"] = cash - int(quote[&"total_cost"])
|
||||
if quantity <= 0 or quantity > 10000:
|
||||
quote[&"reason"] = &"INVALID_QUANTITY"
|
||||
elif not facility.operator_open or facility.durability <= 0:
|
||||
quote[&"reason"] = &"FACILITY_CLOSED"
|
||||
elif int(quote[&"total_cost"]) > cash:
|
||||
quote[&"reason"] = &"INSUFFICIENT_FUNDS"
|
||||
elif quantity * int(item[&"unit_weight"]) > space:
|
||||
quote[&"reason"] = &"STORAGE_FULL"
|
||||
else:
|
||||
quote[&"ok"] = true
|
||||
return quote
|
||||
|
||||
|
||||
func purchase(runtime: Variant, command: SimulationCommand) -> CommandResult:
|
||||
var payload := command.payload
|
||||
if not _text(payload.get(&"warehouse_id")) or not _text(payload.get(&"item_id")) or not _integer(payload.get(&"quantity")):
|
||||
return _failure(command, &"INVALID_PAYLOAD")
|
||||
var warehouse_id := StringName(payload[&"warehouse_id"])
|
||||
var item_id := StringName(payload[&"item_id"])
|
||||
var quantity := int(payload[&"quantity"])
|
||||
var quote := preview_purchase(runtime, warehouse_id, item_id, quantity)
|
||||
if not bool(quote[&"ok"]):
|
||||
return _failure(command, StringName(quote[&"reason"]), quote)
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
|
||||
var item := SupplyCatalog.get_item(item_id)
|
||||
var minute: int = runtime._runtime_event_minute()
|
||||
var inventory_before: Dictionary = runtime.inventory_service.create_snapshot()
|
||||
var economy_before: Dictionary = runtime.economy_service.create_snapshot()
|
||||
var stocked: Dictionary = runtime.inventory_service.add_stack(facility.local_inventory_id, item_id, quantity, 100, minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"]))
|
||||
if not bool(stocked.get(&"ok", false)):
|
||||
return _failure(command, &"INVENTORY_ERROR")
|
||||
var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, StringName("supply.purchase.%d" % command.command_id), &"supply_purchase", &"", false)
|
||||
if not paid.is_success():
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _failure(command, &"PAYMENT_FAILED")
|
||||
if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])):
|
||||
runtime.inventory_service.restore_snapshot(inventory_before)
|
||||
runtime.economy_service.restore_snapshot(economy_before)
|
||||
return _failure(command, &"PAYMENT_FAILED")
|
||||
quote.merge({&"warehouse_id": warehouse_id, &"item_id": item_id, &"quantity": quantity, &"transaction_id": paid.details[&"transaction_id"]})
|
||||
return CommandResult.succeeded(command.command_id, quote)
|
||||
|
||||
|
||||
func set_production(runtime: Variant, command: SimulationCommand) -> CommandResult:
|
||||
var payload := command.payload
|
||||
if not _text(payload.get(&"facility_id")) or not payload.get(&"enabled") is bool or not _integer(payload.get(&"target_stock")):
|
||||
return _failure(command, &"INVALID_PAYLOAD")
|
||||
var facility_id := StringName(payload[&"facility_id"])
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(facility_id)
|
||||
if facility == null or facility.definition_id != &"kitchen":
|
||||
return _failure(command, &"NOT_KITCHEN")
|
||||
var result: Dictionary = runtime.kitchen_production.set_plan(facility_id, bool(payload[&"enabled"]), int(payload[&"target_stock"]))
|
||||
if not bool(result.get(&"ok", false)):
|
||||
return _failure(command, StringName(result.get(&"code", &"INVALID_TARGET")), result)
|
||||
return CommandResult.succeeded(command.command_id, result)
|
||||
|
||||
|
||||
func get_report(runtime: Variant) -> Dictionary:
|
||||
var warehouses: Array[Dictionary] = []
|
||||
var kitchens: Array[Dictionary] = []
|
||||
var destinations: Array[Dictionary] = []
|
||||
for facility: FacilityInstance in _facilities(runtime):
|
||||
var row := _facility_row(runtime, facility)
|
||||
match facility.definition_id:
|
||||
&"warehouse":
|
||||
var quote := preview_purchase(runtime, facility.instance_id, SupplyCatalog.RAW_FOOD, 1)
|
||||
row[&"can_receive"] = bool(quote[&"ok"])
|
||||
row[&"reason_codes"] = [] if bool(quote[&"ok"]) else [quote[&"reason"]]
|
||||
warehouses.append(row)
|
||||
&"kitchen":
|
||||
row.merge(runtime.kitchen_production.get_report(facility.instance_id), true)
|
||||
row.merge({&"target_min": 4, &"target_step": 4, &"target_max": SupplyCatalog.KITCHEN_TARGET_MAX, &"inputs": {SupplyCatalog.RAW_FOOD: 4}, &"outputs": {SupplyCatalog.BASIC_MEAL: 4}}, true)
|
||||
row[&"reason_codes"] = [] if row[&"state"] in [&"PRODUCING", &"READY", &"TARGET_REACHED"] else [row[&"state"]]
|
||||
kitchens.append(row)
|
||||
&"cafeteria":
|
||||
row[&"missing_items"] = {SupplyCatalog.BASIC_MEAL: maxi(12 - runtime.inventory_service.available_quantity(facility.local_inventory_id, SupplyCatalog.BASIC_MEAL), 0)}
|
||||
row[&"reason_codes"] = facility.state_reasons.duplicate()
|
||||
if runtime.inventory_service.available_quantity(facility.local_inventory_id, SupplyCatalog.BASIC_MEAL) <= 0:
|
||||
row[&"reason_codes"].append(&"NO_INPUT")
|
||||
destinations.append(row)
|
||||
var bridge_report: Dictionary = runtime.supply_logistics.get_report()
|
||||
return {&"cash": runtime.economy_service.get_balance(&"PLAYER_CASH"), &"catalog": SupplyCatalog.get_purchase_items(), &"warehouses": warehouses, &"kitchens": kitchens, &"destinations": destinations, &"deliveries": bridge_report.get(&"deliveries", [])}
|
||||
|
||||
|
||||
func ensure_deliveries(runtime: Variant, minute: int) -> void:
|
||||
var facilities := _facilities(runtime)
|
||||
for destination: FacilityInstance in facilities:
|
||||
if not destination.operator_open or destination.durability <= 0:
|
||||
continue
|
||||
var source_type: StringName
|
||||
var item_id: StringName
|
||||
var target := 0
|
||||
if destination.definition_id == &"kitchen" and bool(runtime.kitchen_production.get_plan(destination.instance_id).get(&"enabled", false)):
|
||||
source_type = &"warehouse"
|
||||
item_id = SupplyCatalog.RAW_FOOD
|
||||
target = SupplyCatalog.RAW_TARGET
|
||||
elif destination.definition_id == &"cafeteria":
|
||||
source_type = &"kitchen"
|
||||
item_id = SupplyCatalog.BASIC_MEAL
|
||||
target = 12
|
||||
else:
|
||||
continue
|
||||
var deficit: int = target - runtime.inventory_service.total_quantity(destination.local_inventory_id, item_id) - _incoming_quantity(runtime, destination.local_inventory_id, item_id)
|
||||
var space: int = SupplyCatalog.storage_capacity(destination.definition_id) - SupplyCatalog.inventory_weight(runtime.inventory_service, destination.local_inventory_id) - _incoming_weight(runtime, destination.local_inventory_id)
|
||||
var needed: int = mini(mini(deficit, space), 4)
|
||||
if needed <= 0:
|
||||
continue
|
||||
var sources: Array[Dictionary] = []
|
||||
for source: FacilityInstance in facilities:
|
||||
if source.definition_id != source_type or not source.operator_open:
|
||||
continue
|
||||
if source_type == &"kitchen" and not _has_production_plan(runtime, source.instance_id):
|
||||
continue
|
||||
var available: int = runtime.inventory_service.available_quantity(source.local_inventory_id, item_id, minute)
|
||||
if available <= 0:
|
||||
continue
|
||||
sources.append({&"facility": source, &"available": available, &"distance": _connection_distance(runtime, source, destination)})
|
||||
# Prefer a connected nearby supplier; IDs resolve equal routes stably.
|
||||
# If none is connected, retain one visible request for route repair.
|
||||
sources.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
if a[&"distance"] != b[&"distance"]: return a[&"distance"] < b[&"distance"]
|
||||
return String(a[&"facility"].instance_id) < String(b[&"facility"].instance_id))
|
||||
for source_row: Dictionary in sources:
|
||||
var source: FacilityInstance = source_row[&"facility"]
|
||||
var available := int(source_row[&"available"])
|
||||
var ids_before: Dictionary = runtime.id_generator.create_snapshot()
|
||||
var request_id := StringName("supply.%s" % runtime.id_generator.next_id(&"logistics_request"))
|
||||
var result: CommandResult = runtime._execute_create_logistics_request(SimulationCommand.new(0, minute, minute, 50, &"create_logistics_request", &"supply", destination.instance_id, {&"request_id": request_id, &"source_inventory_id": source.local_inventory_id, &"destination_inventory_id": destination.local_inventory_id, &"item_requirements": {item_id: mini(needed, available)}, &"priority": 50}))
|
||||
if not result.is_success():
|
||||
runtime.id_generator.restore_snapshot(ids_before)
|
||||
break
|
||||
|
||||
|
||||
func validate_demolition(runtime: Variant, facility_id: StringName) -> Dictionary:
|
||||
var facility: FacilityInstance = runtime.facility_service.get_instance(facility_id)
|
||||
if facility == null:
|
||||
return {&"ok": true}
|
||||
if SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id) > 0:
|
||||
return {&"ok": false, &"reason": &"STOCK_REMAINS"}
|
||||
for request: LogisticsRequest in runtime.logistics_requests.values():
|
||||
if not request.is_terminal() and facility.local_inventory_id in [request.source_inventory_id, request.destination_inventory_id]:
|
||||
return {&"ok": false, &"reason": &"DELIVERY_ACTIVE"}
|
||||
if facility.definition_id == &"kitchen":
|
||||
var report: Dictionary = runtime.kitchen_production.get_report(facility_id)
|
||||
if bool(report.get(&"enabled", false)) or not report.get(&"active_batch", {}).is_empty():
|
||||
return {&"ok": false, &"reason": &"PRODUCTION_ACTIVE"}
|
||||
return {&"ok": true}
|
||||
|
||||
|
||||
func _facility_row(runtime: Variant, facility: FacilityInstance) -> Dictionary:
|
||||
var definition: FacilityDefinition = runtime.definition_registry.get_facility(facility.definition_id)
|
||||
return {&"facility_id": facility.instance_id, &"display_name": definition.display_name, &"origin": facility.origin, &"layer": facility.origin.z, &"used": SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id), &"capacity": SupplyCatalog.storage_capacity(facility.definition_id), &"stock": _stock(runtime, facility.local_inventory_id)}
|
||||
|
||||
|
||||
func _stock(runtime: Variant, inventory_id: StringName) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for item_id: StringName in [SupplyCatalog.RAW_FOOD, SupplyCatalog.BASIC_MEAL]:
|
||||
var total: int = runtime.inventory_service.total_quantity(inventory_id, item_id)
|
||||
var available: int = runtime.inventory_service.available_quantity(inventory_id, item_id)
|
||||
rows.append({&"item_id": item_id, &"total": total, &"available": available, &"reserved": total - available, &"in_transit": _incoming_quantity(runtime, inventory_id, item_id)})
|
||||
return rows
|
||||
|
||||
|
||||
func _incoming_quantity(runtime: Variant, inventory_id: StringName, item_id: StringName) -> int:
|
||||
var quantity := 0
|
||||
for request: LogisticsRequest in runtime.logistics_requests.values():
|
||||
if request.is_terminal():
|
||||
continue
|
||||
if runtime.supply_logistics.is_returning(request.request_id):
|
||||
if request.source_inventory_id == inventory_id:
|
||||
quantity += runtime.inventory_service.total_quantity(StringName("cargo:%s" % request.assigned_carrier_id), item_id)
|
||||
elif request.destination_inventory_id == inventory_id:
|
||||
quantity += int(request.item_requirements.get(item_id, 0))
|
||||
return quantity
|
||||
|
||||
|
||||
func _incoming_weight(runtime: Variant, inventory_id: StringName) -> int:
|
||||
var weight := 0
|
||||
for request: LogisticsRequest in runtime.logistics_requests.values():
|
||||
if request.is_terminal():
|
||||
continue
|
||||
if runtime.supply_logistics.is_returning(request.request_id):
|
||||
if request.source_inventory_id == inventory_id:
|
||||
weight += SupplyCatalog.inventory_weight(runtime.inventory_service, StringName("cargo:%s" % request.assigned_carrier_id))
|
||||
elif request.destination_inventory_id == inventory_id:
|
||||
weight += request.total_weight
|
||||
return weight
|
||||
|
||||
|
||||
func _facilities(runtime: Variant) -> Array[FacilityInstance]:
|
||||
var rows: Array[FacilityInstance] = runtime.facility_service.get_instances()
|
||||
rows.sort_custom(func(a: FacilityInstance, b: FacilityInstance) -> bool: return String(a.instance_id) < String(b.instance_id))
|
||||
return rows
|
||||
|
||||
|
||||
func _failure(command: SimulationCommand, reason: StringName, details: Dictionary = {}) -> CommandResult:
|
||||
var result := details.duplicate(true)
|
||||
result[&"reason"] = reason
|
||||
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Supply command rejected: %s" % reason, result)
|
||||
|
||||
|
||||
func _text(value: Variant) -> bool:
|
||||
return (value is String or value is StringName) and not String(value).is_empty()
|
||||
|
||||
|
||||
func _integer(value: Variant) -> bool:
|
||||
return value is int or (value is float and is_finite(value) and value == floor(value) and absf(value) <= 1000000.0)
|
||||
|
||||
|
||||
func _has_production_plan(runtime: Variant, facility_id: StringName) -> bool:
|
||||
for plan: Dictionary in runtime.kitchen_production.get_plans():
|
||||
if StringName(plan[&"facility_id"]) == facility_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _connection_distance(runtime: Variant, source: FacilityInstance, destination: FacilityInstance) -> int:
|
||||
var distance := 1000000
|
||||
var seen: Dictionary = {}
|
||||
for cell: Vector3i in source.occupied_cells:
|
||||
for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
|
||||
var start := cell + delta
|
||||
if seen.has(start) or not runtime.path_service.has_cell(start):
|
||||
continue
|
||||
seen[start] = true
|
||||
var route: Dictionary = runtime.supply_logistics.find_facility_access_path(start, destination)
|
||||
if bool(route.get(&"reachable", false)):
|
||||
distance = mini(distance, int(route[&"steps"]))
|
||||
return distance
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8pm8ddxxfcxx
|
||||
@@ -0,0 +1,569 @@
|
||||
class_name SupplyManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
## Read models and commands only. Prices, stock, recipes and delivery progress
|
||||
## belong to Runtime; changing a control never mutates inventory directly.
|
||||
signal status_requested(message: String, success: bool)
|
||||
signal action_requested(action: StringName)
|
||||
|
||||
const KEY_PREFIX := "ui.supply."
|
||||
const PURCHASE: StringName = &"purchase_supplies"
|
||||
const PRODUCTION: StringName = &"set_kitchen_production"
|
||||
const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"}
|
||||
const STATE_NAMES := {
|
||||
&"OPEN": "補給待ち", &"RESERVED": "運搬員待ち", &"ASSIGNED": "荷元へ移動",
|
||||
&"PICKING_UP": "積込中", &"DELIVERING": "配送中", &"COMPLETED": "納品済み",
|
||||
&"BLOCKED": "配送停止", &"CANCELLED": "取消済み", &"WORKING": "調理中",
|
||||
&"PRODUCING": "調理中", &"IDLE": "待機中", &"DISABLED": "自動生産停止",
|
||||
&"TARGET_REACHED": "目標在庫に到達", &"READY": "稼働可能",
|
||||
}
|
||||
const REASONS := {
|
||||
&"INSUFFICIENT_FUNDS": "資金が不足しています。", &"INSUFFICIENT_CASH": "資金が不足しています。",
|
||||
&"INVALID_QUANTITY": "購入数量を確認してください。", &"INVALID_ITEM": "この品目は購入できません。",
|
||||
&"INVALID_PAYLOAD": "入力した設定を確認してください。", &"ITEM_NOT_PURCHASABLE": "この品目は購入できません。",
|
||||
&"ITEM_NOT_FOUND": "品目が見つかりません。", &"WAREHOUSE_NOT_FOUND": "完成した倉庫を選んでください。",
|
||||
&"FACILITY_NOT_FOUND": "施設が見つかりません。", &"NOT_WAREHOUSE": "受入先には倉庫を選んでください。",
|
||||
&"WAREHOUSE_FULL": "倉庫の空きが不足しています。", &"CAPACITY_EXCEEDED": "保管容量を超えています。",
|
||||
&"STORAGE_FULL": "倉庫の空きが不足しています。", &"FACILITY_CLOSED": "受入先の施設が閉鎖されています。",
|
||||
&"NO_STOCK": "荷元の在庫が不足しています。", &"INSUFFICIENT_STOCK": "荷元の在庫が不足しています。",
|
||||
&"NO_INPUTS": "調理用の食材が不足しています。", &"NO_STAFF": "必要な職員を配属してください。",
|
||||
&"NO_INPUT": "必要な在庫が不足しています。", &"DISABLED": "自動生産は停止中です。",
|
||||
&"INVENTORY_ERROR": "在庫への入庫に失敗しました。", &"PAYMENT_FAILED": "代金を支払えませんでした。",
|
||||
&"NO_CARRIER": "勤務中の運搬員がいません。", &"NO_ELIGIBLE_CARRIER": "運搬できる職員がいません。",
|
||||
&"NO_AVAILABLE_PORTER_OR_ROUTE": "勤務中の運搬員と配送経路を確認してください。",
|
||||
&"FACILITY_UNAVAILABLE": "荷元・供給先の施設が利用できません。",
|
||||
&"DESTINATION_FULL": "供給先の保管容量が不足しています。",
|
||||
&"DESTINATION_FULL_OR_CLOSED": "供給先の空き容量と営業状態を確認してください。",
|
||||
&"NO_DELIVERY_ROUTE": "供給先までの配送経路がありません。",
|
||||
&"ROUTE_BLOCKED": "配送経路が通れません。通路とドアを確認してください。",
|
||||
&"CARRIER_CAPACITY_EXCEEDED": "運搬員が持てる量を超えています。",
|
||||
&"TASK_ASSIGNMENT_FAILED": "運搬作業を割り当てられません。",
|
||||
&"CARRIER_TASK_MISSING": "運搬作業との接続を確認してください。",
|
||||
&"CARRIER_UNAVAILABLE": "担当の運搬員が現在作業できません。",
|
||||
&"SOURCE_STOCK_EXPIRED": "荷元の在庫が消費期限を過ぎました。",
|
||||
&"WAITING_FOR_SOURCE_STOCK": "荷元への入荷を待っています。",
|
||||
&"SOURCE_WEIGHT_CHANGED": "荷物の重量が変わったため再手配が必要です。",
|
||||
&"PICKUP_FAILED": "積込できませんでした。再手配を待っています。",
|
||||
&"DELIVERY_RETRY": "納品を再試行しています。",
|
||||
&"RETURNING_EXPIRED_CARGO": "期限切れの荷物を荷元へ返送しています。",
|
||||
&"EXPIRED_CARGO_RETURN_BLOCKED": "期限切れの荷物を返送できません。経路を確認してください。",
|
||||
&"NO_ROUTE": "通路と従業員ドアの接続を確認してください。", &"NO_ACCESS": "施設への経路がありません。",
|
||||
&"NO_POWER": "施設へ魔力が届いていません。", &"CLOSED": "施設が閉鎖されています。",
|
||||
&"NOT_KITCHEN": "厨房を選んでください。", &"INVALID_TARGET": "目標在庫の上限を確認してください。",
|
||||
&"BANKRUPT": "運営終了後は変更できません。", &"UNAVAILABLE": "現在は操作できません。",
|
||||
}
|
||||
|
||||
var summary_label: Label
|
||||
var warehouse_option: OptionButton
|
||||
var item_option: OptionButton
|
||||
var quantity_spin: SpinBox
|
||||
var purchase_quote_label: Label
|
||||
var purchase_button: Button
|
||||
var warehouse_stock_label: Label
|
||||
var kitchen_option: OptionButton
|
||||
var production_enabled: CheckButton
|
||||
var target_spin: SpinBox
|
||||
var production_button: Button
|
||||
var kitchen_status_label: Label
|
||||
var delivery_label: Label
|
||||
var result_label: Label
|
||||
var tab_buttons: Dictionary = {}
|
||||
var selected_warehouse_id: StringName = &""
|
||||
var selected_item_id: StringName = &""
|
||||
var selected_kitchen_id: StringName = &""
|
||||
var selected_tab: StringName = &"purchase"
|
||||
|
||||
var _runtime: Variant
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _quote: Dictionary = {}
|
||||
var _pages: Dictionary = {}
|
||||
var _kitchen_drafts: Dictionary = {}
|
||||
var _pending_commands: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _sequence := 1
|
||||
var _refreshing := false
|
||||
var _built := false
|
||||
|
||||
|
||||
func configure(runtime: Variant, style_button := Callable()) -> void:
|
||||
_runtime = runtime
|
||||
_style_button = style_button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "SupplyManagementPanel"
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 5)
|
||||
_build_interface()
|
||||
_built = true
|
||||
if _runtime != null:
|
||||
if _runtime.has_signal(&"snapshot_published"):
|
||||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
refresh()
|
||||
|
||||
|
||||
func _build_interface() -> void:
|
||||
summary_label = _label("商品 · 仕入れと補給", "SupplySummary")
|
||||
add_child(summary_label)
|
||||
var tabs := HBoxContainer.new()
|
||||
tabs.add_theme_constant_override("separation", 3)
|
||||
add_child(tabs)
|
||||
for entry: Array in [[&"purchase", "仕入れ"], [&"kitchen", "厨房"], [&"delivery", "配送"]]:
|
||||
var button := _button(entry[1], "SupplyTab_%s" % entry[0], select_tab.bind(entry[0]))
|
||||
button.toggle_mode = true
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
tab_buttons[entry[0]] = button
|
||||
tabs.add_child(button)
|
||||
var page := VBoxContainer.new()
|
||||
page.name = "SupplyPage_%s" % entry[0]
|
||||
page.add_theme_constant_override("separation", 5)
|
||||
_pages[entry[0]] = page
|
||||
add_child(page)
|
||||
var purchase_page: VBoxContainer = _pages[&"purchase"]
|
||||
purchase_page.add_child(_label("受入倉庫"))
|
||||
warehouse_option = _option("SupplyWarehouseOption")
|
||||
warehouse_option.item_selected.connect(func(index: int) -> void:
|
||||
selected_warehouse_id = StringName(warehouse_option.get_item_metadata(index))
|
||||
refresh())
|
||||
purchase_page.add_child(warehouse_option)
|
||||
warehouse_stock_label = _label("", "SupplyWarehouseStock")
|
||||
purchase_page.add_child(warehouse_stock_label)
|
||||
purchase_page.add_child(_label("購入する品目"))
|
||||
item_option = _option("SupplyItemOption")
|
||||
item_option.item_selected.connect(func(index: int) -> void:
|
||||
selected_item_id = StringName(item_option.get_item_metadata(index))
|
||||
refresh())
|
||||
purchase_page.add_child(item_option)
|
||||
quantity_spin = _spin("SupplyQuantity", 1, 1)
|
||||
quantity_spin.value_changed.connect(_on_purchase_quantity_changed)
|
||||
purchase_page.add_child(_label("数量"))
|
||||
purchase_page.add_child(quantity_spin)
|
||||
purchase_quote_label = _label("", "SupplyPurchaseQuote")
|
||||
purchase_page.add_child(purchase_quote_label)
|
||||
purchase_button = _button("購入", "PurchaseSuppliesButton", submit_purchase)
|
||||
purchase_page.add_child(purchase_button)
|
||||
var kitchen_page: VBoxContainer = _pages[&"kitchen"]
|
||||
kitchen_page.add_child(_label("設定する厨房"))
|
||||
kitchen_option = _option("SupplyKitchenOption")
|
||||
kitchen_option.item_selected.connect(func(index: int) -> void:
|
||||
selected_kitchen_id = StringName(kitchen_option.get_item_metadata(index))
|
||||
refresh())
|
||||
kitchen_page.add_child(kitchen_option)
|
||||
production_enabled = CheckButton.new()
|
||||
production_enabled.name = "SupplyProductionEnabled"
|
||||
production_enabled.text = "自動生産"
|
||||
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||||
production_enabled.add_theme_color_override(color_name, Color("55371e"))
|
||||
production_enabled.add_theme_color_override("font_disabled_color", Color("766650"))
|
||||
production_enabled.toggled.connect(_on_production_enabled_changed)
|
||||
kitchen_page.add_child(production_enabled)
|
||||
kitchen_page.add_child(_label("目標在庫 · 完成料理"))
|
||||
target_spin = _spin("SupplyTargetStock", 0, 0)
|
||||
target_spin.value_changed.connect(_on_target_changed)
|
||||
kitchen_page.add_child(target_spin)
|
||||
production_button = _button("生産設定を保存", "SaveKitchenProductionButton", submit_production)
|
||||
kitchen_page.add_child(production_button)
|
||||
kitchen_status_label = _label("", "SupplyKitchenStatus")
|
||||
kitchen_page.add_child(kitchen_status_label)
|
||||
var delivery_page: VBoxContainer = _pages[&"delivery"]
|
||||
delivery_label = _label("", "SupplyDeliveryStatus")
|
||||
delivery_page.add_child(delivery_label)
|
||||
var actions := HBoxContainer.new()
|
||||
for entry: Array in [[&"staff", "従業員を開く"], [&"facility", "施設をつくる"]]:
|
||||
var button := _button(entry[1], "SupplyAction_%s" % entry[0], func() -> void: action_requested.emit(entry[0]))
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
actions.add_child(button)
|
||||
add_child(actions)
|
||||
result_label = _label("", "SupplyResult")
|
||||
add_child(result_label)
|
||||
select_tab(&"purchase")
|
||||
|
||||
|
||||
func select_tab(tab: StringName) -> void:
|
||||
if not _pages.has(tab):
|
||||
return
|
||||
selected_tab = tab
|
||||
for key: StringName in _pages:
|
||||
(_pages[key] as Control).visible = key == tab
|
||||
(tab_buttons[key] as Button).set_pressed_no_signal(key == tab)
|
||||
refresh()
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
return _report.duplicate(true)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not _built:
|
||||
return
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||||
_queue_instance_id = queue.get_instance_id()
|
||||
_kitchen_drafts.clear()
|
||||
for picker: OptionButton in [warehouse_option, item_option, kitchen_option]:
|
||||
picker.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
_report = {}
|
||||
if _runtime != null and _runtime.has_method(&"get_supply_management_report"):
|
||||
var value: Variant = _runtime.call(&"get_supply_management_report")
|
||||
if value is Dictionary:
|
||||
_report = value
|
||||
_refreshing = true
|
||||
summary_label.text = "商品 · 手持ち %dG" % int(_report.get(&"cash", 0)) if not _report.is_empty() else "商品 · 補給情報はまだありません"
|
||||
match selected_tab:
|
||||
&"purchase":
|
||||
_refresh_purchase()
|
||||
&"kitchen":
|
||||
_refresh_kitchen()
|
||||
&"delivery":
|
||||
_refresh_deliveries()
|
||||
_refreshing = false
|
||||
|
||||
|
||||
func _refresh_purchase() -> void:
|
||||
selected_warehouse_id = _fill_option(warehouse_option, _rows(&"warehouses"), &"facility_id", selected_warehouse_id, "完成した倉庫がありません")
|
||||
selected_item_id = _fill_option(item_option, _rows(&"catalog"), &"item_id", selected_item_id, "購入できる品目がありません")
|
||||
var warehouse := _row(&"warehouses", &"facility_id", selected_warehouse_id)
|
||||
var catalog := _row(&"catalog", &"item_id", selected_item_id)
|
||||
warehouse_stock_label.text = "倉庫を完成させると、有料で食材を仕入れられます。" if warehouse.is_empty() else "保管 %d / %d\n%s" % [warehouse.get(&"used", 0), warehouse.get(&"capacity", 0), _stock_text(warehouse.get(&"stock", []))]
|
||||
_quote = {}
|
||||
if _runtime != null and _runtime.has_method(&"preview_supply_purchase") and not warehouse.is_empty() and not catalog.is_empty():
|
||||
var value: Variant = _runtime.call(&"preview_supply_purchase", selected_warehouse_id, selected_item_id, int(quantity_spin.value))
|
||||
if value is Dictionary:
|
||||
_quote = value
|
||||
# The upper bound comes from Runtime. Retain a rejected draft so the player
|
||||
# can see why it cannot be bought instead of silently reducing its quantity.
|
||||
quantity_spin.max_value = maxi(int(quantity_spin.value), maxi(1, int(_quote.get(&"max_quantity", 1))))
|
||||
var pending := _has_pending(PURCHASE)
|
||||
quantity_spin.editable = not pending and not catalog.is_empty() and not warehouse.is_empty()
|
||||
warehouse_option.disabled = pending or _rows(&"warehouses").is_empty()
|
||||
item_option.disabled = pending or _rows(&"catalog").is_empty()
|
||||
purchase_button.disabled = pending or not bool(_quote.get(&"ok", false))
|
||||
purchase_button.text = "購入を受理済み…" if pending else "購入"
|
||||
if catalog.is_empty() or warehouse.is_empty():
|
||||
purchase_quote_label.text = "受入倉庫と品目を選んでください。"
|
||||
return
|
||||
purchase_quote_label.text = "単価 %dG / %s\n合計 %dG · 購入後 %dG" % [catalog.get(&"unit_price", 0), catalog.get(&"unit_name", "個"), _quote.get(&"total_cost", 0), _quote.get(&"cash_after", _report.get(&"cash", 0))]
|
||||
if not bool(_quote.get(&"ok", false)):
|
||||
purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE"))
|
||||
|
||||
|
||||
func _refresh_kitchen() -> void:
|
||||
selected_kitchen_id = _fill_option(kitchen_option, _rows(&"kitchens"), &"facility_id", selected_kitchen_id, "完成した厨房がありません")
|
||||
var row := _row(&"kitchens", &"facility_id", selected_kitchen_id)
|
||||
var draft: Dictionary = _kitchen_drafts.get(selected_kitchen_id, row)
|
||||
var pending := _has_pending(PRODUCTION, selected_kitchen_id)
|
||||
kitchen_option.disabled = _rows(&"kitchens").is_empty()
|
||||
production_enabled.disabled = pending or row.is_empty()
|
||||
target_spin.editable = not pending and not row.is_empty()
|
||||
var target_max := maxi(0, int(row.get(&"target_max", 0)))
|
||||
target_spin.min_value = int(row.get(&"target_min", 4)) if not row.is_empty() else 0
|
||||
target_spin.step = int(row.get(&"target_step", 4)) if not row.is_empty() else 1
|
||||
target_spin.max_value = maxi(target_max, int(draft.get(&"target_stock", 0)))
|
||||
target_spin.set_value_no_signal(int(draft.get(&"target_stock", 0)))
|
||||
production_enabled.set_pressed_no_signal(bool(draft.get(&"enabled", false)))
|
||||
production_button.disabled = pending or row.is_empty() or int(target_spin.value) > target_max
|
||||
production_button.text = "設定を受理済み…" if pending else "生産設定を保存"
|
||||
if row.is_empty():
|
||||
kitchen_status_label.text = "厨房をつくり、調理係を配属してください。"
|
||||
return
|
||||
kitchen_status_label.text = "設定上限 %d · %s\n材料 %s → 生産 %s\n%s" % [target_max, _state_text(row.get(&"state", &"IDLE")), _items_text(row.get(&"inputs", {})), _items_text(row.get(&"outputs", {})), _stock_text(row.get(&"stock", []))]
|
||||
var reasons := _reasons_text(row.get(&"reason_codes", []))
|
||||
if not reasons.is_empty():
|
||||
kitchen_status_label.text += "\n" + reasons
|
||||
var batch: Variant = row.get(&"active_batch", {})
|
||||
if batch is Dictionary and not batch.is_empty():
|
||||
if batch.has(&"remaining_minutes"):
|
||||
kitchen_status_label.text += "\n調理完了まで %d分" % int(batch[&"remaining_minutes"])
|
||||
elif row.has(&"duration_minutes"):
|
||||
kitchen_status_label.text += "\n調理 %d / %d分" % [row.get(&"progress_minutes", 0), row[&"duration_minutes"]]
|
||||
kitchen_status_label.text += "\n食材は倉庫から補給し、完成料理は不足する食堂へ配送します。"
|
||||
|
||||
|
||||
func _refresh_deliveries() -> void:
|
||||
var lines: PackedStringArray = ["食堂の在庫"]
|
||||
for row: Dictionary in _rows(&"destinations"):
|
||||
var missing := _items_text(row.get(&"missing_items", {}))
|
||||
lines.append("%s\n不足:%s" % [_facility_name(row), missing])
|
||||
var reasons := _reasons_text(row.get(&"reason_codes", []))
|
||||
if not reasons.is_empty():
|
||||
lines.append(reasons)
|
||||
if _rows(&"destinations").is_empty():
|
||||
lines.append("食堂はまだありません。")
|
||||
lines.append("\n配送状況")
|
||||
for row: Dictionary in _rows(&"deliveries"):
|
||||
var state := StringName(String(row.get(&"state", &"OPEN")))
|
||||
lines.append("%s → %s\n%s · %s" % [_facility_by_id(StringName(row.get(&"source_facility_id", &""))), _facility_by_id(StringName(row.get(&"destination_facility_id", &""))), _items_text(row.get(&"item_requirements", {})), _state_text(state)])
|
||||
if state not in [&"COMPLETED", &"CANCELLED"]:
|
||||
lines.append("担当:%s" % (row.get(&"carrier_name", "未割当") if not String(row.get(&"carrier_name", "")).is_empty() else "未割当"))
|
||||
var reasons := _reasons_text(row.get(&"reason_codes", []))
|
||||
if not reasons.is_empty():
|
||||
lines.append(reasons)
|
||||
if _rows(&"deliveries").is_empty():
|
||||
lines.append("配送要求はまだありません。")
|
||||
delivery_label.text = "\n".join(lines)
|
||||
|
||||
|
||||
func submit_purchase() -> void:
|
||||
if _has_pending(PURCHASE):
|
||||
return
|
||||
if _runtime == null or not _runtime.has_method(&"preview_supply_purchase"):
|
||||
_set_result("仕入れサービスへ接続できません。", false)
|
||||
return
|
||||
var quote: Variant = _runtime.call(&"preview_supply_purchase", selected_warehouse_id, selected_item_id, int(quantity_spin.value))
|
||||
if not quote is Dictionary or not bool(quote.get(&"ok", false)):
|
||||
_set_result(_reason_text(quote.get(&"reason", &"UNAVAILABLE")) if quote is Dictionary else "購入条件を確認できません。", false)
|
||||
refresh()
|
||||
return
|
||||
_submit(PURCHASE, {&"warehouse_id": selected_warehouse_id, &"item_id": selected_item_id, &"quantity": int(quantity_spin.value)})
|
||||
|
||||
|
||||
func submit_production() -> void:
|
||||
if selected_kitchen_id.is_empty() or _has_pending(PRODUCTION, selected_kitchen_id):
|
||||
return
|
||||
_submit(PRODUCTION, {&"facility_id": selected_kitchen_id, &"enabled": production_enabled.button_pressed, &"target_stock": int(target_spin.value)})
|
||||
|
||||
|
||||
func _submit(type: StringName, payload: Dictionary) -> void:
|
||||
if _runtime == null or not _runtime.has_method(&"submit_command"):
|
||||
_set_result("補給サービスへ接続できません。", false)
|
||||
return
|
||||
var context := {&"type": type, &"payload": payload.duplicate(true)}
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", type, payload, &"player", StringName(payload.get(&"facility_id", payload.get(&"warehouse_id", &""))), 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("操作に応答がありません。", false)
|
||||
return
|
||||
var queued := false
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
if queue != null:
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
|
||||
if result.is_success() and queued:
|
||||
_pending_commands[int(result.command_id)] = context
|
||||
_set_result("操作を受理しました。次の時間更新で反映します。", true)
|
||||
else:
|
||||
_show_result(result, context)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_result(result: Variant, context: Dictionary) -> void:
|
||||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||||
if not result.is_success():
|
||||
_set_result(_reason_text(details.get(&"reason", &"UNAVAILABLE")), false)
|
||||
return
|
||||
var payload: Dictionary = context[&"payload"]
|
||||
if context[&"type"] == PRODUCTION:
|
||||
var id := StringName(payload.get(&"facility_id", &""))
|
||||
_kitchen_drafts.erase(id)
|
||||
_set_result("%s:自動生産%s、目標在庫 %dに変更しました。" % [_facility_by_id(id), "ON" if bool(details.get(&"enabled", payload.get(&"enabled", false))) else "OFF", details.get(&"target_stock", payload.get(&"target_stock", 0))], true)
|
||||
else:
|
||||
var cost_text := "(%dG)" % int(details[&"total_cost"]) if details.has(&"total_cost") else ""
|
||||
_set_result("%sを%d仕入れました%s。" % [_item_name(StringName(payload.get(&"item_id", &""))), details.get(&"quantity", payload.get(&"quantity", 0)), cost_text], true)
|
||||
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending_commands.clear()
|
||||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||||
var key := String(metadata.get("idempotency_key", ""))
|
||||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
var type := StringName(command.get("command_type", &""))
|
||||
if type in [PURCHASE, PRODUCTION]:
|
||||
_pending_commands[int(command.get("command_id", 0))] = {&"type": type, &"payload": command.get("payload", {}).duplicate(true)}
|
||||
|
||||
|
||||
func _has_pending(type: StringName, facility_id: StringName = &"") -> bool:
|
||||
for context: Dictionary in _pending_commands.values():
|
||||
if context[&"type"] == type and (facility_id.is_empty() or StringName(context[&"payload"].get(&"facility_id", &"")) == facility_id):
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if not _pending_commands.has(int(result.command_id)):
|
||||
return
|
||||
var context: Dictionary = _pending_commands[int(result.command_id)]
|
||||
_pending_commands.erase(int(result.command_id))
|
||||
_show_result(result, context)
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||||
if is_visible_in_tree():
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_purchase_quantity_changed(_value: float) -> void:
|
||||
if not _refreshing:
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_production_enabled_changed(_enabled: bool) -> void:
|
||||
_store_kitchen_draft()
|
||||
|
||||
|
||||
func _on_target_changed(_value: float) -> void:
|
||||
_store_kitchen_draft()
|
||||
|
||||
|
||||
func _store_kitchen_draft() -> void:
|
||||
if _refreshing or selected_kitchen_id.is_empty():
|
||||
return
|
||||
_kitchen_drafts[selected_kitchen_id] = {&"enabled": production_enabled.button_pressed, &"target_stock": int(target_spin.value)}
|
||||
refresh()
|
||||
|
||||
|
||||
func _fill_option(picker: OptionButton, rows: Array[Dictionary], id_key: StringName, selected: StringName, empty_text: String) -> StringName:
|
||||
if picker.get_popup().visible:
|
||||
return selected
|
||||
picker.clear()
|
||||
if rows.is_empty():
|
||||
picker.add_item(empty_text)
|
||||
picker.set_item_metadata(0, &"")
|
||||
return &""
|
||||
var index := 0
|
||||
for row: Dictionary in rows:
|
||||
picker.add_item(_facility_name(row) if id_key == &"facility_id" else String(row.get(&"display_name", row.get(id_key, ""))))
|
||||
var added := picker.item_count - 1
|
||||
picker.set_item_metadata(added, StringName(row.get(id_key, &"")))
|
||||
if StringName(row.get(id_key, &"")) == selected:
|
||||
index = added
|
||||
picker.select(index)
|
||||
return StringName(picker.get_item_metadata(index))
|
||||
|
||||
|
||||
func _rows(key: StringName) -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
var value: Variant = _report.get(key, [])
|
||||
if value is Array:
|
||||
for row: Variant in value:
|
||||
if row is Dictionary:
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
func _row(key: StringName, id_key: StringName, id: StringName) -> Dictionary:
|
||||
for row: Dictionary in _rows(key):
|
||||
if StringName(row.get(id_key, &"")) == id:
|
||||
return row
|
||||
return {}
|
||||
|
||||
|
||||
func _facility_name(row: Dictionary) -> String:
|
||||
var label := String(row.get(&"display_name", row.get(&"facility_id", "施設")))
|
||||
var origin: Variant = row.get(&"origin", null)
|
||||
if origin is Vector3i:
|
||||
label += " (%d,%d・%s)" % [origin.x, origin.y, "裏" if int(row.get(&"layer", 0)) == 1 else "表"]
|
||||
elif origin is Dictionary:
|
||||
label += " (%d,%d・%s)" % [origin.get(&"x", 0), origin.get(&"y", 0), "裏" if int(row.get(&"layer", 0)) == 1 else "表"]
|
||||
return label
|
||||
|
||||
|
||||
func _facility_by_id(id: StringName) -> String:
|
||||
for group: StringName in [&"warehouses", &"kitchens", &"destinations"]:
|
||||
var row := _row(group, &"facility_id", id)
|
||||
if not row.is_empty():
|
||||
return _facility_name(row)
|
||||
return "施設未指定" if id.is_empty() else String(id)
|
||||
|
||||
|
||||
func _item_name(id: StringName) -> String:
|
||||
return String(_row(&"catalog", &"item_id", id).get(&"display_name", ITEM_NAMES.get(id, String(id))))
|
||||
|
||||
|
||||
func _items_text(value: Variant) -> String:
|
||||
var parts: PackedStringArray = []
|
||||
if value is Dictionary:
|
||||
for id: Variant in value:
|
||||
parts.append("%s %s" % [_item_name(StringName(id)), value[id]])
|
||||
elif value is Array:
|
||||
for row: Variant in value:
|
||||
if row is Dictionary:
|
||||
parts.append("%s %d" % [_item_name(StringName(row.get(&"item_id", &""))), row.get(&"quantity", row.get(&"missing", 0))])
|
||||
return " / ".join(parts) if not parts.is_empty() else "なし"
|
||||
|
||||
|
||||
func _stock_text(value: Variant) -> String:
|
||||
var lines: PackedStringArray = []
|
||||
if value is Array:
|
||||
for row: Variant in value:
|
||||
if row is Dictionary:
|
||||
lines.append("%s %d(利用可 %d / 予約 %d / 配送中 %d)" % [_item_name(StringName(row.get(&"item_id", &""))), row.get(&"total", 0), row.get(&"available", 0), row.get(&"reserved", 0), row.get(&"in_transit", 0)])
|
||||
return "\n".join(lines) if not lines.is_empty() else "在庫なし"
|
||||
|
||||
|
||||
func _state_text(value: Variant) -> String:
|
||||
var state := StringName(String(value))
|
||||
return String(STATE_NAMES.get(state, REASONS.get(state, String(value))))
|
||||
|
||||
|
||||
func _reason_text(value: Variant) -> String:
|
||||
var reason := StringName(String(value))
|
||||
return String(REASONS.get(reason, "状態を確認してください(%s)。" % reason))
|
||||
|
||||
|
||||
func _reasons_text(value: Variant) -> String:
|
||||
var lines: PackedStringArray = []
|
||||
if value is Array:
|
||||
for reason: Variant in value:
|
||||
lines.append(_reason_text(reason))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
func _set_result(message: String, success: bool) -> void:
|
||||
result_label.text = message
|
||||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
|
||||
func _label(value: String, node_name := "") -> Label:
|
||||
var label := Label.new()
|
||||
if not node_name.is_empty():
|
||||
label.name = node_name
|
||||
label.text = value
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
return label
|
||||
|
||||
|
||||
func _button(value: String, node_name: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.name = node_name
|
||||
button.text = value
|
||||
button.custom_minimum_size.y = 30
|
||||
button.add_theme_font_size_override("font_size", 13)
|
||||
button.pressed.connect(callback)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(button)
|
||||
return button
|
||||
|
||||
|
||||
func _option(node_name: String) -> OptionButton:
|
||||
var picker := OptionButton.new()
|
||||
picker.name = node_name
|
||||
picker.fit_to_longest_item = false
|
||||
picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
picker.custom_minimum_size.y = 30
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(picker)
|
||||
return picker
|
||||
|
||||
|
||||
func _spin(node_name: String, minimum: int, value: int) -> SpinBox:
|
||||
var spin := SpinBox.new()
|
||||
spin.name = node_name
|
||||
spin.min_value = minimum
|
||||
spin.max_value = minimum
|
||||
spin.step = 1
|
||||
spin.value = value
|
||||
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
spin.custom_minimum_size.y = 30
|
||||
return spin
|
||||
@@ -0,0 +1 @@
|
||||
uid://cu6exjai0evbv
|
||||
Reference in New Issue
Block a user