341 lines
12 KiB
GDScript
341 lines
12 KiB
GDScript
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)
|