feat: simulate authoritative caravan travel
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cqi7co2j3lefo
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user