feat: simulate authoritative caravan travel

This commit is contained in:
Rijad Zuzo
2026-08-12 21:37:51 +02:00
parent 7c0f02a5cf
commit 5cf5cfa1db
4 changed files with 1044 additions and 0 deletions
+340
View File
@@ -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)
@@ -0,0 +1 @@
uid://du0dq07y7e007