Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
class_name ShiftInstance
|
||||
extends RefCounted
|
||||
|
||||
## One concrete shift, identified by its start day even when it ends on the
|
||||
## following day. Absolute integer minutes avoid every midnight ambiguity.
|
||||
|
||||
enum Kind {
|
||||
DAY,
|
||||
NIGHT,
|
||||
FLEX,
|
||||
}
|
||||
|
||||
const MINUTES_PER_DAY := 1_440
|
||||
const COMMUTE_LEAD_MINUTES := 30
|
||||
const DAY_START := 360
|
||||
const DAY_END := 1_080
|
||||
const NIGHT_START := 1_080
|
||||
const NIGHT_END := 360
|
||||
|
||||
var shift_instance_id: StringName = &""
|
||||
var staff_id: StringName = &""
|
||||
var template_id: StringName = &"FLEX"
|
||||
var kind: Kind = Kind.FLEX
|
||||
var start_day := 0
|
||||
var start_minute_of_day := 0
|
||||
var end_minute_of_day := 1
|
||||
var start_at := 0
|
||||
var end_at := 1
|
||||
var assigned_facility_id: StringName = &""
|
||||
var cancelled := false
|
||||
var actual_started_at := -1
|
||||
var actual_ended_at := -1
|
||||
var worked_minutes := 0
|
||||
var overtime_minutes := 0
|
||||
var late_minutes := 0
|
||||
var late_reason: StringName = &""
|
||||
|
||||
|
||||
func configure(
|
||||
p_shift_instance_id: StringName,
|
||||
p_staff_id: StringName,
|
||||
p_template_id: StringName,
|
||||
p_start_day: int,
|
||||
p_start_minute_of_day: int,
|
||||
p_end_minute_of_day: int,
|
||||
p_assigned_facility_id: StringName = &"",
|
||||
options: Dictionary = {}
|
||||
) -> ShiftInstance:
|
||||
shift_instance_id = p_shift_instance_id
|
||||
staff_id = p_staff_id
|
||||
template_id = p_template_id if not p_template_id.is_empty() else &"FLEX"
|
||||
kind = _kind_from_template(template_id)
|
||||
start_day = maxi(p_start_day, 0)
|
||||
start_minute_of_day = p_start_minute_of_day
|
||||
end_minute_of_day = p_end_minute_of_day
|
||||
start_at = start_day * MINUTES_PER_DAY + start_minute_of_day
|
||||
end_at = start_day * MINUTES_PER_DAY + end_minute_of_day
|
||||
if end_minute_of_day <= start_minute_of_day:
|
||||
end_at += MINUTES_PER_DAY
|
||||
assigned_facility_id = p_assigned_facility_id
|
||||
cancelled = bool(_value(options, &"cancelled", false))
|
||||
actual_started_at = int(_value(options, &"actual_started_at", -1))
|
||||
actual_ended_at = int(_value(options, &"actual_ended_at", -1))
|
||||
worked_minutes = maxi(int(_value(options, &"worked_minutes", 0)), 0)
|
||||
overtime_minutes = maxi(int(_value(options, &"overtime_minutes", 0)), 0)
|
||||
late_minutes = maxi(int(_value(options, &"late_minutes", 0)), 0)
|
||||
late_reason = StringName(_value(options, &"late_reason", &""))
|
||||
return self
|
||||
|
||||
|
||||
static func create_standard(
|
||||
p_shift_instance_id: StringName,
|
||||
p_staff_id: StringName,
|
||||
p_template_id: StringName,
|
||||
p_start_day: int,
|
||||
p_assigned_facility_id: StringName = &""
|
||||
) -> ShiftInstance:
|
||||
var normalized := String(p_template_id).to_upper()
|
||||
if normalized == "DAY":
|
||||
return ShiftInstance.new().configure(
|
||||
p_shift_instance_id,
|
||||
p_staff_id,
|
||||
&"DAY",
|
||||
p_start_day,
|
||||
DAY_START,
|
||||
DAY_END,
|
||||
p_assigned_facility_id
|
||||
)
|
||||
if normalized == "NIGHT":
|
||||
return ShiftInstance.new().configure(
|
||||
p_shift_instance_id,
|
||||
p_staff_id,
|
||||
&"NIGHT",
|
||||
p_start_day,
|
||||
NIGHT_START,
|
||||
NIGHT_END,
|
||||
p_assigned_facility_id
|
||||
)
|
||||
return null
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return (
|
||||
not shift_instance_id.is_empty()
|
||||
and not staff_id.is_empty()
|
||||
and start_day >= 0
|
||||
and start_minute_of_day >= 0
|
||||
and start_minute_of_day < MINUTES_PER_DAY
|
||||
and end_minute_of_day >= 0
|
||||
and end_minute_of_day < MINUTES_PER_DAY
|
||||
and end_at > start_at
|
||||
and end_at - start_at <= MINUTES_PER_DAY
|
||||
and worked_minutes >= 0
|
||||
and overtime_minutes >= 0
|
||||
and late_minutes >= 0
|
||||
)
|
||||
|
||||
|
||||
func crosses_midnight() -> bool:
|
||||
return end_at / MINUTES_PER_DAY > start_at / MINUTES_PER_DAY
|
||||
|
||||
|
||||
func contains_minute(absolute_minute: int) -> bool:
|
||||
return not cancelled and absolute_minute >= start_at and absolute_minute < end_at
|
||||
|
||||
|
||||
func is_commute_window(absolute_minute: int) -> bool:
|
||||
return (
|
||||
not cancelled
|
||||
and absolute_minute >= start_at - COMMUTE_LEAD_MINUTES
|
||||
and absolute_minute < start_at
|
||||
)
|
||||
|
||||
|
||||
func has_ended(absolute_minute: int) -> bool:
|
||||
return cancelled or absolute_minute >= end_at
|
||||
|
||||
|
||||
func record_duty_minute(absolute_minute: int, overtime := false) -> bool:
|
||||
if actual_started_at < 0:
|
||||
actual_started_at = absolute_minute
|
||||
if overtime:
|
||||
if absolute_minute < end_at:
|
||||
return false
|
||||
overtime_minutes += 1
|
||||
return true
|
||||
if not contains_minute(absolute_minute):
|
||||
return false
|
||||
worked_minutes += 1
|
||||
return true
|
||||
|
||||
|
||||
func close(absolute_minute: int) -> void:
|
||||
if actual_ended_at < 0:
|
||||
actual_ended_at = maxi(absolute_minute, start_at)
|
||||
|
||||
|
||||
func mark_late(absolute_minute: int, reason: StringName) -> bool:
|
||||
if absolute_minute < start_at + 15:
|
||||
return false
|
||||
late_minutes = maxi(absolute_minute - start_at, 15)
|
||||
late_reason = reason
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"shift_instance_id": String(shift_instance_id),
|
||||
&"staff_id": String(staff_id),
|
||||
&"template_id": String(template_id),
|
||||
&"kind": Kind.keys()[kind],
|
||||
&"start_day": start_day,
|
||||
&"start_minute_of_day": start_minute_of_day,
|
||||
&"end_minute_of_day": end_minute_of_day,
|
||||
&"start_at": start_at,
|
||||
&"end_at": end_at,
|
||||
&"assigned_facility_id": String(assigned_facility_id),
|
||||
&"cancelled": cancelled,
|
||||
&"actual_started_at": actual_started_at,
|
||||
&"actual_ended_at": actual_ended_at,
|
||||
&"worked_minutes": worked_minutes,
|
||||
&"overtime_minutes": overtime_minutes,
|
||||
&"late_minutes": late_minutes,
|
||||
&"late_reason": String(late_reason),
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> ShiftInstance:
|
||||
if not _snapshot_shape_is_valid(snapshot):
|
||||
return null
|
||||
var shift := ShiftInstance.new().configure(
|
||||
StringName(snapshot.get(&"shift_instance_id", "")),
|
||||
StringName(snapshot.get(&"staff_id", "")),
|
||||
StringName(snapshot.get(&"template_id", "FLEX")),
|
||||
int(snapshot.get(&"start_day", 0)),
|
||||
int(snapshot.get(&"start_minute_of_day", 0)),
|
||||
int(snapshot.get(&"end_minute_of_day", 1)),
|
||||
StringName(snapshot.get(&"assigned_facility_id", "")),
|
||||
{
|
||||
&"cancelled": snapshot.get(&"cancelled", false),
|
||||
&"actual_started_at": snapshot.get(&"actual_started_at", -1),
|
||||
&"actual_ended_at": snapshot.get(&"actual_ended_at", -1),
|
||||
&"worked_minutes": snapshot.get(&"worked_minutes", 0),
|
||||
&"overtime_minutes": snapshot.get(&"overtime_minutes", 0),
|
||||
&"late_minutes": snapshot.get(&"late_minutes", 0),
|
||||
&"late_reason": snapshot.get(&"late_reason", ""),
|
||||
}
|
||||
)
|
||||
if shift.start_at != int(snapshot.get(&"start_at", -1)):
|
||||
return null
|
||||
if shift.end_at != int(snapshot.get(&"end_at", -1)):
|
||||
return null
|
||||
var kind_index := Kind.keys().find(String(snapshot.get(&"kind", "")))
|
||||
if kind_index < 0 or shift.kind != kind_index:
|
||||
return null
|
||||
return shift if shift.is_valid() else null
|
||||
|
||||
|
||||
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"shift_instance_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"staff_id", "")).is_empty()
|
||||
and int(snapshot.get(&"start_day", -1)) >= 0
|
||||
and int(snapshot.get(&"worked_minutes", -1)) >= 0
|
||||
and int(snapshot.get(&"overtime_minutes", -1)) >= 0
|
||||
)
|
||||
|
||||
|
||||
static func _kind_from_template(value: StringName) -> Kind:
|
||||
match String(value).to_upper():
|
||||
"DAY":
|
||||
return Kind.DAY
|
||||
"NIGHT":
|
||||
return Kind.NIGHT
|
||||
_:
|
||||
return Kind.FLEX
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
@@ -0,0 +1 @@
|
||||
uid://0blu3y4pvj30
|
||||
@@ -0,0 +1,493 @@
|
||||
class_name StaffRuntime
|
||||
extends RefCounted
|
||||
|
||||
## Mutable state for one employee. Static role definitions remain outside this
|
||||
## object; the copied tags make dispatch deterministic even if content is hot
|
||||
## reloaded later.
|
||||
|
||||
enum State {
|
||||
OFF_DUTY,
|
||||
COMMUTING,
|
||||
AVAILABLE,
|
||||
MOVING_TO_TASK,
|
||||
WORKING,
|
||||
BREAKING,
|
||||
EATING,
|
||||
SLEEPING,
|
||||
SEEKING_MEDICAL,
|
||||
FLEEING,
|
||||
INCAPACITATED,
|
||||
STRIKE,
|
||||
DETAINED,
|
||||
CAPTIVE,
|
||||
UNAVAILABLE,
|
||||
}
|
||||
|
||||
## EmploymentKind describes the commercial relationship only. Adult-content
|
||||
## eligibility is deliberately validated by MvpContentCatalog before a hire is
|
||||
## constructed; ordinary Staff fixtures therefore remain valid FREE_HIRE rows.
|
||||
enum EmploymentKind {
|
||||
FREE_HIRE,
|
||||
ADULT_CONTRACT,
|
||||
UNIQUE,
|
||||
}
|
||||
|
||||
const MAX_PSYCHOLOGY_VALUE := 100
|
||||
const MAX_SKILL_VALUE := 999
|
||||
const DANGEROUS_TASK_FATIGUE_LIMIT := 90
|
||||
const SNAPSHOT_VERSION := 1
|
||||
|
||||
var staff_id: StringName = &""
|
||||
var role_id: StringName = &""
|
||||
var current_cell := Vector3i.ZERO
|
||||
var assigned_facility_id: StringName = &""
|
||||
var shift_id: StringName = &""
|
||||
var state: State = State.OFF_DUTY
|
||||
var current_task_id: StringName = &""
|
||||
var skills: Dictionary = {}
|
||||
var fatigue := 0
|
||||
var stress := 0
|
||||
var morale := 100
|
||||
var loyalty := 50
|
||||
var health := 100
|
||||
var carried_inventory_id: StringName = &""
|
||||
var path_handle: StringName = &""
|
||||
var absence_reason: StringName = &""
|
||||
|
||||
var permitted_task_tags: Array[StringName] = []
|
||||
var role_tags: Array[StringName] = []
|
||||
var legal_permissions: Array[StringName] = []
|
||||
var equipment_tags: Array[StringName] = []
|
||||
var required_equipment_tags: Array[StringName] = []
|
||||
var daily_wage := 0
|
||||
var employment_kind: EmploymentKind = EmploymentKind.FREE_HIRE
|
||||
var employment_effective_minute := 0
|
||||
var unpaid_wages := 0
|
||||
var is_retiring := false
|
||||
var is_overtime := false
|
||||
var state_changed_at := 0
|
||||
var state_reason: StringName = &""
|
||||
|
||||
var _employment_configuration_valid := true
|
||||
|
||||
|
||||
func configure(
|
||||
p_staff_id: StringName,
|
||||
p_role_id: StringName,
|
||||
options: Dictionary = {}
|
||||
) -> StaffRuntime:
|
||||
staff_id = p_staff_id
|
||||
role_id = p_role_id
|
||||
current_cell = _vector3i(_value(options, &"current_cell", Vector3i.ZERO))
|
||||
assigned_facility_id = StringName(_value(options, &"assigned_facility_id", &""))
|
||||
shift_id = StringName(_value(options, &"shift_id", &""))
|
||||
state = _state_from_value(_value(options, &"state", State.OFF_DUTY), State.OFF_DUTY)
|
||||
current_task_id = StringName(_value(options, &"current_task_id", &""))
|
||||
skills = _normalize_skills(_value(options, &"skills", {}))
|
||||
fatigue = clampi(int(_value(options, &"fatigue", 0)), 0, MAX_PSYCHOLOGY_VALUE)
|
||||
stress = clampi(int(_value(options, &"stress", 0)), 0, MAX_PSYCHOLOGY_VALUE)
|
||||
morale = clampi(int(_value(options, &"morale", 100)), 0, MAX_PSYCHOLOGY_VALUE)
|
||||
loyalty = clampi(int(_value(options, &"loyalty", 50)), 0, MAX_PSYCHOLOGY_VALUE)
|
||||
health = clampi(int(_value(options, &"health", 100)), 0, MAX_PSYCHOLOGY_VALUE)
|
||||
carried_inventory_id = StringName(_value(options, &"carried_inventory_id", &""))
|
||||
path_handle = StringName(_value(options, &"path_handle", &""))
|
||||
absence_reason = StringName(_value(options, &"absence_reason", &""))
|
||||
permitted_task_tags = _sorted_unique_names(_value(options, &"permitted_task_tags", []))
|
||||
role_tags = _sorted_unique_names(_value(options, &"role_tags", []))
|
||||
if not role_id.is_empty() and not role_tags.has(role_id):
|
||||
role_tags.append(role_id)
|
||||
role_tags.sort_custom(_name_less)
|
||||
legal_permissions = _sorted_unique_names(_value(options, &"legal_permissions", []))
|
||||
equipment_tags = _sorted_unique_names(_value(options, &"equipment_tags", []))
|
||||
required_equipment_tags = _sorted_unique_names(_value(options, &"required_equipment_tags", []))
|
||||
_employment_configuration_valid = true
|
||||
var raw_daily_wage: Variant = _value(options, &"daily_wage", 0)
|
||||
if typeof(raw_daily_wage) != TYPE_INT or int(raw_daily_wage) < 0:
|
||||
_employment_configuration_valid = false
|
||||
daily_wage = 0
|
||||
else:
|
||||
daily_wage = int(raw_daily_wage)
|
||||
var parsed_employment := _parse_employment_kind(
|
||||
_value(options, &"employment_kind", EmploymentKind.FREE_HIRE)
|
||||
)
|
||||
if not bool(parsed_employment[&"ok"]):
|
||||
_employment_configuration_valid = false
|
||||
employment_kind = EmploymentKind.FREE_HIRE
|
||||
else:
|
||||
employment_kind = int(parsed_employment[&"value"]) as EmploymentKind
|
||||
var raw_employment_effective_minute: Variant = _value(
|
||||
options, &"employment_effective_minute", 0
|
||||
)
|
||||
if (
|
||||
typeof(raw_employment_effective_minute) != TYPE_INT
|
||||
or int(raw_employment_effective_minute) < 0
|
||||
):
|
||||
_employment_configuration_valid = false
|
||||
employment_effective_minute = 0
|
||||
else:
|
||||
employment_effective_minute = int(raw_employment_effective_minute)
|
||||
unpaid_wages = maxi(int(_value(options, &"unpaid_wages", 0)), 0)
|
||||
is_retiring = bool(_value(options, &"is_retiring", false))
|
||||
is_overtime = bool(_value(options, &"is_overtime", false))
|
||||
state_changed_at = maxi(int(_value(options, &"state_changed_at", 0)), 0)
|
||||
state_reason = StringName(_value(options, &"state_reason", &""))
|
||||
if health <= 0:
|
||||
state = State.INCAPACITATED
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if staff_id.is_empty() or role_id.is_empty():
|
||||
return false
|
||||
if fatigue < 0 or fatigue > MAX_PSYCHOLOGY_VALUE:
|
||||
return false
|
||||
if stress < 0 or stress > MAX_PSYCHOLOGY_VALUE:
|
||||
return false
|
||||
if morale < 0 or morale > MAX_PSYCHOLOGY_VALUE:
|
||||
return false
|
||||
if loyalty < 0 or loyalty > MAX_PSYCHOLOGY_VALUE:
|
||||
return false
|
||||
if health < 0 or health > MAX_PSYCHOLOGY_VALUE:
|
||||
return false
|
||||
if unpaid_wages < 0:
|
||||
return false
|
||||
if not _employment_configuration_valid or daily_wage < 0:
|
||||
return false
|
||||
if employment_effective_minute < 0:
|
||||
return false
|
||||
if int(employment_kind) < 0 or int(employment_kind) >= EmploymentKind.size():
|
||||
return false
|
||||
for raw_skill_id: Variant in skills:
|
||||
var skill_id := StringName(raw_skill_id)
|
||||
var skill_value := int(skills[raw_skill_id])
|
||||
if skill_id.is_empty() or skill_value < 0 or skill_value > MAX_SKILL_VALUE:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func can_take_shift() -> bool:
|
||||
return health > 0 and not is_retiring and state not in [
|
||||
State.INCAPACITATED,
|
||||
State.STRIKE,
|
||||
State.DETAINED,
|
||||
State.CAPTIVE,
|
||||
State.UNAVAILABLE,
|
||||
]
|
||||
|
||||
|
||||
func has_required_equipment() -> bool:
|
||||
for equipment_id: StringName in required_equipment_tags:
|
||||
if not equipment_tags.has(equipment_id):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func is_available_for_dispatch() -> bool:
|
||||
return can_take_shift() and has_required_equipment() and state == State.AVAILABLE and current_task_id.is_empty()
|
||||
|
||||
|
||||
func meets_task_requirements(task: Variant) -> bool:
|
||||
if task == null or not can_take_shift() or not has_required_equipment():
|
||||
return false
|
||||
if bool(task.dangerous) and fatigue >= DANGEROUS_TASK_FATIGUE_LIMIT:
|
||||
return false
|
||||
for required_tag: StringName in task.required_role_tags:
|
||||
if not role_tags.has(required_tag):
|
||||
return false
|
||||
for task_tag: StringName in task.task_tags:
|
||||
if not permitted_task_tags.is_empty() and not permitted_task_tags.has(task_tag):
|
||||
return false
|
||||
for raw_skill_id: Variant in task.required_skill_min:
|
||||
var skill_id := StringName(raw_skill_id)
|
||||
if int(skills.get(skill_id, 0)) < int(task.required_skill_min[raw_skill_id]):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func role_fit_for(task: Variant) -> int:
|
||||
if task == null:
|
||||
return 0
|
||||
if task.required_role_tags.is_empty():
|
||||
return 0
|
||||
for required_tag: StringName in task.required_role_tags:
|
||||
if not role_tags.has(required_tag):
|
||||
return 0
|
||||
return 1
|
||||
|
||||
|
||||
func skill_fit_for(task: Variant) -> int:
|
||||
if task == null or task.required_skill_min.is_empty():
|
||||
return 0
|
||||
var total := 0
|
||||
for raw_skill_id: Variant in task.required_skill_min:
|
||||
var skill_id := StringName(raw_skill_id)
|
||||
total += int(skills.get(skill_id, 0)) - int(task.required_skill_min[raw_skill_id])
|
||||
return maxi(total, 0)
|
||||
|
||||
|
||||
func reserve_task(task_id: StringName, absolute_minute: int) -> bool:
|
||||
if task_id.is_empty() or not current_task_id.is_empty():
|
||||
return false
|
||||
if state != State.AVAILABLE:
|
||||
return false
|
||||
current_task_id = task_id
|
||||
transition_to(State.MOVING_TO_TASK, &"TASK_RESERVED", absolute_minute)
|
||||
return true
|
||||
|
||||
|
||||
func begin_task(absolute_minute: int) -> bool:
|
||||
if current_task_id.is_empty() or state not in [State.MOVING_TO_TASK, State.AVAILABLE]:
|
||||
return false
|
||||
transition_to(State.WORKING, &"TASK_STARTED", absolute_minute)
|
||||
return true
|
||||
|
||||
|
||||
func release_task(absolute_minute: int, reason: StringName = &"TASK_RELEASED") -> bool:
|
||||
if current_task_id.is_empty():
|
||||
return false
|
||||
current_task_id = &""
|
||||
if can_take_shift():
|
||||
transition_to(State.AVAILABLE, reason, absolute_minute)
|
||||
else:
|
||||
transition_to(State.OFF_DUTY, reason, absolute_minute)
|
||||
return true
|
||||
|
||||
|
||||
func transition_to(next_state: State, reason: StringName, absolute_minute: int) -> void:
|
||||
state = next_state
|
||||
state_reason = reason
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
|
||||
|
||||
func add_fatigue(amount: int) -> int:
|
||||
fatigue = clampi(fatigue + amount, 0, MAX_PSYCHOLOGY_VALUE)
|
||||
return fatigue
|
||||
|
||||
|
||||
func add_stress(amount: int) -> int:
|
||||
stress = clampi(stress + amount, 0, MAX_PSYCHOLOGY_VALUE)
|
||||
return stress
|
||||
|
||||
|
||||
func get_employment_kind_name() -> StringName:
|
||||
return StringName(EmploymentKind.keys()[employment_kind])
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"snapshot_version": SNAPSHOT_VERSION,
|
||||
&"staff_id": String(staff_id),
|
||||
&"role_id": String(role_id),
|
||||
&"current_cell": [current_cell.x, current_cell.y, current_cell.z],
|
||||
&"assigned_facility_id": String(assigned_facility_id),
|
||||
&"shift_id": String(shift_id),
|
||||
&"state": State.keys()[state],
|
||||
&"current_task_id": String(current_task_id),
|
||||
&"skills": _serialize_skills(skills),
|
||||
&"fatigue": fatigue,
|
||||
&"stress": stress,
|
||||
&"morale": morale,
|
||||
&"loyalty": loyalty,
|
||||
&"health": health,
|
||||
&"carried_inventory_id": String(carried_inventory_id),
|
||||
&"path_handle": String(path_handle),
|
||||
&"absence_reason": String(absence_reason),
|
||||
&"permitted_task_tags": _names_to_strings(permitted_task_tags),
|
||||
&"role_tags": _names_to_strings(role_tags),
|
||||
&"legal_permissions": _names_to_strings(legal_permissions),
|
||||
&"equipment_tags": _names_to_strings(equipment_tags),
|
||||
&"required_equipment_tags": _names_to_strings(required_equipment_tags),
|
||||
&"daily_wage": daily_wage,
|
||||
&"employment_kind": String(get_employment_kind_name()),
|
||||
&"employment_effective_minute": employment_effective_minute,
|
||||
&"unpaid_wages": unpaid_wages,
|
||||
&"is_retiring": is_retiring,
|
||||
&"is_overtime": is_overtime,
|
||||
&"state_changed_at": state_changed_at,
|
||||
&"state_reason": String(state_reason),
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> StaffRuntime:
|
||||
if not _snapshot_shape_is_valid(snapshot):
|
||||
return null
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var staff := StaffRuntime.new().configure(
|
||||
StringName(snapshot.get(&"staff_id", "")),
|
||||
StringName(snapshot.get(&"role_id", "")),
|
||||
{
|
||||
&"current_cell": snapshot.get(&"current_cell", []),
|
||||
&"assigned_facility_id": snapshot.get(&"assigned_facility_id", ""),
|
||||
&"shift_id": snapshot.get(&"shift_id", ""),
|
||||
&"state": state_index,
|
||||
&"current_task_id": snapshot.get(&"current_task_id", ""),
|
||||
&"skills": snapshot.get(&"skills", {}),
|
||||
&"fatigue": snapshot.get(&"fatigue", 0),
|
||||
&"stress": snapshot.get(&"stress", 0),
|
||||
&"morale": snapshot.get(&"morale", 100),
|
||||
&"loyalty": snapshot.get(&"loyalty", 50),
|
||||
&"health": snapshot.get(&"health", 100),
|
||||
&"carried_inventory_id": snapshot.get(&"carried_inventory_id", ""),
|
||||
&"path_handle": snapshot.get(&"path_handle", ""),
|
||||
&"absence_reason": snapshot.get(&"absence_reason", ""),
|
||||
&"permitted_task_tags": snapshot.get(&"permitted_task_tags", []),
|
||||
&"role_tags": snapshot.get(&"role_tags", []),
|
||||
&"legal_permissions": snapshot.get(&"legal_permissions", []),
|
||||
&"equipment_tags": snapshot.get(&"equipment_tags", []),
|
||||
&"required_equipment_tags": snapshot.get(&"required_equipment_tags", []),
|
||||
# Snapshots written before payroll support omitted both fields. Their
|
||||
# deterministic migration is a zero-wage ordinary hire.
|
||||
&"daily_wage": snapshot.get(&"daily_wage", 0),
|
||||
&"employment_kind": snapshot.get(&"employment_kind", "FREE_HIRE"),
|
||||
# Rows written before the employment-time payroll gate represent Staff
|
||||
# who already existed at simulation minute zero.
|
||||
&"employment_effective_minute": snapshot.get(&"employment_effective_minute", 0),
|
||||
&"unpaid_wages": snapshot.get(&"unpaid_wages", 0),
|
||||
&"is_retiring": snapshot.get(&"is_retiring", false),
|
||||
&"is_overtime": snapshot.get(&"is_overtime", false),
|
||||
&"state_changed_at": snapshot.get(&"state_changed_at", 0),
|
||||
&"state_reason": snapshot.get(&"state_reason", ""),
|
||||
}
|
||||
)
|
||||
return staff if staff.is_valid() else null
|
||||
|
||||
|
||||
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
|
||||
var has_snapshot_version := snapshot.has(&"snapshot_version") or snapshot.has("snapshot_version")
|
||||
var has_effective_minute := (
|
||||
snapshot.has(&"employment_effective_minute")
|
||||
or snapshot.has("employment_effective_minute")
|
||||
)
|
||||
if has_snapshot_version != has_effective_minute:
|
||||
return false
|
||||
if has_snapshot_version:
|
||||
var raw_snapshot_version: Variant = snapshot.get(
|
||||
&"snapshot_version", snapshot.get("snapshot_version", null)
|
||||
)
|
||||
if typeof(raw_snapshot_version) != TYPE_INT or int(raw_snapshot_version) != SNAPSHOT_VERSION:
|
||||
return false
|
||||
# Versioned rows are current-schema data; omission is corruption rather
|
||||
# than permission to silently reinterpret the employee as a legacy hire.
|
||||
if not has_effective_minute:
|
||||
return false
|
||||
if has_effective_minute:
|
||||
var effective_minute: Variant = snapshot.get(
|
||||
&"employment_effective_minute",
|
||||
snapshot.get("employment_effective_minute", null)
|
||||
)
|
||||
if typeof(effective_minute) != TYPE_INT or int(effective_minute) < 0:
|
||||
return false
|
||||
var unpaid: Variant = snapshot.get(&"unpaid_wages", snapshot.get("unpaid_wages", null))
|
||||
if typeof(unpaid) != TYPE_INT or int(unpaid) < 0:
|
||||
return false
|
||||
if snapshot.has(&"daily_wage") or snapshot.has("daily_wage"):
|
||||
var wage: Variant = snapshot.get(&"daily_wage", snapshot.get("daily_wage"))
|
||||
if typeof(wage) != TYPE_INT or int(wage) < 0:
|
||||
return false
|
||||
if snapshot.has(&"employment_kind") or snapshot.has("employment_kind"):
|
||||
var kind: Variant = snapshot.get(
|
||||
&"employment_kind", snapshot.get("employment_kind")
|
||||
)
|
||||
# The serialized contract is deliberately narrower than configure(): a
|
||||
# current Save always writes one canonical enum name.
|
||||
if typeof(kind) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
if not bool(_parse_employment_kind(kind)[&"ok"]):
|
||||
return false
|
||||
return (
|
||||
not String(snapshot.get(&"staff_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"role_id", "")).is_empty()
|
||||
and snapshot.get(&"current_cell", null) is Array
|
||||
and snapshot.get(&"skills", null) is Dictionary
|
||||
and snapshot.get(&"permitted_task_tags", null) is Array
|
||||
and snapshot.get(&"role_tags", null) is Array
|
||||
and snapshot.get(&"legal_permissions", null) is Array
|
||||
and snapshot.get(&"equipment_tags", null) is Array
|
||||
and snapshot.get(&"required_equipment_tags", null) is Array
|
||||
and int(snapshot.get(&"fatigue", -1)) in range(0, MAX_PSYCHOLOGY_VALUE + 1)
|
||||
and int(snapshot.get(&"health", -1)) in range(0, MAX_PSYCHOLOGY_VALUE + 1)
|
||||
)
|
||||
|
||||
|
||||
static func _parse_employment_kind(value: Variant) -> Dictionary:
|
||||
if typeof(value) == TYPE_INT:
|
||||
var numeric := int(value)
|
||||
return {
|
||||
&"ok": numeric >= 0 and numeric < EmploymentKind.size(),
|
||||
&"value": numeric,
|
||||
}
|
||||
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return {&"ok": false, &"value": EmploymentKind.FREE_HIRE}
|
||||
var index := EmploymentKind.keys().find(String(value))
|
||||
return {
|
||||
&"ok": index >= 0,
|
||||
&"value": index if index >= 0 else EmploymentKind.FREE_HIRE,
|
||||
}
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _state_from_value(value: Variant, fallback: State) -> State:
|
||||
if value is int:
|
||||
var numeric := int(value)
|
||||
if numeric >= 0 and numeric < State.size():
|
||||
return numeric as State
|
||||
var index := State.keys().find(String(value))
|
||||
return index as State if index >= 0 else fallback
|
||||
|
||||
|
||||
static func _vector3i(value: Variant) -> Vector3i:
|
||||
if value is Vector3i:
|
||||
return value
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
static func _normalize_skills(source: Variant) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return result
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_skill_id: Variant in keys:
|
||||
var skill_id := StringName(raw_skill_id)
|
||||
if not skill_id.is_empty():
|
||||
result[skill_id] = clampi(int(source[raw_skill_id]), 0, MAX_SKILL_VALUE)
|
||||
return result
|
||||
|
||||
|
||||
static func _serialize_skills(source: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_skill_id: Variant in keys:
|
||||
result[String(raw_skill_id)] = int(source[raw_skill_id])
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for value: Variant in source:
|
||||
var name := StringName(value)
|
||||
if not name.is_empty() and not result.has(name):
|
||||
result.append(name)
|
||||
result.sort_custom(_name_less)
|
||||
return result
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for value: StringName in source:
|
||||
result.append(String(value))
|
||||
return result
|
||||
|
||||
|
||||
static func _name_less(a: StringName, b: StringName) -> bool:
|
||||
return String(a) < String(b)
|
||||
@@ -0,0 +1 @@
|
||||
uid://d4d740ytn1ngs
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://sox5srfohstf
|
||||
@@ -0,0 +1,361 @@
|
||||
class_name StaffTask
|
||||
extends RefCounted
|
||||
|
||||
enum State {
|
||||
OPEN,
|
||||
RESERVED,
|
||||
MOVING,
|
||||
WORKING,
|
||||
COMPLETED,
|
||||
BLOCKED,
|
||||
CANCELLED,
|
||||
FAILED,
|
||||
}
|
||||
|
||||
const MAX_PRIORITY := 100
|
||||
const MAX_INTERRUPT_LEVEL := 3
|
||||
|
||||
var task_id: StringName = &""
|
||||
var task_type: StringName = &""
|
||||
var priority := 0
|
||||
var created_at := 0
|
||||
var deadline_at := -1
|
||||
var target_entity_id: StringName = &""
|
||||
var target_cell := Vector3i.ZERO
|
||||
var required_role_tags: Array[StringName] = []
|
||||
var required_skill_min: Dictionary = {}
|
||||
var required_items: Dictionary = {}
|
||||
var estimated_minutes := 1
|
||||
var interrupt_level := 3
|
||||
var state: State = State.OPEN
|
||||
var assigned_staff_id: StringName = &""
|
||||
var retry_count := 0
|
||||
|
||||
var task_tags: Array[StringName] = []
|
||||
var reservation_ids: Array[StringName] = []
|
||||
var progress_minutes := 0
|
||||
var dangerous := false
|
||||
var emergency := false
|
||||
var preserve_progress_on_interrupt := true
|
||||
var state_reason: StringName = &""
|
||||
var state_changed_at := 0
|
||||
|
||||
|
||||
func configure(
|
||||
p_task_id: StringName,
|
||||
p_task_type: StringName,
|
||||
p_priority: int,
|
||||
p_created_at: int,
|
||||
options: Dictionary = {}
|
||||
) -> StaffTask:
|
||||
task_id = p_task_id
|
||||
task_type = p_task_type
|
||||
priority = clampi(p_priority, 0, MAX_PRIORITY)
|
||||
created_at = maxi(p_created_at, 0)
|
||||
deadline_at = int(_value(options, &"deadline_at", -1))
|
||||
target_entity_id = StringName(_value(options, &"target_entity_id", &""))
|
||||
target_cell = _vector3i(_value(options, &"target_cell", Vector3i.ZERO))
|
||||
required_role_tags = _sorted_unique_names(_value(options, &"required_role_tags", []))
|
||||
required_skill_min = _normalize_positive_dictionary(_value(options, &"required_skill_min", {}), 999)
|
||||
required_items = _normalize_positive_dictionary(_value(options, &"required_items", {}))
|
||||
estimated_minutes = maxi(int(_value(options, &"estimated_minutes", 1)), 1)
|
||||
interrupt_level = clampi(int(_value(options, &"interrupt_level", 3)), 0, MAX_INTERRUPT_LEVEL)
|
||||
state = _state_from_value(_value(options, &"state", State.OPEN), State.OPEN)
|
||||
assigned_staff_id = StringName(_value(options, &"assigned_staff_id", &""))
|
||||
retry_count = maxi(int(_value(options, &"retry_count", 0)), 0)
|
||||
task_tags = _sorted_unique_names(_value(options, &"task_tags", []))
|
||||
reservation_ids = _sorted_unique_names(_value(options, &"reservation_ids", []))
|
||||
progress_minutes = clampi(
|
||||
int(_value(options, &"progress_minutes", 0)),
|
||||
0,
|
||||
estimated_minutes
|
||||
)
|
||||
dangerous = bool(_value(options, &"dangerous", false))
|
||||
emergency = bool(_value(options, &"emergency", priority >= 90))
|
||||
preserve_progress_on_interrupt = bool(_value(options, &"preserve_progress_on_interrupt", true))
|
||||
state_reason = StringName(_value(options, &"state_reason", &""))
|
||||
state_changed_at = maxi(int(_value(options, &"state_changed_at", created_at)), 0)
|
||||
return self
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
if task_id.is_empty() or task_type.is_empty():
|
||||
return false
|
||||
if priority < 0 or priority > MAX_PRIORITY:
|
||||
return false
|
||||
if estimated_minutes <= 0 or progress_minutes < 0 or progress_minutes > estimated_minutes:
|
||||
return false
|
||||
if interrupt_level < 0 or interrupt_level > MAX_INTERRUPT_LEVEL:
|
||||
return false
|
||||
if state in [State.RESERVED, State.MOVING, State.WORKING] and assigned_staff_id.is_empty():
|
||||
return false
|
||||
if state in [State.OPEN, State.BLOCKED, State.COMPLETED, State.CANCELLED, State.FAILED] \
|
||||
and not assigned_staff_id.is_empty():
|
||||
return false
|
||||
if state == State.OPEN and not reservation_ids.is_empty():
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func is_terminal() -> bool:
|
||||
return state in [State.COMPLETED, State.CANCELLED, State.FAILED]
|
||||
|
||||
|
||||
func is_dispatchable() -> bool:
|
||||
return state == State.OPEN and assigned_staff_id.is_empty()
|
||||
|
||||
|
||||
func reserve(
|
||||
staff_id: StringName,
|
||||
p_reservation_ids: Array[StringName],
|
||||
absolute_minute: int
|
||||
) -> bool:
|
||||
if not is_dispatchable() or staff_id.is_empty():
|
||||
return false
|
||||
if not required_items.is_empty() and p_reservation_ids.is_empty():
|
||||
return false
|
||||
assigned_staff_id = staff_id
|
||||
reservation_ids.assign(p_reservation_ids)
|
||||
state = State.RESERVED
|
||||
state_reason = &""
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func begin_moving(absolute_minute: int) -> bool:
|
||||
if state != State.RESERVED:
|
||||
return false
|
||||
state = State.MOVING
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func begin_working(absolute_minute: int) -> bool:
|
||||
if state not in [State.RESERVED, State.MOVING]:
|
||||
return false
|
||||
state = State.WORKING
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func advance_work(minutes: int, absolute_minute: int) -> bool:
|
||||
if state != State.WORKING or minutes <= 0:
|
||||
return false
|
||||
progress_minutes = mini(progress_minutes + minutes, estimated_minutes)
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return progress_minutes >= estimated_minutes
|
||||
|
||||
|
||||
func complete(absolute_minute: int) -> bool:
|
||||
if state != State.WORKING or progress_minutes < estimated_minutes:
|
||||
return false
|
||||
assigned_staff_id = &""
|
||||
reservation_ids.clear()
|
||||
state = State.COMPLETED
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func block(reason: StringName, absolute_minute: int) -> bool:
|
||||
if is_terminal():
|
||||
return false
|
||||
retry_count += 1
|
||||
assigned_staff_id = &""
|
||||
reservation_ids.clear()
|
||||
state = State.BLOCKED
|
||||
state_reason = reason
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func reopen(reason: StringName, absolute_minute: int, preserve_progress := true) -> bool:
|
||||
if is_terminal():
|
||||
return false
|
||||
assigned_staff_id = &""
|
||||
reservation_ids.clear()
|
||||
if not preserve_progress:
|
||||
progress_minutes = 0
|
||||
state = State.OPEN
|
||||
state_reason = reason
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func cancel(reason: StringName, absolute_minute: int) -> bool:
|
||||
if is_terminal():
|
||||
return false
|
||||
assigned_staff_id = &""
|
||||
reservation_ids.clear()
|
||||
state = State.CANCELLED
|
||||
state_reason = reason
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func fail(reason: StringName, absolute_minute: int) -> bool:
|
||||
if is_terminal():
|
||||
return false
|
||||
assigned_staff_id = &""
|
||||
reservation_ids.clear()
|
||||
state = State.FAILED
|
||||
state_reason = reason
|
||||
state_changed_at = maxi(absolute_minute, 0)
|
||||
return true
|
||||
|
||||
|
||||
func can_be_interrupted_by(candidate: StaffTask) -> bool:
|
||||
if candidate == null or candidate.task_id == task_id:
|
||||
return false
|
||||
match interrupt_level:
|
||||
0:
|
||||
return false
|
||||
1:
|
||||
return candidate.emergency
|
||||
2:
|
||||
return candidate.priority >= priority
|
||||
_:
|
||||
return true
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"task_id": String(task_id),
|
||||
&"task_type": String(task_type),
|
||||
&"priority": priority,
|
||||
&"created_at": created_at,
|
||||
&"deadline_at": deadline_at,
|
||||
&"target_entity_id": String(target_entity_id),
|
||||
&"target_cell": [target_cell.x, target_cell.y, target_cell.z],
|
||||
&"required_role_tags": _names_to_strings(required_role_tags),
|
||||
&"required_skill_min": _serialize_dictionary(required_skill_min),
|
||||
&"required_items": _serialize_dictionary(required_items),
|
||||
&"estimated_minutes": estimated_minutes,
|
||||
&"interrupt_level": interrupt_level,
|
||||
&"state": State.keys()[state],
|
||||
&"assigned_staff_id": String(assigned_staff_id),
|
||||
&"retry_count": retry_count,
|
||||
&"task_tags": _names_to_strings(task_tags),
|
||||
&"reservation_ids": _names_to_strings(reservation_ids),
|
||||
&"progress_minutes": progress_minutes,
|
||||
&"dangerous": dangerous,
|
||||
&"emergency": emergency,
|
||||
&"preserve_progress_on_interrupt": preserve_progress_on_interrupt,
|
||||
&"state_reason": String(state_reason),
|
||||
&"state_changed_at": state_changed_at,
|
||||
}
|
||||
|
||||
|
||||
static func from_snapshot(snapshot: Dictionary) -> StaffTask:
|
||||
if not _snapshot_shape_is_valid(snapshot):
|
||||
return null
|
||||
var state_index := State.keys().find(String(snapshot.get(&"state", "")))
|
||||
if state_index < 0:
|
||||
return null
|
||||
var task := StaffTask.new().configure(
|
||||
StringName(snapshot.get(&"task_id", "")),
|
||||
StringName(snapshot.get(&"task_type", "")),
|
||||
int(snapshot.get(&"priority", 0)),
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
{
|
||||
&"deadline_at": snapshot.get(&"deadline_at", -1),
|
||||
&"target_entity_id": snapshot.get(&"target_entity_id", ""),
|
||||
&"target_cell": snapshot.get(&"target_cell", []),
|
||||
&"required_role_tags": snapshot.get(&"required_role_tags", []),
|
||||
&"required_skill_min": snapshot.get(&"required_skill_min", {}),
|
||||
&"required_items": snapshot.get(&"required_items", {}),
|
||||
&"estimated_minutes": snapshot.get(&"estimated_minutes", 1),
|
||||
&"interrupt_level": snapshot.get(&"interrupt_level", 3),
|
||||
&"state": state_index,
|
||||
&"assigned_staff_id": snapshot.get(&"assigned_staff_id", ""),
|
||||
&"retry_count": snapshot.get(&"retry_count", 0),
|
||||
&"task_tags": snapshot.get(&"task_tags", []),
|
||||
&"reservation_ids": snapshot.get(&"reservation_ids", []),
|
||||
&"progress_minutes": snapshot.get(&"progress_minutes", 0),
|
||||
&"dangerous": snapshot.get(&"dangerous", false),
|
||||
&"emergency": snapshot.get(&"emergency", false),
|
||||
&"preserve_progress_on_interrupt": snapshot.get(&"preserve_progress_on_interrupt", true),
|
||||
&"state_reason": snapshot.get(&"state_reason", ""),
|
||||
&"state_changed_at": snapshot.get(&"state_changed_at", 0),
|
||||
}
|
||||
)
|
||||
return task if task.is_valid() else null
|
||||
|
||||
|
||||
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"task_id", "")).is_empty()
|
||||
and not String(snapshot.get(&"task_type", "")).is_empty()
|
||||
and snapshot.get(&"target_cell", null) is Array
|
||||
and snapshot.get(&"required_role_tags", null) is Array
|
||||
and snapshot.get(&"required_skill_min", null) is Dictionary
|
||||
and snapshot.get(&"required_items", null) is Dictionary
|
||||
and snapshot.get(&"task_tags", null) is Array
|
||||
and snapshot.get(&"reservation_ids", null) is Array
|
||||
and int(snapshot.get(&"priority", -1)) >= 0
|
||||
and int(snapshot.get(&"priority", -1)) <= MAX_PRIORITY
|
||||
and int(snapshot.get(&"estimated_minutes", 0)) > 0
|
||||
and int(snapshot.get(&"interrupt_level", -1)) in range(0, MAX_INTERRUPT_LEVEL + 1)
|
||||
)
|
||||
|
||||
|
||||
static func _value(source: Dictionary, key: StringName, fallback: Variant) -> Variant:
|
||||
return source.get(key, source.get(String(key), fallback))
|
||||
|
||||
|
||||
static func _state_from_value(value: Variant, fallback: State) -> State:
|
||||
if value is int:
|
||||
var numeric := int(value)
|
||||
if numeric >= 0 and numeric < State.size():
|
||||
return numeric as State
|
||||
var index := State.keys().find(String(value))
|
||||
return index as State if index >= 0 else fallback
|
||||
|
||||
|
||||
static func _vector3i(value: Variant) -> Vector3i:
|
||||
if value is Vector3i:
|
||||
return value
|
||||
if value is Array and value.size() == 3:
|
||||
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
||||
return Vector3i.ZERO
|
||||
|
||||
|
||||
static func _normalize_positive_dictionary(source: Variant, maximum := 2_147_483_647) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
if not source is Dictionary:
|
||||
return result
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_id: Variant in keys:
|
||||
var item_id := StringName(raw_id)
|
||||
var value := int(source[raw_id])
|
||||
if not item_id.is_empty() and value > 0:
|
||||
result[item_id] = mini(value, maximum)
|
||||
return result
|
||||
|
||||
|
||||
static func _serialize_dictionary(source: Dictionary) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
var keys: Array = source.keys()
|
||||
keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for raw_id: Variant in keys:
|
||||
result[String(raw_id)] = int(source[raw_id])
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_unique_names(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for value: Variant in source:
|
||||
var name := StringName(value)
|
||||
if not name.is_empty() and not result.has(name):
|
||||
result.append(name)
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
|
||||
|
||||
static func _names_to_strings(source: Array[StringName]) -> Array[String]:
|
||||
var result: Array[String] = []
|
||||
for value: StringName in source:
|
||||
result.append(String(value))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://bi15b1x11sq6v
|
||||
@@ -0,0 +1,419 @@
|
||||
class_name TaskDispatcher
|
||||
extends RefCounted
|
||||
|
||||
## Deterministic employee/task matcher. Inventory reservation is injected so
|
||||
## this layer can keep the Task/Staff pair atomic without owning stock.
|
||||
|
||||
const StaffRuntimeType = preload("res://scripts/staff/staff_runtime.gd")
|
||||
const StaffTaskType = preload("res://scripts/staff/staff_task.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const DISPATCH_INTERVAL_MINUTES := 5
|
||||
const HIGH_PRIORITY_THRESHOLD := 90
|
||||
const PRIORITY_MULTIPLIER := 10
|
||||
const SKILL_FIT_MULTIPLIER := 4
|
||||
const ROLE_FIT_MULTIPLIER := 100
|
||||
const TRAVEL_MINUTE_PENALTY := 3
|
||||
const FATIGUE_PENALTY := 2
|
||||
const OVERTIME_PENALTY := 200
|
||||
const INTERRUPT_COST_LEVEL_1 := 300
|
||||
const INTERRUPT_COST_LEVEL_2 := 150
|
||||
const INTERRUPT_COST_LEVEL_3 := 25
|
||||
const MIN_SCORE := -2_147_483_648
|
||||
|
||||
var last_dispatch_minute := -1
|
||||
var last_report: Dictionary = {}
|
||||
var last_restore_error := ""
|
||||
|
||||
var _item_reserver := Callable()
|
||||
var _item_releaser := Callable()
|
||||
var _travel_estimator := Callable()
|
||||
var _assignment_history: Array[Dictionary] = []
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
item_reserver: Callable = Callable(),
|
||||
item_releaser: Callable = Callable(),
|
||||
travel_estimator: Callable = Callable()
|
||||
) -> TaskDispatcher:
|
||||
_item_reserver = item_reserver
|
||||
_item_releaser = item_releaser
|
||||
_travel_estimator = travel_estimator
|
||||
return self
|
||||
|
||||
|
||||
func should_dispatch(absolute_minute: int, has_high_priority_task := false) -> bool:
|
||||
if has_high_priority_task:
|
||||
return true
|
||||
if last_dispatch_minute < 0:
|
||||
return true
|
||||
return absolute_minute - last_dispatch_minute >= DISPATCH_INTERVAL_MINUTES
|
||||
|
||||
|
||||
func dispatch(
|
||||
tasks: Array,
|
||||
staff_by_id: Dictionary,
|
||||
absolute_minute: int
|
||||
) -> Dictionary:
|
||||
var task_by_id := _task_index(tasks)
|
||||
var pending := _sorted_open_tasks(tasks)
|
||||
var assignments: Array[Dictionary] = []
|
||||
var blocked: Array[Dictionary] = []
|
||||
var unassigned: Array[Dictionary] = []
|
||||
var interruptions: Array[Dictionary] = []
|
||||
var assigned_this_pass: Dictionary = {}
|
||||
|
||||
for task: StaffTask in pending:
|
||||
var selected := _select_candidate(
|
||||
task,
|
||||
staff_by_id,
|
||||
task_by_id,
|
||||
assigned_this_pass,
|
||||
absolute_minute
|
||||
)
|
||||
if selected.is_empty():
|
||||
unassigned.append({
|
||||
&"task_id": task.task_id,
|
||||
&"reason": &"NO_ELIGIBLE_STAFF",
|
||||
})
|
||||
continue
|
||||
var staff_id: StringName = selected[&"staff_id"]
|
||||
var staff: StaffRuntime = staff_by_id[staff_id]
|
||||
var previous_task: StaffTask = selected.get(&"current_task") as StaffTask
|
||||
var result := _reserve_pair(task, staff, previous_task, absolute_minute)
|
||||
if not bool(result.get(&"ok", false)):
|
||||
var reason := StringName(result.get(&"reason", &"RESERVATION_FAILED"))
|
||||
if bool(result.get(&"blocked", false)):
|
||||
blocked.append({&"task_id": task.task_id, &"reason": reason})
|
||||
else:
|
||||
unassigned.append({&"task_id": task.task_id, &"reason": reason})
|
||||
continue
|
||||
if previous_task != null:
|
||||
interruptions.append({
|
||||
&"staff_id": staff_id,
|
||||
&"interrupted_task_id": previous_task.task_id,
|
||||
&"replacement_task_id": task.task_id,
|
||||
})
|
||||
var assignment := {
|
||||
&"task_id": task.task_id,
|
||||
&"staff_id": staff_id,
|
||||
&"score": int(selected[&"score"]),
|
||||
&"travel_minutes": int(selected[&"travel_minutes"]),
|
||||
&"assigned_at": absolute_minute,
|
||||
}
|
||||
assignments.append(assignment)
|
||||
_assignment_history.append(assignment.duplicate(true))
|
||||
assigned_this_pass[staff_id] = true
|
||||
|
||||
last_dispatch_minute = absolute_minute
|
||||
last_report = {
|
||||
&"assigned": assignments,
|
||||
&"blocked": blocked,
|
||||
&"unassigned": unassigned,
|
||||
&"interruptions": interruptions,
|
||||
}
|
||||
return last_report.duplicate(true)
|
||||
|
||||
|
||||
func score_candidate(
|
||||
task: StaffTask,
|
||||
staff: StaffRuntime,
|
||||
travel_minutes: int,
|
||||
current_task: StaffTask = null
|
||||
) -> int:
|
||||
if task == null or staff == null or travel_minutes < 0:
|
||||
return MIN_SCORE
|
||||
var interrupt_cost := 0
|
||||
if current_task != null:
|
||||
match current_task.interrupt_level:
|
||||
1:
|
||||
interrupt_cost = INTERRUPT_COST_LEVEL_1
|
||||
2:
|
||||
interrupt_cost = INTERRUPT_COST_LEVEL_2
|
||||
_:
|
||||
interrupt_cost = INTERRUPT_COST_LEVEL_3
|
||||
return (
|
||||
task.priority * PRIORITY_MULTIPLIER
|
||||
+ staff.skill_fit_for(task) * SKILL_FIT_MULTIPLIER
|
||||
+ staff.role_fit_for(task) * ROLE_FIT_MULTIPLIER
|
||||
- travel_minutes * TRAVEL_MINUTE_PENALTY
|
||||
- staff.fatigue * FATIGUE_PENALTY
|
||||
- interrupt_cost
|
||||
- (OVERTIME_PENALTY if staff.is_overtime else 0)
|
||||
)
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var history: Array[Dictionary] = []
|
||||
for assignment: Dictionary in _assignment_history:
|
||||
var serialized := assignment.duplicate(true)
|
||||
serialized[&"task_id"] = String(assignment[&"task_id"])
|
||||
serialized[&"staff_id"] = String(assignment[&"staff_id"])
|
||||
history.append(serialized)
|
||||
return {
|
||||
&"version": SNAPSHOT_VERSION,
|
||||
&"last_dispatch_minute": last_dispatch_minute,
|
||||
&"assignment_history": history,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
last_restore_error = ""
|
||||
if int(snapshot.get(&"version", -1)) != SNAPSHOT_VERSION:
|
||||
last_restore_error = "Unsupported task dispatcher snapshot version."
|
||||
return false
|
||||
var raw_history: Variant = snapshot.get(&"assignment_history", null)
|
||||
if not raw_history is Array:
|
||||
last_restore_error = "Task dispatcher history is malformed."
|
||||
return false
|
||||
var next_history: Array[Dictionary] = []
|
||||
for raw_assignment: Variant in raw_history:
|
||||
if not raw_assignment is Dictionary:
|
||||
last_restore_error = "Task dispatcher history entry must be a Dictionary."
|
||||
return false
|
||||
var task_id := StringName(raw_assignment.get(&"task_id", ""))
|
||||
var staff_id := StringName(raw_assignment.get(&"staff_id", ""))
|
||||
if task_id.is_empty() or staff_id.is_empty():
|
||||
last_restore_error = "Task dispatcher history has an empty identifier."
|
||||
return false
|
||||
var assignment: Dictionary = raw_assignment.duplicate(true)
|
||||
assignment[&"task_id"] = task_id
|
||||
assignment[&"staff_id"] = staff_id
|
||||
next_history.append(assignment)
|
||||
var next_last_minute := int(snapshot.get(&"last_dispatch_minute", -1))
|
||||
if next_last_minute < -1:
|
||||
last_restore_error = "Task dispatcher minute is invalid."
|
||||
return false
|
||||
_assignment_history = next_history
|
||||
last_dispatch_minute = next_last_minute
|
||||
last_report = {}
|
||||
return true
|
||||
|
||||
|
||||
func assignment_history() -> Array[Dictionary]:
|
||||
return _assignment_history.duplicate(true)
|
||||
|
||||
|
||||
func _select_candidate(
|
||||
task: StaffTask,
|
||||
staff_by_id: Dictionary,
|
||||
task_by_id: Dictionary,
|
||||
excluded_staff_ids: Dictionary,
|
||||
absolute_minute: int
|
||||
) -> Dictionary:
|
||||
var best: Dictionary = {}
|
||||
for staff_id: StringName in _sorted_names(staff_by_id.keys()):
|
||||
if excluded_staff_ids.has(staff_id):
|
||||
continue
|
||||
var staff: StaffRuntime = staff_by_id.get(staff_id) as StaffRuntime
|
||||
if staff == null or not staff.meets_task_requirements(task):
|
||||
continue
|
||||
var current_task: StaffTask = null
|
||||
if staff.current_task_id.is_empty():
|
||||
if not staff.is_available_for_dispatch():
|
||||
continue
|
||||
else:
|
||||
current_task = task_by_id.get(staff.current_task_id) as StaffTask
|
||||
if current_task == null or not current_task.can_be_interrupted_by(task):
|
||||
continue
|
||||
var travel_minutes := _travel_minutes(staff, task, absolute_minute)
|
||||
if travel_minutes < 0:
|
||||
continue
|
||||
var score := score_candidate(task, staff, travel_minutes, current_task)
|
||||
if (
|
||||
best.is_empty()
|
||||
or score > int(best[&"score"])
|
||||
or (score == int(best[&"score"]) and String(staff_id) < String(best[&"staff_id"]))
|
||||
):
|
||||
best = {
|
||||
&"staff_id": staff_id,
|
||||
&"staff": staff,
|
||||
&"current_task": current_task,
|
||||
&"travel_minutes": travel_minutes,
|
||||
&"score": score,
|
||||
}
|
||||
return best
|
||||
|
||||
|
||||
func _reserve_pair(
|
||||
task: StaffTask,
|
||||
staff: StaffRuntime,
|
||||
previous_task: StaffTask,
|
||||
absolute_minute: int
|
||||
) -> Dictionary:
|
||||
if not task.is_dispatchable() or staff == null:
|
||||
return {&"ok": false, &"reason": &"STATE_CHANGED", &"blocked": false}
|
||||
if previous_task == null and not staff.is_available_for_dispatch():
|
||||
return {&"ok": false, &"reason": &"STAFF_STATE_CHANGED", &"blocked": false}
|
||||
if previous_task != null:
|
||||
if staff.current_task_id != previous_task.task_id:
|
||||
return {&"ok": false, &"reason": &"STAFF_TASK_MISMATCH", &"blocked": false}
|
||||
if previous_task.assigned_staff_id != staff.staff_id:
|
||||
return {&"ok": false, &"reason": &"TASK_STAFF_MISMATCH", &"blocked": false}
|
||||
if not previous_task.can_be_interrupted_by(task):
|
||||
return {&"ok": false, &"reason": &"INTERRUPT_DENIED", &"blocked": false}
|
||||
|
||||
var item_result := _reserve_items(task, absolute_minute)
|
||||
if not bool(item_result.get(&"ok", false)):
|
||||
var item_reason := StringName(item_result.get(&"reason", &"ITEM_RESERVATION_FAILED"))
|
||||
task.block(item_reason, absolute_minute)
|
||||
return {&"ok": false, &"reason": item_reason, &"blocked": true}
|
||||
var reservation_ids: Array[StringName] = _name_array(item_result.get(&"reservation_ids", []))
|
||||
|
||||
var staff_before := {
|
||||
&"current_task_id": staff.current_task_id,
|
||||
&"state": staff.state,
|
||||
&"state_reason": staff.state_reason,
|
||||
&"state_changed_at": staff.state_changed_at,
|
||||
}
|
||||
var task_before := {
|
||||
&"state": task.state,
|
||||
&"assigned_staff_id": task.assigned_staff_id,
|
||||
&"reservation_ids": task.reservation_ids.duplicate(),
|
||||
&"state_reason": task.state_reason,
|
||||
&"state_changed_at": task.state_changed_at,
|
||||
}
|
||||
var previous_before: Dictionary = {}
|
||||
if previous_task != null:
|
||||
previous_before = {
|
||||
&"state": previous_task.state,
|
||||
&"assigned_staff_id": previous_task.assigned_staff_id,
|
||||
&"reservation_ids": previous_task.reservation_ids.duplicate(),
|
||||
&"progress_minutes": previous_task.progress_minutes,
|
||||
&"state_reason": previous_task.state_reason,
|
||||
&"state_changed_at": previous_task.state_changed_at,
|
||||
}
|
||||
previous_task.reopen(
|
||||
&"INTERRUPTED",
|
||||
absolute_minute,
|
||||
previous_task.preserve_progress_on_interrupt
|
||||
)
|
||||
staff.release_task(absolute_minute, &"TASK_INTERRUPTED")
|
||||
|
||||
var task_reserved := task.reserve(staff.staff_id, reservation_ids, absolute_minute)
|
||||
var staff_reserved := task_reserved and staff.reserve_task(task.task_id, absolute_minute)
|
||||
if not task_reserved or not staff_reserved:
|
||||
_restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before)
|
||||
_release_items(reservation_ids, task, absolute_minute)
|
||||
return {&"ok": false, &"reason": &"ATOMIC_PAIR_RESERVATION_FAILED", &"blocked": false}
|
||||
|
||||
if previous_task != null:
|
||||
var old_ids: Array[StringName] = _name_array(previous_before[&"reservation_ids"])
|
||||
if not _release_items(old_ids, previous_task, absolute_minute):
|
||||
_restore_pair_state(task, staff, previous_task, task_before, staff_before, previous_before)
|
||||
_release_items(reservation_ids, task, absolute_minute)
|
||||
return {&"ok": false, &"reason": &"INTERRUPT_RELEASE_FAILED", &"blocked": false}
|
||||
return {&"ok": true, &"reservation_ids": reservation_ids}
|
||||
|
||||
|
||||
func _reserve_items(task: StaffTask, absolute_minute: int) -> Dictionary:
|
||||
if task.required_items.is_empty():
|
||||
return {&"ok": true, &"reservation_ids": []}
|
||||
if not _item_reserver.is_valid():
|
||||
return {&"ok": false, &"reason": &"ITEM_RESERVATION_UNAVAILABLE"}
|
||||
var response: Variant = _item_reserver.call(task, absolute_minute)
|
||||
if not response is Dictionary:
|
||||
return {&"ok": false, &"reason": &"ITEM_RESERVATION_INVALID_RESPONSE"}
|
||||
var result: Dictionary = response
|
||||
if not bool(result.get(&"ok", false)):
|
||||
return {
|
||||
&"ok": false,
|
||||
&"reason": StringName(result.get(&"reason", result.get(&"code", &"ITEM_RESERVATION_FAILED"))),
|
||||
}
|
||||
var reservation_ids := _name_array(result.get(&"reservation_ids", []))
|
||||
if reservation_ids.is_empty():
|
||||
return {&"ok": false, &"reason": &"ITEM_RESERVATION_IDS_MISSING"}
|
||||
return {&"ok": true, &"reservation_ids": reservation_ids}
|
||||
|
||||
|
||||
func _release_items(
|
||||
reservation_ids: Array[StringName],
|
||||
task: StaffTask,
|
||||
absolute_minute: int
|
||||
) -> bool:
|
||||
if reservation_ids.is_empty():
|
||||
return true
|
||||
if not _item_releaser.is_valid():
|
||||
return false
|
||||
var response: Variant = _item_releaser.call(reservation_ids, task, absolute_minute)
|
||||
if response is Dictionary:
|
||||
return bool(response.get(&"ok", false))
|
||||
return bool(response)
|
||||
|
||||
|
||||
func _travel_minutes(staff: StaffRuntime, task: StaffTask, absolute_minute: int) -> int:
|
||||
if _travel_estimator.is_valid():
|
||||
return int(_travel_estimator.call(staff, task, absolute_minute))
|
||||
var delta := staff.current_cell - task.target_cell
|
||||
return absi(delta.x) + absi(delta.y) + absi(delta.z)
|
||||
|
||||
|
||||
static func _restore_pair_state(
|
||||
task: StaffTask,
|
||||
staff: StaffRuntime,
|
||||
previous_task: StaffTask,
|
||||
task_before: Dictionary,
|
||||
staff_before: Dictionary,
|
||||
previous_before: Dictionary
|
||||
) -> void:
|
||||
task.state = int(task_before[&"state"]) as StaffTask.State
|
||||
task.assigned_staff_id = task_before[&"assigned_staff_id"]
|
||||
task.reservation_ids.assign(task_before[&"reservation_ids"])
|
||||
task.state_reason = task_before[&"state_reason"]
|
||||
task.state_changed_at = int(task_before[&"state_changed_at"])
|
||||
staff.current_task_id = staff_before[&"current_task_id"]
|
||||
staff.state = int(staff_before[&"state"]) as StaffRuntime.State
|
||||
staff.state_reason = staff_before[&"state_reason"]
|
||||
staff.state_changed_at = int(staff_before[&"state_changed_at"])
|
||||
if previous_task != null:
|
||||
previous_task.state = int(previous_before[&"state"]) as StaffTask.State
|
||||
previous_task.assigned_staff_id = previous_before[&"assigned_staff_id"]
|
||||
previous_task.reservation_ids.assign(previous_before[&"reservation_ids"])
|
||||
previous_task.progress_minutes = int(previous_before[&"progress_minutes"])
|
||||
previous_task.state_reason = previous_before[&"state_reason"]
|
||||
previous_task.state_changed_at = int(previous_before[&"state_changed_at"])
|
||||
|
||||
|
||||
static func _task_index(tasks: Array) -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for value: Variant in tasks:
|
||||
if value is StaffTask:
|
||||
result[value.task_id] = value
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_open_tasks(tasks: Array) -> Array[StaffTask]:
|
||||
var result: Array[StaffTask] = []
|
||||
for value: Variant in tasks:
|
||||
if value is StaffTask and value.is_dispatchable():
|
||||
result.append(value)
|
||||
result.sort_custom(func(a: StaffTask, b: StaffTask) -> bool:
|
||||
if a.priority != b.priority:
|
||||
return a.priority > b.priority
|
||||
var a_deadline := 9_223_372_036_854_775_807 if a.deadline_at < 0 else a.deadline_at
|
||||
var b_deadline := 9_223_372_036_854_775_807 if b.deadline_at < 0 else b.deadline_at
|
||||
if a_deadline != b_deadline:
|
||||
return a_deadline < b_deadline
|
||||
return String(a.task_id) < String(b.task_id)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_names(values: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for value: Variant in values:
|
||||
result.append(StringName(value))
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
|
||||
|
||||
static func _name_array(source: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for value: Variant in source:
|
||||
var name := StringName(value)
|
||||
if not name.is_empty() and not result.has(name):
|
||||
result.append(name)
|
||||
result.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://vjdti463kwwt
|
||||
Reference in New Issue
Block a user