Record original-alignment implementation and traceability checkpoint
Capture current source, content data, tests, asset licenses, and original-specification traceability. Original implementation remains incomplete. The accepted matching UI generation passed 154 suites and 12,253 checks, with eight rendered staff-contract screens reviewed. Latest native Windows and full-original acceptance remain outstanding.
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
extends RefCounted
|
||||
|
||||
## Read-only geometry preflight. A successful preview is NOT a migration commit:
|
||||
## Runtime ownership, routes, jobs, actor positions and backups need their own gate.
|
||||
const DIMENSIONS := {
|
||||
&"premium_room": {&"old": Vector2i(3, 2), &"new": Vector2i(4, 1), &"layer": 0},
|
||||
&"staff_dormitory": {&"old": Vector2i(4, 2), &"new": Vector2i(2, 1), &"layer": 1},
|
||||
&"black_market": {&"old": Vector2i(3, 2), &"new": Vector2i(4, 2), &"layer": 0},
|
||||
}
|
||||
|
||||
static func inspect(world: WorldService, instance: Dictionary, weight_per_cell: int, entity_weights: Dictionary = {}) -> Dictionary:
|
||||
var result := {&"geometry_ok": false, &"ready_to_apply": false, &"errors": [],
|
||||
&"added": [], &"removed": [], &"target_cells": [], &"affected_entities": [],
|
||||
&"remaining_gates": [&"DEFINITION_WEIGHT_VALIDATION", &"RUNTIME_CROSSLINKS", &"ROUTES_AND_ACTORS", &"CONSTRUCTION_JOBS", &"BACKUP_AND_ATOMIC_COMMIT"]}
|
||||
if typeof(instance.get(&"instance_id")) not in [TYPE_STRING, TYPE_STRING_NAME] or typeof(instance.get(&"definition_id")) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
result[&"errors"].append(&"INVALID_ID")
|
||||
return result
|
||||
var id := StringName(instance.get(&"instance_id", &""))
|
||||
var definition_id := StringName(instance.get(&"definition_id", &""))
|
||||
if world == null or id.is_empty() or not DIMENSIONS.has(definition_id) or weight_per_cell <= 0:
|
||||
result[&"errors"].append(&"INVALID_INPUT")
|
||||
return result
|
||||
var origin: Variant = instance.get(&"origin")
|
||||
var rotation: Variant = instance.get(&"rotation")
|
||||
var cells: Variant = instance.get(&"occupied_cells")
|
||||
if not origin is Vector3i or typeof(rotation) != TYPE_INT or rotation not in [0, 90, 180, 270] or not cells is Array:
|
||||
result[&"errors"].append(&"INVALID_GEOMETRY")
|
||||
return result
|
||||
var specification: Dictionary = DIMENSIONS[definition_id]
|
||||
if origin.z != specification[&"layer"]:
|
||||
result[&"errors"].append(&"LAYER_MISMATCH")
|
||||
return result
|
||||
var old_size: Vector2i = specification[&"old"]
|
||||
var new_size: Vector2i = specification[&"new"]
|
||||
if rotation in [90, 270]:
|
||||
old_size = Vector2i(old_size.y, old_size.x)
|
||||
new_size = Vector2i(new_size.y, new_size.x)
|
||||
var expected_old := world.cells_for_footprint(origin, old_size)
|
||||
var target := world.cells_for_footprint(origin, new_size)
|
||||
if not _same_cells(cells, world.get_entity_cells(id)) or (not _same_cells(cells, expected_old) and not _same_cells(cells, target)):
|
||||
result[&"errors"].append(&"SOURCE_OWNERSHIP_OR_SHAPE_MISMATCH")
|
||||
return result
|
||||
result[&"target_cells"] = target.duplicate()
|
||||
for cell: Vector3i in target:
|
||||
if cell not in cells: result[&"added"].append(cell)
|
||||
if not world.is_in_bounds(cell): result[&"errors"].append({&"code": &"OUT_OF_BOUNDS", &"cell": cell})
|
||||
elif cell.y < 0 and not world.is_excavated(cell): result[&"errors"].append({&"code": &"NOT_EXCAVATED", &"cell": cell})
|
||||
if definition_id == &"black_market" and cell.y >= 0: result[&"errors"].append({&"code": &"ZONE_MISMATCH", &"cell": cell})
|
||||
var other := world.get_occupant(cell)
|
||||
if not other.is_empty() and other != id:
|
||||
result[&"errors"].append({&"code": &"OVERLAP", &"cell": cell, &"entity_id": other})
|
||||
for cell: Vector3i in cells:
|
||||
if cell not in target: result[&"removed"].append(cell)
|
||||
if not result[&"errors"].is_empty(): return result
|
||||
var candidate := WorldService.new()
|
||||
if not candidate.restore_snapshot(world.create_snapshot()) or not candidate.release(id) or not candidate.occupy(id, target, world.get_entity_kind(id)):
|
||||
result[&"errors"].append(&"CANDIDATE_REJECTED")
|
||||
return result
|
||||
if not candidate.validate_support(target, weight_per_cell, entity_weights)[&"ok"]:
|
||||
result[&"errors"].append(&"TARGET_UNSUPPORTED")
|
||||
# Recheck every previously supported occupied component; removal may split
|
||||
# a component even when the resized facility itself remains anchored.
|
||||
for row: Dictionary in world.create_snapshot()[&"occupancies"]:
|
||||
if row[&"entity_id"] == id: continue
|
||||
if world.validate_support(row[&"cells"], int(entity_weights.get(row[&"entity_id"], 1)), entity_weights)[&"ok"] and not candidate.validate_support(row[&"cells"], int(entity_weights.get(row[&"entity_id"], 1)), entity_weights)[&"ok"]:
|
||||
result[&"affected_entities"].append(row[&"entity_id"])
|
||||
result[&"errors"].append({&"code": &"SUPPORT_LOSS", &"entity_id": row[&"entity_id"]})
|
||||
result[&"geometry_ok"] = result[&"errors"].is_empty()
|
||||
return result
|
||||
|
||||
static func _same_cells(a: Array, b: Array) -> bool:
|
||||
if a.size() != b.size(): return false
|
||||
var seen := {}
|
||||
for value: Variant in a:
|
||||
if not value is Vector3i or seen.has(value) or value not in b: return false
|
||||
seen[value] = true
|
||||
return true
|
||||
|
||||
## Build a validated construction/world candidate without touching live state.
|
||||
## Other Runtime services and disk backup still need validation before commit.
|
||||
static func prepare_construction_candidate(world: WorldService, construction: ConstructionService, owner_id: StringName, weight: int, weights: Dictionary = {}) -> Dictionary:
|
||||
var source := construction.create_snapshot()
|
||||
var result := {&"ok": false, &"ready_to_apply": false, &"errors": []}
|
||||
var validator := ConstructionService.new(world)
|
||||
if source.is_empty() or not validator.restore_snapshot(source):
|
||||
result[&"errors"].append(&"INVALID_SOURCE_CONSTRUCTION")
|
||||
return result
|
||||
var instance := construction.get_facility(owner_id)
|
||||
var source_job_id: StringName = instance.get(&"source_job_id", &"")
|
||||
if instance.is_empty():
|
||||
for job: ConstructionJob in construction.get_jobs():
|
||||
if job.job_id != owner_id or job.is_terminal(): continue
|
||||
source_job_id = job.job_id
|
||||
instance = {&"instance_id": job.job_id, &"definition_id": job.definition_id,
|
||||
&"origin": job.origin, &"rotation": job.rotation, &"occupied_cells": job.reserved_cells.duplicate()}
|
||||
break
|
||||
if instance.is_empty():
|
||||
result[&"errors"].append(&"MIGRATABLE_OWNER_REQUIRED")
|
||||
return result
|
||||
var preview := inspect(world, instance, weight, weights)
|
||||
result[&"preview"] = preview
|
||||
if not preview[&"geometry_ok"]:
|
||||
result[&"errors"] = preview[&"errors"].duplicate(true)
|
||||
return result
|
||||
var target: Array = preview[&"target_cells"]
|
||||
var candidate_world := WorldService.new()
|
||||
if not candidate_world.restore_snapshot(world.create_snapshot()):
|
||||
result[&"errors"].append(&"CANDIDATE_WORLD_REJECTED")
|
||||
return result
|
||||
if not _same_cells(world.get_entity_cells(owner_id), target) and (not candidate_world.release(owner_id) or not candidate_world.occupy(owner_id, target, world.get_entity_kind(owner_id))):
|
||||
result[&"errors"].append(&"CANDIDATE_WORLD_REJECTED")
|
||||
return result
|
||||
var encoded: Array = []
|
||||
for cell: Vector3i in target: encoded.append([cell.x, cell.y, cell.z])
|
||||
var candidate := source.duplicate(true)
|
||||
for saved: Dictionary in candidate[&"jobs"]:
|
||||
if StringName(saved[&"job_id"]) == source_job_id:
|
||||
saved[&"reserved_cells"] = encoded.duplicate(true)
|
||||
for saved: Dictionary in candidate[&"facilities"]:
|
||||
if StringName(saved[&"instance_id"]) == owner_id:
|
||||
saved[&"occupied_cells"] = encoded.duplicate(true)
|
||||
var restored := ConstructionService.new(candidate_world)
|
||||
if not restored.restore_snapshot(candidate):
|
||||
result[&"errors"].append(&"CANDIDATE_CONSTRUCTION_REJECTED")
|
||||
return result
|
||||
result[&"ok"] = true
|
||||
result[&"world"] = candidate_world.create_snapshot()
|
||||
result[&"construction"] = restored.create_snapshot()
|
||||
return result
|
||||
@@ -0,0 +1 @@
|
||||
uid://5pyjfutw6xl2
|
||||
@@ -0,0 +1,103 @@
|
||||
extends RefCounted
|
||||
|
||||
## Compare actual guest/staff access queries on two isolated Runtime states.
|
||||
## Reachability alone is not a relocation or in-flight transport migration.
|
||||
static func inspect(before: Variant, after: Variant, facility_id: StringName) -> Dictionary:
|
||||
var result := {&"ok": false, &"errors": [], &"guest_replans": [], &"checked_routes": 0}
|
||||
var old_room: FacilityInstance = before.facility_service.get_instance(facility_id)
|
||||
var new_room: FacilityInstance = after.facility_service.get_instance(facility_id)
|
||||
if old_room == null or new_room == null:
|
||||
result[&"errors"].append(&"FACILITY_REQUIRED")
|
||||
return result
|
||||
_check(result, before.get_facility_access_report(facility_id), after.get_facility_access_report(facility_id), &"ENTRANCE_ACCESS_LOST", &"")
|
||||
for guest: GuestRuntime in before.guest_service.get_guests():
|
||||
if guest.target_facility_id != facility_id: continue
|
||||
var restored: GuestRuntime = after.guest_service.get_guest(guest.guest_id)
|
||||
if restored == null:
|
||||
result[&"errors"].append({&"code": &"GUEST_MISSING", &"id": guest.guest_id})
|
||||
continue
|
||||
var old_route: Dictionary = before._find_facility_access_path(guest.current_cell, old_room)
|
||||
var new_route: Dictionary = after._find_facility_access_path(restored.current_cell, new_room)
|
||||
_check(result, old_route, new_route, &"GUEST_ACCESS_LOST", guest.guest_id)
|
||||
if restored.state == GuestRuntime.State.MOVING and new_route[&"reachable"]:
|
||||
var target: Vector3i = after._path_handle_target(restored.path_handle, restored.current_cell)
|
||||
# A new best destination must be applied explicitly to the saved route.
|
||||
if target != new_route[&"target_cell"]:
|
||||
result[&"guest_replans"].append({&"id": guest.guest_id, &"old_target": target, &"target": new_route[&"target_cell"]})
|
||||
for worker: StaffRuntime in before.staff_service.get_staff_members():
|
||||
var assignment: Dictionary = before.staff_service.get_work_assignment(worker.staff_id)
|
||||
var rest: Dictionary = before.get_staff_rest_report(worker.staff_id)
|
||||
var resting_here := StringName(rest.get(&"facility_id", &"")) == facility_id
|
||||
if worker.assigned_facility_id != facility_id and StringName(assignment.get(&"facility_id", &"")) != facility_id and not resting_here: continue
|
||||
var restored: StaffRuntime = after.staff_service.get_staff(worker.staff_id)
|
||||
if restored == null:
|
||||
result[&"errors"].append({&"code": &"STAFF_MISSING", &"id": worker.staff_id})
|
||||
continue
|
||||
_check(result, before.staff_commute.find_facility_access_path(worker.current_cell, old_room), after.staff_commute.find_facility_access_path(restored.current_cell, new_room), &"STAFF_ACCESS_LOST", worker.staff_id)
|
||||
var access_cells := {}
|
||||
for cell: Vector3i in new_room.occupied_cells:
|
||||
for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: access_cells[cell + offset] = true
|
||||
for task: StaffTask in before.staff_service.get_tasks():
|
||||
if task.is_terminal() or (task.target_entity_id != facility_id and task.rescue_facility_id != facility_id): continue
|
||||
var restored: StaffTask = after.staff_service.get_task(task.task_id)
|
||||
if restored == null or restored.create_snapshot() != task.create_snapshot():
|
||||
result[&"errors"].append({&"code": &"TASK_STATE_CHANGED", &"id": task.task_id})
|
||||
continue
|
||||
if not access_cells.has(task.target_cell):
|
||||
result[&"errors"].append({&"code": &"TASK_DESTINATION_CHANGED", &"id": task.task_id})
|
||||
continue
|
||||
if task.assigned_staff_id.is_empty(): continue
|
||||
var old_worker: StaffRuntime = before.staff_service.get_staff(task.assigned_staff_id)
|
||||
var new_worker: StaffRuntime = after.staff_service.get_staff(task.assigned_staff_id)
|
||||
if old_worker == null or new_worker == null:
|
||||
result[&"errors"].append({&"code": &"STAFF_MISSING", &"id": task.assigned_staff_id})
|
||||
continue
|
||||
_check(result, {&"reachable":_task_reachable(before, old_worker, task)}, {&"reachable":_task_reachable(after, new_worker, restored)}, &"TASK_ACCESS_LOST", task.task_id)
|
||||
result[&"ok"] = result[&"errors"].is_empty() and result[&"guest_replans"].is_empty()
|
||||
return result
|
||||
|
||||
static func _check(result: Dictionary, old_route: Dictionary, new_route: Dictionary, code: StringName, actor_id: StringName) -> void:
|
||||
result[&"checked_routes"] += 1
|
||||
if old_route.get(&"reachable", false) and not new_route.get(&"reachable", false):
|
||||
result[&"errors"].append({&"code": code, &"id": actor_id})
|
||||
|
||||
## Apply only to an isolated candidate. Validate the entire batch before changes.
|
||||
## Position, movement credit, service request, wallet and transport stay intact.
|
||||
static func retarget_guests(candidate: Variant, report: Dictionary) -> Dictionary:
|
||||
var result := {&"ok": false, &"errors": [], &"changed": []}
|
||||
if not report.get(&"errors", []).is_empty():
|
||||
result[&"errors"].append(&"ACCESS_LOSS")
|
||||
return result
|
||||
var updates: Array = []
|
||||
var seen := {}
|
||||
for plan: Dictionary in report.get(&"guest_replans", []):
|
||||
var id := StringName(plan.get(&"id", &""))
|
||||
var guest: GuestRuntime = candidate.guest_service.get_guest(id)
|
||||
if guest == null or seen.has(id) or guest.state != GuestRuntime.State.MOVING or not guest.path_handle is Dictionary or not plan.get(&"target") is Vector3i:
|
||||
result[&"errors"].append(&"INVALID_REPLAN")
|
||||
return result
|
||||
seen[id] = true
|
||||
if candidate._path_handle_target(guest.path_handle, guest.current_cell) != plan.get(&"old_target"):
|
||||
result[&"errors"].append(&"STALE_REPLAN")
|
||||
return result
|
||||
if not candidate.elevators.get_actor_trip(candidate._guest_path_actor_id(id)).is_empty():
|
||||
result[&"errors"].append(&"TRANSPORT_REPLAN_REQUIRED")
|
||||
return result
|
||||
if candidate.path_service.find_path(guest.current_cell, plan[&"target"]).is_empty():
|
||||
result[&"errors"].append(&"TARGET_UNREACHABLE")
|
||||
return result
|
||||
updates.append({&"guest": guest, &"target": plan[&"target"]})
|
||||
for update: Dictionary in updates:
|
||||
var guest: GuestRuntime = update[&"guest"]
|
||||
var handle: Dictionary = guest.path_handle.duplicate(true)
|
||||
handle[&"target_cell"] = update[&"target"]
|
||||
handle[&"graph_revision"] = candidate.path_service.graph_revision
|
||||
guest.path_handle = handle
|
||||
result[&"changed"].append(guest.guest_id)
|
||||
result[&"ok"] = true
|
||||
return result
|
||||
|
||||
static func _task_reachable(runtime: Variant, worker: StaffRuntime, task: StaffTask) -> bool:
|
||||
if task.task_type in [&"rescue_pickup", &"rescue_transport"]:
|
||||
return not runtime.path_service.find_backyard_path(worker.current_cell, task.target_cell, true).is_empty()
|
||||
return not runtime.path_service.find_path(worker.current_cell, task.target_cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]).is_empty()
|
||||
@@ -0,0 +1 @@
|
||||
uid://bu8b3e66tt4lc
|
||||
@@ -197,7 +197,7 @@ func create_snapshot() -> Dictionary:
|
||||
processed_data.append(result.to_dictionary())
|
||||
var idempotency_data := {}
|
||||
var idempotency_keys: Array = _idempotency_to_command_id.keys()
|
||||
idempotency_keys.sort()
|
||||
idempotency_keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
for key: Variant in idempotency_keys:
|
||||
idempotency_data[String(key)] = int(_idempotency_to_command_id[key])
|
||||
return {
|
||||
|
||||
@@ -371,9 +371,15 @@ func _validate_facility(definition: FacilityDefinition, requested_path: String)
|
||||
if definition.staff_min < 0 or definition.staff_max < definition.staff_min:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Staff limits are inconsistent.")
|
||||
valid = false
|
||||
if definition.customer_capacity < -1 or definition.service_attribute not in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"]:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"customer_capacity", "Customer capacity or service attribute is invalid.")
|
||||
valid = false
|
||||
if definition.capacity < 0 or definition.queue_capacity < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Service and queue capacities cannot be negative.")
|
||||
valid = false
|
||||
if definition.faith_per_service < 0:
|
||||
_add_error(&"invalid_service_reward", facility_id, requested_path, &"faith_per_service", "Service Faith must be non-negative.")
|
||||
valid = false
|
||||
if definition.base_service_minutes <= 0 or definition.base_price < 0:
|
||||
_add_error(&"value_out_of_range", facility_id, requested_path, &"base_service_minutes", "Service duration must be positive and price cannot be negative.")
|
||||
valid = false
|
||||
|
||||
@@ -8,6 +8,8 @@ const MAX_BATCH_SIZE := 4_096
|
||||
|
||||
var _subscribers: Dictionary = {}
|
||||
var _published_ids: Dictionary = {}
|
||||
var _snapshot_ids: Array[String] = []
|
||||
var _snapshot_ids_dirty := true
|
||||
var _recent_events: Array[SimulationDomainEvent] = []
|
||||
var _pending_deliveries: Array[SimulationDomainEvent] = []
|
||||
var _delivery_active := false
|
||||
@@ -49,6 +51,8 @@ func publish_batch(events: Array) -> bool:
|
||||
return false
|
||||
batch_ids[event.event_id] = true
|
||||
committed.append(event.copy())
|
||||
if not committed.is_empty():
|
||||
_snapshot_ids_dirty = true
|
||||
for event: SimulationDomainEvent in committed:
|
||||
_published_ids[event.event_id] = true
|
||||
_recent_events.append(event.copy())
|
||||
@@ -90,13 +94,13 @@ func recent_events() -> Array[SimulationDomainEvent]:
|
||||
return result
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var published_ids: Array = _published_ids.keys()
|
||||
published_ids.sort_custom(func(left: Variant, right: Variant) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
var saved_ids: Array[String] = []
|
||||
for event_id: Variant in published_ids:
|
||||
saved_ids.append(String(event_id))
|
||||
if _snapshot_ids_dirty:
|
||||
_snapshot_ids.clear()
|
||||
for event_id: Variant in _published_ids:
|
||||
_snapshot_ids.append(String(event_id))
|
||||
_snapshot_ids.sort()
|
||||
_snapshot_ids_dirty = false
|
||||
var saved_ids: Array[String] = _snapshot_ids.duplicate()
|
||||
var saved_recent: Array[Dictionary] = []
|
||||
for event in _recent_events:
|
||||
saved_recent.append(event.to_dictionary())
|
||||
@@ -131,6 +135,7 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if restored_recent.size() > RECENT_EVENT_LIMIT:
|
||||
return false
|
||||
_published_ids = restored_ids
|
||||
_snapshot_ids_dirty = true
|
||||
_recent_events = restored_recent
|
||||
_pending_deliveries.clear()
|
||||
return true
|
||||
|
||||
@@ -16,7 +16,7 @@ const DEFAULT_SAMPLE_CAPACITY := 512
|
||||
|
||||
const DEFAULT_BUDGETS := {
|
||||
&"simulation_x1": {&"aggregation": Aggregation.AVERAGE, &"limit_us": 4_000},
|
||||
&"simulation_x4": {&"aggregation": Aggregation.P95, &"limit_us": 12_000},
|
||||
&"simulation_x3": {&"aggregation": Aggregation.P95, &"limit_us": 12_000},
|
||||
&"path_query": {&"aggregation": Aggregation.P95, &"limit_us": 2_000},
|
||||
&"main_thread_stall": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 50_000},
|
||||
&"save": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 5_000_000},
|
||||
@@ -71,12 +71,12 @@ func record_sample(metric_id: StringName, duration_us: int) -> Dictionary:
|
||||
|
||||
|
||||
func record_simulation_tick(duration_us: int, speed: int) -> Dictionary:
|
||||
var metric_id := &"simulation_x4" if speed == 4 else &"simulation_x1"
|
||||
var metric_id := &"simulation_x3" if speed == 3 else &"simulation_x1"
|
||||
return record_sample(metric_id, duration_us)
|
||||
|
||||
|
||||
## Records the full main-thread cost of one rendered/process frame, including
|
||||
## all game-minute ticks consumed at accelerated speed. This keeps x4 from
|
||||
## all game-minute ticks consumed at accelerated speed. This keeps x3 from
|
||||
## accidentally reporting four individually-fast ticks while the frame stalls.
|
||||
func record_simulation_frame(duration_us: int, speed: int, ticks_run: int) -> Dictionary:
|
||||
if ticks_run <= 0:
|
||||
|
||||
@@ -367,12 +367,27 @@ static func migrate_save(
|
||||
return _complete_timed_operation(result, &"save", started_at_us)
|
||||
|
||||
|
||||
## Domain migrations keep the package format version but still require the
|
||||
## same checksum-verified backup and atomic promotion as format migrations.
|
||||
## The caller validates domain invariants before supplying the candidate.
|
||||
static func replace_snapshot_with_backup(save_path: String, expected: Dictionary, candidate: Dictionary) -> Dictionary:
|
||||
if expected.is_empty() or candidate.is_empty():
|
||||
return _failure(&"MIGRATION_SNAPSHOT_REQUIRED", save_path)
|
||||
var validation := SimulationSnapshot.validate(candidate)
|
||||
if not validation[&"ok"]:
|
||||
return _failure(&"MIGRATION_INVALID_SNAPSHOT", ", ".join(validation[&"errors"]))
|
||||
var started := Time.get_ticks_usec()
|
||||
return _complete_timed_operation(_migrate_save_impl(save_path, null, FORMAT_VERSION, expected, candidate), &"save", started)
|
||||
|
||||
|
||||
static func _migrate_save_impl(
|
||||
save_path: String,
|
||||
registry: SaveMigrationRegistry,
|
||||
target_version: int
|
||||
target_version: int,
|
||||
expected_snapshot: Dictionary = {},
|
||||
replacement_snapshot: Dictionary = {}
|
||||
) -> Dictionary:
|
||||
if registry == null:
|
||||
if registry == null and replacement_snapshot.is_empty():
|
||||
return _failure(&"MIGRATION_REGISTRY_REQUIRED", save_path)
|
||||
var resolved := _resolve_safe_save_path(save_path)
|
||||
if not resolved[&"ok"]:
|
||||
@@ -410,7 +425,14 @@ static func _migrate_save_impl(
|
||||
var from_version := int(source[&"manifest"].get("format_version", -1))
|
||||
if target_version < from_version:
|
||||
return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [from_version, target_version])
|
||||
if target_version == from_version:
|
||||
if not replacement_snapshot.is_empty():
|
||||
if from_version != target_version:
|
||||
return _failure(&"FORMAT_MIGRATION_REQUIRED", save_path)
|
||||
if source[&"snapshot"] == replacement_snapshot:
|
||||
return {&"ok": true, &"code": &"ALREADY_CURRENT", &"backup_created": false, &"path": save_path}
|
||||
if source[&"snapshot"] != expected_snapshot:
|
||||
return _failure(&"MIGRATION_SOURCE_CHANGED", save_path)
|
||||
if target_version == from_version and replacement_snapshot.is_empty():
|
||||
var current_validation := SimulationSnapshot.validate(source[&"snapshot"])
|
||||
if target_version == FORMAT_VERSION and not current_validation[&"ok"]:
|
||||
return _failure(&"INVALID_SNAPSHOT", ", ".join(current_validation[&"errors"]))
|
||||
@@ -427,7 +449,12 @@ static func _migrate_save_impl(
|
||||
&"manifest": source[&"manifest"].duplicate(true),
|
||||
&"snapshot": source[&"snapshot"].duplicate(true),
|
||||
}
|
||||
var migrated := registry.migrate(payload, from_version, target_version)
|
||||
var migrated: Dictionary
|
||||
if replacement_snapshot.is_empty():
|
||||
migrated = registry.migrate(payload, from_version, target_version)
|
||||
else:
|
||||
payload[&"snapshot"] = replacement_snapshot.duplicate(true)
|
||||
migrated = {&"ok": true, &"data": payload, &"applied_migration_ids": [&"domain_snapshot_replacement"]}
|
||||
if not migrated[&"ok"]:
|
||||
return _with_context(migrated, {
|
||||
&"path": save_path,
|
||||
|
||||
@@ -22,6 +22,10 @@ class KeyLayouts:
|
||||
extends RefCounted
|
||||
var strings: Dictionary = {}
|
||||
var names: Dictionary = {}
|
||||
# Bounded per-call immutable scalar encodings; never retain mutable containers.
|
||||
var integers: Dictionary = {}
|
||||
var text_values: Dictionary = {}
|
||||
var name_values: Dictionary = {}
|
||||
|
||||
|
||||
|
||||
@@ -56,14 +60,19 @@ static func _encode_value(value: Variant, output: PackedByteArray, depth: int, l
|
||||
TYPE_BOOL:
|
||||
_append_ascii(output, "b1" if bool(value) else "b0")
|
||||
TYPE_INT:
|
||||
_append_ascii(output, "i%d;" % int(value))
|
||||
if layouts.integers.has(value):
|
||||
output.append_array(layouts.integers[value])
|
||||
else:
|
||||
var encoded := ("i%d;" % int(value)).to_utf8_buffer()
|
||||
output.append_array(encoded)
|
||||
if layouts.integers.size() < 512: layouts.integers[value] = encoded
|
||||
TYPE_FLOAT:
|
||||
_append_ascii(output, "f")
|
||||
return _append_float64(output, float(value))
|
||||
TYPE_STRING:
|
||||
_append_sized_bytes(output, "s", String(value).to_utf8_buffer())
|
||||
_append_cached_text(output, "s", String(value), layouts.text_values)
|
||||
TYPE_STRING_NAME:
|
||||
_append_sized_bytes(output, "q", String(value).to_utf8_buffer())
|
||||
_append_cached_text(output, "q", String(value), layouts.name_values)
|
||||
TYPE_VECTOR2:
|
||||
var vector: Vector2 = value
|
||||
_append_ascii(output, "v2")
|
||||
@@ -290,3 +299,15 @@ static func _append_sized_bytes(output: PackedByteArray, tag: String, value: Pac
|
||||
|
||||
static func _append_ascii(output: PackedByteArray, value: String) -> void:
|
||||
output.append_array(value.to_utf8_buffer())
|
||||
|
||||
|
||||
static func _append_cached_text(output: PackedByteArray, tag: String, value: String, cache: Dictionary) -> void:
|
||||
if cache.has(value):
|
||||
output.append_array(cache[value])
|
||||
return
|
||||
var bytes := value.to_utf8_buffer()
|
||||
var encoded := ("%s%d:" % [tag, bytes.size()]).to_utf8_buffer()
|
||||
encoded.append_array(bytes)
|
||||
output.append_array(encoded)
|
||||
if value.length() <= 128 and cache.size() < 256:
|
||||
cache[value] = encoded
|
||||
|
||||
@@ -25,7 +25,7 @@ enum TickPhase {
|
||||
|
||||
const MINUTES_PER_DAY := 24 * 60
|
||||
const START_ABSOLUTE_MINUTE := 6 * 60
|
||||
const REAL_SECONDS_PER_GAME_HOUR := 30.0
|
||||
const REAL_SECONDS_PER_GAME_HOUR := 10.0
|
||||
const REAL_SECONDS_PER_GAME_MINUTE := REAL_SECONDS_PER_GAME_HOUR / 60.0
|
||||
const MAX_TICKS_PER_FRAME := 8
|
||||
const MAX_BACKLOG_MINUTES := 120
|
||||
@@ -160,7 +160,7 @@ func request_current_tick_abort() -> void:
|
||||
_abort_current_tick_requested = true
|
||||
|
||||
func set_speed(value: int) -> bool:
|
||||
if value not in [1, 2, 4]:
|
||||
if value not in [1, 2, 3]:
|
||||
return false
|
||||
speed = value
|
||||
paused = false
|
||||
@@ -218,7 +218,10 @@ func restore_clock(snapshot: Dictionary) -> bool:
|
||||
if not snapshot.has(&"absolute_minute"):
|
||||
return false
|
||||
var restored_speed := int(snapshot.get(&"speed", 1))
|
||||
if restored_speed not in [1, 2, 4]:
|
||||
# Legacy x4 saves resume at the original specification maximum, x3.
|
||||
if restored_speed == 4:
|
||||
restored_speed = 3
|
||||
if restored_speed not in [1, 2, 3]:
|
||||
return false
|
||||
absolute_minute = maxi(int(snapshot[&"absolute_minute"]), 0)
|
||||
speed = restored_speed
|
||||
|
||||
@@ -23,7 +23,7 @@ static func validate(snapshot: Dictionary) -> Dictionary:
|
||||
var clock_data: Dictionary = snapshot[&"clock"]
|
||||
if int(clock_data.get(&"absolute_minute", -1)) < 0:
|
||||
errors.append("invalid_absolute_minute")
|
||||
if int(clock_data.get(&"speed", 0)) not in [1, 2, 4]:
|
||||
if int(clock_data.get(&"speed", 0)) not in [1, 2, 3, 4]:
|
||||
errors.append("invalid_speed")
|
||||
if not snapshot.get(&"game_state", null) is Dictionary:
|
||||
errors.append("missing_game_state")
|
||||
|
||||
@@ -46,6 +46,16 @@ const FACILITY_PATHS: Array[String] = [
|
||||
|
||||
## Required movement infrastructure added after the original sixteen-facility
|
||||
## catalog. Separate paths preserve the baseline design table and legacy scans.
|
||||
## Original-spec facilities integrated into normal construction beyond baseline.
|
||||
const ORIGINAL_FACILITY_PATHS: Array[String] = [
|
||||
"res://data/facilities/expansion/cleaning_room.tres",
|
||||
"res://data/facilities/expansion/chapel.tres",
|
||||
"res://data/facilities/expansion/premium_room.tres",
|
||||
"res://data/facilities/expansion/staff_dormitory.tres",
|
||||
]
|
||||
|
||||
const RESCUE_FACILITY_PATHS: Array[String] = ["res://data/facilities/rescue/infirmary.tres"]
|
||||
|
||||
const INFRASTRUCTURE_FACILITY_IDS: Array[StringName] = [
|
||||
&"guest_stairs",
|
||||
&"service_stairs",
|
||||
@@ -130,6 +140,8 @@ static func load_available(include_expansion := false) -> Array[FacilityDefiniti
|
||||
static func by_id(facility_id: StringName) -> FacilityDefinition:
|
||||
var definitions := load_available(true)
|
||||
definitions.append_array(load_infrastructure())
|
||||
for path: String in RESCUE_FACILITY_PATHS + ORIGINAL_FACILITY_PATHS:
|
||||
definitions.append(load(path) as FacilityDefinition)
|
||||
for definition in definitions:
|
||||
if definition.id == facility_id:
|
||||
return definition
|
||||
|
||||
@@ -58,6 +58,8 @@ enum ViewLayer {
|
||||
@export var primary_skill: StringName = &"service"
|
||||
|
||||
@export_group("Service")
|
||||
@export var customer_capacity := -1
|
||||
@export var service_attribute: StringName = &""
|
||||
@export var capacity := 0
|
||||
@export var queue_capacity := 0
|
||||
@export var base_service_minutes := 1
|
||||
@@ -84,6 +86,7 @@ enum ViewLayer {
|
||||
@export var content_replacement_id: StringName = &""
|
||||
|
||||
@export_group("Progression")
|
||||
@export var faith_per_service := 0
|
||||
@export_range(1, 7, 1) var unlock_rank := 1
|
||||
@export var research_requirements: Array[StringName] = []
|
||||
|
||||
@@ -105,10 +108,13 @@ func is_valid() -> bool:
|
||||
and maintenance_mana >= 0 \
|
||||
and staff_min >= 0 \
|
||||
and staff_max >= staff_min \
|
||||
and customer_capacity >= -1 \
|
||||
and service_attribute in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"] \
|
||||
and capacity >= 0 \
|
||||
and queue_capacity >= 0 \
|
||||
and base_service_minutes > 0 \
|
||||
and base_price >= 0 \
|
||||
and faith_per_service >= 0 \
|
||||
and detailed_profile_is_valid()
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ const DEFAULT_PATHS := {
|
||||
const REQUIRED_COUNTS := {
|
||||
&"guest_classes": 8,
|
||||
&"species": 4,
|
||||
&"staff_candidates": 20,
|
||||
&"staff_candidates": 21,
|
||||
&"unique_characters": 2,
|
||||
}
|
||||
const REQUIRED_IDS := {
|
||||
@@ -45,7 +45,7 @@ const REQUIRED_IDS := {
|
||||
&"staff_akane", &"staff_bardo", &"staff_chika", &"staff_doran", &"staff_eri",
|
||||
&"staff_fugo", &"staff_gina", &"staff_haru", &"staff_iris", &"staff_juno",
|
||||
&"staff_klaus", &"staff_lina", &"staff_marc", &"staff_nadia", &"staff_otto",
|
||||
&"staff_petra", &"staff_quinn", &"staff_rika", &"staff_sven", &"staff_tessa",
|
||||
&"staff_petra", &"staff_quinn", &"staff_rika", &"staff_sven", &"staff_tessa", &"staff_uma",
|
||||
],
|
||||
&"unique_characters": [&"heroine_anri", &"rival_rai"],
|
||||
}
|
||||
@@ -58,18 +58,35 @@ const SHIFT_IDS: Array[StringName] = [&"any", &"day", &"night"]
|
||||
const SCHEDULE_PROFILE_IDS: Array[StringName] = [&"day", &"evening", &"night"]
|
||||
const UNIQUE_KINDS: Array[StringName] = [&"heroine", &"rival"]
|
||||
const MECHANICAL_EFFECT_IDS: Array[StringName] = [
|
||||
&"assigned_floor_patience_loss_multiplier_bp",
|
||||
&"assigned_floor_revenue_multiplier_bp",
|
||||
&"base_shield_attack",
|
||||
&"night_shield_attack_bonus",
|
||||
&"cleaning_work_rate_multiplier",
|
||||
]
|
||||
const HEROINE_EFFECT_IDS: Array[StringName] = [
|
||||
&"assigned_floor_patience_loss_multiplier_bp",
|
||||
&"assigned_floor_revenue_multiplier_bp",
|
||||
]
|
||||
const RIVAL_EFFECT_IDS: Array[StringName] = [
|
||||
&"base_shield_attack",
|
||||
&"night_shield_attack_bonus",
|
||||
]
|
||||
|
||||
# Trusted per-person declarations. JSON cannot supply or broaden these policies.
|
||||
# The pair below preserves the current MVP JSON contract; it is not a new
|
||||
# relationship assignment inferred for the other characters in original 6.2.
|
||||
const UNIQUE_VALIDATION_POLICIES := {
|
||||
&"heroine_anri": {
|
||||
&"character_kind": &"heroine",
|
||||
&"mechanical_effect_ids": HEROINE_EFFECT_IDS,
|
||||
&"counterpart_id": &"rival_rai",
|
||||
},
|
||||
&"rival_rai": {
|
||||
&"character_kind": &"rival",
|
||||
&"mechanical_effect_ids": RIVAL_EFFECT_IDS,
|
||||
&"counterpart_id": &"heroine_anri",
|
||||
},
|
||||
}
|
||||
|
||||
const GUEST_TAGS: Array[StringName] = [
|
||||
&"combatant", &"diplomatic", &"faith_aligned", &"mana_sensitive",
|
||||
&"nocturnal", &"noise_sensitive", &"stealthy", &"trade_focused",
|
||||
@@ -85,6 +102,7 @@ const PREFERENCE_IDS: Array[StringName] = [
|
||||
&"underground", &"vip",
|
||||
]
|
||||
const ROLE_IDS: Array[StringName] = [
|
||||
&"cleric",
|
||||
&"attendant", &"bartender", &"clerk", &"cleaner", &"cook", &"guard",
|
||||
&"influencer", &"innkeeper", &"maintenance", &"masseur", &"medic",
|
||||
&"porter", &"receptionist",
|
||||
@@ -124,6 +142,8 @@ const UNIQUE_FIELDS: Array[StringName] = [
|
||||
&"role_id", &"skills", &"traits",
|
||||
]
|
||||
|
||||
# Transient revision of validated catalog content; never serialized.
|
||||
var _manifest_generation := 0
|
||||
var _entries_by_type: Dictionary = {}
|
||||
var _ordered_ids_by_type: Dictionary = {}
|
||||
var _errors: Array[Dictionary] = []
|
||||
@@ -172,6 +192,10 @@ func rebuild_from_documents(documents: Dictionary) -> Dictionary:
|
||||
return get_report()
|
||||
|
||||
|
||||
func get_manifest_generation() -> int:
|
||||
return _manifest_generation
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return _errors.is_empty() and _entries_by_type.size() == DEFAULT_PATHS.size()
|
||||
|
||||
@@ -274,22 +298,36 @@ func build_all_guest_definitions() -> Array[GuestDefinition]:
|
||||
return result
|
||||
|
||||
|
||||
## General candidates remain a separate catalog category. Only approved unique
|
||||
## recruitment is exposed here; rival joining conditions are not inferred.
|
||||
func get_recruitable_staff_ids() -> Array[StringName]:
|
||||
var ids := get_staff_candidate_ids()
|
||||
if not get_entry(&"unique_characters", &"heroine_anri").is_empty():
|
||||
ids.append(&"heroine_anri")
|
||||
ids.sort_custom(_name_less)
|
||||
return ids
|
||||
|
||||
|
||||
func get_recruitment_entry(candidate_id: StringName) -> Dictionary:
|
||||
return get_entry(&"unique_characters", candidate_id) if candidate_id == &"heroine_anri" else get_entry(&"staff_candidates", candidate_id)
|
||||
|
||||
|
||||
func staff_hire_payload(candidate_id: StringName) -> Dictionary:
|
||||
var candidate := get_entry(&"staff_candidates", candidate_id)
|
||||
var candidate := get_recruitment_entry(candidate_id)
|
||||
if candidate.is_empty():
|
||||
return {}
|
||||
return {
|
||||
&"staff_id": candidate_id,
|
||||
&"role_id": StringName(candidate[&"role_id"]),
|
||||
&"daily_wage": int(candidate[&"daily_wage"]),
|
||||
&"signing_cost": int(candidate[&"signing_cost"]),
|
||||
&"daily_wage": int(candidate.get(&"daily_wage", 0)),
|
||||
&"signing_cost": int(candidate.get(&"signing_cost", 0)),
|
||||
&"preferred_shift": StringName(candidate[&"preferred_shift"]),
|
||||
&"adult_verified": bool(candidate[&"adult_verified"]),
|
||||
&"age": int(candidate[&"age"]),
|
||||
&"options": {
|
||||
&"daily_wage": int(candidate[&"daily_wage"]),
|
||||
&"employment_kind": &"FREE_HIRE",
|
||||
&"permitted_task_tags": _to_name_array(candidate[&"permitted_task_tags"]),
|
||||
&"daily_wage": int(candidate.get(&"daily_wage", 0)),
|
||||
&"employment_kind": &"UNIQUE" if candidate_id == &"heroine_anri" else &"FREE_HIRE",
|
||||
&"permitted_task_tags": _to_name_array(candidate.get(&"permitted_task_tags", [])),
|
||||
&"role_tags": [StringName(candidate[&"role_id"])],
|
||||
&"skills": _name_keyed_integer_map(candidate[&"skills"]),
|
||||
},
|
||||
@@ -297,6 +335,7 @@ func staff_hire_payload(candidate_id: StringName) -> Dictionary:
|
||||
|
||||
|
||||
func _reset() -> void:
|
||||
_manifest_generation += 1
|
||||
_entries_by_type.clear()
|
||||
_ordered_ids_by_type.clear()
|
||||
_errors.clear()
|
||||
@@ -645,10 +684,20 @@ func _validate_unique(entry_id: StringName, entry: Dictionary) -> void:
|
||||
_add_error(content_type, entry_id, &"character_kind", &"unknown_character_kind", "")
|
||||
elif not String(entry_id).begins_with("%s_" % String(kind)):
|
||||
_add_error(content_type, entry_id, &"id", &"invalid_namespace", "%s_" % String(kind))
|
||||
var raw_counterpart_id: Variant = _value(entry, &"counterpart_id", null)
|
||||
var counterpart_id := StringName(raw_counterpart_id) if _is_string_variant(raw_counterpart_id) else &""
|
||||
if counterpart_id.is_empty() or counterpart_id == entry_id:
|
||||
_add_error(content_type, entry_id, &"counterpart_id", &"invalid_counterpart", "")
|
||||
var policy: Dictionary = _unique_validation_policy(entry_id)
|
||||
if policy.is_empty():
|
||||
_add_error(content_type, entry_id, &"id", &"missing_unique_policy", "")
|
||||
elif kind != StringName(policy[&"character_kind"]):
|
||||
_add_error(content_type, entry_id, &"character_kind", &"character_policy_mismatch", "")
|
||||
if policy.has(&"counterpart_id"):
|
||||
var raw_counterpart_id: Variant = _value(entry, &"counterpart_id", null)
|
||||
var counterpart_id := StringName(raw_counterpart_id) if _is_string_variant(raw_counterpart_id) else &""
|
||||
if counterpart_id.is_empty() or counterpart_id == entry_id:
|
||||
_add_error(content_type, entry_id, &"counterpart_id", &"invalid_counterpart", "")
|
||||
elif not policy.is_empty() and (entry.has(&"counterpart_id") or entry.has("counterpart_id")):
|
||||
# An unspecified relationship is represented by omission, not null,
|
||||
# an empty ID, a fabricated actor or an automatic reciprocal pair.
|
||||
_add_error(content_type, entry_id, &"counterpart_id", &"unexpected_counterpart", "")
|
||||
var traits := _to_name_array(_value(entry, &"traits", []))
|
||||
if not traits.has(&"unique") or not traits.has(&"unsellable"):
|
||||
_add_error(content_type, entry_id, &"traits", &"unique_safety_traits_required", "")
|
||||
@@ -669,7 +718,9 @@ func _validate_unique(entry_id: StringName, entry: Dictionary) -> void:
|
||||
if not MECHANICAL_EFFECT_IDS.has(effect_id) \
|
||||
or not _mechanical_effect_value_is_valid(effect_id, effect_value):
|
||||
_add_error(content_type, entry_id, &"mechanical_effects", &"invalid_mechanical_effect", String(raw_key))
|
||||
var expected_effects: Array[StringName] = HEROINE_EFFECT_IDS if kind == &"heroine" else RIVAL_EFFECT_IDS
|
||||
var expected_effects: Array[StringName] = []
|
||||
if not policy.is_empty():
|
||||
expected_effects.assign(policy[&"mechanical_effect_ids"])
|
||||
for expected_effect_id: StringName in expected_effects:
|
||||
if not seen_effects.has(expected_effect_id):
|
||||
_add_error(content_type, entry_id, &"mechanical_effects", &"missing_mechanical_effect", String(expected_effect_id))
|
||||
@@ -678,6 +729,11 @@ func _validate_unique(entry_id: StringName, entry: Dictionary) -> void:
|
||||
_add_error(content_type, entry_id, &"mechanical_effects", &"effect_kind_mismatch", String(effect_id))
|
||||
|
||||
|
||||
func _unique_validation_policy(entry_id: StringName) -> Dictionary:
|
||||
var policy: Dictionary = UNIQUE_VALIDATION_POLICIES.get(entry_id, {})
|
||||
return policy.duplicate(true)
|
||||
|
||||
|
||||
func _validate_adult(content_type: StringName, entry_id: StringName, entry: Dictionary) -> void:
|
||||
var verified: Variant = _value(entry, &"adult_verified", null)
|
||||
if not verified is bool or not bool(verified):
|
||||
@@ -714,12 +770,22 @@ func _validate_cross_references() -> void:
|
||||
var unique_index: Dictionary = _entries_by_type.get(&"unique_characters", {})
|
||||
for entry_id: StringName in get_unique_character_ids():
|
||||
var entry: Dictionary = unique_index[entry_id]
|
||||
var policy: Dictionary = _unique_validation_policy(entry_id)
|
||||
if policy.is_empty():
|
||||
_add_error(&"unique_characters", entry_id, &"id", &"missing_unique_policy", "")
|
||||
continue
|
||||
if not policy.has(&"counterpart_id"):
|
||||
continue
|
||||
var counterpart_id := StringName(entry[&"counterpart_id"])
|
||||
if not unique_index.has(counterpart_id):
|
||||
_add_error(&"unique_characters", entry_id, &"counterpart_id", &"missing_counterpart", String(counterpart_id))
|
||||
continue
|
||||
if counterpart_id != StringName(policy[&"counterpart_id"]):
|
||||
_add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_policy_mismatch", String(counterpart_id))
|
||||
var counterpart: Dictionary = unique_index[counterpart_id]
|
||||
if StringName(counterpart[&"counterpart_id"]) != entry_id:
|
||||
var counterpart_policy: Dictionary = _unique_validation_policy(counterpart_id)
|
||||
if StringName(_value(counterpart, &"counterpart_id", &"")) != entry_id \
|
||||
or StringName(counterpart_policy.get(&"counterpart_id", &"")) != entry_id:
|
||||
_add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_not_reciprocal", String(counterpart_id))
|
||||
if StringName(counterpart[&"character_kind"]) == StringName(entry[&"character_kind"]):
|
||||
_add_error(&"unique_characters", entry_id, &"character_kind", &"counterpart_kind_conflict", String(counterpart_id))
|
||||
@@ -983,10 +1049,14 @@ static func _mechanical_effect_value_is_valid(effect_id: StringName, raw_value:
|
||||
var value := int(raw_value)
|
||||
if value < 0 or value > MAX_MECHANICAL_EFFECT_VALUE:
|
||||
return false
|
||||
if effect_id == &"assigned_floor_patience_loss_multiplier_bp":
|
||||
if effect_id == &"assigned_floor_revenue_multiplier_bp":
|
||||
return value >= 2500 and value <= 30_000
|
||||
if effect_id in [&"base_shield_attack", &"night_shield_attack_bonus"]:
|
||||
return value <= 100
|
||||
if effect_id == &"cleaning_work_rate_multiplier":
|
||||
# Original 6.2 specifies exactly three. This validator does not register
|
||||
# Fine, assign an employment profile or activate any Runtime effect.
|
||||
return value == 3
|
||||
return false
|
||||
|
||||
|
||||
|
||||
@@ -103,6 +103,30 @@ func get_raid_ids() -> Array[StringName]:
|
||||
return _sorted_keys(_raids)
|
||||
|
||||
|
||||
## Explicit IDs in valid older saves need not have advanced Runtime's allocator.
|
||||
## Query the existing history without exposing or copying its mutable payloads.
|
||||
func has_resolution(raid_id: StringName, resolution_id: StringName) -> bool:
|
||||
return _raids.has(raid_id) and (_raids[raid_id][&"resolved_rounds"] as Dictionary).has(resolution_id)
|
||||
|
||||
|
||||
## Detached primitives only; ordinary ticks must not clone growing round history.
|
||||
## The interval is already part of each persisted raid, not a new balance value.
|
||||
func get_due_rounds(absolute_minute: int) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for raid_id: StringName in _sorted_keys(_raids):
|
||||
var raid: Dictionary = _raids[raid_id]
|
||||
if int(raid[&"state"]) != RaidState.ACTIVE:
|
||||
continue
|
||||
var earliest := int(raid[&"state_changed_at"]) + int(raid[&"round_minutes"])
|
||||
if earliest <= absolute_minute:
|
||||
result.append({
|
||||
&"raid_id": raid_id,
|
||||
&"earliest_minute": earliest,
|
||||
&"round_number": int(raid[&"current_round"]) + 1,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
func get_active_raid_ids() -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for raid_id: StringName in _sorted_keys(_raids):
|
||||
@@ -112,6 +136,75 @@ func get_active_raid_ids() -> Array[StringName]:
|
||||
return result
|
||||
|
||||
|
||||
## Compact, owned projection: never copy combatants or accumulated round history
|
||||
## just to display the current threats. Preparing raids also lock employees.
|
||||
func get_threat_summary() -> Dictionary:
|
||||
var counts := {&"preparing": 0, &"active": 0, &"between_waves": 0, &"total": 0}
|
||||
for raid: Dictionary in _raids.values():
|
||||
match int(raid[&"state"]):
|
||||
RaidState.PREPARING:
|
||||
counts[&"preparing"] += 1
|
||||
RaidState.ACTIVE:
|
||||
counts[&"active"] += 1
|
||||
RaidState.BETWEEN_WAVES:
|
||||
counts[&"between_waves"] += 1
|
||||
counts[&"total"] = counts[&"preparing"] + counts[&"active"] + counts[&"between_waves"]
|
||||
return counts
|
||||
|
||||
|
||||
## Owned UI projections. Deliberately omit resolved rounds and skill history.
|
||||
func get_management_rows() -> Array[Dictionary]:
|
||||
var rows: Array[Dictionary] = []
|
||||
for raid_id: StringName in _sorted_keys(_raids):
|
||||
var raid: Dictionary = _raids[raid_id]
|
||||
var missing: Array[StringName] = []
|
||||
for objective_id: StringName in raid[&"objective_ids"]:
|
||||
if not (raid[&"assets"] as Dictionary).has(objective_id):
|
||||
missing.append(objective_id)
|
||||
var defender_ids: Array[StringName] = []
|
||||
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
|
||||
if raid[&"combatants"][actor_id][&"side"] == &"DEFENDER": defender_ids.append(actor_id)
|
||||
rows.append({
|
||||
&"raid_id": raid_id, &"state": int(raid[&"state"]),
|
||||
&"source_id": raid[&"source_id"], &"current_round": int(raid[&"current_round"]),
|
||||
&"source_kind": String((raid.get(&"event_source", {}) as Dictionary).get("source_kind", "")),
|
||||
&"created_at": int(raid[&"created_at"]),
|
||||
&"current_wave": int(raid[&"current_wave_index"]) + 1,
|
||||
&"wave_count": (raid[&"waves"] as Array).size(),
|
||||
&"retreat_policy": raid[&"retreat_policy"],
|
||||
&"defender_retreat_threshold": int(raid[&"defender_retreat_threshold"]),
|
||||
&"objective_ids": (raid[&"objective_ids"] as Array).duplicate(),
|
||||
&"missing_objectives": missing,
|
||||
&"defender_ids": defender_ids, &"asset_ids": _sorted_keys(raid[&"assets"]),
|
||||
&"result": raid[&"result"], &"result_reason": raid[&"result_reason"],
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
func get_management_detail(raid_id: StringName) -> Dictionary:
|
||||
if not _raids.has(raid_id):
|
||||
return {}
|
||||
var raid: Dictionary = _raids[raid_id]
|
||||
var defenders: Array[Dictionary] = []
|
||||
for actor_id: StringName in _sorted_keys(raid[&"combatants"]):
|
||||
var actor: Dictionary = raid[&"combatants"][actor_id]
|
||||
if actor[&"side"] == &"DEFENDER":
|
||||
defenders.append({
|
||||
&"actor_id": actor_id, &"health": actor[&"health"],
|
||||
&"max_health": actor[&"max_health"], &"combat": actor[&"combat"],
|
||||
&"fatigue": actor[&"fatigue"], &"state": actor[&"state"],
|
||||
})
|
||||
var assets: Array[Dictionary] = []
|
||||
for asset_id: StringName in _sorted_keys(raid[&"assets"]):
|
||||
var asset: Dictionary = raid[&"assets"][asset_id]
|
||||
assets.append({
|
||||
&"asset_id": asset_id, &"kind": asset[&"kind"],
|
||||
&"integrity": asset[&"integrity"], &"max_integrity": asset[&"max_integrity"],
|
||||
&"defense": asset[&"defense"],
|
||||
})
|
||||
return {&"defenders": defenders, &"assets": assets}
|
||||
|
||||
|
||||
func create_raid(
|
||||
raid_id: StringName,
|
||||
absolute_minute: int,
|
||||
@@ -124,6 +217,11 @@ func create_raid(
|
||||
if _raids.has(raid_id):
|
||||
return _duplicate("Raid", raid_id)
|
||||
|
||||
var event_source_value: Variant = _value(definition, &"event_source", {})
|
||||
if not _is_valid_event_source(event_source_value):
|
||||
return _invalid("Event threat provenance is malformed.")
|
||||
var event_source: Dictionary = event_source_value.duplicate(true)
|
||||
|
||||
var entry_points := _sorted_unique_names(_value(definition, &"entry_points", []))
|
||||
var objective_ids := _sorted_unique_names(_value(definition, &"objective_ids", []))
|
||||
if entry_points.is_empty() or objective_ids.is_empty():
|
||||
@@ -146,6 +244,7 @@ func create_raid(
|
||||
var raid := {
|
||||
&"raid_id": raid_id,
|
||||
&"source_id": StringName(_value(definition, &"source_id", &"")),
|
||||
&"event_source": event_source,
|
||||
&"state": RaidState.PREPARING,
|
||||
&"created_at": absolute_minute,
|
||||
&"started_at": -1,
|
||||
@@ -1372,6 +1471,7 @@ func _raid_to_snapshot(raid: Dictionary) -> Dictionary:
|
||||
var snapshot := {
|
||||
&"raid_id": String(raid[&"raid_id"]),
|
||||
&"source_id": String(raid[&"source_id"]),
|
||||
&"event_source": _canonical_snapshot_value(raid.get(&"event_source", {})),
|
||||
&"state": RaidState.keys()[int(raid[&"state"])],
|
||||
&"created_at": raid[&"created_at"],
|
||||
&"started_at": raid[&"started_at"],
|
||||
@@ -1432,6 +1532,9 @@ func _raid_to_snapshot(raid: Dictionary) -> Dictionary:
|
||||
|
||||
|
||||
func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
|
||||
var event_source_value: Variant = _value(raw, &"event_source", {})
|
||||
if not _is_valid_event_source(event_source_value):
|
||||
return {&"ok": false, &"error": "Event threat provenance snapshot is malformed."}
|
||||
for collection_key: StringName in [&"waves", &"combatants", &"assets", &"queued_skills", &"applied_skill_ids", &"resolved_rounds"]:
|
||||
if not _value(raw, collection_key, null) is Array:
|
||||
return {&"ok": false, &"error": "Malformed Raid collection: %s" % collection_key}
|
||||
@@ -1442,6 +1545,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
|
||||
var raid := {
|
||||
&"raid_id": raid_id,
|
||||
&"source_id": StringName(_value(raw, &"source_id", &"")),
|
||||
&"event_source": event_source_value.duplicate(true),
|
||||
&"state": state_index,
|
||||
&"created_at": int(_value(raw, &"created_at", -1)),
|
||||
&"started_at": int(_value(raw, &"started_at", -1)),
|
||||
@@ -1563,6 +1667,8 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
|
||||
|
||||
func _validate_raid(raid: Dictionary) -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
if not _is_valid_event_source(raid.get(&"event_source", {})):
|
||||
errors.append("Event threat provenance is invalid.")
|
||||
var raid_id := StringName(raid.get(&"raid_id", &""))
|
||||
var state := int(raid.get(&"state", -1))
|
||||
var created_at := int(raid.get(&"created_at", -1))
|
||||
@@ -1749,7 +1855,10 @@ func _validate_raid(raid: Dictionary) -> Dictionary:
|
||||
var listed_objective: bool = objective_ids is Array and objective_ids.has(asset_id)
|
||||
if listed_objective != (kind == AssetKind.OBJECTIVE):
|
||||
errors.append("Defense objective registration mismatch: %s" % asset_id)
|
||||
if state != RaidState.PREPARING and assets is Dictionary and objective_ids is Array:
|
||||
# Preparation may be cancelled before all objective assets are registered.
|
||||
# A raid that actually started must retain those assets even after cancellation.
|
||||
var cancelled_before_start := state == RaidState.CANCELLED and started_at == -1
|
||||
if state != RaidState.PREPARING and not cancelled_before_start and assets is Dictionary and objective_ids is Array:
|
||||
for objective_id: StringName in objective_ids:
|
||||
if not assets.has(objective_id):
|
||||
errors.append("Started Raid is missing objective asset: %s" % objective_id)
|
||||
@@ -2127,3 +2236,28 @@ func _not_found(kind: String, value_id: StringName) -> CommandResult:
|
||||
last_error,
|
||||
{&"id": value_id}
|
||||
)
|
||||
|
||||
|
||||
## Optional provenance is absent in legacy/manual raids. Configured event raids
|
||||
## retain it so a replay cannot silently replace an already-running incident.
|
||||
func _is_valid_event_source(raw: Variant) -> bool:
|
||||
if not raw is Dictionary:
|
||||
return false
|
||||
if raw.is_empty():
|
||||
return true
|
||||
var keys := ["event_instance_id", "event_action_id", "template_id", "template_digest", "source_kind"]
|
||||
if raw.size() != keys.size():
|
||||
return false
|
||||
for key: String in keys:
|
||||
var value: Variant = _value(raw, StringName(key), null)
|
||||
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME] or String(value).is_empty():
|
||||
return false
|
||||
if String(_value(raw, &"source_kind", "")) not in ["THIEF", "BANKRUPTCY_RIOT", "BANK_DELINQUENCY", "AUTHORITY_ESCALATION"]:
|
||||
return false
|
||||
var digest := String(_value(raw, &"template_digest", ""))
|
||||
if digest.length() != 64:
|
||||
return false
|
||||
for character: String in digest:
|
||||
if character not in "0123456789abcdef":
|
||||
return false
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
class_name FacilityUpkeepAccrual
|
||||
extends RefCounted
|
||||
|
||||
## Exact upkeep accumulation in gold-minute units. The caller supplies the
|
||||
## applicable daily rates for [current_minute, target_minute); this component
|
||||
## deliberately does not decide whether closed/suspended rooms are billable.
|
||||
## No money is rounded, debited, or forgiven here. Settlement owns those rules.
|
||||
const SNAPSHOT_VERSION := 1
|
||||
const MINUTES_PER_DAY := 1440
|
||||
const MAX_DAILY_RATE := 1000000000
|
||||
const MAX_FACILITIES := 20000
|
||||
const MAX_PERIODS := 10000
|
||||
const MAX_MINUTE := 1000000000
|
||||
|
||||
var _started_at := -1
|
||||
var _current_minute := -1
|
||||
var _period_start := -1
|
||||
var _pending_units: Dictionary = {}
|
||||
var _closed_periods: Array[Dictionary] = []
|
||||
|
||||
|
||||
func start_at(minute: int) -> Dictionary:
|
||||
if _started_at >= 0:
|
||||
return _failure(&"ALREADY_STARTED")
|
||||
if minute < 0 or minute > MAX_MINUTE:
|
||||
return _failure(&"INVALID_MINUTE")
|
||||
_started_at = minute
|
||||
_current_minute = minute
|
||||
_period_start = minute
|
||||
return {&"ok": true}
|
||||
|
||||
|
||||
func get_current_minute() -> int:
|
||||
return _current_minute
|
||||
|
||||
|
||||
func advance_to(minute: int, daily_rates: Dictionary) -> Dictionary:
|
||||
if _started_at < 0:
|
||||
return _failure(&"NOT_STARTED")
|
||||
if minute < _current_minute or minute > MAX_MINUTE:
|
||||
return _failure(&"INVALID_MINUTE")
|
||||
# A bounded batch also supports migration/testing without an unbounded loop.
|
||||
if minute - _current_minute > MINUTES_PER_DAY:
|
||||
return _failure(&"INTERVAL_TOO_LARGE")
|
||||
var parsed := _parse_units(daily_rates, MAX_DAILY_RATE)
|
||||
if not parsed[&"ok"]:
|
||||
return _failure(&"INVALID_RATES")
|
||||
var rates: Dictionary = parsed[&"value"]
|
||||
var touched := _pending_units.duplicate()
|
||||
for id: String in rates:
|
||||
if int(rates[id]) > 0:
|
||||
touched[id] = 0
|
||||
if touched.size() > MAX_FACILITIES:
|
||||
return _failure(&"TOO_MANY_FACILITIES")
|
||||
var closures := minute / MINUTES_PER_DAY - _current_minute / MINUTES_PER_DAY
|
||||
if _closed_periods.size() + closures > MAX_PERIODS:
|
||||
return _failure(&"TOO_MANY_PERIODS")
|
||||
if minute == _current_minute:
|
||||
return {&"ok": true, &"advanced_minutes": 0, &"closed_periods": []}
|
||||
var before := _current_minute
|
||||
var newly_closed: Array[Dictionary] = []
|
||||
while _current_minute < minute:
|
||||
var midnight := (_current_minute / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY
|
||||
var until := mini(midnight, minute)
|
||||
var elapsed := until - _current_minute
|
||||
for id: String in rates:
|
||||
var rate := int(rates[id])
|
||||
if rate > 0:
|
||||
_pending_units[id] = int(_pending_units.get(id, 0)) + rate * elapsed
|
||||
_current_minute = until
|
||||
if until == midnight:
|
||||
var closed := {
|
||||
&"period_start": _period_start,
|
||||
&"period_end": until,
|
||||
&"facility_units": _canonical_units(_pending_units),
|
||||
}
|
||||
_closed_periods.append(closed)
|
||||
newly_closed.append(closed.duplicate(true))
|
||||
_pending_units = {}
|
||||
_period_start = until
|
||||
return {&"ok": true, &"advanced_minutes": minute - before, &"closed_periods": newly_closed}
|
||||
|
||||
|
||||
func get_unsettled_units(facility_id: StringName, settled_periods: int) -> int:
|
||||
var id := String(facility_id)
|
||||
var units := int(_pending_units.get(id, 0))
|
||||
for index in range(clampi(settled_periods, 0, _closed_periods.size()), _closed_periods.size()):
|
||||
units += int(_closed_periods[index][&"facility_units"].get(id, 0))
|
||||
return units
|
||||
|
||||
|
||||
func get_closed_periods_from(index: int) -> Array:
|
||||
return _closed_periods.slice(clampi(index, 0, _closed_periods.size())).duplicate(true)
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
return {
|
||||
&"current_minute": _current_minute,
|
||||
&"period_start": _period_start,
|
||||
&"gold_denominator": MINUTES_PER_DAY,
|
||||
&"pending_facility_units": _canonical_units(_pending_units),
|
||||
&"closed_periods": _closed_periods.duplicate(true),
|
||||
}
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {
|
||||
&"version": SNAPSHOT_VERSION,
|
||||
&"started_at": _started_at,
|
||||
&"current_minute": _current_minute,
|
||||
&"period_start": _period_start,
|
||||
&"pending_units": _canonical_units(_pending_units),
|
||||
&"closed_periods": _closed_periods.duplicate(true),
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(source: Dictionary) -> bool:
|
||||
# Parse and cross-check the complete candidate before mutating live state.
|
||||
if not _exact_keys(source, ["version", "started_at", "current_minute", "period_start", "pending_units", "closed_periods"]):
|
||||
return false
|
||||
for key: String in ["version", "started_at", "current_minute", "period_start"]:
|
||||
if typeof(source[key]) != TYPE_INT:
|
||||
return false
|
||||
if source["version"] != SNAPSHOT_VERSION or not source["closed_periods"] is Array:
|
||||
return false
|
||||
var started := int(source["started_at"])
|
||||
var current := int(source["current_minute"])
|
||||
var period_start := int(source["period_start"])
|
||||
var raw_periods: Array = source["closed_periods"]
|
||||
if raw_periods.size() > MAX_PERIODS:
|
||||
return false
|
||||
if started == -1:
|
||||
if current != -1 or period_start != -1 or not raw_periods.is_empty() \
|
||||
or not source["pending_units"] is Dictionary or not source["pending_units"].is_empty():
|
||||
return false
|
||||
elif started < 0 or current < started or current > MAX_MINUTE \
|
||||
or period_start < started or period_start > current \
|
||||
or period_start / MINUTES_PER_DAY != current / MINUTES_PER_DAY:
|
||||
return false
|
||||
var periods: Array[Dictionary] = []
|
||||
var expected_start := started
|
||||
for raw: Variant in raw_periods:
|
||||
if not raw is Dictionary or not _exact_keys(raw, ["period_start", "period_end", "facility_units"]):
|
||||
return false
|
||||
if typeof(raw["period_start"]) != TYPE_INT or typeof(raw["period_end"]) != TYPE_INT:
|
||||
return false
|
||||
var end := int(raw["period_end"])
|
||||
if raw["period_start"] != expected_start or end > current \
|
||||
or end != (expected_start / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY:
|
||||
return false
|
||||
var units := _parse_units(raw["facility_units"], MAX_DAILY_RATE * (end - expected_start), false)
|
||||
if not units[&"ok"]:
|
||||
return false
|
||||
periods.append({&"period_start": expected_start, &"period_end": end, &"facility_units": units[&"value"]})
|
||||
expected_start = end
|
||||
if expected_start != period_start:
|
||||
return false
|
||||
var pending := _parse_units(source["pending_units"], MAX_DAILY_RATE * maxi(current - period_start, 0), false)
|
||||
if not pending[&"ok"]:
|
||||
return false
|
||||
_started_at = started
|
||||
_current_minute = current
|
||||
_period_start = period_start
|
||||
_pending_units = pending[&"value"]
|
||||
_closed_periods = periods
|
||||
return true
|
||||
|
||||
|
||||
static func _parse_units(value: Variant, maximum: int, allow_zero := true) -> Dictionary:
|
||||
if not value is Dictionary or value.size() > MAX_FACILITIES:
|
||||
return {&"ok": false}
|
||||
var result: Dictionary = {}
|
||||
for raw_id: Variant in value:
|
||||
if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return {&"ok": false}
|
||||
var id := String(raw_id)
|
||||
if id.is_empty() or id.length() > 128 or result.has(id):
|
||||
return {&"ok": false}
|
||||
var amount: Variant = value[raw_id]
|
||||
if typeof(amount) != TYPE_INT or amount < (0 if allow_zero else 1) or amount > maximum:
|
||||
return {&"ok": false}
|
||||
result[id] = amount
|
||||
return {&"ok": true, &"value": _canonical_units(result)}
|
||||
|
||||
|
||||
static func _canonical_units(source: Dictionary) -> Dictionary:
|
||||
var keys := source.keys()
|
||||
keys.sort()
|
||||
var result: Dictionary = {}
|
||||
for key: Variant in keys:
|
||||
result[String(key)] = source[key]
|
||||
return result
|
||||
|
||||
|
||||
static func _exact_keys(value: Dictionary, expected: Array) -> bool:
|
||||
if value.size() != expected.size():
|
||||
return false
|
||||
for key: Variant in value:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected:
|
||||
return false
|
||||
for key: String in expected:
|
||||
if not value.has(key):
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _failure(reason: StringName) -> Dictionary:
|
||||
return {&"ok": false, &"reason": reason}
|
||||
@@ -0,0 +1 @@
|
||||
uid://df3eypvgf3f2v
|
||||
@@ -0,0 +1,252 @@
|
||||
class_name FacilityUpkeepSettlement
|
||||
extends RefCounted
|
||||
|
||||
## Settles exact accrued upkeep against the existing balanced ledger.
|
||||
## Whole gold is recognized as expense even when cash is insufficient. Sub-gold
|
||||
## fractions carry forward; arrears survive the removal of the owning facility.
|
||||
## The caller publishes the returned transaction notification after committing
|
||||
## its wider Runtime transaction (as with payroll).
|
||||
const Accrual = preload("res://scripts/economy/facility_upkeep_accrual.gd")
|
||||
const VERSION := 1
|
||||
const CASH: StringName = &"PLAYER_CASH"
|
||||
const EXPENSE: StringName = &"MAINTENANCE_EXPENSE"
|
||||
const PAYABLE: StringName = &"MAINTENANCE_PAYABLE"
|
||||
|
||||
var _receipts: Array[Dictionary] = []
|
||||
var _arrears: Dictionary = {}
|
||||
var _fractions: Dictionary = {}
|
||||
|
||||
|
||||
static func prepare_accounts(economy: EconomyService) -> bool:
|
||||
if economy == null or not economy.has_account(CASH):
|
||||
return false
|
||||
# Reject incompatible accounts before registering anything.
|
||||
if economy.has_account(EXPENSE) and economy.account_allows_negative(EXPENSE):
|
||||
return false
|
||||
if economy.has_account(PAYABLE) and not economy.account_allows_negative(PAYABLE):
|
||||
return false
|
||||
if not economy.has_account(EXPENSE):
|
||||
economy.register_account(EXPENSE)
|
||||
if not economy.has_account(PAYABLE):
|
||||
economy.register_account(PAYABLE, true)
|
||||
return true
|
||||
|
||||
|
||||
func settle(period: Dictionary, economy: EconomyService) -> Dictionary:
|
||||
var canonical := _parse_period(period)
|
||||
if canonical.is_empty():
|
||||
return _failure(&"INVALID_PERIOD")
|
||||
for saved: Dictionary in _receipts:
|
||||
if saved.period.period_end == canonical.period_end:
|
||||
if saved.period != canonical or not validate_ledger(economy):
|
||||
return _failure(&"REPLAY_MISMATCH")
|
||||
return {&"ok": true, &"replayed": true, &"receipt": saved.duplicate(true), &"transaction_id": &""}
|
||||
if not _receipts.is_empty() and canonical.period_start != _receipts[-1].period.period_end:
|
||||
return _failure(&"PERIOD_GAP_OR_REWIND")
|
||||
if _receipts.size() >= Accrual.MAX_PERIODS:
|
||||
return _failure(&"TOO_MANY_PERIODS")
|
||||
if not validate_ledger(economy):
|
||||
return _failure(&"LEDGER_MISMATCH")
|
||||
var plan := _plan(canonical, economy.get_balance(CASH))
|
||||
if not plan[&"ok"]:
|
||||
return plan
|
||||
var entries: Array = _entries(canonical, plan.rows)
|
||||
var transaction_id: StringName = &""
|
||||
if not entries.is_empty():
|
||||
var result := economy.post(entries, StringName("upkeep.period.%d" % int(canonical.period_end)), &"", false)
|
||||
if not result.is_success():
|
||||
return _failure(&"LEDGER_POST_FAILED")
|
||||
transaction_id = StringName(result.details[&"transaction_id"])
|
||||
# No fallible mutation follows the single, atomic ledger post.
|
||||
_arrears = plan.arrears
|
||||
_fractions = plan.fractions
|
||||
var receipt := {&"period": canonical, &"rows": plan.rows, &"transaction_id": String(transaction_id)}
|
||||
_receipts.append(receipt)
|
||||
return {&"ok": true, &"replayed": false, &"receipt": receipt.duplicate(true), &"transaction_id": transaction_id}
|
||||
|
||||
|
||||
func get_arrears_through(minute: int) -> int:
|
||||
var low := 0
|
||||
var high := _receipts.size()
|
||||
while low < high:
|
||||
var middle := (low + high) / 2
|
||||
if int(_receipts[middle][&"period"][&"period_end"]) <= minute:
|
||||
low = middle + 1
|
||||
else:
|
||||
high = middle
|
||||
if low == 0:
|
||||
return 0
|
||||
var amount := 0
|
||||
for row: Dictionary in _receipts[low - 1][&"rows"]:
|
||||
amount += int(row[&"arrears"])
|
||||
return amount
|
||||
|
||||
|
||||
func get_facility_balance(facility_id: StringName) -> Dictionary:
|
||||
var id := String(facility_id)
|
||||
return {&"arrears": int(_arrears.get(id, 0)), &"fraction_units": int(_fractions.get(id, 0))}
|
||||
|
||||
|
||||
func get_settled_period_count() -> int:
|
||||
return _receipts.size()
|
||||
|
||||
|
||||
func get_arrears_total() -> int:
|
||||
var debt := 0
|
||||
for amount: int in _arrears.values():
|
||||
debt += amount
|
||||
return debt
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
return {&"arrears_total": get_arrears_total(), &"arrears_by_facility": _arrears.duplicate(),
|
||||
&"fraction_units": _fractions.duplicate(), &"gold_denominator": Accrual.MINUTES_PER_DAY,
|
||||
&"receipts": _receipts.duplicate(true)}
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
# Derived balances are reconstructed, not trusted from the save file.
|
||||
return {&"version": VERSION, &"receipts": _receipts.duplicate(true)}
|
||||
|
||||
|
||||
func restore_snapshot(source: Dictionary, economy: EconomyService) -> bool:
|
||||
if not Accrual._exact_keys(source, ["version", "receipts"]) \
|
||||
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
|
||||
or not source["receipts"] is Array or source["receipts"].size() > Accrual.MAX_PERIODS:
|
||||
return false
|
||||
var candidate := FacilityUpkeepSettlement.new()
|
||||
for raw: Variant in source["receipts"]:
|
||||
if not raw is Dictionary or not Accrual._exact_keys(raw, ["period", "rows", "transaction_id"]) \
|
||||
or not raw["period"] is Dictionary or not raw["rows"] is Array \
|
||||
or typeof(raw["transaction_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
var period := _parse_period(raw["period"])
|
||||
if period.is_empty() or (not candidate._receipts.is_empty() \
|
||||
and period.period_start != candidate._receipts[-1].period.period_end):
|
||||
return false
|
||||
var budget := 0
|
||||
for row: Variant in raw["rows"]:
|
||||
if not row is Dictionary or not Accrual._exact_keys(row, ["facility_id", "new_expense", "paid", "arrears", "fraction_units"]) \
|
||||
or typeof(row["facility_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
for field: String in ["new_expense", "paid", "arrears", "fraction_units"]:
|
||||
if typeof(row[field]) != TYPE_INT or row[field] < 0 or row[field] > 9000000000000000:
|
||||
return false
|
||||
if budget > 9000000000000000 - int(row["paid"]):
|
||||
return false
|
||||
budget += int(row["paid"])
|
||||
var plan := candidate._plan(period, budget)
|
||||
if not plan[&"ok"] or plan.rows != raw["rows"]:
|
||||
return false
|
||||
candidate._arrears = plan.arrears
|
||||
candidate._fractions = plan.fractions
|
||||
candidate._receipts.append({&"period": period, &"rows": plan.rows, &"transaction_id": String(raw["transaction_id"])})
|
||||
if not candidate.validate_ledger(economy):
|
||||
return false
|
||||
_receipts = candidate._receipts
|
||||
_arrears = candidate._arrears
|
||||
_fractions = candidate._fractions
|
||||
return true
|
||||
|
||||
|
||||
func validate_ledger(economy: EconomyService) -> bool:
|
||||
if economy == null or not economy.has_account(CASH) or not economy.has_account(EXPENSE) \
|
||||
or not economy.has_account(PAYABLE) or economy.account_allows_negative(EXPENSE) \
|
||||
or not economy.account_allows_negative(PAYABLE):
|
||||
return false
|
||||
var expected_expense := 0
|
||||
var expected_debt := 0
|
||||
var owned_transactions: Dictionary = {}
|
||||
for receipt: Dictionary in _receipts:
|
||||
var expected: Array = _entries(receipt.period, receipt.rows)
|
||||
var transaction := StringName(receipt.transaction_id)
|
||||
if economy.get_transaction_id_for_idempotency_key(StringName("upkeep.period.%d" % int(receipt.period.period_end))) != transaction:
|
||||
return false
|
||||
if expected.is_empty():
|
||||
if not transaction.is_empty():
|
||||
return false
|
||||
continue
|
||||
if transaction.is_empty() or owned_transactions.has(transaction):
|
||||
return false
|
||||
owned_transactions[transaction] = true
|
||||
var actual := economy.get_transaction_entries(transaction)
|
||||
if actual.size() != expected.size():
|
||||
return false
|
||||
for i in range(expected.size()):
|
||||
var a: LedgerEntry = actual[i]
|
||||
var e: LedgerEntry = expected[i]
|
||||
if a.amount != e.amount or a.absolute_minute != e.absolute_minute or a.category != e.category \
|
||||
or a.debit_account != e.debit_account or a.credit_account != e.credit_account \
|
||||
or a.source_entity_id != e.source_entity_id or a.memo_key != e.memo_key \
|
||||
or not a.related_transaction_id.is_empty():
|
||||
return false
|
||||
for row: Dictionary in receipt.rows:
|
||||
expected_expense += int(row.new_expense)
|
||||
for value: int in _arrears.values():
|
||||
expected_debt += value
|
||||
for entry: LedgerEntry in economy.get_entries():
|
||||
if entry.debit_account in [EXPENSE, PAYABLE] or entry.credit_account in [EXPENSE, PAYABLE]:
|
||||
if not owned_transactions.has(entry.transaction_id):
|
||||
return false
|
||||
return economy.get_balance(EXPENSE) == expected_expense and economy.get_balance(PAYABLE) == -expected_debt
|
||||
|
||||
|
||||
func _plan(period: Dictionary, cash: int) -> Dictionary:
|
||||
if cash < 0:
|
||||
return _failure(&"INVALID_CASH")
|
||||
var ids: Dictionary = {}
|
||||
for source: Dictionary in [_arrears, _fractions, period.facility_units]:
|
||||
for id: String in source:
|
||||
ids[id] = true
|
||||
if ids.size() > Accrual.MAX_FACILITIES:
|
||||
return _failure(&"TOO_MANY_FACILITIES")
|
||||
var ordered := ids.keys()
|
||||
ordered.sort()
|
||||
var rows: Array[Dictionary] = []
|
||||
var arrears: Dictionary = {}
|
||||
var fractions: Dictionary = {}
|
||||
for id: String in ordered:
|
||||
var units := int(period.facility_units.get(id, 0)) + int(_fractions.get(id, 0))
|
||||
var expense := units / Accrual.MINUTES_PER_DAY
|
||||
var fraction := units % Accrual.MINUTES_PER_DAY
|
||||
var debt := int(_arrears.get(id, 0)) + expense
|
||||
var paid := mini(debt, cash)
|
||||
cash -= paid
|
||||
if debt > paid:
|
||||
arrears[id] = debt - paid
|
||||
if fraction > 0:
|
||||
fractions[id] = fraction
|
||||
rows.append({&"facility_id": id, &"new_expense": expense, &"paid": paid,
|
||||
&"arrears": debt - paid, &"fraction_units": fraction})
|
||||
return {&"ok": true, &"rows": rows, &"arrears": arrears, &"fractions": fractions}
|
||||
|
||||
|
||||
static func _parse_period(source: Dictionary) -> Dictionary:
|
||||
if not Accrual._exact_keys(source, ["period_start", "period_end", "facility_units"]) \
|
||||
or typeof(source["period_start"]) != TYPE_INT or typeof(source["period_end"]) != TYPE_INT:
|
||||
return {}
|
||||
var start := int(source["period_start"])
|
||||
var end := int(source["period_end"])
|
||||
if start < 0 or end > Accrual.MAX_MINUTE or end != (start / 1440 + 1) * 1440:
|
||||
return {}
|
||||
var units := Accrual._parse_units(source["facility_units"], Accrual.MAX_DAILY_RATE * (end - start), false)
|
||||
if not units[&"ok"]:
|
||||
return {}
|
||||
return {&"period_start": start, &"period_end": end, &"facility_units": units[&"value"]}
|
||||
|
||||
|
||||
static func _entries(period: Dictionary, rows: Array) -> Array:
|
||||
var entries: Array = []
|
||||
for row: Dictionary in rows:
|
||||
var id := StringName(row.facility_id)
|
||||
if int(row.new_expense) > 0:
|
||||
entries.append(LedgerEntry.new().configure(&"", int(period.period_end) - 1,
|
||||
EXPENSE, PAYABLE, int(row.new_expense), &"MAINTENANCE_ACCRUAL", id, &"", &"facility.upkeep.accrued"))
|
||||
if int(row.paid) > 0:
|
||||
entries.append(LedgerEntry.new().configure(&"", int(period.period_end),
|
||||
PAYABLE, CASH, int(row.paid), &"MAINTENANCE_PAYMENT", id, &"", &"facility.upkeep.paid"))
|
||||
return entries
|
||||
|
||||
|
||||
static func _failure(reason: StringName) -> Dictionary:
|
||||
return {&"ok": false, &"reason": reason}
|
||||
@@ -0,0 +1 @@
|
||||
uid://b3e5slv6c8nb4
|
||||
@@ -0,0 +1,115 @@
|
||||
class_name FacilityCleaningBridge
|
||||
extends RefCounted
|
||||
|
||||
## Jobs live in StaffService and their effects in FacilityCleanlinessService.
|
||||
## No independent clock, movement, or hidden completion timer.
|
||||
const TASK_TYPE := &"facility_clean"
|
||||
var _runtime: Variant
|
||||
|
||||
func configure(runtime: Variant) -> void:
|
||||
_runtime = runtime
|
||||
|
||||
static func is_deployed(worker: StaffRuntime, room: FacilityInstance, staff: StaffService) -> bool:
|
||||
if worker == null or room == null or room.definition_id != &"cleaning_room" or worker.role_id != &"cleaner" or worker.assigned_facility_id != room.instance_id or not worker.can_take_shift(): return false
|
||||
var task := staff.get_task(worker.current_task_id)
|
||||
return task != null and task.task_type == TASK_TYPE and not task.is_terminal() and task.assigned_staff_id == worker.staff_id and worker.state in [StaffRuntime.State.MOVING_TO_TASK, StaffRuntime.State.WORKING]
|
||||
|
||||
func can_dispatch(worker: StaffRuntime) -> bool:
|
||||
if worker == null or worker.role_id != &"cleaner": return false
|
||||
var room: FacilityInstance = _runtime.facility_service.get_instance(worker.assigned_facility_id)
|
||||
return room != null and room.definition_id == &"cleaning_room" and room.operation_state == FacilityInstance.OperationState.OPERATING and _runtime.staff_commute.is_present(worker, room)
|
||||
|
||||
func task_can_work(task: StaffTask, worker: StaffRuntime) -> bool:
|
||||
if task == null or worker == null or not is_deployed(worker, _runtime.facility_service.get_instance(worker.assigned_facility_id), _runtime.staff_service): return false
|
||||
if task.state == StaffTask.State.WORKING and (worker.current_cell != task.target_cell or not _runtime.path_service.has_cell(task.target_cell) or _runtime.is_staff_in_transit(worker.staff_id)): return false
|
||||
var room: FacilityInstance = _runtime.facility_service.get_instance(worker.assigned_facility_id)
|
||||
return room.operation_state == FacilityInstance.OperationState.OPERATING and _runtime.facility_service.has_instance(task.target_entity_id) and not _runtime.facility_cleanliness.get_facility(task.target_entity_id).is_empty()
|
||||
|
||||
func tick(minute: int) -> void:
|
||||
var pending: Dictionary = {}
|
||||
for task: StaffTask in _runtime.staff_service.get_tasks():
|
||||
if task.task_type != TASK_TYPE or task.is_terminal(): continue
|
||||
var worker: StaffRuntime = _runtime.staff_service.get_staff(task.assigned_staff_id)
|
||||
if not _runtime.facility_service.has_instance(task.target_entity_id) or (worker != null and not task_can_work(task, worker)):
|
||||
_runtime.staff_service.cancel_task(task.task_id, &"CLEANING_UNAVAILABLE", minute)
|
||||
continue
|
||||
if pending.has(task.target_entity_id):
|
||||
_runtime.staff_service.cancel_task(task.task_id, &"DUPLICATE_CLEANING", minute)
|
||||
continue
|
||||
if task.is_dispatchable():
|
||||
for candidate: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
if can_dispatch(candidate) and prepare_route(task, candidate): break
|
||||
pending[task.target_entity_id] = true
|
||||
var rules: Dictionary = _runtime.cleaning_policy.create_snapshot()
|
||||
for id: StringName in _runtime.facility_cleanliness.get_dirty_facility_ids(rules["clean_at_or_below"]):
|
||||
if pending.has(id): continue
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(id)
|
||||
var cells := access_cells(facility, _runtime.path_service)
|
||||
if cells.is_empty(): continue
|
||||
_runtime.staff_service.create_task(TASK_TYPE, 50, minute, {
|
||||
&"target_entity_id": id, &"target_cell": cells[0],
|
||||
&"required_role_tags": [&"cleaner"], &"task_tags": [&"cleaning"],
|
||||
&"estimated_minutes": rules["work_minutes"], &"interrupt_level": 0,
|
||||
&"preserve_progress_on_interrupt": false,
|
||||
})
|
||||
|
||||
static func access_cells(facility: FacilityInstance, path: PathService, walkable_only := true) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
if facility == null: return result
|
||||
var occupied := facility.occupied_cells.duplicate()
|
||||
if occupied.is_empty(): occupied.append(facility.origin)
|
||||
for cell: Vector3i in occupied:
|
||||
for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]:
|
||||
var candidate := cell + offset
|
||||
if (not walkable_only or path.has_cell(candidate)) and not result.has(candidate): result.append(candidate)
|
||||
result.sort_custom(func(a: Vector3i,b: Vector3i)->bool:
|
||||
if a.z != b.z: return a.z < b.z
|
||||
if a.y != b.y: return a.y < b.y
|
||||
return a.x < b.x)
|
||||
return result
|
||||
|
||||
func completed(task: StaffTask, worker: StaffRuntime) -> bool:
|
||||
if task == null or worker == null or task.state != StaffTask.State.COMPLETED or worker.current_cell != task.target_cell: return false
|
||||
return _runtime.facility_cleanliness.record_cleaning(task.target_entity_id, task.task_id, _runtime.cleaning_policy.create_snapshot()["recovery_per_completed_task"])
|
||||
|
||||
func status(facility_id: StringName) -> String:
|
||||
for task: StaffTask in _runtime.staff_service.get_tasks():
|
||||
if task.task_type != TASK_TYPE or task.target_entity_id != facility_id or task.is_terminal(): continue
|
||||
if task.state == StaffTask.State.WORKING:
|
||||
var rate := work_units_per_minute(_runtime.staff_service.get_staff(task.assigned_staff_id))
|
||||
if rate > 1:
|
||||
return "清掃中(%d倍速・残り%d分)" % [rate,ceili(float(task.estimated_minutes-task.progress_minutes)/rate)]
|
||||
return "清掃中 %d/%d分" % [task.progress_minutes,task.estimated_minutes]
|
||||
if task.state in [StaffTask.State.MOVING,StaffTask.State.RESERVED]: return "清掃員が移動中"
|
||||
return "清掃待ち(清掃員室・配置・経路を確認)"
|
||||
var row: Dictionary = _runtime.facility_cleanliness.get_facility(facility_id)
|
||||
if not row.is_empty() and row["current"] <= _runtime.cleaning_policy.create_snapshot()["clean_at_or_below"]: return "清掃待ち(作業経路を確認)"
|
||||
return ""
|
||||
|
||||
|
||||
## Preserve a reachable reserved destination; reselect only if it is cut off.
|
||||
## A moving cleaner waits in place if every access is isolated, never teleports.
|
||||
func prepare_route(task: StaffTask, worker: StaffRuntime) -> bool:
|
||||
if task == null or worker == null: return false
|
||||
var facility: FacilityInstance = _runtime.facility_service.get_instance(task.target_entity_id)
|
||||
var cells := access_cells(facility, _runtime.path_service)
|
||||
if task.target_cell in cells and not _runtime.path_service.find_path(worker.current_cell, task.target_cell, [0,1], [&"staff"]).is_empty(): return true
|
||||
var best: Variant = null
|
||||
var best_cost := 9223372036854775807
|
||||
for cell: Vector3i in cells:
|
||||
var route: Array[Vector3i] = _runtime.path_service.find_path(worker.current_cell, cell, [0,1], [&"staff"])
|
||||
if route.is_empty(): continue
|
||||
var cost: int = _runtime.path_service.calculate_path_cost(route, [&"staff"])
|
||||
if cost >= 0 and cost < best_cost:
|
||||
best = cell
|
||||
best_cost = cost
|
||||
if best == null: return false
|
||||
task.target_cell = best
|
||||
return true
|
||||
|
||||
## Original 6.2: Fine cleans three times as fast. Task progress remains work
|
||||
## units, so an eight-unit task completes on the third one-minute tick.
|
||||
static func work_units_per_minute(worker: StaffRuntime) -> int:
|
||||
if worker != null and worker.staff_id == &"heroine_fine" and worker.role_id == &"cleaner" and worker.employment_kind == StaffRuntime.EmploymentKind.UNIQUE:
|
||||
return 3
|
||||
return 1
|
||||
@@ -0,0 +1 @@
|
||||
uid://cuqm1vvtndcsg
|
||||
@@ -0,0 +1,36 @@
|
||||
class_name FacilityCleaningPolicy
|
||||
extends RefCounted
|
||||
|
||||
## Approved trial values live in data, never in scheduler defaults.
|
||||
var last_error := ""
|
||||
var _values: Dictionary = {}
|
||||
|
||||
func load_file(path: String = "res://data/environment/cleaning_policy.json") -> bool:
|
||||
var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path))
|
||||
if not parsed is Dictionary:
|
||||
last_error = "Cleaning policy must be a JSON object"
|
||||
return false
|
||||
return configure(parsed)
|
||||
|
||||
func configure(values: Dictionary) -> bool:
|
||||
var keys := ["schema_version", "initial_cleanliness", "maximum_cleanliness", "clean_at_or_below", "work_minutes", "recovery_per_completed_task"]
|
||||
if values.size() != keys.size():
|
||||
last_error = "Unexpected cleaning policy fields"
|
||||
return false
|
||||
var candidate: Dictionary = {}
|
||||
for key: String in keys:
|
||||
var value: Variant = values.get(key)
|
||||
# JSON numbers arrive as floats; accept only finite exact small integers.
|
||||
if typeof(value) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(value)) or float(value) != floor(float(value)) or float(value) < 0 or float(value) > 2147483647:
|
||||
last_error = "Invalid cleaning policy integer: " + key
|
||||
return false
|
||||
candidate[key] = int(value)
|
||||
if candidate["schema_version"] != 1 or candidate["maximum_cleanliness"] <= 0 or candidate["initial_cleanliness"] > candidate["maximum_cleanliness"] or candidate["clean_at_or_below"] >= candidate["maximum_cleanliness"] or candidate["work_minutes"] <= 0 or candidate["recovery_per_completed_task"] <= 0:
|
||||
last_error = "Invalid cleaning policy bounds"
|
||||
return false
|
||||
_values = candidate
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return _values.duplicate(true)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cxaxvqpfyjfkc
|
||||
@@ -0,0 +1,123 @@
|
||||
class_name FacilityCleanlinessService
|
||||
extends RefCounted
|
||||
|
||||
## Parameterized cleanliness state. Runtime supplies approved initial/max values
|
||||
## and completed-use/task IDs; this model invents no operating balance values.
|
||||
var last_error := ""
|
||||
var _facilities: Dictionary = {}
|
||||
var _receipts: Array[Dictionary] = []
|
||||
var _receipt_index: Dictionary = {}
|
||||
|
||||
func register_facility(id: StringName, initial: int, maximum: int) -> bool:
|
||||
if id.is_empty() or _facilities.has(id) or maximum <= 0 or initial < 0 or initial > maximum:
|
||||
return _fail("Invalid or duplicate cleanliness facility")
|
||||
_facilities[id] = {"id": String(id), "initial": initial, "maximum": maximum, "current": initial}
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
func unregister_facility(id: StringName) -> bool:
|
||||
if not _facilities.has(id): return _fail("Unknown cleanliness facility")
|
||||
_facilities.erase(id)
|
||||
var retained: Array[Dictionary] = []
|
||||
_receipt_index.clear()
|
||||
for receipt: Dictionary in _receipts:
|
||||
if StringName(receipt["facility_id"]) != id:
|
||||
retained.append(receipt)
|
||||
_receipt_index[_receipt_key(receipt)] = receipt
|
||||
_receipts = retained
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
func record_use(id: StringName, service_id: StringName, soil_amount: int) -> bool:
|
||||
return _apply(id, service_id, soil_amount, "use")
|
||||
|
||||
func record_cleaning(id: StringName, task_id: StringName, recovery_amount: int) -> bool:
|
||||
return _apply(id, task_id, recovery_amount, "clean")
|
||||
|
||||
func get_facility(id: StringName) -> Dictionary:
|
||||
return (_facilities.get(id, {}) as Dictionary).duplicate(true)
|
||||
|
||||
func get_dirty_facility_ids(threshold: int) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for id: StringName in _facilities:
|
||||
if int(_facilities[id]["current"]) <= threshold:
|
||||
result.append(id)
|
||||
result.sort_custom(_id_before)
|
||||
return result
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var rows: Array[Dictionary] = []
|
||||
var ids: Array = _facilities.keys()
|
||||
ids.sort_custom(_id_before)
|
||||
for id: StringName in ids: rows.append(_facilities[id].duplicate(true))
|
||||
return {"schema_version": 1, "facilities": rows, "receipts": _receipts.duplicate(true)}
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if not _keys(snapshot, ["schema_version", "facilities", "receipts"]) or typeof(snapshot.get("schema_version")) != TYPE_INT or snapshot["schema_version"] != 1:
|
||||
return _fail("Unsupported cleanliness snapshot")
|
||||
if not snapshot["facilities"] is Array or not snapshot["receipts"] is Array:
|
||||
return _fail("Invalid cleanliness collections")
|
||||
var candidate := FacilityCleanlinessService.new()
|
||||
var expected: Dictionary = {}
|
||||
for value: Variant in snapshot["facilities"]:
|
||||
if not value is Dictionary or not _keys(value, ["id", "initial", "maximum", "current"]):
|
||||
return _fail("Invalid cleanliness facility row")
|
||||
if typeof(value["id"]) != TYPE_STRING or typeof(value["initial"]) != TYPE_INT or typeof(value["maximum"]) != TYPE_INT or typeof(value["current"]) != TYPE_INT:
|
||||
return _fail("Invalid cleanliness facility value types")
|
||||
var id := StringName(value["id"])
|
||||
if not candidate.register_facility(id, value["initial"], value["maximum"]):
|
||||
return _fail(candidate.last_error)
|
||||
expected[id] = value["current"]
|
||||
for value: Variant in snapshot["receipts"]:
|
||||
if not value is Dictionary or not _keys(value, ["facility_id", "source_id", "kind", "amount"]):
|
||||
return _fail("Invalid cleanliness receipt")
|
||||
if typeof(value["facility_id"]) != TYPE_STRING or typeof(value["source_id"]) != TYPE_STRING or typeof(value["kind"]) != TYPE_STRING or typeof(value["amount"]) != TYPE_INT:
|
||||
return _fail("Invalid cleanliness receipt value types")
|
||||
if candidate._receipt_index.has(_receipt_key(value)) or not candidate._apply(StringName(value["facility_id"]), StringName(value["source_id"]), value["amount"], value["kind"]):
|
||||
return _fail("Invalid, duplicated or dangling cleanliness receipt")
|
||||
for id: StringName in expected:
|
||||
if candidate._facilities[id]["current"] != expected[id]:
|
||||
return _fail("Cleanliness value does not match its completed work")
|
||||
_facilities = candidate._facilities
|
||||
_receipts = candidate._receipts
|
||||
_receipt_index = candidate._receipt_index
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
func _apply(id: StringName, source_id: StringName, amount: int, kind: String) -> bool:
|
||||
if not _facilities.has(id) or source_id.is_empty() or amount < 0 or kind not in ["use", "clean"]:
|
||||
return _fail("Invalid cleanliness effect")
|
||||
var receipt := {"facility_id": String(id), "source_id": String(source_id), "kind": kind, "amount": amount}
|
||||
var key := _receipt_key(receipt)
|
||||
if _receipt_index.has(key):
|
||||
if _receipt_index[key] != receipt: return _fail("Conflicting cleanliness completion replay")
|
||||
last_error = ""
|
||||
return true
|
||||
var row: Dictionary = _facilities[id]
|
||||
# Clamp the operand before addition/subtraction to avoid int overflow.
|
||||
if kind == "use": row["current"] -= mini(amount, int(row["current"]))
|
||||
else: row["current"] += mini(amount, int(row["maximum"]) - int(row["current"]))
|
||||
_receipts.append(receipt)
|
||||
_receipt_index[key] = receipt
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
static func _receipt_key(receipt: Dictionary) -> String:
|
||||
return String(receipt["kind"]) + ":" + String(receipt["source_id"])
|
||||
|
||||
static func _keys(value: Dictionary, expected: Array) -> bool:
|
||||
if value.size() != expected.size(): return false
|
||||
for key: Variant in value:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: return false
|
||||
for key: String in expected:
|
||||
if not value.has(key): return false
|
||||
return true
|
||||
|
||||
func _fail(message: String) -> bool:
|
||||
last_error = message
|
||||
return false
|
||||
|
||||
# StringName's built-in ordering is process-local. Persisted row ordering and
|
||||
# dispatch priority must instead use the stable textual facility identifier.
|
||||
static func _id_before(a: StringName, b: StringName) -> bool:
|
||||
return String(a) < String(b)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt4s42nme54s1
|
||||
@@ -0,0 +1,94 @@
|
||||
extends RefCounted
|
||||
|
||||
## Weather state and scheduled transitions. The caller owns generation policy;
|
||||
## this model invents neither probabilities nor rain/fog balance multipliers.
|
||||
const VERSION := 1
|
||||
const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"]
|
||||
const MAX_MINUTE := 2147483647
|
||||
const MAX_PENDING := 4096
|
||||
var _minute := -1
|
||||
var _kind: StringName = &""
|
||||
var _since := -1
|
||||
var _pending: Array[Dictionary] = []
|
||||
|
||||
func initialize(kind: Variant, minute: Variant) -> bool:
|
||||
if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute):
|
||||
return false
|
||||
_minute = minute
|
||||
_since = minute
|
||||
_kind = StringName(kind)
|
||||
return true
|
||||
|
||||
func get_kind() -> StringName:
|
||||
return _kind
|
||||
|
||||
func get_minute() -> int:
|
||||
return _minute
|
||||
|
||||
func permits_new_arrivals() -> bool:
|
||||
return _minute >= 0 and _kind != &"storm"
|
||||
|
||||
func schedule(kind: Variant, minute: Variant) -> Dictionary:
|
||||
if _minute < 0 or not _valid_kind(kind) or not _valid_minute(minute) or minute <= _minute:
|
||||
return {&"ok": false, &"code": &"INVALID_TRANSITION"}
|
||||
for row: Dictionary in _pending:
|
||||
if row[&"minute"] == minute:
|
||||
return {&"ok": row[&"kind"] == StringName(kind), &"replayed": row[&"kind"] == StringName(kind)}
|
||||
if _pending.size() >= MAX_PENDING:
|
||||
return {&"ok": false, &"code": &"SCHEDULE_FULL"}
|
||||
_pending.append({&"kind": StringName(kind), &"minute": minute})
|
||||
_pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"])
|
||||
return {&"ok": true, &"replayed": false}
|
||||
|
||||
func advance_to(minute: Variant) -> Dictionary:
|
||||
if _minute < 0 or not _valid_minute(minute) or minute < _minute:
|
||||
return {&"ok": false, &"code": &"INVALID_TIME"}
|
||||
var changes: Array[Dictionary] = []
|
||||
var replayed: bool = minute == _minute
|
||||
while not _pending.is_empty() and int(_pending[0][&"minute"]) <= minute:
|
||||
var transition: Dictionary = _pending.pop_front()
|
||||
if transition[&"kind"] == _kind:
|
||||
continue
|
||||
changes.append({&"from": _kind, &"to": transition[&"kind"], &"minute": transition[&"minute"]})
|
||||
_kind = transition[&"kind"]
|
||||
_since = transition[&"minute"]
|
||||
_minute = minute
|
||||
return {&"ok": true, &"replayed": replayed, &"changes": changes}
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {&"version": VERSION, &"minute": _minute, &"kind": _kind,
|
||||
&"since": _since, &"pending": _pending.duplicate(true)}
|
||||
|
||||
func restore_snapshot(source: Dictionary) -> bool:
|
||||
if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \
|
||||
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
|
||||
or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \
|
||||
or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \
|
||||
or not source["pending"] is Array or source["pending"].size() > MAX_PENDING:
|
||||
return false
|
||||
var previous: int = source["minute"]
|
||||
var pending: Array[Dictionary] = []
|
||||
for value: Variant in source["pending"]:
|
||||
if not value is Dictionary or not _keys(value, ["kind", "minute"]) \
|
||||
or not _valid_kind(value["kind"]) or not _valid_minute(value["minute"]) \
|
||||
or value["minute"] <= previous:
|
||||
return false
|
||||
previous = value["minute"]
|
||||
pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]})
|
||||
_minute = source["minute"]
|
||||
_kind = StringName(source["kind"])
|
||||
_since = source["since"]
|
||||
_pending = pending
|
||||
return true
|
||||
|
||||
static func _valid_kind(value: Variant) -> bool:
|
||||
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS
|
||||
|
||||
static func _valid_minute(value: Variant) -> bool:
|
||||
return typeof(value) == TYPE_INT and value >= 0 and value <= MAX_MINUTE
|
||||
|
||||
static func _keys(source: Dictionary, expected: Array) -> bool:
|
||||
if source.size() != expected.size(): return false
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: return false
|
||||
return true
|
||||
@@ -0,0 +1 @@
|
||||
uid://bihm0hldfc1im
|
||||
@@ -13,6 +13,7 @@ enum Type {
|
||||
TELEPORT_ENTITY_FOR_RECOVERY,
|
||||
SCHEDULE_EVENT,
|
||||
SET_ROUTE_LOCK,
|
||||
CREATE_DEFENSE_RAID,
|
||||
}
|
||||
|
||||
var action_id: StringName = &""
|
||||
@@ -32,7 +33,7 @@ func configure(
|
||||
|
||||
|
||||
func is_valid() -> bool:
|
||||
return not action_id.is_empty() and type >= Type.SET_FLAG and type <= Type.SET_ROUTE_LOCK
|
||||
return not action_id.is_empty() and type >= Type.SET_FLAG and type <= Type.CREATE_DEFENSE_RAID
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
@@ -48,6 +49,18 @@ static func from_variant(raw_value: Variant) -> EventAction:
|
||||
return raw_value
|
||||
if not raw_value is Dictionary:
|
||||
return null
|
||||
# Serialized action enums are authoritative. Keep an invalid object in the
|
||||
# action list so EventDefinition/EventChoice reject it instead of silently
|
||||
# dropping it, clamping it to a destructive action, or choosing SET_FLAG.
|
||||
var raw_type: Variant = _value(raw_value, &"type", "SET_FLAG")
|
||||
if raw_type is int:
|
||||
if int(raw_type) < Type.SET_FLAG or int(raw_type) > Type.CREATE_DEFENSE_RAID:
|
||||
return EventAction.new()
|
||||
elif typeof(raw_type) in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
if Type.keys().find(String(raw_type).to_upper()) < 0:
|
||||
return EventAction.new()
|
||||
else:
|
||||
return EventAction.new()
|
||||
return EventAction.new().configure(
|
||||
StringName(_value(raw_value, &"action_id", &"")),
|
||||
_value(raw_value, &"type", "SET_FLAG"),
|
||||
@@ -57,7 +70,7 @@ static func from_variant(raw_value: Variant) -> EventAction:
|
||||
|
||||
static func _type_from_variant(raw_value: Variant) -> Type:
|
||||
if raw_value is int:
|
||||
return clampi(int(raw_value), Type.SET_FLAG, Type.SET_ROUTE_LOCK) as Type
|
||||
return clampi(int(raw_value), Type.SET_FLAG, Type.CREATE_DEFENSE_RAID) as Type
|
||||
var index := Type.keys().find(String(raw_value).to_upper())
|
||||
return (index if index >= 0 else Type.SET_FLAG) as Type
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@ 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,
|
||||
@@ -42,6 +44,13 @@ 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
|
||||
@@ -70,6 +79,11 @@ func configure(
|
||||
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()
|
||||
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))
|
||||
@@ -97,6 +111,9 @@ 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 \
|
||||
@@ -140,6 +157,11 @@ func is_within_opening_hours(absolute_minute: int) -> bool:
|
||||
|
||||
|
||||
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()
|
||||
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"):
|
||||
@@ -158,6 +180,8 @@ func set_operational_conditions(conditions: Dictionary, absolute_minute: int) ->
|
||||
|
||||
|
||||
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:
|
||||
@@ -192,7 +216,19 @@ func evaluate_state(absolute_minute: int) -> bool:
|
||||
next_reasons.append(reason)
|
||||
if next_state > State.DEGRADED:
|
||||
next_state = State.DEGRADED
|
||||
var changed := state != next_state
|
||||
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
|
||||
@@ -203,8 +239,35 @@ func can_start_service(absolute_minute: int) -> bool:
|
||||
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:
|
||||
return maxi(capacity - active_services.size(), 0)
|
||||
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:
|
||||
@@ -281,6 +344,10 @@ func create_snapshot() -> Dictionary:
|
||||
&"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,
|
||||
@@ -309,6 +376,10 @@ static func from_snapshot(snapshot: Dictionary) -> FacilityInstance:
|
||||
&"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),
|
||||
@@ -350,10 +421,17 @@ static func from_snapshot(snapshot: Dictionary) -> FacilityInstance:
|
||||
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",
|
||||
|
||||
@@ -27,6 +27,23 @@ static func quote(base_price: int, policy: Dictionary) -> int:
|
||||
return result
|
||||
|
||||
|
||||
static func apply_multiplier(price: int, multiplier_bp: int) -> int:
|
||||
# Same nearest-G, half-up convention as policy pricing.
|
||||
@warning_ignore("integer_division")
|
||||
return (maxi(price, 0) * maxi(multiplier_bp, 0) + 5000) / 10000
|
||||
|
||||
|
||||
## Caller supplies already-determined deficient attributes. Threshold and surcharge
|
||||
## are deliberately not invented. An unresolved quote must not be charged.
|
||||
static func quote_service(base_price: int, policy: Dictionary, service_attribute: StringName, deficient_attributes: Array[StringName], surcharge_amount: int = -1) -> Dictionary:
|
||||
var base := quote(base_price, policy)
|
||||
var matches := not service_attribute.is_empty() and deficient_attributes.has(service_attribute)
|
||||
var resolved := not matches or surcharge_amount > 0
|
||||
var surcharge := surcharge_amount if matches and resolved else 0
|
||||
return {&"base_price": base, &"matches": matches, &"resolved": resolved,
|
||||
&"surcharge": surcharge, &"total": base + surcharge, &"eligible": true}
|
||||
|
||||
|
||||
static func satisfaction_modifier(
|
||||
policy: Dictionary,
|
||||
quality: int,
|
||||
|
||||
@@ -123,6 +123,7 @@ func unregister_instance(instance_id: StringName) -> CommandResult:
|
||||
{&"instance_id": instance_id}
|
||||
)
|
||||
_instances.erase(instance_id)
|
||||
refresh_operating_dependencies(current_minute)
|
||||
instance_unregistered.emit(instance_id)
|
||||
last_error = ""
|
||||
return _success({&"instance_id": instance_id})
|
||||
@@ -136,9 +137,29 @@ func set_operational_status(
|
||||
var instance := get_instance(instance_id)
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
var old_state := instance.state
|
||||
if _has_value(conditions, &"related_facility_id"):
|
||||
var raw_related: Variant = _value(conditions, &"related_facility_id", null)
|
||||
if typeof(raw_related) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return _invalid("Related facility must be one facility ID.")
|
||||
var related := StringName(raw_related)
|
||||
if related == instance_id or (not related.is_empty() and not has_instance(related)):
|
||||
return _invalid("Related facility is missing or refers to itself.")
|
||||
var seen := {instance_id: true}
|
||||
var cursor := related
|
||||
while not cursor.is_empty() and has_instance(cursor):
|
||||
if seen.has(cursor):
|
||||
return _invalid("Related facilities cannot form a cycle.")
|
||||
seen[cursor] = true
|
||||
cursor = get_instance(cursor).related_facility_id
|
||||
if _has_value(conditions, &"related_facility_operating") or _has_value(conditions, &"operation_state"):
|
||||
return _invalid("Operating flags are computed from actual facility conditions.")
|
||||
if _has_value(conditions, &"suspension_reason") and (typeof(_value(conditions, &"suspension_reason", null)) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(_value(conditions, &"suspension_reason", &"")) not in [&"DESTROYED", &"RAIDED"]):
|
||||
return _invalid("Suspension recovery is not specified.")
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
instance.set_operational_conditions(conditions, minute)
|
||||
if old_state != instance.state or _has_value(conditions, &"related_facility_id"):
|
||||
refresh_operating_dependencies(minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
last_error = ""
|
||||
return _success({
|
||||
@@ -161,6 +182,7 @@ func set_opening_hours(
|
||||
return _invalid("Opening hours must be valid minute-of-day values.")
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(_resolve_minute(absolute_minute))
|
||||
refresh_operating_dependencies(current_minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
last_error = ""
|
||||
return _success({&"instance_id": instance_id})
|
||||
@@ -191,6 +213,7 @@ func set_policy(
|
||||
var changed := instance.apply_policy_patch(normalized_patch)
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(current_minute)
|
||||
refresh_operating_dependencies(current_minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
last_error = ""
|
||||
return _success({
|
||||
@@ -319,6 +342,30 @@ func cancel_request(request_id: StringName, reason: StringName = &"GUEST_LEFT")
|
||||
return _not_found("Queued service request", request_id)
|
||||
|
||||
|
||||
func refresh_operating_dependencies(minute: int) -> void:
|
||||
var resolved: Dictionary = {}
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
_resolve_operating_dependency(instance_id, minute, resolved, {})
|
||||
|
||||
|
||||
func _resolve_operating_dependency(id: StringName, minute: int, resolved: Dictionary, visiting: Dictionary) -> bool:
|
||||
if resolved.has(id):
|
||||
return bool(resolved[id])
|
||||
if visiting.has(id) or not has_instance(id):
|
||||
return false
|
||||
visiting[id] = true
|
||||
var instance := get_instance(id)
|
||||
instance.apply_pending_policy(minute)
|
||||
var related := instance.related_facility_id
|
||||
instance.related_facility_operating = related.is_empty() or _resolve_operating_dependency(related, minute, resolved, visiting)
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(minute)
|
||||
_emit_state_change(instance, old_state)
|
||||
visiting.erase(id)
|
||||
resolved[id] = instance.operation_state == FacilityInstanceType.OperationState.OPERATING
|
||||
return bool(resolved[id])
|
||||
|
||||
|
||||
func start_next_service(
|
||||
instance_id: StringName,
|
||||
absolute_minute: int = -1,
|
||||
@@ -328,6 +375,7 @@ func start_next_service(
|
||||
if instance == null:
|
||||
return _not_found("Facility instance", instance_id)
|
||||
var minute := _resolve_minute(absolute_minute)
|
||||
_resolve_operating_dependency(instance_id, minute, {}, {})
|
||||
instance.apply_pending_policy(minute)
|
||||
var old_state := instance.state
|
||||
instance.evaluate_state(minute)
|
||||
@@ -347,6 +395,8 @@ func start_next_service(
|
||||
var request := get_request(instance.queue_entries[0])
|
||||
if request == null:
|
||||
return _invalid("The next queue entry is corrupt.")
|
||||
if not instance.can_accept_customers(request.guest_ids):
|
||||
return _invalid("Simultaneous customer limit reached.", {&"reason": &"CUSTOMER_CAPACITY"})
|
||||
var effective_validator := validator if validator.is_valid() else _start_validator
|
||||
var callback_failure := _run_boundary_callback(
|
||||
effective_validator,
|
||||
@@ -376,6 +426,8 @@ func start_next_service(
|
||||
}
|
||||
)
|
||||
instance.queue_entries.remove_at(0)
|
||||
# Report this start's service index, independent of customer headcount and callbacks.
|
||||
var slot := instance.active_services.size()
|
||||
instance.active_services[service_id] = service
|
||||
_known_service_ids[service_id] = true
|
||||
_increment_stat(instance.statistics_today, &"services_started")
|
||||
@@ -386,7 +438,7 @@ func start_next_service(
|
||||
&"instance_id": instance_id,
|
||||
&"request_id": request.request_id,
|
||||
&"service_id": service_id,
|
||||
&"slot": instance.capacity - instance.available_slots() - 1,
|
||||
&"slot": slot,
|
||||
})
|
||||
|
||||
|
||||
@@ -416,6 +468,7 @@ func advance_one_minute(absolute_minute: int = -1) -> Array[CommandResult]:
|
||||
return [_invalid("FacilityService cannot advance backwards in time.")]
|
||||
else:
|
||||
current_minute = absolute_minute
|
||||
refresh_operating_dependencies(current_minute)
|
||||
var results: Array[CommandResult] = []
|
||||
for instance_id: StringName in _sorted_keys(_instances):
|
||||
var instance: FacilityInstance = _instances[instance_id]
|
||||
@@ -632,10 +685,23 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
_known_service_ids = restored_known_services
|
||||
_next_service_sequence = restored_next_sequence
|
||||
current_minute = restored_minute
|
||||
for instance: FacilityInstance in _instances.values():
|
||||
instance.related_facility_operating = _restored_dependency_operates(instance.related_facility_id, {})
|
||||
instance.operation_state = FacilityInstanceType.OperationState.SUSPENDED if not instance.suspension_reason.is_empty() else (FacilityInstanceType.OperationState.OPERATING if instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and instance.related_facility_operating else FacilityInstanceType.OperationState.INACTIVE)
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func _restored_dependency_operates(id: StringName, seen: Dictionary) -> bool:
|
||||
if id.is_empty():
|
||||
return true
|
||||
if seen.has(id) or not has_instance(id):
|
||||
return false
|
||||
seen[id] = true
|
||||
var instance := get_instance(id)
|
||||
return instance.suspension_reason.is_empty() and instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and _restored_dependency_operates(instance.related_facility_id, seen)
|
||||
|
||||
|
||||
func _saved_policy_is_valid(policy: Dictionary) -> bool:
|
||||
var validation := FacilityPricingType.validate_patch(policy)
|
||||
if not bool(validation[&"valid"]):
|
||||
@@ -656,6 +722,14 @@ func _validate_restored_graph(
|
||||
var active_service_ids: Dictionary = {}
|
||||
for instance_id: StringName in _sorted_keys(instances):
|
||||
var instance: FacilityInstance = instances[instance_id]
|
||||
var dependency_seen: Dictionary = {instance_id: true}
|
||||
var dependency := instance.related_facility_id
|
||||
while not dependency.is_empty() and instances.has(dependency):
|
||||
if dependency_seen.has(dependency):
|
||||
last_error = "Related facility cycle in snapshot."
|
||||
return false
|
||||
dependency_seen[dependency] = true
|
||||
dependency = (instances[dependency] as FacilityInstance).related_facility_id
|
||||
if not known_instances.has(instance_id):
|
||||
last_error = "Active facility ID is absent from known IDs."
|
||||
return false
|
||||
@@ -703,6 +777,13 @@ func _prepare_service_for_tick(
|
||||
if instance.state == FacilityInstanceType.State.BROKEN:
|
||||
_fail_service(instance, service, INTERRUPTION_BROKEN, current_minute)
|
||||
return _success({&"service_id": service.service_id, &"failed": true})
|
||||
if not instance.related_facility_operating:
|
||||
if service.state == ServiceInstanceType.State.ACTIVE:
|
||||
service.pause(&"RELATED_FACILITY_INACTIVE", current_minute)
|
||||
service_interrupted.emit(service, &"RELATED_FACILITY_INACTIVE")
|
||||
return null
|
||||
if service.state == ServiceInstanceType.State.PAUSED and service.interruption_reason == &"RELATED_FACILITY_INACTIVE":
|
||||
service.resume()
|
||||
if not instance.power_available:
|
||||
if service.state == ServiceInstanceType.State.ACTIVE:
|
||||
service.pause(INTERRUPTION_NO_POWER, current_minute)
|
||||
|
||||
@@ -22,6 +22,7 @@ const PATH_FAILURE_LIMIT := 3
|
||||
const STUCK_REPLAN_MINUTES := 60
|
||||
const DEFAULT_WAIT_HARD_LIMIT := 120
|
||||
const DEFAULT_EXIT_THRESHOLD := 15
|
||||
const PRIMARY_NEEDS: Array[StringName] = [&"hunger", &"sleep", &"leisure", &"intimacy"]
|
||||
|
||||
const NEED_IDS: Array[StringName] = [
|
||||
&"hunger",
|
||||
@@ -518,6 +519,7 @@ func evaluate_candidate(guest_id: StringName, candidate: Dictionary) -> Dictiona
|
||||
)
|
||||
var result := candidate.duplicate(true)
|
||||
result[&"facility_id"] = _facility_id(candidate)
|
||||
result[&"need_priority"] = _primary_need_priority(guest, candidate)
|
||||
result[&"utility"] = utility
|
||||
result[&"urgency"] = urgency
|
||||
result[&"travel_minutes"] = travel_minutes
|
||||
@@ -554,9 +556,10 @@ func select_best_candidate(
|
||||
exit_threshold: int = DEFAULT_EXIT_THRESHOLD
|
||||
) -> Dictionary:
|
||||
var ranked := rank_candidates(guest_id, candidates)
|
||||
if ranked.is_empty() or int(ranked[0][&"utility"]) < exit_threshold:
|
||||
return {}
|
||||
return ranked[0].duplicate(true)
|
||||
for candidate: Dictionary in ranked:
|
||||
if int(candidate[&"utility"]) >= exit_threshold:
|
||||
return candidate.duplicate(true)
|
||||
return {}
|
||||
|
||||
|
||||
func decide(
|
||||
@@ -574,7 +577,7 @@ func decide(
|
||||
var reserve_callback: Callable = _value(context, &"reserve_callback", Callable())
|
||||
for candidate: Dictionary in ranked:
|
||||
if int(candidate[&"utility"]) < threshold:
|
||||
break
|
||||
continue
|
||||
if reserve_callback.is_valid():
|
||||
var reservation_result: Variant = reserve_callback.call(
|
||||
guest_id,
|
||||
@@ -1203,6 +1206,8 @@ func _candidate_rejection_reason(
|
||||
) -> StringName:
|
||||
if not _candidate_need_maps_are_valid(candidate):
|
||||
return &"INVALID_NEED_MAP"
|
||||
if not bool(_value(candidate, &"inputs_available", true)):
|
||||
return &"MISSING_INPUTS"
|
||||
var adult_only := bool(_value(
|
||||
candidate,
|
||||
&"adult_only",
|
||||
@@ -1361,7 +1366,44 @@ func _candidate_urgency(guest: GuestRuntime, candidate: Dictionary) -> int:
|
||||
return result
|
||||
|
||||
|
||||
func _primary_need_priority(guest: GuestRuntime, candidate: Dictionary) -> int:
|
||||
var relief: Variant = _value(candidate, &"need_relief", {})
|
||||
if not relief is Dictionary:
|
||||
return 0
|
||||
var normalized: Dictionary = _normalized_partial_need_map(relief)
|
||||
var priority := 0
|
||||
for need: StringName in PRIMARY_NEEDS:
|
||||
if int(normalized.get(need, 0)) > 0:
|
||||
priority = maxi(priority, int(guest.needs[need]))
|
||||
return priority
|
||||
|
||||
|
||||
func get_observation(guest_id: StringName) -> Dictionary:
|
||||
var guest := get_guest(guest_id)
|
||||
if guest == null:
|
||||
return {}
|
||||
var highest := 0
|
||||
var primary: Array[StringName] = []
|
||||
for need: StringName in PRIMARY_NEEDS:
|
||||
var value := int(guest.needs[need])
|
||||
if value > highest:
|
||||
highest = value
|
||||
primary.clear()
|
||||
if value == highest and value > 0:
|
||||
primary.append(need)
|
||||
var observed_needs: Dictionary = {}
|
||||
for need: StringName in NEED_IDS:
|
||||
observed_needs[need] = guest.needs[need]
|
||||
return {&"guest_id": guest.guest_id, &"state": guest.state, &"needs": observed_needs,
|
||||
&"primary_needs": primary, &"target_facility_id": guest.target_facility_id,
|
||||
&"wallet_gold": guest.wallet_gold, &"patience": guest.patience_remaining}
|
||||
|
||||
|
||||
func _candidate_precedes(a: Dictionary, b: Dictionary) -> bool:
|
||||
var a_priority := int(a.get(&"need_priority", 0))
|
||||
var b_priority := int(b.get(&"need_priority", 0))
|
||||
if a_priority != b_priority:
|
||||
return a_priority > b_priority
|
||||
var a_utility := int(a[&"utility"])
|
||||
var b_utility := int(b[&"utility"])
|
||||
if a_utility != b_utility:
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
extends RefCounted
|
||||
|
||||
## Derived power conditions for the Runtime-owned facility network.
|
||||
## Does not change durability, operator policy, dependency or suspension status.
|
||||
## A missing consumer denotes a facility with no network consumption; the reserve
|
||||
## still gates it. Runtime must register every positive-demand consumer first.
|
||||
static func apply(facilities: FacilityService, network: ManaNetworkService, reserve: int, minute: int) -> Dictionary:
|
||||
if facilities == null or network == null or reserve < 0 or minute < 0:
|
||||
return {&"ok": false, &"code": &"INVALID_POWER_CONTEXT"}
|
||||
var changed: Array[StringName] = []
|
||||
for facility: FacilityInstance in facilities.get_instances():
|
||||
var node := network.get_node(facility.instance_id)
|
||||
var supplied := node.is_empty() or int(node.get(&"state", ManaNetworkService.ConsumerState.NO_POWER)) in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED]
|
||||
# Preserve the existing construction-to-first-evaluation contract. An
|
||||
# unevaluated node is not an observed outage; reserve zero still wins.
|
||||
if not node.is_empty() and &"awaiting_evaluation" in node.get(&"state_reasons", []):
|
||||
supplied = facility.power_available
|
||||
var available := reserve > 0 and supplied
|
||||
var reasons: Array[StringName] = facility.degraded_reasons.duplicate()
|
||||
reasons.erase(&"MANA_BROWNOUT")
|
||||
if reserve > 0 and not node.is_empty() and int(node.get(&"state", -1)) == ManaNetworkService.ConsumerState.DEGRADED:
|
||||
reasons.append(&"MANA_BROWNOUT")
|
||||
if available == facility.power_available and reasons == facility.degraded_reasons:
|
||||
continue
|
||||
var result := facilities.set_operational_status(facility.instance_id, {&"power_available": available, &"degraded_reasons": reasons}, minute)
|
||||
if not result.is_success():
|
||||
return {&"ok": false, &"code": &"POWER_PROJECTION_FAILED", &"facility_id": facility.instance_id}
|
||||
changed.append(facility.instance_id)
|
||||
return {&"ok": true, &"depleted": reserve == 0, &"changed_facilities": changed}
|
||||
@@ -0,0 +1 @@
|
||||
uid://dkowhbe1mgbdn
|
||||
@@ -0,0 +1,99 @@
|
||||
extends RefCounted
|
||||
|
||||
## Finite mana reserve; timing and production rates belong to the caller.
|
||||
## Every advance supplies totals for ONE interval, not a per-minute rate.
|
||||
## Generation first covers demand, then unused mana enters storage up to capacity.
|
||||
## No Runtime wiring or default balance values are implied by this component.
|
||||
const VERSION := 1
|
||||
const LIMIT := 1000000000
|
||||
var _capacity := -1
|
||||
var _amount := -1
|
||||
var _minute := -1
|
||||
var _last_flow: Dictionary = {}
|
||||
|
||||
func initialize(amount: Variant, capacity: Variant, minute: Variant) -> Dictionary:
|
||||
if _minute >= 0:
|
||||
return _failure(&"ALREADY_INITIALIZED")
|
||||
if not _integer(amount) or not _integer(capacity) or not _integer(minute) or amount > capacity:
|
||||
return _failure(&"INVALID_INITIAL_STATE")
|
||||
_capacity = capacity
|
||||
_amount = amount
|
||||
_minute = minute
|
||||
return {&"ok": true}
|
||||
|
||||
func get_amount() -> int:
|
||||
return _amount
|
||||
|
||||
func is_depleted() -> bool:
|
||||
return _minute >= 0 and _amount == 0
|
||||
|
||||
func advance_to(minute: Variant, generation: Variant, demand: Variant) -> Dictionary:
|
||||
if _minute < 0:
|
||||
return _failure(&"NOT_INITIALIZED")
|
||||
if not _integer(minute) or not _integer(generation) or not _integer(demand) or minute < _minute:
|
||||
return _failure(&"INVALID_FLOW")
|
||||
if minute == _minute:
|
||||
if not _last_flow.is_empty() and generation == _last_flow[&"generation"] and demand == _last_flow[&"demand"]:
|
||||
return {&"ok": true, &"replayed": true, &"flow": _last_flow.duplicate(true)}
|
||||
return _failure(&"REPLAY_MISMATCH")
|
||||
var flow := _plan(_amount, _capacity, generation, demand, _minute, minute)
|
||||
_amount = flow[&"after"]
|
||||
_minute = minute
|
||||
_last_flow = flow
|
||||
return {&"ok": true, &"replayed": false, &"flow": flow.duplicate(true)}
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
return {&"version": VERSION, &"capacity": _capacity, &"amount": _amount,
|
||||
&"minute": _minute, &"last_flow": _last_flow.duplicate(true)}
|
||||
|
||||
func restore_snapshot(source: Dictionary) -> bool:
|
||||
if not _keys(source, ["version", "capacity", "amount", "minute", "last_flow"]) \
|
||||
or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \
|
||||
or not _integer(source["capacity"]) or not _integer(source["amount"]) \
|
||||
or not _integer(source["minute"]) or source["amount"] > source["capacity"] \
|
||||
or not source["last_flow"] is Dictionary:
|
||||
return false
|
||||
var flow: Dictionary = source["last_flow"]
|
||||
if not flow.is_empty():
|
||||
if not _keys(flow, ["from_minute", "to_minute", "before", "generation", "demand", "consumed", "shortage", "overflow", "after"]):
|
||||
return false
|
||||
for key: Variant in flow:
|
||||
# Overflow may include an entire reserve plus this interval's supply.
|
||||
if typeof(flow[key]) != TYPE_INT or flow[key] < 0 or flow[key] > LIMIT * 2:
|
||||
return false
|
||||
if not _integer(flow["generation"]) or not _integer(flow["demand"]) \
|
||||
or not _integer(flow["from_minute"]) or flow["from_minute"] >= flow["to_minute"] \
|
||||
or flow["to_minute"] != source["minute"] or flow["before"] > source["capacity"]:
|
||||
return false
|
||||
var expected := _plan(flow["before"], source["capacity"], flow["generation"], flow["demand"], flow["from_minute"], flow["to_minute"])
|
||||
if expected != flow or flow["after"] != source["amount"]:
|
||||
return false
|
||||
# Commit only after validation; invalid loads leave the existing reserve intact.
|
||||
_capacity = source["capacity"]
|
||||
_amount = source["amount"]
|
||||
_minute = source["minute"]
|
||||
_last_flow = flow.duplicate(true)
|
||||
return true
|
||||
|
||||
static func _plan(before: int, capacity: int, generation: int, demand: int, start: int, end: int) -> Dictionary:
|
||||
var available := before + generation
|
||||
var consumed := mini(available, demand)
|
||||
var remaining := available - consumed
|
||||
return {&"from_minute": start, &"to_minute": end, &"before": before,
|
||||
&"generation": generation, &"demand": demand, &"consumed": consumed,
|
||||
&"shortage": demand - consumed, &"overflow": maxi(remaining - capacity, 0),
|
||||
&"after": mini(remaining, capacity)}
|
||||
|
||||
static func _integer(value: Variant) -> bool:
|
||||
return typeof(value) == TYPE_INT and value >= 0 and value <= LIMIT
|
||||
|
||||
static func _keys(source: Dictionary, expected: Array) -> bool:
|
||||
if source.size() != expected.size():
|
||||
return false
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected:
|
||||
return false
|
||||
return true
|
||||
|
||||
static func _failure(code: StringName) -> Dictionary:
|
||||
return {&"ok": false, &"code": code}
|
||||
@@ -0,0 +1 @@
|
||||
uid://bq7s1rvh3rqy0
|
||||
@@ -17,13 +17,13 @@ signal terminal_state_changed(old_state: StringName, new_state: StringName, reas
|
||||
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_VERSION := 3
|
||||
const SNAPSHOT_VERSION := 4
|
||||
const MIN_SUPPORTED_SNAPSHOT_VERSION := 1
|
||||
const MINUTES_PER_DAY := 1_440
|
||||
const DEMAND_INTERVAL_MINUTES := 60
|
||||
const ARRIVAL_INTERVAL_MINUTES := 30
|
||||
const MORNING_MINUTE_OF_DAY := 360
|
||||
const FIRST_DAILY_CLOSE_MINUTE := MORNING_MINUTE_OF_DAY + MINUTES_PER_DAY
|
||||
const FIRST_DAILY_CLOSE_MINUTE := MINUTES_PER_DAY
|
||||
const KEY_SEPARATOR := "\u001f"
|
||||
const MAX_START_MINUTE := 360
|
||||
const MAX_TRACKED_DAYS := 31
|
||||
@@ -98,6 +98,11 @@ var last_error := ""
|
||||
|
||||
var _origin_minute := 0
|
||||
|
||||
# Old saves finish their open 06:00 period once, then use midnight.
|
||||
# Historical insolvency evidence is retained without mixing it into new loss streaks.
|
||||
var _legacy_calendar_last_day := 0
|
||||
var _legacy_history_last_day := 0
|
||||
|
||||
var _config: Dictionary = DEFAULT_CONFIG.duplicate(true)
|
||||
var _demand_profiles: Dictionary = {}
|
||||
var _arrival_carry: Dictionary = {}
|
||||
@@ -111,6 +116,7 @@ var _processed_arrival_minutes: Dictionary = {}
|
||||
|
||||
var _population_count := 0
|
||||
var _arrears_total := 0
|
||||
# Serialized field name retained for compatibility; new closes count net losses.
|
||||
var _consecutive_insolvency_count := 0
|
||||
var _terminal_state: StringName = TERMINAL_RUNNING
|
||||
var _next_arrival_plan_sequence := 1
|
||||
@@ -129,6 +135,7 @@ var _notification_queue: Array[Dictionary] = []
|
||||
var _notification_delivery_deferred := false
|
||||
var _initialization_valid := true
|
||||
|
||||
var _midnight_boundary := Callable()
|
||||
var _daily_close_boundary := Callable()
|
||||
var _arrival_boundary := Callable()
|
||||
var _morning_boundary := Callable()
|
||||
@@ -163,11 +170,13 @@ func _init(config: Dictionary = {}) -> void:
|
||||
func configure_callbacks(
|
||||
daily_close_boundary: Callable = Callable(),
|
||||
arrival_boundary: Callable = Callable(),
|
||||
morning_boundary: Callable = Callable()
|
||||
morning_boundary: Callable = Callable(),
|
||||
midnight_boundary: Callable = Callable()
|
||||
) -> OperationsCoordinator:
|
||||
_daily_close_boundary = daily_close_boundary
|
||||
_arrival_boundary = arrival_boundary
|
||||
_morning_boundary = morning_boundary
|
||||
_midnight_boundary = midnight_boundary
|
||||
return self
|
||||
|
||||
|
||||
@@ -548,6 +557,19 @@ func can_start_construction_or_investment() -> Dictionary:
|
||||
}
|
||||
|
||||
|
||||
## Compact, defensive projection for the continuously visible operations HUD.
|
||||
func get_dashboard_summary() -> Dictionary:
|
||||
var latest_day := -1
|
||||
for day: int in _day_summaries:
|
||||
latest_day = maxi(latest_day, day)
|
||||
var summaries: Array[Dictionary] = []
|
||||
if latest_day >= 0:
|
||||
var latest: Dictionary = _day_summaries[latest_day]
|
||||
summaries.append({&"day":latest[&"day"], &"net_profit":latest[&"net_profit"]})
|
||||
return {&"terminal_state":_terminal_state, &"consecutive_insolvency_count":_consecutive_insolvency_count,
|
||||
&"legacy_history_last_day":_legacy_history_last_day, &"day_summaries":summaries}
|
||||
|
||||
|
||||
func get_day_summary(day: int) -> Dictionary:
|
||||
if not _day_summaries.has(day):
|
||||
return {}
|
||||
@@ -598,7 +620,8 @@ func create_snapshot() -> Dictionary:
|
||||
if _notification_delivery_deferred or not _notification_queue.is_empty():
|
||||
last_error = "Deferred Operations notifications must be resolved before snapshotting."
|
||||
return {}
|
||||
var internal_report := _variant_tree_report({
|
||||
var field_cache: Dictionary = {}
|
||||
var internal_report := _snapshot_root_report({
|
||||
&"config": _config,
|
||||
&"demand_profiles": _demand_profiles,
|
||||
&"arrival_carry": _arrival_carry,
|
||||
@@ -608,7 +631,7 @@ func create_snapshot() -> Dictionary:
|
||||
&"morning_reports": _morning_reports,
|
||||
&"alerts": _alerts,
|
||||
&"quarantine_report": _quarantine_report,
|
||||
})
|
||||
}, field_cache, true)
|
||||
var shape_error := _internal_snapshot_shape_error()
|
||||
if not bool(internal_report[&"ok"]) or not shape_error.is_empty():
|
||||
last_error = "Operations state cannot be serialized: %s" % [
|
||||
@@ -636,20 +659,27 @@ func create_snapshot() -> Dictionary:
|
||||
|
||||
var saved_mornings: Array[Dictionary] = []
|
||||
for day: int in _sorted_int_keys(_morning_reports):
|
||||
saved_mornings.append((_morning_reports[day] as Dictionary).duplicate(true))
|
||||
saved_mornings.append(_morning_reports[day])
|
||||
var saved_days: Array[Dictionary] = []
|
||||
for day: int in _sorted_int_keys(_day_summaries):
|
||||
saved_days.append(_day_summaries[day])
|
||||
|
||||
# These borrowed containers never leave this synchronous method. The final
|
||||
# canonical conversion recursively creates owned dictionaries and arrays.
|
||||
var raw_snapshot := {
|
||||
&"schema_version": SNAPSHOT_VERSION,
|
||||
&"legacy_calendar_last_day": _legacy_calendar_last_day,
|
||||
&"legacy_history_last_day": _legacy_history_last_day,
|
||||
&"origin_minute": _origin_minute,
|
||||
&"current_minute": current_minute,
|
||||
&"config": _string_key_dictionary(_config),
|
||||
&"demand_profiles": saved_profiles,
|
||||
&"arrival_carry": saved_carry,
|
||||
&"demand_history": _demand_history.duplicate(true),
|
||||
&"arrival_reports": _arrival_reports.duplicate(true),
|
||||
&"day_summaries": get_day_summaries(),
|
||||
&"demand_history": _demand_history,
|
||||
&"arrival_reports": _arrival_reports,
|
||||
&"day_summaries": saved_days,
|
||||
&"morning_reports": saved_mornings,
|
||||
&"alerts": _alerts.duplicate(true),
|
||||
&"alerts": _alerts,
|
||||
&"processed_demand_minutes": _sorted_int_keys(_processed_demand_minutes),
|
||||
&"processed_arrival_minutes": _sorted_int_keys(_processed_arrival_minutes),
|
||||
&"population_count": _population_count,
|
||||
@@ -666,13 +696,13 @@ func create_snapshot() -> Dictionary:
|
||||
&"deferred_boundary_minute": _deferred_boundary_minute,
|
||||
&"pause_reason": String(_pause_reason),
|
||||
&"external_quarantined": _external_quarantined,
|
||||
&"quarantine_report": _quarantine_report.duplicate(true),
|
||||
&"quarantine_report": _quarantine_report,
|
||||
}
|
||||
var tree_report := _variant_tree_report(raw_snapshot)
|
||||
var tree_report := _snapshot_root_report(raw_snapshot, field_cache, false)
|
||||
if not bool(tree_report[&"ok"]):
|
||||
last_error = "Operations state cannot be serialized: %s" % tree_report[&"code"]
|
||||
return {}
|
||||
return _to_snapshot_variant(raw_snapshot)
|
||||
return tree_report[&"value"]
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
@@ -686,6 +716,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return false
|
||||
var state: Dictionary = parsed[&"state"]
|
||||
# Atomic swap begins only after the entire candidate passed validation.
|
||||
_legacy_calendar_last_day = int(state[&"legacy_calendar_last_day"])
|
||||
_legacy_history_last_day = int(state[&"legacy_history_last_day"])
|
||||
_origin_minute = int(state[&"origin_minute"])
|
||||
current_minute = int(state[&"current_minute"])
|
||||
_config = (state[&"config"] as Dictionary).duplicate(true)
|
||||
@@ -840,9 +872,15 @@ func validate_invariants() -> Dictionary:
|
||||
|
||||
|
||||
func _process_minute_boundaries(minute: int, counts: Dictionary) -> CommandResult:
|
||||
# Fixed 06:00 boundary order: prior operating-day finance, morning
|
||||
# operations, demand, arrivals. The first 06:00 opens Day 1; the first close
|
||||
# occurs at Day 2 06:00 after a complete 24-hour operating period.
|
||||
# Migrated 06:00 financial periods retain their original payroll/history.
|
||||
# Calendar-midnight obligations still execute atomically at 00:00. Their
|
||||
# external receipt owns idempotency when a later boundary in this minute fails.
|
||||
if minute % MINUTES_PER_DAY == 0 and not _is_daily_close_boundary(minute) and _midnight_boundary.is_valid():
|
||||
var midnight := _run_boundary_transaction(_midnight_boundary, {&"boundary_minute": minute}, {&"ok": true})
|
||||
if not bool(midnight[&"ok"]):
|
||||
return _transaction_boundary_failure(midnight, &"MIDNIGHT_UPKEEP", minute, &"MIDNIGHT_UPKEEP_FAILED")
|
||||
# Finance precedes demand/arrivals at midnight. Morning work stays at 06:00.
|
||||
# During legacy migration, the final 06:00 close also precedes morning work.
|
||||
if _is_daily_close_boundary(minute):
|
||||
var close := _process_daily_close(minute)
|
||||
if not close.is_success():
|
||||
@@ -1086,7 +1124,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
&"operation": &"DAILY_CLOSE",
|
||||
&"day": completed_day,
|
||||
&"boundary_minute": boundary_minute,
|
||||
&"period_start_minute": boundary_minute - MINUTES_PER_DAY,
|
||||
&"period_start_minute": get_financial_period_start(completed_day),
|
||||
&"period_end_minute_exclusive": boundary_minute,
|
||||
&"idempotency_key": "daily-close:%d" % completed_day,
|
||||
&"prior_arrears_total": _arrears_total,
|
||||
@@ -1097,7 +1135,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
&"GENERATE_FACILITY_PROFIT_LOSS",
|
||||
&"DETECT_ANOMALIES",
|
||||
&"BUILD_DAY_SUMMARY",
|
||||
&"EVALUATE_ARREARS_AND_BANKRUPTCY",
|
||||
&"EVALUATE_LOSS_STREAK_AND_BANKRUPTCY",
|
||||
],
|
||||
}
|
||||
var transaction := _run_boundary_transaction(
|
||||
@@ -1141,14 +1179,15 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
previous_profit = int((_day_summaries[completed_day - 1] as Dictionary)[&"net_profit"])
|
||||
var rolling := _rolling_profit_with(net_profit)
|
||||
var arrears := int((result[&"settlement"] as Dictionary)[&"arrears_total"])
|
||||
var consecutive := _consecutive_insolvency_count + 1 if arrears > 0 else 0
|
||||
var prior_streak := 0 if completed_day == _legacy_history_last_day + 1 else _consecutive_insolvency_count
|
||||
var consecutive := prior_streak + 1 if net_profit < 0 else 0
|
||||
var future_terminal := _terminal_state
|
||||
if consecutive >= int(_config[&"bankruptcy_close_count"]):
|
||||
future_terminal = TERMINAL_BANKRUPT
|
||||
var reconciliation_difference := int(result[&"reconciliation_difference"])
|
||||
var summary := {
|
||||
&"day": completed_day,
|
||||
&"period_start_minute": boundary_minute - MINUTES_PER_DAY,
|
||||
&"period_start_minute": get_financial_period_start(completed_day),
|
||||
&"period_end_minute_exclusive": boundary_minute,
|
||||
&"closed_at": boundary_minute,
|
||||
&"category_totals": category_totals,
|
||||
@@ -1181,6 +1220,9 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
&"difference": reconciliation_difference,
|
||||
&"automatic_correction": false,
|
||||
})
|
||||
if net_profit < 0:
|
||||
_append_alert({&"code": &"LOSS_STREAK", &"minute": boundary_minute,
|
||||
&"day": completed_day, &"net_profit": net_profit, &"consecutive_count": consecutive})
|
||||
if arrears > 0:
|
||||
_append_alert({
|
||||
&"code": &"ARREARS_REMAIN",
|
||||
@@ -1201,7 +1243,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult:
|
||||
})
|
||||
_queue_notification(
|
||||
&"terminal",
|
||||
[old_state, _terminal_state, &"CONSECUTIVE_INSOLVENCY"]
|
||||
[old_state, _terminal_state, &"CONSECUTIVE_LOSSES"]
|
||||
)
|
||||
_queue_notification(&"daily", [summary.duplicate(true)])
|
||||
return _success(summary)
|
||||
@@ -1771,8 +1813,10 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
&"deferred_boundary_minute", &"pause_reason", &"external_quarantined",
|
||||
&"quarantine_report",
|
||||
]
|
||||
if int(_value(snapshot, &"schema_version", 0)) >= 4:
|
||||
top_keys.append_array([&"legacy_calendar_last_day", &"legacy_history_last_day"])
|
||||
if not _dictionary_has_exact_string_keys(snapshot, top_keys):
|
||||
return "Operations v3 snapshot has missing or unknown root fields."
|
||||
return "Operations snapshot has missing or unknown root fields."
|
||||
|
||||
var profiles: Variant = _value(snapshot, &"demand_profiles", null)
|
||||
var carry: Variant = _value(snapshot, &"arrival_carry", null)
|
||||
@@ -1782,33 +1826,33 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
var mornings: Variant = _value(snapshot, &"morning_reports", null)
|
||||
if not profiles is Array or not carry is Array or not demand_history is Array \
|
||||
or not arrivals is Array or not days is Array or not mornings is Array:
|
||||
return "Operations v3 snapshot history collections are malformed."
|
||||
return "Operations snapshot history collections are malformed."
|
||||
for raw_profile: Variant in profiles:
|
||||
if not raw_profile is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_profile,
|
||||
[&"facility_tag", &"guest_class", &"time_band", &"components",
|
||||
&"demand_index", &"last_evaluated_at"]
|
||||
):
|
||||
return "Operations v3 demand profile has missing or unknown fields."
|
||||
return "Operations demand profile has missing or unknown fields."
|
||||
for raw_carry: Variant in carry:
|
||||
if not raw_carry is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_carry, [&"facility_tag", &"guest_class", &"time_band", &"numerator"]
|
||||
):
|
||||
return "Operations v3 arrival carry row has missing or unknown fields."
|
||||
return "Operations arrival carry row has missing or unknown fields."
|
||||
for raw_report: Variant in demand_history:
|
||||
if not raw_report is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_report, [&"minute", &"values"]
|
||||
):
|
||||
return "Operations v3 demand history row has missing or unknown fields."
|
||||
return "Operations demand history row has missing or unknown fields."
|
||||
var values: Variant = (raw_report as Dictionary).get(&"values", null)
|
||||
if not values is Array:
|
||||
return "Operations v3 demand history values are malformed."
|
||||
return "Operations demand history values are malformed."
|
||||
for raw_value: Variant in values:
|
||||
if not raw_value is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_value,
|
||||
[&"facility_tag", &"guest_class", &"time_band", &"demand_index"]
|
||||
):
|
||||
return "Operations v3 demand history value has missing or unknown fields."
|
||||
return "Operations demand history value has missing or unknown fields."
|
||||
for raw_report: Variant in arrivals:
|
||||
if not raw_report is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_report,
|
||||
@@ -1816,18 +1860,18 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String:
|
||||
&"plans", &"total_count", &"population_after", &"capacity_remaining",
|
||||
&"carry_capped"]
|
||||
):
|
||||
return "Operations v3 arrival report has missing or unknown fields."
|
||||
return "Operations arrival report has missing or unknown fields."
|
||||
var plans: Variant = (raw_report as Dictionary).get(&"plans", null)
|
||||
var capped: Variant = (raw_report as Dictionary).get(&"carry_capped", null)
|
||||
if not plans is Array or not capped is Array:
|
||||
return "Operations v3 arrival report collections are malformed."
|
||||
return "Operations arrival report collections are malformed."
|
||||
for raw_plan: Variant in plans:
|
||||
if not raw_plan is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_plan,
|
||||
[&"plan_id", &"minute", &"facility_tag", &"guest_class", &"time_band",
|
||||
&"demand_index", &"count", &"guest_ids"]
|
||||
):
|
||||
return "Operations v3 arrival plan has missing or unknown fields."
|
||||
return "Operations arrival plan has missing or unknown fields."
|
||||
for raw_capped: Variant in capped:
|
||||
if not raw_capped is Dictionary or not _dictionary_has_exact_string_keys(
|
||||
raw_capped, [&"facility_tag", &"guest_class", &"discarded_numerator"]
|
||||
@@ -1918,7 +1962,7 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
var version := int(version_value)
|
||||
if version < MIN_SUPPORTED_SNAPSHOT_VERSION or version > SNAPSHOT_VERSION:
|
||||
return {&"ok": false, &"error": "Unsupported operations snapshot version."}
|
||||
if version == SNAPSHOT_VERSION:
|
||||
if version >= 3:
|
||||
var exact_shape_error := _current_snapshot_exact_shape_error(snapshot)
|
||||
if not exact_shape_error.is_empty():
|
||||
return {&"ok": false, &"error": exact_shape_error}
|
||||
@@ -2106,7 +2150,29 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
if typeof(quarantine_value) != TYPE_BOOL or not quarantine_report_value is Dictionary:
|
||||
return {&"ok": false, &"error": "External quarantine snapshot state is malformed."}
|
||||
|
||||
var legacy_history := 0
|
||||
var legacy_calendar := 0
|
||||
if version < 4:
|
||||
for day: int in histories[&"day_summaries"]:
|
||||
legacy_history = maxi(legacy_history, day)
|
||||
legacy_calendar = legacy_history + 1
|
||||
else:
|
||||
var calendar_value: Variant = _value(snapshot, &"legacy_calendar_last_day", null)
|
||||
var history_value: Variant = _value(snapshot, &"legacy_history_last_day", null)
|
||||
if not _is_integral_value(calendar_value) or not _is_integral_value(history_value):
|
||||
return {&"ok": false, &"error": "Invalid legacy calendar markers."}
|
||||
legacy_calendar = int(calendar_value)
|
||||
legacy_history = int(history_value)
|
||||
if legacy_history < 0 or legacy_calendar < 0 or legacy_calendar > MAX_TRACKED_DAYS + 1 \
|
||||
or (legacy_calendar != 0 and legacy_calendar != legacy_history + 1) \
|
||||
or (legacy_calendar == 0 and legacy_history != 0):
|
||||
return {&"ok": false, &"error": "Invalid legacy calendar transition."}
|
||||
for day in range(1, legacy_history + 1):
|
||||
if not (histories[&"day_summaries"] as Dictionary).has(day):
|
||||
return {&"ok": false, &"error": "Missing legacy financial history."}
|
||||
var state := {
|
||||
&"legacy_calendar_last_day": legacy_calendar,
|
||||
&"legacy_history_last_day": legacy_history,
|
||||
&"origin_minute": restored_origin,
|
||||
&"current_minute": restored_minute,
|
||||
&"config": normalized_config[&"config"],
|
||||
@@ -2138,6 +2204,8 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
# Validate the fully assembled state using a temporary instance without
|
||||
# copying any callback into save data.
|
||||
var candidate := OperationsCoordinator.new(state[&"config"])
|
||||
candidate._legacy_calendar_last_day = legacy_calendar
|
||||
candidate._legacy_history_last_day = legacy_history
|
||||
candidate._origin_minute = restored_origin
|
||||
candidate.current_minute = restored_minute
|
||||
candidate._config = (state[&"config"] as Dictionary).duplicate(true)
|
||||
@@ -2459,7 +2527,7 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void:
|
||||
var expected_close := _daily_close_minute_for_day(day)
|
||||
if int(_value(summary, &"day", 0)) != day \
|
||||
or int(_value(summary, &"closed_at", -1)) != expected_close \
|
||||
or int(_value(summary, &"period_start_minute", -1)) != expected_close - MINUTES_PER_DAY \
|
||||
or int(_value(summary, &"period_start_minute", -1)) != get_financial_period_start(day) \
|
||||
or int(_value(summary, &"period_end_minute_exclusive", -1)) != expected_close \
|
||||
or expected_close <= _origin_minute \
|
||||
or expected_close > maximum_committed_minute:
|
||||
@@ -2520,7 +2588,10 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void:
|
||||
or int(_value(settlement_value, &"arrears_total", -2)) != expected_arrears \
|
||||
or not _variant_tree_report(settlement_value)[&"ok"]:
|
||||
errors.append({&"code": &"SETTLEMENT_ARREARS_MISMATCH", &"day": day})
|
||||
expected_consecutive = expected_consecutive + 1 if expected_arrears > 0 else 0
|
||||
if day == _legacy_history_last_day + 1:
|
||||
expected_consecutive = 0
|
||||
var failed_close := expected_arrears > 0 if day <= _legacy_history_last_day else net < 0
|
||||
expected_consecutive = expected_consecutive + 1 if failed_close else 0
|
||||
if int(_value(summary, &"consecutive_insolvency_count", -1)) != expected_consecutive:
|
||||
errors.append({&"code": &"INSOLVENCY_COUNT_MISMATCH", &"day": day})
|
||||
if expected_consecutive >= int(_config[&"bankruptcy_close_count"]):
|
||||
@@ -2614,6 +2685,7 @@ func _validate_partial_boundary_prefix(minute: int, errors: Array[Dictionary]) -
|
||||
func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void:
|
||||
var imbalance_by_day: Dictionary = {}
|
||||
var arrears_by_day: Dictionary = {}
|
||||
var losses_by_day: Dictionary = {}
|
||||
var bankrupt_count := 0
|
||||
var quarantine_count := 0
|
||||
for alert: Dictionary in _alerts:
|
||||
@@ -2625,6 +2697,16 @@ func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void:
|
||||
&"LEDGER_IMBALANCE":
|
||||
var day := int(_value(alert, &"day", 0))
|
||||
imbalance_by_day[day] = int(imbalance_by_day.get(day, 0)) + 1
|
||||
&"LOSS_STREAK":
|
||||
var day := int(_value(alert, &"day", 0))
|
||||
losses_by_day[day] = int(losses_by_day.get(day, 0)) + 1
|
||||
var summary: Dictionary = _day_summaries.get(day, {})
|
||||
if summary.is_empty() or day <= _legacy_history_last_day \
|
||||
or int(_value(alert, &"net_profit", 0)) >= 0 \
|
||||
or _value(alert, &"minute", null) != summary.get(&"closed_at") \
|
||||
or _value(alert, &"net_profit", null) != summary.get(&"net_profit") \
|
||||
or _value(alert, &"consecutive_count", null) != summary.get(&"consecutive_insolvency_count"):
|
||||
errors.append({&"code": &"INVALID_LOSS_ALERT", &"day": day})
|
||||
&"ARREARS_REMAIN":
|
||||
var day := int(_value(alert, &"day", 0))
|
||||
arrears_by_day[day] = int(arrears_by_day.get(day, 0)) + 1
|
||||
@@ -2641,6 +2723,9 @@ func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void:
|
||||
var imbalanced := int(_value(summary, &"reconciliation_difference", 0)) != 0
|
||||
if int(imbalance_by_day.get(day, 0)) != int(imbalanced):
|
||||
errors.append({&"code": &"IMBALANCE_ALERT_MISMATCH", &"day": day})
|
||||
var new_loss := day > _legacy_history_last_day and int(_value(summary, &"net_profit", 0)) < 0
|
||||
if int(losses_by_day.get(day, 0)) != int(new_loss):
|
||||
errors.append({&"code": &"LOSS_ALERT_MISMATCH", &"day": day})
|
||||
var has_arrears := int(_value(summary, &"arrears_total", 0)) > 0
|
||||
if int(arrears_by_day.get(day, 0)) != int(has_arrears):
|
||||
errors.append({&"code": &"ARREARS_ALERT_MISMATCH", &"day": day})
|
||||
@@ -2725,16 +2810,32 @@ func _minute_has_scheduled_boundary(minute: int) -> bool:
|
||||
|
||||
|
||||
func _is_daily_close_boundary(minute: int) -> bool:
|
||||
return minute >= FIRST_DAILY_CLOSE_MINUTE \
|
||||
and minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY
|
||||
var day := minute / MINUTES_PER_DAY
|
||||
return day > 0 and minute == get_daily_close_minute(day)
|
||||
|
||||
|
||||
func _financial_day_for_boundary(boundary_minute: int) -> int:
|
||||
return (boundary_minute - MORNING_MINUTE_OF_DAY) / MINUTES_PER_DAY
|
||||
return boundary_minute / MINUTES_PER_DAY
|
||||
|
||||
|
||||
func _daily_close_minute_for_day(day: int) -> int:
|
||||
return day * MINUTES_PER_DAY + MORNING_MINUTE_OF_DAY
|
||||
return get_daily_close_minute(day)
|
||||
|
||||
|
||||
func get_daily_close_minute(day: int) -> int:
|
||||
return day * MINUTES_PER_DAY + (MORNING_MINUTE_OF_DAY if day <= _legacy_calendar_last_day else 0)
|
||||
|
||||
|
||||
func is_legacy_financial_history(day: int) -> bool:
|
||||
return day <= _legacy_history_last_day
|
||||
|
||||
|
||||
func get_open_financial_day() -> int:
|
||||
return _day_summaries.size() + 1
|
||||
|
||||
|
||||
func get_financial_period_start(day: int) -> int:
|
||||
return get_daily_close_minute(day - 1) if day > 1 else (MORNING_MINUTE_OF_DAY if _legacy_calendar_last_day > 0 else 0)
|
||||
|
||||
|
||||
func _max_carry_for_config(config: Dictionary) -> int:
|
||||
@@ -2764,67 +2865,169 @@ static func _is_save_safe(value: Variant, depth := 0) -> bool:
|
||||
|
||||
|
||||
static func _variant_tree_report(value: Variant) -> Dictionary:
|
||||
var budget := {&"nodes": 0, &"string_length": 0}
|
||||
# Shared per-traversal counters: [visited nodes, total string length].
|
||||
var budget: Array[int] = [0, 0]
|
||||
var issue := _inspect_variant_tree(value, budget, 0)
|
||||
if not issue.is_empty():
|
||||
return {&"ok": false, &"code": issue}
|
||||
return {
|
||||
&"ok": true,
|
||||
&"code": &"OK",
|
||||
&"nodes": int(budget[&"nodes"]),
|
||||
&"string_length": int(budget[&"string_length"]),
|
||||
&"nodes": budget[0],
|
||||
&"string_length": budget[1],
|
||||
}
|
||||
|
||||
|
||||
static func _inspect_variant_tree(
|
||||
value: Variant,
|
||||
budget: Dictionary,
|
||||
budget: Array[int],
|
||||
depth: int
|
||||
) -> StringName:
|
||||
var issues: Array[StringName] = []
|
||||
_visit_snapshot_tree(value, budget, depth, issues, false)
|
||||
return issues[0] if not issues.is_empty() else &""
|
||||
|
||||
|
||||
static func _snapshot_tree_report(value: Variant) -> Dictionary:
|
||||
var budget: Array[int] = [0, 0]
|
||||
var issues: Array[StringName] = []
|
||||
var encoded: Variant = _visit_snapshot_tree(value, budget, 0, issues, true)
|
||||
if not issues.is_empty():
|
||||
return {&"ok": false, &"code": issues[0]}
|
||||
return {&"ok": true, &"code": &"OK", &"nodes": budget[0],
|
||||
&"string_length": budget[1], &"value": encoded}
|
||||
|
||||
|
||||
# Capture validated, canonical copies for unchanged root fields.
|
||||
# The cache never survives create_snapshot and only root-depth subtrees reuse it.
|
||||
# Use only for that synchronous internal-to-output conversion: these four fields
|
||||
# have identical source values and depths in both root dictionaries. No callbacks
|
||||
# or state mutation may occur between capture and reuse. Aggregate-limit failure
|
||||
# still uses the original visitor to retain its exact error precedence.
|
||||
static func _snapshot_root_report(source: Dictionary, cache: Dictionary, capture: bool) -> Dictionary:
|
||||
var budget: Array[int] = [1, 0]
|
||||
var issues: Array[StringName] = []
|
||||
var converted: Dictionary = {}
|
||||
if source.size() > MAX_SAFE_COLLECTION_SIZE:
|
||||
return {&"ok": false, &"code": &"COLLECTION_TOO_LARGE"}
|
||||
if not _canonical_key_collision(source).is_empty():
|
||||
return {&"ok": false, &"code": &"DUPLICATE_CANONICAL_KEY"}
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]:
|
||||
return {&"ok": false, &"code": &"INVALID_DICTIONARY_KEY"}
|
||||
budget[0] += 1
|
||||
if budget[0] > MAX_VARIANT_NODES:
|
||||
return {&"ok": false, &"code": &"MAX_VARIANT_NODES"}
|
||||
var name := str(key)
|
||||
var length := name.length()
|
||||
if length > MAX_SINGLE_STRING_LENGTH:
|
||||
return {&"ok": false, &"code": &"STRING_TOO_LONG"}
|
||||
budget[1] += length
|
||||
if budget[1] > MAX_TOTAL_STRING_LENGTH:
|
||||
return {&"ok": false, &"code": &"TOTAL_STRING_BUDGET"}
|
||||
var node_start := budget[0]
|
||||
var string_start := budget[1]
|
||||
var reusable := name in ["demand_history", "arrival_reports", "alerts", "quarantine_report"]
|
||||
var value: Variant
|
||||
var cached: Dictionary = cache.get(name, {}) if not capture else {}
|
||||
if not cached.is_empty() and budget[0] + cached[&"nodes"] <= MAX_VARIANT_NODES and budget[1] + cached[&"strings"] <= MAX_TOTAL_STRING_LENGTH:
|
||||
budget[0] += cached[&"nodes"]
|
||||
budget[1] += cached[&"strings"]
|
||||
value = cached[&"value"]
|
||||
else:
|
||||
# Fall back to the original source-order visitor when an aggregate
|
||||
# limit can fail, preserving which budget error occurs first.
|
||||
value = _visit_snapshot_tree(source[key], budget, 1, issues, not capture or reusable)
|
||||
if not issues.is_empty():
|
||||
return {&"ok": false, &"code": issues[0]}
|
||||
if capture and reusable:
|
||||
cache[name] = {&"nodes": budget[0] - node_start, &"strings": budget[1] - string_start, &"value": value}
|
||||
elif not capture:
|
||||
converted[name] = value
|
||||
converted.sort()
|
||||
return {&"ok": true, &"code": &"OK", &"nodes": budget[0], &"string_length": budget[1], &"value": converted}
|
||||
|
||||
|
||||
static func _snapshot_tree_issue(issues: Array[StringName], code: StringName) -> Variant:
|
||||
issues.append(code)
|
||||
return null
|
||||
|
||||
|
||||
static func _visit_snapshot_tree(
|
||||
value: Variant,
|
||||
budget: Array[int],
|
||||
depth: int,
|
||||
issues: Array[StringName],
|
||||
encode: bool
|
||||
) -> Variant:
|
||||
# The validator and encoder share one traversal so their depth, key and
|
||||
# aggregate budgets cannot drift. Validation-only callers allocate no copies.
|
||||
if depth > MAX_VARIANT_DEPTH:
|
||||
return &"MAX_VARIANT_DEPTH"
|
||||
budget[&"nodes"] = int(budget[&"nodes"]) + 1
|
||||
if int(budget[&"nodes"]) > MAX_VARIANT_NODES:
|
||||
return &"MAX_VARIANT_NODES"
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_DEPTH")
|
||||
budget[0] += 1
|
||||
if budget[0] > MAX_VARIANT_NODES:
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_NODES")
|
||||
match typeof(value):
|
||||
TYPE_NIL, TYPE_BOOL, TYPE_INT:
|
||||
return &""
|
||||
return value
|
||||
TYPE_FLOAT:
|
||||
return &"" if is_finite(float(value)) else &"NON_FINITE_NUMBER"
|
||||
return value if is_finite(float(value)) else _snapshot_tree_issue(issues, &"NON_FINITE_NUMBER")
|
||||
TYPE_STRING, TYPE_STRING_NAME:
|
||||
var text := String(value)
|
||||
if text.length() > MAX_SINGLE_STRING_LENGTH:
|
||||
return &"STRING_TOO_LONG"
|
||||
budget[&"string_length"] = int(budget[&"string_length"]) + text.length()
|
||||
return &"" if int(budget[&"string_length"]) <= MAX_TOTAL_STRING_LENGTH \
|
||||
else &"TOTAL_STRING_BUDGET"
|
||||
return _snapshot_tree_issue(issues, &"STRING_TOO_LONG")
|
||||
budget[1] += text.length()
|
||||
if budget[1] > MAX_TOTAL_STRING_LENGTH:
|
||||
return _snapshot_tree_issue(issues, &"TOTAL_STRING_BUDGET")
|
||||
return text
|
||||
TYPE_ARRAY:
|
||||
var values: Array = value
|
||||
if values.size() > MAX_SAFE_COLLECTION_SIZE:
|
||||
return &"COLLECTION_TOO_LARGE"
|
||||
return _snapshot_tree_issue(issues, &"COLLECTION_TOO_LARGE")
|
||||
var converted: Variant = [] if encode else null
|
||||
for child: Variant in values:
|
||||
var issue := _inspect_variant_tree(child, budget, depth + 1)
|
||||
if not issue.is_empty():
|
||||
return issue
|
||||
return &""
|
||||
var encoded: Variant = _visit_snapshot_tree(child, budget, depth + 1, issues, encode)
|
||||
if not issues.is_empty():
|
||||
return null
|
||||
if encode:
|
||||
converted.append(encoded)
|
||||
return converted
|
||||
TYPE_DICTIONARY:
|
||||
var source: Dictionary = value
|
||||
if source.size() > MAX_SAFE_COLLECTION_SIZE:
|
||||
return &"COLLECTION_TOO_LARGE"
|
||||
return _snapshot_tree_issue(issues, &"COLLECTION_TOO_LARGE")
|
||||
if not _canonical_key_collision(source).is_empty():
|
||||
return &"DUPLICATE_CANONICAL_KEY"
|
||||
return _snapshot_tree_issue(issues, &"DUPLICATE_CANONICAL_KEY")
|
||||
var converted: Variant = {} if encode else null
|
||||
# Validate in source order, retaining the historical error precedence.
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]:
|
||||
return &"INVALID_DICTIONARY_KEY"
|
||||
var key_issue := _inspect_variant_tree(str(key), budget, depth + 1)
|
||||
if not key_issue.is_empty():
|
||||
return key_issue
|
||||
var value_issue := _inspect_variant_tree(source[key], budget, depth + 1)
|
||||
if not value_issue.is_empty():
|
||||
return value_issue
|
||||
return &""
|
||||
return _snapshot_tree_issue(issues, &"INVALID_DICTIONARY_KEY")
|
||||
if depth + 1 > MAX_VARIANT_DEPTH:
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_DEPTH")
|
||||
budget[0] += 1
|
||||
if budget[0] > MAX_VARIANT_NODES:
|
||||
return _snapshot_tree_issue(issues, &"MAX_VARIANT_NODES")
|
||||
var string_key := str(key)
|
||||
var key_length := string_key.length()
|
||||
if key_length > MAX_SINGLE_STRING_LENGTH:
|
||||
return _snapshot_tree_issue(issues, &"STRING_TOO_LONG")
|
||||
budget[1] += key_length
|
||||
if budget[1] > MAX_TOTAL_STRING_LENGTH:
|
||||
return _snapshot_tree_issue(issues, &"TOTAL_STRING_BUDGET")
|
||||
var encoded: Variant = _visit_snapshot_tree(source[key], budget, depth + 1, issues, encode)
|
||||
if not issues.is_empty():
|
||||
return null
|
||||
if encode:
|
||||
converted[string_key] = encoded
|
||||
if not encode:
|
||||
return null
|
||||
# Every key is now a unique String. Native string sorting preserves
|
||||
# canonical byte order without repeated Variant-to-string callbacks.
|
||||
converted.sort()
|
||||
return converted
|
||||
_:
|
||||
return &"UNSAVEABLE_VARIANT"
|
||||
return _snapshot_tree_issue(issues, &"UNSAVEABLE_VARIANT")
|
||||
|
||||
|
||||
static func _canonical_key_collision(source: Dictionary) -> Dictionary:
|
||||
|
||||
@@ -269,6 +269,12 @@ func add_stress(amount: int) -> int:
|
||||
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])
|
||||
|
||||
|
||||
@@ -50,6 +50,16 @@ var _item_releaser := Callable()
|
||||
var _travel_estimator := Callable()
|
||||
var _external_task_driver := Callable()
|
||||
var _attendance_driver := Callable()
|
||||
var _arrival_driver := Callable()
|
||||
|
||||
# Optional scheduler policy: zero delegates work progression to Runtime.
|
||||
var _work_progress_driver: Callable
|
||||
func configure_work_progress_driver(driver: Callable) -> void:
|
||||
_work_progress_driver = driver
|
||||
|
||||
|
||||
func configure_arrival_driver(driver: Callable) -> void:
|
||||
_arrival_driver = driver
|
||||
|
||||
|
||||
func configure_callbacks(
|
||||
@@ -170,7 +180,7 @@ func get_daily_payroll_rows() -> Array[Dictionary]:
|
||||
rows.append({
|
||||
&"staff_id": staff.staff_id,
|
||||
&"employment_kind": staff.get_employment_kind_name(),
|
||||
&"daily_wage": staff.daily_wage,
|
||||
&"daily_wage": staff.get_effective_daily_wage(),
|
||||
})
|
||||
return rows
|
||||
|
||||
@@ -193,7 +203,7 @@ func get_payroll_obligations() -> Array[Dictionary]:
|
||||
&"staff_id": staff_id,
|
||||
&"payroll_status": &"ACTIVE",
|
||||
&"employment_kind": staff.get_employment_kind_name(),
|
||||
&"daily_wage": staff.daily_wage,
|
||||
&"daily_wage": staff.get_effective_daily_wage(),
|
||||
&"unpaid_wages": staff.unpaid_wages,
|
||||
}
|
||||
for staff_id: StringName in _sorted_keys(_departed_payroll):
|
||||
@@ -730,6 +740,8 @@ func begin_task(task_id: StringName, absolute_minute: int = -1) -> CommandResult
|
||||
return _conflict("Task cannot begin from its current state.")
|
||||
if staff.state not in [StaffRuntimeType.State.MOVING_TO_TASK, StaffRuntimeType.State.AVAILABLE]:
|
||||
return _conflict("Staff cannot begin the reserved Task from its current state.")
|
||||
if _arrival_driver.is_valid() and not bool(_arrival_driver.call(task, staff, minute)):
|
||||
return _conflict("Task arrival was redirected or is no longer valid.")
|
||||
# Both state preconditions are checked before either side is mutated.
|
||||
var task_state_before: StaffTask.State = task.state
|
||||
var task_changed_at_before := task.state_changed_at
|
||||
@@ -829,7 +841,11 @@ func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]:
|
||||
StaffTaskType.State.WORKING:
|
||||
if is_externally_driven_task(task_id):
|
||||
continue
|
||||
var progress_result := progress_task(task.task_id, 1, absolute_minute)
|
||||
var units := 1
|
||||
if _work_progress_driver.is_valid():
|
||||
units = int(_work_progress_driver.call(task.task_id))
|
||||
if units <= 0: continue
|
||||
var progress_result := progress_task(task.task_id, units, absolute_minute)
|
||||
if progress_result.is_success():
|
||||
actions.append({
|
||||
&"task_id": task.task_id,
|
||||
@@ -878,6 +894,8 @@ func remove_staff(
|
||||
var staff := get_staff(staff_id)
|
||||
if staff == null:
|
||||
return _not_found("Staff", staff_id)
|
||||
if staff.employment_kind == StaffRuntimeType.EmploymentKind.UNIQUE:
|
||||
return _conflict("Unique Staff cannot be dismissed, sold or permanently removed.")
|
||||
if is_externally_driven_task(staff.current_task_id):
|
||||
return _conflict("Finish the employee external operation before removal.")
|
||||
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
||||
@@ -921,6 +939,8 @@ func retire_staff(staff_id: StringName, absolute_minute: int = -1) -> CommandRes
|
||||
var staff := get_staff(staff_id)
|
||||
if staff == null:
|
||||
return _not_found("Staff", staff_id)
|
||||
if staff.employment_kind == StaffRuntimeType.EmploymentKind.UNIQUE:
|
||||
return _conflict("Unique Staff cannot be dismissed, sold or permanently removed.")
|
||||
if is_externally_driven_task(staff.current_task_id):
|
||||
return _conflict("Finish the employee external operation before retirement.")
|
||||
staff.is_retiring = true
|
||||
@@ -1409,7 +1429,8 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re
|
||||
staff.shift_id = &""
|
||||
if staff.assigned_facility_id == shift.assigned_facility_id:
|
||||
staff.assigned_facility_id = &""
|
||||
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"SHIFT_ENDED", absolute_minute)
|
||||
if staff.can_take_shift():
|
||||
staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"SHIFT_ENDED", absolute_minute)
|
||||
shift.close(absolute_minute)
|
||||
shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state)
|
||||
shift = null
|
||||
@@ -1550,7 +1571,7 @@ func _build_payroll_preview(day: int) -> Dictionary:
|
||||
# Daily close covers [0, day * 1440). A hire exactly on the end
|
||||
# boundary belongs to the next close; carried debt remains due now.
|
||||
var newly_accrued := (
|
||||
staff.daily_wage if staff.employment_effective_minute < period_end else 0
|
||||
staff.get_effective_daily_wage() if staff.employment_effective_minute < period_end else 0
|
||||
)
|
||||
if (
|
||||
prior < 0
|
||||
|
||||
@@ -20,6 +20,7 @@ var task_type: StringName = &""
|
||||
var priority := 0
|
||||
var created_at := 0
|
||||
var deadline_at := -1
|
||||
var rescue_facility_id: StringName = &""
|
||||
var target_entity_id: StringName = &""
|
||||
var target_cell := Vector3i.ZERO
|
||||
var required_role_tags: Array[StringName] = []
|
||||
@@ -53,6 +54,7 @@ func configure(
|
||||
priority = clampi(p_priority, 0, MAX_PRIORITY)
|
||||
created_at = maxi(p_created_at, 0)
|
||||
deadline_at = int(_value(options, &"deadline_at", -1))
|
||||
rescue_facility_id = StringName(_value(options, &"rescue_facility_id", &""))
|
||||
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", []))
|
||||
@@ -224,6 +226,7 @@ func create_snapshot() -> Dictionary:
|
||||
&"priority": priority,
|
||||
&"created_at": created_at,
|
||||
&"deadline_at": deadline_at,
|
||||
&"rescue_facility_id": String(rescue_facility_id),
|
||||
&"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),
|
||||
@@ -258,6 +261,7 @@ static func from_snapshot(snapshot: Dictionary) -> StaffTask:
|
||||
int(snapshot.get(&"created_at", 0)),
|
||||
{
|
||||
&"deadline_at": snapshot.get(&"deadline_at", -1),
|
||||
&"rescue_facility_id": snapshot.get(&"rescue_facility_id", ""),
|
||||
&"target_entity_id": snapshot.get(&"target_entity_id", ""),
|
||||
&"target_cell": snapshot.get(&"target_cell", []),
|
||||
&"required_role_tags": snapshot.get(&"required_role_tags", []),
|
||||
@@ -283,7 +287,8 @@ static func from_snapshot(snapshot: Dictionary) -> StaffTask:
|
||||
|
||||
static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool:
|
||||
return (
|
||||
not String(snapshot.get(&"task_id", "")).is_empty()
|
||||
(snapshot.get(&"rescue_facility_id", "") is String or snapshot.get(&"rescue_facility_id", "") is StringName)
|
||||
and 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
|
||||
|
||||
@@ -237,6 +237,8 @@ func _complete_batch(batch: Dictionary, minute: int) -> Dictionary:
|
||||
func _production_state(facility: FacilityInstance, plan: Dictionary, batch: Dictionary, minute: int) -> StringName:
|
||||
if not bool(plan[&"enabled"]): return &"DISABLED"
|
||||
if facility.durability <= 0: return &"BROKEN"
|
||||
if not facility.suspension_reason.is_empty(): return &"SUSPENDED"
|
||||
if not facility.related_facility_operating: return &"RELATED_FACILITY_INACTIVE"
|
||||
if not facility.access_available: return &"NO_ACCESS"
|
||||
if not facility.power_available: return &"NO_POWER"
|
||||
if facility.active_staff_ids.size() < maxi(facility.staff_min, 1): return &"NO_STAFF"
|
||||
|
||||
@@ -171,7 +171,7 @@ func is_returning(request_id: StringName) -> bool:
|
||||
func _dispatch(request: LogisticsRequest, minute: int) -> void:
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
||||
if source == null or destination == null or not source.operator_open or not destination.operator_open:
|
||||
if source == null or destination == null or not source.allows_logistics() or not destination.allows_logistics():
|
||||
request.state_reason = "FACILITY_UNAVAILABLE"
|
||||
return
|
||||
if not _has_storage_space(destination, request.total_weight):
|
||||
@@ -216,10 +216,10 @@ func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, mi
|
||||
var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id)
|
||||
if source == null or not _cell_touches_facility(staff.current_cell, source):
|
||||
return _block_before_pickup(request, "INVALID_PICKUP_ACCESS", minute) and false
|
||||
if not source.operator_open:
|
||||
if not source.allows_logistics():
|
||||
return _block_before_pickup(request, "SOURCE_CLOSED", minute) and false
|
||||
var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id)
|
||||
if destination == null or not destination.operator_open or not _has_storage_space(destination, request.total_weight):
|
||||
if destination == null or not destination.allows_logistics() or not _has_storage_space(destination, request.total_weight):
|
||||
return _block_before_pickup(request, "DESTINATION_FULL_OR_CLOSED", minute) and false
|
||||
var route := _access_path(staff.current_cell, destination)
|
||||
if not bool(route.get(&"reachable", false)):
|
||||
@@ -251,7 +251,7 @@ func _deliver(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, m
|
||||
request.state_reason = "INVALID_DELIVERY_ACCESS"
|
||||
return false
|
||||
var quantities := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id))
|
||||
if destination == null or not destination.operator_open or not _has_storage_space(destination, SupplyCatalog.inventory_weight(_runtime.inventory_service, _cargo_id(staff.staff_id))):
|
||||
if destination == null or not destination.allows_logistics() or not _has_storage_space(destination, SupplyCatalog.inventory_weight(_runtime.inventory_service, _cargo_id(staff.staff_id))):
|
||||
if not returning:
|
||||
request.state_reason = "DESTINATION_FULL"
|
||||
return false
|
||||
|
||||
@@ -23,7 +23,7 @@ func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: Strin
|
||||
quote[&"cash_after"] = cash - int(quote[&"total_cost"])
|
||||
if quantity <= 0 or quantity > 10000:
|
||||
quote[&"reason"] = &"INVALID_QUANTITY"
|
||||
elif not facility.operator_open or facility.durability <= 0:
|
||||
elif not facility.allows_logistics():
|
||||
quote[&"reason"] = &"FACILITY_CLOSED"
|
||||
elif int(quote[&"total_cost"]) > cash:
|
||||
quote[&"reason"] = &"INSUFFICIENT_FUNDS"
|
||||
@@ -109,7 +109,7 @@ func get_report(runtime: Variant) -> Dictionary:
|
||||
func ensure_deliveries(runtime: Variant, minute: int) -> void:
|
||||
var facilities := _facilities(runtime)
|
||||
for destination: FacilityInstance in facilities:
|
||||
if not destination.operator_open or destination.durability <= 0:
|
||||
if not destination.allows_logistics():
|
||||
continue
|
||||
var source_type: StringName
|
||||
var item_id: StringName
|
||||
@@ -131,7 +131,7 @@ func ensure_deliveries(runtime: Variant, minute: int) -> void:
|
||||
continue
|
||||
var sources: Array[Dictionary] = []
|
||||
for source: FacilityInstance in facilities:
|
||||
if source.definition_id != source_type or not source.operator_open:
|
||||
if source.definition_id != source_type or not source.allows_logistics():
|
||||
continue
|
||||
if source_type == &"kitchen" and not _has_production_plan(runtime, source.instance_id):
|
||||
continue
|
||||
|
||||
@@ -174,6 +174,34 @@ func request_trip(actor_id: StringName, from: Vector3i, to: Vector3i, minute: in
|
||||
return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)}
|
||||
|
||||
|
||||
## Reserve two real seats atomically; no synthetic passenger or hidden capacity.
|
||||
func request_pair(first: StringName, second: StringName, from: Vector3i, to: Vector3i,
|
||||
minute: int, access: Array[StringName] = []) -> Dictionary:
|
||||
if first.is_empty() or second.is_empty() or first == second:
|
||||
return _failure(&"INVALID_PAIR", "Two distinct passengers are required.")
|
||||
if _trips.has(first) or _trips.has(second):
|
||||
if _trips.has(first) and _trips.has(second) and _trips[first].get(&"companion_id", &"") == second and _trips[second].get(&"companion_id", &"") == first and _trips[first][&"from"] == from and _trips[first][&"to"] == to:
|
||||
return {&"ok": true, &"replay": true, &"trip": _trips[first].duplicate(true)}
|
||||
return _failure(&"ACTOR_ALREADY_TRAVELLING", "Pair member already owns another trip.")
|
||||
var path_before := _path.create_snapshot()
|
||||
var sequence_before := _sequence
|
||||
var a := request_trip(first, from, to, minute, access)
|
||||
if not a[&"ok"]:
|
||||
_path.restore_snapshot(path_before)
|
||||
_sequence = sequence_before
|
||||
return a
|
||||
var b := request_trip(second, from, to, minute, access)
|
||||
if not b[&"ok"] or _shafts[_trips[first][&"shaft_id"]][&"config"][&"capacity"] < 2:
|
||||
_path.restore_snapshot(path_before)
|
||||
_trips.erase(first)
|
||||
_trips.erase(second)
|
||||
_sequence = sequence_before
|
||||
return _failure(&"PAIR_REJECTED", "Both passengers must fit in the same cabin queue.")
|
||||
_trips[first][&"companion_id"] = second
|
||||
_trips[second][&"companion_id"] = first
|
||||
return {&"ok": true, &"replay": false, &"trip": _trips[first].duplicate(true)}
|
||||
|
||||
|
||||
func advance_one_minute(minute: int) -> Array[Dictionary]:
|
||||
var arrivals: Array[Dictionary] = []
|
||||
if minute == _minute: return arrivals
|
||||
@@ -206,6 +234,22 @@ func get_actor_trip(actor_id: StringName) -> Dictionary:
|
||||
|
||||
|
||||
func cancel_actor(actor_id: StringName) -> bool:
|
||||
var partner: StringName = _trips.get(actor_id, {}).get(&"companion_id", &"")
|
||||
if not partner.is_empty():
|
||||
if _trips[actor_id][&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
||||
last_error = "An onboard pair must alight together at a real landing."
|
||||
return false
|
||||
var path_before := _path.create_snapshot()
|
||||
var trips_before := _trips.duplicate(true)
|
||||
if not _cancel_single(actor_id) or not _cancel_single(partner):
|
||||
_path.restore_snapshot(path_before)
|
||||
_trips = trips_before
|
||||
return false
|
||||
return true
|
||||
return _cancel_single(actor_id)
|
||||
|
||||
|
||||
func _cancel_single(actor_id: StringName) -> bool:
|
||||
if not _trips.has(actor_id): return true
|
||||
var trip: Dictionary = _trips[actor_id]
|
||||
if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED:
|
||||
@@ -259,8 +303,9 @@ func _apply_operational(shaft_id: StringName, operational: bool, minute: int) ->
|
||||
# landing; movement remains held and requests remain owned.
|
||||
car[&"operational"] = false
|
||||
for actor_id: StringName in _names(_trips):
|
||||
if not _trips.has(actor_id): continue
|
||||
var waiting: Dictionary = _trips[actor_id]
|
||||
if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and cancel_actor(actor_id):
|
||||
if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and _cancel_single(actor_id):
|
||||
arrivals.append({&"actor_id": actor_id, &"request_id": waiting[&"request_id"], &"cell": waiting[&"from"], &"emergency": false, &"cancelled": true})
|
||||
_publish_cabin(car)
|
||||
return arrivals
|
||||
@@ -364,7 +409,11 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
for value: Variant in snapshot[&"trips"]:
|
||||
if not value is Dictionary: return _invalid("Elevator trip must be a Dictionary.")
|
||||
var trip: Dictionary = value
|
||||
if not _exact_keys(trip, [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]):
|
||||
var keys: Array = [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]
|
||||
if trip.has(&"companion_id"):
|
||||
if not (trip[&"companion_id"] is String or trip[&"companion_id"] is StringName) or String(trip[&"companion_id"]).is_empty(): return _invalid("Invalid companion identity.")
|
||||
keys.append(&"companion_id")
|
||||
if not _exact_keys(trip, keys):
|
||||
return _invalid("Elevator trip fields are invalid.")
|
||||
for key: StringName in [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"state"]:
|
||||
if not (trip[key] is String or trip[key] is StringName) or str(trip[key]).is_empty(): return _invalid("Elevator trip names are invalid.")
|
||||
@@ -395,6 +444,15 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if not found: return _invalid("Queued trip details disagree with Path queue.")
|
||||
else: return _invalid("Unknown elevator trip state.")
|
||||
next_trips[actor_id] = trip.duplicate(true)
|
||||
for actor_id: StringName in next_trips:
|
||||
var trip: Dictionary = next_trips[actor_id]
|
||||
var partner: StringName = trip.get(&"companion_id", &"")
|
||||
if partner.is_empty(): continue
|
||||
if partner == actor_id or not next_trips.has(partner): return _invalid("Missing companion trip.")
|
||||
var other: Dictionary = next_trips[partner]
|
||||
if other.get(&"companion_id", &"") != actor_id: return _invalid("Companion ownership is not reciprocal.")
|
||||
for key: StringName in [&"shaft_id", &"connector_id", &"from", &"to", &"state", &"enqueued_at"]:
|
||||
if trip[key] != other[key]: return _invalid("Companion trip was split.")
|
||||
for actor_id: StringName in boarded_owners:
|
||||
if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.")
|
||||
var path_snapshot := _path.create_snapshot()
|
||||
@@ -444,7 +502,10 @@ func _dispatch(car: Dictionary) -> void:
|
||||
func _board(car: Dictionary) -> void:
|
||||
var config: Dictionary = car[&"config"]
|
||||
var connector_id := _connector_id(config[&"shaft_id"], car[&"boarding_floor"], car[&"target_floor"])
|
||||
var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"])
|
||||
var companions: Dictionary = {}
|
||||
for actor: StringName in _trips:
|
||||
if _trips[actor].has(&"companion_id"): companions[actor] = _trips[actor][&"companion_id"]
|
||||
var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"], companions)
|
||||
car[&"boarded"] = []
|
||||
for request: Dictionary in boarded:
|
||||
var actor_id: StringName = request[&"unit_id"]
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
class_name BusinessManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
## Policy drafts are local; accepted commands and read models belong to Runtime.
|
||||
signal status_requested(message: String, success: bool)
|
||||
signal action_requested(action: StringName)
|
||||
|
||||
const KEY_PREFIX := "ui.business."
|
||||
const COMMAND: StringName = &"set_facility_policy"
|
||||
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与は発生します。"
|
||||
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与も発生します。"
|
||||
const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。"
|
||||
const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。"
|
||||
const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"}
|
||||
const REASONS := {
|
||||
&"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。",
|
||||
@@ -53,7 +55,9 @@ func _ready() -> void:
|
||||
_build_interface()
|
||||
_built = true
|
||||
if _runtime != null:
|
||||
if _runtime.has_signal(&"snapshot_published"):
|
||||
if _runtime.has_signal(&"state_updated"):
|
||||
_runtime.connect(&"state_updated", _on_snapshot_published)
|
||||
elif _runtime.has_signal(&"snapshot_published"):
|
||||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
@@ -85,6 +89,7 @@ func _build_interface() -> void:
|
||||
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||||
business_open_button.add_theme_color_override(color_name, Color("55371e"))
|
||||
business_open_button.add_theme_color_override("font_disabled_color", Color("766650"))
|
||||
LayerSkin.register_toggle_text(business_open_button, LayerSkin.active_layer(self))
|
||||
business_open_button.add_theme_font_size_override("font_size", 13)
|
||||
business_open_button.toggled.connect(func(_enabled: bool) -> void: _store_draft())
|
||||
add_child(business_open_button)
|
||||
@@ -129,9 +134,13 @@ func refresh() -> void:
|
||||
business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true)))
|
||||
var pending := _has_pending(selected_facility_id)
|
||||
var can_price := bool(row.get(&"can_price", false))
|
||||
var is_cleaning := StringName(row.get(&"definition_id", &"")) == &"cleaning_room"
|
||||
var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"]
|
||||
business_open_button.text = "営業する" if is_supply else "新規の受付を開く"
|
||||
explanation_label.text = SUPPLY_POLICY_EXPLANATION if is_supply else GUEST_POLICY_EXPLANATION
|
||||
if is_cleaning:
|
||||
business_open_button.text = "清掃業務を有効にする"
|
||||
explanation_label.text = "配置した清掃員が汚れた施設へ向かいます。清掃業務を無効にすると、途中の清掃も中断します。維持費と給与は継続します。"
|
||||
facility_option.disabled = _rows(&"facilities").is_empty()
|
||||
price_option.disabled = row.is_empty() or not can_price or pending or price_option.item_count == 0
|
||||
business_open_button.disabled = row.is_empty() or pending
|
||||
@@ -141,21 +150,36 @@ func refresh() -> void:
|
||||
facility_status_label.text = "施設が完成すると営業を設定できます。"
|
||||
quote_label.text = "施設を選んでください。"
|
||||
effect_label.text = ""
|
||||
if int(row.get(&"faith_per_service", 0)) > 0:
|
||||
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
|
||||
today_label.text = "本日の実績はまだありません。"
|
||||
else:
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_count", 0)), int(row.get(&"active_count", 0))]
|
||||
facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"]
|
||||
if is_supply:
|
||||
facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
if is_cleaning:
|
||||
facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))]
|
||||
if int(row.get(&"cleanliness", -1)) >= 0:
|
||||
facility_status_label.text += "\n清潔度 %d / 100" % int(row[&"cleanliness"])
|
||||
if not String(row.get(&"cleaning_status", "")).is_empty():
|
||||
facility_status_label.text += "\n" + String(row[&"cleaning_status"])
|
||||
if can_price:
|
||||
var mode := StringName(draft.get(&"price_mode", &"standard"))
|
||||
var quoted := FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode})
|
||||
var unit := String({&"standard_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
|
||||
var quoted := FacilityPricing.apply_multiplier(FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode}) * int(row.get(&"donation_multiplier", 1)), int(row.get(&"floor_price_multiplier_bp", 10000)))
|
||||
var unit := String({&"standard_room": "泊", &"premium_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回"))
|
||||
quote_label.text = "設定後 %dG / %s\n現在 %dG · 標準 %dG" % [quoted, unit, int(row.get(&"price", 0)), int(row.get(&"base_price", 0))]
|
||||
effect_label.text = _price_effect(mode)
|
||||
if int(row.get(&"donation_multiplier", 1)) == 3:
|
||||
effect_label.text += "\nアメリア配置:寄付金3倍"
|
||||
if int(row.get(&"floor_price_multiplier_bp", 10000)) == 12000:
|
||||
effect_label.text += "\nアンリ配置フロア:利用料金+20%"
|
||||
else:
|
||||
quote_label.text = "この施設に利用料金はありません。"
|
||||
effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。"
|
||||
today_label.text = "本日の記帳 · 06:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌朝の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
|
||||
if is_cleaning: effect_label.text = "清掃業務の有効・無効を設定できます。"
|
||||
if int(row.get(&"faith_per_service", 0)) > 0:
|
||||
effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"])
|
||||
today_label.text = "本日の記帳 · 00:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌日の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))]
|
||||
_refreshing = false
|
||||
|
||||
|
||||
@@ -263,7 +287,7 @@ func _on_command_completed(result: Variant) -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||||
if is_visible_in_tree():
|
||||
refresh()
|
||||
|
||||
@@ -344,7 +368,7 @@ func _facility_row(id: StringName) -> Dictionary:
|
||||
|
||||
func _set_result(message: String, success: bool) -> void:
|
||||
result_label.text = message
|
||||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||||
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
|
||||
@@ -357,6 +381,7 @@ func _label(value: String, node_name := "") -> Label:
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(label, LayerSkin.active_layer(self))
|
||||
return label
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
class_name DefenseManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
## Player controls for an incident that already exists. Threat creation,
|
||||
## combat balancing, skill authoring and automatic combat belong to Runtime.
|
||||
signal status_requested(message: String, success: bool)
|
||||
|
||||
const KEY_PREFIX := "ui.defense."
|
||||
const COMMANDS := [&"register_defense_combatant", &"register_defense_asset", &"start_defense_raid", &"set_defense_retreat_policy", &"begin_next_defense_wave", &"retreat_defenders"]
|
||||
const STATE_NAMES := ["準備中", "戦闘中", "次の襲撃待ち", "撃退", "突破された", "撤退", "準備終了"]
|
||||
const SOURCE_NAMES := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"}
|
||||
const POLICIES := [&"HOLD", &"MANUAL", &"THRESHOLD", &"PROTECT_AND_RETREAT"]
|
||||
const POLICY_LABELS := ["持ち場を守る", "自分で撤退を指示", "体力が減ったら撤退", "体力・施設の損傷で撤退"]
|
||||
|
||||
var selected_raid_id: StringName = &""
|
||||
var selected_staff_id: StringName = &""
|
||||
var selected_objective_id: StringName = &""
|
||||
var raid_option: OptionButton
|
||||
var staff_option: OptionButton
|
||||
var objective_option: OptionButton
|
||||
var policy_option: OptionButton
|
||||
var threshold_spin: SpinBox
|
||||
var summary_label: Label
|
||||
var defenders_label: Label
|
||||
var assets_label: Label
|
||||
var staff_label: Label
|
||||
var objective_label: Label
|
||||
var result_label: Label
|
||||
var register_staff_button: Button
|
||||
var register_objective_button: Button
|
||||
var start_button: Button
|
||||
var policy_button: Button
|
||||
var next_wave_button: Button
|
||||
var retreat_button: Button
|
||||
var confirmation: ConfirmationDialog
|
||||
|
||||
var _runtime: Variant
|
||||
var _style_button: Callable
|
||||
var _report: Dictionary = {}
|
||||
var _raid_labels: Dictionary = {}
|
||||
var _pending: Dictionary = {}
|
||||
var _queue_instance_id := 0
|
||||
var _sequence := 1
|
||||
var _built := false
|
||||
var _confirmation_context: Dictionary = {}
|
||||
var _policy_drafts: Dictionary = {}
|
||||
var _refreshing := false
|
||||
|
||||
|
||||
func configure(runtime: Variant, style_button := Callable()) -> void:
|
||||
_runtime = runtime
|
||||
_style_button = style_button
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
name = "DefenseManagementPanel"
|
||||
size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
add_theme_constant_override("separation", 6)
|
||||
_build_interface()
|
||||
_built = true
|
||||
if _runtime != null:
|
||||
if _runtime.has_signal(&"state_updated"):
|
||||
_runtime.connect(&"state_updated", _on_snapshot_published)
|
||||
elif _runtime.has_signal(&"snapshot_published"):
|
||||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
refresh()
|
||||
|
||||
|
||||
func _build_interface() -> void:
|
||||
raid_option = _option("DefenseRaidOption")
|
||||
raid_option.fit_to_longest_item = false
|
||||
raid_option.clip_text = true
|
||||
raid_option.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
raid_option.item_selected.connect(func(index: int) -> void: select_raid(StringName(raid_option.get_item_metadata(index))))
|
||||
add_child(raid_option)
|
||||
summary_label = _label("", "DefenseSummary")
|
||||
add_child(summary_label)
|
||||
defenders_label = _label("", "DefenseParticipants")
|
||||
add_child(defenders_label)
|
||||
assets_label = _label("", "DefenseAssets")
|
||||
add_child(assets_label)
|
||||
add_child(HSeparator.new())
|
||||
add_child(_label("参加する従業員"))
|
||||
staff_option = _option("DefenseStaffOption")
|
||||
staff_option.item_selected.connect(func(index: int) -> void:
|
||||
selected_staff_id = StringName(staff_option.get_item_metadata(index))
|
||||
_refresh_controls())
|
||||
add_child(staff_option)
|
||||
staff_label = _label("", "DefenseStaffStatus")
|
||||
add_child(staff_label)
|
||||
register_staff_button = _button("防衛に参加", "DefenseRegisterStaff", _register_staff)
|
||||
add_child(register_staff_button)
|
||||
add_child(_label("防衛する施設"))
|
||||
objective_option = _option("DefenseObjectiveOption")
|
||||
objective_option.item_selected.connect(func(index: int) -> void:
|
||||
selected_objective_id = StringName(objective_option.get_item_metadata(index))
|
||||
_refresh_controls())
|
||||
add_child(objective_option)
|
||||
objective_label = _label("", "DefenseObjectiveStatus")
|
||||
add_child(objective_label)
|
||||
register_objective_button = _button("防衛対象に登録", "DefenseRegisterObjective", _register_objective)
|
||||
add_child(register_objective_button)
|
||||
add_child(HSeparator.new())
|
||||
add_child(_label("撤退の方針"))
|
||||
policy_option = _option("DefenseRetreatPolicy")
|
||||
for i: int in POLICIES.size():
|
||||
policy_option.add_item(POLICY_LABELS[i])
|
||||
policy_option.set_item_metadata(i, POLICIES[i])
|
||||
policy_option.item_selected.connect(func(_index: int) -> void: _store_policy_draft())
|
||||
add_child(policy_option)
|
||||
var threshold_row := HBoxContainer.new()
|
||||
threshold_row.add_child(_label("撤退する残量(%)"))
|
||||
threshold_spin = SpinBox.new()
|
||||
threshold_spin.name = "DefenseRetreatThreshold"
|
||||
threshold_spin.min_value = 0
|
||||
threshold_spin.max_value = 100
|
||||
threshold_spin.step = 1
|
||||
threshold_spin.value_changed.connect(func(_value: float) -> void: _store_policy_draft())
|
||||
threshold_row.add_child(threshold_spin)
|
||||
add_child(threshold_row)
|
||||
policy_button = _button("撤退方針を適用", "DefenseApplyPolicy", _apply_policy)
|
||||
add_child(policy_button)
|
||||
var actions := HBoxContainer.new()
|
||||
start_button = _button("防衛を開始", "DefenseStart", func() -> void: _submit(&"start_defense_raid"))
|
||||
next_wave_button = _button("次の襲撃に対応", "DefenseNextWave", func() -> void: _submit(&"begin_next_defense_wave"))
|
||||
retreat_button = _button("撤退する", "DefenseRetreat", _request_retreat_confirmation)
|
||||
for button: Button in [start_button, next_wave_button, retreat_button]:
|
||||
button.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
actions.add_child(button)
|
||||
add_child(actions)
|
||||
result_label = _label("", "DefenseResult")
|
||||
add_child(result_label)
|
||||
confirmation = ConfirmationDialog.new()
|
||||
confirmation.name = "DefenseConfirmation"
|
||||
confirmation.ok_button_text = "実行する"
|
||||
confirmation.cancel_button_text = "戻る"
|
||||
confirmation.confirmed.connect(_confirm_operation)
|
||||
confirmation.canceled.connect(func() -> void: _confirmation_context.clear())
|
||||
add_child(confirmation)
|
||||
confirmation.get_label().add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(confirmation.get_label(), LayerSkin.active_layer(self))
|
||||
if _style_button.is_valid():
|
||||
for button: Button in [confirmation.get_ok_button(), confirmation.get_cancel_button()]:
|
||||
_style_button.call(button)
|
||||
|
||||
|
||||
func select_raid(raid_id: StringName) -> void:
|
||||
selected_raid_id = raid_id
|
||||
selected_staff_id = &""
|
||||
selected_objective_id = &""
|
||||
_confirmation_context.clear()
|
||||
if confirmation != null: confirmation.hide()
|
||||
refresh()
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if not _built:
|
||||
return
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
if queue != null and queue.get_instance_id() != _queue_instance_id:
|
||||
_queue_instance_id = queue.get_instance_id()
|
||||
_policy_drafts.clear()
|
||||
_confirmation_context.clear()
|
||||
confirmation.hide()
|
||||
for option: OptionButton in [raid_option, staff_option, objective_option, policy_option]:
|
||||
option.get_popup().hide()
|
||||
_synchronize_commands(queue)
|
||||
_report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {}
|
||||
_raid_labels = _build_raid_labels(_report.get(&"raids", []))
|
||||
selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &""))
|
||||
selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id)
|
||||
selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id)
|
||||
selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id)
|
||||
var row: Dictionary = _report.get(&"selected", {})
|
||||
var draft: Dictionary = _policy_drafts.get(selected_raid_id, row)
|
||||
var policy := StringName(draft.get(&"retreat_policy", &"HOLD"))
|
||||
_refreshing = true
|
||||
policy_option.select(maxi(POLICIES.find(policy), 0))
|
||||
threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0)))
|
||||
_refreshing = false
|
||||
summary_label.text = "進行中・保存済みの防衛戦はありません。" if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"]
|
||||
var names: Array[String] = []
|
||||
for defender: Dictionary in _report.get(&"defenders", []):
|
||||
var employee := _row(&"staff", &"staff_id", defender[&"actor_id"])
|
||||
names.append("%s 体力%d/%d・戦闘%d" % [employee.get(&"display_name", defender[&"actor_id"]), defender[&"health"], defender[&"max_health"], defender[&"combat"]])
|
||||
defenders_label.text = "参加者:" + ("、".join(names) if not names.is_empty() else "未登録")
|
||||
names.clear()
|
||||
for asset: Dictionary in _report.get(&"assets", []):
|
||||
var facility := _row(&"objectives", &"facility_id", asset[&"asset_id"])
|
||||
names.append("%s 耐久%d/%d" % [facility.get(&"display_name", asset[&"asset_id"]), asset[&"integrity"], asset[&"max_integrity"]])
|
||||
assets_label.text = "対象:" + ("、".join(names) if not names.is_empty() else "未登録")
|
||||
_refresh_controls()
|
||||
|
||||
|
||||
func _refresh_controls() -> void:
|
||||
var row: Dictionary = _report.get(&"selected", {})
|
||||
var state := int(row.get(&"state", -1))
|
||||
var pending := _has_pending(selected_raid_id)
|
||||
var blocked := row.is_empty() or pending or not bool(_report.get(&"can_control", false))
|
||||
var staff := _row(&"staff", &"staff_id", selected_staff_id)
|
||||
var objective := _row(&"objectives", &"facility_id", selected_objective_id)
|
||||
staff_label.text = "従業員がいません。" if staff.is_empty() else "体力%d/100・戦闘%d\n%s" % [staff[&"health"], staff[&"combat"], "参加できます。参加中は配属を変更できません。" if staff[&"can_register"] else staff[&"reason"]]
|
||||
objective_label.text = "防衛対象がありません。" if objective.is_empty() else "耐久%d/10000\n%s" % [objective[&"durability"], "登録できます。" if objective[&"can_register"] else objective[&"reason"]]
|
||||
register_staff_button.disabled = blocked or staff.is_empty() or not bool(staff.get(&"can_register", false))
|
||||
register_objective_button.disabled = blocked or objective.is_empty() or not bool(objective.get(&"can_register", false))
|
||||
start_button.visible = state == DefenseService.RaidState.PREPARING
|
||||
start_button.disabled = blocked or not (row.get(&"missing_objectives", []) as Array).is_empty()
|
||||
next_wave_button.visible = state == DefenseService.RaidState.BETWEEN_WAVES
|
||||
next_wave_button.disabled = blocked
|
||||
retreat_button.visible = state in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]
|
||||
retreat_button.disabled = blocked
|
||||
var terminal := state in DefenseService.TERMINAL_STATES or state < 0
|
||||
policy_option.disabled = blocked or terminal
|
||||
var chosen_policy := StringName(policy_option.get_item_metadata(policy_option.selected))
|
||||
threshold_spin.editable = not blocked and not terminal and chosen_policy in [&"THRESHOLD", &"PROTECT_AND_RETREAT"]
|
||||
policy_button.disabled = blocked or terminal
|
||||
staff_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or staff.is_empty()
|
||||
objective_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or objective.is_empty()
|
||||
if pending: result_label.text = "指示を受理しました。次の時間更新で反映します。"
|
||||
|
||||
|
||||
func _register_staff() -> void:
|
||||
var staff := _row(&"staff", &"staff_id", selected_staff_id)
|
||||
if staff.is_empty() or not bool(staff.get(&"can_register", false)): return
|
||||
_submit(&"register_defense_combatant", {&"actor_id": selected_staff_id, &"side": &"DEFENDER", &"combat": int(staff[&"combat"])})
|
||||
|
||||
|
||||
func _register_objective() -> void:
|
||||
var facility := _row(&"objectives", &"facility_id", selected_objective_id)
|
||||
if facility.is_empty() or not bool(facility.get(&"can_register", false)): return
|
||||
# Plain objectives have no defense-device bonus. Omit numeric overrides and
|
||||
# use the existing Runtime command defaults and current live durability.
|
||||
_submit(&"register_defense_asset", {&"asset_id": selected_objective_id, &"kind": &"OBJECTIVE"})
|
||||
|
||||
|
||||
func _apply_policy() -> void:
|
||||
_submit(&"set_defense_retreat_policy", {&"policy": policy_option.get_item_metadata(policy_option.selected), &"threshold": int(threshold_spin.value)})
|
||||
|
||||
|
||||
func _store_policy_draft() -> void:
|
||||
if _refreshing or selected_raid_id.is_empty() or _has_pending(selected_raid_id): return
|
||||
_policy_drafts[selected_raid_id] = {&"retreat_policy": policy_option.get_item_metadata(policy_option.selected), &"defender_retreat_threshold": int(threshold_spin.value)}
|
||||
_refresh_controls()
|
||||
|
||||
|
||||
func _request_retreat_confirmation() -> void:
|
||||
refresh()
|
||||
var row: Dictionary = _report.get(&"selected", {})
|
||||
if _has_pending(selected_raid_id) or selected_raid_id.is_empty() or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: return
|
||||
_confirmation_context = {&"raid_id": selected_raid_id, &"queue_instance_id": _queue_instance_id}
|
||||
confirmation.title = "撤退の確認"
|
||||
confirmation.dialog_text = "撤退すると防衛戦は終了します。参加者の状態と施設の損傷が反映されます。"
|
||||
confirmation.popup_centered(Vector2i(430, 170))
|
||||
|
||||
|
||||
func _confirm_operation() -> void:
|
||||
var context := _confirmation_context.duplicate()
|
||||
_confirmation_context.clear()
|
||||
var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null
|
||||
if queue == null or queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)) or context.get(&"raid_id", &"") != selected_raid_id:
|
||||
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
|
||||
refresh()
|
||||
return
|
||||
refresh()
|
||||
if context.get(&"raid_id", &"") != selected_raid_id:
|
||||
_set_result("対象が変わりました。防衛戦を選び直してください。", false)
|
||||
return
|
||||
var row: Dictionary = _report.get(&"selected", {})
|
||||
if _has_pending(selected_raid_id) or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]:
|
||||
_set_result("現在の防衛戦では撤退を指示できません。", false)
|
||||
return
|
||||
_submit(&"retreat_defenders")
|
||||
|
||||
|
||||
func _submit(command_type: StringName, patch: Dictionary = {}) -> void:
|
||||
if selected_raid_id.is_empty() or _has_pending(selected_raid_id) or command_type not in COMMANDS: return
|
||||
var payload := patch.duplicate(true)
|
||||
payload[&"raid_id"] = selected_raid_id
|
||||
var key := StringName("%s%08d" % [KEY_PREFIX, _sequence])
|
||||
_sequence += 1
|
||||
var result: Variant = _runtime.call(&"submit_command", command_type, payload, &"player", selected_raid_id, 75, -1, key)
|
||||
if result == null or not result.has_method(&"is_success"):
|
||||
_set_result("防衛の指示に応答がありません。", false)
|
||||
return
|
||||
var queue: Variant = _runtime.get(&"command_queue")
|
||||
var queued := false
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
queued = queued or int(command.get("command_id", 0)) == int(result.command_id)
|
||||
if result.is_success() and queued:
|
||||
_pending[int(result.command_id)] = payload
|
||||
else:
|
||||
_show_result(result, payload)
|
||||
refresh()
|
||||
|
||||
|
||||
func _show_result(result: Variant, payload: Dictionary) -> void:
|
||||
if result.is_success() and payload.has(&"policy"):
|
||||
_policy_drafts.erase(StringName(payload.get(&"raid_id", &"")))
|
||||
_set_result("防衛の指示を反映しました。" if result.is_success() else "指示を反映できませんでした。体調・業務中の状態・対象施設を確認してください。", result.is_success())
|
||||
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending.clear()
|
||||
for metadata: Dictionary in queue.get_accepted_command_metadata():
|
||||
var key := String(metadata.get("idempotency_key", ""))
|
||||
if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int():
|
||||
_sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
|
||||
for command: Dictionary in queue.create_snapshot().get("pending", []):
|
||||
if StringName(command.get("command_type", &"")) in COMMANDS:
|
||||
_pending[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true)
|
||||
|
||||
|
||||
func _on_command_completed(result: Variant) -> void:
|
||||
if not _pending.has(int(result.command_id)): return
|
||||
var payload: Dictionary = _pending[int(result.command_id)]
|
||||
_pending.erase(int(result.command_id))
|
||||
_show_result(result, payload)
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||||
if is_visible_in_tree(): refresh()
|
||||
|
||||
|
||||
func _has_pending(raid_id: StringName) -> bool:
|
||||
for payload: Dictionary in _pending.values():
|
||||
if payload.get(&"raid_id", &"") == raid_id: return true
|
||||
return false
|
||||
|
||||
|
||||
func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Dictionary:
|
||||
for row: Dictionary in _report.get(key, []):
|
||||
if row.get(id_key, &"") == entity_id: return row
|
||||
return {}
|
||||
|
||||
|
||||
func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName) -> StringName:
|
||||
if option.get_popup().visible: return selected
|
||||
option.clear()
|
||||
if rows.is_empty():
|
||||
option.add_item("対象なし")
|
||||
option.set_item_metadata(0, &"")
|
||||
return &""
|
||||
var index := 0
|
||||
for row: Dictionary in rows:
|
||||
var id := StringName(row[id_key])
|
||||
var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id))
|
||||
option.add_item(display_name)
|
||||
if id_key == &"raid_id": option.set_item_tooltip(option.item_count - 1, display_name)
|
||||
option.set_item_metadata(option.item_count - 1, id)
|
||||
if id == selected: index = option.item_count - 1
|
||||
option.select(index)
|
||||
return StringName(option.get_item_metadata(index))
|
||||
|
||||
|
||||
## Cause labels are display-only. Never infer cause from an internal ID or
|
||||
## source_id, and never use the label as an option's metadata or command target.
|
||||
func _build_raid_labels(rows: Array) -> Dictionary:
|
||||
var totals: Dictionary = {}
|
||||
for row: Dictionary in rows:
|
||||
var base := _raid_base_label(row)
|
||||
totals[base] = int(totals.get(base, 0)) + 1
|
||||
var seen: Dictionary = {}
|
||||
var labels: Dictionary = {}
|
||||
for row: Dictionary in rows:
|
||||
var base := _raid_base_label(row)
|
||||
seen[base] = int(seen.get(base, 0)) + 1
|
||||
labels[StringName(row[&"raid_id"])] = base + ("(%d)" % int(seen[base]) if int(totals[base]) > 1 else "")
|
||||
return labels
|
||||
|
||||
|
||||
func _raid_base_label(row: Dictionary) -> String:
|
||||
var cause := String(SOURCE_NAMES.get(String(row.get(&"source_kind", "")), "防衛戦"))
|
||||
var raw_minute: Variant = row.get(&"created_at", null)
|
||||
if typeof(raw_minute) != TYPE_INT or int(raw_minute) < 0:
|
||||
return cause + " · 時刻不明"
|
||||
var minute := int(raw_minute)
|
||||
return "%s · %d日目 %02d:%02d" % [cause, minute / 1440 + 1, minute % 1440 / 60, minute % 60]
|
||||
|
||||
|
||||
func _set_result(message: String, success: bool) -> void:
|
||||
result_label.text = message
|
||||
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
|
||||
func _label(text_value: String, node_name := "") -> Label:
|
||||
var label := Label.new()
|
||||
if not node_name.is_empty(): label.name = node_name
|
||||
label.text = text_value
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(label, LayerSkin.active_layer(self))
|
||||
return label
|
||||
|
||||
|
||||
func _option(node_name: String) -> OptionButton:
|
||||
var option := OptionButton.new()
|
||||
option.name = node_name
|
||||
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
if _style_button.is_valid(): _style_button.call(option)
|
||||
return option
|
||||
|
||||
|
||||
func _button(text_value: String, node_name: String, callback: Callable) -> Button:
|
||||
var button := Button.new()
|
||||
button.name = node_name
|
||||
button.text = text_value
|
||||
button.custom_minimum_size.y = 30
|
||||
button.pressed.connect(callback)
|
||||
if _style_button.is_valid(): _style_button.call(button)
|
||||
return button
|
||||
@@ -0,0 +1 @@
|
||||
uid://s1oh611b0xrj
|
||||
@@ -0,0 +1,149 @@
|
||||
class_name GuestObservationPanel
|
||||
extends VBoxContainer
|
||||
|
||||
const NEED_NAMES := {&"hunger": "食欲", &"sleep": "睡眠欲", &"leisure": "娯楽欲", &"intimacy": "性欲", &"hygiene": "衛生", &"medical": "救護"}
|
||||
const STATES := ["到着", "入館", "行先を検討", "移動中", "待機中", "利用中", "退館中", "退館", "再検討", "待機終了", "中断", "避難中", "気絶"]
|
||||
const REJECTION_NAMES := {
|
||||
"MISSING_INPUTS": "食材・資材不足", "FACILITY_CLOSED": "閉店・稼働停止", "UNREACHABLE": "経路なし",
|
||||
"CANNOT_PAY": "利用可能な資金が不足", "ETA_AFTER_EXIT": "出発予定に間に合わない",
|
||||
"WAIT_HARD_LIMIT": "待ち時間が長すぎる", "QUALITY_TOO_LOW": "希望する品質に届かない",
|
||||
"RISK_REFUSED": "危険度が高い", "ILLEGALITY_REFUSED": "違法性を敬遠",
|
||||
"MISSING_PERMISSION": "利用資格なし", "FORBIDDEN_GUEST_TAG": "利用対象外",
|
||||
"GUEST_TYPE_NOT_ALLOWED": "利用対象外", "ADULT_NOT_VERIFIED": "年齢条件を満たさない",
|
||||
"CONTENT_FILTERED": "利用設定の対象外", "INVALID_NEED_MAP": "施設設定を確認できない",
|
||||
}
|
||||
var guests: OptionButton
|
||||
var details: Label
|
||||
var decision_label: Label
|
||||
var priority_label: Label
|
||||
var bars: Dictionary = {}
|
||||
var selected_guest_id: StringName = &""
|
||||
var _runtime: Variant
|
||||
var _ids: Array[StringName] = []
|
||||
var _elapsed := 0.0
|
||||
|
||||
func configure(runtime: Variant) -> void:
|
||||
_runtime = runtime
|
||||
|
||||
func _ready() -> void:
|
||||
name = "GuestObservationPanel"
|
||||
custom_minimum_size = Vector2(420, 340)
|
||||
add_theme_constant_override("separation", 8)
|
||||
guests = OptionButton.new()
|
||||
guests.item_selected.connect(func(index: int) -> void:
|
||||
selected_guest_id = StringName(guests.get_item_metadata(index))
|
||||
refresh())
|
||||
add_child(guests)
|
||||
details = _label()
|
||||
for need: StringName in GuestService.PRIMARY_NEEDS:
|
||||
var row := HBoxContainer.new()
|
||||
var title := Label.new()
|
||||
title.text = NEED_NAMES[need]
|
||||
title.custom_minimum_size.x = 85
|
||||
row.add_child(title)
|
||||
var bar := ProgressBar.new()
|
||||
bar.max_value = 100
|
||||
bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
row.add_child(bar)
|
||||
bars[need] = bar
|
||||
add_child(row)
|
||||
priority_label = _label()
|
||||
decision_label = _label()
|
||||
_label().text = "欲求が高いほど、そのサービスを優先します。\n利用できる施設の中で欲求を優先し、同じ優先度なら距離・行列・料金などで比較します。\n表示は現在の欲求です。移動・利用の途中で目的地を勝手に変更しません。"
|
||||
refresh()
|
||||
|
||||
func _label() -> Label:
|
||||
var label := Label.new()
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
add_child(label)
|
||||
return label
|
||||
|
||||
func _process(delta: float) -> void:
|
||||
if not is_visible_in_tree() or not get_window().visible:
|
||||
return
|
||||
_elapsed += delta
|
||||
if _elapsed >= 0.5:
|
||||
_elapsed = 0
|
||||
refresh()
|
||||
|
||||
func refresh() -> void:
|
||||
if guests == null or _runtime == null:
|
||||
return
|
||||
var service: Variant = _runtime.get(&"guest_service")
|
||||
if service == null:
|
||||
return
|
||||
var next: Array[StringName] = []
|
||||
for guest: GuestRuntime in service.get_guests(): next.append(guest.guest_id)
|
||||
if next != _ids:
|
||||
_ids = next
|
||||
guests.clear()
|
||||
for id: StringName in _ids:
|
||||
guests.add_item(String(id))
|
||||
guests.set_item_metadata(guests.item_count-1, id)
|
||||
if not _ids.has(selected_guest_id):
|
||||
selected_guest_id = _ids[0] if not _ids.is_empty() else &""
|
||||
guests.disabled = _ids.is_empty()
|
||||
if not _ids.is_empty(): guests.select(_ids.find(selected_guest_id))
|
||||
var report: Dictionary = service.get_observation(selected_guest_id)
|
||||
for need: StringName in bars:
|
||||
bars[need].value = report.get(&"needs", {}).get(need, 0)
|
||||
if report.is_empty():
|
||||
details.text = "現在、来客はいません。"
|
||||
priority_label.text = ""
|
||||
decision_label.text = ""
|
||||
return
|
||||
var target := String(report[&"target_facility_id"])
|
||||
var facility: Variant = _runtime.facility_service.get_instance(StringName(target))
|
||||
if facility != null:
|
||||
var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id)
|
||||
if definition != null: target = definition.display_name
|
||||
if target.is_empty(): target = "未選択"
|
||||
details.text = "%s · 所持金 %dG · 忍耐 %d\n目的地: %s" % [STATES[int(report[&"state"])], report[&"wallet_gold"], report[&"patience"], target]
|
||||
var names: PackedStringArray = []
|
||||
for need: StringName in report[&"primary_needs"]: names.append(NEED_NAMES[need])
|
||||
priority_label.text = "現在最も高い欲求: " + ("なし" if names.is_empty() else "・".join(names))
|
||||
decision_label.text = _decision_text(_runtime.get_guest_decision_record(selected_guest_id))
|
||||
|
||||
|
||||
func _decision_text(record: Dictionary) -> String:
|
||||
if record.is_empty():
|
||||
return "直近の行先判断:記録なし(判断前、旧セーブ、または履歴の保存範囲外)"
|
||||
var minute := int(record.get(&"absolute_minute", 0))
|
||||
var title := "直近の行先判断 · %d日目 %02d:%02d" % [minute / 1440 + 1, (minute % 1440) / 60, minute % 60]
|
||||
if StringName(record.get(&"action", &"")) == &"LEAVE":
|
||||
return title + "\n条件を満たして選べる施設がなく、退館を選びました。" + _rejection_text(record)
|
||||
var id := StringName(record.get(&"facility_id", &""))
|
||||
var destination := String(id)
|
||||
var facility: Variant = _runtime.facility_service.get_instance(id)
|
||||
if facility != null:
|
||||
var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id)
|
||||
if definition != null: destination = definition.display_name
|
||||
var needs: PackedStringArray = []
|
||||
for need: StringName in record.get(&"primary_needs", []):
|
||||
needs.append("%s %d" % [NEED_NAMES.get(need, String(need)), int(record.get(&"needs", {}).get(need, 0))])
|
||||
return title + "\n選択:%s / 判断時の最大欲求:%s\n移動 %d分・待ち %d分・見込料金 %dG\n選択候補の欲求優先度 %d・比較評価 %d(判断時点)" % [destination, "・".join(needs) if not needs.is_empty() else "なし", int(record.get(&"travel_minutes", 0)), int(record.get(&"wait_minutes", 0)), int(record.get(&"estimated_total_gold", 0)), int(record.get(&"need_priority", 0)), int(record.get(&"utility", 0))] + _rejection_text(record)
|
||||
|
||||
|
||||
func _rejection_text(record: Dictionary) -> String:
|
||||
if not record.has(&"rejection_counts"):
|
||||
return "\n候補の除外理由:この記録にはありません。"
|
||||
var counts: Dictionary = record[&"rejection_counts"]
|
||||
if counts.is_empty():
|
||||
return "\n今回検討した候補には、利用条件による除外はありません。"
|
||||
var reasons: PackedStringArray = []
|
||||
var keys := counts.keys()
|
||||
keys.sort()
|
||||
for reason: String in keys:
|
||||
reasons.append("%s %d施設" % [REJECTION_NAMES.get(reason, "条件を満たさない"), int(counts[reason])])
|
||||
var text := "\n判断時の除外理由:" + "・".join(reasons)
|
||||
for row: Dictionary in record.get(&"rejection_examples", []):
|
||||
var id := StringName(row.get(&"facility_id", &""))
|
||||
var facility: Variant = _runtime.facility_service.get_instance(id)
|
||||
var label := String(id)
|
||||
if facility != null:
|
||||
var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id)
|
||||
if definition != null: label = "%s(%s)" % [definition.display_name, id]
|
||||
text += "\n・%s:%s" % [label, REJECTION_NAMES.get(String(row.get(&"reason", "")), "条件を満たさない")]
|
||||
var omitted: int = int(record.get(&"rejection_count", 0)) - record.get(&"rejection_examples", []).size()
|
||||
if omitted > 0: text += "\nほか%d施設(件数は上記に含みます)" % omitted
|
||||
return text
|
||||
@@ -0,0 +1 @@
|
||||
uid://diaqemp3k1wj
|
||||
@@ -0,0 +1,18 @@
|
||||
extends Node2D
|
||||
|
||||
# Reuse the same room art at low opacity; this layer has no input handlers.
|
||||
const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd")
|
||||
var rooms: Array[Dictionary] = []
|
||||
var pixel_scale := 1.0
|
||||
var dungeon: Texture2D
|
||||
var town: Texture2D
|
||||
func _ready() -> void:
|
||||
modulate = Color(1,1,1,0.22)
|
||||
texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
|
||||
func _draw() -> void:
|
||||
for room: Dictionary in rooms:
|
||||
if bool(room.get(&"building", false)): continue
|
||||
if bool(room.get(&"legacy_stair", false)):
|
||||
PixelArt.stairs(self,room[&"rect"],room[&"rect"].size.x/14.0,true,true)
|
||||
continue
|
||||
PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{})
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn241cup6ht38
|
||||
@@ -1,6 +1,8 @@
|
||||
class_name InnOpeningGuide
|
||||
extends PanelContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
## Read-only guidance. Every action goes back through the normal dashboard.
|
||||
signal action_requested(action: StringName)
|
||||
|
||||
@@ -15,7 +17,7 @@ var _sale_economy_id := 0
|
||||
var _first_sale_minute := -1
|
||||
|
||||
|
||||
func setup(runtime: Variant, clock: Variant) -> void:
|
||||
func setup(runtime: Variant, clock: Variant, layer := 0) -> void:
|
||||
_runtime = runtime
|
||||
_clock = clock
|
||||
name = "InnOpeningGuide"
|
||||
@@ -25,6 +27,7 @@ func setup(runtime: Variant, clock: Variant) -> void:
|
||||
skin.border_color = Color("b58a45")
|
||||
skin.set_border_width_all(1)
|
||||
skin.set_content_margin_all(8)
|
||||
LayerSkin.register_style(skin, &"panel_leather", layer)
|
||||
add_theme_stylebox_override("panel", skin)
|
||||
var column := VBoxContainer.new()
|
||||
column.add_theme_constant_override("separation", 5)
|
||||
@@ -32,7 +35,8 @@ func setup(runtime: Variant, clock: Variant) -> void:
|
||||
_heading = Button.new()
|
||||
_heading.alignment = HORIZONTAL_ALIGNMENT_LEFT
|
||||
_heading.add_theme_font_size_override("font_size", 14)
|
||||
_heading.pressed.connect(func() -> void: _body.visible = not _body.visible)
|
||||
_heading.pressed.connect(_toggle_body)
|
||||
_style_button(_heading, layer)
|
||||
column.add_child(_heading)
|
||||
_body = VBoxContainer.new()
|
||||
column.add_child(_body)
|
||||
@@ -41,14 +45,33 @@ func setup(runtime: Variant, clock: Variant) -> void:
|
||||
_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
_detail.add_theme_font_size_override("font_size", 13)
|
||||
_detail.add_theme_color_override("font_color", Color("fff0cf"))
|
||||
LayerSkin.register_text(_detail, layer)
|
||||
_body.add_child(_detail)
|
||||
_action = Button.new()
|
||||
_action.add_theme_font_size_override("font_size", 13)
|
||||
_style_button(_action, layer)
|
||||
_action.pressed.connect(func() -> void: action_requested.emit(StringName(_progress.get(&"action", &""))))
|
||||
_body.add_child(_action)
|
||||
refresh()
|
||||
|
||||
|
||||
func _toggle_body() -> void:
|
||||
_body.visible = not _body.visible
|
||||
reset_size.call_deferred()
|
||||
|
||||
|
||||
func _style_button(button: Button, layer: int) -> void:
|
||||
button.add_theme_color_override("font_color", Color("fff0cf"))
|
||||
for state_name: StringName in [&"normal", &"hover", &"pressed", &"focus", &"disabled"]:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = Color("30261b") if state_name == &"normal" else Color("51402a")
|
||||
style.border_color = Color("b58a45")
|
||||
style.set_border_width_all(1)
|
||||
style.set_content_margin_all(4)
|
||||
LayerSkin.register_style(style, StringName("button_"+String(state_name if state_name != &"focus" else &"pressed")), layer)
|
||||
button.add_theme_stylebox_override(state_name, style)
|
||||
|
||||
|
||||
func refresh() -> void:
|
||||
if _runtime == null or _heading == null:
|
||||
return
|
||||
@@ -70,14 +93,14 @@ func _read_progress() -> Dictionary:
|
||||
if first_sale >= 0 and _runtime.operations_coordinator != null:
|
||||
for summary: Dictionary in _runtime.operations_coordinator.get_day_summaries():
|
||||
if int(summary.get(&"closed_at", -1)) > first_sale:
|
||||
return _step(&"complete", "初日の営業を達成", "宿泊売上と翌朝の決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る")
|
||||
return _step(&"complete", "初日の営業を達成", "宿泊売上と日次決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る")
|
||||
var reception: FacilityInstance
|
||||
var rooms: Array[FacilityInstance] = []
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"reception":
|
||||
if reception == null or _reception_score(facility) > _reception_score(reception):
|
||||
reception = facility
|
||||
elif facility.definition_id == &"standard_room":
|
||||
elif _is_guest_lodging(facility.definition_id):
|
||||
rooms.append(facility)
|
||||
if reception == null:
|
||||
return _build_step(&"reception", &"reception", "1/6 受付をつくる", "表の地表階に受付を配置します。完成した受付の上に客室を増築できます。", &"build_reception")
|
||||
@@ -95,7 +118,7 @@ func _read_progress() -> Dictionary:
|
||||
return _step(&"staff", "4/6 受付係を配属", "従業員から受付係を雇い、雇用済み一覧でこの受付と勤務帯を選び「配属・勤務帯を変更」を押します。", &"staff", "従業員を開く")
|
||||
if first_sale < 0:
|
||||
return _step(&"sale", "5/6 最初の宿泊売上", "時間を進めて、お客さまの到着と宿泊を見守りましょう。売上は現在のサービスが完了すると記帳されます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
|
||||
return _step(&"close", "6/6 翌朝の決算へ", "最初の宿泊売上が入りました。06:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
|
||||
return _step(&"close", "6/6 深夜の決算へ", "最初の宿泊売上が入りました。00:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る")
|
||||
|
||||
|
||||
func _can_reach_facility(facility: FacilityInstance) -> bool:
|
||||
@@ -103,6 +126,8 @@ func _can_reach_facility(facility: FacilityInstance) -> bool:
|
||||
return false
|
||||
# Runtime uses the same public entrance and access cells as real guests.
|
||||
# Component-only guide fixtures retain their explicit availability flags.
|
||||
if _runtime.has_method(&"is_facility_reachable"):
|
||||
return bool(_runtime.call(&"is_facility_reachable", facility.instance_id))
|
||||
if _runtime.has_method(&"get_facility_access_report"):
|
||||
var report: Dictionary = _runtime.call(&"get_facility_access_report", facility.instance_id)
|
||||
return bool(report.get(&"reachable", false))
|
||||
@@ -114,18 +139,37 @@ func _reception_score(facility: FacilityInstance) -> int:
|
||||
|
||||
|
||||
func _has_reception_assignment(reception: FacilityInstance) -> bool:
|
||||
var assigned := not reception.active_staff_ids.is_empty()
|
||||
# A repeating plan remains valid off-duty. Historical or cancelled shifts
|
||||
# cannot conceal a deliberate unassignment made before the first sale.
|
||||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(reception.definition_id)
|
||||
if definition == null:
|
||||
return false
|
||||
var unplanned_staff: Dictionary = {}
|
||||
# Repeating plans remain valid off-duty; explicit plans take precedence
|
||||
# over historical shifts. Management placement is not receptionist work.
|
||||
for staff: StaffRuntime in _runtime.staff_service.get_staff_members():
|
||||
if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles:
|
||||
continue
|
||||
if staff.staff_id in reception.active_staff_ids:
|
||||
return true
|
||||
var plan: Dictionary = _runtime.staff_service.get_work_assignment(staff.staff_id)
|
||||
if not plan.is_empty():
|
||||
assigned = assigned or StringName(plan.get(&"facility_id", &"")) == reception.instance_id
|
||||
continue
|
||||
for shift: ShiftInstance in _runtime.staff_service.get_shifts():
|
||||
if shift.staff_id == staff.staff_id and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id:
|
||||
assigned = true
|
||||
return assigned
|
||||
if StringName(plan.get(&"facility_id", &"")) == reception.instance_id:
|
||||
return true
|
||||
else:
|
||||
unplanned_staff[staff.staff_id] = true
|
||||
if unplanned_staff.is_empty():
|
||||
return false
|
||||
# Enumerate/sort the historical shifts once, not once per employee.
|
||||
for shift: ShiftInstance in _runtime.staff_service.get_shifts():
|
||||
if unplanned_staff.has(shift.staff_id) and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func _is_guest_lodging(definition_id: StringName) -> bool:
|
||||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id)
|
||||
return definition != null and definition.layer == FacilityDefinition.ViewLayer.FRONT and (
|
||||
definition.service_types.has(&"lodging") or definition.tags.has(&"lodging")
|
||||
)
|
||||
|
||||
|
||||
func _first_lodging_sale() -> int:
|
||||
@@ -139,10 +183,10 @@ func _first_lodging_sale() -> int:
|
||||
return _first_sale_minute
|
||||
var room_ids: Dictionary = {}
|
||||
for job: ConstructionJob in _runtime.construction_service.get_jobs():
|
||||
if job.definition_id == &"standard_room" and not job.facility_instance_id.is_empty():
|
||||
if _is_guest_lodging(job.definition_id) and not job.facility_instance_id.is_empty():
|
||||
room_ids[job.facility_instance_id] = true
|
||||
for facility: FacilityInstance in _runtime.facility_service.get_instances():
|
||||
if facility.definition_id == &"standard_room":
|
||||
if _is_guest_lodging(facility.definition_id):
|
||||
room_ids[facility.instance_id] = true
|
||||
if room_ids.is_empty():
|
||||
return -1
|
||||
@@ -154,7 +198,7 @@ func _first_lodging_sale() -> int:
|
||||
|
||||
func _build_step(stage: StringName, definition_id: StringName, title: String, detail: String, action: StringName) -> Dictionary:
|
||||
for job: ConstructionJob in _runtime.construction_service.get_jobs():
|
||||
if job.definition_id == definition_id and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]:
|
||||
if (job.definition_id == definition_id or (stage == &"room" and _is_guest_lodging(job.definition_id))) and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]:
|
||||
return _step(stage, title, "工事中 %d%%。時間を進めると完成します。" % (job.progress / 100), &"resume" if _clock.paused else &"", "時間を進める")
|
||||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id)
|
||||
if definition != null:
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
class_name LayerInterfaceSkin
|
||||
extends RefCounted
|
||||
## Visual-only palette for the persistent HUD. No simulation state is owned here.
|
||||
const STYLE_META := &"layer_interface_front_style"
|
||||
const TEXT_META := &"layer_interface_front_text"
|
||||
const BACK_TEXT_META := &"layer_interface_back_text"
|
||||
const BACK_PANELS := {
|
||||
&"panel_outer": Color("10191e"), &"panel_wood": Color("1a282e"),
|
||||
&"panel_leather": Color("172229"), &"panel_parchment": Color("202e34"),
|
||||
&"button_normal": Color("293c43"), &"button_hover": Color("385860"),
|
||||
&"button_pressed": Color("275954"), &"button_disabled": Color("293135"),
|
||||
}
|
||||
static func register_style(style: StyleBoxFlat, role: StringName, layer: int) -> void:
|
||||
style.set_meta(STYLE_META, {&"role":role, &"background":style.bg_color, &"border":style.border_color})
|
||||
apply_style(style, layer)
|
||||
static func apply_style(style: StyleBoxFlat, layer: int) -> void:
|
||||
if not style.has_meta(STYLE_META): return
|
||||
var front: Dictionary = style.get_meta(STYLE_META)
|
||||
var background: Color = BACK_PANELS.get(front[&"role"], Color("202e34")) if layer == 1 else front[&"background"]
|
||||
var border: Color = Color("73989a") if layer == 1 else front[&"border"]
|
||||
if style.bg_color != background: style.bg_color = background
|
||||
if style.border_color != border: style.border_color = border
|
||||
static func register_text(label: Label, layer: int) -> void:
|
||||
if not label.has_meta(TEXT_META): label.set_meta(TEXT_META, label.get_theme_color("font_color"))
|
||||
var color: Color = label.get_meta(BACK_TEXT_META, Color("d8eeea")) if layer == 1 else label.get_meta(TEXT_META)
|
||||
if label.get_theme_color("font_color") != color: label.add_theme_color_override("font_color", color)
|
||||
static func register_status_text(label: Label, success: bool, layer: int) -> void:
|
||||
label.set_meta(TEXT_META, Color("397346") if success else Color("a7402e"))
|
||||
label.set_meta(BACK_TEXT_META, Color("b5e7ba") if success else Color("ffb498"))
|
||||
register_text(label, layer)
|
||||
static func active_layer(node: Node) -> int:
|
||||
if not node.is_inside_tree(): return 0
|
||||
var state := node.get_node_or_null("/root/GameState")
|
||||
return int(state.get("active_layer")) if state != null else 0
|
||||
static func register_toggle_text(button: BaseButton, layer: int) -> void:
|
||||
const META := &"layer_interface_toggle_text"
|
||||
if not button.has_meta(META):
|
||||
var colors := {}
|
||||
for key: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color", &"font_disabled_color"]:
|
||||
colors[key] = button.get_theme_color(key)
|
||||
button.set_meta(META, colors)
|
||||
var front: Dictionary = button.get_meta(META)
|
||||
for key: StringName in front:
|
||||
var color: Color = front[key] if layer == 0 else (Color("8da4a4") if key == &"font_disabled_color" else Color("d8eeea"))
|
||||
if button.get_theme_color(key) != color: button.add_theme_color_override(key, color)
|
||||
static func apply_tree(node: Node, layer: int) -> void:
|
||||
if node is Window and node.theme != null:
|
||||
for entry: Array in [[&"panel", &"AcceptDialog"], [&"embedded_border", &"Window"]]:
|
||||
if node.theme.has_stylebox(entry[0], entry[1]):
|
||||
var window_style: StyleBox = node.theme.get_stylebox(entry[0], entry[1])
|
||||
if window_style is StyleBoxFlat: apply_style(window_style, layer)
|
||||
if node is Control:
|
||||
for key: StringName in [&"panel", &"normal", &"hover", &"pressed", &"focus", &"disabled"]:
|
||||
if node.has_theme_stylebox_override(key):
|
||||
var style: StyleBox = node.get_theme_stylebox(key)
|
||||
if style is StyleBoxFlat: apply_style(style, layer)
|
||||
if node is Label and node.has_meta(TEXT_META): register_text(node, layer)
|
||||
if node is BaseButton and node.has_meta(&"layer_interface_toggle_text"): register_toggle_text(node, layer)
|
||||
for child: Node in node.get_children(): apply_tree(child, layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bl3cvg8q4ykt7
|
||||
@@ -1,7 +1,10 @@
|
||||
class_name OperatingLedgerPanel
|
||||
extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
## Projection only: authoritative close and ledger entries own all amounts.
|
||||
var _initial_layer := 0
|
||||
var _runtime: Variant
|
||||
var _clock: Variant
|
||||
var _cash_label: Label
|
||||
@@ -12,18 +15,21 @@ var _selected_day := -1
|
||||
var _picker_days: Array[int] = []
|
||||
|
||||
|
||||
func setup(runtime: Variant, clock: Variant) -> void:
|
||||
func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void:
|
||||
_initial_layer = layer
|
||||
_runtime = runtime
|
||||
_clock = clock
|
||||
name = "OperatingLedgerPanel"
|
||||
add_theme_constant_override("separation", 8)
|
||||
var heading := Label.new()
|
||||
heading.text = "帳簿 · 06:00締め"
|
||||
heading.text = "帳簿 · 00:00締め"
|
||||
heading.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(heading, _initial_layer)
|
||||
add_child(heading)
|
||||
_cash_label = _label()
|
||||
_day_picker = OptionButton.new()
|
||||
_day_picker.name = "LedgerDayPicker"
|
||||
if style_button.is_valid(): style_button.call(_day_picker)
|
||||
_day_picker.item_selected.connect(func(index: int) -> void:
|
||||
_selected_day = _day_picker.get_item_id(index)
|
||||
refresh())
|
||||
@@ -33,16 +39,26 @@ func setup(runtime: Variant, clock: Variant) -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func select_latest_close() -> void:
|
||||
_selected_day = -1
|
||||
_day_picker.get_popup().hide()
|
||||
refresh()
|
||||
|
||||
|
||||
func get_report() -> Dictionary:
|
||||
if _runtime == null:
|
||||
return {}
|
||||
var minute: int = _clock.absolute_minute
|
||||
var business_day := maxi(1, int(floor(float(minute - 360) / 1440.0)) + 1)
|
||||
var start := (business_day - 1) * 1440 + 360
|
||||
var business_day := minute / 1440 + 1
|
||||
var start := (business_day - 1) * 1440
|
||||
if _runtime.operations_coordinator != null:
|
||||
business_day = _runtime.operations_coordinator.get_open_financial_day()
|
||||
start = _runtime.operations_coordinator.get_financial_period_start(business_day)
|
||||
var inflow := 0
|
||||
var outflow := 0
|
||||
var construction_outflow := 0
|
||||
var wage_outflow := 0
|
||||
var upkeep_outflow := 0
|
||||
for entry: LedgerEntry in _runtime.economy_service.get_entries():
|
||||
if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION":
|
||||
continue
|
||||
@@ -52,6 +68,8 @@ func get_report() -> Dictionary:
|
||||
outflow += entry.amount
|
||||
if entry.category == &"CONSTRUCTION":
|
||||
construction_outflow += entry.amount
|
||||
elif entry.category == &"MAINTENANCE_PAYMENT":
|
||||
upkeep_outflow += entry.amount
|
||||
elif entry.category == &"WAGE":
|
||||
wage_outflow += entry.amount
|
||||
var summaries: Array[Dictionary] = []
|
||||
@@ -68,6 +86,8 @@ func get_report() -> Dictionary:
|
||||
&"cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"),
|
||||
&"cash_in": inflow, &"cash_out": outflow, &"cash_net": inflow - outflow,
|
||||
&"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow,
|
||||
&"upkeep_cash_out": upkeep_outflow,
|
||||
&"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(),
|
||||
&"closed_days": summaries, &"selected_close": selected.duplicate(true),
|
||||
}
|
||||
|
||||
@@ -78,13 +98,13 @@ func refresh() -> void:
|
||||
if _day_picker.get_popup().visible:
|
||||
return
|
||||
var report := get_report()
|
||||
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"cash_net"]]
|
||||
_cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]]
|
||||
var summary: Dictionary = report[&"selected_close"]
|
||||
if summary.is_empty():
|
||||
if not _day_picker.disabled or _day_picker.item_count == 0:
|
||||
_day_picker.clear()
|
||||
_picker_days.clear()
|
||||
_day_picker.add_item("決算は翌朝06:00に確定します")
|
||||
_day_picker.add_item("決算は翌日00:00に確定します")
|
||||
_day_picker.disabled = true
|
||||
_closed_label.text = "建築後の手持ち資金だけで営業成績は決まりません。宿泊などの売上と日々の費用をここで確認できます。"
|
||||
_facility_label.text = ""
|
||||
@@ -105,6 +125,10 @@ func refresh() -> void:
|
||||
for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]:
|
||||
other += int(totals.get(key, 0))
|
||||
_closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"]
|
||||
var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"]))
|
||||
_closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"]
|
||||
var close_minute := int(summary[&"closed_at"]) % 1440
|
||||
_closed_label.text += "\n確定時刻 %02d:%02d" % [close_minute / 60, close_minute % 60]
|
||||
var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"]
|
||||
var rows: Dictionary = summary.get(&"facility_profit_loss", {})
|
||||
var ids := rows.keys()
|
||||
@@ -115,6 +139,14 @@ func refresh() -> void:
|
||||
if facility != null:
|
||||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility.definition_id)
|
||||
label = "%s (%d,%d)" % [definition.display_name if definition != null else facility.definition_id, facility.origin.x, facility.origin.y]
|
||||
else:
|
||||
# Corridors and stairs are completed construction entities without
|
||||
# service instances, but their ownership upkeep still appears here.
|
||||
var completed: Dictionary = _runtime.construction_service.get_facility(StringName(id))
|
||||
if not completed.is_empty():
|
||||
var definition: FacilityDefinition = _runtime.definition_registry.get_facility(completed[&"definition_id"])
|
||||
var origin: Vector3i = completed[&"origin"]
|
||||
label = "%s (%d,%d)" % [definition.display_name if definition != null else completed[&"definition_id"], origin.x, origin.y]
|
||||
var row: Dictionary = rows[id]
|
||||
lines.append("%s\n%dG / %dG / %+dG" % [label, row.get(&"revenue", 0), row.get(&"expenses", 0), row.get(&"net_profit", 0)])
|
||||
_facility_label.text = "\n".join(lines) if not rows.is_empty() else "施設別の記帳はまだありません。"
|
||||
@@ -124,6 +156,7 @@ func _label() -> Label:
|
||||
var label := Label.new()
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(label, _initial_layer)
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
add_child(label)
|
||||
return label
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
class_name PixelPeople
|
||||
extends RefCounted
|
||||
|
||||
## Authored side-view poses on a 16x18 grid. No neighbouring atlas characters
|
||||
## are used as animation frames. All motion is presentation-only.
|
||||
const COATS := [Color("688da0"), Color("b87659"), Color("a69a63"), Color("89789e"), Color("638d72")]
|
||||
const SKINS := [Color("edc49d"), Color("c99371"), Color("a56f50")]
|
||||
const INK := Color("303c40")
|
||||
|
||||
static func pose(mode: StringName, frame: int, variant: int, staff: bool) -> Array[Dictionary]:
|
||||
var marks: Array[Dictionary] = []
|
||||
var skin: Color = SKINS[posmod(variant, SKINS.size())]
|
||||
var coat: Color = Color("719d94") if staff else COATS[posmod(variant, COATS.size())]
|
||||
var phase := posmod(frame, 8)
|
||||
var walk := mode == &"walk" or mode == &"carry_walk"
|
||||
var work := mode in [&"work", &"clean", &"eat"]
|
||||
var stride := [0, 1, 2, 1, 0, -1, -2, -1][phase] as int
|
||||
var bob := -1 if walk and phase in [2, 6] else 0
|
||||
if mode in [&"sleep", &"collapsed"]:
|
||||
mark(marks, Rect2(2, 14, 12, 2), INK)
|
||||
mark(marks, Rect2(3, 12, 4, 3), skin)
|
||||
mark(marks, Rect2(2, 12, 2, 3), INK)
|
||||
mark(marks, Rect2(7, 12, 6, 3), coat)
|
||||
mark(marks, Rect2(13, 13, 2, 2), INK)
|
||||
if mode == &"sleep":
|
||||
mark(marks, Rect2(3 + phase / 3, 8 - phase / 3, 2, 1), Color("eee6c7"))
|
||||
return marks
|
||||
# Far arm and leg, then silhouette, face, near arm and leg.
|
||||
mark(marks, Rect2(6, 8 + bob, 2, 5), coat.darkened(0.25))
|
||||
var far := -stride if walk else 0
|
||||
var near := stride if walk else 0
|
||||
mark(marks, Rect2(7 + far, 12 + bob, 2, 4 - bob), INK.lightened(0.12))
|
||||
mark(marks, Rect2(7 + far, 16, 3, 1), INK)
|
||||
mark(marks, Rect2(6, 6 + bob, 5, 7), INK)
|
||||
mark(marks, Rect2(7, 7 + bob, 4, 5), coat)
|
||||
mark(marks, Rect2(7, 1 + bob, 4, 5), skin)
|
||||
mark(marks, Rect2(6, 1 + bob, 4, 2), INK)
|
||||
mark(marks, Rect2(6, 3 + bob, 1, 2), INK)
|
||||
mark(marks, Rect2(11, 3 + bob, 1, 2), skin)
|
||||
mark(marks, Rect2(10, 3 + bob, 1, 1), INK)
|
||||
mark(marks, Rect2(8, 6 + bob, 2, 1), skin)
|
||||
mark(marks, Rect2(8 + near, 12 + bob, 2, 4 - bob), INK)
|
||||
mark(marks, Rect2(8 + near, 16, 3, 1), INK)
|
||||
var arm := -stride if walk else 0
|
||||
if work:
|
||||
arm = 2 if phase < 4 else 1
|
||||
mark(marks, Rect2(9 + arm, 8 + bob, 2, 3), coat.lightened(0.12))
|
||||
mark(marks, Rect2(9 + arm, 11 + bob, 2, 1), skin)
|
||||
if staff:
|
||||
mark(marks, Rect2(7, 9 + bob, 2, 3), Color("e3dfc2"))
|
||||
if mode in [&"carry", &"carry_walk"]:
|
||||
mark(marks, Rect2(10, 8 + bob, 5, 5), Color("956940"))
|
||||
mark(marks, Rect2(10, 8 + bob, 5, 1), Color("d3ad6b"))
|
||||
mark(marks, Rect2(12, 9 + bob, 1, 4), Color("b48850"))
|
||||
elif mode == &"clean":
|
||||
mark(marks, Rect2(11 + arm, 9, 1, 7), Color("af9059"))
|
||||
mark(marks, Rect2(9 + arm, 16, 5, 1), Color("d8bc78"))
|
||||
elif mode == &"eat":
|
||||
mark(marks, Rect2(11, 6 if phase < 4 else 10, 3, 2), Color("f3ebcf"))
|
||||
elif mode == &"work":
|
||||
mark(marks, Rect2(11, 12, 4, 1), Color("efe7ce"))
|
||||
mark(marks, Rect2(12 + phase % 2, 10, 1, 2), Color("805e44"))
|
||||
elif mode == &"wait":
|
||||
mark(marks, Rect2(9, 9, 3, 1), skin)
|
||||
return marks
|
||||
|
||||
static func mark(marks: Array[Dictionary], rect: Rect2, color: Color) -> void:
|
||||
marks.append({&"rect": rect, &"color": color})
|
||||
|
||||
static func draw(canvas: CanvasItem, origin: Vector2, pixel: float, mode: StringName, frame: int, variant: int, staff: bool, facing_left: bool) -> void:
|
||||
for item: Dictionary in pose(mode, frame, variant, staff):
|
||||
var rect: Rect2 = item[&"rect"]
|
||||
if facing_left:
|
||||
rect.position.x = 16 - rect.end.x
|
||||
canvas.draw_rect(Rect2(origin + rect.position * pixel, rect.size * pixel), item[&"color"])
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvhvqan76tawa
|
||||
@@ -3,6 +3,7 @@ extends RefCounted
|
||||
|
||||
## Small, code-native room furnishings. Coordinates use the same pixel size as
|
||||
## Kenney's 16 px sprites. This renderer never changes simulation state.
|
||||
const Layout := preload("res://scripts/ui/room_presentation.gd")
|
||||
const INK := Color("34413b")
|
||||
const WOOD := Color("8e6348")
|
||||
const WOOD_LIGHT := Color("c39665")
|
||||
@@ -15,7 +16,7 @@ static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rec
|
||||
canvas.draw_texture_rect_region(texture, target, Rect2((index % 12) * 16, (index / 12) * 16, 16, 16), tint)
|
||||
|
||||
|
||||
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0) -> void:
|
||||
static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}) -> void:
|
||||
var p := pixel
|
||||
if id in [&"guest_stairs", &"service_stairs"]:
|
||||
stairs(canvas, rect, p, id == &"service_stairs")
|
||||
@@ -69,17 +70,14 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
counter(canvas, Vector2(mid-9*p, floor_y-6*p), 18*p, p)
|
||||
canvas.draw_rect(Rect2(mid-6*p, floor_y-8*p, 4*p, 2*p), CREAM)
|
||||
canvas.draw_rect(Rect2(mid+4*p, floor_y-8*p, 2*p, 2*p), Color("d6ad52"))
|
||||
&"standard_room", &"adult_private_room":
|
||||
bed(canvas, Vector2(rect.position.x+2*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13)
|
||||
if w >= 28*p:
|
||||
bed(canvas, Vector2(rect.end.x-15*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13)
|
||||
&"standard_room", &"premium_room", &"staff_dormitory", &"adult_private_room", &"infirmary":
|
||||
for at: Vector2 in Layout.slots(rect, p, id):
|
||||
bed(canvas, at + Vector2(-7, -9)*p, p, Color("709b94") if not dark else Color("a37b91"), 13)
|
||||
&"cafeteria", &"tavern":
|
||||
for x in range(int(rect.position.x+5*p), int(rect.end.x-13*p), int(20*p)):
|
||||
table(canvas, Vector2(x, floor_y-7*p), p)
|
||||
if rect.size.y > 28*p:
|
||||
counter(canvas, Vector2(rect.position.x+7*p, top+13*p), w-14*p, p)
|
||||
for x in range(int(rect.position.x+10*p), int(rect.end.x-8*p), int(12*p)):
|
||||
canvas.draw_rect(Rect2(x, top+11*p, 3*p, 2*p), CREAM)
|
||||
for at: Vector2 in Layout.slots(rect, p, id):
|
||||
table(canvas, at + Vector2(-2, -7)*p, p)
|
||||
&"chapel":
|
||||
chapel_furnishings(canvas, rect, p)
|
||||
&"public_bath":
|
||||
canvas.draw_rect(Rect2(rect.position.x+4*p, floor_y-7*p, w-8*p, 7*p), CREAM)
|
||||
canvas.draw_rect(Rect2(rect.position.x+6*p, floor_y-6*p, w-12*p, 4*p), Color("72b6b8"))
|
||||
@@ -92,6 +90,15 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
&"warehouse":
|
||||
for x in range(int(rect.position.x+2*p), int(rect.end.x-12*p), int(13*p)):
|
||||
tile(canvas, dungeon, 63, Rect2(x, floor_y-12*p, 12*p, 12*p))
|
||||
&"cleaning_room":
|
||||
# Original code-drawn supply rack, mop and bucket; existing palette.
|
||||
counter(canvas, Vector2(rect.position.x+3*p, floor_y-10*p), 10*p, p)
|
||||
for shelf in 2:
|
||||
canvas.draw_rect(Rect2(rect.position.x+4*p,floor_y-(12+3*shelf)*p,8*p,2*p),CREAM)
|
||||
canvas.draw_rect(Rect2(mid+3*p,floor_y-16*p,p,14*p),WOOD_LIGHT)
|
||||
canvas.draw_rect(Rect2(mid+p,floor_y-3*p,6*p,2*p),CREAM)
|
||||
canvas.draw_rect(Rect2(rect.end.x-9*p,floor_y-6*p,5*p,5*p),TEAL)
|
||||
canvas.draw_rect(Rect2(rect.end.x-8*p,floor_y-7*p,3*p,p),INK)
|
||||
&"staff_waiting_room":
|
||||
table(canvas, Vector2(mid-6*p, floor_y-7*p), p)
|
||||
tile(canvas, dungeon, 89, Rect2(rect.position.x+2*p, floor_y-12*p, 12*p, 12*p))
|
||||
@@ -103,9 +110,65 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float,
|
||||
_:
|
||||
table(canvas, Vector2(mid-6*p, floor_y-7*p), p)
|
||||
|
||||
if not presentation.is_empty():
|
||||
if rect.size.y >= 40*p and id != &"chapel":
|
||||
var workers: Array = presentation.get(&"staff", [])
|
||||
var columns := maxi(int(w / (16*p)), 1)
|
||||
if not workers.is_empty():
|
||||
canvas.draw_rect(Rect2(rect.position.x+p,top+24*p,w-2*p,p), WOOD_LIGHT)
|
||||
for slot: int in mini(workers.size(), columns):
|
||||
counter(canvas, Vector2(rect.position.x+(slot+0.5)*w/columns-6*p, top+20*p), 12*p, p)
|
||||
|
||||
var operation := int(presentation[&"operation"])
|
||||
if operation != FacilityInstance.OperationState.OPERATING:
|
||||
canvas.draw_rect(rect.grow(-p), Color(0.12, 0.16, 0.18, 0.42))
|
||||
# A closed shutter differs in shape as well as colour from an open room.
|
||||
for y: int in range(int(top+10*p), int(top+17*p), maxi(int(3*p), 1)):
|
||||
canvas.draw_rect(Rect2(rect.position.x+2*p, y, w-4*p, p), WOOD)
|
||||
if operation == FacilityInstance.OperationState.SUSPENDED:
|
||||
canvas.draw_line(rect.position+Vector2(3,10)*p, rect.end-Vector2(3,5)*p, Color("ca9c62"), 2*p)
|
||||
canvas.draw_line(Vector2(rect.end.x-3*p,top+10*p), Vector2(rect.position.x+3*p,rect.end.y-5*p), Color("ca9c62"), 2*p)
|
||||
elif int(presentation[&"count"]) > 0:
|
||||
canvas.draw_rect(Rect2(lamp_x-2*p, top+5*p, 4*p, 2*p), Color("ffe9a8"))
|
||||
|
||||
|
||||
|
||||
## The upper landing owns a short flight down to the floor below. Six square
|
||||
## treads and a stepped handrail keep the shape legible at the 16 px room scale.
|
||||
static func chapel_furnishings(canvas: CanvasItem, rect: Rect2, p: float) -> void:
|
||||
# Two rows share the live visitor anchors. Keep the central aisle clear;
|
||||
# the altar and world-tree glass occupy the otherwise unused upper wall.
|
||||
var mid := rect.get_center().x
|
||||
var top := rect.position.y
|
||||
canvas.draw_rect(Rect2(mid-6*p,top+3*p,12*p,19*p), INK)
|
||||
canvas.draw_rect(Rect2(mid-5*p,top+4*p,10*p,17*p), Color("6c8993"))
|
||||
canvas.draw_rect(Rect2(mid-4*p,top+6*p,8*p,13*p), Color("adc5b4"))
|
||||
canvas.draw_rect(Rect2(mid-p,top+9*p,2*p,10*p), WOOD)
|
||||
for row in 3:
|
||||
var width := float(row+1)*2*p
|
||||
canvas.draw_rect(Rect2(mid-width/2,top+(6+row*3)*p,width,3*p), Color("52836c"))
|
||||
canvas.draw_rect(Rect2(mid-p,top+5*p,2*p,p), Color("edc97c"))
|
||||
var altar_y := top+28*p
|
||||
counter(canvas,Vector2(mid-9*p,altar_y),18*p,p)
|
||||
canvas.draw_rect(Rect2(mid-9*p,altar_y,18*p,2*p), CREAM)
|
||||
canvas.draw_rect(Rect2(mid-2*p,altar_y+2*p,4*p,4*p), TEAL)
|
||||
for direction in [-1,1]:
|
||||
canvas.draw_rect(Rect2(mid+direction*6*p,altar_y-3*p,p,3*p), CREAM)
|
||||
canvas.draw_rect(Rect2(mid+direction*6*p,altar_y-4*p,p,p), Color("edc97c"))
|
||||
var rows: Dictionary = {}
|
||||
for at: Vector2 in Layout.slots(rect,p,&"chapel"):
|
||||
rows[at.y] = true
|
||||
for y: float in rows:
|
||||
for side in 2:
|
||||
var x := rect.position.x+3*p if side==0 else mid+3*p
|
||||
var width := rect.size.x/2-6*p
|
||||
canvas.draw_rect(Rect2(x,y-8*p,width,5*p), WOOD)
|
||||
canvas.draw_rect(Rect2(x,y-8*p,width,p), WOOD_LIGHT)
|
||||
canvas.draw_rect(Rect2(x-p,y-3*p,width+2*p,2*p), WOOD_LIGHT)
|
||||
canvas.draw_rect(Rect2(x,y-p,p,p), INK)
|
||||
canvas.draw_rect(Rect2(x+width-p,y-p,p,p), INK)
|
||||
|
||||
|
||||
static func stairs(canvas: CanvasItem, rect: Rect2, p: float, staff_only: bool, compact := false) -> void:
|
||||
if not compact:
|
||||
canvas.draw_rect(rect, INK)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
extends RefCounted
|
||||
|
||||
## Read-only presentation. Counts refer to people, never storage capacity.
|
||||
static func describe(instance: Variant) -> Dictionary:
|
||||
if instance == null:
|
||||
return {}
|
||||
var ids: Array[StringName] = []
|
||||
for service: Variant in instance.active_services.values():
|
||||
for id: StringName in service.guest_ids:
|
||||
if not ids.has(id):
|
||||
ids.append(id)
|
||||
ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b))
|
||||
return {&"operation": int(instance.operation_state), &"people": ids,
|
||||
&"count": ids.size(), &"limit": int(instance.customer_capacity),
|
||||
&"staff": instance.active_staff_ids.duplicate(), &"suspension": StringName(instance.suspension_reason)}
|
||||
|
||||
|
||||
static func slots(rect: Rect2, p: float, id: StringName) -> Array[Vector2]:
|
||||
# Each anchor owns a 16x18 person footprint. Overflow is reported as a count,
|
||||
# not drawn on top of another person. Furniture uses these same anchors.
|
||||
var result: Array[Vector2] = []
|
||||
var step := 20.0 if id in [&"cafeteria", &"tavern"] else 16.0
|
||||
var columns := maxi(int(rect.size.x / p / step), 1)
|
||||
var rows := maxi(int((rect.size.y / p - 10.0) / 20.0), 1)
|
||||
if id == &"chapel":
|
||||
columns = mini(columns, 4)
|
||||
rows = maxi(int((rect.size.y / p - 30.0) / 20.0), 1)
|
||||
for row: int in rows:
|
||||
for col: int in columns:
|
||||
if id == &"chapel" and result.size() == 8:
|
||||
return result
|
||||
result.append(Vector2(rect.position.x + (float(col) + 0.5) * rect.size.x / columns,
|
||||
rect.end.y - (4.0 + row * 20.0) * p))
|
||||
return result
|
||||
|
||||
|
||||
static func label(data: Dictionary, visible_count: int) -> String:
|
||||
if data.is_empty():
|
||||
return ""
|
||||
var operation := int(data[&"operation"])
|
||||
if operation == FacilityInstance.OperationState.SUSPENDED:
|
||||
return "休止・摘発" if data[&"suspension"] == &"RAIDED" else "休止・破壊"
|
||||
if operation == FacilityInstance.OperationState.INACTIVE:
|
||||
return "未稼働"
|
||||
var count := int(data[&"count"])
|
||||
var limit := int(data[&"limit"])
|
||||
var value := "利用%d/%d人" % [count, limit] if limit >= 0 else "利用%d人" % count
|
||||
if limit > 0 and count >= limit:
|
||||
value = "満員 " + value
|
||||
if count > visible_count:
|
||||
value += "(他%d人)" % (count - visible_count)
|
||||
return value
|
||||
@@ -0,0 +1 @@
|
||||
uid://dllkdogv348p
|
||||
@@ -1,6 +1,8 @@
|
||||
class_name StaffManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
## Roster and assignment editing only. Runtime owns Shift, staffing, movement,
|
||||
## and persistence; this panel never edits a Staff or Shift instance directly.
|
||||
signal status_requested(message: String, success: bool)
|
||||
@@ -11,7 +13,7 @@ const ROLE_NAMES := {
|
||||
&"attendant": "接客", &"bartender": "酒場", &"clerk": "会計",
|
||||
&"cleaner": "清掃", &"cook": "調理", &"guard": "警備",
|
||||
&"maintenance": "保守", &"masseur": "施術", &"medic": "医療",
|
||||
&"porter": "物流", &"receptionist": "受付",
|
||||
&"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者",
|
||||
}
|
||||
const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"]
|
||||
|
||||
@@ -51,7 +53,9 @@ func _ready() -> void:
|
||||
_build_interface()
|
||||
_built = true
|
||||
if _runtime != null:
|
||||
if _runtime.has_signal(&"snapshot_published"):
|
||||
if _runtime.has_signal(&"state_updated"):
|
||||
_runtime.connect(&"state_updated", _on_snapshot_published)
|
||||
elif _runtime.has_signal(&"snapshot_published"):
|
||||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
@@ -139,11 +143,12 @@ func refresh() -> void:
|
||||
button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS
|
||||
button.toggle_mode = true
|
||||
button.pressed.connect(select_staff.bind(staff_id))
|
||||
button.set_drag_forwarding(_staff_drag_data.bind(staff_id), Callable(), Callable())
|
||||
_style(button)
|
||||
roster_list.add_child(button)
|
||||
roster_buttons[staff_id] = button
|
||||
button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""]
|
||||
button.tooltip_text = "%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
|
||||
button.tooltip_text = "施設へドラッグして配属(選択中の勤務帯を適用)\n%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)]
|
||||
for raw_id: Variant in roster_buttons.keys():
|
||||
if not live_ids.has(StringName(raw_id)):
|
||||
var stale := roster_buttons[raw_id] as Button
|
||||
@@ -182,11 +187,16 @@ func _refresh_editor() -> void:
|
||||
if not _draft_dirty:
|
||||
_draft_facility_id = current_facility
|
||||
_draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY"
|
||||
staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 疲労 %d / 未払 G%d" % [
|
||||
staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 体力 %d / 疲労 %d / 未払 G%d" % [
|
||||
row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)),
|
||||
_facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務",
|
||||
_state_name(row), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)),
|
||||
_state_name(row), int(row.get(&"health", 100)), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)),
|
||||
]
|
||||
staff_detail_label.text += _unique_contract_text(row)
|
||||
if int(row.get(&"state", 0)) == StaffRuntime.State.INCAPACITATED:
|
||||
staff_detail_label.text += "\n" + String(row.get(&"rescue_status", "救護待ち"))
|
||||
if not String(row.get(&"rest_status", "")).is_empty():
|
||||
staff_detail_label.text += "\n" + String(row[&"rest_status"])
|
||||
var effective_from := int(row.get(&"effective_from", -1))
|
||||
var now := int(get_node("/root/GameClock").get("absolute_minute"))
|
||||
if effective_from > now:
|
||||
@@ -216,12 +226,35 @@ func _refresh_editor() -> void:
|
||||
shift_option.disabled = pending
|
||||
var can_edit := bool(row.get(&"can_edit", true))
|
||||
apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index)
|
||||
apply_button.tooltip_text = "現在の作業や拘束が終わってから変更できます。" if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
|
||||
apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。"
|
||||
if not can_edit:
|
||||
staff_detail_label.text += "\n現在の作業や拘束が終わってから変更できます。"
|
||||
staff_detail_label.text += "\n" + _assignment_block_text(row)
|
||||
apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更"
|
||||
|
||||
|
||||
## Runtime supplies canonical, owned presentation data after every normal
|
||||
## load. The panel neither infers a unique identity from wages nor edits a save.
|
||||
func _unique_contract_text(row: Dictionary) -> String:
|
||||
var contract: Dictionary = row.get(&"unique_contract", {})
|
||||
if contract.is_empty():
|
||||
return ""
|
||||
var text := ""
|
||||
if bool(contract.get(&"free_employment", false)) and bool(contract.get(&"free_upkeep", false)) and bool(contract.get(&"permanent_removal_forbidden", false)):
|
||||
text = "\nユニーク:雇用・維持費無料 / 解雇・売却不可"
|
||||
for effect: Dictionary in contract.get(&"passive_effects", []):
|
||||
if effect.get(&"effect_id") != &"assigned_floor_revenue_multiplier_bp" or effect.get(&"scope") != &"ASSIGNED_FLOOR_ALL_LAYERS":
|
||||
continue
|
||||
var bonus_percent := int((int(effect[&"multiplier_bp"]) - 10000) / 100)
|
||||
text += "\n[%s] 配置階の全施設売上+%d%%" % [effect[&"display_name"], bonus_percent]
|
||||
if bool(effect.get(&"applied", false)) and effect.has(&"floor_y"):
|
||||
var floor_y := int(effect[&"floor_y"])
|
||||
var floor_name := "%dF" % (floor_y + 1) if floor_y >= 0 else "B%d" % -floor_y
|
||||
text += "\n対象:%s(表層・裏層の全施設)" % floor_name
|
||||
else:
|
||||
text += "\n未適用(配属先なし・施設不在)"
|
||||
return text
|
||||
|
||||
|
||||
func _on_facility_changed(index: int) -> void:
|
||||
_draft_facility_id = StringName(facility_option.get_item_metadata(index))
|
||||
_draft_dirty = true
|
||||
@@ -288,6 +321,18 @@ func _show_command_result(result: Variant, staff_id: StringName = &"") -> void:
|
||||
func _failure_message(result: Variant) -> String:
|
||||
var details: Dictionary = result.details if result.details is Dictionary else {}
|
||||
var reason := StringName(details.get(&"reason", details.get("reason", &"")))
|
||||
var translated := _assignment_reason_text(reason)
|
||||
if not translated.is_empty():
|
||||
return translated
|
||||
return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。"
|
||||
|
||||
|
||||
func _assignment_block_text(row: Dictionary) -> String:
|
||||
var translated := _assignment_reason_text(StringName(row.get(&"assignment_block_reason", &"")))
|
||||
return translated if not translated.is_empty() else "現在の作業や拘束が終わってから変更できます。"
|
||||
|
||||
|
||||
func _assignment_reason_text(reason: StringName) -> String:
|
||||
var reasons := {
|
||||
&"STAFF_NOT_FOUND": "従業員が見つかりません。",
|
||||
&"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。",
|
||||
@@ -306,9 +351,7 @@ func _failure_message(result: Variant) -> String:
|
||||
&"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。",
|
||||
&"BANKRUPT": "運営終了後は配属を変更できません。",
|
||||
}
|
||||
if reasons.has(reason):
|
||||
return reasons[reason]
|
||||
return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。"
|
||||
return reasons.get(reason, "")
|
||||
|
||||
|
||||
func _synchronize_commands(queue: Variant) -> void:
|
||||
@@ -324,7 +367,7 @@ func _synchronize_commands(queue: Variant) -> void:
|
||||
_pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &"")))
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||||
if is_visible_in_tree():
|
||||
refresh()
|
||||
|
||||
@@ -340,7 +383,7 @@ func _on_command_completed(result: Variant) -> void:
|
||||
|
||||
func _set_result(message: String, success: bool) -> void:
|
||||
result_label.text = message
|
||||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||||
LayerSkin.register_status_text(result_label, success, _active_layer())
|
||||
status_requested.emit(message, success)
|
||||
|
||||
|
||||
@@ -400,10 +443,49 @@ func _label(text_value: String) -> Label:
|
||||
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
|
||||
label.add_theme_font_size_override("font_size", 12)
|
||||
label.add_theme_color_override("font_color", Color("394937"))
|
||||
LayerSkin.register_text(label, _active_layer())
|
||||
return label
|
||||
|
||||
|
||||
func _active_layer() -> int:
|
||||
var state := get_node_or_null("/root/GameState")
|
||||
return int(state.get("active_layer")) if state != null else 0
|
||||
|
||||
|
||||
func _style(button: Button) -> void:
|
||||
button.add_theme_font_size_override("font_size", 12)
|
||||
if _style_button.is_valid():
|
||||
_style_button.call(button)
|
||||
|
||||
|
||||
func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant:
|
||||
if selected_staff_id != staff_id:
|
||||
select_staff(staff_id)
|
||||
if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)):
|
||||
_set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false)
|
||||
return null
|
||||
var preview := Label.new()
|
||||
preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"]
|
||||
(roster_buttons[staff_id] as Control).set_drag_preview(preview)
|
||||
return {&"kind": &"staff_assignment", &"staff_id": staff_id, &"template_id": _draft_template_id, &"queue_instance_id": _queue_instance_id}
|
||||
|
||||
|
||||
func assign_from_drop(data: Dictionary, facility_id: StringName) -> void:
|
||||
# A successful load replaces the queue, even when staff/facility IDs match.
|
||||
# Never carry an unfinished pointer gesture into that restored world.
|
||||
refresh()
|
||||
if data.get(&"queue_instance_id", 0) != _queue_instance_id:
|
||||
_set_result("読み込み前のドラッグは取り消しました。従業員を選び直して配置してください。", false)
|
||||
return
|
||||
if facility_id.is_empty():
|
||||
_set_result("完成した配属先施設へドロップしてください。表裏の層も確認してください。", false)
|
||||
return
|
||||
var staff_id := StringName(data.get(&"staff_id", &""))
|
||||
if not roster_buttons.has(staff_id):
|
||||
_set_result("従業員が見つかりません。", false)
|
||||
return
|
||||
select_staff(staff_id)
|
||||
_draft_facility_id = facility_id
|
||||
_draft_template_id = StringName(data.get(&"template_id", &"DAY"))
|
||||
_draft_dirty = true
|
||||
submit_assignment()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
class_name SupplyManagementPanel
|
||||
extends VBoxContainer
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
## Read models and commands only. Prices, stock, recipes and delivery progress
|
||||
## belong to Runtime; changing a control never mutates inventory directly.
|
||||
signal status_requested(message: String, success: bool)
|
||||
@@ -11,6 +13,8 @@ const PURCHASE: StringName = &"purchase_supplies"
|
||||
const PRODUCTION: StringName = &"set_kitchen_production"
|
||||
const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"}
|
||||
const STATE_NAMES := {
|
||||
&"SUSPENDED": "破壊・摘発による休止",
|
||||
&"RELATED_FACILITY_INACTIVE": "関連施設が未稼働",
|
||||
&"OPEN": "補給待ち", &"RESERVED": "運搬員待ち", &"ASSIGNED": "荷元へ移動",
|
||||
&"PICKING_UP": "積込中", &"DELIVERING": "配送中", &"COMPLETED": "納品済み",
|
||||
&"BLOCKED": "配送停止", &"CANCELLED": "取消済み", &"WORKING": "調理中",
|
||||
@@ -99,7 +103,9 @@ func _ready() -> void:
|
||||
_build_interface()
|
||||
_built = true
|
||||
if _runtime != null:
|
||||
if _runtime.has_signal(&"snapshot_published"):
|
||||
if _runtime.has_signal(&"state_updated"):
|
||||
_runtime.connect(&"state_updated", _on_snapshot_published)
|
||||
elif _runtime.has_signal(&"snapshot_published"):
|
||||
_runtime.connect(&"snapshot_published", _on_snapshot_published)
|
||||
if _runtime.has_signal(&"command_completed"):
|
||||
_runtime.connect(&"command_completed", _on_command_completed)
|
||||
@@ -159,6 +165,7 @@ func _build_interface() -> void:
|
||||
for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||||
production_enabled.add_theme_color_override(color_name, Color("55371e"))
|
||||
production_enabled.add_theme_color_override("font_disabled_color", Color("766650"))
|
||||
LayerSkin.register_toggle_text(production_enabled, LayerSkin.active_layer(self))
|
||||
production_enabled.toggled.connect(_on_production_enabled_changed)
|
||||
kitchen_page.add_child(production_enabled)
|
||||
kitchen_page.add_child(_label("目標在庫 · 完成料理"))
|
||||
@@ -395,7 +402,7 @@ func _on_command_completed(result: Variant) -> void:
|
||||
refresh()
|
||||
|
||||
|
||||
func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void:
|
||||
func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void:
|
||||
if is_visible_in_tree():
|
||||
refresh()
|
||||
|
||||
@@ -519,7 +526,7 @@ func _reasons_text(value: Variant) -> String:
|
||||
|
||||
func _set_result(message: String, success: bool) -> void:
|
||||
result_label.text = message
|
||||
result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e"))
|
||||
LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self))
|
||||
status_requested.emit(message, success)
|
||||
|
||||
|
||||
@@ -532,6 +539,7 @@ func _label(value: String, node_name := "") -> Label:
|
||||
label.size_flags_horizontal = Control.SIZE_EXPAND_FILL
|
||||
label.add_theme_font_size_override("font_size", 13)
|
||||
label.add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(label, LayerSkin.active_layer(self))
|
||||
return label
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
class_name WorldTreeRankDialog
|
||||
extends ConfirmationDialog
|
||||
|
||||
const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd")
|
||||
|
||||
signal status_requested(message: String, success: bool)
|
||||
const LABELS := {
|
||||
&"reputation":"評判", &"asset_gold":"所持金", &"faith_lifetime":"累計信仰", &"guest_lifetime":"累計来客数",
|
||||
&"underground_influence":"裏社会への影響", &"reputation_or_underground_influence":"評判または裏社会への影響",
|
||||
&"active_above_ground_facilities":"地上の稼働施設", &"active_underground_facilities":"地下の稼働施設",
|
||||
&"important_rank_5":"重要クエスト", &"main_chapter_complete":"本編の章目標",
|
||||
}
|
||||
var _runtime: Variant
|
||||
var _target := -1
|
||||
var _pending_id := 0
|
||||
var _queue_id := 0
|
||||
var _skin_styles: Array[StyleBoxFlat] = []
|
||||
func configure(runtime: Variant) -> void: _runtime=runtime
|
||||
func _ready() -> void:
|
||||
name="WorldTreeRankDialog"
|
||||
title="世界樹ランク"
|
||||
get_ok_button().text="昇格する"
|
||||
get_cancel_button().text="閉じる"
|
||||
confirmed.connect(_promote)
|
||||
_runtime.command_completed.connect(_on_command_completed)
|
||||
_build_skin()
|
||||
apply_layer_skin(LayerSkin.active_layer(self))
|
||||
func show_rank() -> void:
|
||||
refresh()
|
||||
popup_centered(Vector2i(480,260))
|
||||
func refresh() -> void:
|
||||
var queue_id: int=_runtime.command_queue.get_instance_id()
|
||||
if queue_id!=_queue_id:
|
||||
_pending_id=0
|
||||
_queue_id=queue_id
|
||||
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]):
|
||||
if command.get("command_type","")=="set_world_tree_rank":
|
||||
_pending_id=int(command["command_id"])
|
||||
break
|
||||
var report: Dictionary=_runtime.get_live_progression_report()
|
||||
var rank: int=report[&"snapshot"][&"current_rank"]
|
||||
var evaluation: Dictionary=report[&"evaluation"]
|
||||
_target=rank+1
|
||||
var lines: Array[String]=["現在のランク:%d" % rank]
|
||||
if rank>=ProgressionService.MAX_RANK:
|
||||
lines.append("最高ランクに到達しています。")
|
||||
else:
|
||||
lines.append("ランク%dへの条件" % _target)
|
||||
for requirement: Dictionary in evaluation[&"requirements"]:
|
||||
var label: String=LABELS.get(StringName(requirement[&"id"]),"進行条件")
|
||||
var value: String="%d / %d" % [requirement[&"current"],requirement[&"required"]]
|
||||
if requirement[&"comparison"]==&"QUEST_COMPLETED": value="達成済み" if requirement[&"met"] else "未達成"
|
||||
lines.append("%s %s:%s" % ["✓" if requirement[&"met"] else "・",label,value])
|
||||
if evaluation.get(&"eligible",false):lines.append("条件を満たしています。昇格すると建設範囲が広がります。")
|
||||
elif evaluation.get(&"reason",&"")==&"QUARANTINED":lines.append("進行データの確認が必要なため、現在は昇格できません。")
|
||||
if evaluation.get(&"reason",&"")==&"OPERATIONS_BLOCKED":
|
||||
var reason: StringName=evaluation.get(&"block_reason",&"")
|
||||
lines.append("未払い経費を精算するまで昇格できません。" if reason in [&"ARREARS_REMAIN",&"UNPAID_EXPENSES"] else "ゲーム終了後は昇格できません。" if reason==&"BANKRUPT" else "営業状態の復旧が必要なため、現在は昇格できません。")
|
||||
if evaluation.get(&"reason",&"")==&"UNAVAILABLE":lines.append("条件を確認できません。もう一度開き直してください。")
|
||||
if _pending_id!=0:lines.append("昇格申請を受理しました。時間更新を待っています。")
|
||||
dialog_text="\n\n".join(lines)
|
||||
get_ok_button().disabled=rank>=ProgressionService.MAX_RANK or not evaluation.get(&"eligible",false) or _pending_id!=0
|
||||
func _promote() -> void:
|
||||
if _pending_id!=0 or _target<2:return
|
||||
var result: CommandResult=_runtime.submit_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)})
|
||||
for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]):
|
||||
if result.is_success() and int(command["command_id"])==result.command_id:
|
||||
_pending_id=result.command_id
|
||||
status_requested.emit("昇格申請を受理しました。次の時間更新で反映します。",true)
|
||||
return
|
||||
_finish(result)
|
||||
func _on_command_completed(result: CommandResult) -> void:
|
||||
if _pending_id!=0 and result.command_id==_pending_id:
|
||||
_pending_id=0
|
||||
_finish(result)
|
||||
func _finish(result: CommandResult) -> void:
|
||||
status_requested.emit("世界樹ランクが上がりました。" if result.is_success() else "昇格できませんでした。現在の条件を確認してください。",result.is_success())
|
||||
refresh()
|
||||
|
||||
|
||||
func _build_skin() -> void:
|
||||
# Window uses a local Theme rather than Control theme overrides.
|
||||
# Keep native dialog input, focus and confirmation behaviour intact.
|
||||
theme = Theme.new()
|
||||
var panel := _skin_box(Color("f4e3bd"), &"panel_parchment")
|
||||
theme.set_stylebox("panel", "AcceptDialog", panel)
|
||||
var border := _skin_box(Color("49311f"), &"panel_outer")
|
||||
border.expand_margin_top = 32
|
||||
theme.set_stylebox("embedded_border", "Window", border)
|
||||
theme.set_color("title_color", "Window", Color("fff0cf"))
|
||||
get_label().add_theme_color_override("font_color", Color("55371e"))
|
||||
LayerSkin.register_text(get_label(), 0)
|
||||
for button: Button in [get_ok_button(), get_cancel_button()]:
|
||||
for key: StringName in [&"normal", &"hover", &"pressed", &"disabled", &"focus"]:
|
||||
var role: StringName = &"button_normal"
|
||||
var color := Color("65452d")
|
||||
if key in [&"hover", &"focus"]: role = &"button_hover"; color = Color("896139")
|
||||
elif key == &"pressed": role = &"button_pressed"; color = Color("49311f")
|
||||
elif key == &"disabled": role = &"button_disabled"; color = Color("786852")
|
||||
button.add_theme_stylebox_override(key, _skin_box(color, role))
|
||||
for key: StringName in [&"font_color", &"font_hover_color", &"font_pressed_color", &"font_hover_pressed_color", &"font_focus_color"]:
|
||||
button.add_theme_color_override(key, Color("fff0cf"))
|
||||
button.add_theme_color_override("font_disabled_color", Color("c7bda9"))
|
||||
LayerSkin.register_toggle_text(button, 0)
|
||||
|
||||
|
||||
func _skin_box(background: Color, role: StringName) -> StyleBoxFlat:
|
||||
var style := StyleBoxFlat.new()
|
||||
style.bg_color = background
|
||||
style.border_color = Color("a96f2f")
|
||||
style.set_border_width_all(2)
|
||||
style.set_content_margin_all(10)
|
||||
LayerSkin.register_style(style, role, 0)
|
||||
_skin_styles.append(style)
|
||||
return style
|
||||
|
||||
|
||||
func apply_layer_skin(layer: int) -> void:
|
||||
for style: StyleBoxFlat in _skin_styles:
|
||||
LayerSkin.apply_style(style, layer)
|
||||
LayerSkin.register_text(get_label(), layer)
|
||||
for button: Button in [get_ok_button(), get_cancel_button()]:
|
||||
LayerSkin.register_toggle_text(button, layer)
|
||||
@@ -0,0 +1 @@
|
||||
uid://lr3xhtjonmwu
|
||||
@@ -107,6 +107,9 @@ var _processed_requests: Dictionary = {}
|
||||
var _quarantined := false
|
||||
var _quarantine_reason: StringName = &""
|
||||
var _transaction_active := false
|
||||
# Private serialized copy: equality is checked against a fresh snapshot on every
|
||||
# validation. Live dependency checks remain unconditional, even on a cache hit.
|
||||
var _validated_owned_snapshot: Dictionary = {}
|
||||
|
||||
|
||||
## The explicit three-way binding is intentional: exact rollback must restore
|
||||
@@ -503,14 +506,24 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
return true
|
||||
|
||||
|
||||
func is_bound_to(adapter: RouteSubslotPathAdapter, source: SpatialSubslotService, path: PathService) -> bool:
|
||||
return _configured and _adapter == adapter and _source == source and _path == path
|
||||
|
||||
|
||||
func validate_invariants() -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
if not _configured or _adapter == null or _source == null or _path == null:
|
||||
errors.append("Facility infrastructure projector is not configured.")
|
||||
return {&"ok": false, &"errors": errors}
|
||||
var parsed := _parse_snapshot(create_snapshot())
|
||||
if not bool(parsed[&"ok"]):
|
||||
errors.append(String(parsed[&"message"]))
|
||||
var owned_snapshot := create_snapshot()
|
||||
if owned_snapshot != _validated_owned_snapshot:
|
||||
var parsed := _parse_snapshot(owned_snapshot)
|
||||
if not bool(parsed[&"ok"]):
|
||||
errors.append(String(parsed[&"message"]))
|
||||
else:
|
||||
# create_snapshot allocates every container; no caller or live state
|
||||
# retains a mutable reference to this successful validation input.
|
||||
_validated_owned_snapshot = owned_snapshot
|
||||
if not _quarantined and not _dependencies_match_current_state():
|
||||
errors.append("Facility infrastructure live crosslinks are stale.")
|
||||
return {&"ok": errors.is_empty(), &"errors": errors}
|
||||
@@ -991,9 +1004,11 @@ func _dependencies_match_projection_map(projections: Dictionary) -> bool:
|
||||
if _adapter == null or _source == null or _path == null:
|
||||
return false
|
||||
if not _adapter.is_bound_to(_source, _path) \
|
||||
or _adapter.is_quarantined() \
|
||||
or _adapter.is_projection_stale():
|
||||
or _adapter.is_quarantined():
|
||||
return false
|
||||
# For this exact, non-quarantined binding, adapter validation also checks
|
||||
# projection staleness. Keep the independent source invariant audit, but
|
||||
# avoid repeating the same live projection scan through is_projection_stale().
|
||||
if not bool(_source.validate_invariants().get(&"ok", false)) \
|
||||
or not bool(_adapter.validate_invariants().get(&"ok", false)):
|
||||
return false
|
||||
|
||||
+126
-33
@@ -14,7 +14,7 @@ const ORTHOGONAL_MOVE_COST := 100
|
||||
const DEFAULT_CONNECTOR_COST := 150
|
||||
const DEFAULT_CELL_CAPACITY := 4
|
||||
const CONGESTION_COST_PER_EXCESS := 75
|
||||
const MINIMUM_SPEED_MULTIPLIER := 0.25
|
||||
const MINIMUM_SPEED_MULTIPLIER := 0.5
|
||||
const STUCK_THRESHOLD_MINUTES := 30
|
||||
const ELEVATOR_STARVATION_MINUTES := 60
|
||||
const SNAPSHOT_VERSION := 5
|
||||
@@ -569,13 +569,10 @@ func get_speed_multiplier(cell: Vector3i) -> float:
|
||||
var traffic: Dictionary = _cell_traffic.get(cell, {})
|
||||
if traffic.is_empty():
|
||||
return 1.0
|
||||
var capacity := int(traffic[&"base_capacity"])
|
||||
var density := int(traffic[&"current_density"])
|
||||
return clampf(
|
||||
float(capacity) / float(maxi(capacity, density)),
|
||||
MINIMUM_SPEED_MULTIPLIER,
|
||||
1.0
|
||||
)
|
||||
# Original 4.3: overlapping units move at half speed. A wide corridor's
|
||||
# pathfinding capacity must not silently redefine this movement debuff.
|
||||
return MINIMUM_SPEED_MULTIPLIER if int(traffic[&"current_density"]) > 1 else 1.0
|
||||
|
||||
|
||||
|
||||
func project_actor_position(
|
||||
@@ -839,7 +836,8 @@ func dequeue_elevator_boarding(
|
||||
connector_id: StringName,
|
||||
current_floor: int,
|
||||
absolute_minute: int,
|
||||
available_capacity: int = -1
|
||||
available_capacity: int = -1,
|
||||
companions: Dictionary = {}
|
||||
) -> Array[Dictionary]:
|
||||
var boarded: Array[Dictionary] = []
|
||||
if not _connectors.has(connector_id):
|
||||
@@ -860,12 +858,27 @@ func dequeue_elevator_boarding(
|
||||
return boarded
|
||||
|
||||
var queue := get_elevator_queue(connector_id, absolute_minute)
|
||||
var selected: Dictionary = {}
|
||||
var by_actor: Dictionary = {}
|
||||
for request: Dictionary in queue: by_actor[request[&"unit_id"]] = request
|
||||
for request: Dictionary in queue:
|
||||
var actor: StringName = request[&"unit_id"]
|
||||
if selected.has(actor) or int(request[&"floor_from"]) != current_floor: continue
|
||||
var partner: StringName = companions.get(actor, &"")
|
||||
var group: Array[Dictionary] = [request]
|
||||
if not partner.is_empty():
|
||||
if companions.get(partner, &"") != actor or not by_actor.has(partner): continue
|
||||
var other: Dictionary = by_actor[partner]
|
||||
if other[&"floor_from"] != current_floor or other[&"floor_to"] != request[&"floor_to"]: continue
|
||||
group.append(other)
|
||||
if boarded.size() + group.size() > boarding_capacity:
|
||||
break # Keep the oldest pair together and ahead of later requests.
|
||||
for member: Dictionary in group:
|
||||
selected[member[&"unit_id"]] = true
|
||||
boarded.append(member.duplicate(true))
|
||||
var remaining: Array = []
|
||||
for request: Dictionary in queue:
|
||||
if boarded.size() < boarding_capacity and int(request[&"floor_from"]) == current_floor:
|
||||
boarded.append(request.duplicate(true))
|
||||
else:
|
||||
remaining.append(request.duplicate(true))
|
||||
if not selected.has(request[&"unit_id"]): remaining.append(request.duplicate(true))
|
||||
connector[&"request_queue"] = remaining
|
||||
connector[&"current_floor"] = current_floor
|
||||
connector[&"cabin_state"] = CABIN_DOOR_OPEN if not boarded.is_empty() else CABIN_IDLE
|
||||
@@ -1100,6 +1113,19 @@ func cached_path_count() -> int:
|
||||
return _path_cache.size()
|
||||
|
||||
|
||||
## Compare graph membership without serializing traffic, actors and connectors.
|
||||
## Duplicate expected cells are invalid, just as in snapshot crosslink validation.
|
||||
func matches_passable_cells(expected_cells: Array[Vector3i]) -> bool:
|
||||
if expected_cells.size() != _passable_cells.size():
|
||||
return false
|
||||
var seen: Dictionary = {}
|
||||
for cell: Vector3i in expected_cells:
|
||||
if seen.has(cell) or not _passable_cells.has(cell):
|
||||
return false
|
||||
seen[cell] = true
|
||||
return true
|
||||
|
||||
|
||||
func has_cell(cell: Vector3i) -> bool:
|
||||
return _passable_cells.has(cell)
|
||||
|
||||
@@ -1295,7 +1321,7 @@ func create_snapshot() -> Dictionary:
|
||||
var saved_cell_traffic: Array = []
|
||||
for cell_value: Variant in cells:
|
||||
var cell: Vector3i = cell_value
|
||||
var traffic: Dictionary = _cell_traffic.get(cell, _default_cell_traffic())
|
||||
var traffic: Dictionary = _cell_traffic[cell] if _cell_traffic.has(cell) else _default_cell_traffic()
|
||||
saved_cell_traffic.append({
|
||||
&"cell": cell,
|
||||
&"base_capacity": int(traffic[&"base_capacity"]),
|
||||
@@ -1579,17 +1605,20 @@ func _run_a_star(
|
||||
if start == goal:
|
||||
return [start]
|
||||
|
||||
var open_set: Dictionary = {start: true}
|
||||
var frontier: Array[Array] = []
|
||||
_heap_push(frontier, [start, 0, _heuristic(start, goal)])
|
||||
var closed_set: Dictionary = {}
|
||||
var came_from: Dictionary = {}
|
||||
var g_score: Dictionary = {start: 0}
|
||||
|
||||
while not open_set.is_empty():
|
||||
var current := _select_best_open_node(open_set, g_score, goal)
|
||||
while not frontier.is_empty():
|
||||
var entry := _heap_pop(frontier)
|
||||
var current: Vector3i = entry[0]
|
||||
if closed_set.has(current) or int(entry[1]) != int(g_score[current]):
|
||||
continue
|
||||
if current == goal:
|
||||
return _reconstruct_path(came_from, current)
|
||||
|
||||
open_set.erase(current)
|
||||
closed_set[current] = true
|
||||
var edges: Array = _adjacency.get(current, [])
|
||||
for edge_value: Variant in edges:
|
||||
@@ -1612,25 +1641,45 @@ func _run_a_star(
|
||||
g_score[neighbor] = tentative_cost
|
||||
if closed_set.has(neighbor):
|
||||
closed_set.erase(neighbor)
|
||||
open_set[neighbor] = true
|
||||
_heap_push(frontier, [neighbor, tentative_cost, tentative_cost + _heuristic(neighbor, goal)])
|
||||
|
||||
return []
|
||||
|
||||
|
||||
func _select_best_open_node(open_set: Dictionary, g_score: Dictionary, goal: Vector3i) -> Vector3i:
|
||||
var best := Vector3i.ZERO
|
||||
var has_best := false
|
||||
var best_score := _INFINITE_COST
|
||||
for candidate_value: Variant in open_set:
|
||||
var candidate: Vector3i = candidate_value
|
||||
var score: int = int(g_score[candidate]) + _heuristic(candidate, goal)
|
||||
if not has_best or score < best_score:
|
||||
best = candidate
|
||||
best_score = score
|
||||
has_best = true
|
||||
elif score == best_score and _coordinate_less(candidate, best):
|
||||
best = candidate
|
||||
return best
|
||||
# Query-local heap. Stale improved-cost entries are skipped on removal.
|
||||
# Keep the original f-score / x / y / layer order, including reopened nodes.
|
||||
func _heap_less(left: Array, right: Array) -> bool:
|
||||
return int(left[2]) < int(right[2]) or (int(left[2]) == int(right[2]) and _coordinate_less(left[0], right[0]))
|
||||
|
||||
|
||||
func _heap_push(heap: Array[Array], entry: Array) -> void:
|
||||
heap.append(entry)
|
||||
var index := heap.size() - 1
|
||||
while index > 0:
|
||||
var parent := (index - 1) >> 1
|
||||
if not _heap_less(entry, heap[parent]):
|
||||
break
|
||||
heap[index] = heap[parent]
|
||||
index = parent
|
||||
heap[index] = entry
|
||||
|
||||
|
||||
func _heap_pop(heap: Array[Array]) -> Array:
|
||||
var first := heap[0]
|
||||
var last: Array = heap.pop_back()
|
||||
if heap.is_empty():
|
||||
return first
|
||||
var index := 0
|
||||
while index * 2 + 1 < heap.size():
|
||||
var child := index * 2 + 1
|
||||
if child + 1 < heap.size() and _heap_less(heap[child + 1], heap[child]):
|
||||
child += 1
|
||||
if not _heap_less(heap[child], last):
|
||||
break
|
||||
heap[index] = heap[child]
|
||||
index = child
|
||||
heap[index] = last
|
||||
return first
|
||||
|
||||
|
||||
func _heuristic(from_cell: Vector3i, to_cell: Vector3i) -> int:
|
||||
@@ -2607,3 +2656,47 @@ func _dictionary_value(source: Dictionary, key: StringName, default_value: Varia
|
||||
return source[key]
|
||||
var string_key := str(key)
|
||||
return source.get(string_key, default_value)
|
||||
|
||||
|
||||
## Rescue uses BACK circulation, with FRONT access legs at either endpoint.
|
||||
## Optional managed elevators must have room for both patient and carrier.
|
||||
func find_backyard_path(start: Vector3i, goal: Vector3i, allow_elevators := false) -> Array[Vector3i]:
|
||||
if not has_cell(start) or not has_cell(goal) or _cell_is_temporarily_blocked(start) or _cell_is_temporarily_blocked(goal):
|
||||
return []
|
||||
if start == goal:
|
||||
return [start]
|
||||
var first := Vector4i(start.x, start.y, start.z, 2 if start.z == 1 else 0)
|
||||
var frontier: Array[Vector4i] = [first]
|
||||
var visited := {first: true}
|
||||
var parents: Dictionary = {}
|
||||
var cursor := 0
|
||||
while cursor < frontier.size():
|
||||
var state := frontier[cursor]
|
||||
cursor += 1
|
||||
var cell := Vector3i(state.x,state.y,state.z)
|
||||
for edge: Dictionary in _adjacency.get(cell, []):
|
||||
var next: Vector3i = edge[&"to"]
|
||||
if _cell_is_temporarily_blocked(next) or not _edge_meets_vertical_policy(cell, edge) or not _edge_is_accessible(edge, [&"staff"]):
|
||||
continue
|
||||
var connector: Dictionary = _connectors.get(edge[&"connector_id"], {})
|
||||
if connector.get(&"movement_type", &"") == MOVEMENT_ELEVATOR:
|
||||
if not allow_elevators or cell.z != 1 or next.z != 1 or not String(edge[&"connector_id"]).begins_with("transport.") or int(connector.get(&"cabin_capacity", 0)) < 2:
|
||||
continue
|
||||
if state.w == 3 and next.z == 1:
|
||||
continue
|
||||
var phase := state.w
|
||||
if next.z == 1: phase = 1 if state.w == 0 else 2
|
||||
elif state.w == 1: continue
|
||||
elif state.w == 2: phase = 3
|
||||
var key := Vector4i(next.x,next.y,next.z,phase)
|
||||
if visited.has(key): continue
|
||||
visited[key] = true
|
||||
parents[key] = state
|
||||
if next == goal and phase > 0:
|
||||
var result: Array[Vector3i] = [next]
|
||||
while parents.has(key):
|
||||
key = parents[key]
|
||||
result.push_front(Vector3i(key.x,key.y,key.z))
|
||||
return result
|
||||
frontier.append(key)
|
||||
return []
|
||||
|
||||
@@ -53,6 +53,8 @@ var _processed_requests: Dictionary = {}
|
||||
var _quarantined := false
|
||||
var _quarantine_reason: StringName = &""
|
||||
var _transaction_active := false
|
||||
# One private successful parse input; live graph/source checks still run each time.
|
||||
var _validated_owned_snapshot: Dictionary = {}
|
||||
|
||||
|
||||
## Binds the two authoritative services once and establishes projection 1.
|
||||
@@ -283,9 +285,13 @@ func validate_invariants() -> Dictionary:
|
||||
if not _configured or _source == null or _path == null:
|
||||
errors.append("Route projection adapter is not configured.")
|
||||
return {&"ok": false, &"errors": errors}
|
||||
var parsed := _parse_snapshot(create_snapshot())
|
||||
if not bool(parsed[&"ok"]):
|
||||
errors.append(String(parsed[&"message"]))
|
||||
var owned_snapshot := create_snapshot()
|
||||
if owned_snapshot != _validated_owned_snapshot:
|
||||
var parsed := _parse_snapshot(owned_snapshot)
|
||||
if not bool(parsed[&"ok"]):
|
||||
errors.append(String(parsed[&"message"]))
|
||||
else:
|
||||
_validated_owned_snapshot = owned_snapshot
|
||||
if not _quarantine_state_is_valid(_quarantined, _quarantine_reason):
|
||||
errors.append("Route projection quarantine state is inconsistent.")
|
||||
if not _quarantined:
|
||||
|
||||
@@ -51,12 +51,16 @@ var _excavated: Dictionary = {}
|
||||
var _support_capacity: Dictionary = {}
|
||||
var _support_anchors: Dictionary = {}
|
||||
var _dirty_cells: Dictionary = {}
|
||||
var _snapshot_geometry: Dictionary = {}
|
||||
var _snapshot_geometry_revision := -1
|
||||
|
||||
|
||||
func reset(bounds: Variant) -> bool:
|
||||
var normalized := _normalize_bounds(bounds)
|
||||
if normalized.is_empty():
|
||||
return false
|
||||
_snapshot_geometry.clear()
|
||||
_snapshot_geometry_revision = -1
|
||||
_bounds = normalized
|
||||
_occupants.clear()
|
||||
_entity_cells.clear()
|
||||
@@ -298,11 +302,15 @@ func is_support_anchor(cell: Vector3i) -> bool:
|
||||
|
||||
## Validates connected structural components touched by the proposed cells.
|
||||
## Existing structural occupant kinds contribute their configured capacity/load.
|
||||
func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary:
|
||||
func validate_support(cells: Array, weight_per_cell := 1, entity_weights: Dictionary = {}) -> Dictionary:
|
||||
var base_report := _validate_cells(cells)
|
||||
var errors: Array[StringName] = base_report[&"errors"].duplicate()
|
||||
if weight_per_cell <= 0:
|
||||
_add_error(errors, ERROR_INVALID_WEIGHT)
|
||||
# Optional definition weights are read-only and apply to existing occupants.
|
||||
for id: Variant in entity_weights:
|
||||
if typeof(id) not in [TYPE_STRING, TYPE_STRING_NAME] or not _entity_cells.has(id) or typeof(entity_weights[id]) != TYPE_INT or int(entity_weights[id]) <= 0:
|
||||
_add_error(errors, ERROR_INVALID_WEIGHT)
|
||||
var proposed: Array[Vector3i] = base_report[&"valid_cells"].duplicate()
|
||||
proposed.sort_custom(_cell_less)
|
||||
var result := {
|
||||
@@ -325,7 +333,7 @@ func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary:
|
||||
structural_set[cell] = true
|
||||
for entity_id: StringName in _sorted_entity_ids():
|
||||
var kind: StringName = _entity_kinds[entity_id]
|
||||
if _kind_support_capacity(kind) <= 0 and _kind_weight(kind) <= 0 and not _kind_is_anchor(kind):
|
||||
if _kind_support_capacity(kind) <= 0 and int(entity_weights.get(entity_id, _kind_weight(kind))) <= 0 and not _kind_is_anchor(kind):
|
||||
continue
|
||||
for occupied_cell: Vector3i in _entity_cells[entity_id]:
|
||||
structural_set[occupied_cell] = true
|
||||
@@ -351,7 +359,7 @@ func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary:
|
||||
component_proposed.append(component_cell)
|
||||
required += weight_per_cell
|
||||
if _occupants.has(component_cell) and not proposed_set.has(component_cell):
|
||||
required += _kind_weight(get_occupant_kind(component_cell))
|
||||
required += int(entity_weights.get(get_occupant(component_cell), _kind_weight(get_occupant_kind(component_cell))))
|
||||
capacity += get_support_capacity(component_cell)
|
||||
anchored = anchored or is_support_anchor(component_cell)
|
||||
component_proposed.sort_custom(_cell_less)
|
||||
@@ -393,6 +401,22 @@ func consume_dirty_cells() -> Array[Vector3i]:
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
# Geometry only changes through world mutations. Dirty-cell consumption and
|
||||
# public revision counters are sampled separately on every read.
|
||||
if _snapshot_geometry_revision != revision or _snapshot_geometry.is_empty():
|
||||
_snapshot_geometry = _create_geometry_snapshot()
|
||||
_snapshot_geometry_revision = revision
|
||||
var snapshot := _snapshot_geometry.duplicate(true)
|
||||
snapshot[&"dirty_cells"] = get_dirty_cells()
|
||||
snapshot[&"revisions"] = {
|
||||
&"world": revision, &"occupancy": occupancy_revision,
|
||||
&"walkability": walkability_revision, &"excavation": excavation_revision,
|
||||
&"support": support_revision,
|
||||
}
|
||||
return snapshot
|
||||
|
||||
|
||||
func _create_geometry_snapshot() -> Dictionary:
|
||||
var occupancies: Array[Dictionary] = []
|
||||
for entity_id: StringName in _sorted_entity_ids():
|
||||
occupancies.append({
|
||||
@@ -426,14 +450,6 @@ func create_snapshot() -> Dictionary:
|
||||
&"walkable_cells": _sorted_dictionary_cells(_walkable),
|
||||
&"excavation_overrides": excavation_overrides,
|
||||
&"support": support_entries,
|
||||
&"dirty_cells": get_dirty_cells(),
|
||||
&"revisions": {
|
||||
&"world": revision,
|
||||
&"occupancy": occupancy_revision,
|
||||
&"walkability": walkability_revision,
|
||||
&"excavation": excavation_revision,
|
||||
&"support": support_revision,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -548,6 +564,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
):
|
||||
return false
|
||||
|
||||
_snapshot_geometry.clear()
|
||||
_snapshot_geometry_revision = -1
|
||||
_bounds = new_bounds
|
||||
_occupants = new_occupants
|
||||
_entity_cells = new_entity_cells
|
||||
|
||||
Reference in New Issue
Block a user