Files
TheTree_mock/scripts/facility/facility_instance.gd
Takeshi b84bade045 Save The Tree implementation and acceptance work in progress
Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
2026-10-11 15:58:00 +09:00

590 lines
22 KiB
GDScript

class_name FacilityInstance
extends RefCounted
const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd")
const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd")
## Values are deliberately ordered by operational priority.
enum OperationState { INACTIVE, OPERATING, SUSPENDED }
enum State {
BROKEN,
NO_ACCESS,
NO_POWER,
NO_STAFF,
CLOSED,
DEGRADED,
OPEN,
}
const FULL_DAY_MINUTES := 1_440
const DEFAULT_DURABILITY := 10_000
const DEGRADED_DURABILITY_THRESHOLD := 3_000
var instance_id: StringName = &""
var definition_id: StringName = &""
var origin := Vector3i.ZERO
var rotation := 0
var occupied_cells: Array[Vector3i] = []
var level := 1
var state: State = State.OPEN
var state_reasons: Array[StringName] = []
var durability := DEFAULT_DURABILITY
var local_inventory_id: StringName = &""
var assigned_staff_by_shift: Dictionary = {}
var active_staff_ids: Array[StringName] = []
var queue_entries: Array[StringName] = []
var active_services: Dictionary = {}
var policy_current: Dictionary = {}
var policy_pending: Dictionary = {}
var policy_revision := 0
var policy_pending_revision := 0
var policy_pending_effective_at := -1
var statistics_today: Dictionary = {}
var lifetime_statistics: Dictionary = {}
var created_at := 0
var customer_capacity := -1 # Unspecified; never inferred from storage/production capacity.
var service_attribute: StringName = &""
var related_facility_id: StringName = &""
var related_facility_operating := true
var suspension_reason: StringName = &""
var operation_state: OperationState = OperationState.INACTIVE
var capacity := 1
var queue_capacity := 0
var base_service_minutes := 1
var base_quality := 0
var base_price := 0
var staff_min := 0
var staff_max := 0
var access_available := true
var power_available := true
var operator_open := true
var opening_intervals: Array[Vector2i] = [Vector2i(0, FULL_DAY_MINUTES)]
var degraded_reasons: Array[StringName] = []
func configure(
p_instance_id: StringName,
p_definition_id: StringName,
options: Dictionary = {}
) -> FacilityInstance:
instance_id = p_instance_id
definition_id = p_definition_id
origin = _vector3i(_value(options, &"origin", Vector3i.ZERO))
rotation = _normalize_rotation(int(_value(options, &"rotation", 0)))
occupied_cells = _cell_array(_value(options, &"occupied_cells", []))
level = maxi(int(_value(options, &"level", 1)), 1)
durability = clampi(int(_value(options, &"durability", DEFAULT_DURABILITY)), 0, DEFAULT_DURABILITY)
local_inventory_id = StringName(_value(options, &"local_inventory_id", &""))
capacity = maxi(int(_value(options, &"capacity", 1)), 1)
customer_capacity = int(_value(options, &"customer_capacity", -1))
service_attribute = StringName(_value(options, &"service_attribute", &""))
related_facility_id = StringName(_value(options, &"related_facility_id", &""))
related_facility_operating = related_facility_id.is_empty() and not requires_operating_kitchen()
suspension_reason = StringName(_value(options, &"suspension_reason", &""))
queue_capacity = maxi(int(_value(options, &"queue_capacity", 0)), 0)
base_service_minutes = maxi(int(_value(options, &"base_service_minutes", 1)), 1)
base_quality = int(_value(options, &"base_quality", 0))
base_price = maxi(int(_value(options, &"base_price", 0)), 0)
staff_min = maxi(int(_value(options, &"staff_min", 0)), 0)
staff_max = maxi(int(_value(options, &"staff_max", maxi(staff_min, 0))), staff_min)
policy_current = _normalize_policy(_value(options, &"policy_current", {}))
policy_revision = maxi(int(_value(options, &"policy_revision", 0)), 0)
created_at = maxi(int(_value(options, &"created_at", 0)), 0)
access_available = bool(_value(options, &"access_available", true))
power_available = bool(_value(options, &"power_available", true))
operator_open = bool(_value(options, &"operator_open", true))
if _has_value(policy_current, &"business_open"):
operator_open = bool(_value(policy_current, &"business_open", operator_open))
var intervals := _parse_intervals(_value(options, &"opening_intervals", []))
if not intervals.is_empty():
opening_intervals = intervals
active_staff_ids = _sorted_unique_names(_value(options, &"active_staff_ids", []))
degraded_reasons = _sorted_unique_names(_value(options, &"degraded_reasons", []))
evaluate_state(created_at)
return self
# Original 8.2 requires each dining venue to use a BACK kitchen. Geometry and
# shared logistics are separate contracts; this single-ID rule does not infer either.
func requires_operating_kitchen() -> bool:
return definition_id in [&"cafeteria", &"restaurant", &"tavern"]
func is_valid() -> bool:
return not instance_id.is_empty() \
and not definition_id.is_empty() \
and capacity > 0 \
and customer_capacity >= -1 \
and service_attribute in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"] \
and suspension_reason in [&"", &"DESTROYED", &"RAIDED"] \
and queue_capacity >= 0 \
and base_service_minutes > 0 \
and staff_min >= 0 \
and staff_max >= staff_min \
and _intervals_are_valid(opening_intervals) \
and bool(FacilityPricingType.validate_patch(policy_current)[&"valid"])
func set_opening_hours(open_minute_of_day: int, close_minute_of_day: int) -> bool:
if open_minute_of_day < 0 or open_minute_of_day >= FULL_DAY_MINUTES:
return false
if close_minute_of_day < 0 or close_minute_of_day > FULL_DAY_MINUTES:
return false
if open_minute_of_day == close_minute_of_day:
opening_intervals = [Vector2i(0, FULL_DAY_MINUTES)]
elif open_minute_of_day < close_minute_of_day:
opening_intervals = [Vector2i(open_minute_of_day, close_minute_of_day)]
else:
opening_intervals = []
if close_minute_of_day > 0:
opening_intervals.append(Vector2i(0, close_minute_of_day))
if open_minute_of_day < FULL_DAY_MINUTES:
opening_intervals.append(Vector2i(open_minute_of_day, FULL_DAY_MINUTES))
return true
func set_opening_intervals(intervals: Variant) -> bool:
var parsed := _parse_intervals(intervals)
if parsed.is_empty() or not _intervals_are_valid(parsed):
return false
opening_intervals = parsed
return true
func is_within_opening_hours(absolute_minute: int) -> bool:
var minute_of_day := posmod(absolute_minute, FULL_DAY_MINUTES)
for interval: Vector2i in opening_intervals:
if minute_of_day >= interval.x and minute_of_day < interval.y:
return true
return false
func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> bool:
if _has_value(conditions, &"related_facility_id"):
related_facility_id = StringName(_value(conditions, &"related_facility_id", &""))
related_facility_operating = related_facility_id.is_empty() and not requires_operating_kitchen()
if StringName(_value(conditions, &"suspension_reason", &"")) in [&"DESTROYED", &"RAIDED"]:
suspension_reason = StringName(_value(conditions, &"suspension_reason", &""))
if _has_value(conditions, &"durability"):
durability = clampi(int(_value(conditions, &"durability", durability)), 0, DEFAULT_DURABILITY)
if _has_value(conditions, &"access_available"):
access_available = bool(_value(conditions, &"access_available", access_available))
if _has_value(conditions, &"power_available"):
power_available = bool(_value(conditions, &"power_available", power_available))
if _has_value(conditions, &"operator_open"):
operator_open = bool(_value(conditions, &"operator_open", operator_open))
if _has_value(policy_current, &"business_open"):
apply_policy_patch({&"business_open": operator_open})
if _has_value(conditions, &"active_staff_ids"):
active_staff_ids = _sorted_unique_names(_value(conditions, &"active_staff_ids", []))
if _has_value(conditions, &"degraded_reasons"):
degraded_reasons = _sorted_unique_names(_value(conditions, &"degraded_reasons", []))
return evaluate_state(absolute_minute)
func evaluate_state(absolute_minute: int) -> bool:
if durability <= 0:
suspension_reason = &"DESTROYED"
var next_reasons: Array[StringName] = []
var next_state := State.OPEN
if durability <= 0:
next_reasons.append(&"BROKEN")
next_state = State.BROKEN
if not access_available:
next_reasons.append(&"NO_ACCESS")
if next_state > State.NO_ACCESS:
next_state = State.NO_ACCESS
if not power_available:
next_reasons.append(&"NO_POWER")
if next_state > State.NO_POWER:
next_state = State.NO_POWER
if active_staff_ids.size() < staff_min:
next_reasons.append(&"NO_STAFF")
if next_state > State.NO_STAFF:
next_state = State.NO_STAFF
if not operator_open:
next_reasons.append(&"CLOSED_BY_OPERATOR")
if next_state > State.CLOSED:
next_state = State.CLOSED
if not is_within_opening_hours(absolute_minute):
next_reasons.append(&"OUTSIDE_OPENING_HOURS")
if next_state > State.CLOSED:
next_state = State.CLOSED
if durability > 0 and durability < DEGRADED_DURABILITY_THRESHOLD:
next_reasons.append(&"LOW_DURABILITY")
if next_state > State.DEGRADED:
next_state = State.DEGRADED
for reason: StringName in degraded_reasons:
if not next_reasons.has(reason):
next_reasons.append(reason)
if next_state > State.DEGRADED:
next_state = State.DEGRADED
if not related_facility_operating:
next_reasons.append(&"RELATED_FACILITY_INACTIVE")
next_state = mini(next_state, State.CLOSED) as State
if not suspension_reason.is_empty():
next_reasons.append(suspension_reason)
next_state = State.BROKEN
var next_operation := OperationState.INACTIVE
if not suspension_reason.is_empty():
next_operation = OperationState.SUSPENDED
elif next_state in [State.OPEN, State.DEGRADED]:
next_operation = OperationState.OPERATING
var changed := state != next_state or operation_state != next_operation
operation_state = next_operation
state = next_state
state_reasons = next_reasons
return changed
func can_start_service(absolute_minute: int) -> bool:
evaluate_state(absolute_minute)
return state in [State.OPEN, State.DEGRADED] and available_slots() > 0
func allows_logistics() -> bool:
return operator_open and power_available and durability > 0 and suspension_reason.is_empty() and related_facility_operating
func available_slots() -> int:
var slots := maxi(capacity - active_services.size(), 0)
if customer_capacity >= 0:
slots = mini(slots, maxi(customer_capacity - customer_count(), 0))
return slots
func customer_count() -> int:
var customers: Dictionary = {}
for service: ServiceInstance in active_services.values():
for guest_id: StringName in service.guest_ids:
customers[guest_id] = true
return customers.size()
func can_accept_customers(guest_ids: Array[StringName]) -> bool:
if customer_capacity < 0:
return true
var customers: Dictionary = {}
for service: ServiceInstance in active_services.values():
for guest_id: StringName in service.guest_ids:
customers[guest_id] = true
for guest_id: StringName in guest_ids:
customers[guest_id] = true
return customers.size() <= customer_capacity
func has_queue_space() -> bool:
return queue_entries.size() < queue_capacity
func apply_policy_patch(patch: Dictionary) -> bool:
var changed := false
var keys: Array = patch.keys()
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
for raw_key: Variant in keys:
var key := StringName(raw_key)
var value: Variant = patch[raw_key]
var has_existing := _has_value(policy_current, key)
var existing: Variant = _value(policy_current, key, null)
if not has_existing or existing != value:
policy_current.erase(String(key))
policy_current[key] = _copy(value)
changed = true
if _has_value(patch, &"business_open"):
operator_open = bool(_value(patch, &"business_open", operator_open))
if changed:
policy_revision += 1
return changed
func schedule_policy_patch(patch: Dictionary, effective_at: int) -> bool:
if effective_at < 0 or patch.is_empty():
return false
policy_pending = _normalize_policy(patch)
policy_pending_effective_at = effective_at
policy_pending_revision = policy_revision + 1
return true
func apply_pending_policy(absolute_minute: int) -> bool:
if policy_pending.is_empty() or policy_pending_effective_at < 0:
return false
if absolute_minute < policy_pending_effective_at:
return false
var changed := apply_policy_patch(policy_pending)
policy_pending.clear()
policy_pending_effective_at = -1
policy_pending_revision = 0
return changed
func create_snapshot() -> Dictionary:
var saved_services: Array[Dictionary] = []
var service_ids := _sorted_keys(active_services)
for service_id: StringName in service_ids:
var service: ServiceInstance = active_services[service_id]
saved_services.append(service.create_snapshot())
return {
&"instance_id": String(instance_id),
&"definition_id": String(definition_id),
&"origin": [origin.x, origin.y, origin.z],
&"rotation": rotation,
&"occupied_cells": _serialize_cells(occupied_cells),
&"level": level,
&"state": State.keys()[state],
&"state_reasons": _names_to_strings(state_reasons),
&"durability": durability,
&"local_inventory_id": String(local_inventory_id),
&"assigned_staff_by_shift": assigned_staff_by_shift.duplicate(true),
&"active_staff_ids": _names_to_strings(active_staff_ids),
&"queue_entries": _names_to_strings(queue_entries),
&"active_services": saved_services,
&"policy_current": policy_current.duplicate(true),
&"policy_pending": policy_pending.duplicate(true),
&"policy_revision": policy_revision,
&"policy_pending_revision": policy_pending_revision,
&"policy_pending_effective_at": policy_pending_effective_at,
&"statistics_today": statistics_today.duplicate(true),
&"lifetime_statistics": lifetime_statistics.duplicate(true),
&"created_at": created_at,
&"customer_capacity": customer_capacity,
&"service_attribute": service_attribute,
&"related_facility_id": related_facility_id,
&"suspension_reason": suspension_reason,
&"capacity": capacity,
&"queue_capacity": queue_capacity,
&"base_service_minutes": base_service_minutes,
&"base_quality": base_quality,
&"base_price": base_price,
&"staff_min": staff_min,
&"staff_max": staff_max,
&"access_available": access_available,
&"power_available": power_available,
&"operator_open": operator_open,
&"opening_intervals": _serialize_intervals(opening_intervals),
&"degraded_reasons": _names_to_strings(degraded_reasons),
}
static func from_snapshot(snapshot: Dictionary) -> FacilityInstance:
if not _snapshot_shapes_are_valid(snapshot):
return null
var instance := FacilityInstance.new().configure(
StringName(snapshot.get(&"instance_id", "")),
StringName(snapshot.get(&"definition_id", "")),
{
&"origin": snapshot.get(&"origin", []),
&"rotation": snapshot.get(&"rotation", 0),
&"occupied_cells": snapshot.get(&"occupied_cells", []),
&"level": snapshot.get(&"level", 1),
&"durability": snapshot.get(&"durability", DEFAULT_DURABILITY),
&"local_inventory_id": snapshot.get(&"local_inventory_id", ""),
&"customer_capacity": snapshot.get(&"customer_capacity", -1),
&"service_attribute": snapshot.get(&"service_attribute", ""),
&"related_facility_id": snapshot.get(&"related_facility_id", ""),
&"suspension_reason": snapshot.get(&"suspension_reason", ""),
&"capacity": snapshot.get(&"capacity", 1),
&"queue_capacity": snapshot.get(&"queue_capacity", 0),
&"base_service_minutes": snapshot.get(&"base_service_minutes", 1),
&"base_quality": snapshot.get(&"base_quality", 0),
&"base_price": snapshot.get(&"base_price", 0),
&"staff_min": snapshot.get(&"staff_min", 0),
&"staff_max": snapshot.get(&"staff_max", 0),
&"policy_current": snapshot.get(&"policy_current", {}),
&"policy_revision": snapshot.get(&"policy_revision", 0),
&"created_at": snapshot.get(&"created_at", 0),
&"access_available": snapshot.get(&"access_available", true),
&"power_available": snapshot.get(&"power_available", true),
&"operator_open": snapshot.get(&"operator_open", true),
&"opening_intervals": snapshot.get(&"opening_intervals", []),
&"active_staff_ids": snapshot.get(&"active_staff_ids", []),
&"degraded_reasons": snapshot.get(&"degraded_reasons", []),
}
)
var state_index := State.keys().find(String(snapshot.get(&"state", "OPEN")))
if state_index < 0:
return null
instance.state = state_index as State
instance.state_reasons = _string_name_array(snapshot.get(&"state_reasons", []))
instance.assigned_staff_by_shift = snapshot.get(&"assigned_staff_by_shift", {}).duplicate(true)
instance.queue_entries = _string_name_array(snapshot.get(&"queue_entries", []))
instance.policy_pending = _normalize_policy(snapshot.get(&"policy_pending", {}))
instance.policy_pending_revision = maxi(int(snapshot.get(&"policy_pending_revision", 0)), 0)
instance.policy_pending_effective_at = int(snapshot.get(&"policy_pending_effective_at", -1))
instance.statistics_today = snapshot.get(&"statistics_today", {}).duplicate(true)
instance.lifetime_statistics = snapshot.get(&"lifetime_statistics", {}).duplicate(true)
var seen_services: Dictionary = {}
for saved_service: Variant in snapshot.get(&"active_services", []):
var service := ServiceInstanceType.from_snapshot(saved_service)
if service == null or service.is_terminal() or service.facility_id != instance.instance_id:
return null
if seen_services.has(service.service_id):
return null
seen_services[service.service_id] = true
instance.active_services[service.service_id] = service
if not instance.is_valid() or instance.active_services.size() > instance.capacity:
return null
if instance.customer_capacity >= 0 and instance.customer_count() > instance.customer_capacity:
return null
return instance
static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool:
if snapshot.has(&"customer_capacity") and (typeof(snapshot[&"customer_capacity"]) != TYPE_INT or int(snapshot[&"customer_capacity"]) < -1):
return false
for field: StringName in [&"service_attribute", &"related_facility_id", &"suspension_reason"]:
if snapshot.has(field) and typeof(snapshot[field]) not in [TYPE_STRING, TYPE_STRING_NAME]:
return false
var array_fields := [
&"origin", &"occupied_cells", &"state_reasons", &"active_staff_ids",
&"queue_entries", &"active_services", &"opening_intervals", &"degraded_reasons",
]
for field: StringName in array_fields:
if not snapshot.get(field, null) is Array:
return false
var dictionary_fields := [
&"assigned_staff_by_shift", &"policy_current", &"policy_pending",
&"statistics_today", &"lifetime_statistics",
]
for field: StringName in dictionary_fields:
if not snapshot.get(field, null) is Dictionary:
return false
for field: StringName in [&"policy_current", &"policy_pending"]:
if not bool(FacilityPricingType.validate_patch(snapshot[field])[&"valid"]):
return false
var saved_policy: Dictionary = snapshot[&"policy_current"]
if _has_value(saved_policy, &"business_open") \
and _value(saved_policy, &"business_open", true) != snapshot.get(&"operator_open", true):
return false
return true
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
return source.get(key, source.get(String(key), fallback))
static func _has_value(source: Dictionary, key: StringName) -> bool:
return source.has(key) or source.has(String(key))
static func _copy(value: Variant) -> Variant:
return value.duplicate(true) if value is Dictionary or value is Array else value
static func _dictionary_copy(source: Variant) -> Dictionary:
return source.duplicate(true) if source is Dictionary else {}
static func _normalize_policy(source: Variant) -> Dictionary:
var normalized: Dictionary = {}
if not source is Dictionary:
return normalized
for raw_key: Variant in source:
normalized[StringName(raw_key)] = _copy(source[raw_key])
return normalized
static func _vector3i(source: Variant) -> Vector3i:
if source is Vector3i:
return source
if source is Array and source.size() == 3:
return Vector3i(int(source[0]), int(source[1]), int(source[2]))
return Vector3i.ZERO
static func _cell_array(source: Variant) -> Array[Vector3i]:
var cells: Array[Vector3i] = []
if not source is Array:
return cells
for raw_cell: Variant in source:
if raw_cell is Vector3i:
cells.append(raw_cell)
elif raw_cell is Array and raw_cell.size() == 3:
cells.append(_vector3i(raw_cell))
return cells
static func _parse_intervals(source: Variant) -> Array[Vector2i]:
var intervals: Array[Vector2i] = []
if not source is Array:
return intervals
for raw_interval: Variant in source:
if raw_interval is Vector2i:
intervals.append(raw_interval)
elif raw_interval is Array and raw_interval.size() == 2:
intervals.append(Vector2i(int(raw_interval[0]), int(raw_interval[1])))
intervals.sort_custom(func(a: Vector2i, b: Vector2i) -> bool:
return a.x < b.x or (a.x == b.x and a.y < b.y)
)
return intervals
static func _intervals_are_valid(intervals: Array[Vector2i]) -> bool:
if intervals.is_empty():
return false
var previous_end := -1
for interval: Vector2i in intervals:
if interval.x < 0 or interval.y > FULL_DAY_MINUTES or interval.x >= interval.y:
return false
if interval.x < previous_end:
return false
previous_end = interval.y
return true
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
var values := _string_name_array(source)
var seen: Dictionary = {}
var unique: Array[StringName] = []
for value: StringName in values:
if not value.is_empty() and not seen.has(value):
seen[value] = true
unique.append(value)
unique.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return unique
static func _string_name_array(source: Variant) -> Array[StringName]:
var values: Array[StringName] = []
if source is Array:
for value: Variant in source:
values.append(StringName(value))
return values
static func _sorted_keys(source: Dictionary) -> Array[StringName]:
var values: Array[StringName] = []
for value: Variant in source.keys():
values.append(StringName(value))
values.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
return values
static func _serialize_cells(source: Array[Vector3i]) -> Array[Array]:
var cells: Array[Array] = []
for cell: Vector3i in source:
cells.append([cell.x, cell.y, cell.z])
return cells
static func _serialize_intervals(source: Array[Vector2i]) -> Array[Array]:
var intervals: Array[Array] = []
for interval: Vector2i in source:
intervals.append([interval.x, interval.y])
return intervals
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
var values: Array[String] = []
for value: StringName in source:
values.append(String(value))
return values
static func _normalize_rotation(value: int) -> int:
var normalized := posmod(value, 360)
return (normalized / 90) * 90