From c5978a9807526ba7894a9ec3aefa8c6f016eab21 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 21:22:04 +0200 Subject: [PATCH] feat: define regional world state --- simulation/regional/RegionalStateSupport.gd | 144 +++ .../regional/RegionalStateSupport.gd.uid | 1 + simulation/regional/RegionalWorldState.gd | 858 ++++++++++++++++++ simulation/regional/RegionalWorldState.gd.uid | 1 + simulation/state/DiplomaticRelationRecord.gd | 129 +++ .../state/DiplomaticRelationRecord.gd.uid | 1 + simulation/state/LocationStateRecord.gd | 110 +++ simulation/state/LocationStateRecord.gd.uid | 1 + simulation/state/MobileGroupStateRecord.gd | 406 +++++++++ .../state/MobileGroupStateRecord.gd.uid | 1 + simulation/state/PersonStateRecord.gd | 183 ++++ simulation/state/PersonStateRecord.gd.uid | 1 + simulation/state/PolityStateRecord.gd | 98 ++ simulation/state/PolityStateRecord.gd.uid | 1 + simulation/state/PopulationCohortRecord.gd | 147 +++ .../state/PopulationCohortRecord.gd.uid | 1 + simulation/state/RouteStateRecord.gd | 117 +++ simulation/state/RouteStateRecord.gd.uid | 1 + simulation/state/SettlementStateRecord.gd | 166 ++++ simulation/state/SettlementStateRecord.gd.uid | 1 + tests/unit/test_regional_world_state.gd | 334 +++++++ tests/unit/test_regional_world_state.gd.uid | 1 + 22 files changed, 2703 insertions(+) create mode 100644 simulation/regional/RegionalStateSupport.gd create mode 100644 simulation/regional/RegionalStateSupport.gd.uid create mode 100644 simulation/regional/RegionalWorldState.gd create mode 100644 simulation/regional/RegionalWorldState.gd.uid create mode 100644 simulation/state/DiplomaticRelationRecord.gd create mode 100644 simulation/state/DiplomaticRelationRecord.gd.uid create mode 100644 simulation/state/LocationStateRecord.gd create mode 100644 simulation/state/LocationStateRecord.gd.uid create mode 100644 simulation/state/MobileGroupStateRecord.gd create mode 100644 simulation/state/MobileGroupStateRecord.gd.uid create mode 100644 simulation/state/PersonStateRecord.gd create mode 100644 simulation/state/PersonStateRecord.gd.uid create mode 100644 simulation/state/PolityStateRecord.gd create mode 100644 simulation/state/PolityStateRecord.gd.uid create mode 100644 simulation/state/PopulationCohortRecord.gd create mode 100644 simulation/state/PopulationCohortRecord.gd.uid create mode 100644 simulation/state/RouteStateRecord.gd create mode 100644 simulation/state/RouteStateRecord.gd.uid create mode 100644 simulation/state/SettlementStateRecord.gd create mode 100644 simulation/state/SettlementStateRecord.gd.uid create mode 100644 tests/unit/test_regional_world_state.gd create mode 100644 tests/unit/test_regional_world_state.gd.uid diff --git a/simulation/regional/RegionalStateSupport.gd b/simulation/regional/RegionalStateSupport.gd new file mode 100644 index 0000000..896b0e2 --- /dev/null +++ b/simulation/regional/RegionalStateSupport.gd @@ -0,0 +1,144 @@ +class_name RegionalStateSupport +extends RefCounted + + +static func normalize_id_array(values: Array, allow_empty: bool = true) -> Variant: + var seen: Dictionary = {} + var normalized: Array[String] = [] + for value: Variant in values: + if not value is String and not value is StringName: + return null + var id := String(value) + if id.is_empty() or seen.has(id): + return null + seen[id] = true + normalized.append(id) + if not allow_empty and normalized.is_empty(): + return null + normalized.sort() + return normalized + + +static func normalize_history_event_ids(values: Array) -> Variant: + var seen: Dictionary = {} + var normalized: Array[int] = [] + for value: Variant in values: + if not value is int: + return null + var event_id := int(value) + if event_id < 0 or seen.has(event_id): + return null + seen[event_id] = true + normalized.append(event_id) + normalized.sort() + return normalized + + +static func normalize_amount_ledger(values: Dictionary) -> Variant: + var source_keys: Dictionary = {} + for raw_key: Variant in values: + if not raw_key is String and not raw_key is StringName: + return null + var key := String(raw_key) + if key.is_empty() or source_keys.has(key): + return null + var raw_amount: Variant = values[raw_key] + if not raw_amount is int and not raw_amount is float: + return null + var amount := float(raw_amount) + if not is_finite(amount) or amount < 0.0: + return null + source_keys[key] = raw_key + var keys: Array = source_keys.keys() + keys.sort() + var normalized: Dictionary = {} + for key: String in keys: + normalized[key] = float(values[source_keys[key]]) + return normalized + + +static func normalize_count_ledger(values: Dictionary) -> Variant: + var source_keys: Dictionary = {} + for raw_key: Variant in values: + if not raw_key is String and not raw_key is StringName: + return null + var key := String(raw_key) + if key.is_empty() or source_keys.has(key): + return null + var raw_count: Variant = values[raw_key] + if not raw_count is int or int(raw_count) <= 0: + return null + source_keys[key] = raw_key + var keys: Array = source_keys.keys() + keys.sort() + var normalized: Dictionary = {} + for key: String in keys: + normalized[key] = int(values[source_keys[key]]) + return normalized + + +static func amount_ledger_total(values: Dictionary) -> float: + var total := 0.0 + for value: Variant in values.values(): + total += float(value) + return total + + +static func normalize_address(value: Variant) -> Variant: + var address: SpatialAddress + if value is SpatialAddress: + address = value as SpatialAddress + elif value is Dictionary: + address = SpatialAddress.from_dictionary(value) + if address == null or not address.is_valid(): + return null + return address.to_dictionary() + + +static func address_from_data(value: Variant) -> SpatialAddress: + if not value is Dictionary: + return null + return SpatialAddress.from_dictionary(value) + + +static func address_matches( + value: Variant, expected_world_id: StringName, expected_location_id: StringName +) -> bool: + var address := address_from_data(value) + return ( + address != null + and address.get_world_id() == expected_world_id + and address.get_location_id() == expected_location_id + ) + + +static func copy_string_array(values: Variant) -> Array[StringName]: + var copied: Array[StringName] = [] + if not values is Array: + return copied + for value: Variant in values: + copied.append(StringName(value)) + return copied + + +static func copy_int_array(values: Variant) -> Array[int]: + var copied: Array[int] = [] + if not values is Array: + return copied + for value: Variant in values: + copied.append(int(value)) + return copied + + +static func pair_key(first_id: StringName, second_id: StringName) -> String: + var first := String(first_id) + var second := String(second_id) + return "%s\u001f%s" % [first, second] if first < second else "%s\u001f%s" % [second, first] + + +static func is_non_empty_id(value: StringName) -> bool: + return not value.is_empty() + + +static func is_valid_tick(value: int, allow_unset: bool = false) -> bool: + return value >= (-1 if allow_unset else 0) diff --git a/simulation/regional/RegionalStateSupport.gd.uid b/simulation/regional/RegionalStateSupport.gd.uid new file mode 100644 index 0000000..ea8ab05 --- /dev/null +++ b/simulation/regional/RegionalStateSupport.gd.uid @@ -0,0 +1 @@ +uid://c041d4ms34euf diff --git a/simulation/regional/RegionalWorldState.gd b/simulation/regional/RegionalWorldState.gd new file mode 100644 index 0000000..2d1f37c --- /dev/null +++ b/simulation/regional/RegionalWorldState.gd @@ -0,0 +1,858 @@ +class_name RegionalWorldState +extends RefCounted + +const SCHEMA_VERSION := 1 + +var _world_id: StringName +var _locations: Dictionary = {} +var _settlements: Dictionary = {} +var _routes: Dictionary = {} +var _mobile_groups: Dictionary = {} +var _persons: Dictionary = {} +var _cohorts: Dictionary = {} +var _polities: Dictionary = {} +var _diplomatic_relations: Dictionary = {} +var _diplomacy_by_pair: Dictionary = {} +var _route_ids_by_location: Dictionary = {} +var _group_ids_by_location: Dictionary = {} +var _group_ids_by_route: Dictionary = {} +var _person_ids_by_location: Dictionary = {} +var _person_ids_by_group: Dictionary = {} +var _cohort_ids_by_location: Dictionary = {} +var _cohort_ids_by_group: Dictionary = {} +var _settlement_ids_by_location: Dictionary = {} +var _global_entity_types: Dictionary = {} +var _last_validation_errors: Array[String] = [] + + +static func create( + world_id: StringName, + locations: Array[LocationStateRecord], + settlements: Array[SettlementStateRecord], + routes: Array[RouteStateRecord], + mobile_groups: Array[MobileGroupStateRecord], + persons: Array[PersonStateRecord], + cohorts: Array[PopulationCohortRecord], + polities: Array[PolityStateRecord], + diplomatic_relations: Array[DiplomaticRelationRecord] +) -> RegionalWorldState: + var state := RegionalWorldState.new() + return ( + state + if state.replace_all( + world_id, + locations, + settlements, + routes, + mobile_groups, + persons, + cohorts, + polities, + diplomatic_relations + ) + else null + ) + + +static func from_dictionary(record_data: Dictionary) -> RegionalWorldState: + var state := RegionalWorldState.new() + return state if state.restore_from_dictionary(record_data) else null + + +func replace_all( + world_id: StringName, + locations: Array[LocationStateRecord], + settlements: Array[SettlementStateRecord], + routes: Array[RouteStateRecord], + mobile_groups: Array[MobileGroupStateRecord], + persons: Array[PersonStateRecord], + cohorts: Array[PopulationCohortRecord], + polities: Array[PolityStateRecord], + diplomatic_relations: Array[DiplomaticRelationRecord] +) -> bool: + var candidate := RegionalWorldState.new() + candidate._world_id = world_id + if world_id.is_empty(): + candidate._add_error("world_id must be non-empty") + for source: LocationStateRecord in locations: + var record := ( + LocationStateRecord.from_dictionary(source.to_dictionary()) if source != null else null + ) + if record == null: + candidate._add_error("invalid location record") + elif not candidate._insert_location(record): + pass + for source: SettlementStateRecord in settlements: + var record := ( + SettlementStateRecord.from_dictionary(source.to_dictionary()) + if source != null + else null + ) + if record == null: + candidate._add_error("invalid settlement record") + elif not candidate._insert_settlement(record): + pass + for source: RouteStateRecord in routes: + var record := ( + RouteStateRecord.from_dictionary(source.to_dictionary()) if source != null else null + ) + if record == null: + candidate._add_error("invalid route record") + elif not candidate._insert_route(record): + pass + for source: MobileGroupStateRecord in mobile_groups: + var record := ( + MobileGroupStateRecord.from_dictionary(source.to_dictionary()) + if source != null + else null + ) + if record == null: + candidate._add_error("invalid mobile-group record") + elif not candidate._insert_mobile_group(record): + pass + for source: PersonStateRecord in persons: + var record := ( + PersonStateRecord.from_dictionary(source.to_dictionary()) if source != null else null + ) + if record == null: + candidate._add_error("invalid person record") + elif not candidate._insert_person(record): + pass + for source: PopulationCohortRecord in cohorts: + var record := ( + PopulationCohortRecord.from_dictionary(source.to_dictionary()) + if source != null + else null + ) + if record == null: + candidate._add_error("invalid population-cohort record") + elif not candidate._insert_cohort(record): + pass + for source: PolityStateRecord in polities: + var record := ( + PolityStateRecord.from_dictionary(source.to_dictionary()) if source != null else null + ) + if record == null: + candidate._add_error("invalid polity record") + elif not candidate._insert_polity(record): + pass + for source: DiplomaticRelationRecord in diplomatic_relations: + var record := ( + DiplomaticRelationRecord.from_dictionary(source.to_dictionary()) + if source != null + else null + ) + if record == null: + candidate._add_error("invalid diplomatic-relation record") + elif not candidate._insert_diplomatic_relation(record): + pass + candidate._validate_cross_references() + if not candidate._last_validation_errors.is_empty(): + candidate._last_validation_errors.sort() + _last_validation_errors = candidate._last_validation_errors.duplicate() + return false + candidate._rebuild_membership_indexes() + _adopt(candidate) + return true + + +func restore_from_dictionary(record_data: Dictionary) -> bool: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return _reject("unsupported regional-world schema version") + if not ( + record_data + . has_all( + [ + "world_id", + "locations", + "settlements", + "routes", + "mobile_groups", + "persons", + "population_cohorts", + "polities", + "diplomatic_relations", + ] + ) + ): + return _reject("regional-world save is missing required fields") + for field: String in [ + "locations", + "settlements", + "routes", + "mobile_groups", + "persons", + "population_cohorts", + "polities", + "diplomatic_relations", + ]: + if not record_data[field] is Array: + return _reject("regional-world field '%s' must be an array" % field) + var locations: Array[LocationStateRecord] = [] + for saved_record: Variant in record_data["locations"]: + if not saved_record is Dictionary: + return _reject("location entry must be a dictionary") + var record := LocationStateRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid location entry") + locations.append(record) + var settlements: Array[SettlementStateRecord] = [] + for saved_record: Variant in record_data["settlements"]: + if not saved_record is Dictionary: + return _reject("settlement entry must be a dictionary") + var record := SettlementStateRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid settlement entry") + settlements.append(record) + var routes: Array[RouteStateRecord] = [] + for saved_record: Variant in record_data["routes"]: + if not saved_record is Dictionary: + return _reject("route entry must be a dictionary") + var record := RouteStateRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid route entry") + routes.append(record) + var mobile_groups: Array[MobileGroupStateRecord] = [] + for saved_record: Variant in record_data["mobile_groups"]: + if not saved_record is Dictionary: + return _reject("mobile-group entry must be a dictionary") + var record := MobileGroupStateRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid mobile-group entry") + mobile_groups.append(record) + var persons: Array[PersonStateRecord] = [] + for saved_record: Variant in record_data["persons"]: + if not saved_record is Dictionary: + return _reject("person entry must be a dictionary") + var record := PersonStateRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid person entry") + persons.append(record) + var cohorts: Array[PopulationCohortRecord] = [] + for saved_record: Variant in record_data["population_cohorts"]: + if not saved_record is Dictionary: + return _reject("population-cohort entry must be a dictionary") + var record := PopulationCohortRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid population-cohort entry") + cohorts.append(record) + var polities: Array[PolityStateRecord] = [] + for saved_record: Variant in record_data["polities"]: + if not saved_record is Dictionary: + return _reject("polity entry must be a dictionary") + var record := PolityStateRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid polity entry") + polities.append(record) + var relations: Array[DiplomaticRelationRecord] = [] + for saved_record: Variant in record_data["diplomatic_relations"]: + if not saved_record is Dictionary: + return _reject("diplomatic-relation entry must be a dictionary") + var record := DiplomaticRelationRecord.from_dictionary(saved_record) + if record == null: + return _reject("invalid diplomatic-relation entry") + relations.append(record) + return replace_all( + StringName(record_data["world_id"]), + locations, + settlements, + routes, + mobile_groups, + persons, + cohorts, + polities, + relations + ) + + +func get_world_id() -> StringName: + return _world_id + + +func has_global_id(entity_id: StringName) -> bool: + return _global_entity_types.has(entity_id) + + +func get_entity_type(entity_id: StringName) -> StringName: + return StringName(_global_entity_types.get(entity_id, &"")) + + +func get_location(location_id: StringName) -> LocationStateRecord: + return _locations.get(location_id) as LocationStateRecord + + +func get_settlement(settlement_id: StringName) -> SettlementStateRecord: + return _settlements.get(settlement_id) as SettlementStateRecord + + +func get_route(route_id: StringName) -> RouteStateRecord: + return _routes.get(route_id) as RouteStateRecord + + +func get_mobile_group(group_id: StringName) -> MobileGroupStateRecord: + return _mobile_groups.get(group_id) as MobileGroupStateRecord + + +func get_person(person_id: StringName) -> PersonStateRecord: + return _persons.get(person_id) as PersonStateRecord + + +func get_population_cohort(cohort_id: StringName) -> PopulationCohortRecord: + return _cohorts.get(cohort_id) as PopulationCohortRecord + + +func get_polity(polity_id: StringName) -> PolityStateRecord: + return _polities.get(polity_id) as PolityStateRecord + + +func get_diplomatic_relation(relation_id: StringName) -> DiplomaticRelationRecord: + return _diplomatic_relations.get(relation_id) as DiplomaticRelationRecord + + +func get_diplomatic_relation_between( + first_polity_id: StringName, second_polity_id: StringName +) -> DiplomaticRelationRecord: + var pair_key := RegionalStateSupport.pair_key(first_polity_id, second_polity_id) + var relation_id: StringName = _diplomacy_by_pair.get(pair_key, &"") + return get_diplomatic_relation(relation_id) + + +func get_all_locations() -> Array[LocationStateRecord]: + var records: Array[LocationStateRecord] = [] + for id: StringName in _sorted_ids(_locations): + records.append(get_location(id)) + return records + + +func get_all_settlements() -> Array[SettlementStateRecord]: + var records: Array[SettlementStateRecord] = [] + for id: StringName in _sorted_ids(_settlements): + records.append(get_settlement(id)) + return records + + +func get_all_routes() -> Array[RouteStateRecord]: + var records: Array[RouteStateRecord] = [] + for id: StringName in _sorted_ids(_routes): + records.append(get_route(id)) + return records + + +func get_all_mobile_groups() -> Array[MobileGroupStateRecord]: + var records: Array[MobileGroupStateRecord] = [] + for id: StringName in _sorted_ids(_mobile_groups): + records.append(get_mobile_group(id)) + return records + + +func get_all_persons() -> Array[PersonStateRecord]: + var records: Array[PersonStateRecord] = [] + for id: StringName in _sorted_ids(_persons): + records.append(get_person(id)) + return records + + +func get_all_population_cohorts() -> Array[PopulationCohortRecord]: + var records: Array[PopulationCohortRecord] = [] + for id: StringName in _sorted_ids(_cohorts): + records.append(get_population_cohort(id)) + return records + + +func get_all_polities() -> Array[PolityStateRecord]: + var records: Array[PolityStateRecord] = [] + for id: StringName in _sorted_ids(_polities): + records.append(get_polity(id)) + return records + + +func get_all_diplomatic_relations() -> Array[DiplomaticRelationRecord]: + var records: Array[DiplomaticRelationRecord] = [] + for id: StringName in _sorted_ids(_diplomatic_relations): + records.append(get_diplomatic_relation(id)) + return records + + +func get_routes_for_location(location_id: StringName) -> Array[RouteStateRecord]: + var records: Array[RouteStateRecord] = [] + for route_id: StringName in _index_ids(_route_ids_by_location, location_id): + records.append(get_route(route_id)) + return records + + +func get_mobile_groups_at_location(location_id: StringName) -> Array[MobileGroupStateRecord]: + var records: Array[MobileGroupStateRecord] = [] + for group_id: StringName in _index_ids(_group_ids_by_location, location_id): + records.append(get_mobile_group(group_id)) + return records + + +func get_mobile_groups_on_route(route_id: StringName) -> Array[MobileGroupStateRecord]: + var records: Array[MobileGroupStateRecord] = [] + for group_id: StringName in _index_ids(_group_ids_by_route, route_id): + records.append(get_mobile_group(group_id)) + return records + + +func get_persons_at_location(location_id: StringName) -> Array[PersonStateRecord]: + var records: Array[PersonStateRecord] = [] + for person_id: StringName in _index_ids(_person_ids_by_location, location_id): + records.append(get_person(person_id)) + return records + + +func get_persons_in_group(group_id: StringName) -> Array[PersonStateRecord]: + var records: Array[PersonStateRecord] = [] + for person_id: StringName in _index_ids(_person_ids_by_group, group_id): + records.append(get_person(person_id)) + return records + + +func get_cohorts_at_location(location_id: StringName) -> Array[PopulationCohortRecord]: + var records: Array[PopulationCohortRecord] = [] + for cohort_id: StringName in _index_ids(_cohort_ids_by_location, location_id): + records.append(get_population_cohort(cohort_id)) + return records + + +func get_cohorts_in_group(group_id: StringName) -> Array[PopulationCohortRecord]: + var records: Array[PopulationCohortRecord] = [] + for cohort_id: StringName in _index_ids(_cohort_ids_by_group, group_id): + records.append(get_population_cohort(cohort_id)) + return records + + +func get_settlements_at_location(location_id: StringName) -> Array[SettlementStateRecord]: + var records: Array[SettlementStateRecord] = [] + for settlement_id: StringName in _index_ids(_settlement_ids_by_location, location_id): + records.append(get_settlement(settlement_id)) + return records + + +func get_last_validation_errors() -> Array[String]: + return _last_validation_errors.duplicate() + + +func replace_settlement_and_mobile_group( + settlement: SettlementStateRecord, mobile_group: MobileGroupStateRecord +) -> bool: + if ( + settlement == null + or mobile_group == null + or not _settlements.has(settlement.get_settlement_id()) + or not _mobile_groups.has(mobile_group.get_group_id()) + ): + return false + var settlements := get_all_settlements() + for index in range(settlements.size()): + if settlements[index].get_settlement_id() == settlement.get_settlement_id(): + settlements[index] = settlement + break + var mobile_groups := get_all_mobile_groups() + for index in range(mobile_groups.size()): + if mobile_groups[index].get_group_id() == mobile_group.get_group_id(): + mobile_groups[index] = mobile_group + break + return replace_all( + _world_id, + get_all_locations(), + settlements, + get_all_routes(), + mobile_groups, + get_all_persons(), + get_all_population_cohorts(), + get_all_polities(), + get_all_diplomatic_relations() + ) + + +func to_dictionary() -> Dictionary: + var locations: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_locations): + locations.append(get_location(id).to_dictionary()) + var settlements: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_settlements): + settlements.append(get_settlement(id).to_dictionary()) + var routes: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_routes): + routes.append(get_route(id).to_dictionary()) + var mobile_groups: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_mobile_groups): + mobile_groups.append(get_mobile_group(id).to_dictionary()) + var persons: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_persons): + persons.append(get_person(id).to_dictionary()) + var cohorts: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_cohorts): + cohorts.append(get_population_cohort(id).to_dictionary()) + var polities: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_polities): + polities.append(get_polity(id).to_dictionary()) + var diplomatic_relations: Array[Dictionary] = [] + for id: StringName in _sorted_ids(_diplomatic_relations): + diplomatic_relations.append(get_diplomatic_relation(id).to_dictionary()) + return { + "schema_version": SCHEMA_VERSION, + "world_id": String(_world_id), + "locations": locations, + "settlements": settlements, + "routes": routes, + "mobile_groups": mobile_groups, + "persons": persons, + "population_cohorts": cohorts, + "polities": polities, + "diplomatic_relations": diplomatic_relations, + } + + +func checksum() -> String: + return JSON.stringify(to_dictionary()).sha256_text() + + +func _insert_location(record: LocationStateRecord) -> bool: + var id := record.get_location_id() + if not _claim_global_id(id, &"location"): + return false + _locations[id] = record + return true + + +func _insert_settlement(record: SettlementStateRecord) -> bool: + var id := record.get_settlement_id() + if not _claim_global_id(id, &"settlement"): + return false + _settlements[id] = record + return true + + +func _insert_route(record: RouteStateRecord) -> bool: + var id := record.get_route_id() + if not _claim_global_id(id, &"route"): + return false + _routes[id] = record + return true + + +func _insert_mobile_group(record: MobileGroupStateRecord) -> bool: + var id := record.get_group_id() + if not _claim_global_id(id, &"mobile_group"): + return false + _mobile_groups[id] = record + return true + + +func _insert_person(record: PersonStateRecord) -> bool: + var id := record.get_person_id() + if not _claim_global_id(id, &"person"): + return false + _persons[id] = record + return true + + +func _insert_cohort(record: PopulationCohortRecord) -> bool: + var id := record.get_cohort_id() + if not _claim_global_id(id, &"population_cohort"): + return false + _cohorts[id] = record + return true + + +func _insert_polity(record: PolityStateRecord) -> bool: + var id := record.get_polity_id() + if not _claim_global_id(id, &"polity"): + return false + _polities[id] = record + return true + + +func _insert_diplomatic_relation(record: DiplomaticRelationRecord) -> bool: + var id := record.get_relation_id() + if not _claim_global_id(id, &"diplomatic_relation"): + return false + var pair_key := record.get_pair_key() + if _diplomacy_by_pair.has(pair_key): + _add_error("duplicate diplomatic relation for pair '%s'" % pair_key) + return false + _diplomatic_relations[id] = record + _diplomacy_by_pair[pair_key] = id + return true + + +func _claim_global_id(id: StringName, entity_type: StringName) -> bool: + if _global_entity_types.has(id): + _add_error( + "duplicate global ID '%s' (%s and %s)" % [id, _global_entity_types[id], entity_type] + ) + return false + _global_entity_types[id] = entity_type + return true + + +func _validate_cross_references() -> void: + for location: LocationStateRecord in _locations.values(): + if location.get_world_id() != _world_id: + _add_error("location '%s' belongs to another world" % location.get_location_id()) + for settlement: SettlementStateRecord in _settlements.values(): + _require_id( + _locations, + settlement.get_location_id(), + "settlement location", + settlement.get_settlement_id() + ) + _require_id( + _polities, + settlement.get_polity_id(), + "settlement polity", + settlement.get_settlement_id() + ) + for route: RouteStateRecord in _routes.values(): + _require_id( + _locations, route.get_from_location_id(), "route endpoint", route.get_route_id() + ) + _require_id(_locations, route.get_to_location_id(), "route endpoint", route.get_route_id()) + for group: MobileGroupStateRecord in _mobile_groups.values(): + _validate_mobile_group(group) + for person: PersonStateRecord in _persons.values(): + _validate_person(person) + for cohort: PopulationCohortRecord in _cohorts.values(): + _validate_cohort(cohort) + for polity: PolityStateRecord in _polities.values(): + _validate_polity(polity) + for relation: DiplomaticRelationRecord in _diplomatic_relations.values(): + _require_id( + _polities, + relation.get_first_polity_id(), + "diplomatic polity", + relation.get_relation_id() + ) + _require_id( + _polities, + relation.get_second_polity_id(), + "diplomatic polity", + relation.get_relation_id() + ) + _validate_group_membership_ownership() + + +func _validate_mobile_group(group: MobileGroupStateRecord) -> void: + _require_id(_polities, group.get_polity_id(), "mobile-group polity", group.get_group_id()) + if group.is_at_location(): + var address := group.get_location_address() + if address == null or not _locations.has(address.get_location_id()): + _add_error("mobile group '%s' has an orphan location" % group.get_group_id()) + elif not RegionalStateSupport.address_matches( + group.data["location_address"], _world_id, address.get_location_id() + ): + _add_error( + "mobile group '%s' has a cross-world location address" % group.get_group_id() + ) + return + var route := get_route(group.get_route_id()) + if route == null: + _add_error("mobile group '%s' has an orphan route" % group.get_group_id()) + return + if not route.connects(group.get_departure_location_id(), group.get_arrival_location_id()): + _add_error( + "mobile group '%s' uses invalid route direction/endpoints" % group.get_group_id() + ) + if group.get_arrival_tick() - group.get_departure_tick() != route.get_travel_ticks(): + _add_error("mobile group '%s' journey duration does not match route" % group.get_group_id()) + + +func _validate_person(person: PersonStateRecord) -> void: + _require_id(_polities, person.get_polity_id(), "person polity", person.get_person_id()) + _require_id(_locations, person.get_home_location_id(), "person home", person.get_person_id()) + if person.is_at_location(): + var address := person.get_location_address() + if address == null or not _locations.has(address.get_location_id()): + _add_error("person '%s' has an orphan location" % person.get_person_id()) + elif not RegionalStateSupport.address_matches( + person.data["location_address"], _world_id, address.get_location_id() + ): + _add_error("person '%s' has a cross-world location address" % person.get_person_id()) + else: + _require_id( + _mobile_groups, + person.get_mobile_group_id(), + "person mobile group", + person.get_person_id() + ) + + +func _validate_cohort(cohort: PopulationCohortRecord) -> void: + _require_id(_polities, cohort.get_polity_id(), "cohort polity", cohort.get_cohort_id()) + if cohort.is_at_location(): + _require_id(_locations, cohort.get_location_id(), "cohort location", cohort.get_cohort_id()) + else: + _require_id( + _mobile_groups, + cohort.get_mobile_group_id(), + "cohort mobile group", + cohort.get_cohort_id() + ) + + +func _validate_polity(polity: PolityStateRecord) -> void: + _require_id( + _locations, polity.get_capital_location_id(), "polity capital", polity.get_polity_id() + ) + for settlement_id: StringName in polity.get_settlement_ids(): + var settlement := get_settlement(settlement_id) + if settlement == null: + _add_error( + "polity '%s' lists orphan settlement '%s'" % [polity.get_polity_id(), settlement_id] + ) + elif settlement.get_polity_id() != polity.get_polity_id(): + _add_error("settlement '%s' belongs to another polity" % settlement_id) + for settlement: SettlementStateRecord in _settlements.values(): + if ( + settlement.get_polity_id() == polity.get_polity_id() + and settlement.get_settlement_id() not in polity.get_settlement_ids() + ): + _add_error( + ( + "polity '%s' omits settlement '%s'" + % [polity.get_polity_id(), settlement.get_settlement_id()] + ) + ) + + +func _validate_group_membership_ownership() -> void: + var person_owner: Dictionary = {} + var cohort_owner: Dictionary = {} + for group: MobileGroupStateRecord in _mobile_groups.values(): + for person_id: StringName in group.get_detailed_member_ids(): + var person := get_person(person_id) + if person == null: + _add_error( + "mobile group '%s' lists orphan person '%s'" % [group.get_group_id(), person_id] + ) + continue + if person_owner.has(person_id): + _add_error("person '%s' is listed by multiple mobile groups" % person_id) + person_owner[person_id] = group.get_group_id() + if person.get_mobile_group_id() != group.get_group_id(): + _add_error("person '%s' has non-reciprocal mobile-group membership" % person_id) + if person.get_polity_id() != group.get_polity_id(): + _add_error("person '%s' and mobile group have different polities" % person_id) + for cohort_key: Variant in group.get_cohort_counts(): + var cohort_id := StringName(cohort_key) + var cohort := get_population_cohort(cohort_id) + if cohort == null: + _add_error( + "mobile group '%s' lists orphan cohort '%s'" % [group.get_group_id(), cohort_id] + ) + continue + if cohort_owner.has(cohort_id): + _add_error("cohort '%s' is listed by multiple mobile groups" % cohort_id) + cohort_owner[cohort_id] = group.get_group_id() + if cohort.get_mobile_group_id() != group.get_group_id(): + _add_error("cohort '%s' has non-reciprocal mobile-group membership" % cohort_id) + if int(group.get_cohort_counts()[cohort_key]) != cohort.get_count(): + _add_error("cohort '%s' count disagrees with its mobile group" % cohort_id) + if cohort.get_polity_id() != group.get_polity_id(): + _add_error("cohort '%s' and mobile group have different polities" % cohort_id) + for person: PersonStateRecord in _persons.values(): + if person.is_in_mobile_group() and not person_owner.has(person.get_person_id()): + _add_error("person '%s' is omitted by its mobile group" % person.get_person_id()) + for cohort: PopulationCohortRecord in _cohorts.values(): + if cohort.is_in_mobile_group() and not cohort_owner.has(cohort.get_cohort_id()): + _add_error("cohort '%s' is omitted by its mobile group" % cohort.get_cohort_id()) + + +func _rebuild_membership_indexes() -> void: + for route: RouteStateRecord in _routes.values(): + _index_append(_route_ids_by_location, route.get_from_location_id(), route.get_route_id()) + _index_append(_route_ids_by_location, route.get_to_location_id(), route.get_route_id()) + for settlement: SettlementStateRecord in _settlements.values(): + _index_append( + _settlement_ids_by_location, + settlement.get_location_id(), + settlement.get_settlement_id() + ) + for group: MobileGroupStateRecord in _mobile_groups.values(): + if group.is_at_location(): + _index_append( + _group_ids_by_location, group.get_current_location_id(), group.get_group_id() + ) + else: + _index_append(_group_ids_by_route, group.get_route_id(), group.get_group_id()) + for person: PersonStateRecord in _persons.values(): + if person.is_at_location(): + _index_append( + _person_ids_by_location, + person.get_location_address().get_location_id(), + person.get_person_id() + ) + else: + _index_append( + _person_ids_by_group, person.get_mobile_group_id(), person.get_person_id() + ) + for cohort: PopulationCohortRecord in _cohorts.values(): + if cohort.is_at_location(): + _index_append(_cohort_ids_by_location, cohort.get_location_id(), cohort.get_cohort_id()) + else: + _index_append( + _cohort_ids_by_group, cohort.get_mobile_group_id(), cohort.get_cohort_id() + ) + + +func _require_id(index: Dictionary, id: StringName, label: String, owner_id: StringName) -> void: + if not index.has(id): + _add_error("%s '%s' references missing ID '%s'" % [label, owner_id, id]) + + +func _index_append(index: Dictionary, key: StringName, id: StringName) -> void: + var ids: Array[StringName] = index.get(key, [] as Array[StringName]) + ids.append(id) + ids.sort_custom(_compare_ids) + index[key] = ids + + +func _index_ids(index: Dictionary, key: StringName) -> Array[StringName]: + var ids: Array[StringName] = index.get(key, [] as Array[StringName]) + return ids.duplicate() + + +func _sorted_ids(index: Dictionary) -> Array[StringName]: + var ids: Array[StringName] = [] + for id: Variant in index: + ids.append(StringName(id)) + ids.sort_custom(_compare_ids) + return ids + + +static func _compare_ids(first: StringName, second: StringName) -> bool: + return String(first) < String(second) + + +func _reject(message: String) -> bool: + _last_validation_errors = [message] + return false + + +func _add_error(message: String) -> void: + _last_validation_errors.append(message) + + +func _adopt(source: RegionalWorldState) -> void: + _world_id = source._world_id + _locations = source._locations + _settlements = source._settlements + _routes = source._routes + _mobile_groups = source._mobile_groups + _persons = source._persons + _cohorts = source._cohorts + _polities = source._polities + _diplomatic_relations = source._diplomatic_relations + _diplomacy_by_pair = source._diplomacy_by_pair + _route_ids_by_location = source._route_ids_by_location + _group_ids_by_location = source._group_ids_by_location + _group_ids_by_route = source._group_ids_by_route + _person_ids_by_location = source._person_ids_by_location + _person_ids_by_group = source._person_ids_by_group + _cohort_ids_by_location = source._cohort_ids_by_location + _cohort_ids_by_group = source._cohort_ids_by_group + _settlement_ids_by_location = source._settlement_ids_by_location + _global_entity_types = source._global_entity_types + _last_validation_errors.clear() diff --git a/simulation/regional/RegionalWorldState.gd.uid b/simulation/regional/RegionalWorldState.gd.uid new file mode 100644 index 0000000..79a5213 --- /dev/null +++ b/simulation/regional/RegionalWorldState.gd.uid @@ -0,0 +1 @@ +uid://dkjcccwgeb82g diff --git a/simulation/state/DiplomaticRelationRecord.gd b/simulation/state/DiplomaticRelationRecord.gd new file mode 100644 index 0000000..0f5254d --- /dev/null +++ b/simulation/state/DiplomaticRelationRecord.gd @@ -0,0 +1,129 @@ +class_name DiplomaticRelationRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + relation_id: StringName, + first_polity_id: StringName, + second_polity_id: StringName, + stance_id: StringName, + trust: float, + started_tick: int, + history_event_ids: Array = [] +) -> DiplomaticRelationRecord: + var first := String(first_polity_id) + var second := String(second_polity_id) + if second < first: + var swapped := first + first = second + second = swapped + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + if normalized_history == null: + return null + var record := ( + DiplomaticRelationRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "relation_id": String(relation_id), + "first_polity_id": first, + "second_polity_id": second, + "stance_id": String(stance_id), + "trust": trust, + "started_tick": started_tick, + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> DiplomaticRelationRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "relation_id", + "first_polity_id", + "second_polity_id", + "stance_id", + "trust", + "started_tick", + "history_event_ids", + ] + ) + ): + return null + if not record_data["history_event_ids"] is Array: + return null + return create( + StringName(record_data["relation_id"]), + StringName(record_data["first_polity_id"]), + StringName(record_data["second_polity_id"]), + StringName(record_data["stance_id"]), + float(record_data["trust"]), + int(record_data["started_tick"]), + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_relation_id().is_empty() + and not get_first_polity_id().is_empty() + and not get_second_polity_id().is_empty() + and String(get_first_polity_id()) < String(get_second_polity_id()) + and not get_stance_id().is_empty() + and is_finite(get_trust()) + and get_trust() >= -1.0 + and get_trust() <= 1.0 + and get_started_tick() >= 0 + ) + + +func get_relation_id() -> StringName: + return StringName(data["relation_id"]) + + +func get_first_polity_id() -> StringName: + return StringName(data["first_polity_id"]) + + +func get_second_polity_id() -> StringName: + return StringName(data["second_polity_id"]) + + +func get_stance_id() -> StringName: + return StringName(data["stance_id"]) + + +func get_trust() -> float: + return float(data["trust"]) + + +func get_started_tick() -> int: + return int(data["started_tick"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func get_pair_key() -> String: + return RegionalStateSupport.pair_key(get_first_polity_id(), get_second_polity_id()) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/DiplomaticRelationRecord.gd.uid b/simulation/state/DiplomaticRelationRecord.gd.uid new file mode 100644 index 0000000..cc06135 --- /dev/null +++ b/simulation/state/DiplomaticRelationRecord.gd.uid @@ -0,0 +1 @@ +uid://cd7j3wipdurdt diff --git a/simulation/state/LocationStateRecord.gd b/simulation/state/LocationStateRecord.gd new file mode 100644 index 0000000..6342117 --- /dev/null +++ b/simulation/state/LocationStateRecord.gd @@ -0,0 +1,110 @@ +class_name LocationStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + location_id: StringName, + world_id: StringName, + location_type: StringName, + display_name: String, + address: SpatialAddress, + history_event_ids: Array = [] +) -> LocationStateRecord: + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + var normalized_address: Variant = RegionalStateSupport.normalize_address(address) + if normalized_history == null or normalized_address == null: + return null + var record := ( + LocationStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "location_id": String(location_id), + "world_id": String(world_id), + "location_type": String(location_type), + "display_name": display_name, + "address": normalized_address, + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> LocationStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "location_id", + "world_id", + "location_type", + "display_name", + "address", + "history_event_ids", + ] + ) + ): + return null + if not record_data["address"] is Dictionary or not record_data["history_event_ids"] is Array: + return null + return create( + StringName(record_data["location_id"]), + StringName(record_data["world_id"]), + StringName(record_data["location_type"]), + String(record_data["display_name"]), + SpatialAddress.from_dictionary(record_data["address"]), + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_location_id().is_empty() + and not get_world_id().is_empty() + and not get_location_type().is_empty() + and not get_display_name().is_empty() + and RegionalStateSupport.address_matches( + data.get("address"), get_world_id(), get_location_id() + ) + ) + + +func get_location_id() -> StringName: + return StringName(data["location_id"]) + + +func get_world_id() -> StringName: + return StringName(data["world_id"]) + + +func get_location_type() -> StringName: + return StringName(data["location_type"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_address() -> SpatialAddress: + return RegionalStateSupport.address_from_data(data["address"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/LocationStateRecord.gd.uid b/simulation/state/LocationStateRecord.gd.uid new file mode 100644 index 0000000..f496784 --- /dev/null +++ b/simulation/state/LocationStateRecord.gd.uid @@ -0,0 +1 @@ +uid://cu05dxii51mnv diff --git a/simulation/state/MobileGroupStateRecord.gd b/simulation/state/MobileGroupStateRecord.gd new file mode 100644 index 0000000..6eff12a --- /dev/null +++ b/simulation/state/MobileGroupStateRecord.gd @@ -0,0 +1,406 @@ +class_name MobileGroupStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create_at_location( + group_id: StringName, + group_type: StringName, + display_name: String, + polity_id: StringName, + address: SpatialAddress, + detailed_member_ids: Array = [], + cohort_counts: Dictionary = {}, + cargo_ledger: Dictionary = {}, + cargo_capacity: float = 0.0, + last_cargo_event_id: int = -1, + history_event_ids: Array = [] +) -> MobileGroupStateRecord: + return _create( + group_id, + group_type, + display_name, + polity_id, + address, + &"", + &"", + &"", + -1, + -1, + detailed_member_ids, + cohort_counts, + cargo_ledger, + cargo_capacity, + last_cargo_event_id, + history_event_ids + ) + + +static func create_on_route( + group_id: StringName, + group_type: StringName, + display_name: String, + polity_id: StringName, + route_id: StringName, + departure_location_id: StringName, + arrival_location_id: StringName, + departure_tick: int, + arrival_tick: int, + detailed_member_ids: Array = [], + cohort_counts: Dictionary = {}, + cargo_ledger: Dictionary = {}, + cargo_capacity: float = 0.0, + last_cargo_event_id: int = -1, + history_event_ids: Array = [] +) -> MobileGroupStateRecord: + return _create( + group_id, + group_type, + display_name, + polity_id, + null, + route_id, + departure_location_id, + arrival_location_id, + departure_tick, + arrival_tick, + detailed_member_ids, + cohort_counts, + cargo_ledger, + cargo_capacity, + last_cargo_event_id, + history_event_ids + ) + + +static func _create( + group_id: StringName, + group_type: StringName, + display_name: String, + polity_id: StringName, + address: Variant, + route_id: StringName, + departure_location_id: StringName, + arrival_location_id: StringName, + departure_tick: int, + arrival_tick: int, + detailed_member_ids: Array, + cohort_counts: Dictionary, + cargo_ledger: Dictionary, + cargo_capacity: float, + last_cargo_event_id: int, + history_event_ids: Array +) -> MobileGroupStateRecord: + var normalized_members: Variant = RegionalStateSupport.normalize_id_array(detailed_member_ids) + var normalized_cohorts: Variant = RegionalStateSupport.normalize_count_ledger(cohort_counts) + var normalized_cargo: Variant = RegionalStateSupport.normalize_amount_ledger(cargo_ledger) + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + var normalized_address: Variant = null + if address != null: + normalized_address = RegionalStateSupport.normalize_address(address) + if ( + normalized_members == null + or normalized_cohorts == null + or normalized_cargo == null + or normalized_history == null + or (address != null and normalized_address == null) + ): + return null + var record := ( + MobileGroupStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "group_id": String(group_id), + "group_type": String(group_type), + "display_name": display_name, + "polity_id": String(polity_id), + "location_address": normalized_address, + "route_id": String(route_id), + "departure_location_id": String(departure_location_id), + "arrival_location_id": String(arrival_location_id), + "departure_tick": departure_tick, + "arrival_tick": arrival_tick, + "detailed_member_ids": normalized_members, + "cohort_counts": normalized_cohorts, + "cargo_ledger": normalized_cargo, + "cargo_capacity": cargo_capacity, + "last_cargo_event_id": last_cargo_event_id, + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> MobileGroupStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "group_id", + "group_type", + "display_name", + "polity_id", + "location_address", + "route_id", + "departure_location_id", + "arrival_location_id", + "departure_tick", + "arrival_tick", + "detailed_member_ids", + "cohort_counts", + "cargo_ledger", + "cargo_capacity", + "last_cargo_event_id", + "history_event_ids", + ] + ) + ): + return null + if ( + ( + record_data["location_address"] != null + and not record_data["location_address"] is Dictionary + ) + or not record_data["detailed_member_ids"] is Array + or not record_data["cohort_counts"] is Dictionary + or not record_data["cargo_ledger"] is Dictionary + or not record_data["history_event_ids"] is Array + ): + return null + return _create( + StringName(record_data["group_id"]), + StringName(record_data["group_type"]), + String(record_data["display_name"]), + StringName(record_data["polity_id"]), + record_data["location_address"], + StringName(record_data["route_id"]), + StringName(record_data["departure_location_id"]), + StringName(record_data["arrival_location_id"]), + int(record_data["departure_tick"]), + int(record_data["arrival_tick"]), + record_data["detailed_member_ids"], + record_data["cohort_counts"], + record_data["cargo_ledger"], + float(record_data["cargo_capacity"]), + int(record_data["last_cargo_event_id"]), + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + if ( + get_group_id().is_empty() + or get_group_type().is_empty() + or get_display_name().is_empty() + or get_polity_id().is_empty() + or not is_finite(get_cargo_capacity()) + or get_cargo_capacity() < 0.0 + or RegionalStateSupport.amount_ledger_total(data["cargo_ledger"]) > get_cargo_capacity() + or get_last_cargo_event_id() < -1 + or ( + get_last_cargo_event_id() >= 0 + and get_last_cargo_event_id() not in get_history_event_ids() + ) + ): + return false + if is_at_location(): + return ( + get_route_id().is_empty() + and get_departure_location_id().is_empty() + and get_arrival_location_id().is_empty() + and get_departure_tick() == -1 + and get_arrival_tick() == -1 + ) + return ( + is_on_route() + and not get_departure_location_id().is_empty() + and not get_arrival_location_id().is_empty() + and get_departure_location_id() != get_arrival_location_id() + and get_departure_tick() >= 0 + and get_arrival_tick() > get_departure_tick() + ) + + +func is_at_location() -> bool: + return data["location_address"] is Dictionary + + +func is_on_route() -> bool: + return data["location_address"] == null and not get_route_id().is_empty() + + +func get_group_id() -> StringName: + return StringName(data["group_id"]) + + +func get_group_type() -> StringName: + return StringName(data["group_type"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_polity_id() -> StringName: + return StringName(data["polity_id"]) + + +func get_location_address() -> SpatialAddress: + return RegionalStateSupport.address_from_data(data["location_address"]) + + +func get_current_location_id() -> StringName: + var address := get_location_address() + return address.get_location_id() if address != null else &"" + + +func get_route_id() -> StringName: + return StringName(data["route_id"]) + + +func get_departure_location_id() -> StringName: + return StringName(data["departure_location_id"]) + + +func get_arrival_location_id() -> StringName: + return StringName(data["arrival_location_id"]) + + +func get_departure_tick() -> int: + return int(data["departure_tick"]) + + +func get_arrival_tick() -> int: + return int(data["arrival_tick"]) + + +func get_detailed_member_ids() -> Array[StringName]: + return RegionalStateSupport.copy_string_array(data["detailed_member_ids"]) + + +func get_cohort_counts() -> Dictionary: + return data["cohort_counts"].duplicate(true) + + +func get_cargo_ledger() -> Dictionary: + return data["cargo_ledger"].duplicate(true) + + +func get_cargo_amount(item_id: StringName) -> float: + return float(data["cargo_ledger"].get(String(item_id), 0.0)) + + +func get_total_cargo_amount() -> float: + return RegionalStateSupport.amount_ledger_total(data["cargo_ledger"]) + + +func get_cargo_capacity() -> float: + return float(data["cargo_capacity"]) + + +func get_last_cargo_event_id() -> int: + return int(data["last_cargo_event_id"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func get_headcount() -> int: + var headcount := get_detailed_member_ids().size() + for count: Variant in data["cohort_counts"].values(): + headcount += int(count) + return headcount + + +func with_location( + address: SpatialAddress, cause_event_id: int = -1 +) -> MobileGroupStateRecord: + var history_event_ids := _history_with_event(cause_event_id) + return create_at_location( + get_group_id(), + get_group_type(), + get_display_name(), + get_polity_id(), + address, + data["detailed_member_ids"], + data["cohort_counts"], + data["cargo_ledger"], + get_cargo_capacity(), + get_last_cargo_event_id(), + history_event_ids + ) + + +func with_route_journey( + route_id: StringName, + departure_location_id: StringName, + arrival_location_id: StringName, + departure_tick: int, + arrival_tick: int, + cause_event_id: int = -1 +) -> MobileGroupStateRecord: + var history_event_ids := _history_with_event(cause_event_id) + return create_on_route( + get_group_id(), + get_group_type(), + get_display_name(), + get_polity_id(), + route_id, + departure_location_id, + arrival_location_id, + departure_tick, + arrival_tick, + data["detailed_member_ids"], + data["cohort_counts"], + data["cargo_ledger"], + get_cargo_capacity(), + get_last_cargo_event_id(), + history_event_ids + ) + + +func with_cargo_ledger(cargo_ledger: Dictionary, cargo_event_id: int) -> MobileGroupStateRecord: + var history_event_ids := _history_with_event(cargo_event_id) + return _create( + get_group_id(), + get_group_type(), + get_display_name(), + get_polity_id(), + data["location_address"], + get_route_id(), + get_departure_location_id(), + get_arrival_location_id(), + get_departure_tick(), + get_arrival_tick(), + data["detailed_member_ids"], + data["cohort_counts"], + cargo_ledger, + get_cargo_capacity(), + cargo_event_id, + history_event_ids + ) + + +func _history_with_event(event_id: int) -> Array: + var history_event_ids: Array = data["history_event_ids"].duplicate() + if event_id >= 0 and event_id not in history_event_ids: + history_event_ids.append(event_id) + return history_event_ids + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/MobileGroupStateRecord.gd.uid b/simulation/state/MobileGroupStateRecord.gd.uid new file mode 100644 index 0000000..149f7e0 --- /dev/null +++ b/simulation/state/MobileGroupStateRecord.gd.uid @@ -0,0 +1 @@ +uid://jyqncykmnbfi diff --git a/simulation/state/PersonStateRecord.gd b/simulation/state/PersonStateRecord.gd new file mode 100644 index 0000000..2854ba0 --- /dev/null +++ b/simulation/state/PersonStateRecord.gd @@ -0,0 +1,183 @@ +class_name PersonStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create_at_location( + person_id: StringName, + display_name: String, + role_id: StringName, + polity_id: StringName, + home_location_id: StringName, + address: SpatialAddress, + history_event_ids: Array = [] +) -> PersonStateRecord: + return _create( + person_id, + display_name, + role_id, + polity_id, + home_location_id, + address, + &"", + history_event_ids + ) + + +static func create_in_group( + person_id: StringName, + display_name: String, + role_id: StringName, + polity_id: StringName, + home_location_id: StringName, + mobile_group_id: StringName, + history_event_ids: Array = [] +) -> PersonStateRecord: + return _create( + person_id, + display_name, + role_id, + polity_id, + home_location_id, + null, + mobile_group_id, + history_event_ids + ) + + +static func _create( + person_id: StringName, + display_name: String, + role_id: StringName, + polity_id: StringName, + home_location_id: StringName, + address: Variant, + mobile_group_id: StringName, + history_event_ids: Array +) -> PersonStateRecord: + var normalized_address: Variant = null + if address != null: + normalized_address = RegionalStateSupport.normalize_address(address) + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + if normalized_history == null or (address != null and normalized_address == null): + return null + var record := ( + PersonStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "person_id": String(person_id), + "display_name": display_name, + "role_id": String(role_id), + "polity_id": String(polity_id), + "home_location_id": String(home_location_id), + "location_address": normalized_address, + "mobile_group_id": String(mobile_group_id), + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> PersonStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "person_id", + "display_name", + "role_id", + "polity_id", + "home_location_id", + "location_address", + "mobile_group_id", + "history_event_ids", + ] + ) + ): + return null + if ( + ( + record_data["location_address"] != null + and not record_data["location_address"] is Dictionary + ) + or not record_data["history_event_ids"] is Array + ): + return null + return _create( + StringName(record_data["person_id"]), + String(record_data["display_name"]), + StringName(record_data["role_id"]), + StringName(record_data["polity_id"]), + StringName(record_data["home_location_id"]), + record_data["location_address"], + StringName(record_data["mobile_group_id"]), + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_person_id().is_empty() + and not get_display_name().is_empty() + and not get_role_id().is_empty() + and not get_polity_id().is_empty() + and not get_home_location_id().is_empty() + and (is_at_location() != is_in_mobile_group()) + ) + + +func get_person_id() -> StringName: + return StringName(data["person_id"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_role_id() -> StringName: + return StringName(data["role_id"]) + + +func get_polity_id() -> StringName: + return StringName(data["polity_id"]) + + +func get_home_location_id() -> StringName: + return StringName(data["home_location_id"]) + + +func is_at_location() -> bool: + return data["location_address"] is Dictionary and get_mobile_group_id().is_empty() + + +func is_in_mobile_group() -> bool: + return data["location_address"] == null and not get_mobile_group_id().is_empty() + + +func get_location_address() -> SpatialAddress: + return RegionalStateSupport.address_from_data(data["location_address"]) + + +func get_mobile_group_id() -> StringName: + return StringName(data["mobile_group_id"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/PersonStateRecord.gd.uid b/simulation/state/PersonStateRecord.gd.uid new file mode 100644 index 0000000..97e1971 --- /dev/null +++ b/simulation/state/PersonStateRecord.gd.uid @@ -0,0 +1 @@ +uid://bfotwdi60mdnm diff --git a/simulation/state/PolityStateRecord.gd b/simulation/state/PolityStateRecord.gd new file mode 100644 index 0000000..2447431 --- /dev/null +++ b/simulation/state/PolityStateRecord.gd @@ -0,0 +1,98 @@ +class_name PolityStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + polity_id: StringName, + display_name: String, + capital_location_id: StringName, + settlement_ids: Array = [], + history_event_ids: Array = [] +) -> PolityStateRecord: + var normalized_settlements: Variant = RegionalStateSupport.normalize_id_array(settlement_ids) + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + if normalized_settlements == null or normalized_history == null: + return null + var record := ( + PolityStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "polity_id": String(polity_id), + "display_name": display_name, + "capital_location_id": String(capital_location_id), + "settlement_ids": normalized_settlements, + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> PolityStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "polity_id", + "display_name", + "capital_location_id", + "settlement_ids", + "history_event_ids", + ] + ) + ): + return null + if not record_data["settlement_ids"] is Array or not record_data["history_event_ids"] is Array: + return null + return create( + StringName(record_data["polity_id"]), + String(record_data["display_name"]), + StringName(record_data["capital_location_id"]), + record_data["settlement_ids"], + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_polity_id().is_empty() + and not get_display_name().is_empty() + and not get_capital_location_id().is_empty() + ) + + +func get_polity_id() -> StringName: + return StringName(data["polity_id"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_capital_location_id() -> StringName: + return StringName(data["capital_location_id"]) + + +func get_settlement_ids() -> Array[StringName]: + return RegionalStateSupport.copy_string_array(data["settlement_ids"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/PolityStateRecord.gd.uid b/simulation/state/PolityStateRecord.gd.uid new file mode 100644 index 0000000..705e081 --- /dev/null +++ b/simulation/state/PolityStateRecord.gd.uid @@ -0,0 +1 @@ +uid://b1hsgds86deba diff --git a/simulation/state/PopulationCohortRecord.gd b/simulation/state/PopulationCohortRecord.gd new file mode 100644 index 0000000..d6b83dd --- /dev/null +++ b/simulation/state/PopulationCohortRecord.gd @@ -0,0 +1,147 @@ +class_name PopulationCohortRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create_at_location( + cohort_id: StringName, + cohort_type: StringName, + polity_id: StringName, + count: int, + location_id: StringName, + history_event_ids: Array = [] +) -> PopulationCohortRecord: + return _create(cohort_id, cohort_type, polity_id, count, location_id, &"", history_event_ids) + + +static func create_in_group( + cohort_id: StringName, + cohort_type: StringName, + polity_id: StringName, + count: int, + mobile_group_id: StringName, + history_event_ids: Array = [] +) -> PopulationCohortRecord: + return _create( + cohort_id, cohort_type, polity_id, count, &"", mobile_group_id, history_event_ids + ) + + +static func _create( + cohort_id: StringName, + cohort_type: StringName, + polity_id: StringName, + count: int, + location_id: StringName, + mobile_group_id: StringName, + history_event_ids: Array +) -> PopulationCohortRecord: + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + if normalized_history == null: + return null + var record := ( + PopulationCohortRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "cohort_id": String(cohort_id), + "cohort_type": String(cohort_type), + "polity_id": String(polity_id), + "count": count, + "location_id": String(location_id), + "mobile_group_id": String(mobile_group_id), + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> PopulationCohortRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "cohort_id", + "cohort_type", + "polity_id", + "count", + "location_id", + "mobile_group_id", + "history_event_ids", + ] + ) + ): + return null + if not record_data["history_event_ids"] is Array: + return null + return _create( + StringName(record_data["cohort_id"]), + StringName(record_data["cohort_type"]), + StringName(record_data["polity_id"]), + int(record_data["count"]), + StringName(record_data["location_id"]), + StringName(record_data["mobile_group_id"]), + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_cohort_id().is_empty() + and not get_cohort_type().is_empty() + and not get_polity_id().is_empty() + and get_count() > 0 + and (is_at_location() != is_in_mobile_group()) + ) + + +func get_cohort_id() -> StringName: + return StringName(data["cohort_id"]) + + +func get_cohort_type() -> StringName: + return StringName(data["cohort_type"]) + + +func get_polity_id() -> StringName: + return StringName(data["polity_id"]) + + +func get_count() -> int: + return int(data["count"]) + + +func is_at_location() -> bool: + return not get_location_id().is_empty() and get_mobile_group_id().is_empty() + + +func is_in_mobile_group() -> bool: + return get_location_id().is_empty() and not get_mobile_group_id().is_empty() + + +func get_location_id() -> StringName: + return StringName(data["location_id"]) + + +func get_mobile_group_id() -> StringName: + return StringName(data["mobile_group_id"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/PopulationCohortRecord.gd.uid b/simulation/state/PopulationCohortRecord.gd.uid new file mode 100644 index 0000000..3e139b0 --- /dev/null +++ b/simulation/state/PopulationCohortRecord.gd.uid @@ -0,0 +1 @@ +uid://di6mlijatrbgu diff --git a/simulation/state/RouteStateRecord.gd b/simulation/state/RouteStateRecord.gd new file mode 100644 index 0000000..1c9689a --- /dev/null +++ b/simulation/state/RouteStateRecord.gd @@ -0,0 +1,117 @@ +class_name RouteStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + route_id: StringName, + from_location_id: StringName, + to_location_id: StringName, + travel_ticks: int, + bidirectional: bool = true, + history_event_ids: Array = [] +) -> RouteStateRecord: + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + if normalized_history == null: + return null + var record := ( + RouteStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "route_id": String(route_id), + "from_location_id": String(from_location_id), + "to_location_id": String(to_location_id), + "travel_ticks": travel_ticks, + "bidirectional": bidirectional, + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> RouteStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "route_id", + "from_location_id", + "to_location_id", + "travel_ticks", + "bidirectional", + "history_event_ids", + ] + ) + ): + return null + if not record_data["bidirectional"] is bool or not record_data["history_event_ids"] is Array: + return null + return create( + StringName(record_data["route_id"]), + StringName(record_data["from_location_id"]), + StringName(record_data["to_location_id"]), + int(record_data["travel_ticks"]), + bool(record_data["bidirectional"]), + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_route_id().is_empty() + and not get_from_location_id().is_empty() + and not get_to_location_id().is_empty() + and get_from_location_id() != get_to_location_id() + and get_travel_ticks() > 0 + ) + + +func get_route_id() -> StringName: + return StringName(data["route_id"]) + + +func get_from_location_id() -> StringName: + return StringName(data["from_location_id"]) + + +func get_to_location_id() -> StringName: + return StringName(data["to_location_id"]) + + +func get_travel_ticks() -> int: + return int(data["travel_ticks"]) + + +func is_bidirectional() -> bool: + return bool(data["bidirectional"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func connects(first_location_id: StringName, second_location_id: StringName) -> bool: + if first_location_id == get_from_location_id() and second_location_id == get_to_location_id(): + return true + return ( + is_bidirectional() + and first_location_id == get_to_location_id() + and second_location_id == get_from_location_id() + ) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/RouteStateRecord.gd.uid b/simulation/state/RouteStateRecord.gd.uid new file mode 100644 index 0000000..376088a --- /dev/null +++ b/simulation/state/RouteStateRecord.gd.uid @@ -0,0 +1 @@ +uid://dw73sybt7s5cx diff --git a/simulation/state/SettlementStateRecord.gd b/simulation/state/SettlementStateRecord.gd new file mode 100644 index 0000000..7b8136c --- /dev/null +++ b/simulation/state/SettlementStateRecord.gd @@ -0,0 +1,166 @@ +class_name SettlementStateRecord +extends RefCounted + +const SCHEMA_VERSION := 1 + +var data: Dictionary + + +func _init(record_data: Dictionary = {}) -> void: + data = record_data.duplicate(true) + + +static func create( + settlement_id: StringName, + location_id: StringName, + display_name: String, + polity_id: StringName, + tier: int, + founding_group_id: StringName, + founded_tick: int, + stockpile_ledger: Dictionary = {}, + history_event_ids: Array = [] +) -> SettlementStateRecord: + var normalized_stockpile: Variant = RegionalStateSupport.normalize_amount_ledger( + stockpile_ledger + ) + var normalized_history: Variant = RegionalStateSupport.normalize_history_event_ids( + history_event_ids + ) + if normalized_stockpile == null or normalized_history == null: + return null + var record := ( + SettlementStateRecord + . new( + { + "schema_version": SCHEMA_VERSION, + "settlement_id": String(settlement_id), + "location_id": String(location_id), + "display_name": display_name, + "polity_id": String(polity_id), + "tier": tier, + "founding_group_id": String(founding_group_id), + "founded_tick": founded_tick, + "stockpile_ledger": normalized_stockpile, + "history_event_ids": normalized_history, + } + ) + ) + return record if record.is_valid() else null + + +static func from_dictionary(record_data: Dictionary) -> SettlementStateRecord: + if int(record_data.get("schema_version", -1)) != SCHEMA_VERSION: + return null + if not ( + record_data + . has_all( + [ + "settlement_id", + "location_id", + "display_name", + "polity_id", + "tier", + "founding_group_id", + "founded_tick", + "stockpile_ledger", + "history_event_ids", + ] + ) + ): + return null + if ( + not record_data["stockpile_ledger"] is Dictionary + or not record_data["history_event_ids"] is Array + ): + return null + return create( + StringName(record_data["settlement_id"]), + StringName(record_data["location_id"]), + String(record_data["display_name"]), + StringName(record_data["polity_id"]), + int(record_data["tier"]), + StringName(record_data["founding_group_id"]), + int(record_data["founded_tick"]), + record_data["stockpile_ledger"], + record_data["history_event_ids"] + ) + + +func is_valid() -> bool: + return ( + not get_settlement_id().is_empty() + and not get_location_id().is_empty() + and not get_display_name().is_empty() + and not get_polity_id().is_empty() + and not get_founding_group_id().is_empty() + and get_tier() >= 0 + and get_founded_tick() >= 0 + ) + + +func get_settlement_id() -> StringName: + return StringName(data["settlement_id"]) + + +func get_location_id() -> StringName: + return StringName(data["location_id"]) + + +func get_display_name() -> String: + return String(data["display_name"]) + + +func get_polity_id() -> StringName: + return StringName(data["polity_id"]) + + +func get_tier() -> int: + return int(data["tier"]) + + +func get_founding_group_id() -> StringName: + return StringName(data["founding_group_id"]) + + +func get_founded_tick() -> int: + return int(data["founded_tick"]) + + +func get_stockpile_ledger() -> Dictionary: + return data["stockpile_ledger"].duplicate(true) + + +func get_stockpile_amount(item_id: StringName) -> float: + return float(data["stockpile_ledger"].get(String(item_id), 0.0)) + + +func get_total_stockpile_amount() -> float: + return RegionalStateSupport.amount_ledger_total(data["stockpile_ledger"]) + + +func get_history_event_ids() -> Array[int]: + return RegionalStateSupport.copy_int_array(data["history_event_ids"]) + + +func with_stockpile_ledger( + stockpile_ledger: Dictionary, cause_event_id: int = -1 +) -> SettlementStateRecord: + var history_event_ids: Array = data["history_event_ids"].duplicate() + if cause_event_id >= 0 and cause_event_id not in history_event_ids: + history_event_ids.append(cause_event_id) + return create( + get_settlement_id(), + get_location_id(), + get_display_name(), + get_polity_id(), + get_tier(), + get_founding_group_id(), + get_founded_tick(), + stockpile_ledger, + history_event_ids + ) + + +func to_dictionary() -> Dictionary: + return data.duplicate(true) diff --git a/simulation/state/SettlementStateRecord.gd.uid b/simulation/state/SettlementStateRecord.gd.uid new file mode 100644 index 0000000..1d36998 --- /dev/null +++ b/simulation/state/SettlementStateRecord.gd.uid @@ -0,0 +1 @@ +uid://8ofxqx8bfmyg diff --git a/tests/unit/test_regional_world_state.gd b/tests/unit/test_regional_world_state.gd new file mode 100644 index 0000000..a21bd5c --- /dev/null +++ b/tests/unit/test_regional_world_state.gd @@ -0,0 +1,334 @@ +extends GutTest + + +func test_canonical_primitive_round_trip_rebuilds_catalog_indexes() -> void: + var state := _fixture_state() + var saved := state.to_dictionary() + var restored := RegionalWorldState.from_dictionary(saved) + + assert_not_null(restored) + assert_true(_is_primitive_only(saved)) + assert_eq(restored.to_dictionary(), saved) + assert_eq(restored.checksum(), state.checksum()) + assert_true(restored.has_global_id(&"group_blue_caravan")) + assert_eq(restored.get_entity_type(&"group_blue_caravan"), &"mobile_group") + assert_eq(restored.get_all_persons().size(), 3) + assert_eq(saved["locations"][0]["location_id"], "location_jajce") + assert_eq(saved["locations"][1]["location_id"], "location_travnik") + assert_eq(saved["mobile_groups"][0]["cargo_ledger"].keys(), ["food", "wood"]) + assert_eq(_person_ids(restored.get_persons_in_group(&"group_blue_caravan")), [&"person_ana"]) + assert_eq( + _cohort_ids(restored.get_cohorts_in_group(&"group_blue_caravan")), [&"cohort_carriers"] + ) + assert_eq( + _route_ids(restored.get_routes_for_location(&"location_jajce")), [&"route_jajce_travnik"] + ) + assert_eq( + _group_ids(restored.get_mobile_groups_on_route(&"route_jajce_travnik")), + [&"group_blue_caravan"] + ) + assert_same( + restored.get_diplomatic_relation_between(&"polity_travnik", &"polity_jajce"), + restored.get_diplomatic_relation(&"relation_jajce_travnik") + ) + + +func test_equal_local_coordinates_remain_scoped_to_their_locations() -> void: + var state := _fixture_state() + var jajce_people := state.get_persons_at_location(&"location_jajce") + var travnik_people := state.get_persons_at_location(&"location_travnik") + + assert_eq(_person_ids(jajce_people), [&"person_boris"]) + assert_eq(_person_ids(travnik_people), [&"person_cemal"]) + assert_eq(jajce_people[0].get_location_address().get_position(), Vector3(1.0, 0.0, 1.0)) + assert_eq(travnik_people[0].get_location_address().get_position(), Vector3(1.0, 0.0, 1.0)) + assert_ne( + jajce_people[0].get_location_address().index_key(), + travnik_people[0].get_location_address().index_key() + ) + + var before := state.to_dictionary() + var wrong_world := before.duplicate(true) + for person_data: Dictionary in wrong_world["persons"]: + if person_data["person_id"] == "person_boris": + person_data["location_address"]["world_id"] = "another_world" + assert_false(state.restore_from_dictionary(wrong_world)) + assert_true(_errors_contain(state.get_last_validation_errors(), "cross-world")) + assert_eq(state.to_dictionary(), before) + + +func test_duplicate_and_orphan_memberships_are_rejected_transactionally() -> void: + var state := _fixture_state() + var before := state.to_dictionary() + var duplicate := before.duplicate(true) + duplicate["persons"].append(duplicate["persons"][0].duplicate(true)) + + assert_false(state.restore_from_dictionary(duplicate)) + assert_true(_errors_contain(state.get_last_validation_errors(), "duplicate global ID")) + assert_eq(state.to_dictionary(), before) + + var orphan := before.duplicate(true) + for person_data: Dictionary in orphan["persons"]: + if person_data["person_id"] == "person_ana": + person_data["mobile_group_id"] = "missing_caravan" + assert_false(state.restore_from_dictionary(orphan)) + assert_true(_errors_contain(state.get_last_validation_errors(), "missing_caravan")) + assert_eq(state.to_dictionary(), before) + + var duplicate_owner := before.duplicate(true) + var second_group := MobileGroupStateRecord.create_on_route( + &"group_second_caravan", + &"caravan", + "Second caravan", + &"polity_jajce", + &"route_jajce_travnik", + &"location_jajce", + &"location_travnik", + 20, + 32, + [&"person_ana"] + ) + duplicate_owner["mobile_groups"].append(second_group.to_dictionary()) + assert_false(state.restore_from_dictionary(duplicate_owner)) + assert_true(_errors_contain(state.get_last_validation_errors(), "multiple mobile groups")) + assert_eq(state.to_dictionary(), before) + + +func test_cargo_and_cohort_payloads_are_conservation_friendly() -> void: + assert_null( + MobileGroupStateRecord.create_at_location( + &"overloaded", + &"caravan", + "Overloaded", + &"polity_jajce", + _location_address(&"location_jajce"), + [], + {}, + {&"food": 3.0}, + 2.0 + ) + ) + assert_null( + SettlementStateRecord.create( + &"bad_settlement", + &"location_jajce", + "Bad", + &"polity_jajce", + 0, + &"founders", + 0, + {&"food": -1.0} + ) + ) + + var state := _fixture_state() + var caravan := state.get_mobile_group(&"group_blue_caravan") + var settlement := state.get_settlement(&"settlement_jajce") + assert_eq(caravan.get_cohort_counts(), {"cohort_carriers": 3}) + assert_eq(caravan.get_cargo_ledger(), {"food": 5.0, "wood": 2.0}) + assert_eq(caravan.get_last_cargo_event_id(), 41) + var food_before := settlement.get_stockpile_amount(&"food") + caravan.get_cargo_amount(&"food") + var changed_settlement := settlement.with_stockpile_ledger({&"food": 22.0, &"wood": 12.0}, 57) + var changed_cargo := caravan.with_cargo_ledger({&"food": 8.0, &"wood": 2.0}, 57) + assert_not_null(changed_settlement) + assert_not_null(changed_cargo) + assert_eq(changed_cargo.get_last_cargo_event_id(), 57) + assert_eq(changed_cargo.get_cargo_ledger(), {"food": 8.0, "wood": 2.0}) + assert_true(57 in changed_cargo.get_history_event_ids()) + assert_true(57 in changed_settlement.get_history_event_ids()) + assert_eq( + changed_settlement.get_stockpile_amount(&"food") + changed_cargo.get_cargo_amount(&"food"), + food_before + ) + assert_eq(caravan.get_cargo_ledger(), {"food": 5.0, "wood": 2.0}) + + var mismatch := state.to_dictionary() + mismatch["mobile_groups"][0]["cohort_counts"]["cohort_carriers"] = 4 + assert_false(state.restore_from_dictionary(mismatch)) + assert_true(_errors_contain(state.get_last_validation_errors(), "count disagrees")) + + +func test_route_membership_requires_exact_endpoints_duration_and_no_address() -> void: + var state := _fixture_state() + var before := state.to_dictionary() + var bad_duration := before.duplicate(true) + bad_duration["mobile_groups"][0]["arrival_tick"] = 33 + assert_false(state.restore_from_dictionary(bad_duration)) + assert_true(_errors_contain(state.get_last_validation_errors(), "duration")) + assert_eq(state.to_dictionary(), before) + + var bad_direction := before.duplicate(true) + bad_direction["routes"][0]["bidirectional"] = false + bad_direction["mobile_groups"][0]["departure_location_id"] = "location_travnik" + bad_direction["mobile_groups"][0]["arrival_location_id"] = "location_jajce" + assert_false(state.restore_from_dictionary(bad_direction)) + assert_true(_errors_contain(state.get_last_validation_errors(), "direction/endpoints")) + assert_eq(state.to_dictionary(), before) + + +func _fixture_state() -> RegionalWorldState: + var jajce := LocationStateRecord.create( + &"location_jajce", + &"regional_bosnia", + &"settlement", + "Jajce", + _location_address(&"location_jajce"), + [8, 2] + ) + var travnik := LocationStateRecord.create( + &"location_travnik", + &"regional_bosnia", + &"settlement", + "Travnik", + _location_address(&"location_travnik") + ) + var settlement_jajce := SettlementStateRecord.create( + &"settlement_jajce", + &"location_jajce", + "Jajce", + &"polity_jajce", + 2, + &"founders_jajce", + 0, + {&"wood": 12.0, &"food": 25.0}, + [12, 3] + ) + var settlement_travnik := SettlementStateRecord.create( + &"settlement_travnik", + &"location_travnik", + "Travnik", + &"polity_travnik", + 1, + &"founders_travnik", + 1, + {&"food": 18.0} + ) + var route := RouteStateRecord.create( + &"route_jajce_travnik", &"location_jajce", &"location_travnik", 12, true, [19] + ) + var caravan := MobileGroupStateRecord.create_on_route( + &"group_blue_caravan", + &"caravan", + "Blue caravan", + &"polity_jajce", + &"route_jajce_travnik", + &"location_jajce", + &"location_travnik", + 20, + 32, + [&"person_ana"], + {&"cohort_carriers": 3}, + {&"wood": 2.0, &"food": 5.0}, + 10.0, + 41, + [41, 18] + ) + var ana := PersonStateRecord.create_in_group( + &"person_ana", "Ana", &"merchant", &"polity_jajce", &"location_jajce", &"group_blue_caravan" + ) + var boris := PersonStateRecord.create_at_location( + &"person_boris", + "Boris", + &"steward", + &"polity_jajce", + &"location_jajce", + SpatialAddress.create(&"regional_bosnia", &"location_jajce", Vector3(1.0, 0.0, 1.0)) + ) + var cemal := PersonStateRecord.create_at_location( + &"person_cemal", + "Cemal", + &"steward", + &"polity_travnik", + &"location_travnik", + SpatialAddress.create(&"regional_bosnia", &"location_travnik", Vector3(1.0, 0.0, 1.0)) + ) + var carriers := PopulationCohortRecord.create_in_group( + &"cohort_carriers", &"adult_carriers", &"polity_jajce", 3, &"group_blue_caravan" + ) + var residents := PopulationCohortRecord.create_at_location( + &"cohort_travnik_residents", &"residents", &"polity_travnik", 20, &"location_travnik" + ) + var jajce_polity := PolityStateRecord.create( + &"polity_jajce", "Jajce council", &"location_jajce", [&"settlement_jajce"] + ) + var travnik_polity := PolityStateRecord.create( + &"polity_travnik", "Travnik council", &"location_travnik", [&"settlement_travnik"] + ) + var relation := DiplomaticRelationRecord.create( + &"relation_jajce_travnik", &"polity_travnik", &"polity_jajce", &"trade", 0.4, 5, [20] + ) + var locations: Array[LocationStateRecord] = [travnik, jajce] + var settlements: Array[SettlementStateRecord] = [settlement_travnik, settlement_jajce] + var routes: Array[RouteStateRecord] = [route] + var mobile_groups: Array[MobileGroupStateRecord] = [caravan] + var persons: Array[PersonStateRecord] = [cemal, ana, boris] + var cohorts: Array[PopulationCohortRecord] = [residents, carriers] + var polities: Array[PolityStateRecord] = [travnik_polity, jajce_polity] + var relations: Array[DiplomaticRelationRecord] = [relation] + return RegionalWorldState.create( + &"regional_bosnia", + locations, + settlements, + routes, + mobile_groups, + persons, + cohorts, + polities, + relations + ) + + +func _location_address(location_id: StringName) -> SpatialAddress: + return SpatialAddress.create(&"regional_bosnia", location_id, Vector3.ZERO) + + +func _is_primitive_only(value: Variant) -> bool: + match typeof(value): + TYPE_NIL, TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING: + return true + TYPE_ARRAY: + for item: Variant in value: + if not _is_primitive_only(item): + return false + return true + TYPE_DICTIONARY: + for key: Variant in value: + if not key is String or not _is_primitive_only(value[key]): + return false + return true + return false + + +func _errors_contain(errors: Array[String], fragment: String) -> bool: + for error: String in errors: + if fragment in error: + return true + return false + + +func _person_ids(records: Array[PersonStateRecord]) -> Array[StringName]: + var ids: Array[StringName] = [] + for record: PersonStateRecord in records: + ids.append(record.get_person_id()) + return ids + + +func _cohort_ids(records: Array[PopulationCohortRecord]) -> Array[StringName]: + var ids: Array[StringName] = [] + for record: PopulationCohortRecord in records: + ids.append(record.get_cohort_id()) + return ids + + +func _route_ids(records: Array[RouteStateRecord]) -> Array[StringName]: + var ids: Array[StringName] = [] + for record: RouteStateRecord in records: + ids.append(record.get_route_id()) + return ids + + +func _group_ids(records: Array[MobileGroupStateRecord]) -> Array[StringName]: + var ids: Array[StringName] = [] + for record: MobileGroupStateRecord in records: + ids.append(record.get_group_id()) + return ids diff --git a/tests/unit/test_regional_world_state.gd.uid b/tests/unit/test_regional_world_state.gd.uid new file mode 100644 index 0000000..8f2f43b --- /dev/null +++ b/tests/unit/test_regional_world_state.gd.uid @@ -0,0 +1 @@ +uid://bgp50g6mkntvb