Enable first operating day with staff assignments, opening guide, and ledger

This commit is contained in:
Codex
2026-09-09 08:26:20 +09:00
parent 423fd6dd2e
commit 29a660aed0
28 changed files with 2451 additions and 156 deletions
+143
View File
@@ -256,6 +256,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S
register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate"))
register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift"))
register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override"))
register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment"))
register_command_executor(&"create_staff_task", Callable(self, "_execute_create_staff_task"))
register_command_executor(&"begin_staff_task", Callable(self, "_execute_begin_staff_task"))
register_command_executor(&"progress_staff_task", Callable(self, "_execute_progress_staff_task"))
@@ -5936,6 +5937,85 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool:
_runtime_event_minute()
)
func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandResult:
for key: StringName in [&"staff_id", &"facility_id", &"template_id"]:
if typeof(command.payload.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]:
return _staff_assignment_failure(command.command_id, &"INVALID_PAYLOAD", "Staff, facility, and shift IDs must be strings.")
var staff_id := StringName(command.payload[&"staff_id"])
var facility_id := StringName(command.payload[&"facility_id"])
var template_id := StringName(command.payload[&"template_id"])
var staff := staff_service.get_staff(staff_id)
if staff == null:
return _staff_assignment_failure(command.command_id, &"STAFF_NOT_FOUND", "Staff was not found.")
if template_id not in [&"DAY", &"NIGHT"]:
return _staff_assignment_failure(command.command_id, &"INVALID_TEMPLATE", "Choose DAY or NIGHT.")
if not _defense_lock_owner(staff_id, &"DEFENDER").is_empty():
return _staff_assignment_failure(command.command_id, &"DEFENSE_LOCKED", "Staff is participating in a defense operation.")
if event_service.get_actor_locks().has(staff_id):
return _staff_assignment_failure(command.command_id, &"EVENT_LOCKED", "Staff is participating in an event.")
if not staff.current_task_id.is_empty():
return _staff_assignment_failure(command.command_id, &"STAFF_BUSY", "Finish the current task before changing assignment.")
if not staff.can_take_shift() or staff.state not in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE]:
return _staff_assignment_failure(command.command_id, &"STAFF_UNAVAILABLE", "Staff cannot change assignment in the current state.")
if not facility_id.is_empty():
var facility := facility_service.get_instance(facility_id)
if facility == null:
return _staff_assignment_failure(command.command_id, &"FACILITY_NOT_FOUND", "Facility was not found.")
var definition := definition_registry.get_facility(facility.definition_id)
if definition == null or definition.staff_max <= 0:
return _staff_assignment_failure(command.command_id, &"FACILITY_UNSTAFFED", "This facility does not accept Staff assignments.")
if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles:
return _staff_assignment_failure(command.command_id, &"ROLE_MISMATCH", "Staff role does not match this facility.")
if _staff_assignment_count(facility_id, template_id, staff_id) >= definition.staff_max:
return _staff_assignment_failure(command.command_id, &"FACILITY_FULL", "This shift has reached the facility Staff limit.")
if not _staff_can_reach_facility(staff, facility):
return _staff_assignment_failure(command.command_id, &"NO_ROUTE", "Staff cannot reach this facility through an accessible route.")
var result := staff_service.set_work_assignment(staff_id, facility_id, template_id, _runtime_event_minute())
result.command_id = command.command_id
if result.is_success():
_staffing_projection_enabled = true
_sync_facility_staffing(_runtime_event_minute())
if result.details.get(&"reason", &"") == &"ASSIGNMENT_UPDATED":
_publish_actor_event(&"STAFF_WORK_ASSIGNMENT_CHANGED", &"staff_service", [staff_id], result.details)
return result
func _staff_assignment_failure(command_id: int, reason: StringName, message: String) -> CommandResult:
return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command_id, message, {&"reason": reason})
func _staff_assignment_count(facility_id: StringName, template_id: StringName, excluded_staff: StringName) -> int:
var counted: Dictionary = {}
for row: Dictionary in staff_service.get_work_assignments():
var staff_id := StringName(row[&"staff_id"])
if staff_id != excluded_staff and StringName(row[&"facility_id"]) == facility_id \
and StringName(row[&"template_id"]) == template_id:
counted[staff_id] = true
var interval := StaffService._work_interval(template_id, _runtime_event_minute())
for shift: ShiftInstance in staff_service.get_shifts():
if shift.staff_id != excluded_staff and not shift.cancelled \
and shift.assigned_facility_id == facility_id \
and shift.end_at > maxi(interval.x, _runtime_event_minute()) and shift.start_at < interval.y:
counted[shift.staff_id] = true
return counted.size()
func _staff_can_reach_facility(staff: StaffRuntime, facility: FacilityInstance) -> bool:
var occupied: Array[Vector3i] = facility.occupied_cells.duplicate()
if occupied.is_empty():
occupied.append(facility.origin)
var candidates: Dictionary = {}
for cell: Vector3i in occupied:
for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]:
var candidate := cell + offset
if path_service.has_cell(candidate):
candidates[candidate] = true
for candidate: Vector3i in candidates:
if not path_service.find_path(staff.current_cell, candidate, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]).is_empty():
return true
return false
func _execute_set_staff_schedule_override(command: SimulationCommand) -> CommandResult:
var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", "")))
var start_day := int(command.payload.get(&"start_day", command.payload.get("start_day", -1)))
@@ -6764,6 +6844,9 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult:
"A Facility assigned to a non-terminal Defense Raid cannot be demolished.",
{&"raid_id": defense_owner}
)
for assignment: Dictionary in staff_service.get_work_assignments():
if StringName(assignment[&"facility_id"]) == instance_id:
return _staff_assignment_failure(command.command_id, &"STAFF_ASSIGNED", "Unassign scheduled Staff before demolishing this facility.")
var operational_instance := facility_service.get_instance(instance_id)
if operational_instance != null \
and (not operational_instance.queue_entries.is_empty() or not operational_instance.active_services.is_empty()):
@@ -9187,6 +9270,18 @@ func _validate_runtime_crosslinks(
or inventory.current_minute != absolute_minute \
or staff.current_minute != absolute_minute:
return false
for assignment: Dictionary in staff.get_work_assignments():
var facility_id := StringName(assignment[&"facility_id"])
if facility_id.is_empty():
continue
var assigned_facility := facilities.get_instance(facility_id)
if assigned_facility == null:
return false
var assigned_staff := staff.get_staff(StringName(assignment[&"staff_id"]))
var definition := definition_registry.get_facility(assigned_facility.definition_id)
if assigned_staff == null or definition == null or definition.staff_max <= 0 \
or (not definition.required_roles.is_empty() and assigned_staff.role_id not in definition.required_roles):
return false
var queued_links: Dictionary = {}
var service_links: Dictionary = {}
for facility: FacilityInstance in facilities.get_instances():
@@ -9607,6 +9702,54 @@ func get_mvp_staff_candidate_summaries() -> Array[Dictionary]:
return summaries
## Defensive presentation snapshot for the Staff management panel. Planned
## assignment remains visible at night/off-duty even when the active link clears.
func get_staff_management_report() -> Dictionary:
var rows: Array[Dictionary] = []
for staff: StaffRuntime in staff_service.get_staff_members():
var entry := mvp_content_catalog.get_entry(&"staff_candidates", staff.staff_id)
var plan := staff_service.get_work_assignment(staff.staff_id)
var shift := staff_service.get_shift(staff.shift_id)
var template_id: StringName = &"DAY"
var effective_from := _runtime_event_minute()
var next_start_at := -1
var assigned_facility_id := staff.assigned_facility_id
if not plan.is_empty():
template_id = StringName(plan[&"template_id"])
assigned_facility_id = StringName(plan[&"facility_id"])
effective_from = int(plan[&"effective_from"])
next_start_at = int(plan[&"next_start_at"])
elif shift != null:
template_id = shift.template_id
effective_from = shift.start_at
else:
template_id = &"NIGHT" if StringName(entry.get(&"preferred_shift", &"day")) == &"night" else &"DAY"
rows.append({
&"staff_id": staff.staff_id, &"display_name": String(entry.get(&"display_name", staff.staff_id)),
&"role_id": staff.role_id, &"state": staff.state, &"state_name": StaffRuntime.State.keys()[staff.state],
&"assigned_facility_id": assigned_facility_id, &"active_facility_id": staff.assigned_facility_id,
&"template_id": template_id, &"recurring": not plan.is_empty() and not assigned_facility_id.is_empty(),
&"effective_from": effective_from, &"next_start_at": next_start_at,
&"daily_wage": staff.daily_wage, &"fatigue": staff.fatigue, &"unpaid_wages": staff.unpaid_wages,
&"current_task_id": staff.current_task_id,
&"can_edit": staff.can_take_shift() and staff.current_task_id.is_empty()
and staff.state in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE]
and _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty()
and not event_service.get_actor_locks().has(staff.staff_id),
})
var facilities: Array[Dictionary] = []
for facility: FacilityInstance in facility_service.get_instances():
var definition := definition_registry.get_facility(facility.definition_id)
if definition == null or definition.staff_max <= 0:
continue
facilities.append({
&"facility_id": facility.instance_id, &"display_name": definition.display_name,
&"definition_id": facility.definition_id, &"layer": facility.origin.z, &"origin": facility.origin,
&"required_roles": definition.required_roles.duplicate(), &"staff_min": definition.staff_min, &"staff_max": definition.staff_max,
})
return {&"staff": rows, &"facilities": facilities}
func get_hired_staff_candidate_ids() -> Array[StringName]:
return _sorted_name_keys(_hired_staff_candidate_ids)