1035 lines
36 KiB
GDScript
1035 lines
36 KiB
GDScript
class_name ExcavationService
|
|
extends RefCounted
|
|
|
|
## Deterministic underground excavation queue.
|
|
##
|
|
## Excavation is owned per spatial X/Y cell. Completing one job unlocks both
|
|
## FRONT and BACK through WorldService.set_excavated(). A job can be paused and
|
|
## resumed without losing work, and every new cell must remain connected to the
|
|
## boundary through already excavated cells.
|
|
|
|
signal job_created(job: Dictionary)
|
|
signal job_changed(job: Dictionary)
|
|
signal excavation_completed(report: Dictionary)
|
|
|
|
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
|
|
|
const SNAPSHOT_VERSION := 2
|
|
const LEGACY_SNAPSHOT_VERSION := 1
|
|
const MAX_PROGRESS := 10_000
|
|
const NORMAL_WORK_RATE := 10_000
|
|
const MAX_WORK_RATE := 1_000_000
|
|
const MAX_WORK_UNITS := 9_000_000_000_000_000
|
|
const MAX_HARDNESS := 3
|
|
const MAX_JOBS := 20_000
|
|
const MAX_SEQUENCE := 99_999_999
|
|
|
|
const STATE_ACTIVE: StringName = &"ACTIVE"
|
|
const STATE_PAUSED: StringName = &"PAUSED"
|
|
const STATE_COMPLETED: StringName = &"COMPLETED"
|
|
|
|
const ERROR_OUT_OF_BOUNDS: StringName = &"OUT_OF_BOUNDS"
|
|
const ERROR_NOT_UNDERGROUND: StringName = &"NOT_UNDERGROUND"
|
|
const ERROR_ALREADY_EXCAVATED: StringName = &"ALREADY_EXCAVATED"
|
|
const ERROR_ALREADY_RESERVED: StringName = &"ALREADY_RESERVED"
|
|
const ERROR_NO_EXCAVATED_NEIGHBOR: StringName = &"NO_EXCAVATED_NEIGHBOR"
|
|
const ERROR_NO_ENTRANCE_CONNECTION: StringName = &"NO_ENTRANCE_CONNECTION"
|
|
const ERROR_SUPPORT_REQUIRED: StringName = &"SUPPORT_REQUIRED"
|
|
const ERROR_INSUFFICIENT_RESOURCES: StringName = &"INSUFFICIENT_RESOURCES"
|
|
const ERROR_MISSING_DEPENDENCY: StringName = &"MISSING_DEPENDENCY"
|
|
|
|
const HARDNESS_RULES := {
|
|
0: {&"gold": 20, &"materials": 1, &"minutes": 30},
|
|
1: {&"gold": 50, &"materials": 1, &"minutes": 60},
|
|
2: {&"gold": 100, &"materials": 2, &"minutes": 120},
|
|
3: {&"gold": 200, &"materials": 4, &"minutes": 240},
|
|
}
|
|
|
|
var current_minute := 0
|
|
var last_error := ""
|
|
|
|
var _world: Variant
|
|
var _resource_state: Variant
|
|
var _hardness: Dictionary = {}
|
|
var _jobs: Dictionary = {}
|
|
var _cell_jobs: Dictionary = {}
|
|
var _next_job_sequence := 1
|
|
var _external_mutation_in_progress := false
|
|
var _quarantined := false
|
|
var _quarantine_reason := ""
|
|
|
|
|
|
func _init(world: Variant = null, resource_state: Variant = null) -> void:
|
|
_world = world
|
|
_resource_state = resource_state
|
|
|
|
|
|
func configure(world: Variant, resource_state: Variant) -> ExcavationService:
|
|
_world = world
|
|
_resource_state = resource_state
|
|
return self
|
|
|
|
|
|
func set_hardness(cell_or_position: Variant, hardness: int) -> bool:
|
|
if not _mutation_allowed():
|
|
return false
|
|
var position: Variant = _spatial_position(cell_or_position)
|
|
if position == null or hardness < 0 or hardness > MAX_HARDNESS:
|
|
return _fail("Excavation hardness or position is invalid.")
|
|
var front := Vector3i(position.x, position.y, 0)
|
|
if not _world_ready() or not _call_bool(_world, &"is_in_bounds", [front]) or position.y >= 0:
|
|
return _fail("Hardness can only be assigned to an in-bounds underground cell.")
|
|
if _cell_jobs.has(position) or _call_bool(_world, &"is_excavated", [front]):
|
|
return _fail("Hardness is immutable after excavation has started.")
|
|
if hardness == 0:
|
|
_hardness.erase(position)
|
|
else:
|
|
_hardness[position] = hardness
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func get_hardness(cell_or_position: Variant) -> int:
|
|
var position: Variant = _spatial_position(cell_or_position)
|
|
if position == null:
|
|
return -1
|
|
return int(_hardness.get(position, 0))
|
|
|
|
|
|
func get_rule(hardness: int) -> Dictionary:
|
|
if not HARDNESS_RULES.has(hardness):
|
|
return {}
|
|
return (HARDNESS_RULES[hardness] as Dictionary).duplicate(true)
|
|
|
|
|
|
func validate_excavation(cell: Vector3i) -> Dictionary:
|
|
var position := Vector2i(cell.x, cell.y)
|
|
var errors: Array[StringName] = []
|
|
var hardness := get_hardness(position)
|
|
var rule := get_rule(hardness)
|
|
var report := {
|
|
&"ok": false,
|
|
&"position": position,
|
|
&"cells": [
|
|
Vector3i(position.x, position.y, 0),
|
|
Vector3i(position.x, position.y, 1),
|
|
],
|
|
&"hardness": hardness,
|
|
&"cost": _rule_cost(rule),
|
|
&"build_minutes": int(rule.get(&"minutes", 0)),
|
|
&"errors": errors,
|
|
&"missing_resources": {},
|
|
&"support_required": false,
|
|
&"entrance_connected": false,
|
|
}
|
|
if _quarantined or not _world_ready():
|
|
_add_error(errors, ERROR_MISSING_DEPENDENCY)
|
|
report[&"errors"] = errors
|
|
return report
|
|
var front := Vector3i(position.x, position.y, 0)
|
|
if not _call_bool(_world, &"is_in_bounds", [front]):
|
|
_add_error(errors, ERROR_OUT_OF_BOUNDS)
|
|
if position.y >= 0:
|
|
_add_error(errors, ERROR_NOT_UNDERGROUND)
|
|
if errors.is_empty() and _call_bool(_world, &"is_excavated", [front]):
|
|
_add_error(errors, ERROR_ALREADY_EXCAVATED)
|
|
if _cell_jobs.has(position):
|
|
_add_error(errors, ERROR_ALREADY_RESERVED)
|
|
|
|
if errors.is_empty():
|
|
var excavated_neighbors := _excavated_neighbors(position)
|
|
if excavated_neighbors.is_empty():
|
|
_add_error(errors, ERROR_NO_EXCAVATED_NEIGHBOR)
|
|
else:
|
|
var connected := false
|
|
for neighbor: Vector2i in excavated_neighbors:
|
|
if _has_boundary_connection(neighbor):
|
|
connected = true
|
|
break
|
|
report[&"entrance_connected"] = connected
|
|
if not connected:
|
|
_add_error(errors, ERROR_NO_ENTRANCE_CONNECTION)
|
|
|
|
if errors.is_empty() and hardness >= 2:
|
|
var hard_component := _prospective_hard_span(position)
|
|
var support_required := hard_component.size() >= 4
|
|
report[&"support_required"] = support_required
|
|
if support_required and not _component_has_support(hard_component):
|
|
_add_error(errors, ERROR_SUPPORT_REQUIRED)
|
|
|
|
var missing: Dictionary = {}
|
|
if _resource_state == null:
|
|
_add_error(errors, ERROR_MISSING_DEPENDENCY)
|
|
else:
|
|
for resource_name: StringName in [&"gold", &"materials"]:
|
|
var required := int(report[&"cost"].get(resource_name, 0))
|
|
var available := _resource_amount(resource_name)
|
|
if available < required:
|
|
missing[resource_name] = required - available
|
|
if not missing.is_empty():
|
|
_add_error(errors, ERROR_INSUFFICIENT_RESOURCES)
|
|
report[&"missing_resources"] = missing
|
|
report[&"errors"] = errors
|
|
report[&"ok"] = errors.is_empty()
|
|
return report
|
|
|
|
|
|
func start_excavation(cell: Vector3i, 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("EXCAVATION_LIMIT", "Excavation job capacity has been reached.")
|
|
var report := validate_excavation(cell)
|
|
if not bool(report[&"ok"]):
|
|
return _failure("INVALID_EXCAVATION", "Excavation cannot start.", {&"report": report})
|
|
var resource_snapshot: Variant = _capture_resource_state()
|
|
var cost: Dictionary = report[&"cost"]
|
|
_external_mutation_in_progress = true
|
|
if not _spend_cost(cost):
|
|
var rolled_back := _restore_resource_state(resource_snapshot, cost)
|
|
_external_mutation_in_progress = false
|
|
if not rolled_back:
|
|
_quarantine("Excavation payment failed after mutating resources and could not be rolled back.")
|
|
return _failure("PAYMENT_FAILED", "Excavation resources changed before payment.", {
|
|
&"cost": cost,
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
})
|
|
_external_mutation_in_progress = false
|
|
var job_id := StringName("excavation_%08d" % _next_job_sequence)
|
|
var position: Vector2i = report[&"position"]
|
|
var job := {
|
|
&"job_id": job_id,
|
|
&"position": position,
|
|
&"hardness": int(report[&"hardness"]),
|
|
&"cost": cost.duplicate(true),
|
|
&"build_minutes": int(report[&"build_minutes"]),
|
|
&"work_units_done": 0,
|
|
&"progress": 0,
|
|
&"state": STATE_ACTIVE,
|
|
&"state_reason": &"",
|
|
&"created_at": current_minute if created_at < 0 else created_at,
|
|
&"started_at": -1,
|
|
&"completed_at": -1,
|
|
&"last_progress_at": -1,
|
|
}
|
|
if _cell_jobs.has(position):
|
|
_external_mutation_in_progress = true
|
|
var rolled_back := _restore_resource_state(resource_snapshot, cost)
|
|
_external_mutation_in_progress = false
|
|
if not rolled_back:
|
|
_quarantine("Excavation payment rollback failed after a concurrent reservation.")
|
|
return _failure("RESERVATION_FAILED", "The excavation cell was reserved concurrently.", {
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
})
|
|
_jobs[job_id] = job
|
|
_cell_jobs[position] = job_id
|
|
_next_job_sequence += 1
|
|
last_error = ""
|
|
job_created.emit(job.duplicate(true))
|
|
return _success({&"job_id": job_id, &"report": report})
|
|
|
|
|
|
func get_job(job_id: StringName) -> Dictionary:
|
|
if not _jobs.has(job_id):
|
|
return {}
|
|
return (_jobs[job_id] as Dictionary).duplicate(true)
|
|
|
|
|
|
func get_jobs() -> Array[Dictionary]:
|
|
var result: Array[Dictionary] = []
|
|
for job_id: StringName in _sorted_job_ids():
|
|
result.append((_jobs[job_id] as Dictionary).duplicate(true))
|
|
return result
|
|
|
|
|
|
func pause_job(job_id: StringName, reason: StringName = &"PLAYER_PAUSED") -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
if not _jobs.has(job_id):
|
|
return _failure("NOT_FOUND", "Excavation job does not exist.")
|
|
var job: Dictionary = _jobs[job_id]
|
|
if job[&"state"] == STATE_COMPLETED:
|
|
return _failure("TERMINAL_STATE", "Completed excavation cannot be paused.")
|
|
if job[&"state"] == STATE_PAUSED:
|
|
var replay := _success({&"job_id": job_id, &"progress": job[&"progress"]})
|
|
replay.is_replay = true
|
|
return replay
|
|
job[&"state"] = STATE_PAUSED
|
|
job[&"state_reason"] = reason
|
|
job_changed.emit(job.duplicate(true))
|
|
return _success({&"job_id": job_id, &"progress": job[&"progress"]})
|
|
|
|
|
|
func resume_job(job_id: StringName) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
if not _jobs.has(job_id):
|
|
return _failure("NOT_FOUND", "Excavation job does not exist.")
|
|
var job: Dictionary = _jobs[job_id]
|
|
if job[&"state"] == STATE_COMPLETED:
|
|
return _failure("TERMINAL_STATE", "Completed excavation cannot resume.")
|
|
if job[&"state"] == STATE_ACTIVE:
|
|
var replay := _success({&"job_id": job_id, &"progress": job[&"progress"]})
|
|
replay.is_replay = true
|
|
return replay
|
|
if not _can_resume(job):
|
|
return _failure("EXCAVATION_DISCONNECTED", "The excavation route no longer reaches an entrance.")
|
|
job[&"state"] = STATE_ACTIVE
|
|
job[&"state_reason"] = &""
|
|
job_changed.emit(job.duplicate(true))
|
|
return _success({&"job_id": job_id, &"progress": job[&"progress"]})
|
|
|
|
|
|
func advance_one_minute(
|
|
absolute_minute: int = -1,
|
|
work_rates: Dictionary = {}
|
|
) -> Array[CommandResult]:
|
|
if not _mutation_allowed():
|
|
return [_unavailable_result()]
|
|
if absolute_minute < 0:
|
|
current_minute += 1
|
|
else:
|
|
current_minute = maxi(current_minute, absolute_minute)
|
|
var results: Array[CommandResult] = []
|
|
for job_id: StringName in _sorted_job_ids():
|
|
var job: Dictionary = _jobs[job_id]
|
|
if job[&"state"] != STATE_ACTIVE:
|
|
continue
|
|
var rate := int(work_rates.get(job_id, NORMAL_WORK_RATE))
|
|
results.append(progress_job(job_id, rate, current_minute))
|
|
return results
|
|
|
|
|
|
func progress_job(
|
|
job_id: StringName,
|
|
work_rate_basis_points: int = NORMAL_WORK_RATE,
|
|
absolute_minute: int = -1
|
|
) -> CommandResult:
|
|
if not _mutation_allowed():
|
|
return _unavailable_result()
|
|
if not _jobs.has(job_id):
|
|
return _failure("NOT_FOUND", "Excavation job does not exist.")
|
|
var job: Dictionary = _jobs[job_id]
|
|
if job[&"state"] == STATE_COMPLETED:
|
|
return _failure("TERMINAL_STATE", "Completed excavation cannot progress.")
|
|
if job[&"state"] == STATE_PAUSED:
|
|
return _failure("PAUSED", "Paused excavation must be resumed first.")
|
|
if work_rate_basis_points <= 0 or work_rate_basis_points > MAX_WORK_RATE:
|
|
return _failure("INVALID_WORK_RATE", "Excavation work rate must be positive.")
|
|
if not _can_resume(job):
|
|
job[&"state"] = STATE_PAUSED
|
|
job[&"state_reason"] = &"ENTRANCE_DISCONNECTED"
|
|
job_changed.emit(job.duplicate(true))
|
|
return _failure("EXCAVATION_DISCONNECTED", "Excavation lost its entrance connection.")
|
|
var minute := current_minute if absolute_minute < 0 else absolute_minute
|
|
if minute < 0:
|
|
return _failure("INVALID_MINUTE", "Excavation progress minute cannot be negative.")
|
|
if minute <= int(job[&"last_progress_at"]):
|
|
var replay := _success({&"job_id": job_id, &"progress": job[&"progress"], &"state": job[&"state"]})
|
|
replay.is_replay = true
|
|
return replay
|
|
if int(job[&"started_at"]) < 0:
|
|
job[&"started_at"] = minute
|
|
job[&"last_progress_at"] = minute
|
|
job[&"work_units_done"] = mini(
|
|
MAX_WORK_UNITS,
|
|
int(job[&"work_units_done"]) + work_rate_basis_points
|
|
)
|
|
job[&"progress"] = mini(
|
|
MAX_PROGRESS,
|
|
int(job[&"work_units_done"]) / int(job[&"build_minutes"])
|
|
)
|
|
if int(job[&"progress"]) < MAX_PROGRESS:
|
|
job_changed.emit(job.duplicate(true))
|
|
return _success({&"job_id": job_id, &"progress": job[&"progress"], &"state": STATE_ACTIVE})
|
|
return _complete_job(job_id, minute)
|
|
|
|
|
|
func validate_invariants() -> Dictionary:
|
|
var errors: Array[Dictionary] = []
|
|
if _next_job_sequence < 1 or _next_job_sequence > MAX_SEQUENCE + 1:
|
|
errors.append({&"code": &"INVALID_SEQUENCE"})
|
|
if _jobs.size() > MAX_JOBS or _cell_jobs.size() > _jobs.size():
|
|
errors.append({&"code": &"JOB_LIMIT"})
|
|
var seen_positions: Dictionary = {}
|
|
var high_water := 0
|
|
for job_id: StringName in _sorted_job_ids():
|
|
var job: Variant = _jobs[job_id]
|
|
var parsed := _validate_job(job, job_id)
|
|
if not bool(parsed[&"ok"]):
|
|
errors.append({&"code": &"INVALID_JOB", &"job_id": job_id})
|
|
continue
|
|
var position: Vector2i = job[&"position"]
|
|
if seen_positions.has(position):
|
|
errors.append({&"code": &"DUPLICATE_JOB_CELL", &"job_id": job_id})
|
|
seen_positions[position] = true
|
|
var sequence := _sequence_from_job_id(job_id)
|
|
if sequence <= 0:
|
|
errors.append({&"code": &"INVALID_JOB_ID", &"job_id": job_id})
|
|
else:
|
|
high_water = maxi(high_water, sequence)
|
|
if job[&"state"] == STATE_COMPLETED:
|
|
if _cell_jobs.has(position):
|
|
errors.append({&"code": &"COMPLETED_CELL_RESERVED", &"job_id": job_id})
|
|
if _world_ready() and not _call_bool(_world, &"is_excavated", [Vector3i(position.x, position.y, 0)]):
|
|
errors.append({&"code": &"COMPLETED_CELL_NOT_EXCAVATED", &"job_id": job_id})
|
|
else:
|
|
if _cell_jobs.get(position, &"") != job_id:
|
|
errors.append({&"code": &"MISSING_CELL_RESERVATION", &"job_id": job_id})
|
|
if _world_ready() and _call_bool(_world, &"is_excavated", [Vector3i(position.x, position.y, 0)]):
|
|
errors.append({&"code": &"RESERVED_CELL_ALREADY_EXCAVATED", &"job_id": job_id})
|
|
if job[&"state"] == STATE_ACTIVE and not _can_resume(job):
|
|
errors.append({&"code": &"ACTIVE_JOB_DISCONNECTED", &"job_id": job_id})
|
|
var rule := get_rule(int(job[&"hardness"]))
|
|
if (
|
|
int(job[&"hardness"]) != int(_hardness.get(position, 0))
|
|
or job[&"cost"] != _rule_cost(rule)
|
|
or int(job[&"build_minutes"]) != int(rule.get(&"minutes", 0))
|
|
):
|
|
errors.append({&"code": &"JOB_RULE_MISMATCH", &"job_id": job_id})
|
|
if high_water >= _next_job_sequence:
|
|
errors.append({&"code": &"SEQUENCE_NOT_ABOVE_HIGH_WATER"})
|
|
for position: Variant in _hardness:
|
|
if (
|
|
not position is Vector2i
|
|
or not _hardness[position] is int
|
|
or int(_hardness[position]) < 1
|
|
or int(_hardness[position]) > MAX_HARDNESS
|
|
or position.y >= 0
|
|
or (_world_ready() and not _call_bool(_world, &"is_in_bounds", [Vector3i(position.x, position.y, 0)]))
|
|
):
|
|
errors.append({&"code": &"INVALID_HARDNESS"})
|
|
if _quarantined and _quarantine_reason.is_empty():
|
|
errors.append({&"code": &"INVALID_QUARANTINE"})
|
|
if not _quarantined and not _quarantine_reason.is_empty():
|
|
errors.append({&"code": &"INVALID_QUARANTINE"})
|
|
return {&"ok": errors.is_empty(), &"errors": errors}
|
|
|
|
|
|
func create_snapshot() -> Dictionary:
|
|
var hardness_entries: Array[Dictionary] = []
|
|
var hardness_positions: Array[Vector2i] = []
|
|
for position: Variant in _hardness:
|
|
hardness_positions.append(position)
|
|
hardness_positions.sort_custom(_position_less)
|
|
for position: Vector2i in hardness_positions:
|
|
hardness_entries.append({&"position": [position.x, position.y], &"hardness": _hardness[position]})
|
|
var jobs: Array[Dictionary] = []
|
|
for job_id: StringName in _sorted_job_ids():
|
|
jobs.append(_serialize_job(_jobs[job_id]))
|
|
return {
|
|
&"schema_version": SNAPSHOT_VERSION,
|
|
&"current_minute": current_minute,
|
|
&"next_job_sequence": _next_job_sequence,
|
|
&"hardness": hardness_entries,
|
|
&"jobs": jobs,
|
|
&"quarantined": _quarantined,
|
|
&"quarantine_reason": _quarantine_reason,
|
|
}
|
|
|
|
|
|
func restore_snapshot(snapshot: Dictionary) -> bool:
|
|
if _external_mutation_in_progress:
|
|
last_error = "Excavation state cannot be restored during an external mutation."
|
|
return false
|
|
var parsed := _parse_snapshot(snapshot)
|
|
if not bool(parsed[&"ok"]):
|
|
last_error = String(parsed[&"error"])
|
|
return false
|
|
var previous_hardness := _hardness
|
|
var previous_jobs := _jobs
|
|
var previous_cell_jobs := _cell_jobs
|
|
var previous_minute := current_minute
|
|
var previous_sequence := _next_job_sequence
|
|
var previous_quarantined := _quarantined
|
|
var previous_quarantine_reason := _quarantine_reason
|
|
_hardness = parsed[&"hardness"]
|
|
_jobs = parsed[&"jobs"]
|
|
_cell_jobs = parsed[&"cell_jobs"]
|
|
current_minute = parsed[&"current_minute"]
|
|
_next_job_sequence = parsed[&"next_job_sequence"]
|
|
_quarantined = parsed[&"quarantined"]
|
|
_quarantine_reason = parsed[&"quarantine_reason"]
|
|
var invariants := validate_invariants()
|
|
if not bool(invariants[&"ok"]):
|
|
_hardness = previous_hardness
|
|
_jobs = previous_jobs
|
|
_cell_jobs = previous_cell_jobs
|
|
current_minute = previous_minute
|
|
_next_job_sequence = previous_sequence
|
|
_quarantined = previous_quarantined
|
|
_quarantine_reason = previous_quarantine_reason
|
|
last_error = "Excavation snapshot violates live world invariants."
|
|
return false
|
|
last_error = ""
|
|
return true
|
|
|
|
|
|
func _complete_job(job_id: StringName, minute: int) -> CommandResult:
|
|
var job: Dictionary = _jobs[job_id]
|
|
var position: Vector2i = job[&"position"]
|
|
var front := Vector3i(position.x, position.y, 0)
|
|
var world_snapshot := _capture_world_state()
|
|
_external_mutation_in_progress = true
|
|
var committed := _call_bool(_world, &"set_excavated", [front, true])
|
|
_external_mutation_in_progress = false
|
|
if not committed:
|
|
_external_mutation_in_progress = true
|
|
var rolled_back := _restore_world_state(world_snapshot, front)
|
|
_external_mutation_in_progress = false
|
|
job[&"state"] = STATE_PAUSED
|
|
job[&"state_reason"] = &"WORLD_COMMIT_FAILED"
|
|
if not rolled_back:
|
|
_quarantine("World excavation commit and rollback both failed.")
|
|
job_changed.emit(job.duplicate(true))
|
|
return _failure("WORLD_COMMIT_FAILED", "The world rejected completed excavation.", {
|
|
&"rolled_back": rolled_back,
|
|
&"rollback_failed": not rolled_back,
|
|
})
|
|
job[&"progress"] = MAX_PROGRESS
|
|
job[&"state"] = STATE_COMPLETED
|
|
job[&"state_reason"] = &""
|
|
job[&"completed_at"] = minute
|
|
_cell_jobs.erase(position)
|
|
var report := {
|
|
&"job_id": job_id,
|
|
&"position": position,
|
|
&"cells": [front, Vector3i(position.x, position.y, 1)],
|
|
&"hardness": job[&"hardness"],
|
|
&"completed_at": minute,
|
|
}
|
|
job_changed.emit(job.duplicate(true))
|
|
excavation_completed.emit(report.duplicate(true))
|
|
return _success(report)
|
|
|
|
|
|
func _can_resume(job: Dictionary) -> bool:
|
|
var position: Vector2i = job[&"position"]
|
|
if not _world_ready() or _call_bool(_world, &"is_excavated", [Vector3i(position.x, position.y, 0)]):
|
|
return false
|
|
for neighbor: Vector2i in _excavated_neighbors(position):
|
|
if _has_boundary_connection(neighbor):
|
|
return true
|
|
return false
|
|
|
|
|
|
func _excavated_neighbors(position: Vector2i) -> Array[Vector2i]:
|
|
var result: Array[Vector2i] = []
|
|
for neighbor: Vector2i in _neighbors(position):
|
|
var front := Vector3i(neighbor.x, neighbor.y, 0)
|
|
if _call_bool(_world, &"is_in_bounds", [front]) and _call_bool(_world, &"is_excavated", [front]):
|
|
result.append(neighbor)
|
|
return result
|
|
|
|
|
|
func _has_boundary_connection(start: Vector2i) -> bool:
|
|
if start.y == 0:
|
|
return true
|
|
var queue: Array[Vector2i] = [start]
|
|
var visited := {start: true}
|
|
var index := 0
|
|
while index < queue.size():
|
|
var current := queue[index]
|
|
index += 1
|
|
for neighbor: Vector2i in _neighbors(current):
|
|
if visited.has(neighbor):
|
|
continue
|
|
var front := Vector3i(neighbor.x, neighbor.y, 0)
|
|
if not _call_bool(_world, &"is_in_bounds", [front]) or not _call_bool(_world, &"is_excavated", [front]):
|
|
continue
|
|
if neighbor.y == 0:
|
|
return true
|
|
visited[neighbor] = true
|
|
queue.append(neighbor)
|
|
return false
|
|
|
|
|
|
func _prospective_hard_span(position: Vector2i) -> Array[Vector2i]:
|
|
var horizontal := _hard_run(position, Vector2i.RIGHT)
|
|
var vertical := _hard_run(position, Vector2i.DOWN)
|
|
if vertical.size() > horizontal.size():
|
|
return vertical
|
|
return horizontal
|
|
|
|
|
|
func _hard_run(position: Vector2i, direction: Vector2i) -> Array[Vector2i]:
|
|
var result: Array[Vector2i] = [position]
|
|
for sign_value: int in [-1, 1]:
|
|
var cursor := position + direction * sign_value
|
|
while get_hardness(cursor) >= 2:
|
|
var front := Vector3i(cursor.x, cursor.y, 0)
|
|
if not _call_bool(_world, &"is_in_bounds", [front]) or not _call_bool(_world, &"is_excavated", [front]):
|
|
break
|
|
result.append(cursor)
|
|
cursor += direction * sign_value
|
|
result.sort_custom(_position_less)
|
|
return result
|
|
|
|
|
|
func _component_has_support(component: Array[Vector2i]) -> bool:
|
|
if not _world.has_method(&"get_support_capacity") or not _world.has_method(&"is_support_anchor"):
|
|
return false
|
|
var candidates: Dictionary = {}
|
|
for position: Vector2i in component:
|
|
candidates[position] = true
|
|
for neighbor: Vector2i in _neighbors(position):
|
|
candidates[neighbor] = true
|
|
for position: Variant in candidates:
|
|
for layer: int in [0, 1]:
|
|
var cell := Vector3i(position.x, position.y, layer)
|
|
if not _call_bool(_world, &"is_in_bounds", [cell]):
|
|
continue
|
|
if _call_bool(_world, &"is_support_anchor", [cell]) or _call_int(_world, &"get_support_capacity", [cell], 0) > 0:
|
|
return true
|
|
return false
|
|
|
|
|
|
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, &"error": "Unsupported excavation snapshot version."}
|
|
var version := int(raw_version)
|
|
var top_level_keys: Array[StringName] = [
|
|
&"schema_version", &"current_minute", &"next_job_sequence", &"hardness", &"jobs",
|
|
]
|
|
if version == SNAPSHOT_VERSION:
|
|
top_level_keys.append_array([&"quarantined", &"quarantine_reason"])
|
|
if not _has_exact_keys(snapshot, top_level_keys):
|
|
return {&"ok": false, &"error": "Excavation snapshot schema is not exact."}
|
|
var minute: Variant = snapshot.get(&"current_minute", null)
|
|
var sequence: Variant = snapshot.get(&"next_job_sequence", null)
|
|
var hardness_values: Variant = snapshot.get(&"hardness", null)
|
|
var job_values: Variant = snapshot.get(&"jobs", null)
|
|
if (
|
|
not minute is int
|
|
or int(minute) < 0
|
|
or not sequence is int
|
|
or int(sequence) < 1
|
|
or int(sequence) > MAX_SEQUENCE + 1
|
|
):
|
|
return {&"ok": false, &"error": "Excavation snapshot counters are invalid."}
|
|
if not hardness_values is Array or not job_values is Array or job_values.size() > MAX_JOBS:
|
|
return {&"ok": false, &"error": "Excavation snapshot collections are invalid."}
|
|
var quarantined := false
|
|
var quarantine_reason := ""
|
|
if version == SNAPSHOT_VERSION:
|
|
var raw_quarantined: Variant = snapshot[&"quarantined"]
|
|
var raw_reason: Variant = snapshot[&"quarantine_reason"]
|
|
if not raw_quarantined is bool or not raw_reason is String:
|
|
return {&"ok": false, &"error": "Excavation quarantine state is invalid."}
|
|
quarantined = raw_quarantined
|
|
quarantine_reason = raw_reason
|
|
if quarantined == quarantine_reason.is_empty():
|
|
return {&"ok": false, &"error": "Excavation quarantine state is inconsistent."}
|
|
var hardness: Dictionary = {}
|
|
for value: Variant in hardness_values:
|
|
if not value is Dictionary or not _has_exact_keys(value, [&"position", &"hardness"]):
|
|
return {&"ok": false, &"error": "Hardness entry is invalid."}
|
|
var position: Variant = _parse_position(value.get(&"position", null))
|
|
var amount: Variant = value.get(&"hardness", null)
|
|
if position == null or not amount is int or int(amount) < 1 or int(amount) > MAX_HARDNESS or hardness.has(position):
|
|
return {&"ok": false, &"error": "Hardness entry is invalid."}
|
|
hardness[position] = int(amount)
|
|
var jobs: Dictionary = {}
|
|
var cell_jobs: Dictionary = {}
|
|
for value: Variant in job_values:
|
|
if not value is Dictionary or not _validate_serialized_job(value, version):
|
|
return {&"ok": false, &"error": "Excavation job entry is invalid."}
|
|
var job := _deserialize_job(value, version, int(minute))
|
|
var job_id := StringName(job.get(&"job_id", &""))
|
|
if job_id.is_empty() or jobs.has(job_id) or not bool(_validate_job(job, job_id)[&"ok"]):
|
|
return {&"ok": false, &"error": "Excavation job entry is invalid."}
|
|
var position: Vector2i = job[&"position"]
|
|
if cell_jobs.has(position):
|
|
return {&"ok": false, &"error": "Excavation jobs overlap."}
|
|
jobs[job_id] = job
|
|
if job[&"state"] != STATE_COMPLETED:
|
|
cell_jobs[position] = job_id
|
|
return {
|
|
&"ok": true,
|
|
&"current_minute": int(minute),
|
|
&"next_job_sequence": int(sequence),
|
|
&"hardness": hardness,
|
|
&"jobs": jobs,
|
|
&"cell_jobs": cell_jobs,
|
|
&"quarantined": quarantined,
|
|
&"quarantine_reason": quarantine_reason,
|
|
}
|
|
|
|
|
|
func _validate_job(value: Variant, expected_id: StringName) -> Dictionary:
|
|
if not value is Dictionary:
|
|
return {&"ok": false}
|
|
var required: Array[StringName] = [
|
|
&"job_id", &"position", &"hardness", &"cost", &"build_minutes",
|
|
&"work_units_done", &"progress", &"state", &"state_reason", &"created_at",
|
|
&"started_at", &"completed_at", &"last_progress_at",
|
|
]
|
|
if not _has_exact_keys(value, required):
|
|
return {&"ok": false}
|
|
if not value[&"job_id"] is StringName or value[&"job_id"] != expected_id or not value[&"position"] is Vector2i:
|
|
return {&"ok": false}
|
|
for integer_key: StringName in [
|
|
&"hardness", &"build_minutes", &"work_units_done", &"progress", &"created_at",
|
|
&"started_at", &"completed_at", &"last_progress_at",
|
|
]:
|
|
if not value[integer_key] is int:
|
|
return {&"ok": false}
|
|
if int(value[&"hardness"]) < 0 or int(value[&"hardness"]) > MAX_HARDNESS:
|
|
return {&"ok": false}
|
|
if not _valid_cost(value[&"cost"]) or int(value[&"build_minutes"]) <= 0:
|
|
return {&"ok": false}
|
|
if (
|
|
int(value[&"work_units_done"]) < 0
|
|
or int(value[&"work_units_done"]) > MAX_WORK_UNITS
|
|
or int(value[&"progress"]) < 0
|
|
or int(value[&"progress"]) > MAX_PROGRESS
|
|
):
|
|
return {&"ok": false}
|
|
var expected_progress := mini(MAX_PROGRESS, int(value[&"work_units_done"]) / int(value[&"build_minutes"]))
|
|
if int(value[&"progress"]) != expected_progress:
|
|
return {&"ok": false}
|
|
if not value[&"state"] is StringName or not value[&"state_reason"] is StringName:
|
|
return {&"ok": false}
|
|
var state: StringName = value[&"state"]
|
|
if state not in [STATE_ACTIVE, STATE_PAUSED, STATE_COMPLETED]:
|
|
return {&"ok": false}
|
|
if (
|
|
int(value[&"created_at"]) < 0
|
|
or int(value[&"started_at"]) < -1
|
|
or int(value[&"completed_at"]) < -1
|
|
or int(value[&"last_progress_at"]) < -1
|
|
):
|
|
return {&"ok": false}
|
|
if int(value[&"work_units_done"]) == 0 and (int(value[&"started_at"]) != -1 or int(value[&"last_progress_at"]) != -1):
|
|
return {&"ok": false}
|
|
if int(value[&"work_units_done"]) > 0 and (
|
|
int(value[&"started_at"]) < 0
|
|
or int(value[&"last_progress_at"]) < int(value[&"started_at"])
|
|
):
|
|
return {&"ok": false}
|
|
if state == STATE_COMPLETED and (int(value[&"progress"]) != MAX_PROGRESS or int(value[&"completed_at"]) < 0):
|
|
return {&"ok": false}
|
|
if state != STATE_COMPLETED and int(value[&"completed_at"]) != -1:
|
|
return {&"ok": false}
|
|
return {&"ok": true}
|
|
|
|
|
|
func _validate_serialized_job(value: Dictionary, version: int) -> bool:
|
|
var required: Array[StringName] = [
|
|
&"job_id", &"position", &"hardness", &"cost", &"build_minutes",
|
|
&"work_units_done", &"progress", &"state", &"state_reason", &"created_at",
|
|
&"started_at", &"completed_at",
|
|
]
|
|
if version == SNAPSHOT_VERSION:
|
|
required.append(&"last_progress_at")
|
|
if not _has_exact_keys(value, required):
|
|
return false
|
|
if not value[&"job_id"] is String or String(value[&"job_id"]).is_empty():
|
|
return false
|
|
if _parse_position(value[&"position"]) == null:
|
|
return false
|
|
if not value[&"state"] is String or not value[&"state_reason"] is String:
|
|
return false
|
|
for integer_key: StringName in [
|
|
&"hardness", &"build_minutes", &"work_units_done", &"progress", &"created_at",
|
|
&"started_at", &"completed_at",
|
|
]:
|
|
if not value[integer_key] is int:
|
|
return false
|
|
if version == SNAPSHOT_VERSION and not value[&"last_progress_at"] is int:
|
|
return false
|
|
return _valid_cost(value[&"cost"])
|
|
|
|
|
|
func _serialize_job(job: Dictionary) -> Dictionary:
|
|
var result := job.duplicate(true)
|
|
var position: Vector2i = job[&"position"]
|
|
result[&"job_id"] = String(job[&"job_id"])
|
|
result[&"position"] = [position.x, position.y]
|
|
result[&"state"] = String(job[&"state"])
|
|
result[&"state_reason"] = String(job[&"state_reason"])
|
|
return result
|
|
|
|
|
|
func _deserialize_job(value: Dictionary, version: int, snapshot_minute: int) -> Dictionary:
|
|
var result := value.duplicate(true)
|
|
result[&"job_id"] = StringName(value[&"job_id"])
|
|
result[&"position"] = _parse_position(value[&"position"])
|
|
result[&"state"] = StringName(value[&"state"])
|
|
result[&"state_reason"] = StringName(value[&"state_reason"])
|
|
if version == LEGACY_SNAPSHOT_VERSION:
|
|
result[&"last_progress_at"] = -1
|
|
if int(result[&"work_units_done"]) > 0:
|
|
result[&"last_progress_at"] = maxi(int(result[&"started_at"]), snapshot_minute)
|
|
return result
|
|
|
|
|
|
func _world_ready() -> bool:
|
|
return (
|
|
_world != null
|
|
and _world.has_method(&"is_in_bounds")
|
|
and _world.has_method(&"is_excavated")
|
|
and _world.has_method(&"set_excavated")
|
|
)
|
|
|
|
|
|
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)
|
|
if returned is int:
|
|
return returned
|
|
return 0
|
|
|
|
|
|
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 _resource_state.has_method(&"apply_resource_deltas"):
|
|
return false
|
|
var deltas: Dictionary = {}
|
|
for resource_name: Variant in cost:
|
|
if _resource_amount(StringName(resource_name)) < int(cost[resource_name]):
|
|
return false
|
|
deltas[StringName(resource_name)] = -int(cost[resource_name])
|
|
return _operation_succeeded(_resource_state.call(&"apply_resource_deltas", deltas))
|
|
|
|
|
|
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, cost: Dictionary) -> bool:
|
|
if not snapshot is Dictionary or _resource_state == null:
|
|
return false
|
|
var attempted := false
|
|
if (
|
|
bool(snapshot.get(&"has_native", false))
|
|
and _resource_state.has_method(&"restore_snapshot")
|
|
):
|
|
attempted = true
|
|
_operation_succeeded(_resource_state.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null)))
|
|
elif _resource_state.has_method(&"apply_resource_deltas"):
|
|
attempted = true
|
|
var expected: Variant = snapshot.get(&"amounts", null)
|
|
if not expected is Dictionary:
|
|
return false
|
|
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))
|
|
if not attempted:
|
|
return false
|
|
return _resource_amounts_match(snapshot.get(&"amounts", {}))
|
|
|
|
|
|
func _capture_world_state() -> Dictionary:
|
|
var captured := {
|
|
&"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, front: Vector3i) -> bool:
|
|
if _world == null:
|
|
return false
|
|
if (
|
|
snapshot is Dictionary
|
|
and bool(snapshot.get(&"has_native", false))
|
|
and _world.has_method(&"restore_snapshot")
|
|
):
|
|
_operation_succeeded(_world.call(&"restore_snapshot", snapshot.get(&"native_snapshot", null)))
|
|
elif _world.has_method(&"set_excavated"):
|
|
_call_bool(_world, &"set_excavated", [front, false])
|
|
else:
|
|
return false
|
|
return not _call_bool(_world, &"is_excavated", [front])
|
|
|
|
|
|
func _resource_amounts_match(expected: Variant) -> bool:
|
|
if not expected is Dictionary:
|
|
return false
|
|
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 _rule_cost(rule: Dictionary) -> Dictionary:
|
|
return {
|
|
&"gold": int(rule.get(&"gold", 0)),
|
|
&"materials": int(rule.get(&"materials", 0)),
|
|
}
|
|
|
|
|
|
func _sorted_job_ids() -> Array[StringName]:
|
|
var result: Array[StringName] = []
|
|
for job_id: Variant in _jobs:
|
|
result.append(StringName(job_id))
|
|
result.sort_custom(func(left: StringName, right: StringName) -> bool:
|
|
return String(left) < String(right)
|
|
)
|
|
return result
|
|
|
|
|
|
func _sequence_from_job_id(job_id: StringName) -> int:
|
|
var text := String(job_id)
|
|
if not text.begins_with("excavation_") or text.length() != 19:
|
|
return -1
|
|
var suffix := text.substr(11)
|
|
if not suffix.is_valid_int():
|
|
return -1
|
|
return int(suffix)
|
|
|
|
|
|
static func _spatial_position(value: Variant) -> Variant:
|
|
if value is Vector2i:
|
|
return value
|
|
if value is Vector3i:
|
|
return Vector2i(value.x, value.y)
|
|
return null
|
|
|
|
|
|
static func _parse_position(value: Variant) -> Variant:
|
|
if not value is Array or value.size() != 2 or not value[0] is int or not value[1] is int:
|
|
return null
|
|
return Vector2i(int(value[0]), int(value[1]))
|
|
|
|
|
|
static func _valid_cost(value: Variant) -> bool:
|
|
if not value is Dictionary or not _has_exact_keys(value, [&"gold", &"materials"]):
|
|
return false
|
|
return (
|
|
value[&"gold"] is int
|
|
and value[&"materials"] is int
|
|
and int(value[&"gold"]) >= 0
|
|
and int(value[&"materials"]) >= 0
|
|
)
|
|
|
|
|
|
static func _has_exact_keys(value: Dictionary, expected: Array) -> bool:
|
|
if value.size() != expected.size():
|
|
return false
|
|
for key: Variant in expected:
|
|
if not value.has(key):
|
|
return false
|
|
return true
|
|
|
|
|
|
static func _call_bool(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 _call_int(
|
|
target: Variant,
|
|
method_name: StringName,
|
|
arguments: Array,
|
|
fallback: int
|
|
) -> int:
|
|
if target == null or not target.has_method(method_name):
|
|
return fallback
|
|
var returned: Variant = target.callv(method_name, arguments)
|
|
return returned if returned is int else fallback
|
|
|
|
|
|
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 _neighbors(position: Vector2i) -> Array[Vector2i]:
|
|
return [
|
|
position + Vector2i.LEFT,
|
|
position + Vector2i.RIGHT,
|
|
position + Vector2i.UP,
|
|
position + Vector2i.DOWN,
|
|
]
|
|
|
|
|
|
static func _position_less(left: Vector2i, right: Vector2i) -> bool:
|
|
if left.y != right.y:
|
|
return left.y < right.y
|
|
return left.x < right.x
|
|
|
|
|
|
static func _add_error(errors: Array[StringName], code: StringName) -> void:
|
|
if not errors.has(code):
|
|
errors.append(code)
|
|
|
|
|
|
func _success(details: Dictionary = {}) -> CommandResult:
|
|
return CommandResultType.succeeded(0, details)
|
|
|
|
|
|
func is_quarantined() -> bool:
|
|
return _quarantined
|
|
|
|
|
|
func get_quarantine_reason() -> String:
|
|
return _quarantine_reason
|
|
|
|
|
|
func _mutation_allowed() -> bool:
|
|
if _external_mutation_in_progress:
|
|
last_error = "A reentrant excavation mutation was rejected."
|
|
return false
|
|
if _quarantined:
|
|
last_error = "Excavation service is quarantined: %s" % _quarantine_reason
|
|
return false
|
|
return true
|
|
|
|
|
|
func _unavailable_result() -> CommandResult:
|
|
var code: StringName = &"SERVICE_QUARANTINED" if _quarantined else &"REENTRANT_MUTATION"
|
|
return _failure(code, last_error, {&"quarantine_reason": _quarantine_reason})
|
|
|
|
|
|
func _quarantine(reason: String) -> void:
|
|
_quarantined = true
|
|
_quarantine_reason = reason
|
|
last_error = reason
|
|
|
|
|
|
func _failure(code: StringName, message: String, details: Dictionary = {}) -> CommandResult:
|
|
last_error = message
|
|
var merged := details.duplicate(true)
|
|
merged[&"code"] = code
|
|
return CommandResultType.failed(CommandResultType.Code.VALIDATION_FAILED, 0, message, merged)
|
|
|
|
|
|
func _fail(message: String) -> bool:
|
|
last_error = message
|
|
return false
|