Files
2026-08-12 21:37:51 +02:00

703 lines
21 KiB
GDScript

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