1534 lines
54 KiB
GDScript
1534 lines
54 KiB
GDScript
class_name ConstructionService
|
|
extends RefCounted
|
|
|
|
signal job_created(job: ConstructionJob)
|
|
signal job_changed(job: ConstructionJob)
|
|
signal facility_completed(instance: Dictionary)
|
|
signal facility_demolished(instance_id: StringName, refund: Dictionary)
|
|
|
|
const PlacementReportType = preload("res://scripts/construction/placement_report.gd")
|
|
const ConstructionJobType = preload("res://scripts/construction/construction_job.gd")
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
|
|
const SNAPSHOT_VERSION := 2
|
|
const LEGACY_SNAPSHOT_VERSION := 1
|
|
const DEFAULT_BUILD_MINUTES := 60
|
|
const DEMOLITION_MATERIAL_REFUND_BASIS_POINTS := 2_500
|
|
const MAX_PROGRESS := 10_000
|
|
const MAX_WORK_RATE := 1_000_000
|
|
const MAX_WORK_UNITS := 9_000_000_000_000_000
|
|
const MAX_BUILD_MINUTES := 10_000_000
|
|
const MAX_JOBS := 20_000
|
|
const MAX_FACILITIES := 20_000
|
|
const MAX_SEQUENCE := 99_999_999
|
|
const DEFERRED_JOB_CHANGED: StringName = &"JOB_CHANGED"
|
|
const DEFERRED_FACILITY_COMPLETED: StringName = &"FACILITY_COMPLETED"
|
|
|
|
var default_build_minutes := DEFAULT_BUILD_MINUTES
|
|
var current_minute := 0
|
|
|
|
var _world: Variant
|
|
var _resource_state: Variant
|
|
var _jobs: Dictionary = {}
|
|
var _facilities: Dictionary = {}
|
|
var _next_job_sequence := 1
|
|
var _next_facility_sequence := 1
|
|
var _last_progress_minutes: Dictionary = {}
|
|
var _mutation_in_progress := false
|
|
var _quarantined := false
|
|
var _quarantine_reason := ""
|
|
var _deferred_notifications: Array[Dictionary] = []
|
|
var _notification_staging_active := false
|
|
var _notification_commit_active := false
|
|
|
|
|
|
func _init(world: Variant = null, resource_state: Variant = null) -> void:
|
|
_world = world
|
|
_resource_state = resource_state
|
|
|
|
|
|
func configure(world: Variant, resource_state: Variant) -> ConstructionService:
|
|
_world = world
|
|
_resource_state = resource_state
|
|
return self
|
|
|
|
|
|
func validate_placement(
|
|
definition: Variant,
|
|
origin: Vector3i,
|
|
rotation: int = 0
|
|
) -> PlacementReport:
|
|
var report := PlacementReportType.new()
|
|
if not _definition_is_valid(definition):
|
|
report.add_error(
|
|
PlacementReportType.ERROR_INVALID_DEFINITION,
|
|
"The facility definition is missing or invalid."
|
|
)
|
|
return report.finalize()
|
|
|
|
var footprint: Vector2i = _definition_value(definition, &"footprint", Vector2i.ZERO)
|
|
var expected_zone := int(_definition_value(definition, &"zone", 0))
|
|
var expected_layer := int(_definition_value(definition, &"layer", origin.z))
|
|
report.total_cost = _normalize_cost(_definition_value(definition, &"construction_cost", {}))
|
|
report.build_time = _definition_build_minutes(definition)
|
|
|
|
var missing_world_methods := _missing_methods(
|
|
_world,
|
|
[&"cells_for_footprint", &"is_in_bounds", &"get_zone", &"get_occupant", &"is_excavated", &"validate_support"]
|
|
)
|
|
if not missing_world_methods.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_MISSING_DEPENDENCY,
|
|
"World placement services are unavailable.",
|
|
[],
|
|
{&"methods": missing_world_methods}
|
|
)
|
|
if _resource_state == null:
|
|
report.add_error(
|
|
PlacementReportType.ERROR_MISSING_DEPENDENCY,
|
|
"The resource state is unavailable."
|
|
)
|
|
|
|
if missing_world_methods.is_empty():
|
|
report.cells = _world_cells_for_footprint(
|
|
origin,
|
|
footprint,
|
|
_normalize_rotation(rotation),
|
|
expected_layer
|
|
)
|
|
if report.cells.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_INVALID_DEFINITION,
|
|
"The footprint produced no world cells."
|
|
)
|
|
_validate_cell_constraints(report, origin, expected_zone, expected_layer)
|
|
_validate_support(report, definition)
|
|
|
|
_validate_cost(report)
|
|
return report.finalize()
|
|
|
|
|
|
func start_construction(
|
|
definition: Variant,
|
|
origin: Vector3i,
|
|
rotation: int = 0,
|
|
build_minutes: int = 0,
|
|
created_at: int = -1
|
|
) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
if _jobs.size() >= MAX_JOBS or _next_job_sequence > MAX_SEQUENCE:
|
|
return _failure("CONSTRUCTION_LIMIT", "Construction job capacity has been reached.")
|
|
if build_minutes > MAX_BUILD_MINUTES:
|
|
return _failure("INVALID_BUILD_TIME", "Construction build time exceeds the supported limit.")
|
|
_mutation_in_progress = true
|
|
var report := validate_placement(definition, origin, rotation)
|
|
if not report.can_build:
|
|
_mutation_in_progress = false
|
|
return _failure("INVALID_PLACEMENT", "Facility placement is invalid.", {
|
|
&"placement_report": report.to_dictionary(),
|
|
})
|
|
|
|
var job_id := StringName("construction_%08d" % _next_job_sequence)
|
|
var resource_snapshot: Variant = _capture_resource_state()
|
|
var world_snapshot := _capture_world_state(report.cells)
|
|
if not _spend_cost(report.total_cost):
|
|
var payment_rolled_back := _restore_resource_state(resource_snapshot)
|
|
_mutation_in_progress = false
|
|
if not payment_rolled_back:
|
|
_quarantine("Construction payment failed after mutating resources and could not be rolled back.")
|
|
return _failure("PAYMENT_FAILED", "Construction resources changed before the command was applied.", {
|
|
&"cost": report.total_cost.duplicate(true),
|
|
&"rolled_back": payment_rolled_back,
|
|
&"rollback_failed": not payment_rolled_back,
|
|
})
|
|
|
|
if not _world_occupy(report.cells, job_id, &"construction"):
|
|
var world_rolled_back := _restore_world_state(world_snapshot)
|
|
var resources_rolled_back := _restore_resource_state(resource_snapshot)
|
|
var rolled_back := world_rolled_back and resources_rolled_back
|
|
_mutation_in_progress = false
|
|
if not rolled_back:
|
|
_quarantine("Construction reservation failed and transaction rollback was incomplete.")
|
|
return _failure("RESERVATION_FAILED", "World reservation failed.", {
|
|
&"job_id": job_id,
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
&"world_rolled_back": world_rolled_back,
|
|
&"resources_rolled_back": resources_rolled_back,
|
|
})
|
|
|
|
var effective_minutes := build_minutes if build_minutes > 0 else report.build_time
|
|
var effective_created_at := current_minute if created_at < 0 else created_at
|
|
var definition_id := StringName(_definition_value(definition, &"id", &""))
|
|
var expected_layer := int(_definition_value(definition, &"layer", origin.z))
|
|
var job := ConstructionJobType.new().configure(
|
|
job_id,
|
|
definition_id,
|
|
origin,
|
|
rotation,
|
|
expected_layer,
|
|
report.cells,
|
|
report.total_cost,
|
|
effective_minutes,
|
|
effective_created_at
|
|
)
|
|
_jobs[job_id] = job
|
|
_last_progress_minutes[job_id] = -1
|
|
_next_job_sequence += 1
|
|
_mutation_in_progress = false
|
|
job_created.emit(job)
|
|
return CommandResultType.succeeded(0, {
|
|
&"job_id": job_id,
|
|
&"placement_report": report.to_dictionary(),
|
|
})
|
|
|
|
|
|
func get_job(job_id: StringName) -> ConstructionJob:
|
|
return _jobs.get(job_id) as ConstructionJob
|
|
|
|
|
|
func get_jobs() -> Array[ConstructionJob]:
|
|
var result: Array[ConstructionJob] = []
|
|
var ids := _sorted_string_name_keys(_jobs)
|
|
for job_id: StringName in ids:
|
|
result.append(_jobs[job_id])
|
|
return result
|
|
|
|
|
|
func get_facility(instance_id: StringName) -> Dictionary:
|
|
if not _facilities.has(instance_id):
|
|
return {}
|
|
return (_facilities[instance_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
func get_facilities() -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
var ids := _sorted_string_name_keys(_facilities)
|
|
for instance_id: StringName in ids:
|
|
result.append((_facilities[instance_id] as Dictionary).duplicate(true))
|
|
return result
|
|
|
|
|
|
func assign_builders(job_id: StringName, builder_ids: Array[StringName]) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
var job := get_job(job_id)
|
|
if job == null:
|
|
return _not_found("Construction job", job_id)
|
|
if job.is_terminal():
|
|
return _invalid_state("A terminal construction job cannot receive builders.", job_id)
|
|
job.assigned_builder_ids.assign(builder_ids)
|
|
_emit_job_changed(job)
|
|
return CommandResultType.succeeded(0, {&"job_id": job_id})
|
|
|
|
|
|
func advance_one_minute(
|
|
absolute_minute: int = -1,
|
|
work_rates: Dictionary = {},
|
|
defer_notifications := false
|
|
) -> Array[CommandResult]:
|
|
if _notification_staging_active or _notification_commit_active:
|
|
return [_notification_unavailable_result(&"NOTIFICATION_REENTRY", "Construction notification dispatch is already active.")]
|
|
if has_deferred_notifications():
|
|
return [_notification_unavailable_result(
|
|
&"DEFERRED_NOTIFICATIONS_PENDING",
|
|
"Deferred construction notifications must be committed or discarded before another advance."
|
|
)]
|
|
if not _mutation_allowed():
|
|
return [_unavailable_result()]
|
|
_notification_staging_active = defer_notifications
|
|
if absolute_minute < 0:
|
|
current_minute += 1
|
|
else:
|
|
current_minute = maxi(absolute_minute, current_minute)
|
|
var results: Array[CommandResult] = []
|
|
for job_id: StringName in _sorted_string_name_keys(_jobs):
|
|
var job: ConstructionJob = _jobs[job_id]
|
|
if job.is_terminal():
|
|
continue
|
|
var raw_rate: Variant = work_rates.get(job_id, ConstructionJobType.NORMAL_WORK_RATE)
|
|
if not raw_rate is int:
|
|
results.append(_failure("INVALID_WORK_RATE", "Construction work rate must be an integer."))
|
|
continue
|
|
results.append(progress_job(job_id, int(raw_rate), current_minute))
|
|
_notification_staging_active = false
|
|
return results
|
|
|
|
|
|
func has_deferred_notifications() -> bool:
|
|
return not _deferred_notifications.is_empty()
|
|
|
|
|
|
func commit_deferred_notifications() -> bool:
|
|
if _notification_staging_active or _notification_commit_active or _mutation_in_progress:
|
|
return false
|
|
if _deferred_notifications.is_empty():
|
|
return true
|
|
_notification_commit_active = true
|
|
var prepared: Array[Dictionary] = []
|
|
for notification: Dictionary in _deferred_notifications:
|
|
var kind: StringName = notification.get(&"kind", &"")
|
|
match kind:
|
|
DEFERRED_JOB_CHANGED:
|
|
var saved_job: Variant = notification.get(&"payload", null)
|
|
if not saved_job is Dictionary:
|
|
_notification_commit_active = false
|
|
return false
|
|
var job := ConstructionJobType.from_snapshot(saved_job.duplicate(true))
|
|
if job == null:
|
|
_notification_commit_active = false
|
|
return false
|
|
prepared.append({&"kind": kind, &"payload": job})
|
|
DEFERRED_FACILITY_COMPLETED:
|
|
var saved_instance: Variant = notification.get(&"payload", null)
|
|
if not saved_instance is Dictionary:
|
|
_notification_commit_active = false
|
|
return false
|
|
prepared.append({&"kind": kind, &"payload": saved_instance.duplicate(true)})
|
|
_:
|
|
_notification_commit_active = false
|
|
return false
|
|
_deferred_notifications.clear()
|
|
for notification: Dictionary in prepared:
|
|
match StringName(notification[&"kind"]):
|
|
DEFERRED_JOB_CHANGED:
|
|
job_changed.emit(notification[&"payload"])
|
|
DEFERRED_FACILITY_COMPLETED:
|
|
facility_completed.emit((notification[&"payload"] as Dictionary).duplicate(true))
|
|
_notification_commit_active = false
|
|
return true
|
|
|
|
|
|
func discard_deferred_notifications() -> bool:
|
|
if _notification_staging_active or _notification_commit_active or _mutation_in_progress:
|
|
return false
|
|
_deferred_notifications.clear()
|
|
return true
|
|
|
|
|
|
func _emit_job_changed(job: ConstructionJob) -> void:
|
|
if _notification_staging_active:
|
|
_deferred_notifications.append({
|
|
&"kind": DEFERRED_JOB_CHANGED,
|
|
&"payload": job.create_snapshot().duplicate(true),
|
|
})
|
|
return
|
|
job_changed.emit(job)
|
|
|
|
|
|
func _emit_facility_completed(instance: Dictionary) -> void:
|
|
var payload := instance.duplicate(true)
|
|
if _notification_staging_active:
|
|
_deferred_notifications.append({
|
|
&"kind": DEFERRED_FACILITY_COMPLETED,
|
|
&"payload": payload,
|
|
})
|
|
return
|
|
facility_completed.emit(payload)
|
|
|
|
|
|
func progress_job(
|
|
job_id: StringName,
|
|
work_rate_basis_points: int = ConstructionJob.NORMAL_WORK_RATE,
|
|
absolute_minute: int = -1
|
|
) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
var job := get_job(job_id)
|
|
if job == null:
|
|
return _not_found("Construction job", job_id)
|
|
if job.is_terminal():
|
|
return _invalid_state("A completed or cancelled job cannot progress.", job_id)
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
if minute < 0:
|
|
return _failure("INVALID_MINUTE", "Construction progress minute cannot be negative.")
|
|
if work_rate_basis_points <= 0 or work_rate_basis_points > MAX_WORK_RATE:
|
|
return _failure("INVALID_WORK_RATE", "Construction work rate is outside the supported range.")
|
|
if minute <= int(_last_progress_minutes.get(job_id, -1)):
|
|
var replay := CommandResultType.succeeded(0, {
|
|
&"job_id": job_id,
|
|
&"progress": job.progress,
|
|
&"state": ConstructionJobType.State.keys()[job.state],
|
|
})
|
|
replay.is_replay = true
|
|
return replay
|
|
if job.work_units_done > MAX_WORK_UNITS - work_rate_basis_points:
|
|
return _failure("WORK_LIMIT", "Construction work accumulator limit was reached.")
|
|
_mutation_in_progress = true
|
|
var ready_to_complete := job.advance_one_minute(work_rate_basis_points, minute)
|
|
_last_progress_minutes[job_id] = minute
|
|
if ready_to_complete:
|
|
var completed := _complete_job(job, minute)
|
|
_mutation_in_progress = false
|
|
return completed
|
|
_mutation_in_progress = false
|
|
_emit_job_changed(job)
|
|
return CommandResultType.succeeded(0, {
|
|
&"job_id": job_id,
|
|
&"progress": job.progress,
|
|
&"state": ConstructionJobType.State.keys()[job.state],
|
|
})
|
|
|
|
|
|
func cancel_job(job_id: StringName, absolute_minute: int = -1) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
var job := get_job(job_id)
|
|
if job == null:
|
|
return _not_found("Construction job", job_id)
|
|
if job.is_terminal():
|
|
return _invalid_state("A completed or cancelled job cannot be cancelled.", job_id)
|
|
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
if minute < 0:
|
|
return _failure("INVALID_MINUTE", "Construction cancellation minute cannot be negative.")
|
|
var refund := job.refundable_cost()
|
|
var resource_snapshot: Variant = _capture_resource_state()
|
|
var world_snapshot := _capture_world_state(job.reserved_cells)
|
|
_mutation_in_progress = true
|
|
if not _world_release(job.reserved_cells, job.job_id):
|
|
var release_rolled_back := _restore_world_state(world_snapshot)
|
|
_mutation_in_progress = false
|
|
if not release_rolled_back:
|
|
_quarantine("Construction cancellation release failed and world state could not be restored.")
|
|
return _failure("RELEASE_FAILED", "The construction reservation could not be released.", {
|
|
&"job_id": job_id,
|
|
&"rolled_back": release_rolled_back,
|
|
&"rollback_failed": not release_rolled_back,
|
|
})
|
|
if not _refund_cost(refund):
|
|
var world_rolled_back := _restore_world_state(world_snapshot)
|
|
var resources_rolled_back := _restore_resource_state(resource_snapshot)
|
|
var rolled_back := world_rolled_back and resources_rolled_back
|
|
_mutation_in_progress = false
|
|
if not rolled_back:
|
|
_quarantine("Construction cancellation refund failed and rollback was incomplete.")
|
|
return _failure("REFUND_FAILED", "The cancellation refund failed.", {
|
|
&"job_id": job_id,
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
&"world_rolled_back": world_rolled_back,
|
|
&"resources_rolled_back": resources_rolled_back,
|
|
})
|
|
|
|
job.mark_cancelled(minute)
|
|
_mutation_in_progress = false
|
|
_emit_job_changed(job)
|
|
return CommandResultType.succeeded(0, {
|
|
&"job_id": job_id,
|
|
&"refund": refund,
|
|
&"progress": job.progress,
|
|
})
|
|
|
|
|
|
func demolish_facility(
|
|
instance_id: StringName,
|
|
material_refund_basis_points: int = DEMOLITION_MATERIAL_REFUND_BASIS_POINTS
|
|
) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
if not _facilities.has(instance_id):
|
|
return _not_found("Facility instance", instance_id)
|
|
var instance: Dictionary = _facilities[instance_id]
|
|
var cells: Array[Vector3i] = []
|
|
cells.assign(instance.get(&"occupied_cells", []))
|
|
var refund: Dictionary = {}
|
|
var construction_cost: Dictionary = instance.get(&"construction_cost", {})
|
|
var materials := int(construction_cost.get(&"materials", 0))
|
|
if materials > 0:
|
|
refund[&"materials"] = materials * clampi(material_refund_basis_points, 0, 10_000) / 10_000
|
|
|
|
var resource_snapshot: Variant = _capture_resource_state()
|
|
var world_snapshot := _capture_world_state(cells)
|
|
_mutation_in_progress = true
|
|
if not _world_release(cells, instance_id):
|
|
var release_rolled_back := _restore_world_state(world_snapshot)
|
|
_mutation_in_progress = false
|
|
if not release_rolled_back:
|
|
_quarantine("Facility demolition release failed and world state could not be restored.")
|
|
return _failure("RELEASE_FAILED", "The facility occupancy could not be released.", {
|
|
&"instance_id": instance_id,
|
|
&"rolled_back": release_rolled_back,
|
|
&"rollback_failed": not release_rolled_back,
|
|
})
|
|
if not _refund_cost(refund):
|
|
var world_rolled_back := _restore_world_state(world_snapshot)
|
|
var resources_rolled_back := _restore_resource_state(resource_snapshot)
|
|
var rolled_back := world_rolled_back and resources_rolled_back
|
|
_mutation_in_progress = false
|
|
if not rolled_back:
|
|
_quarantine("Facility demolition refund failed and rollback was incomplete.")
|
|
return _failure("REFUND_FAILED", "The demolition refund failed.", {
|
|
&"instance_id": instance_id,
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
&"world_rolled_back": world_rolled_back,
|
|
&"resources_rolled_back": resources_rolled_back,
|
|
})
|
|
|
|
_facilities.erase(instance_id)
|
|
_mutation_in_progress = false
|
|
facility_demolished.emit(instance_id, refund)
|
|
return CommandResultType.succeeded(0, {
|
|
&"instance_id": instance_id,
|
|
&"refund": refund,
|
|
})
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
if _notification_staging_active or _notification_commit_active or has_deferred_notifications():
|
|
return {}
|
|
var saved_jobs: Array[Dictionary] = []
|
|
for job_id: StringName in _sorted_string_name_keys(_jobs):
|
|
var job: ConstructionJob = _jobs[job_id]
|
|
var saved_job := job.create_snapshot()
|
|
saved_job[&"last_progress_at"] = int(_last_progress_minutes.get(job_id, -1))
|
|
saved_jobs.append(saved_job)
|
|
var saved_facilities: Array[Dictionary] = []
|
|
for instance_id: StringName in _sorted_string_name_keys(_facilities):
|
|
saved_facilities.append(_serialize_facility(_facilities[instance_id]))
|
|
return {
|
|
&"schema_version": SNAPSHOT_VERSION,
|
|
&"default_build_minutes": default_build_minutes,
|
|
&"current_minute": current_minute,
|
|
&"next_job_sequence": _next_job_sequence,
|
|
&"next_facility_sequence": _next_facility_sequence,
|
|
&"jobs": saved_jobs,
|
|
&"facilities": saved_facilities,
|
|
&"quarantined": _quarantined,
|
|
&"quarantine_reason": _quarantine_reason,
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if _mutation_in_progress or _notification_staging_active \
|
|
or _notification_commit_active or has_deferred_notifications():
|
|
return false
|
|
var parsed := _parse_snapshot(snapshot)
|
|
if not bool(parsed.get(&"ok", false)):
|
|
return false
|
|
if not _validate_restored_state(parsed):
|
|
return false
|
|
_jobs = parsed[&"jobs"]
|
|
_facilities = parsed[&"facilities"]
|
|
_last_progress_minutes = parsed[&"last_progress_minutes"]
|
|
default_build_minutes = parsed[&"default_build_minutes"]
|
|
current_minute = parsed[&"current_minute"]
|
|
_next_job_sequence = parsed[&"next_job_sequence"]
|
|
_next_facility_sequence = parsed[&"next_facility_sequence"]
|
|
_quarantined = parsed[&"quarantined"]
|
|
_quarantine_reason = parsed[&"quarantine_reason"]
|
|
return true
|
|
|
|
|
|
func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
|
var raw_version: Variant = snapshot.get(&"schema_version", null)
|
|
if not raw_version is int or int(raw_version) not in [LEGACY_SNAPSHOT_VERSION, SNAPSHOT_VERSION]:
|
|
return {&"ok": false}
|
|
var version := int(raw_version)
|
|
var top_level_keys: Array[StringName] = [
|
|
&"schema_version", &"default_build_minutes", &"current_minute",
|
|
&"next_job_sequence", &"next_facility_sequence", &"jobs", &"facilities",
|
|
]
|
|
if version == SNAPSHOT_VERSION:
|
|
top_level_keys.append_array([&"quarantined", &"quarantine_reason"])
|
|
if not _has_exact_keys(snapshot, top_level_keys):
|
|
return {&"ok": false}
|
|
for counter_key: StringName in [
|
|
&"default_build_minutes", &"current_minute", &"next_job_sequence", &"next_facility_sequence",
|
|
]:
|
|
if not snapshot[counter_key] is int:
|
|
return {&"ok": false}
|
|
var restored_default := int(snapshot[&"default_build_minutes"])
|
|
var restored_minute := int(snapshot[&"current_minute"])
|
|
var job_sequence := int(snapshot[&"next_job_sequence"])
|
|
var facility_sequence := int(snapshot[&"next_facility_sequence"])
|
|
if (
|
|
restored_default < 1
|
|
or restored_default > MAX_BUILD_MINUTES
|
|
or restored_minute < 0
|
|
or job_sequence < 1
|
|
or job_sequence > MAX_SEQUENCE + 1
|
|
or facility_sequence < 1
|
|
or facility_sequence > MAX_SEQUENCE + 1
|
|
):
|
|
return {&"ok": false}
|
|
var saved_jobs: Variant = snapshot[&"jobs"]
|
|
var saved_facilities: Variant = snapshot[&"facilities"]
|
|
if (
|
|
not saved_jobs is Array
|
|
or not saved_facilities is Array
|
|
or saved_jobs.size() > MAX_JOBS
|
|
or saved_facilities.size() > MAX_FACILITIES
|
|
):
|
|
return {&"ok": false}
|
|
var quarantined := false
|
|
var quarantine_reason := ""
|
|
if version == SNAPSHOT_VERSION:
|
|
if not snapshot[&"quarantined"] is bool or not snapshot[&"quarantine_reason"] is String:
|
|
return {&"ok": false}
|
|
quarantined = snapshot[&"quarantined"]
|
|
quarantine_reason = snapshot[&"quarantine_reason"]
|
|
if quarantined == quarantine_reason.is_empty():
|
|
return {&"ok": false}
|
|
|
|
var restored_jobs: Dictionary = {}
|
|
var progress_minutes: Dictionary = {}
|
|
for job_data: Variant in saved_jobs:
|
|
if not job_data is Dictionary or not _validate_saved_job(job_data, version):
|
|
return {&"ok": false}
|
|
var construction_data: Dictionary = job_data.duplicate(true)
|
|
construction_data.erase(&"last_progress_at")
|
|
var job := ConstructionJobType.from_snapshot(construction_data)
|
|
if job == null or restored_jobs.has(job.job_id):
|
|
return {&"ok": false}
|
|
restored_jobs[job.job_id] = job
|
|
var last_progress := -1
|
|
if version == SNAPSHOT_VERSION:
|
|
last_progress = int(job_data[&"last_progress_at"])
|
|
elif job.work_units_done > 0:
|
|
last_progress = maxi(job.started_at, restored_minute)
|
|
progress_minutes[job.job_id] = last_progress
|
|
|
|
var restored_facilities: Dictionary = {}
|
|
for facility_data: Variant in saved_facilities:
|
|
if not facility_data is Dictionary or not _validate_saved_facility(facility_data):
|
|
return {&"ok": false}
|
|
var instance := _deserialize_facility(facility_data)
|
|
var instance_id: StringName = instance[&"instance_id"]
|
|
if restored_facilities.has(instance_id):
|
|
return {&"ok": false}
|
|
restored_facilities[instance_id] = instance
|
|
return {
|
|
&"ok": true,
|
|
&"jobs": restored_jobs,
|
|
&"facilities": restored_facilities,
|
|
&"last_progress_minutes": progress_minutes,
|
|
&"default_build_minutes": restored_default,
|
|
&"current_minute": restored_minute,
|
|
&"next_job_sequence": job_sequence,
|
|
&"next_facility_sequence": facility_sequence,
|
|
&"quarantined": quarantined,
|
|
&"quarantine_reason": quarantine_reason,
|
|
}
|
|
|
|
|
|
func _validate_saved_job(saved: Dictionary, version: int) -> bool:
|
|
var keys: Array[StringName] = [
|
|
&"job_id", &"definition_id", &"origin", &"rotation", &"layer", &"reserved_cells",
|
|
&"progress", &"state", &"state_reason", &"assigned_builder_ids", &"construction_cost",
|
|
&"build_minutes", &"work_units_done", &"created_at", &"started_at", &"completed_at",
|
|
&"cancelled_at", &"facility_instance_id",
|
|
]
|
|
if version == SNAPSHOT_VERSION:
|
|
keys.append(&"last_progress_at")
|
|
if not _has_exact_keys(saved, keys):
|
|
return false
|
|
for text_key: StringName in [
|
|
&"job_id", &"definition_id", &"state", &"state_reason", &"facility_instance_id",
|
|
]:
|
|
if not saved[text_key] is String:
|
|
return false
|
|
if String(saved[&"job_id"]).is_empty() or String(saved[&"definition_id"]).is_empty():
|
|
return false
|
|
if _parse_saved_cell(saved[&"origin"]) == null:
|
|
return false
|
|
var cells: Variant = _parse_saved_cells(saved[&"reserved_cells"])
|
|
if cells == null or cells.is_empty():
|
|
return false
|
|
var origin: Vector3i = _parse_saved_cell(saved[&"origin"])
|
|
for integer_key: StringName in [
|
|
&"rotation", &"layer", &"progress", &"build_minutes", &"work_units_done",
|
|
&"created_at", &"started_at", &"completed_at", &"cancelled_at",
|
|
]:
|
|
if not saved[integer_key] is int:
|
|
return false
|
|
if version == SNAPSHOT_VERSION and not saved[&"last_progress_at"] is int:
|
|
return false
|
|
if int(saved[&"rotation"]) not in [0, 90, 180, 270] or int(saved[&"layer"]) not in [0, 1]:
|
|
return false
|
|
if origin.z != int(saved[&"layer"]) or not cells.has(origin):
|
|
return false
|
|
for cell: Vector3i in cells:
|
|
if cell.z != int(saved[&"layer"]):
|
|
return false
|
|
if not _valid_cost(saved[&"construction_cost"]):
|
|
return false
|
|
if not saved[&"assigned_builder_ids"] is Array:
|
|
return false
|
|
var builder_set: Dictionary = {}
|
|
for builder_id: Variant in saved[&"assigned_builder_ids"]:
|
|
if not builder_id is String or String(builder_id).is_empty() or builder_set.has(builder_id):
|
|
return false
|
|
builder_set[builder_id] = true
|
|
var progress := int(saved[&"progress"])
|
|
var build_minutes := int(saved[&"build_minutes"])
|
|
var work_done := int(saved[&"work_units_done"])
|
|
if (
|
|
progress < 0
|
|
or progress > MAX_PROGRESS
|
|
or build_minutes < 1
|
|
or build_minutes > MAX_BUILD_MINUTES
|
|
or work_done < 0
|
|
or work_done > MAX_WORK_UNITS
|
|
or progress != mini(MAX_PROGRESS, work_done / build_minutes)
|
|
or int(saved[&"created_at"]) < 0
|
|
or int(saved[&"started_at"]) < -1
|
|
or int(saved[&"completed_at"]) < -1
|
|
or int(saved[&"cancelled_at"]) < -1
|
|
):
|
|
return false
|
|
if work_done == 0 and int(saved[&"started_at"]) != -1:
|
|
return false
|
|
if work_done > 0 and int(saved[&"started_at"]) < 0:
|
|
return false
|
|
if version == SNAPSHOT_VERSION:
|
|
var last_progress := int(saved[&"last_progress_at"])
|
|
if last_progress < -1:
|
|
return false
|
|
if work_done == 0 and last_progress != -1:
|
|
return false
|
|
if work_done > 0 and last_progress < int(saved[&"started_at"]):
|
|
return false
|
|
var state := String(saved[&"state"])
|
|
if state not in ConstructionJobType.State.keys():
|
|
return false
|
|
var facility_id := String(saved[&"facility_instance_id"])
|
|
match state:
|
|
"COMPLETED":
|
|
if progress != MAX_PROGRESS or int(saved[&"completed_at"]) < 0 or int(saved[&"cancelled_at"]) != -1 or facility_id.is_empty() or not String(saved[&"state_reason"]).is_empty():
|
|
return false
|
|
"CANCELLED":
|
|
if int(saved[&"cancelled_at"]) < 0 or int(saved[&"completed_at"]) != -1 or not facility_id.is_empty() or not String(saved[&"state_reason"]).is_empty():
|
|
return false
|
|
"PLANNED":
|
|
if work_done != 0 or progress != 0 or int(saved[&"started_at"]) != -1 or not String(saved[&"state_reason"]).is_empty():
|
|
return false
|
|
"BUILDING":
|
|
if work_done <= 0 or progress >= MAX_PROGRESS or not String(saved[&"state_reason"]).is_empty():
|
|
return false
|
|
"BLOCKED":
|
|
if String(saved[&"state_reason"]).is_empty():
|
|
return false
|
|
_:
|
|
if int(saved[&"completed_at"]) != -1 or int(saved[&"cancelled_at"]) != -1 or not facility_id.is_empty():
|
|
return false
|
|
if state not in ["COMPLETED", "CANCELLED"] and (
|
|
int(saved[&"completed_at"]) != -1
|
|
or int(saved[&"cancelled_at"]) != -1
|
|
or not facility_id.is_empty()
|
|
):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _validate_saved_facility(saved: Dictionary) -> bool:
|
|
var keys: Array[StringName] = [
|
|
&"instance_id", &"definition_id", &"origin", &"rotation", &"layer", &"occupied_cells",
|
|
&"construction_cost", &"source_job_id", &"state", &"completed_at",
|
|
]
|
|
if not _has_exact_keys(saved, keys):
|
|
return false
|
|
for text_key: StringName in [&"instance_id", &"definition_id", &"source_job_id", &"state"]:
|
|
if not saved[text_key] is String or String(saved[text_key]).is_empty():
|
|
return false
|
|
if String(saved[&"state"]) != "ACTIVE" or _parse_saved_cell(saved[&"origin"]) == null:
|
|
return false
|
|
var cells: Variant = _parse_saved_cells(saved[&"occupied_cells"])
|
|
if cells == null or cells.is_empty():
|
|
return false
|
|
var origin: Vector3i = _parse_saved_cell(saved[&"origin"])
|
|
for integer_key: StringName in [&"rotation", &"layer", &"completed_at"]:
|
|
if not saved[integer_key] is int:
|
|
return false
|
|
if (
|
|
int(saved[&"rotation"]) not in [0, 90, 180, 270]
|
|
or int(saved[&"layer"]) not in [0, 1]
|
|
or int(saved[&"completed_at"]) < 0
|
|
):
|
|
return false
|
|
if origin.z != int(saved[&"layer"]) or not cells.has(origin):
|
|
return false
|
|
for cell: Vector3i in cells:
|
|
if cell.z != int(saved[&"layer"]):
|
|
return false
|
|
return _valid_cost(saved[&"construction_cost"])
|
|
|
|
|
|
func _validate_restored_state(parsed: Dictionary) -> bool:
|
|
var restored_jobs: Dictionary = parsed[&"jobs"]
|
|
var restored_facilities: Dictionary = parsed[&"facilities"]
|
|
var progress_minutes: Dictionary = parsed[&"last_progress_minutes"]
|
|
if (
|
|
(not restored_jobs.is_empty() or not restored_facilities.is_empty())
|
|
and _missing_methods(_world, [&"is_in_bounds", &"get_occupant"]).size() > 0
|
|
):
|
|
return false
|
|
var job_high_water := 0
|
|
var facility_high_water := 0
|
|
for job_id: Variant in restored_jobs:
|
|
if not job_id is StringName or not progress_minutes.has(job_id):
|
|
return false
|
|
var job: ConstructionJob = restored_jobs[job_id]
|
|
if not _strict_bool_call(_world, &"is_in_bounds", [job.origin]):
|
|
return false
|
|
for cell: Vector3i in job.reserved_cells:
|
|
if not _strict_bool_call(_world, &"is_in_bounds", [cell]):
|
|
return false
|
|
var sequence := _sequence_from_id(String(job_id), "construction_")
|
|
if sequence < 1:
|
|
return false
|
|
job_high_water = maxi(job_high_water, sequence)
|
|
if job.state == ConstructionJobType.State.COMPLETED:
|
|
if not restored_facilities.has(job.facility_instance_id):
|
|
return false
|
|
elif job.state != ConstructionJobType.State.CANCELLED:
|
|
for cell: Vector3i in job.reserved_cells:
|
|
if _world_occupant(cell) != job.job_id:
|
|
return false
|
|
for instance_id: Variant in restored_facilities:
|
|
if not instance_id is StringName:
|
|
return false
|
|
var sequence := _sequence_from_id(String(instance_id), "facility_")
|
|
if sequence < 1:
|
|
return false
|
|
facility_high_water = maxi(facility_high_water, sequence)
|
|
var instance: Dictionary = restored_facilities[instance_id]
|
|
if not _strict_bool_call(_world, &"is_in_bounds", [instance[&"origin"]]):
|
|
return false
|
|
var source_id: StringName = instance[&"source_job_id"]
|
|
if not restored_jobs.has(source_id):
|
|
return false
|
|
var source_job: ConstructionJob = restored_jobs[source_id]
|
|
if (
|
|
source_job.state != ConstructionJobType.State.COMPLETED
|
|
or source_job.facility_instance_id != instance_id
|
|
or source_job.definition_id != instance[&"definition_id"]
|
|
or source_job.origin != instance[&"origin"]
|
|
or source_job.rotation != int(instance[&"rotation"])
|
|
or source_job.layer != int(instance[&"layer"])
|
|
or source_job.reserved_cells != instance[&"occupied_cells"]
|
|
or source_job.construction_cost != instance[&"construction_cost"]
|
|
or source_job.completed_at != int(instance[&"completed_at"])
|
|
):
|
|
return false
|
|
for cell: Vector3i in instance[&"occupied_cells"]:
|
|
if _world_occupant(cell) != instance_id:
|
|
return false
|
|
return (
|
|
job_high_water < int(parsed[&"next_job_sequence"])
|
|
and facility_high_water < int(parsed[&"next_facility_sequence"])
|
|
)
|
|
|
|
|
|
static func _parse_saved_cell(value: Variant) -> Variant:
|
|
if not value is Array or value.size() != 3:
|
|
return null
|
|
if not value[0] is int or not value[1] is int or not value[2] is int:
|
|
return null
|
|
return Vector3i(int(value[0]), int(value[1]), int(value[2]))
|
|
|
|
|
|
static func _parse_saved_cells(value: Variant) -> Variant:
|
|
if not value is Array:
|
|
return null
|
|
var result: Array[Vector3i] = []
|
|
var seen: Dictionary = {}
|
|
for raw_cell: Variant in value:
|
|
var cell: Variant = _parse_saved_cell(raw_cell)
|
|
if cell == null or seen.has(cell):
|
|
return null
|
|
seen[cell] = true
|
|
result.append(cell)
|
|
return result
|
|
|
|
|
|
static func _sequence_from_id(value: String, prefix: String) -> int:
|
|
if not value.begins_with(prefix) or value.length() != prefix.length() + 8:
|
|
return -1
|
|
var suffix := value.substr(prefix.length())
|
|
if not suffix.is_valid_int():
|
|
return -1
|
|
return int(suffix)
|
|
|
|
|
|
func _validate_cell_constraints(
|
|
report: PlacementReport,
|
|
origin: Vector3i,
|
|
expected_zone: int,
|
|
expected_layer: int
|
|
) -> void:
|
|
if origin.z != expected_layer:
|
|
report.layer_mismatch_cells.append(origin)
|
|
if expected_zone == 2 and origin.y != 0:
|
|
report.zone_mismatch_cells.append(origin)
|
|
for cell: Vector3i in report.cells:
|
|
var in_bounds := _strict_bool_call(_world, &"is_in_bounds", [cell])
|
|
if not in_bounds:
|
|
report.out_of_bounds_cells.append(cell)
|
|
if cell.z != expected_layer and not report.layer_mismatch_cells.has(cell):
|
|
report.layer_mismatch_cells.append(cell)
|
|
if not in_bounds:
|
|
continue
|
|
var zone_mismatch := false
|
|
if expected_zone == 2:
|
|
zone_mismatch = cell.y < 0
|
|
elif expected_zone != 0:
|
|
zone_mismatch = not _zone_matches(expected_zone, _world.call(&"get_zone", cell))
|
|
if zone_mismatch and not report.zone_mismatch_cells.has(cell):
|
|
report.zone_mismatch_cells.append(cell)
|
|
var occupant: Variant = _world.call(&"get_occupant", cell)
|
|
if not _occupant_is_empty(occupant):
|
|
report.occupied_cells.append(cell)
|
|
if cell.y < 0 and not _strict_bool_call(_world, &"is_excavated", [cell]):
|
|
report.unexcavated_cells.append(cell)
|
|
|
|
if not report.out_of_bounds_cells.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_OUT_OF_BOUNDS,
|
|
"One or more footprint cells are outside the current map bounds.",
|
|
report.out_of_bounds_cells
|
|
)
|
|
if not report.zone_mismatch_cells.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_ZONE_MISMATCH,
|
|
"The facility is not permitted in one or more map zones.",
|
|
report.zone_mismatch_cells,
|
|
{&"expected_zone": expected_zone}
|
|
)
|
|
if not report.layer_mismatch_cells.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_LAYER_MISMATCH,
|
|
"The footprint layer differs from the facility definition.",
|
|
report.layer_mismatch_cells,
|
|
{&"expected_layer": expected_layer}
|
|
)
|
|
if not report.occupied_cells.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_OVERLAP,
|
|
"One or more footprint cells are already occupied or reserved.",
|
|
report.occupied_cells
|
|
)
|
|
if not report.unexcavated_cells.is_empty():
|
|
report.add_error(
|
|
PlacementReportType.ERROR_NOT_EXCAVATED,
|
|
"Underground facilities require every footprint cell to be excavated first.",
|
|
report.unexcavated_cells
|
|
)
|
|
|
|
|
|
func _validate_support(report: PlacementReport, definition: Variant) -> void:
|
|
if report.cells.is_empty():
|
|
return
|
|
var weight_per_cell := maxi(int(_definition_value(definition, &"weight_per_cell", 1)), 1)
|
|
var support_result: Variant = _call_by_supported_arity(
|
|
_world,
|
|
&"validate_support",
|
|
[report.cells, weight_per_cell]
|
|
)
|
|
var supported := true
|
|
var reason := "The structure does not have sufficient support."
|
|
if support_result is bool:
|
|
supported = support_result
|
|
if not supported:
|
|
report.unsupported_cells.assign(report.cells)
|
|
elif support_result is Array:
|
|
for cell: Variant in support_result:
|
|
if cell is Vector3i:
|
|
report.unsupported_cells.append(cell)
|
|
supported = report.unsupported_cells.is_empty()
|
|
elif support_result is Dictionary:
|
|
var raw_ok: Variant = support_result.get(&"ok", support_result.get("ok", null))
|
|
supported = raw_ok if raw_ok is bool else false
|
|
reason = String(support_result.get(&"reason", support_result.get("reason", reason)))
|
|
var unsupported: Variant = support_result.get(
|
|
&"unsupported_cells",
|
|
support_result.get("unsupported_cells", [])
|
|
)
|
|
if unsupported is Array:
|
|
for cell: Variant in unsupported:
|
|
if cell is Vector3i:
|
|
report.unsupported_cells.append(cell)
|
|
if not supported and report.unsupported_cells.is_empty():
|
|
report.unsupported_cells.assign(report.cells)
|
|
elif support_result is Object and support_result.has_method(&"is_success"):
|
|
var raw_success: Variant = support_result.call(&"is_success")
|
|
supported = raw_success if raw_success is bool else false
|
|
if not supported:
|
|
report.unsupported_cells.assign(report.cells)
|
|
|
|
if not supported:
|
|
report.add_error(
|
|
PlacementReportType.ERROR_UNSUPPORTED,
|
|
reason,
|
|
report.unsupported_cells
|
|
)
|
|
|
|
|
|
func _validate_cost(report: PlacementReport) -> void:
|
|
if _resource_state == null:
|
|
return
|
|
if _can_afford(report.total_cost):
|
|
return
|
|
for resource_name: Variant in report.total_cost:
|
|
var required := int(report.total_cost[resource_name])
|
|
var available := _resource_amount(StringName(resource_name))
|
|
if available < required:
|
|
report.missing_resources[StringName(resource_name)] = required - available
|
|
report.add_error(
|
|
PlacementReportType.ERROR_INSUFFICIENT_RESOURCES,
|
|
"Available resources do not cover the complete construction cost.",
|
|
[],
|
|
{&"missing": report.missing_resources.duplicate(true)}
|
|
)
|
|
|
|
|
|
func _complete_job(job: ConstructionJob, absolute_minute: int) -> CommandResult:
|
|
if _facilities.size() >= MAX_FACILITIES or _next_facility_sequence > MAX_SEQUENCE:
|
|
job.mark_blocked("Facility capacity has been reached.")
|
|
_emit_job_changed(job)
|
|
return _failure("FACILITY_LIMIT", job.state_reason, {&"job_id": job.job_id})
|
|
var instance_id := StringName("facility_%08d" % _next_facility_sequence)
|
|
var world_snapshot := _capture_world_state(job.reserved_cells)
|
|
if not _world_release(job.reserved_cells, job.job_id):
|
|
var release_rolled_back := _restore_world_state(world_snapshot)
|
|
job.mark_blocked("The construction reservation could not be released for completion.")
|
|
if not release_rolled_back:
|
|
_quarantine("Construction completion release failed and world state could not be restored.")
|
|
_emit_job_changed(job)
|
|
return _failure("RELEASE_FAILED", job.state_reason, {
|
|
&"job_id": job.job_id,
|
|
&"rolled_back": release_rolled_back,
|
|
&"rollback_failed": not release_rolled_back,
|
|
})
|
|
if not _world_occupy(job.reserved_cells, instance_id, &"facility"):
|
|
var rolled_back := _restore_world_state(world_snapshot)
|
|
job.mark_blocked("Facility occupancy failed during construction completion.")
|
|
if not rolled_back:
|
|
_quarantine("Facility occupancy failed and the construction reservation could not be restored.")
|
|
_emit_job_changed(job)
|
|
return _failure("FACILITY_OCCUPANCY_FAILED", job.state_reason, {
|
|
&"job_id": job.job_id,
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
})
|
|
|
|
var instance := {
|
|
&"instance_id": instance_id,
|
|
&"definition_id": job.definition_id,
|
|
&"origin": job.origin,
|
|
&"rotation": job.rotation,
|
|
&"layer": job.layer,
|
|
&"occupied_cells": job.reserved_cells.duplicate(),
|
|
&"construction_cost": job.construction_cost.duplicate(true),
|
|
&"source_job_id": job.job_id,
|
|
&"state": &"ACTIVE",
|
|
&"completed_at": absolute_minute,
|
|
}
|
|
_facilities[instance_id] = instance
|
|
_next_facility_sequence += 1
|
|
job.mark_completed(instance_id, absolute_minute)
|
|
_emit_job_changed(job)
|
|
_emit_facility_completed(instance)
|
|
return CommandResultType.succeeded(0, {
|
|
&"job_id": job.job_id,
|
|
&"instance_id": instance_id,
|
|
&"progress": job.progress,
|
|
})
|
|
|
|
|
|
func _world_cells_for_footprint(
|
|
origin: Vector3i,
|
|
footprint: Vector2i,
|
|
rotation: int,
|
|
layer: int
|
|
) -> Array[Vector3i]:
|
|
var argument_count := _method_argument_count(_world, &"cells_for_footprint")
|
|
var returned: Variant
|
|
if argument_count <= 2:
|
|
var rotated_footprint := footprint
|
|
if rotation == 90 or rotation == 270:
|
|
rotated_footprint = Vector2i(footprint.y, footprint.x)
|
|
returned = _world.callv(&"cells_for_footprint", [origin, rotated_footprint])
|
|
else:
|
|
returned = _call_by_supported_arity(
|
|
_world,
|
|
&"cells_for_footprint",
|
|
[origin, footprint, rotation, layer]
|
|
)
|
|
var cells: Array[Vector3i] = []
|
|
var seen: Dictionary = {}
|
|
if not returned is Array or returned.size() != footprint.x * footprint.y:
|
|
return cells
|
|
for cell: Variant in returned:
|
|
if not cell is Vector3i or seen.has(cell):
|
|
return []
|
|
seen[cell] = true
|
|
cells.append(cell)
|
|
return cells
|
|
|
|
|
|
func _world_occupy(cells: Array[Vector3i], occupant_id: StringName, kind: StringName) -> bool:
|
|
if _world == null or not _world.has_method(&"occupy"):
|
|
return false
|
|
var argument_count := _method_argument_count(_world, &"occupy")
|
|
var arguments: Array = [occupant_id, cells, kind]
|
|
var returned: Variant = _world.callv(&"occupy", arguments.slice(0, argument_count))
|
|
return _operation_succeeded(returned)
|
|
|
|
|
|
func _world_release(cells: Array[Vector3i], occupant_id: StringName) -> bool:
|
|
if _world == null or not _world.has_method(&"release"):
|
|
return false
|
|
var argument_count := _method_argument_count(_world, &"release")
|
|
var arguments: Array = [occupant_id] if argument_count == 1 else [occupant_id, cells]
|
|
var returned: Variant = _world.callv(&"release", arguments.slice(0, argument_count))
|
|
return _operation_succeeded(returned)
|
|
|
|
|
|
func _definition_build_minutes(definition: Variant) -> int:
|
|
var configured := int(_definition_value(definition, &"build_minutes", 0))
|
|
return configured if configured > 0 else maxi(default_build_minutes, 1)
|
|
|
|
|
|
func _definition_is_valid(definition: Variant) -> bool:
|
|
if definition == null:
|
|
return false
|
|
if definition is Object and definition.has_method(&"is_valid"):
|
|
var valid_result: Variant = definition.call(&"is_valid")
|
|
if not valid_result is bool or not valid_result:
|
|
return false
|
|
var raw_id: Variant = _definition_value(definition, &"id", null)
|
|
var footprint: Variant = _definition_value(definition, &"footprint", null)
|
|
var cost: Variant = _definition_value(definition, &"construction_cost", null)
|
|
var minutes: Variant = _definition_value(definition, &"build_minutes", default_build_minutes)
|
|
return (
|
|
(raw_id is String or raw_id is StringName)
|
|
and not String(raw_id).is_empty()
|
|
and footprint is Vector2i
|
|
and footprint.x > 0
|
|
and footprint.y > 0
|
|
and _valid_cost(cost)
|
|
and minutes is int
|
|
and int(minutes) > 0
|
|
and int(minutes) <= MAX_BUILD_MINUTES
|
|
)
|
|
|
|
|
|
func _definition_value(source: Variant, property_name: StringName, fallback: Variant) -> Variant:
|
|
if source is Dictionary:
|
|
return source.get(property_name, source.get(String(property_name), fallback))
|
|
if source == null or not source.has_method(&"get_property_list"):
|
|
return fallback
|
|
for property: Dictionary in source.get_property_list():
|
|
if StringName(property.get(&"name", &"")) == property_name:
|
|
return source.get(property_name)
|
|
return fallback
|
|
|
|
|
|
func _can_afford(cost: Dictionary) -> bool:
|
|
if _resource_state == null:
|
|
return false
|
|
if _resource_state.has_method(&"can_afford"):
|
|
return _operation_succeeded(_resource_state.call(&"can_afford", cost))
|
|
for resource_name: Variant in cost:
|
|
if _resource_amount(StringName(resource_name)) < int(cost[resource_name]):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _spend_cost(cost: Dictionary) -> bool:
|
|
if _resource_state == null:
|
|
return false
|
|
if _resource_state.has_method(&"spend"):
|
|
return _operation_succeeded(_resource_state.call(&"spend", cost))
|
|
if not _can_afford(cost):
|
|
return false
|
|
var deltas: Dictionary = {}
|
|
for resource_name: Variant in cost:
|
|
deltas[StringName(resource_name)] = -int(cost[resource_name])
|
|
if _resource_state.has_method(&"apply_resource_deltas"):
|
|
return _operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas))
|
|
return false
|
|
|
|
|
|
func _refund_cost(refund: Dictionary) -> bool:
|
|
if refund.is_empty():
|
|
return true
|
|
if _resource_state == null:
|
|
return false
|
|
if _resource_state.has_method(&"apply_resource_deltas"):
|
|
return _operation_succeeded(_resource_state.call(&"apply_resource_deltas", refund))
|
|
if _resource_state.has_method(&"add_resource"):
|
|
for resource_name: Variant in refund:
|
|
var returned: Variant = _resource_state.call(
|
|
&"add_resource",
|
|
StringName(resource_name),
|
|
int(refund[resource_name])
|
|
)
|
|
if not returned is int and not (returned is bool and returned):
|
|
return false
|
|
return true
|
|
return false
|
|
|
|
|
|
func _resource_amount(resource_name: StringName) -> int:
|
|
if _resource_state == null:
|
|
return 0
|
|
if _resource_state.has_method(&"get_resource"):
|
|
var returned: Variant = _resource_state.call(&"get_resource", resource_name)
|
|
return returned if returned is int else 0
|
|
var resources: Variant = _definition_value(_resource_state, &"resources", {})
|
|
if resources is Dictionary:
|
|
var amount: Variant = resources.get(resource_name, resources.get(String(resource_name), 0))
|
|
return amount if amount is int else 0
|
|
return 0
|
|
|
|
|
|
func _capture_resource_state() -> Variant:
|
|
var captured := {
|
|
&"amounts": {
|
|
&"gold": _resource_amount(&"gold"),
|
|
&"materials": _resource_amount(&"materials"),
|
|
},
|
|
&"has_native": false,
|
|
&"native_snapshot": null,
|
|
}
|
|
if _resource_state != null and _resource_state.has_method(&"create_snapshot"):
|
|
captured[&"has_native"] = true
|
|
captured[&"native_snapshot"] = _resource_state.call(&"create_snapshot")
|
|
return captured
|
|
|
|
|
|
func _restore_resource_state(snapshot: Variant) -> bool:
|
|
if not snapshot is Dictionary or _resource_state == null:
|
|
return false
|
|
var expected: Variant = snapshot.get(&"amounts", null)
|
|
if not expected is Dictionary:
|
|
return false
|
|
if bool(snapshot.get(&"has_native", false)) and _resource_state.has_method(&"restore_snapshot"):
|
|
_operation_succeeded(_resource_state.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null)))
|
|
elif _resource_state.has_method(&"apply_resource_deltas"):
|
|
var deltas: Dictionary = {}
|
|
for resource_name: StringName in [&"gold", &"materials"]:
|
|
var expected_amount: Variant = expected.get(resource_name, null)
|
|
if not expected_amount is int:
|
|
return false
|
|
deltas[resource_name] = int(expected_amount) - _resource_amount(resource_name)
|
|
_operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas))
|
|
else:
|
|
return false
|
|
return _resource_amounts_match(expected)
|
|
|
|
|
|
func _capture_world_state(cells: Array[Vector3i]) -> Dictionary:
|
|
var occupants: Array[Dictionary] = []
|
|
for cell: Vector3i in cells:
|
|
var occupant := _world_occupant(cell)
|
|
var kind := &""
|
|
if _world != null and _world.has_method(&"get_occupant_kind"):
|
|
var raw_kind: Variant = _world.call(&"get_occupant_kind", cell)
|
|
if raw_kind is String or raw_kind is StringName:
|
|
kind = StringName(raw_kind)
|
|
if kind.is_empty() and not occupant.is_empty():
|
|
kind = &"construction" if String(occupant).begins_with("construction_") else &"facility"
|
|
occupants.append({&"cell": cell, &"occupant_id": occupant, &"kind": kind})
|
|
var captured := {
|
|
&"occupants": occupants,
|
|
&"has_native": false,
|
|
&"native_snapshot": null,
|
|
}
|
|
if _world != null and _world.has_method(&"create_snapshot"):
|
|
captured[&"has_native"] = true
|
|
captured[&"native_snapshot"] = _world.call(&"create_snapshot")
|
|
return captured
|
|
|
|
|
|
func _restore_world_state(snapshot: Variant) -> bool:
|
|
if not snapshot is Dictionary or _world == null:
|
|
return false
|
|
var occupants: Variant = snapshot.get(&"occupants", null)
|
|
if not occupants is Array:
|
|
return false
|
|
if bool(snapshot.get(&"has_native", false)) and _world.has_method(&"restore_snapshot"):
|
|
_operation_succeeded(_world.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null)))
|
|
else:
|
|
var cells: Array[Vector3i] = []
|
|
var ids_to_release: Dictionary = {}
|
|
for entry: Variant in occupants:
|
|
if not entry is Dictionary or not entry.get(&"cell", null) is Vector3i:
|
|
return false
|
|
var cell: Vector3i = entry[&"cell"]
|
|
cells.append(cell)
|
|
var current_id := _world_occupant(cell)
|
|
if not current_id.is_empty():
|
|
ids_to_release[current_id] = true
|
|
for occupant_id: Variant in ids_to_release:
|
|
_world_release(cells, StringName(occupant_id))
|
|
var groups: Dictionary = {}
|
|
for entry: Dictionary in occupants:
|
|
var expected_id := StringName(entry[&"occupant_id"])
|
|
if expected_id.is_empty():
|
|
continue
|
|
if not groups.has(expected_id):
|
|
groups[expected_id] = {&"cells": [], &"kind": StringName(entry[&"kind"])}
|
|
groups[expected_id][&"cells"].append(entry[&"cell"])
|
|
for expected_id: Variant in groups:
|
|
var restored_cells: Array[Vector3i] = []
|
|
restored_cells.assign(groups[expected_id][&"cells"])
|
|
_world_occupy(restored_cells, StringName(expected_id), groups[expected_id][&"kind"])
|
|
return _world_state_matches(occupants)
|
|
|
|
|
|
func _world_state_matches(occupants: Variant) -> bool:
|
|
if not occupants is Array:
|
|
return false
|
|
for entry: Variant in occupants:
|
|
if not entry is Dictionary or not entry.get(&"cell", null) is Vector3i:
|
|
return false
|
|
if _world_occupant(entry[&"cell"]) != StringName(entry.get(&"occupant_id", &"")):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _world_occupant(cell: Vector3i) -> StringName:
|
|
if _world == null or not _world.has_method(&"get_occupant"):
|
|
return &""
|
|
var returned: Variant = _world.call(&"get_occupant", cell)
|
|
if returned is String or returned is StringName:
|
|
return StringName(returned)
|
|
return &""
|
|
|
|
|
|
func _resource_amounts_match(expected: Dictionary) -> bool:
|
|
for resource_name: StringName in [&"gold", &"materials"]:
|
|
var amount: Variant = expected.get(resource_name, null)
|
|
if not amount is int or _resource_amount(resource_name) != int(amount):
|
|
return false
|
|
return true
|
|
|
|
|
|
func _serialize_facility(instance: Dictionary) -> Dictionary:
|
|
var serialized := instance.duplicate(true)
|
|
serialized[&"instance_id"] = String(instance.get(&"instance_id", &""))
|
|
serialized[&"definition_id"] = String(instance.get(&"definition_id", &""))
|
|
serialized[&"source_job_id"] = String(instance.get(&"source_job_id", &""))
|
|
serialized[&"state"] = String(instance.get(&"state", &"ACTIVE"))
|
|
var origin: Vector3i = instance.get(&"origin", Vector3i.ZERO)
|
|
serialized[&"origin"] = [origin.x, origin.y, origin.z]
|
|
var saved_cells: Array[Array] = []
|
|
for cell: Vector3i in instance.get(&"occupied_cells", []):
|
|
saved_cells.append([cell.x, cell.y, cell.z])
|
|
serialized[&"occupied_cells"] = saved_cells
|
|
return serialized
|
|
|
|
|
|
func _deserialize_facility(saved: Dictionary) -> Dictionary:
|
|
var instance := saved.duplicate(true)
|
|
instance[&"instance_id"] = StringName(saved.get(&"instance_id", ""))
|
|
instance[&"definition_id"] = StringName(saved.get(&"definition_id", ""))
|
|
instance[&"source_job_id"] = StringName(saved.get(&"source_job_id", ""))
|
|
instance[&"state"] = StringName(saved.get(&"state", "ACTIVE"))
|
|
var origin: Variant = saved.get(&"origin", [])
|
|
if origin is Array and origin.size() == 3:
|
|
instance[&"origin"] = Vector3i(int(origin[0]), int(origin[1]), int(origin[2]))
|
|
else:
|
|
instance[&"origin"] = Vector3i.ZERO
|
|
var cells: Array[Vector3i] = []
|
|
var saved_cells: Variant = saved.get(&"occupied_cells", [])
|
|
if saved_cells is Array:
|
|
for cell: Variant in saved_cells:
|
|
if cell is Array and cell.size() == 3:
|
|
cells.append(Vector3i(int(cell[0]), int(cell[1]), int(cell[2])))
|
|
instance[&"occupied_cells"] = cells
|
|
instance[&"construction_cost"] = _normalize_cost(saved.get(&"construction_cost", {}))
|
|
return instance
|
|
|
|
|
|
func _not_found(entity_name: String, entity_id: StringName) -> CommandResult:
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.NOT_FOUND,
|
|
0,
|
|
"%s '%s' was not found." % [entity_name, entity_id],
|
|
{&"id": entity_id}
|
|
)
|
|
|
|
|
|
func _invalid_state(message: String, entity_id: StringName) -> CommandResult:
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.VALIDATION_FAILED,
|
|
0,
|
|
message,
|
|
{&"id": entity_id}
|
|
)
|
|
|
|
|
|
func _failure(code: StringName, message: String, details: Dictionary = {}) -> CommandResult:
|
|
var merged := details.duplicate(true)
|
|
merged[&"code"] = code
|
|
return CommandResultType.failed(
|
|
CommandResultType.Code.TRANSACTION_APPLY_FAILED,
|
|
0,
|
|
message,
|
|
merged
|
|
)
|
|
|
|
|
|
func _notification_unavailable_result(code: StringName, message: String) -> CommandResult:
|
|
return _failure(code, message)
|
|
|
|
|
|
func is_quarantined() -> bool:
|
|
return _quarantined
|
|
|
|
|
|
func get_quarantine_reason() -> String:
|
|
return _quarantine_reason
|
|
|
|
|
|
func _mutation_allowed() -> bool:
|
|
return (
|
|
not _mutation_in_progress
|
|
and not _notification_commit_active
|
|
and not _quarantined
|
|
and (_notification_staging_active or not has_deferred_notifications())
|
|
)
|
|
|
|
|
|
func _unavailable_result() -> CommandResult:
|
|
var code: StringName = &"SERVICE_QUARANTINED" if _quarantined else &"REENTRANT_MUTATION"
|
|
var message := (
|
|
"Construction service is quarantined: %s" % _quarantine_reason
|
|
if _quarantined
|
|
else "A reentrant construction mutation was rejected."
|
|
)
|
|
return _failure(code, message, {&"quarantine_reason": _quarantine_reason})
|
|
|
|
|
|
func _quarantine(reason: String) -> void:
|
|
_quarantined = true
|
|
_quarantine_reason = reason
|
|
|
|
|
|
static func _normalize_cost(source: Variant) -> Dictionary:
|
|
var normalized: Dictionary = {}
|
|
if not _valid_cost(source):
|
|
return normalized
|
|
for resource_name: StringName in [&"gold", &"materials"]:
|
|
normalized[resource_name] = int(source[resource_name])
|
|
return normalized
|
|
|
|
|
|
static func _valid_cost(source: Variant) -> bool:
|
|
if not source is Dictionary or source.size() != 2:
|
|
return false
|
|
for resource_name: StringName in [&"gold", &"materials"]:
|
|
if not source.has(resource_name):
|
|
return false
|
|
var amount: Variant = source[resource_name]
|
|
if not amount is int or int(amount) < 0:
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _has_exact_keys(source: Dictionary, expected: Array) -> bool:
|
|
if source.size() != expected.size():
|
|
return false
|
|
for key: Variant in expected:
|
|
if not source.has(key):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _normalize_rotation(value: int) -> int:
|
|
var normalized := value % 360
|
|
if normalized < 0:
|
|
normalized += 360
|
|
return (normalized / 90) * 90
|
|
|
|
|
|
static func _zone_matches(expected_zone: int, actual_zone: Variant) -> bool:
|
|
if actual_zone is int:
|
|
return int(actual_zone) == expected_zone
|
|
var normalized := String(actual_zone).to_upper()
|
|
match expected_zone:
|
|
1:
|
|
return normalized == "ABOVE_GROUND"
|
|
2:
|
|
return normalized == "BOUNDARY"
|
|
3:
|
|
return normalized == "UNDERGROUND"
|
|
return false
|
|
|
|
|
|
static func _occupant_is_empty(occupant: Variant) -> bool:
|
|
if occupant == null:
|
|
return true
|
|
match typeof(occupant):
|
|
TYPE_BOOL:
|
|
return not bool(occupant)
|
|
TYPE_INT, TYPE_FLOAT:
|
|
return float(occupant) == 0.0
|
|
TYPE_STRING, TYPE_STRING_NAME:
|
|
return String(occupant).is_empty()
|
|
return false
|
|
|
|
|
|
static func _operation_succeeded(returned: Variant) -> bool:
|
|
if returned is bool:
|
|
return returned
|
|
if returned is Object and returned.has_method(&"is_success"):
|
|
var result: Variant = returned.call(&"is_success")
|
|
return result if result is bool else false
|
|
return false
|
|
|
|
|
|
static func _strict_bool_call(target: Variant, method_name: StringName, arguments: Array) -> bool:
|
|
if target == null or not target.has_method(method_name):
|
|
return false
|
|
var returned: Variant = target.callv(method_name, arguments)
|
|
return returned if returned is bool else false
|
|
|
|
|
|
static func _missing_methods(target: Variant, methods: Array[StringName]) -> Array[String]:
|
|
var missing: Array[String] = []
|
|
if target == null:
|
|
for method_name: StringName in methods:
|
|
missing.append(String(method_name))
|
|
return missing
|
|
for method_name: StringName in methods:
|
|
if not target.has_method(method_name):
|
|
missing.append(String(method_name))
|
|
return missing
|
|
|
|
|
|
static func _call_by_supported_arity(target: Variant, method_name: StringName, arguments: Array) -> Variant:
|
|
if target == null or not target.has_method(method_name):
|
|
return null
|
|
var argument_count := mini(arguments.size(), _method_argument_count(target, method_name))
|
|
return target.callv(method_name, arguments.slice(0, argument_count))
|
|
|
|
|
|
static func _method_argument_count(target: Variant, method_name: StringName) -> int:
|
|
if target == null or not target.has_method(method_name):
|
|
return 0
|
|
for method: Dictionary in target.get_method_list():
|
|
if StringName(method.get(&"name", &"")) != method_name:
|
|
continue
|
|
var declared_arguments: Variant = method.get(&"args", [])
|
|
if declared_arguments is Array:
|
|
return declared_arguments.size()
|
|
return 0
|
|
|
|
|
|
static func _sorted_string_name_keys(source: Dictionary) -> Array[StringName]:
|
|
var as_strings: Array[String] = []
|
|
for key: Variant in source:
|
|
as_strings.append(String(key))
|
|
as_strings.sort()
|
|
var result: Array[StringName] = []
|
|
for value: String in as_strings:
|
|
result.append(StringName(value))
|
|
return result
|