class_name RegionalCaravanService extends RefCounted const SCHEMA_VERSION := 1 const JOB_TYPE_ARRIVAL := &"caravan_arrival" const JOB_PHASE_ARRIVAL := 10 const EVENT_CARGO_LOADED := &"caravan_cargo_loaded" const EVENT_DEPARTED := &"caravan_departed" const EVENT_ARRIVED := &"caravan_arrived" const EVENT_CARGO_DEPOSITED := &"caravan_cargo_deposited" const ENTITY_TYPE_SETTLEMENT := &"settlement" const ENTITY_TYPE_MOBILE_GROUP := &"mobile_group" const ENTITY_TYPE_ROUTE := &"route" const ROLE_CARAVAN := &"caravan" const ROLE_ROUTE := &"route" const ROLE_ORIGIN := &"origin" const ROLE_DESTINATION := &"destination" var _world_state: RegionalWorldState var _scheduler: RegionalJobScheduler var _event_store: WorldEventStore var _next_event_id := 0 var _job_handlers: Dictionary = {} var _last_error := "" func _init( world_state: RegionalWorldState = null, scheduler: RegionalJobScheduler = null, event_store: WorldEventStore = null, next_event_id: int = 0 ) -> void: _world_state = world_state _scheduler = scheduler _event_store = event_store _next_event_id = next_event_id _register_builtin_handlers() static func create( world_state: RegionalWorldState, scheduler: RegionalJobScheduler, event_store: WorldEventStore, next_event_id: int = 0 ) -> RegionalCaravanService: var service := RegionalCaravanService.new(world_state, scheduler, event_store, next_event_id) return service if service.is_valid() else null static func from_dictionary(envelope: Dictionary) -> RegionalCaravanService: if int(envelope.get("schema_version", -1)) != SCHEMA_VERSION: return null if not envelope.has_all(["next_event_id", "world_state", "scheduler", "event_store"]): return null if ( not envelope["next_event_id"] is int or not envelope["world_state"] is Dictionary or not envelope["scheduler"] is Dictionary or not envelope["event_store"] is Dictionary ): return null var world_state := RegionalWorldState.from_dictionary(envelope["world_state"]) var scheduler := RegionalJobScheduler.from_dictionary(envelope["scheduler"]) var event_store := WorldEventStore.from_dictionary(envelope["event_store"]) if world_state == null or scheduler == null or event_store == null: return null return create(world_state, scheduler, event_store, int(envelope["next_event_id"])) func is_valid() -> bool: return ( _world_state != null and _scheduler != null and _event_store != null and _event_cursor_is_valid() and _job_handlers.has(JOB_TYPE_ARRIVAL) and (_job_handlers[JOB_TYPE_ARRIVAL] as Callable).is_valid() and _pending_arrival_jobs_are_valid() ) func get_world_state() -> RegionalWorldState: return _world_state func get_scheduler() -> RegionalJobScheduler: return _scheduler func get_event_store() -> WorldEventStore: return _event_store func get_next_event_id() -> int: return _next_event_id func get_last_error() -> String: return _last_error func get_registered_job_types() -> Array[StringName]: var job_types: Array[StringName] = [] for job_type: Variant in _job_handlers: job_types.append(StringName(job_type)) job_types.sort_custom( func(first: StringName, second: StringName) -> bool: return String(first) < String(second) ) return job_types func depart( group_id: StringName, origin_settlement_id: StringName, destination_settlement_id: StringName, route_id: StringName, cargo_to_load: Dictionary, departure_tick: int, presentation_mode: StringName = &"never" ) -> bool: _last_error = "" var normalized_cargo: Variant = RegionalStateSupport.normalize_amount_ledger(cargo_to_load) if ( departure_tick < 0 or normalized_cargo == null or normalized_cargo.is_empty() or not _all_amounts_positive(normalized_cargo) ): return _fail("invalid cargo or departure tick") var group := _world_state.get_mobile_group(group_id) var origin := _world_state.get_settlement(origin_settlement_id) var destination := _world_state.get_settlement(destination_settlement_id) var route := _world_state.get_route(route_id) if group == null or origin == null or destination == null or route == null: return _fail("caravan departure references a missing regional record") if origin.get_settlement_id() == destination.get_settlement_id(): return _fail("caravan origin and destination must differ") if ( not group.is_at_location() or group.get_current_location_id() != origin.get_location_id() or not route.connects(origin.get_location_id(), destination.get_location_id()) ): return _fail("caravan is not at the route origin") var next_origin_ledger: Variant = _subtract_ledgers( origin.get_stockpile_ledger(), normalized_cargo ) var next_group_ledger: Variant = _add_ledgers(group.get_cargo_ledger(), normalized_cargo) if next_origin_ledger == null or next_group_ledger == null: return _fail("origin stockpile cannot supply requested cargo") if RegionalStateSupport.amount_ledger_total(next_group_ledger) > group.get_cargo_capacity(): return _fail("requested cargo exceeds caravan capacity") var load_event_id := _next_event_id var depart_event_id := load_event_id + 1 if _event_store.has_event(load_event_id) or _event_store.has_event(depart_event_id): return _fail("next caravan event ID is already in use") var arrival_tick := departure_tick + route.get_travel_ticks() var job_id := _arrival_job_id(group_id, departure_tick) var dedupe_key := _arrival_dedupe_key(group_id) var event_payload := _journey_payload( group, origin, destination, route, normalized_cargo, departure_tick, arrival_tick, -1, presentation_mode ) var load_event := _create_event( load_event_id, EVENT_CARGO_LOADED, departure_tick, group, origin, destination, route, origin.get_location_id(), event_payload ) var depart_payload := event_payload.duplicate(true) depart_payload["cause_event_id"] = load_event_id var depart_event := _create_event( depart_event_id, EVENT_DEPARTED, departure_tick, group, origin, destination, route, origin.get_location_id(), depart_payload ) var next_origin := origin.with_stockpile_ledger(next_origin_ledger, load_event_id) var loaded_group := group.with_cargo_ledger(next_group_ledger, load_event_id) var journey_group := ( loaded_group.with_route_journey( route_id, origin.get_location_id(), destination.get_location_id(), departure_tick, arrival_tick, depart_event_id ) if loaded_group != null else null ) var job_payload := { "group_id": String(group_id), "origin_settlement_id": String(origin_settlement_id), "destination_settlement_id": String(destination_settlement_id), "route_id": String(route_id), "departure_tick": departure_tick, "arrival_tick": arrival_tick, "depart_event_id": depart_event_id, "cargo_ledger": normalized_cargo, } if load_event == null or depart_event == null or next_origin == null or journey_group == null: return _fail("caravan departure could not build valid replacement records") var snapshot := _snapshot() if ( _scheduler.schedule_new( job_id, JOB_TYPE_ARRIVAL, arrival_tick, JOB_PHASE_ARRIVAL, group_id, job_payload, dedupe_key ) == null ): return _fail("arrival job conflicts with an existing job") if not _world_state.replace_settlement_and_mobile_group(next_origin, journey_group): _restore_snapshot(snapshot) return _fail("caravan departure state transaction failed") if not _event_store.append(load_event) or not _event_store.append(depart_event): _restore_snapshot(snapshot) return _fail("caravan departure event transaction failed") _next_event_id += 2 return true func process_due( current_tick: int, execution_budget: int = RegionalJobScheduler.UNLIMITED_BUDGET, presentation_mode: StringName = &"never" ) -> Array[ScheduledJobRecord]: _last_error = "" var executed: Array[ScheduledJobRecord] = [] if execution_budget < RegionalJobScheduler.UNLIMITED_BUDGET: _fail("invalid regional execution budget") return executed var remaining := execution_budget while remaining != 0: var scheduler_snapshot := _scheduler.to_dictionary() var batch := _scheduler.take_due(current_tick, 1) if batch.is_empty(): break var job := batch[0] var handler := _job_handlers.get(job.get_job_type()) as Callable if ( handler == null or not handler.is_valid() or not bool(handler.call(job, presentation_mode)) ): _scheduler.restore_from_dictionary(scheduler_snapshot) _fail("scheduled regional job '%s' failed atomically" % job.get_job_id()) break executed.append(job) if remaining > 0: remaining -= 1 return executed func to_dictionary() -> Dictionary: return { "schema_version": SCHEMA_VERSION, "next_event_id": _next_event_id, "world_state": _world_state.to_dictionary(), "scheduler": _scheduler.to_dictionary(), "event_store": _event_store.to_dictionary(), } func checksum() -> String: return JSON.stringify(to_dictionary()).sha256_text() func _register_builtin_handlers() -> void: _job_handlers.clear() _register_handler(JOB_TYPE_ARRIVAL, _handle_arrival) func _register_handler(job_type: StringName, handler: Callable) -> bool: if job_type.is_empty() or not handler.is_valid() or _job_handlers.has(job_type): return false _job_handlers[job_type] = handler return true func _pending_arrival_jobs_are_valid() -> bool: for job: ScheduledJobRecord in _scheduler.get_all_sorted(): if job.get_job_type() == JOB_TYPE_ARRIVAL and _validated_arrival_context(job).is_empty(): return false return true func _validated_arrival_context(job: ScheduledJobRecord) -> Dictionary: if ( job == null or job.get_job_type() != JOB_TYPE_ARRIVAL or job.get_due_tick() < 0 or job.get_phase() != JOB_PHASE_ARRIVAL or job.get_repeat_interval() != ScheduledJobRecord.NO_REPEAT or job.get_occurrence() != 0 ): return {} var payload := job.get_payload() if not ( payload . has_all( [ "group_id", "origin_settlement_id", "destination_settlement_id", "route_id", "departure_tick", "arrival_tick", "depart_event_id", "cargo_ledger", ] ) ): return {} if ( not payload["group_id"] is String or not payload["origin_settlement_id"] is String or not payload["destination_settlement_id"] is String or not payload["route_id"] is String or not payload["departure_tick"] is int or not payload["arrival_tick"] is int or not payload["depart_event_id"] is int or not payload["cargo_ledger"] is Dictionary ): return {} var normalized_cargo: Variant = RegionalStateSupport.normalize_amount_ledger( payload["cargo_ledger"] ) var group := _world_state.get_mobile_group(StringName(payload["group_id"])) var origin := _world_state.get_settlement(StringName(payload["origin_settlement_id"])) var destination := _world_state.get_settlement(StringName(payload["destination_settlement_id"])) var route := _world_state.get_route(StringName(payload["route_id"])) var departure_tick := int(payload["departure_tick"]) var arrival_tick := int(payload["arrival_tick"]) var depart_event_id := int(payload["depart_event_id"]) if ( normalized_cargo == null or not _all_amounts_positive(normalized_cargo) or group == null or origin == null or destination == null or route == null or job.get_entity_id() != group.get_group_id() or job.get_job_id() != _arrival_job_id(group.get_group_id(), departure_tick) or job.get_dedupe_key() != _arrival_dedupe_key(group.get_group_id()) or job.get_due_tick() != arrival_tick or not group.is_on_route() or group.get_route_id() != route.get_route_id() or group.get_departure_location_id() != origin.get_location_id() or group.get_arrival_location_id() != destination.get_location_id() or group.get_departure_tick() != departure_tick or group.get_arrival_tick() != arrival_tick or not route.connects(origin.get_location_id(), destination.get_location_id()) or arrival_tick - departure_tick != route.get_travel_ticks() or not _departure_facts_match( depart_event_id, group, origin, destination, route, normalized_cargo, departure_tick, arrival_tick ) or group.get_last_cargo_event_id() != depart_event_id - 1 or depart_event_id not in group.get_history_event_ids() or depart_event_id - 1 not in group.get_history_event_ids() or depart_event_id - 1 not in origin.get_history_event_ids() ): return {} for cargo_key: Variant in normalized_cargo: if group.get_cargo_amount(StringName(cargo_key)) < float(normalized_cargo[cargo_key]): return {} return { "group": group, "origin": origin, "destination": destination, "route": route, "cargo_ledger": normalized_cargo, "departure_tick": departure_tick, "arrival_tick": arrival_tick, "depart_event_id": depart_event_id, } func _handle_arrival(job: ScheduledJobRecord, _presentation_mode: StringName) -> bool: var context := _validated_arrival_context(job) if context.is_empty(): return false var group := context["group"] as MobileGroupStateRecord var origin := context["origin"] as SettlementStateRecord var destination := context["destination"] as SettlementStateRecord var route := context["route"] as RouteStateRecord var normalized_cargo: Dictionary = context["cargo_ledger"] var departure_tick := int(context["departure_tick"]) var arrival_tick := int(context["arrival_tick"]) var depart_event_id := int(context["depart_event_id"]) var arrive_event_id := _next_event_id var deposit_event_id := arrive_event_id + 1 if _event_store.has_event(arrive_event_id) or _event_store.has_event(deposit_event_id): return false var arrival_payload := _journey_payload( group, origin, destination, route, normalized_cargo, departure_tick, arrival_tick, depart_event_id, &"" ) var arrive_event := _create_event( arrive_event_id, EVENT_ARRIVED, arrival_tick, group, origin, destination, route, destination.get_location_id(), arrival_payload ) var deposit_payload := arrival_payload.duplicate(true) deposit_payload["cause_event_id"] = arrive_event_id var deposit_event := _create_event( deposit_event_id, EVENT_CARGO_DEPOSITED, arrival_tick, group, origin, destination, route, destination.get_location_id(), deposit_payload ) var next_group_ledger: Variant = _subtract_ledgers(group.get_cargo_ledger(), normalized_cargo) var next_destination_ledger: Variant = _add_ledgers( destination.get_stockpile_ledger(), normalized_cargo ) var arrived_group := group.with_location( SpatialAddress.create( _world_state.get_world_id(), destination.get_location_id(), Vector3.ZERO ), arrive_event_id ) var empty_group := ( arrived_group.with_cargo_ledger(next_group_ledger, deposit_event_id) if arrived_group != null and next_group_ledger != null else null ) var next_destination := destination.with_stockpile_ledger( next_destination_ledger, deposit_event_id ) if ( arrive_event == null or deposit_event == null or empty_group == null or next_destination == null ): return false var snapshot := _snapshot() if not _world_state.replace_settlement_and_mobile_group(next_destination, empty_group): return false if not _event_store.append(arrive_event) or not _event_store.append(deposit_event): _restore_snapshot(snapshot) return false _next_event_id += 2 return true func _create_event( event_id: int, event_type: StringName, tick: int, group: MobileGroupStateRecord, origin: SettlementStateRecord, destination: SettlementStateRecord, route: RouteStateRecord, location_id: StringName, payload: Dictionary ) -> WorldEventRecord: return ( WorldEventRecord . create( event_id, event_type, tick, { ROLE_CARAVAN: WorldEntityRef.create(ENTITY_TYPE_MOBILE_GROUP, group.get_group_id()), ROLE_ROUTE: WorldEntityRef.create(ENTITY_TYPE_ROUTE, route.get_route_id()), ROLE_ORIGIN: WorldEntityRef.create(ENTITY_TYPE_SETTLEMENT, origin.get_settlement_id()), ROLE_DESTINATION: WorldEntityRef.create(ENTITY_TYPE_SETTLEMENT, destination.get_settlement_id()), }, SpatialAddress.create(_world_state.get_world_id(), location_id, Vector3.ZERO), payload ) ) func _departure_facts_match( depart_event_id: int, group: MobileGroupStateRecord, origin: SettlementStateRecord, destination: SettlementStateRecord, route: RouteStateRecord, cargo_ledger: Dictionary, departure_tick: int, arrival_tick: int ) -> bool: var load_event := _event_store.get_by_id(depart_event_id - 1) var depart_event := _event_store.get_by_id(depart_event_id) return ( _event_matches_journey( load_event, EVENT_CARGO_LOADED, group, origin, destination, route, cargo_ledger, departure_tick, arrival_tick, -1 ) and _event_matches_journey( depart_event, EVENT_DEPARTED, group, origin, destination, route, cargo_ledger, departure_tick, arrival_tick, depart_event_id - 1 ) ) func _event_matches_journey( event: WorldEventRecord, event_type: StringName, group: MobileGroupStateRecord, origin: SettlementStateRecord, destination: SettlementStateRecord, route: RouteStateRecord, cargo_ledger: Dictionary, departure_tick: int, arrival_tick: int, cause_event_id: int ) -> bool: if event == null or event.get_event_type() != event_type or event.get_tick() != departure_tick: return false var payload := event.get_payload() return ( event.has_location() and event.get_location().get_world_id() == _world_state.get_world_id() and event.get_location().get_location_id() == origin.get_location_id() and _participant_matches( event, ROLE_CARAVAN, ENTITY_TYPE_MOBILE_GROUP, group.get_group_id() ) and _participant_matches(event, ROLE_ROUTE, ENTITY_TYPE_ROUTE, route.get_route_id()) and _participant_matches( event, ROLE_ORIGIN, ENTITY_TYPE_SETTLEMENT, origin.get_settlement_id() ) and _participant_matches( event, ROLE_DESTINATION, ENTITY_TYPE_SETTLEMENT, destination.get_settlement_id() ) and payload.get("group_id") == String(group.get_group_id()) and payload.get("origin_settlement_id") == String(origin.get_settlement_id()) and payload.get("destination_settlement_id") == String(destination.get_settlement_id()) and payload.get("route_id") == String(route.get_route_id()) and payload.get("cargo_ledger") == cargo_ledger and int(payload.get("headcount", -1)) == group.get_headcount() and int(payload.get("departure_tick", -1)) == departure_tick and int(payload.get("arrival_tick", -1)) == arrival_tick and int(payload.get("cause_event_id", -2)) == cause_event_id ) static func _participant_matches( event: WorldEventRecord, role: StringName, entity_type: StringName, entity_id: StringName ) -> bool: var participant := event.get_participant(role) return ( participant != null and participant.get_entity_type() == entity_type and participant.get_entity_id() == entity_id ) func _journey_payload( group: MobileGroupStateRecord, origin: SettlementStateRecord, destination: SettlementStateRecord, route: RouteStateRecord, cargo_ledger: Dictionary, departure_tick: int, arrival_tick: int, cause_event_id: int, _presentation_mode: StringName ) -> Dictionary: return { "group_id": String(group.get_group_id()), "origin_settlement_id": String(origin.get_settlement_id()), "destination_settlement_id": String(destination.get_settlement_id()), "route_id": String(route.get_route_id()), "cargo_ledger": cargo_ledger, "headcount": group.get_headcount(), "departure_tick": departure_tick, "arrival_tick": arrival_tick, "cause_event_id": cause_event_id, } func _snapshot() -> Dictionary: return { "world_state": _world_state.to_dictionary(), "scheduler": _scheduler.to_dictionary(), "event_store": _event_store.to_dictionary(), "next_event_id": _next_event_id, } func _restore_snapshot(snapshot: Dictionary) -> bool: var world_ok := _world_state.restore_from_dictionary(snapshot["world_state"]) var scheduler_ok := _scheduler.restore_from_dictionary(snapshot["scheduler"]) var events_ok := _event_store.restore_from_dictionary(snapshot["event_store"]) _next_event_id = int(snapshot["next_event_id"]) return world_ok and scheduler_ok and events_ok static func _arrival_job_id(group_id: StringName, departure_tick: int) -> StringName: return StringName("caravan_arrival:%s:%d" % [group_id, departure_tick]) static func _arrival_dedupe_key(group_id: StringName) -> StringName: return StringName("caravan_arrival:%s" % group_id) func _event_cursor_is_valid() -> bool: if _next_event_id < 0: return false var highest_event_id := -1 for event: WorldEventRecord in _event_store.get_all(): highest_event_id = maxi(highest_event_id, event.get_event_id()) return _next_event_id == highest_event_id + 1 static func _all_amounts_positive(ledger: Dictionary) -> bool: for amount: Variant in ledger.values(): if float(amount) <= 0.0: return false return not ledger.is_empty() static func _add_ledgers(first: Dictionary, second: Dictionary) -> Variant: var result: Dictionary = first.duplicate(true) for key: Variant in second: var item_id := String(key) result[item_id] = float(result.get(item_id, 0.0)) + float(second[key]) return RegionalStateSupport.normalize_amount_ledger(result) static func _subtract_ledgers(first: Dictionary, second: Dictionary) -> Variant: var result: Dictionary = first.duplicate(true) for key: Variant in second: var item_id := String(key) var remaining := float(result.get(item_id, 0.0)) - float(second[key]) if remaining < 0.0: return null result[item_id] = remaining return RegionalStateSupport.normalize_amount_ledger(result) func _fail(message: String) -> bool: _last_error = message return false