Files
TheTree_mock/scripts/supply/kitchen_production_service.gd
Takeshi 5dde0c5ec2 Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
2026-09-30 23:52:24 +09:00

419 lines
18 KiB
GDScript

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.suspension_reason.is_empty(): return &"SUSPENDED"
if not facility.related_facility_operating: return &"RELATED_FACILITY_INACTIVE"
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