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_effective_daily_wage() -> int: # Preserve historical stored wages for exact save round trips; UNIQUE # characters accrue no new upkeep under original section 6.1. return 0 if employment_kind == EmploymentKind.UNIQUE else daily_wage 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)