class_name AnimalStateRecord extends RefCounted signal changed(state: AnimalStateRecord) signal fed(state: AnimalStateRecord, actor_id: int) const SCHEMA_VERSION := 3 const ROUTINE_SCHEMA_VERSION := 2 const LEGACY_SCHEMA_VERSION := 1 const HUNGER_PER_TICK := 0.125 const FEED_THRESHOLD := 65.0 const HUNGER_RELIEF := 55.0 const NEVER_FED_TICK := -1 const LEGACY_DEFINITION_BY_SPECIES := {"goat": "domestic_goat"} var data: Dictionary func _init(record_data: Dictionary = {}) -> void: data = record_data.duplicate(true) static func create_from_node(node: AnimalNode) -> AnimalStateRecord: return ( AnimalStateRecord . new( { "schema_version": SCHEMA_VERSION, "animal_id": String(node.animal_id), "animal_definition_id": String(node.animal_definition_id), "display_name": node.display_name, "species_id": String(node.species_id), "position": [node.global_position.x, node.global_position.y, node.global_position.z], "hunger": node.initial_hunger, "last_fed_tick": NEVER_FED_TICK, "reserved_by": -1, "enabled": node.initial_enabled, "can_npcs_feed": node.can_npcs_feed, "can_player_feed": node.can_player_feed, "routine_site_id": String(node.initial_routine_site_id), "travel_target_site_id": "", "travel_target_position": [0.0, 0.0, 0.0], "has_travel_target": false, "next_routine_tick": node.initial_next_routine_tick, } ) ) static func from_dictionary(record_data: Dictionary) -> AnimalStateRecord: var version := int(record_data.get("schema_version", -1)) if version == LEGACY_SCHEMA_VERSION: record_data = _migrate_legacy(record_data) elif version == ROUTINE_SCHEMA_VERSION: record_data = _migrate_definition_identity(record_data) elif version != SCHEMA_VERSION: return null if not ( record_data . has_all( [ "animal_id", "animal_definition_id", "display_name", "species_id", "position", "hunger", "last_fed_tick", "reserved_by", "enabled", "can_npcs_feed", "can_player_feed", "routine_site_id", "travel_target_site_id", "travel_target_position", "has_travel_target", "next_routine_tick", ] ) ): return null var animal_id := String(record_data["animal_id"]) var animal_definition_id := String(record_data["animal_definition_id"]) var display_name := String(record_data["display_name"]) var species_id := String(record_data["species_id"]) var saved_position = record_data["position"] var hunger := float(record_data["hunger"]) var last_fed_tick := int(record_data["last_fed_tick"]) var reserved_by := int(record_data["reserved_by"]) var routine_site_id := String(record_data["routine_site_id"]) var travel_target_site_id := String(record_data["travel_target_site_id"]) var saved_travel_target = record_data["travel_target_position"] var has_travel_target := bool(record_data["has_travel_target"]) var next_routine_tick := int(record_data["next_routine_tick"]) var enabled := bool(record_data["enabled"]) if ( not AnimalDefinition.is_valid_stable_id(animal_id) or not AnimalDefinition.is_valid_stable_id(animal_definition_id) or display_name.is_empty() or not AnimalDefinition.is_valid_stable_id(species_id) ): return null if not saved_position is Array or saved_position.size() != 3: return null for component in saved_position: if not is_finite(float(component)): return null if not saved_travel_target is Array or saved_travel_target.size() != 3: return null for component in saved_travel_target: if not is_finite(float(component)): return null if not is_finite(hunger) or hunger < 0.0 or hunger > 100.0: return null if last_fed_tick < NEVER_FED_TICK or reserved_by < -1 or next_routine_tick < 0: return null if reserved_by >= 0 and (hunger < FEED_THRESHOLD or has_travel_target): return null if has_travel_target != (not travel_target_site_id.is_empty()): return null if has_travel_target and (not enabled or travel_target_site_id == routine_site_id): return null var travel_target_position := Vector3( float(saved_travel_target[0]), float(saved_travel_target[1]), float(saved_travel_target[2]) ) if not has_travel_target and not travel_target_position.is_zero_approx(): return null var normalized := record_data.duplicate(true) normalized["schema_version"] = SCHEMA_VERSION normalized["animal_id"] = animal_id normalized["animal_definition_id"] = animal_definition_id normalized["display_name"] = display_name normalized["species_id"] = species_id normalized["position"] = [ float(saved_position[0]), float(saved_position[1]), float(saved_position[2]) ] normalized["hunger"] = hunger normalized["last_fed_tick"] = last_fed_tick normalized["reserved_by"] = reserved_by normalized["enabled"] = enabled normalized["can_npcs_feed"] = bool(record_data["can_npcs_feed"]) normalized["can_player_feed"] = bool(record_data["can_player_feed"]) normalized["routine_site_id"] = routine_site_id normalized["travel_target_site_id"] = travel_target_site_id normalized["travel_target_position"] = [ travel_target_position.x, travel_target_position.y, travel_target_position.z ] normalized["has_travel_target"] = has_travel_target normalized["next_routine_tick"] = next_routine_tick return AnimalStateRecord.new(normalized) func apply_definition(node: AnimalNode) -> bool: if node == null or node.animal_id != get_animal_id(): return false return ( node.animal_definition_id == get_animal_definition_id() and node.display_name == get_display_name() and node.species_id == get_species_id() and node.can_npcs_feed == can_npc_feed() and node.can_player_feed == can_player_feed_animal() ) func get_animal_id() -> StringName: return StringName(data["animal_id"]) func get_animal_definition_id() -> StringName: return StringName(data["animal_definition_id"]) func get_display_name() -> String: return String(data["display_name"]) func get_species_id() -> StringName: return StringName(data["species_id"]) func get_position() -> Vector3: var saved_position: Array = data["position"] return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2])) func get_hunger() -> float: return float(data["hunger"]) func get_last_fed_tick() -> int: return int(data["last_fed_tick"]) func get_reserved_by() -> int: return int(data["reserved_by"]) func is_enabled() -> bool: return bool(data["enabled"]) func can_npc_feed() -> bool: return bool(data["can_npcs_feed"]) func can_player_feed_animal() -> bool: return bool(data["can_player_feed"]) func get_routine_site_id() -> StringName: return StringName(data["routine_site_id"]) func get_travel_target_site_id() -> StringName: return StringName(data["travel_target_site_id"]) func get_travel_target_position() -> Vector3: var saved_position: Array = data["travel_target_position"] return Vector3(float(saved_position[0]), float(saved_position[1]), float(saved_position[2])) func has_active_travel_target() -> bool: return bool(data["has_travel_target"]) func get_next_routine_tick() -> int: return int(data["next_routine_tick"]) func needs_feed() -> bool: return is_enabled() and get_hunger() >= FEED_THRESHOLD func is_available_for(agent_id: int) -> bool: return needs_feed() and (get_reserved_by() == -1 or get_reserved_by() == agent_id) func is_routine_due(current_tick: int) -> bool: return ( is_enabled() and not needs_feed() and get_reserved_by() == -1 and not has_active_travel_target() and current_tick >= get_next_routine_tick() ) func reserve(agent_id: int) -> bool: if agent_id < 0 or not is_available_for(agent_id): return false data["reserved_by"] = agent_id changed.emit(self) return true func release(agent_id: int) -> void: if get_reserved_by() != agent_id: return data["reserved_by"] = -1 changed.emit(self) func advance_tick() -> void: if not is_enabled(): return var next_hunger := minf(get_hunger() + HUNGER_PER_TICK, 100.0) if is_equal_approx(next_hunger, get_hunger()): return data["hunger"] = next_hunger changed.emit(self) func feed(actor_id: int, current_tick: int) -> bool: if not needs_feed() or current_tick < 0: return false data["hunger"] = maxf(get_hunger() - HUNGER_RELIEF, 0.0) data["last_fed_tick"] = current_tick data["reserved_by"] = -1 changed.emit(self) fed.emit(self, actor_id) return true func begin_travel(site_id: StringName, target_position: Vector3) -> bool: if ( site_id.is_empty() or site_id == get_routine_site_id() or not target_position.is_finite() or not is_enabled() or has_active_travel_target() or needs_feed() or get_reserved_by() >= 0 ): return false data["travel_target_site_id"] = String(site_id) data["travel_target_position"] = [target_position.x, target_position.y, target_position.z] data["has_travel_target"] = true changed.emit(self) return true func complete_travel(arrival_position: Vector3, next_routine_tick: int) -> bool: if not has_active_travel_target() or not arrival_position.is_finite() or next_routine_tick < 0: return false data["position"] = [arrival_position.x, arrival_position.y, arrival_position.z] data["routine_site_id"] = data["travel_target_site_id"] data["travel_target_site_id"] = "" data["travel_target_position"] = [0.0, 0.0, 0.0] data["has_travel_target"] = false data["next_routine_tick"] = next_routine_tick changed.emit(self) return true func cancel_travel(next_routine_tick: int) -> bool: if next_routine_tick < 0: return false data["travel_target_site_id"] = "" data["travel_target_position"] = [0.0, 0.0, 0.0] data["has_travel_target"] = false data["next_routine_tick"] = next_routine_tick changed.emit(self) return true func schedule_routine_at(next_routine_tick: int) -> bool: if next_routine_tick < 0 or has_active_travel_target(): return false data["next_routine_tick"] = next_routine_tick changed.emit(self) return true func set_position(value: Vector3) -> void: if not value.is_finite() or value.is_equal_approx(get_position()): return data["position"] = [value.x, value.y, value.z] changed.emit(self) func set_hunger(value: float) -> void: if not is_finite(value): return data["hunger"] = clampf(value, 0.0, 100.0) if not needs_feed(): data["reserved_by"] = -1 changed.emit(self) func set_enabled(value: bool) -> void: data["enabled"] = value if not value: data["reserved_by"] = -1 data["travel_target_site_id"] = "" data["travel_target_position"] = [0.0, 0.0, 0.0] data["has_travel_target"] = false changed.emit(self) func to_dictionary() -> Dictionary: return data.duplicate(true) static func _migrate_legacy(record_data: Dictionary) -> Dictionary: var migrated := record_data.duplicate(true) migrated["schema_version"] = ROUTINE_SCHEMA_VERSION migrated["routine_site_id"] = "" migrated["travel_target_site_id"] = "" migrated["travel_target_position"] = [0.0, 0.0, 0.0] migrated["has_travel_target"] = false migrated["next_routine_tick"] = 0 return _migrate_definition_identity(migrated) static func _migrate_definition_identity(record_data: Dictionary) -> Dictionary: var migrated := record_data.duplicate(true) migrated["schema_version"] = SCHEMA_VERSION var species_id := String(migrated.get("species_id", "")) migrated["animal_definition_id"] = String(LEGACY_DEFINITION_BY_SPECIES.get(species_id, "")) return migrated