Preserve approved pixel tower prototype as development baseline
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://uj1a8hstgr4q
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://q0jnihjkulpv
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://b0ti4l6jf1cbh
|
||||
@@ -0,0 +1,890 @@
|
||||
class_name SpatialSubslotService
|
||||
extends RefCounted
|
||||
|
||||
## Independent spatial occupancy for systems that may share a WorldService cell
|
||||
## with a primary facility. Every layer cell owns exactly one slot per category.
|
||||
|
||||
signal entity_placed(entity_id: StringName, category: StringName, cells: Array[Vector3i])
|
||||
signal entity_removed(entity_id: StringName, category: StringName, cells: Array[Vector3i])
|
||||
signal entity_cells_replaced(
|
||||
entity_id: StringName,
|
||||
category: StringName,
|
||||
before_cells: Array[Vector3i],
|
||||
after_cells: Array[Vector3i]
|
||||
)
|
||||
|
||||
const CommandResultType = preload("res://scripts/core/command_result.gd")
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION := 1
|
||||
|
||||
const CATEGORY_ROUTE: StringName = &"ROUTE"
|
||||
const CATEGORY_DECORATION: StringName = &"DECORATION"
|
||||
const CATEGORY_UTILITY: StringName = &"UTILITY"
|
||||
const CATEGORIES: Array[StringName] = [
|
||||
CATEGORY_ROUTE,
|
||||
CATEGORY_DECORATION,
|
||||
CATEGORY_UTILITY,
|
||||
]
|
||||
|
||||
const LAYER_FRONT := 0
|
||||
const LAYER_BACK := 1
|
||||
|
||||
const MAX_ENTITY_ID_LENGTH := 128
|
||||
const MAX_ENTITIES := 4_096
|
||||
const MAX_CELLS_PER_ENTITY := 256
|
||||
const MAX_TOTAL_SLOTS := 65_536
|
||||
const MAX_DIRTY_CELLS := 65_536
|
||||
const MAX_COORDINATE_ABS := 100_000
|
||||
const MAX_REVISION := 9_007_199_254_740_991
|
||||
const MAX_SNAPSHOT_DEPTH := 8
|
||||
const MAX_CONTAINER_ITEMS := 65_536
|
||||
|
||||
var revision := 0
|
||||
var last_error := ""
|
||||
var last_callback_report: Dictionary = {}
|
||||
|
||||
var _category_revisions: Dictionary = {
|
||||
CATEGORY_ROUTE: 0,
|
||||
CATEGORY_DECORATION: 0,
|
||||
CATEGORY_UTILITY: 0,
|
||||
}
|
||||
var _slots: Dictionary = {}
|
||||
var _entity_cells: Dictionary = {}
|
||||
var _entity_categories: Dictionary = {}
|
||||
var _dirty_cells: Dictionary = {}
|
||||
var _compatibility_validator := Callable()
|
||||
var _mutation_active := false
|
||||
|
||||
|
||||
## Optional read-only boundary for World-specific rules such as pass-through
|
||||
## facilities. The callback receives one defensive payload and must return a
|
||||
## strict `{ok: bool, reason?: StringName, details?: Dictionary}` response.
|
||||
func configure_compatibility_validator(callback: Callable = Callable()) -> bool:
|
||||
if _mutation_active:
|
||||
last_error = "Spatial subslot mutation is already active."
|
||||
return false
|
||||
_compatibility_validator = callback
|
||||
last_callback_report = {}
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func place(entity_id: Variant, category: Variant, cells: Variant) -> CommandResult:
|
||||
if _mutation_active:
|
||||
return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Spatial subslot mutation is already active.")
|
||||
_mutation_active = true
|
||||
var result := _place_internal(entity_id, category, cells)
|
||||
_mutation_active = false
|
||||
return result
|
||||
|
||||
|
||||
func remove(entity_id: Variant) -> CommandResult:
|
||||
if _mutation_active:
|
||||
return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Spatial subslot mutation is already active.")
|
||||
_mutation_active = true
|
||||
var result := _remove_internal(entity_id)
|
||||
_mutation_active = false
|
||||
return result
|
||||
|
||||
|
||||
func replace_entity_cells(entity_id: Variant, cells: Variant) -> CommandResult:
|
||||
if _mutation_active:
|
||||
return _failure(CommandResultType.Code.TRANSACTION_CLOSED, "Spatial subslot mutation is already active.")
|
||||
_mutation_active = true
|
||||
var result := _replace_internal(entity_id, cells)
|
||||
_mutation_active = false
|
||||
return result
|
||||
|
||||
|
||||
func has_entity(entity_id: StringName) -> bool:
|
||||
return _entity_cells.has(entity_id)
|
||||
|
||||
|
||||
func get_entity_category(entity_id: StringName) -> StringName:
|
||||
return StringName(_entity_categories.get(entity_id, &""))
|
||||
|
||||
|
||||
func get_entity_cells(entity_id: StringName) -> Array[Vector3i]:
|
||||
var source: Variant = _entity_cells.get(entity_id, [])
|
||||
return _copy_cells(source) if source is Array else []
|
||||
|
||||
|
||||
func get_entity_ids(category: Variant = null) -> Array[StringName]:
|
||||
var filter_category: StringName = &""
|
||||
if category != null:
|
||||
if not _category_is_valid(category):
|
||||
return []
|
||||
filter_category = StringName(category)
|
||||
var result: Array[StringName] = []
|
||||
for entity_id: StringName in _sorted_entity_ids(_entity_cells):
|
||||
if filter_category.is_empty() or _entity_categories[entity_id] == filter_category:
|
||||
result.append(entity_id)
|
||||
return result
|
||||
|
||||
|
||||
func get_entities(category: Variant = null) -> Array[Dictionary]:
|
||||
var result: Array[Dictionary] = []
|
||||
for entity_id: StringName in get_entity_ids(category):
|
||||
result.append({
|
||||
&"entity_id": entity_id,
|
||||
&"category": _entity_categories[entity_id],
|
||||
&"cells": get_entity_cells(entity_id),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
func get_slot_entity(cell: Variant, category: Variant) -> StringName:
|
||||
if not _cell_is_valid(cell) or not _category_is_valid(category):
|
||||
return &""
|
||||
var cell_slots: Variant = _slots.get(cell, {})
|
||||
if not cell_slots is Dictionary:
|
||||
return &""
|
||||
return StringName(cell_slots.get(StringName(category), &""))
|
||||
|
||||
|
||||
func get_cell_slots(cell: Variant) -> Dictionary:
|
||||
if not _cell_is_valid(cell):
|
||||
return {}
|
||||
var source: Variant = _slots.get(cell, {})
|
||||
if not source is Dictionary:
|
||||
return {}
|
||||
var result: Dictionary = {}
|
||||
for category: StringName in CATEGORIES:
|
||||
if source.has(category):
|
||||
result[category] = StringName(source[category])
|
||||
return result
|
||||
|
||||
|
||||
func get_cells_for_category(category: Variant) -> Array[Vector3i]:
|
||||
if not _category_is_valid(category):
|
||||
return []
|
||||
var cell_set: Dictionary = {}
|
||||
var canonical_category := StringName(category)
|
||||
for entity_id: StringName in _sorted_entity_ids(_entity_cells):
|
||||
if _entity_categories[entity_id] != canonical_category:
|
||||
continue
|
||||
for cell: Vector3i in _entity_cells[entity_id]:
|
||||
cell_set[cell] = true
|
||||
return _sorted_cells(cell_set.keys())
|
||||
|
||||
|
||||
## Canonical passable-cell input for a future PathService graph rebuild.
|
||||
func get_route_cells() -> Array[Vector3i]:
|
||||
return get_cells_for_category(CATEGORY_ROUTE)
|
||||
|
||||
|
||||
func get_revision_for_category(category: Variant) -> int:
|
||||
if not _category_is_valid(category):
|
||||
return -1
|
||||
return int(_category_revisions[StringName(category)])
|
||||
|
||||
|
||||
func get_dirty_revision() -> int:
|
||||
return revision
|
||||
|
||||
|
||||
func get_dirty_cells() -> Array[Vector3i]:
|
||||
return _sorted_cells(_dirty_cells.keys())
|
||||
|
||||
|
||||
func consume_dirty_cells() -> Array[Vector3i]:
|
||||
if _mutation_active:
|
||||
last_error = "Dirty cells cannot be consumed during a spatial mutation."
|
||||
return []
|
||||
var result := get_dirty_cells()
|
||||
_dirty_cells.clear()
|
||||
last_error = ""
|
||||
return result
|
||||
|
||||
|
||||
func get_last_callback_report() -> Dictionary:
|
||||
return last_callback_report.duplicate(true)
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var entities: Array[Dictionary] = []
|
||||
for entity_id: StringName in _sorted_entity_ids(_entity_cells):
|
||||
entities.append({
|
||||
&"entity_id": String(entity_id),
|
||||
&"category": String(_entity_categories[entity_id]),
|
||||
&"cells": get_entity_cells(entity_id),
|
||||
})
|
||||
return {
|
||||
&"schema_version": SNAPSHOT_SCHEMA_VERSION,
|
||||
&"revision": revision,
|
||||
&"category_revisions": {
|
||||
&"ROUTE": int(_category_revisions[CATEGORY_ROUTE]),
|
||||
&"DECORATION": int(_category_revisions[CATEGORY_DECORATION]),
|
||||
&"UTILITY": int(_category_revisions[CATEGORY_UTILITY]),
|
||||
},
|
||||
&"dirty_cells": get_dirty_cells(),
|
||||
&"entities": entities,
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if _mutation_active:
|
||||
last_error = "Spatial subslot mutation is already active."
|
||||
return false
|
||||
_mutation_active = true
|
||||
var parsed := _parse_snapshot(snapshot)
|
||||
if not bool(parsed.get(&"ok", false)):
|
||||
_mutation_active = false
|
||||
last_error = String(parsed.get(&"message", "Spatial subslot snapshot is invalid."))
|
||||
return false
|
||||
_slots = parsed[&"slots"]
|
||||
_entity_cells = parsed[&"entity_cells"]
|
||||
_entity_categories = parsed[&"entity_categories"]
|
||||
_dirty_cells = parsed[&"dirty_cells"]
|
||||
_category_revisions = parsed[&"category_revisions"]
|
||||
revision = int(parsed[&"revision"])
|
||||
_mutation_active = false
|
||||
last_callback_report = {}
|
||||
last_error = ""
|
||||
return true
|
||||
|
||||
|
||||
func validate_invariants() -> Dictionary:
|
||||
var errors: Array[String] = []
|
||||
if revision < 0 or revision > MAX_REVISION:
|
||||
errors.append("Revision is outside the supported range.")
|
||||
if not _dictionary_has_exact_keys(_category_revisions, CATEGORIES):
|
||||
errors.append("Category revision keys are not canonical.")
|
||||
else:
|
||||
var category_revisions_valid := true
|
||||
var latest_category_revision := 0
|
||||
for category: StringName in CATEGORIES:
|
||||
var raw_category_revision: Variant = _category_revisions[category]
|
||||
if typeof(raw_category_revision) != TYPE_INT \
|
||||
or int(raw_category_revision) < 0 \
|
||||
or int(raw_category_revision) > revision:
|
||||
errors.append("Category revision is invalid: %s" % category)
|
||||
category_revisions_valid = false
|
||||
else:
|
||||
latest_category_revision = max(latest_category_revision, int(raw_category_revision))
|
||||
if category_revisions_valid and revision > 0 and latest_category_revision != revision:
|
||||
errors.append("No category revision matches the global revision.")
|
||||
if _entity_cells.size() > MAX_ENTITIES or _slots.size() > MAX_TOTAL_SLOTS:
|
||||
errors.append("Spatial subslot capacity is exceeded.")
|
||||
if _entity_cells.size() != _entity_categories.size():
|
||||
errors.append("Entity cell/category maps differ in size.")
|
||||
var expected_slots: Dictionary = {}
|
||||
var total_slots := 0
|
||||
var valid_entity_ids: Array[StringName] = []
|
||||
for raw_entity_id: Variant in _entity_cells:
|
||||
if typeof(raw_entity_id) not in [TYPE_STRING, TYPE_STRING_NAME] \
|
||||
or not _stable_id_is_valid(raw_entity_id):
|
||||
errors.append("Entity cell map contains an invalid ID.")
|
||||
continue
|
||||
valid_entity_ids.append(StringName(raw_entity_id))
|
||||
valid_entity_ids.sort_custom(_name_less)
|
||||
for entity_id: StringName in valid_entity_ids:
|
||||
if not _stable_id_is_valid(entity_id) or not _entity_categories.has(entity_id):
|
||||
errors.append("Entity ID/category crosslink is invalid: %s" % entity_id)
|
||||
continue
|
||||
var category: Variant = _entity_categories[entity_id]
|
||||
var raw_cells: Variant = _entity_cells[entity_id]
|
||||
if not _category_is_valid(category) or not raw_cells is Array:
|
||||
errors.append("Entity category/cells are invalid: %s" % entity_id)
|
||||
continue
|
||||
var validation := _validate_cells(raw_cells)
|
||||
if not bool(validation[&"ok"]) or validation[&"cells"] != raw_cells:
|
||||
errors.append("Entity cells are invalid or unordered: %s" % entity_id)
|
||||
continue
|
||||
for cell: Vector3i in validation[&"cells"]:
|
||||
var cell_slots: Dictionary = expected_slots.get(cell, {})
|
||||
if cell_slots.has(category):
|
||||
errors.append("Two entities claim one category slot at %s." % cell)
|
||||
else:
|
||||
cell_slots[category] = entity_id
|
||||
expected_slots[cell] = cell_slots
|
||||
total_slots += 1
|
||||
if total_slots > MAX_TOTAL_SLOTS:
|
||||
errors.append("Total occupied subslots exceed the capacity.")
|
||||
if expected_slots != _slots:
|
||||
errors.append("Cell/entity/category crosslinks do not match.")
|
||||
if _dirty_cells.size() > MAX_DIRTY_CELLS:
|
||||
errors.append("Dirty-cell capacity is exceeded.")
|
||||
for raw_cell: Variant in _dirty_cells:
|
||||
if not _cell_is_valid(raw_cell) \
|
||||
or typeof(_dirty_cells[raw_cell]) != TYPE_BOOL \
|
||||
or not bool(_dirty_cells[raw_cell]):
|
||||
errors.append("Dirty cell is invalid.")
|
||||
return {&"ok": errors.is_empty(), &"errors": errors}
|
||||
|
||||
|
||||
func _place_internal(entity_id_value: Variant, category_value: Variant, cells_value: Variant) -> CommandResult:
|
||||
var identity := _parse_identity(entity_id_value, category_value)
|
||||
if not bool(identity[&"ok"]):
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(identity[&"message"]))
|
||||
var entity_id: StringName = identity[&"entity_id"]
|
||||
var category: StringName = identity[&"category"]
|
||||
if _entity_cells.has(entity_id):
|
||||
return _failure(CommandResultType.Code.DUPLICATE_COMMAND_ID, "Spatial entity ID already exists.")
|
||||
if _entity_cells.size() >= MAX_ENTITIES:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial entity capacity is exhausted.")
|
||||
var cell_validation := _validate_cells(cells_value)
|
||||
if not bool(cell_validation[&"ok"]):
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(cell_validation[&"message"]))
|
||||
var cells: Array[Vector3i] = cell_validation[&"cells"]
|
||||
var current_total := _total_occupied_slots()
|
||||
if current_total > MAX_TOTAL_SLOTS - cells.size():
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial subslot capacity is exhausted.")
|
||||
for cell: Vector3i in cells:
|
||||
var cell_slots: Dictionary = _slots.get(cell, {})
|
||||
if cell_slots.has(category):
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"Spatial category slot is already occupied.",
|
||||
{&"cell": cell, &"category": category, &"occupant_id": cell_slots[category]}
|
||||
)
|
||||
if not _dirty_capacity_allows(cells):
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Dirty-cell capacity is exhausted.")
|
||||
if revision >= MAX_REVISION:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial revision limit is exhausted.")
|
||||
var payload := _mutation_payload(&"PLACE", entity_id, category, [], cells)
|
||||
var boundary := _validate_external_boundary(payload)
|
||||
if not bool(boundary[&"ok"]):
|
||||
return _failure(
|
||||
CommandResultType.Code.TRANSACTION_APPLY_FAILED,
|
||||
String(boundary[&"message"]),
|
||||
boundary.get(&"details", {})
|
||||
)
|
||||
for cell: Vector3i in cells:
|
||||
var cell_slots: Dictionary = _slots.get(cell, {})
|
||||
cell_slots[category] = entity_id
|
||||
_slots[cell] = cell_slots
|
||||
_entity_cells[entity_id] = cells.duplicate()
|
||||
_entity_categories[entity_id] = category
|
||||
_mark_changed(category, cells)
|
||||
entity_placed.emit(entity_id, category, cells.duplicate())
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"entity_id": entity_id,
|
||||
&"category": category,
|
||||
&"cells": cells.duplicate(),
|
||||
&"revision": revision,
|
||||
&"changed": true,
|
||||
})
|
||||
|
||||
|
||||
func _remove_internal(entity_id_value: Variant) -> CommandResult:
|
||||
if not _stable_id_is_valid(entity_id_value):
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Spatial entity ID is invalid.")
|
||||
var entity_id := StringName(entity_id_value)
|
||||
if not _entity_cells.has(entity_id):
|
||||
return _failure(CommandResultType.Code.NOT_FOUND, "Spatial entity was not found.")
|
||||
var category: StringName = _entity_categories[entity_id]
|
||||
var cells := get_entity_cells(entity_id)
|
||||
if not _dirty_capacity_allows(cells):
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Dirty-cell capacity is exhausted.")
|
||||
if revision >= MAX_REVISION:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial revision limit is exhausted.")
|
||||
var boundary := _validate_external_boundary(
|
||||
_mutation_payload(&"REMOVE", entity_id, category, cells, [])
|
||||
)
|
||||
if not bool(boundary[&"ok"]):
|
||||
return _failure(
|
||||
CommandResultType.Code.TRANSACTION_APPLY_FAILED,
|
||||
String(boundary[&"message"]),
|
||||
boundary.get(&"details", {})
|
||||
)
|
||||
for cell: Vector3i in cells:
|
||||
var cell_slots: Dictionary = _slots.get(cell, {})
|
||||
if cell_slots.get(category, &"") == entity_id:
|
||||
cell_slots.erase(category)
|
||||
if cell_slots.is_empty():
|
||||
_slots.erase(cell)
|
||||
else:
|
||||
_slots[cell] = cell_slots
|
||||
_entity_cells.erase(entity_id)
|
||||
_entity_categories.erase(entity_id)
|
||||
_mark_changed(category, cells)
|
||||
entity_removed.emit(entity_id, category, cells.duplicate())
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"entity_id": entity_id,
|
||||
&"category": category,
|
||||
&"cells": cells,
|
||||
&"revision": revision,
|
||||
&"changed": true,
|
||||
})
|
||||
|
||||
|
||||
func _replace_internal(entity_id_value: Variant, cells_value: Variant) -> CommandResult:
|
||||
if not _stable_id_is_valid(entity_id_value):
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, "Spatial entity ID is invalid.")
|
||||
var entity_id := StringName(entity_id_value)
|
||||
if not _entity_cells.has(entity_id):
|
||||
return _failure(CommandResultType.Code.NOT_FOUND, "Spatial entity was not found.")
|
||||
var validation := _validate_cells(cells_value)
|
||||
if not bool(validation[&"ok"]):
|
||||
return _failure(CommandResultType.Code.INVALID_PAYLOAD, String(validation[&"message"]))
|
||||
var after_cells: Array[Vector3i] = validation[&"cells"]
|
||||
var before_cells := get_entity_cells(entity_id)
|
||||
var category: StringName = _entity_categories[entity_id]
|
||||
if before_cells == after_cells:
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"entity_id": entity_id,
|
||||
&"category": category,
|
||||
&"cells": after_cells.duplicate(),
|
||||
&"revision": revision,
|
||||
&"changed": false,
|
||||
})
|
||||
var projected_total := _total_occupied_slots() - before_cells.size() + after_cells.size()
|
||||
if projected_total > MAX_TOTAL_SLOTS:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial subslot capacity is exhausted.")
|
||||
for cell: Vector3i in after_cells:
|
||||
var occupant := get_slot_entity(cell, category)
|
||||
if not occupant.is_empty() and occupant != entity_id:
|
||||
return _failure(
|
||||
CommandResultType.Code.VALIDATION_FAILED,
|
||||
"Spatial category slot is already occupied.",
|
||||
{&"cell": cell, &"category": category, &"occupant_id": occupant}
|
||||
)
|
||||
var dirty_union: Dictionary = {}
|
||||
for cell: Vector3i in before_cells:
|
||||
dirty_union[cell] = true
|
||||
for cell: Vector3i in after_cells:
|
||||
dirty_union[cell] = true
|
||||
var dirty_cells := _sorted_cells(dirty_union.keys())
|
||||
if not _dirty_capacity_allows(dirty_cells):
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Dirty-cell capacity is exhausted.")
|
||||
if revision >= MAX_REVISION:
|
||||
return _failure(CommandResultType.Code.VALIDATION_FAILED, "Spatial revision limit is exhausted.")
|
||||
var boundary := _validate_external_boundary(
|
||||
_mutation_payload(&"REPLACE", entity_id, category, before_cells, after_cells)
|
||||
)
|
||||
if not bool(boundary[&"ok"]):
|
||||
return _failure(
|
||||
CommandResultType.Code.TRANSACTION_APPLY_FAILED,
|
||||
String(boundary[&"message"]),
|
||||
boundary.get(&"details", {})
|
||||
)
|
||||
for cell: Vector3i in before_cells:
|
||||
var cell_slots: Dictionary = _slots.get(cell, {})
|
||||
if cell_slots.get(category, &"") == entity_id:
|
||||
cell_slots.erase(category)
|
||||
if cell_slots.is_empty():
|
||||
_slots.erase(cell)
|
||||
else:
|
||||
_slots[cell] = cell_slots
|
||||
for cell: Vector3i in after_cells:
|
||||
var cell_slots: Dictionary = _slots.get(cell, {})
|
||||
cell_slots[category] = entity_id
|
||||
_slots[cell] = cell_slots
|
||||
_entity_cells[entity_id] = after_cells.duplicate()
|
||||
_mark_changed(category, dirty_cells)
|
||||
entity_cells_replaced.emit(
|
||||
entity_id,
|
||||
category,
|
||||
before_cells.duplicate(),
|
||||
after_cells.duplicate()
|
||||
)
|
||||
last_error = ""
|
||||
return _success({
|
||||
&"entity_id": entity_id,
|
||||
&"category": category,
|
||||
&"before_cells": before_cells,
|
||||
&"cells": after_cells.duplicate(),
|
||||
&"revision": revision,
|
||||
&"changed": true,
|
||||
})
|
||||
|
||||
|
||||
func _validate_external_boundary(payload: Dictionary) -> Dictionary:
|
||||
last_callback_report = {}
|
||||
if not _compatibility_validator.is_valid():
|
||||
return {&"ok": true, &"message": "", &"details": {}}
|
||||
var before := _capture_internal_state()
|
||||
var raw_response: Variant = _compatibility_validator.call(payload.duplicate(true))
|
||||
var tampered := not _internal_state_matches(before)
|
||||
if tampered:
|
||||
_restore_internal_state(before)
|
||||
var response_ok := false
|
||||
var response_reason: StringName = &""
|
||||
var response_details: Dictionary = {}
|
||||
var response_valid := raw_response is Dictionary
|
||||
if response_valid:
|
||||
var response: Dictionary = raw_response
|
||||
response_valid = _dictionary_has_only_keys(response, [&"ok", &"reason", &"details"])
|
||||
var raw_ok: Variant = response.get(&"ok", null)
|
||||
response_valid = response_valid and typeof(raw_ok) == TYPE_BOOL
|
||||
if response.has(&"reason"):
|
||||
response_valid = response_valid and typeof(response[&"reason"]) in [TYPE_STRING, TYPE_STRING_NAME]
|
||||
if response.has(&"details"):
|
||||
response_valid = response_valid \
|
||||
and response[&"details"] is Dictionary \
|
||||
and _variant_depth_is_valid(response[&"details"], 0)
|
||||
if response_valid:
|
||||
response_ok = bool(raw_ok)
|
||||
response_reason = StringName(response.get(&"reason", &""))
|
||||
response_details = (response.get(&"details", {}) as Dictionary).duplicate(true)
|
||||
last_callback_report = {
|
||||
&"ok": response_ok and not tampered,
|
||||
&"response_valid": response_valid,
|
||||
&"tampered": tampered,
|
||||
&"reason": response_reason,
|
||||
&"details": response_details.duplicate(true),
|
||||
}
|
||||
if tampered:
|
||||
return {
|
||||
&"ok": false,
|
||||
&"message": "Compatibility callback mutated SpatialSubslotService and was rolled back.",
|
||||
&"details": {&"reason": &"CALLBACK_TAMPERED", &"rollback_ok": true},
|
||||
}
|
||||
if not response_valid:
|
||||
return {
|
||||
&"ok": false,
|
||||
&"message": "Compatibility callback returned an invalid response.",
|
||||
&"details": {&"reason": &"INVALID_CALLBACK_RESPONSE", &"rollback_ok": true},
|
||||
}
|
||||
if not response_ok:
|
||||
return {
|
||||
&"ok": false,
|
||||
&"message": "Compatibility callback rejected the spatial mutation.",
|
||||
&"details": {
|
||||
&"reason": response_reason if not response_reason.is_empty() else &"COMPATIBILITY_REJECTED",
|
||||
&"callback_details": response_details,
|
||||
&"rollback_ok": true,
|
||||
},
|
||||
}
|
||||
return {&"ok": true, &"message": "", &"details": response_details}
|
||||
|
||||
|
||||
func _capture_internal_state() -> Dictionary:
|
||||
return {
|
||||
&"mutation_active": _mutation_active,
|
||||
&"compatibility_validator": _compatibility_validator,
|
||||
&"revision": revision,
|
||||
&"category_revisions": _category_revisions.duplicate(true),
|
||||
&"slots": _slots.duplicate(true),
|
||||
&"entity_cells": _entity_cells.duplicate(true),
|
||||
&"entity_categories": _entity_categories.duplicate(true),
|
||||
&"dirty_cells": _dirty_cells.duplicate(true),
|
||||
}
|
||||
|
||||
|
||||
func _internal_state_matches(capture: Dictionary) -> bool:
|
||||
return (
|
||||
_mutation_active == bool(capture[&"mutation_active"])
|
||||
and _compatibility_validator == capture[&"compatibility_validator"]
|
||||
and revision == int(capture[&"revision"])
|
||||
and _category_revisions == capture[&"category_revisions"]
|
||||
and _slots == capture[&"slots"]
|
||||
and _entity_cells == capture[&"entity_cells"]
|
||||
and _entity_categories == capture[&"entity_categories"]
|
||||
and _dirty_cells == capture[&"dirty_cells"]
|
||||
)
|
||||
|
||||
|
||||
func _restore_internal_state(capture: Dictionary) -> void:
|
||||
_mutation_active = bool(capture[&"mutation_active"])
|
||||
_compatibility_validator = capture[&"compatibility_validator"]
|
||||
revision = int(capture[&"revision"])
|
||||
_category_revisions = (capture[&"category_revisions"] as Dictionary).duplicate(true)
|
||||
_slots = (capture[&"slots"] as Dictionary).duplicate(true)
|
||||
_entity_cells = (capture[&"entity_cells"] as Dictionary).duplicate(true)
|
||||
_entity_categories = (capture[&"entity_categories"] as Dictionary).duplicate(true)
|
||||
_dirty_cells = (capture[&"dirty_cells"] as Dictionary).duplicate(true)
|
||||
|
||||
|
||||
func _mark_changed(category: StringName, cells: Array[Vector3i]) -> void:
|
||||
revision += 1
|
||||
_category_revisions[category] = revision
|
||||
for cell: Vector3i in cells:
|
||||
_dirty_cells[cell] = true
|
||||
|
||||
|
||||
func _dirty_capacity_allows(cells: Array[Vector3i]) -> bool:
|
||||
var additional := 0
|
||||
for cell: Vector3i in cells:
|
||||
if not _dirty_cells.has(cell):
|
||||
additional += 1
|
||||
return _dirty_cells.size() <= MAX_DIRTY_CELLS - additional
|
||||
|
||||
|
||||
func _total_occupied_slots() -> int:
|
||||
var total := 0
|
||||
for entity_id: StringName in _sorted_entity_ids(_entity_cells):
|
||||
var cells: Variant = _entity_cells[entity_id]
|
||||
if cells is Array:
|
||||
total += cells.size()
|
||||
return total
|
||||
|
||||
|
||||
static func _mutation_payload(
|
||||
operation: StringName,
|
||||
entity_id: StringName,
|
||||
category: StringName,
|
||||
before_cells: Array[Vector3i],
|
||||
after_cells: Array[Vector3i]
|
||||
) -> Dictionary:
|
||||
return {
|
||||
&"operation": operation,
|
||||
&"entity_id": entity_id,
|
||||
&"category": category,
|
||||
&"before_cells": before_cells.duplicate(),
|
||||
&"after_cells": after_cells.duplicate(),
|
||||
}
|
||||
|
||||
|
||||
static func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
|
||||
if not _variant_depth_is_valid(snapshot, 0):
|
||||
return {&"ok": false, &"message": "Snapshot depth or container capacity is exceeded."}
|
||||
if not _dictionary_has_exact_keys(snapshot, [
|
||||
&"schema_version", &"revision", &"category_revisions", &"dirty_cells", &"entities",
|
||||
]):
|
||||
return {&"ok": false, &"message": "Snapshot top-level fields are not canonical."}
|
||||
if typeof(snapshot[&"schema_version"]) != TYPE_INT \
|
||||
or int(snapshot[&"schema_version"]) != SNAPSHOT_SCHEMA_VERSION:
|
||||
return {&"ok": false, &"message": "Unsupported SpatialSubslotService snapshot version."}
|
||||
if typeof(snapshot[&"revision"]) != TYPE_INT:
|
||||
return {&"ok": false, &"message": "Snapshot revision must be an integer."}
|
||||
var next_revision := int(snapshot[&"revision"])
|
||||
if next_revision < 0 or next_revision > MAX_REVISION:
|
||||
return {&"ok": false, &"message": "Snapshot revision is outside the supported range."}
|
||||
if not snapshot[&"category_revisions"] is Dictionary \
|
||||
or not _dictionary_has_exact_keys(snapshot[&"category_revisions"], CATEGORIES):
|
||||
return {&"ok": false, &"message": "Snapshot category revisions are malformed."}
|
||||
var next_category_revisions: Dictionary = {}
|
||||
var latest_category_revision := 0
|
||||
for category: StringName in CATEGORIES:
|
||||
var raw_category_revision: Variant = snapshot[&"category_revisions"][category]
|
||||
if typeof(raw_category_revision) != TYPE_INT \
|
||||
or int(raw_category_revision) < 0 \
|
||||
or int(raw_category_revision) > next_revision:
|
||||
return {&"ok": false, &"message": "Snapshot category revision is invalid."}
|
||||
next_category_revisions[category] = int(raw_category_revision)
|
||||
latest_category_revision = max(latest_category_revision, int(raw_category_revision))
|
||||
if next_revision > 0 and latest_category_revision != next_revision:
|
||||
return {&"ok": false, &"message": "Snapshot has no category at the global revision."}
|
||||
|
||||
if not snapshot[&"entities"] is Array or snapshot[&"entities"].size() > MAX_ENTITIES:
|
||||
return {&"ok": false, &"message": "Snapshot entity collection is malformed or too large."}
|
||||
var next_slots: Dictionary = {}
|
||||
var next_entity_cells: Dictionary = {}
|
||||
var next_entity_categories: Dictionary = {}
|
||||
var total_slots := 0
|
||||
var previous_entity_id := ""
|
||||
for raw_entity: Variant in snapshot[&"entities"]:
|
||||
if not raw_entity is Dictionary or not _dictionary_has_exact_keys(raw_entity, [
|
||||
&"entity_id", &"category", &"cells",
|
||||
]):
|
||||
return {&"ok": false, &"message": "Snapshot entity entry is malformed."}
|
||||
if typeof(raw_entity[&"entity_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return {&"ok": false, &"message": "Snapshot entity ID must be a string."}
|
||||
var entity_id := StringName(raw_entity[&"entity_id"])
|
||||
var entity_text := String(entity_id)
|
||||
if not _stable_id_is_valid(entity_id) \
|
||||
or (not previous_entity_id.is_empty() and entity_text <= previous_entity_id):
|
||||
return {&"ok": false, &"message": "Snapshot entity IDs are invalid, duplicated, or unordered."}
|
||||
if not _category_is_valid(raw_entity[&"category"]):
|
||||
return {&"ok": false, &"message": "Snapshot entity category is invalid."}
|
||||
var category := StringName(raw_entity[&"category"])
|
||||
var cells_validation := _validate_cells(raw_entity[&"cells"])
|
||||
if not bool(cells_validation[&"ok"]):
|
||||
return {&"ok": false, &"message": String(cells_validation[&"message"])}
|
||||
var cells: Array[Vector3i] = cells_validation[&"cells"]
|
||||
if raw_entity[&"cells"] != cells:
|
||||
return {&"ok": false, &"message": "Snapshot entity cells are not canonically ordered."}
|
||||
if total_slots > MAX_TOTAL_SLOTS - cells.size():
|
||||
return {&"ok": false, &"message": "Snapshot occupied subslot capacity is exceeded."}
|
||||
for cell: Vector3i in cells:
|
||||
var cell_slots: Dictionary = next_slots.get(cell, {})
|
||||
if cell_slots.has(category):
|
||||
return {&"ok": false, &"message": "Snapshot contains a duplicate category slot."}
|
||||
cell_slots[category] = entity_id
|
||||
next_slots[cell] = cell_slots
|
||||
next_entity_cells[entity_id] = cells
|
||||
next_entity_categories[entity_id] = category
|
||||
total_slots += cells.size()
|
||||
previous_entity_id = entity_text
|
||||
|
||||
if not snapshot[&"dirty_cells"] is Array or snapshot[&"dirty_cells"].size() > MAX_DIRTY_CELLS:
|
||||
return {&"ok": false, &"message": "Snapshot dirty-cell collection is malformed or too large."}
|
||||
var next_dirty: Dictionary = {}
|
||||
var previous_dirty: Variant = null
|
||||
for raw_cell: Variant in snapshot[&"dirty_cells"]:
|
||||
if not _cell_is_valid(raw_cell) or next_dirty.has(raw_cell):
|
||||
return {&"ok": false, &"message": "Snapshot dirty cell is invalid or duplicated."}
|
||||
var cell: Vector3i = raw_cell
|
||||
if previous_dirty is Vector3i and not _cell_less(previous_dirty, cell):
|
||||
return {&"ok": false, &"message": "Snapshot dirty cells are not canonically ordered."}
|
||||
next_dirty[cell] = true
|
||||
previous_dirty = cell
|
||||
if next_revision == 0 and (not next_entity_cells.is_empty() or not next_dirty.is_empty()):
|
||||
return {&"ok": false, &"message": "Zero revision cannot own entities or dirty cells."}
|
||||
for category: StringName in CATEGORIES:
|
||||
if int(next_category_revisions[category]) == 0:
|
||||
for entity_id: StringName in _sorted_entity_ids(next_entity_cells):
|
||||
if next_entity_categories[entity_id] == category:
|
||||
return {&"ok": false, &"message": "Occupied category must have a positive revision."}
|
||||
return {
|
||||
&"ok": true,
|
||||
&"revision": next_revision,
|
||||
&"category_revisions": next_category_revisions,
|
||||
&"slots": next_slots,
|
||||
&"entity_cells": next_entity_cells,
|
||||
&"entity_categories": next_entity_categories,
|
||||
&"dirty_cells": next_dirty,
|
||||
}
|
||||
|
||||
|
||||
static func _parse_identity(entity_id_value: Variant, category_value: Variant) -> Dictionary:
|
||||
if not _stable_id_is_valid(entity_id_value):
|
||||
return {&"ok": false, &"message": "Spatial entity ID is invalid."}
|
||||
if not _category_is_valid(category_value):
|
||||
return {&"ok": false, &"message": "Spatial category is invalid."}
|
||||
return {
|
||||
&"ok": true,
|
||||
&"entity_id": StringName(entity_id_value),
|
||||
&"category": StringName(category_value),
|
||||
}
|
||||
|
||||
|
||||
static func _validate_cells(value: Variant) -> Dictionary:
|
||||
if not value is Array:
|
||||
return {&"ok": false, &"message": "Spatial cells must be an Array.", &"cells": []}
|
||||
if value.is_empty() or value.size() > MAX_CELLS_PER_ENTITY:
|
||||
return {
|
||||
&"ok": false,
|
||||
&"message": "Spatial cells must be non-empty and within the per-entity limit.",
|
||||
&"cells": [],
|
||||
}
|
||||
var seen: Dictionary = {}
|
||||
var cells: Array[Vector3i] = []
|
||||
for raw_cell: Variant in value:
|
||||
if not _cell_is_valid(raw_cell):
|
||||
return {&"ok": false, &"message": "Spatial cell type, coordinate, or layer is invalid.", &"cells": []}
|
||||
var cell: Vector3i = raw_cell
|
||||
if seen.has(cell):
|
||||
return {&"ok": false, &"message": "Spatial cells contain a duplicate.", &"cells": []}
|
||||
seen[cell] = true
|
||||
cells.append(cell)
|
||||
cells.sort_custom(_cell_less)
|
||||
return {&"ok": true, &"message": "", &"cells": cells}
|
||||
|
||||
|
||||
static func _stable_id_is_valid(value: Variant) -> bool:
|
||||
if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME]:
|
||||
return false
|
||||
var text := String(value)
|
||||
if text.is_empty() or text.length() > MAX_ENTITY_ID_LENGTH:
|
||||
return false
|
||||
for index: int in range(text.length()):
|
||||
var code := text.unicode_at(index)
|
||||
var allowed := (
|
||||
code >= 48 and code <= 57
|
||||
or code >= 65 and code <= 90
|
||||
or code >= 97 and code <= 122
|
||||
or code in [45, 46, 58, 64, 95]
|
||||
)
|
||||
if not allowed:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
static func _category_is_valid(value: Variant) -> bool:
|
||||
return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and CATEGORIES.has(StringName(value))
|
||||
|
||||
|
||||
static func _cell_is_valid(value: Variant) -> bool:
|
||||
if not value is Vector3i:
|
||||
return false
|
||||
var cell: Vector3i = value
|
||||
return (
|
||||
cell.z in [LAYER_FRONT, LAYER_BACK]
|
||||
and cell.x >= -MAX_COORDINATE_ABS
|
||||
and cell.x <= MAX_COORDINATE_ABS
|
||||
and cell.y >= -MAX_COORDINATE_ABS
|
||||
and cell.y <= MAX_COORDINATE_ABS
|
||||
)
|
||||
|
||||
|
||||
static func _sorted_entity_ids(source: Dictionary) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for raw_entity_id: Variant in source:
|
||||
result.append(StringName(raw_entity_id))
|
||||
result.sort_custom(_name_less)
|
||||
return result
|
||||
|
||||
|
||||
static func _copy_cells(source: Array) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
for raw_cell: Variant in source:
|
||||
if raw_cell is Vector3i:
|
||||
result.append(raw_cell)
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
static func _sorted_cells(source: Variant) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
if not source is Array:
|
||||
return result
|
||||
for raw_cell: Variant in source:
|
||||
if raw_cell is Vector3i:
|
||||
result.append(raw_cell)
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
static func _cell_less(left: Vector3i, right: Vector3i) -> bool:
|
||||
if left.y != right.y:
|
||||
return left.y < right.y
|
||||
if left.x != right.x:
|
||||
return left.x < right.x
|
||||
return left.z < right.z
|
||||
|
||||
|
||||
static func _name_less(left: StringName, right: StringName) -> bool:
|
||||
return String(left) < String(right)
|
||||
|
||||
|
||||
static func _dictionary_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 _dictionary_has_only_keys(source: Dictionary, allowed: Array) -> bool:
|
||||
for key: Variant in source:
|
||||
if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or not allowed.has(StringName(key)):
|
||||
return false
|
||||
return source.has(&"ok") or source.has("ok")
|
||||
|
||||
|
||||
static func _variant_depth_is_valid(value: Variant, depth: int) -> bool:
|
||||
if depth > MAX_SNAPSHOT_DEPTH:
|
||||
return false
|
||||
if value is Array:
|
||||
if value.size() > MAX_CONTAINER_ITEMS:
|
||||
return false
|
||||
for child: Variant in value:
|
||||
if not _variant_depth_is_valid(child, depth + 1):
|
||||
return false
|
||||
elif value is Dictionary:
|
||||
if value.size() > MAX_CONTAINER_ITEMS:
|
||||
return false
|
||||
for key: Variant in value:
|
||||
if not _variant_depth_is_valid(key, depth + 1) \
|
||||
or not _variant_depth_is_valid(value[key], depth + 1):
|
||||
return false
|
||||
elif typeof(value) in [TYPE_OBJECT, TYPE_CALLABLE, TYPE_SIGNAL, TYPE_RID]:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func _success(details: Dictionary = {}) -> CommandResult:
|
||||
last_error = ""
|
||||
return CommandResultType.succeeded(0, details)
|
||||
|
||||
|
||||
func _failure(code: CommandResult.Code, message: String, details: Dictionary = {}) -> CommandResult:
|
||||
last_error = message
|
||||
return CommandResultType.failed(code, 0, message, details)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cb48ceerjvv7h
|
||||
@@ -0,0 +1,793 @@
|
||||
class_name WorldService
|
||||
extends RefCounted
|
||||
|
||||
## Deterministic logical-world storage for S1.
|
||||
##
|
||||
## Vector3i uses (x, y, layer), where layer 0 is FRONT and layer 1 is BACK.
|
||||
## All collection-returning APIs use a stable y/x/layer or entity-id order.
|
||||
|
||||
const SNAPSHOT_SCHEMA_VERSION := 1
|
||||
|
||||
const LAYER_FRONT := 0
|
||||
const LAYER_BACK := 1
|
||||
|
||||
const ZONE_INVALID := &"INVALID"
|
||||
const ZONE_ABOVE_GROUND := &"ABOVE_GROUND"
|
||||
const ZONE_BOUNDARY := &"BOUNDARY"
|
||||
const ZONE_UNDERGROUND := &"UNDERGROUND"
|
||||
|
||||
const ERROR_EMPTY_CELLS := &"EMPTY_CELLS"
|
||||
const ERROR_INVALID_CELL_TYPE := &"INVALID_CELL_TYPE"
|
||||
const ERROR_OUT_OF_BOUNDS := &"OUT_OF_BOUNDS"
|
||||
const ERROR_DUPLICATE_CELL := &"DUPLICATE_CELL"
|
||||
const ERROR_LAYER_MISMATCH := &"LAYER_MISMATCH"
|
||||
const ERROR_OCCUPIED := &"OCCUPIED"
|
||||
const ERROR_ENTITY_EXISTS := &"ENTITY_EXISTS"
|
||||
const ERROR_INVALID_ENTITY_ID := &"INVALID_ENTITY_ID"
|
||||
const ERROR_INVALID_KIND := &"INVALID_KIND"
|
||||
const ERROR_INVALID_WEIGHT := &"INVALID_WEIGHT"
|
||||
const ERROR_UNSUPPORTED := &"UNSUPPORTED"
|
||||
|
||||
var revision := 0
|
||||
var dirty_revision: int:
|
||||
get:
|
||||
return revision
|
||||
var occupancy_revision := 0
|
||||
var walkability_revision := 0
|
||||
var excavation_revision := 0
|
||||
var support_revision := 0
|
||||
|
||||
var _bounds := {
|
||||
&"min_x": 0,
|
||||
&"max_x": -1,
|
||||
&"min_y": 0,
|
||||
&"max_y": -1,
|
||||
}
|
||||
var _occupants: Dictionary = {}
|
||||
var _entity_cells: Dictionary = {}
|
||||
var _entity_kinds: Dictionary = {}
|
||||
var _walkable: Dictionary = {}
|
||||
var _excavated: Dictionary = {}
|
||||
var _support_capacity: Dictionary = {}
|
||||
var _support_anchors: Dictionary = {}
|
||||
var _dirty_cells: Dictionary = {}
|
||||
|
||||
|
||||
func reset(bounds: Variant) -> bool:
|
||||
var normalized := _normalize_bounds(bounds)
|
||||
if normalized.is_empty():
|
||||
return false
|
||||
_bounds = normalized
|
||||
_occupants.clear()
|
||||
_entity_cells.clear()
|
||||
_entity_kinds.clear()
|
||||
_walkable.clear()
|
||||
_excavated.clear()
|
||||
_support_capacity.clear()
|
||||
_support_anchors.clear()
|
||||
_dirty_cells.clear()
|
||||
revision = 0
|
||||
occupancy_revision = 0
|
||||
walkability_revision = 0
|
||||
excavation_revision = 0
|
||||
support_revision = 0
|
||||
return true
|
||||
|
||||
|
||||
func get_bounds() -> Dictionary:
|
||||
return _bounds.duplicate(true)
|
||||
|
||||
|
||||
func expand_bounds(bounds: Variant) -> bool:
|
||||
var normalized := _normalize_bounds(bounds)
|
||||
if normalized.is_empty():
|
||||
return false
|
||||
if (
|
||||
int(normalized[&"min_x"]) > int(_bounds[&"min_x"])
|
||||
or int(normalized[&"max_x"]) < int(_bounds[&"max_x"])
|
||||
or int(normalized[&"min_y"]) > int(_bounds[&"min_y"])
|
||||
or int(normalized[&"max_y"]) < int(_bounds[&"max_y"])
|
||||
):
|
||||
return false
|
||||
if normalized == _bounds:
|
||||
return true
|
||||
_bounds = normalized
|
||||
revision += 1
|
||||
return true
|
||||
|
||||
|
||||
func is_in_bounds(cell: Variant) -> bool:
|
||||
return cell is Vector3i and _is_in_bounds_with(cell, _bounds)
|
||||
|
||||
|
||||
func get_zone(cell_or_y: Variant) -> StringName:
|
||||
var y: int
|
||||
if cell_or_y is Vector3i or cell_or_y is Vector2i:
|
||||
y = cell_or_y.y
|
||||
elif cell_or_y is int:
|
||||
y = int(cell_or_y)
|
||||
else:
|
||||
return ZONE_INVALID
|
||||
if y > 0:
|
||||
return ZONE_ABOVE_GROUND
|
||||
if y < 0:
|
||||
return ZONE_UNDERGROUND
|
||||
return ZONE_BOUNDARY
|
||||
|
||||
|
||||
func cells_for_footprint(origin: Vector3i, footprint: Variant) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
if origin.z not in [LAYER_FRONT, LAYER_BACK]:
|
||||
return result
|
||||
if footprint is Vector2i:
|
||||
if footprint.x <= 0 or footprint.y <= 0:
|
||||
return result
|
||||
for y_offset in range(footprint.y):
|
||||
for x_offset in range(footprint.x):
|
||||
result.append(origin + Vector3i(x_offset, y_offset, 0))
|
||||
return result
|
||||
if footprint is Array:
|
||||
for offset: Variant in footprint:
|
||||
if not offset is Vector2i:
|
||||
return []
|
||||
result.append(origin + Vector3i(offset.x, offset.y, 0))
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
func validate_occupancy(cells: Array, entity_id: StringName = &"") -> Dictionary:
|
||||
var report := _validate_cells(cells)
|
||||
var occupied_cells: Array[Vector3i] = []
|
||||
for cell: Vector3i in report[&"valid_cells"]:
|
||||
if _occupants.has(cell):
|
||||
occupied_cells.append(cell)
|
||||
if not occupied_cells.is_empty():
|
||||
_add_error(report[&"errors"], ERROR_OCCUPIED)
|
||||
if not entity_id.is_empty() and _entity_cells.has(entity_id):
|
||||
_add_error(report[&"errors"], ERROR_ENTITY_EXISTS)
|
||||
report[&"occupied_cells"] = occupied_cells
|
||||
report[&"existing_entity"] = not entity_id.is_empty() and _entity_cells.has(entity_id)
|
||||
report[&"ok"] = report[&"errors"].is_empty()
|
||||
return report
|
||||
|
||||
|
||||
func occupy(entity_id: StringName, cells: Array, kind: StringName = &"facility") -> bool:
|
||||
if entity_id.is_empty() or kind.is_empty():
|
||||
return false
|
||||
var report := validate_occupancy(cells, entity_id)
|
||||
if not report[&"ok"]:
|
||||
return false
|
||||
var canonical_cells: Array[Vector3i] = report[&"valid_cells"].duplicate()
|
||||
canonical_cells.sort_custom(_cell_less)
|
||||
for cell: Vector3i in canonical_cells:
|
||||
_occupants[cell] = {
|
||||
&"entity_id": entity_id,
|
||||
&"kind": kind,
|
||||
}
|
||||
_entity_cells[entity_id] = canonical_cells
|
||||
_entity_kinds[entity_id] = kind
|
||||
var structural := _kind_support_capacity(kind) > 0 or _kind_weight(kind) > 0 or _kind_is_anchor(kind)
|
||||
_mark_dirty(canonical_cells, true, false, false, structural)
|
||||
return true
|
||||
|
||||
|
||||
func release(entity_id: StringName) -> bool:
|
||||
if entity_id.is_empty() or not _entity_cells.has(entity_id):
|
||||
return false
|
||||
var cells: Array[Vector3i] = _copy_cell_array(_entity_cells[entity_id])
|
||||
var kind: StringName = _entity_kinds.get(entity_id, &"")
|
||||
for cell: Vector3i in cells:
|
||||
var occupant: Dictionary = _occupants.get(cell, {})
|
||||
if occupant.get(&"entity_id", &"") == entity_id:
|
||||
_occupants.erase(cell)
|
||||
_entity_cells.erase(entity_id)
|
||||
_entity_kinds.erase(entity_id)
|
||||
var structural := _kind_support_capacity(kind) > 0 or _kind_weight(kind) > 0 or _kind_is_anchor(kind)
|
||||
_mark_dirty(cells, true, false, false, structural)
|
||||
return true
|
||||
|
||||
|
||||
func get_occupant(cell: Vector3i) -> StringName:
|
||||
var occupant: Dictionary = _occupants.get(cell, {})
|
||||
return occupant.get(&"entity_id", &"")
|
||||
|
||||
|
||||
func get_occupant_kind(cell: Vector3i) -> StringName:
|
||||
var occupant: Dictionary = _occupants.get(cell, {})
|
||||
return occupant.get(&"kind", &"")
|
||||
|
||||
|
||||
func get_entity_cells(entity_id: StringName) -> Array[Vector3i]:
|
||||
if not _entity_cells.has(entity_id):
|
||||
return []
|
||||
return _copy_cell_array(_entity_cells[entity_id])
|
||||
|
||||
|
||||
func get_entity_kind(entity_id: StringName) -> StringName:
|
||||
return _entity_kinds.get(entity_id, &"")
|
||||
|
||||
|
||||
func set_walkable(cell: Vector3i, value: bool) -> bool:
|
||||
if not is_in_bounds(cell):
|
||||
return false
|
||||
var previous := bool(_walkable.get(cell, false))
|
||||
if previous == value:
|
||||
return true
|
||||
if value:
|
||||
_walkable[cell] = true
|
||||
else:
|
||||
_walkable.erase(cell)
|
||||
_mark_dirty([cell], false, true, false, false)
|
||||
return true
|
||||
|
||||
|
||||
func is_walkable(cell: Vector3i) -> bool:
|
||||
return is_in_bounds(cell) and bool(_walkable.get(cell, false))
|
||||
|
||||
|
||||
func get_walkable_cells() -> Array[Vector3i]:
|
||||
return _sorted_dictionary_cells(_walkable)
|
||||
|
||||
|
||||
func set_excavated(cell: Vector3i, value: bool) -> bool:
|
||||
if not is_in_bounds(cell):
|
||||
return false
|
||||
var spatial_key := Vector2i(cell.x, cell.y)
|
||||
var previous := is_excavated(cell)
|
||||
if previous == value:
|
||||
return true
|
||||
var default_value := cell.y >= 0
|
||||
if value == default_value:
|
||||
_excavated.erase(spatial_key)
|
||||
else:
|
||||
_excavated[spatial_key] = value
|
||||
var affected: Array[Vector3i] = [
|
||||
Vector3i(cell.x, cell.y, LAYER_FRONT),
|
||||
Vector3i(cell.x, cell.y, LAYER_BACK),
|
||||
]
|
||||
_mark_dirty(affected, false, false, true, false)
|
||||
return true
|
||||
|
||||
|
||||
func is_excavated(cell: Vector3i) -> bool:
|
||||
if not is_in_bounds(cell):
|
||||
return false
|
||||
var spatial_key := Vector2i(cell.x, cell.y)
|
||||
return bool(_excavated.get(spatial_key, cell.y >= 0))
|
||||
|
||||
|
||||
## Sets explicit support on a layer cell. Anchors have unlimited capacity.
|
||||
## A capacity of zero with anchor=false removes the explicit support record.
|
||||
func set_support(cell: Vector3i, capacity: int, anchor := false) -> bool:
|
||||
if not is_in_bounds(cell) or capacity < 0:
|
||||
return false
|
||||
var previous_capacity := int(_support_capacity.get(cell, 0))
|
||||
var previous_anchor := bool(_support_anchors.get(cell, false))
|
||||
if previous_capacity == capacity and previous_anchor == anchor:
|
||||
return true
|
||||
if capacity == 0:
|
||||
_support_capacity.erase(cell)
|
||||
else:
|
||||
_support_capacity[cell] = capacity
|
||||
if anchor:
|
||||
_support_anchors[cell] = true
|
||||
else:
|
||||
_support_anchors.erase(cell)
|
||||
_mark_dirty([cell], false, false, false, true)
|
||||
return true
|
||||
|
||||
|
||||
func get_support_capacity(cell: Vector3i) -> int:
|
||||
if not is_in_bounds(cell):
|
||||
return 0
|
||||
var capacity := int(_support_capacity.get(cell, 0))
|
||||
if _occupants.has(cell):
|
||||
capacity += _kind_support_capacity(get_occupant_kind(cell))
|
||||
return capacity
|
||||
|
||||
|
||||
func is_support_anchor(cell: Vector3i) -> bool:
|
||||
return (
|
||||
is_in_bounds(cell)
|
||||
and (
|
||||
bool(_support_anchors.get(cell, false))
|
||||
or (_occupants.has(cell) and _kind_is_anchor(get_occupant_kind(cell)))
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
## Validates connected structural components touched by the proposed cells.
|
||||
## Existing structural occupant kinds contribute their configured capacity/load.
|
||||
func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary:
|
||||
var base_report := _validate_cells(cells)
|
||||
var errors: Array[StringName] = base_report[&"errors"].duplicate()
|
||||
if weight_per_cell <= 0:
|
||||
_add_error(errors, ERROR_INVALID_WEIGHT)
|
||||
var proposed: Array[Vector3i] = base_report[&"valid_cells"].duplicate()
|
||||
proposed.sort_custom(_cell_less)
|
||||
var result := {
|
||||
&"ok": false,
|
||||
&"errors": errors,
|
||||
&"unsupported_cells": [] as Array[Vector3i],
|
||||
&"required_weight": 0,
|
||||
&"available_capacity": 0,
|
||||
&"components": [] as Array[Dictionary],
|
||||
}
|
||||
if not errors.is_empty():
|
||||
return result
|
||||
|
||||
var structural_set: Dictionary = {}
|
||||
for proposed_cell: Vector3i in proposed:
|
||||
structural_set[proposed_cell] = true
|
||||
for cell: Variant in _sorted_dictionary_cells(_support_capacity):
|
||||
structural_set[cell] = true
|
||||
for cell: Variant in _sorted_dictionary_cells(_support_anchors):
|
||||
structural_set[cell] = true
|
||||
for entity_id: StringName in _sorted_entity_ids():
|
||||
var kind: StringName = _entity_kinds[entity_id]
|
||||
if _kind_support_capacity(kind) <= 0 and _kind_weight(kind) <= 0 and not _kind_is_anchor(kind):
|
||||
continue
|
||||
for occupied_cell: Vector3i in _entity_cells[entity_id]:
|
||||
structural_set[occupied_cell] = true
|
||||
|
||||
var proposed_set: Dictionary = {}
|
||||
for proposed_cell: Vector3i in proposed:
|
||||
proposed_set[proposed_cell] = true
|
||||
var visited: Dictionary = {}
|
||||
var unsupported: Array[Vector3i] = []
|
||||
var total_required := 0
|
||||
var total_capacity := 0
|
||||
var all_components_anchored := true
|
||||
for proposed_cell: Vector3i in proposed:
|
||||
if visited.has(proposed_cell):
|
||||
continue
|
||||
var component := _collect_component(proposed_cell, structural_set, visited)
|
||||
var component_proposed: Array[Vector3i] = []
|
||||
var required := 0
|
||||
var capacity := 0
|
||||
var anchored := false
|
||||
for component_cell: Vector3i in component:
|
||||
if proposed_set.has(component_cell):
|
||||
component_proposed.append(component_cell)
|
||||
required += weight_per_cell
|
||||
if _occupants.has(component_cell) and not proposed_set.has(component_cell):
|
||||
required += _kind_weight(get_occupant_kind(component_cell))
|
||||
capacity += get_support_capacity(component_cell)
|
||||
anchored = anchored or is_support_anchor(component_cell)
|
||||
component_proposed.sort_custom(_cell_less)
|
||||
var supported := anchored or capacity >= required
|
||||
if not supported:
|
||||
unsupported.append_array(component_proposed)
|
||||
result[&"components"].append({
|
||||
&"cells": component_proposed,
|
||||
&"required_weight": required,
|
||||
&"available_capacity": -1 if anchored else capacity,
|
||||
&"anchored": anchored,
|
||||
&"supported": supported,
|
||||
})
|
||||
total_required += required
|
||||
total_capacity += capacity
|
||||
all_components_anchored = all_components_anchored and anchored
|
||||
unsupported.sort_custom(_cell_less)
|
||||
if not unsupported.is_empty():
|
||||
_add_error(result[&"errors"], ERROR_UNSUPPORTED)
|
||||
result[&"unsupported_cells"] = unsupported
|
||||
result[&"required_weight"] = total_required
|
||||
result[&"available_capacity"] = -1 if all_components_anchored else total_capacity
|
||||
result[&"ok"] = result[&"errors"].is_empty()
|
||||
return result
|
||||
|
||||
|
||||
func get_dirty_revision() -> int:
|
||||
return revision
|
||||
|
||||
|
||||
func get_dirty_cells() -> Array[Vector3i]:
|
||||
return _sorted_dictionary_cells(_dirty_cells)
|
||||
|
||||
|
||||
func consume_dirty_cells() -> Array[Vector3i]:
|
||||
var result := get_dirty_cells()
|
||||
_dirty_cells.clear()
|
||||
return result
|
||||
|
||||
|
||||
func create_snapshot() -> Dictionary:
|
||||
var occupancies: Array[Dictionary] = []
|
||||
for entity_id: StringName in _sorted_entity_ids():
|
||||
occupancies.append({
|
||||
&"entity_id": entity_id,
|
||||
&"kind": _entity_kinds[entity_id],
|
||||
&"cells": get_entity_cells(entity_id),
|
||||
})
|
||||
var excavation_overrides: Array[Dictionary] = []
|
||||
var spatial_keys: Array[Vector2i] = []
|
||||
for key: Variant in _excavated:
|
||||
spatial_keys.append(key)
|
||||
spatial_keys.sort_custom(_spatial_less)
|
||||
for key: Vector2i in spatial_keys:
|
||||
excavation_overrides.append({&"position": key, &"value": bool(_excavated[key])})
|
||||
var support_entries: Array[Dictionary] = []
|
||||
var support_cell_set: Dictionary = {}
|
||||
for cell: Variant in _support_capacity:
|
||||
support_cell_set[cell] = true
|
||||
for cell: Variant in _support_anchors:
|
||||
support_cell_set[cell] = true
|
||||
for cell: Vector3i in _sorted_dictionary_cells(support_cell_set):
|
||||
support_entries.append({
|
||||
&"cell": cell,
|
||||
&"capacity": int(_support_capacity.get(cell, 0)),
|
||||
&"anchor": bool(_support_anchors.get(cell, false)),
|
||||
})
|
||||
return {
|
||||
&"schema_version": SNAPSHOT_SCHEMA_VERSION,
|
||||
&"bounds": get_bounds(),
|
||||
&"occupancies": occupancies,
|
||||
&"walkable_cells": _sorted_dictionary_cells(_walkable),
|
||||
&"excavation_overrides": excavation_overrides,
|
||||
&"support": support_entries,
|
||||
&"dirty_cells": get_dirty_cells(),
|
||||
&"revisions": {
|
||||
&"world": revision,
|
||||
&"occupancy": occupancy_revision,
|
||||
&"walkability": walkability_revision,
|
||||
&"excavation": excavation_revision,
|
||||
&"support": support_revision,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
func restore_snapshot(snapshot: Dictionary) -> bool:
|
||||
if int(snapshot.get(&"schema_version", -1)) != SNAPSHOT_SCHEMA_VERSION:
|
||||
return false
|
||||
var new_bounds := _normalize_bounds(snapshot.get(&"bounds", {}))
|
||||
if new_bounds.is_empty():
|
||||
return false
|
||||
var new_occupants: Dictionary = {}
|
||||
var new_entity_cells: Dictionary = {}
|
||||
var new_entity_kinds: Dictionary = {}
|
||||
var occupancies: Variant = snapshot.get(&"occupancies", null)
|
||||
if not occupancies is Array:
|
||||
return false
|
||||
for entry: Variant in occupancies:
|
||||
if not entry is Dictionary:
|
||||
return false
|
||||
var entity_id := StringName(entry.get(&"entity_id", &""))
|
||||
var kind := StringName(entry.get(&"kind", &""))
|
||||
var cells: Variant = entry.get(&"cells", null)
|
||||
if entity_id.is_empty() or kind.is_empty() or not cells is Array or new_entity_cells.has(entity_id):
|
||||
return false
|
||||
var validation := _validate_cells_against_bounds(cells, new_bounds)
|
||||
if not validation[&"errors"].is_empty():
|
||||
return false
|
||||
var canonical_cells: Array[Vector3i] = validation[&"valid_cells"].duplicate()
|
||||
for cell: Vector3i in canonical_cells:
|
||||
if new_occupants.has(cell):
|
||||
return false
|
||||
new_occupants[cell] = {&"entity_id": entity_id, &"kind": kind}
|
||||
new_entity_cells[entity_id] = canonical_cells
|
||||
new_entity_kinds[entity_id] = kind
|
||||
|
||||
var new_walkable: Dictionary = {}
|
||||
var walkable_cells: Variant = snapshot.get(&"walkable_cells", null)
|
||||
if not walkable_cells is Array:
|
||||
return false
|
||||
for cell: Variant in walkable_cells:
|
||||
if not cell is Vector3i or not _is_in_bounds_with(cell, new_bounds) or new_walkable.has(cell):
|
||||
return false
|
||||
new_walkable[cell] = true
|
||||
|
||||
var new_excavated: Dictionary = {}
|
||||
var excavation_entries: Variant = snapshot.get(&"excavation_overrides", null)
|
||||
if not excavation_entries is Array:
|
||||
return false
|
||||
for entry: Variant in excavation_entries:
|
||||
if not entry is Dictionary:
|
||||
return false
|
||||
var position: Variant = entry.get(&"position", null)
|
||||
var value: Variant = entry.get(&"value", null)
|
||||
if not position is Vector2i or not value is bool or new_excavated.has(position):
|
||||
return false
|
||||
if not _is_spatial_in_bounds(position, new_bounds):
|
||||
return false
|
||||
if bool(value) == (position.y >= 0):
|
||||
return false
|
||||
new_excavated[position] = value
|
||||
|
||||
var new_support_capacity: Dictionary = {}
|
||||
var new_support_anchors: Dictionary = {}
|
||||
var support_entries: Variant = snapshot.get(&"support", null)
|
||||
if not support_entries is Array:
|
||||
return false
|
||||
var seen_support: Dictionary = {}
|
||||
for entry: Variant in support_entries:
|
||||
if not entry is Dictionary:
|
||||
return false
|
||||
var cell: Variant = entry.get(&"cell", null)
|
||||
var capacity: Variant = entry.get(&"capacity", null)
|
||||
var anchor: Variant = entry.get(&"anchor", null)
|
||||
if (
|
||||
not cell is Vector3i
|
||||
or not _is_in_bounds_with(cell, new_bounds)
|
||||
or not capacity is int
|
||||
or int(capacity) < 0
|
||||
or not anchor is bool
|
||||
or (int(capacity) == 0 and not bool(anchor))
|
||||
or seen_support.has(cell)
|
||||
):
|
||||
return false
|
||||
seen_support[cell] = true
|
||||
if int(capacity) > 0:
|
||||
new_support_capacity[cell] = int(capacity)
|
||||
if bool(anchor):
|
||||
new_support_anchors[cell] = true
|
||||
|
||||
var new_dirty: Dictionary = {}
|
||||
var dirty_cells: Variant = snapshot.get(&"dirty_cells", null)
|
||||
if not dirty_cells is Array:
|
||||
return false
|
||||
for cell: Variant in dirty_cells:
|
||||
if not cell is Vector3i or not _is_in_bounds_with(cell, new_bounds) or new_dirty.has(cell):
|
||||
return false
|
||||
new_dirty[cell] = true
|
||||
|
||||
var revisions: Variant = snapshot.get(&"revisions", null)
|
||||
if not revisions is Dictionary:
|
||||
return false
|
||||
var revision_names: Array[StringName] = [
|
||||
&"world", &"occupancy", &"walkability", &"excavation", &"support",
|
||||
]
|
||||
for revision_name: StringName in revision_names:
|
||||
if not revisions.get(revision_name, null) is int or int(revisions[revision_name]) < 0:
|
||||
return false
|
||||
if (
|
||||
int(revisions[&"occupancy"]) > int(revisions[&"world"])
|
||||
or int(revisions[&"walkability"]) > int(revisions[&"world"])
|
||||
or int(revisions[&"excavation"]) > int(revisions[&"world"])
|
||||
or int(revisions[&"support"]) > int(revisions[&"world"])
|
||||
):
|
||||
return false
|
||||
|
||||
_bounds = new_bounds
|
||||
_occupants = new_occupants
|
||||
_entity_cells = new_entity_cells
|
||||
_entity_kinds = new_entity_kinds
|
||||
_walkable = new_walkable
|
||||
_excavated = new_excavated
|
||||
_support_capacity = new_support_capacity
|
||||
_support_anchors = new_support_anchors
|
||||
_dirty_cells = new_dirty
|
||||
revision = int(revisions[&"world"])
|
||||
occupancy_revision = int(revisions[&"occupancy"])
|
||||
walkability_revision = int(revisions[&"walkability"])
|
||||
excavation_revision = int(revisions[&"excavation"])
|
||||
support_revision = int(revisions[&"support"])
|
||||
return true
|
||||
|
||||
|
||||
func _validate_cells(cells: Array) -> Dictionary:
|
||||
return _validate_cells_against_bounds(cells, _bounds)
|
||||
|
||||
|
||||
func _validate_cells_against_bounds(cells: Array, bounds: Dictionary) -> Dictionary:
|
||||
var errors: Array[StringName] = []
|
||||
var valid_cells: Array[Vector3i] = []
|
||||
var invalid_cells: Array[Vector3i] = []
|
||||
var duplicate_cells: Array[Vector3i] = []
|
||||
var seen: Dictionary = {}
|
||||
var layers: Dictionary = {}
|
||||
if cells.is_empty():
|
||||
_add_error(errors, ERROR_EMPTY_CELLS)
|
||||
for value: Variant in cells:
|
||||
if not value is Vector3i:
|
||||
_add_error(errors, ERROR_INVALID_CELL_TYPE)
|
||||
continue
|
||||
var cell: Vector3i = value
|
||||
layers[cell.z] = true
|
||||
if seen.has(cell):
|
||||
duplicate_cells.append(cell)
|
||||
_add_error(errors, ERROR_DUPLICATE_CELL)
|
||||
continue
|
||||
seen[cell] = true
|
||||
valid_cells.append(cell)
|
||||
if not _is_in_bounds_with(cell, bounds):
|
||||
invalid_cells.append(cell)
|
||||
_add_error(errors, ERROR_OUT_OF_BOUNDS)
|
||||
if layers.size() > 1:
|
||||
_add_error(errors, ERROR_LAYER_MISMATCH)
|
||||
valid_cells.sort_custom(_cell_less)
|
||||
invalid_cells.sort_custom(_cell_less)
|
||||
duplicate_cells.sort_custom(_cell_less)
|
||||
return {
|
||||
&"ok": errors.is_empty(),
|
||||
&"errors": errors,
|
||||
&"valid_cells": valid_cells,
|
||||
&"invalid_cells": invalid_cells,
|
||||
&"duplicate_cells": duplicate_cells,
|
||||
&"layer_mismatch": layers.size() > 1,
|
||||
}
|
||||
|
||||
|
||||
func _collect_component(start: Vector3i, structural_set: Dictionary, visited: Dictionary) -> Array[Vector3i]:
|
||||
var queue: Array[Vector3i] = [start]
|
||||
var component: Array[Vector3i] = []
|
||||
visited[start] = true
|
||||
var index := 0
|
||||
while index < queue.size():
|
||||
var current := queue[index]
|
||||
index += 1
|
||||
component.append(current)
|
||||
var neighbors: Array[Vector3i] = [
|
||||
current + Vector3i(-1, 0, 0),
|
||||
current + Vector3i(0, -1, 0),
|
||||
current + Vector3i(0, 1, 0),
|
||||
current + Vector3i(1, 0, 0),
|
||||
]
|
||||
for neighbor: Vector3i in neighbors:
|
||||
if structural_set.has(neighbor) and not visited.has(neighbor):
|
||||
visited[neighbor] = true
|
||||
queue.append(neighbor)
|
||||
component.sort_custom(_cell_less)
|
||||
return component
|
||||
|
||||
|
||||
func _mark_dirty(
|
||||
cells: Array,
|
||||
occupancy_changed: bool,
|
||||
walkability_changed: bool,
|
||||
excavation_changed: bool,
|
||||
support_changed: bool
|
||||
) -> void:
|
||||
revision += 1
|
||||
if occupancy_changed:
|
||||
occupancy_revision = revision
|
||||
if walkability_changed:
|
||||
walkability_revision = revision
|
||||
if excavation_changed:
|
||||
excavation_revision = revision
|
||||
if support_changed:
|
||||
support_revision = revision
|
||||
for cell: Variant in cells:
|
||||
if cell is Vector3i and is_in_bounds(cell):
|
||||
_dirty_cells[cell] = true
|
||||
|
||||
|
||||
func _normalize_bounds(value: Variant) -> Dictionary:
|
||||
var min_x: int
|
||||
var max_x: int
|
||||
var min_y: int
|
||||
var max_y: int
|
||||
if value is Rect2i:
|
||||
if value.size.x <= 0 or value.size.y <= 0:
|
||||
return {}
|
||||
min_x = value.position.x
|
||||
max_x = value.position.x + value.size.x - 1
|
||||
min_y = value.position.y
|
||||
max_y = value.position.y + value.size.y - 1
|
||||
elif value is Dictionary:
|
||||
if value.has(&"min") and value.has(&"max"):
|
||||
var minimum: Variant = value[&"min"]
|
||||
var maximum: Variant = value[&"max"]
|
||||
if not minimum is Vector2i or not maximum is Vector2i:
|
||||
return {}
|
||||
min_x = minimum.x
|
||||
max_x = maximum.x
|
||||
min_y = minimum.y
|
||||
max_y = maximum.y
|
||||
else:
|
||||
var names: Array[StringName] = [&"min_x", &"max_x", &"min_y", &"max_y"]
|
||||
for name: StringName in names:
|
||||
if not value.get(name, null) is int:
|
||||
return {}
|
||||
min_x = int(value[&"min_x"])
|
||||
max_x = int(value[&"max_x"])
|
||||
min_y = int(value[&"min_y"])
|
||||
max_y = int(value[&"max_y"])
|
||||
elif value is Vector4i:
|
||||
min_x = value.x
|
||||
max_x = value.y
|
||||
min_y = value.z
|
||||
max_y = value.w
|
||||
else:
|
||||
return {}
|
||||
if min_x > max_x or min_y > max_y:
|
||||
return {}
|
||||
return {
|
||||
&"min_x": min_x,
|
||||
&"max_x": max_x,
|
||||
&"min_y": min_y,
|
||||
&"max_y": max_y,
|
||||
}
|
||||
|
||||
|
||||
func _sorted_entity_ids() -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for entity_id: Variant in _entity_cells:
|
||||
result.append(StringName(entity_id))
|
||||
result.sort_custom(func(left: StringName, right: StringName) -> bool:
|
||||
return String(left) < String(right)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
func _sorted_dictionary_cells(source: Dictionary) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
for cell: Variant in source:
|
||||
if cell is Vector3i:
|
||||
result.append(cell)
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
func _copy_cell_array(source: Array) -> Array[Vector3i]:
|
||||
var result: Array[Vector3i] = []
|
||||
for cell: Variant in source:
|
||||
result.append(cell)
|
||||
result.sort_custom(_cell_less)
|
||||
return result
|
||||
|
||||
|
||||
func _kind_support_capacity(kind: StringName) -> int:
|
||||
match String(kind).to_lower():
|
||||
"artificial_floor", "floor":
|
||||
return 2
|
||||
"reinforced_floor":
|
||||
return 5
|
||||
"support_column", "column":
|
||||
return 10
|
||||
return 0
|
||||
|
||||
|
||||
func _kind_weight(kind: StringName) -> int:
|
||||
match String(kind).to_lower():
|
||||
"facility", "facility_small", "small":
|
||||
return 1
|
||||
"facility_medium", "medium":
|
||||
return 2
|
||||
"facility_large", "facility_heavy", "large", "heavy":
|
||||
return 3
|
||||
return 0
|
||||
|
||||
|
||||
func _kind_is_anchor(kind: StringName) -> bool:
|
||||
return String(kind).to_lower() in [
|
||||
"anchor", "structural_anchor", "natural_branch", "world_tree", "world_tree_anchor",
|
||||
]
|
||||
|
||||
|
||||
static func _is_in_bounds_with(cell: Vector3i, bounds: Dictionary) -> bool:
|
||||
return (
|
||||
cell.z in [LAYER_FRONT, LAYER_BACK]
|
||||
and cell.x >= int(bounds.get(&"min_x", 0))
|
||||
and cell.x <= int(bounds.get(&"max_x", -1))
|
||||
and cell.y >= int(bounds.get(&"min_y", 0))
|
||||
and cell.y <= int(bounds.get(&"max_y", -1))
|
||||
)
|
||||
|
||||
|
||||
static func _is_spatial_in_bounds(position: Vector2i, bounds: Dictionary) -> bool:
|
||||
return (
|
||||
position.x >= int(bounds.get(&"min_x", 0))
|
||||
and position.x <= int(bounds.get(&"max_x", -1))
|
||||
and position.y >= int(bounds.get(&"min_y", 0))
|
||||
and position.y <= int(bounds.get(&"max_y", -1))
|
||||
)
|
||||
|
||||
|
||||
static func _cell_less(left: Vector3i, right: Vector3i) -> bool:
|
||||
if left.y != right.y:
|
||||
return left.y < right.y
|
||||
if left.x != right.x:
|
||||
return left.x < right.x
|
||||
return left.z < right.z
|
||||
|
||||
|
||||
static func _spatial_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], error_code: StringName) -> void:
|
||||
if not errors.has(error_code):
|
||||
errors.append(error_code)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpv4h0rgdjl2t
|
||||
Reference in New Issue
Block a user