From 5cf5cfa1db0607848469abd9ea8c907ce115fd10 Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 21:37:51 +0200 Subject: [PATCH] feat: simulate authoritative caravan travel --- simulation/regional/RegionalCaravanService.gd | 702 ++++++++++++++++++ .../regional/RegionalCaravanService.gd.uid | 1 + tests/unit/test_regional_caravan_service.gd | 340 +++++++++ .../unit/test_regional_caravan_service.gd.uid | 1 + 4 files changed, 1044 insertions(+) create mode 100644 simulation/regional/RegionalCaravanService.gd create mode 100644 simulation/regional/RegionalCaravanService.gd.uid create mode 100644 tests/unit/test_regional_caravan_service.gd create mode 100644 tests/unit/test_regional_caravan_service.gd.uid diff --git a/simulation/regional/RegionalCaravanService.gd b/simulation/regional/RegionalCaravanService.gd new file mode 100644 index 0000000..ff1edab --- /dev/null +++ b/simulation/regional/RegionalCaravanService.gd @@ -0,0 +1,702 @@ +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 diff --git a/simulation/regional/RegionalCaravanService.gd.uid b/simulation/regional/RegionalCaravanService.gd.uid new file mode 100644 index 0000000..c1a35ff --- /dev/null +++ b/simulation/regional/RegionalCaravanService.gd.uid @@ -0,0 +1 @@ +uid://cqi7co2j3lefo diff --git a/tests/unit/test_regional_caravan_service.gd b/tests/unit/test_regional_caravan_service.gd new file mode 100644 index 0000000..b536eaf --- /dev/null +++ b/tests/unit/test_regional_caravan_service.gd @@ -0,0 +1,340 @@ +extends GutTest + +const ORIGIN_FOOD := 20.0 +const DESTINATION_FOOD := 4.0 +const CARGO_FOOD := 6.0 +const DEPARTURE_TICK := 10 +const ARRIVAL_TICK := 15 + + +func test_departure_and_arrival_conserve_cargo_headcount_and_record_exact_facts() -> void: + var service := _fixture_service() + var headcount_before := _regional_headcount(service.get_world_state()) + var food_before := _regional_food(service.get_world_state()) + + assert_true(_depart(service, &"never")) + assert_eq(service.get_scheduler().size(), 1) + assert_true(service.get_world_state().get_mobile_group(&"group_caravan").is_on_route()) + assert_eq( + service.get_world_state().get_settlement(&"settlement_origin").get_stockpile_amount( + &"food" + ), + ORIGIN_FOOD - CARGO_FOOD + ) + assert_eq( + service.get_world_state().get_mobile_group(&"group_caravan").get_cargo_amount(&"food"), + CARGO_FOOD + ) + assert_eq(_event_types(service.get_event_store()), _departure_event_types()) + assert_eq(_regional_food(service.get_world_state()), food_before) + assert_eq(_regional_headcount(service.get_world_state()), headcount_before) + + var executed := service.process_due(ARRIVAL_TICK, 1, &"always") + assert_eq(executed.size(), 1) + assert_eq(executed[0].get_due_tick(), ARRIVAL_TICK) + assert_true(service.get_scheduler().is_empty()) + var group := service.get_world_state().get_mobile_group(&"group_caravan") + assert_true(group.is_at_location()) + assert_eq(group.get_current_location_id(), &"location_destination") + assert_eq(group.get_cargo_amount(&"food"), 0.0) + assert_eq( + service.get_world_state().get_settlement(&"settlement_destination").get_stockpile_amount( + &"food" + ), + DESTINATION_FOOD + CARGO_FOOD + ) + assert_eq(_regional_food(service.get_world_state()), food_before) + assert_eq(_regional_headcount(service.get_world_state()), headcount_before) + assert_eq(_event_types(service.get_event_store()), _all_event_types()) + _assert_causal_event_chain(service.get_event_store()) + + +func test_budgets_one_eight_and_unlimited_finish_with_identical_state_and_events() -> void: + var budget_one := _run_complete(1, &"never") + var budget_eight := _run_complete(8, &"never") + var unlimited := _run_complete(RegionalJobScheduler.UNLIMITED_BUDGET, &"never") + + assert_eq(budget_one, budget_eight) + assert_eq(budget_one, unlimited) + assert_eq(budget_one["event_types"], _all_event_types()) + + +func test_save_load_mid_route_continues_exactly() -> void: + var uninterrupted := _fixture_service() + assert_true(_depart(uninterrupted, &"never")) + var saved := uninterrupted.to_dictionary() + var restored := RegionalCaravanService.from_dictionary(saved) + + assert_not_null(restored) + assert_eq(restored.to_dictionary(), saved) + assert_eq(restored.get_next_event_id(), 2) + assert_eq(restored.get_registered_job_types(), [RegionalCaravanService.JOB_TYPE_ARRIVAL]) + assert_eq(restored.process_due(ARRIVAL_TICK, 1).size(), 1) + assert_eq(uninterrupted.process_due(ARRIVAL_TICK, 1).size(), 1) + assert_eq(restored.to_dictionary(), uninterrupted.to_dictionary()) + assert_eq(restored.checksum(), uninterrupted.checksum()) + var stale_cursor := restored.to_dictionary() + stale_cursor["next_event_id"] = 3 + assert_null(RegionalCaravanService.from_dictionary(stale_cursor)) + var skipped_cursor := restored.to_dictionary() + skipped_cursor["next_event_id"] = 9 + assert_null(RegionalCaravanService.from_dictionary(skipped_cursor)) + var coerced_cursor := restored.to_dictionary() + coerced_cursor["next_event_id"] = "4" + assert_null(RegionalCaravanService.from_dictionary(coerced_cursor)) + + +func test_presentation_loading_modes_are_inert_observers() -> void: + var always := _run_complete(1, &"always") + var never := _run_complete(1, &"never") + var load_unload := _fixture_service() + assert_true(_depart(load_unload, &"loaded")) + assert_eq(load_unload.process_due(ARRIVAL_TICK, 1, &"unloaded").size(), 1) + var load_unload_result := _result(load_unload) + + assert_eq(always, never) + assert_eq(always, load_unload_result) + + +func test_invalid_and_duplicate_jobs_roll_back_without_partial_authority() -> void: + var service := _fixture_service() + assert_not_null( + service.get_scheduler().schedule_new( + &"occupied_arrival", + RegionalCaravanService.JOB_TYPE_ARRIVAL, + 99, + RegionalCaravanService.JOB_PHASE_ARRIVAL, + &"group_caravan", + {}, + &"caravan_arrival:group_caravan" + ) + ) + var before := service.to_dictionary() + assert_false(_depart(service, &"never")) + assert_eq(service.to_dictionary(), before) + assert_true("conflicts" in service.get_last_error()) + + var invalid_service := _fixture_service() + assert_true(_depart(invalid_service, &"never")) + var scheduled := invalid_service.get_scheduler().get_all_sorted()[0] + var bad_data := scheduled.to_dictionary() + bad_data["payload"]["destination_settlement_id"] = "missing_settlement" + assert_not_null(invalid_service.get_scheduler().cancel(scheduled.get_job_id())) + assert_true( + invalid_service.get_scheduler().schedule(ScheduledJobRecord.from_dictionary(bad_data)) + ) + var invalid_before := invalid_service.to_dictionary() + assert_true(invalid_service.process_due(ARRIVAL_TICK, 1).is_empty()) + assert_eq(invalid_service.to_dictionary(), invalid_before) + assert_true("failed atomically" in invalid_service.get_last_error()) + + var forged_service := _fixture_service() + assert_true(_depart(forged_service, &"never")) + var forged_envelope := forged_service.to_dictionary() + forged_envelope["event_store"]["events"][1]["event_type"] = "unrelated_fact" + assert_null(RegionalCaravanService.from_dictionary(forged_envelope)) + + +func test_restore_rejects_a_duplicate_but_unrelated_departure_pair() -> void: + var service := _fixture_service() + assert_true(_depart(service, &"never")) + var envelope := service.to_dictionary() + var duplicate_load: Dictionary = envelope["event_store"]["events"][0].duplicate(true) + var duplicate_depart: Dictionary = envelope["event_store"]["events"][1].duplicate(true) + duplicate_load["event_id"] = 2 + duplicate_depart["event_id"] = 3 + duplicate_depart["payload"]["cause_event_id"] = 2 + envelope["event_store"]["events"].append(duplicate_load) + envelope["event_store"]["events"].append(duplicate_depart) + envelope["scheduler"]["jobs"][0]["payload"]["depart_event_id"] = 3 + envelope["next_event_id"] = 4 + + assert_not_null(WorldEventStore.from_dictionary(envelope["event_store"])) + assert_not_null(RegionalJobScheduler.from_dictionary(envelope["scheduler"])) + assert_null(RegionalCaravanService.from_dictionary(envelope)) + + +func _fixture_service() -> RegionalCaravanService: + var origin_location := LocationStateRecord.create( + &"location_origin", &"regional_world", &"settlement", "Origin", _address(&"location_origin") + ) + var destination_location := LocationStateRecord.create( + &"location_destination", + &"regional_world", + &"settlement", + "Destination", + _address(&"location_destination") + ) + var origin := SettlementStateRecord.create( + &"settlement_origin", + &"location_origin", + "Origin", + &"polity_origin", + 1, + &"founders_origin", + 0, + {&"food": ORIGIN_FOOD} + ) + var destination := SettlementStateRecord.create( + &"settlement_destination", + &"location_destination", + "Destination", + &"polity_destination", + 1, + &"founders_destination", + 0, + {&"food": DESTINATION_FOOD} + ) + var route := RouteStateRecord.create( + &"route_trade", &"location_origin", &"location_destination", 5 + ) + var group := MobileGroupStateRecord.create_at_location( + &"group_caravan", + &"caravan", + "Caravan", + &"polity_origin", + _address(&"location_origin"), + [&"person_merchant"], + {&"cohort_guards": 2}, + {}, + 10.0 + ) + var person := PersonStateRecord.create_in_group( + &"person_merchant", + "Merchant", + &"merchant", + &"polity_origin", + &"location_origin", + &"group_caravan" + ) + var guards := PopulationCohortRecord.create_in_group( + &"cohort_guards", &"guards", &"polity_origin", 2, &"group_caravan" + ) + var origin_polity := PolityStateRecord.create( + &"polity_origin", "Origin polity", &"location_origin", [&"settlement_origin"] + ) + var destination_polity := PolityStateRecord.create( + &"polity_destination", + "Destination polity", + &"location_destination", + [&"settlement_destination"] + ) + var locations: Array[LocationStateRecord] = [destination_location, origin_location] + var settlements: Array[SettlementStateRecord] = [destination, origin] + var routes: Array[RouteStateRecord] = [route] + var groups: Array[MobileGroupStateRecord] = [group] + var persons: Array[PersonStateRecord] = [person] + var cohorts: Array[PopulationCohortRecord] = [guards] + var polities: Array[PolityStateRecord] = [destination_polity, origin_polity] + var relations: Array[DiplomaticRelationRecord] = [] + var world_state := RegionalWorldState.create( + &"regional_world", + locations, + settlements, + routes, + groups, + persons, + cohorts, + polities, + relations + ) + return RegionalCaravanService.create( + world_state, RegionalJobScheduler.new(), WorldEventStore.new() + ) + + +func _depart(service: RegionalCaravanService, presentation_mode: StringName) -> bool: + return service.depart( + &"group_caravan", + &"settlement_origin", + &"settlement_destination", + &"route_trade", + {&"food": CARGO_FOOD}, + DEPARTURE_TICK, + presentation_mode + ) + + +func _run_complete(budget: int, presentation_mode: StringName) -> Dictionary: + var service := _fixture_service() + assert_true(_depart(service, presentation_mode)) + assert_eq(service.process_due(ARRIVAL_TICK, budget, presentation_mode).size(), 1) + return _result(service) + + +func _result(service: RegionalCaravanService) -> Dictionary: + return { + "world_state": service.get_world_state().to_dictionary(), + "scheduler": service.get_scheduler().to_dictionary(), + "events": service.get_event_store().to_dictionary(), + "next_event_id": service.get_next_event_id(), + "event_types": _event_types(service.get_event_store()), + } + + +func _address(location_id: StringName) -> SpatialAddress: + return SpatialAddress.create(&"regional_world", location_id, Vector3.ZERO) + + +func _regional_food(state: RegionalWorldState) -> float: + var total := 0.0 + for settlement: SettlementStateRecord in state.get_all_settlements(): + total += settlement.get_stockpile_amount(&"food") + for group: MobileGroupStateRecord in state.get_all_mobile_groups(): + total += group.get_cargo_amount(&"food") + return total + + +func _regional_headcount(state: RegionalWorldState) -> int: + var total := 0 + for group: MobileGroupStateRecord in state.get_all_mobile_groups(): + total += group.get_headcount() + for cohort: PopulationCohortRecord in state.get_all_population_cohorts(): + if cohort.is_at_location(): + total += cohort.get_count() + for person: PersonStateRecord in state.get_all_persons(): + if person.is_at_location(): + total += 1 + return total + + +func _event_types(store: WorldEventStore) -> Array[StringName]: + var types: Array[StringName] = [] + for event: WorldEventRecord in store.get_all(): + types.append(event.get_event_type()) + return types + + +func _departure_event_types() -> Array[StringName]: + return [ + RegionalCaravanService.EVENT_CARGO_LOADED, + RegionalCaravanService.EVENT_DEPARTED, + ] + + +func _all_event_types() -> Array[StringName]: + return [ + RegionalCaravanService.EVENT_CARGO_LOADED, + RegionalCaravanService.EVENT_DEPARTED, + RegionalCaravanService.EVENT_ARRIVED, + RegionalCaravanService.EVENT_CARGO_DEPOSITED, + ] + + +func _assert_causal_event_chain(store: WorldEventStore) -> void: + var events := store.get_all() + assert_eq(events[0].get_payload()["cause_event_id"], -1) + assert_eq(events[1].get_payload()["cause_event_id"], events[0].get_event_id()) + assert_eq(events[2].get_payload()["cause_event_id"], events[1].get_event_id()) + assert_eq(events[3].get_payload()["cause_event_id"], events[2].get_event_id()) + for event: WorldEventRecord in events: + assert_eq(event.get_payload()["cargo_ledger"], {"food": CARGO_FOOD}) + assert_eq(event.get_payload()["headcount"], 3) + assert_not_null(event.get_participant(RegionalCaravanService.ROLE_CARAVAN)) + assert_not_null(event.get_participant(RegionalCaravanService.ROLE_ROUTE)) + assert_not_null(event.get_participant(RegionalCaravanService.ROLE_ORIGIN)) + assert_not_null(event.get_participant(RegionalCaravanService.ROLE_DESTINATION)) + var caravan_ref := WorldEntityRef.create(&"mobile_group", &"group_caravan") + assert_eq(store.get_for_participant(caravan_ref).size(), 4) + assert_eq(store.get_for_location(_address(&"location_origin")).size(), 2) + assert_eq(store.get_for_location(_address(&"location_destination")).size(), 2) diff --git a/tests/unit/test_regional_caravan_service.gd.uid b/tests/unit/test_regional_caravan_service.gd.uid new file mode 100644 index 0000000..8216fd0 --- /dev/null +++ b/tests/unit/test_regional_caravan_service.gd.uid @@ -0,0 +1 @@ +uid://du0dq07y7e007