feat: bound active regional presentation
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
extends GutTest
|
||||||
|
|
||||||
|
const WORLD_ID := &"regional_bosnia"
|
||||||
|
const LOCATION_ID := &"location_jajce"
|
||||||
|
const CONTEXT_ID := &"jajce_surface"
|
||||||
|
|
||||||
|
|
||||||
|
func test_two_thousand_candidates_stay_bounded_pinned_and_order_independent() -> void:
|
||||||
|
var policy := PresentationRelevancePolicy.new()
|
||||||
|
var candidates := _large_candidate_fixture()
|
||||||
|
var first := policy.select_detailed_visuals(
|
||||||
|
_context(), _address(LOCATION_ID, Vector3.ZERO), candidates
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_true(first["valid"])
|
||||||
|
assert_eq(first["candidate_count"], 2000)
|
||||||
|
assert_eq(first["selected_ids"].size(), PresentationRelevancePolicy.DEFAULT_DETAIL_LIMIT)
|
||||||
|
assert_eq(first["mandatory_count"], 6)
|
||||||
|
for mandatory_id in _mandatory_ids():
|
||||||
|
assert_has(first["selected_ids"], mandatory_id)
|
||||||
|
var decision := policy.get_decision(first, mandatory_id)
|
||||||
|
assert_true(decision["selected"])
|
||||||
|
assert_true(decision["mandatory"])
|
||||||
|
assert_true("selected_mandatory_pin" in decision["reason_trace"])
|
||||||
|
|
||||||
|
var reordered := candidates.duplicate(true)
|
||||||
|
for candidate: Dictionary in reordered:
|
||||||
|
candidate["loaded"] = not bool(candidate["loaded"])
|
||||||
|
reordered.reverse()
|
||||||
|
var second := policy.select_detailed_visuals(
|
||||||
|
_context(), _address(LOCATION_ID, Vector3.ZERO), reordered
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_true(second["valid"])
|
||||||
|
assert_eq(second["selected_ids"], first["selected_ids"])
|
||||||
|
assert_eq(_selected_scores(second), _selected_scores(first))
|
||||||
|
|
||||||
|
|
||||||
|
func test_context_and_location_scope_override_equal_coordinates_and_foreign_pins() -> void:
|
||||||
|
var policy := PresentationRelevancePolicy.new()
|
||||||
|
var candidates: Array[Dictionary] = [
|
||||||
|
_candidate(&"local_named", LOCATION_ID, Vector3.ZERO, 0.2),
|
||||||
|
_candidate(
|
||||||
|
&"foreign_threat",
|
||||||
|
&"location_travnik",
|
||||||
|
Vector3.ZERO,
|
||||||
|
1.0,
|
||||||
|
{"player_affecting_threat": true}
|
||||||
|
),
|
||||||
|
_candidate(
|
||||||
|
&"other_context_leader",
|
||||||
|
LOCATION_ID,
|
||||||
|
Vector3.ZERO,
|
||||||
|
1.0,
|
||||||
|
{"context_id": &"jajce_interior", "leader": true}
|
||||||
|
),
|
||||||
|
]
|
||||||
|
var result := policy.select_detailed_visuals(
|
||||||
|
_context(), _address(LOCATION_ID, Vector3.ZERO), candidates, 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_true(result["valid"])
|
||||||
|
assert_eq(result["selected_ids"], [&"local_named"])
|
||||||
|
var foreign := policy.get_decision(result, &"foreign_threat")
|
||||||
|
assert_false(foreign["selected"])
|
||||||
|
assert_has(foreign["reason_trace"], "rejected_different_location")
|
||||||
|
assert_has(foreign["reason_trace"], "pin_ignored_outside_active_scope")
|
||||||
|
assert_has(foreign["reason_trace"], "pin_player_affecting_threat")
|
||||||
|
var other_context := policy.get_decision(result, &"other_context_leader")
|
||||||
|
assert_has(other_context["reason_trace"], "rejected_inactive_context")
|
||||||
|
|
||||||
|
|
||||||
|
func test_score_distance_and_stable_id_form_a_total_tie_order_with_rejection_trace() -> void:
|
||||||
|
var policy := PresentationRelevancePolicy.new()
|
||||||
|
var candidates: Array[Dictionary] = [
|
||||||
|
_candidate(&"entity_b", LOCATION_ID, Vector3(3.0, 0.0, 0.0), 0.5),
|
||||||
|
_candidate(&"entity_a", LOCATION_ID, Vector3(3.0, 0.0, 0.0), 0.5),
|
||||||
|
_candidate(&"entity_far", LOCATION_ID, Vector3(20.0, 0.0, 0.0), 0.1),
|
||||||
|
]
|
||||||
|
var result := policy.select_detailed_visuals(
|
||||||
|
_context(), _address(LOCATION_ID, Vector3.ZERO), candidates, 1
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_true(result["valid"])
|
||||||
|
assert_eq(result["selected_ids"], [&"entity_a"])
|
||||||
|
var rejected_tie := policy.get_decision(result, &"entity_b")
|
||||||
|
assert_false(rejected_tie["selected"])
|
||||||
|
assert_true(_trace_contains_prefix(rejected_tie["reason_trace"], "importance_units_"))
|
||||||
|
assert_true(_trace_contains_prefix(rejected_tie["reason_trace"], "distance_millimeters_"))
|
||||||
|
assert_true(
|
||||||
|
_trace_contains_prefix(rejected_tie["reason_trace"], "rejected_detail_capacity_rank_")
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates.reverse()
|
||||||
|
var reordered := policy.select_detailed_visuals(
|
||||||
|
_context(), _address(LOCATION_ID, Vector3.ZERO), candidates, 1
|
||||||
|
)
|
||||||
|
assert_eq(reordered["selected_ids"], result["selected_ids"])
|
||||||
|
|
||||||
|
|
||||||
|
func test_unsatisfiable_pin_overflow_fails_closed_instead_of_silently_unpinning() -> void:
|
||||||
|
var policy := PresentationRelevancePolicy.new()
|
||||||
|
var candidates: Array[Dictionary] = [
|
||||||
|
_candidate(&"leader_a", LOCATION_ID, Vector3.ZERO, 0.1, {"leader": true}),
|
||||||
|
_candidate(&"leader_b", LOCATION_ID, Vector3.ONE, 0.2, {"leader": true}),
|
||||||
|
_candidate(&"leader_c", LOCATION_ID, Vector3.RIGHT, 0.3, {"leader": true}),
|
||||||
|
]
|
||||||
|
var result := policy.select_detailed_visuals(
|
||||||
|
_context(), _address(LOCATION_ID, Vector3.ZERO), candidates, 2
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_false(result["valid"])
|
||||||
|
assert_true(result["selected_ids"].is_empty())
|
||||||
|
assert_eq(result["mandatory_count"], 3)
|
||||||
|
assert_true("mandatory_pin_overflow" in String(result["errors"][0]))
|
||||||
|
for candidate in candidates:
|
||||||
|
var decision := policy.get_decision(result, candidate["stable_id"])
|
||||||
|
assert_has(decision["reason_trace"], "rejected_unsatisfiable_pin_overflow")
|
||||||
|
|
||||||
|
|
||||||
|
func test_navigation_request_budget_grants_only_two_new_stable_requests_per_frame() -> void:
|
||||||
|
var budget := PresentationNavRequestBudget.new()
|
||||||
|
assert_true(budget.enqueue(&"entity_c", 5, ["relevant"]))
|
||||||
|
assert_true(budget.enqueue(&"entity_b", 10, ["threat"]))
|
||||||
|
assert_true(budget.enqueue(&"entity_a", 10, ["leader"]))
|
||||||
|
assert_true(budget.enqueue(&"entity_d", 1, ["nearby"]))
|
||||||
|
assert_false(budget.enqueue(&"entity_a", 99, ["duplicate"]))
|
||||||
|
|
||||||
|
var first_frame := budget.take_new_requests(42)
|
||||||
|
assert_eq(_request_ids(first_frame), [&"entity_a", &"entity_b"])
|
||||||
|
assert_eq(first_frame.size(), PresentationNavRequestBudget.MAX_NEW_REQUESTS_PER_FRAME)
|
||||||
|
assert_true(budget.take_new_requests(42).is_empty())
|
||||||
|
assert_eq(budget.get_in_flight_ids(), [&"entity_a", &"entity_b"])
|
||||||
|
assert_eq(budget.get_pending_ids(), [&"entity_c", &"entity_d"])
|
||||||
|
|
||||||
|
var next_frame := budget.take_new_requests(43)
|
||||||
|
assert_eq(_request_ids(next_frame), [&"entity_c", &"entity_d"])
|
||||||
|
assert_true(budget.complete(&"entity_a"))
|
||||||
|
assert_true(budget.enqueue(&"entity_a", 2, ["retargeted"]))
|
||||||
|
assert_eq(_request_ids(budget.take_new_requests(44)), [&"entity_a"])
|
||||||
|
|
||||||
|
|
||||||
|
func _large_candidate_fixture() -> Array[Dictionary]:
|
||||||
|
var candidates: Array[Dictionary] = []
|
||||||
|
for index in 2000:
|
||||||
|
var extras := {}
|
||||||
|
match index:
|
||||||
|
1994:
|
||||||
|
extras["pinned"] = true
|
||||||
|
1995:
|
||||||
|
extras["player_affecting_threat"] = true
|
||||||
|
1996:
|
||||||
|
extras["situation_participant"] = true
|
||||||
|
1997:
|
||||||
|
extras["commitment_participant"] = true
|
||||||
|
1998:
|
||||||
|
extras["leader"] = true
|
||||||
|
1999:
|
||||||
|
extras["unique_owner"] = true
|
||||||
|
var position := Vector3(float(index % 73), 0.0, float((index * 11) % 89))
|
||||||
|
var importance := float((index * 37) % 1000) / 999.0
|
||||||
|
var candidate := _candidate(
|
||||||
|
StringName("named_entity_%04d" % index), LOCATION_ID, position, importance, extras
|
||||||
|
)
|
||||||
|
candidate["loaded"] = index % 3 == 0
|
||||||
|
candidates.append(candidate)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
func _mandatory_ids() -> Array[StringName]:
|
||||||
|
return [
|
||||||
|
&"named_entity_1994",
|
||||||
|
&"named_entity_1995",
|
||||||
|
&"named_entity_1996",
|
||||||
|
&"named_entity_1997",
|
||||||
|
&"named_entity_1998",
|
||||||
|
&"named_entity_1999",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
func _candidate(
|
||||||
|
stable_id: StringName,
|
||||||
|
location_id: StringName,
|
||||||
|
position: Vector3,
|
||||||
|
importance: float,
|
||||||
|
extras: Dictionary = {}
|
||||||
|
) -> Dictionary:
|
||||||
|
var descriptor := {
|
||||||
|
"stable_id": stable_id,
|
||||||
|
"context_id": CONTEXT_ID,
|
||||||
|
"address": _address(location_id, position),
|
||||||
|
"importance": importance,
|
||||||
|
"named": true,
|
||||||
|
"loaded": false,
|
||||||
|
}
|
||||||
|
for field in extras:
|
||||||
|
descriptor[field] = extras[field]
|
||||||
|
return descriptor
|
||||||
|
|
||||||
|
|
||||||
|
func _context() -> Dictionary:
|
||||||
|
return {"context_id": CONTEXT_ID, "world_id": WORLD_ID, "location_id": LOCATION_ID}
|
||||||
|
|
||||||
|
|
||||||
|
func _address(location_id: StringName, position: Vector3) -> SpatialAddress:
|
||||||
|
return SpatialAddress.create(WORLD_ID, location_id, position)
|
||||||
|
|
||||||
|
|
||||||
|
func _selected_scores(result: Dictionary) -> Array[int]:
|
||||||
|
var scores: Array[int] = []
|
||||||
|
for decision: Dictionary in result["selected_details"]:
|
||||||
|
scores.append(int(decision["score_units"]))
|
||||||
|
return scores
|
||||||
|
|
||||||
|
|
||||||
|
func _request_ids(requests: Array[Dictionary]) -> Array[StringName]:
|
||||||
|
var ids: Array[StringName] = []
|
||||||
|
for request in requests:
|
||||||
|
ids.append(StringName(request["stable_id"]))
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
func _trace_contains_prefix(trace: Array, prefix: String) -> bool:
|
||||||
|
for reason in trace:
|
||||||
|
if String(reason).begins_with(prefix):
|
||||||
|
return true
|
||||||
|
return false
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bgonghipke3xq
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
class_name PresentationNavRequestBudget
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const MAX_NEW_REQUESTS_PER_FRAME := 2
|
||||||
|
|
||||||
|
var _pending_by_id: Dictionary = {}
|
||||||
|
var _in_flight_ids: Dictionary = {}
|
||||||
|
var _last_drained_frame := -1
|
||||||
|
|
||||||
|
|
||||||
|
func enqueue(stable_id: StringName, priority: int = 0, reason_trace: Array[String] = []) -> bool:
|
||||||
|
if stable_id.is_empty() or _pending_by_id.has(stable_id) or _in_flight_ids.has(stable_id):
|
||||||
|
return false
|
||||||
|
var normalized_trace := reason_trace.duplicate()
|
||||||
|
normalized_trace.sort()
|
||||||
|
_pending_by_id[stable_id] = {
|
||||||
|
"stable_id": stable_id,
|
||||||
|
"priority": priority,
|
||||||
|
"reason_trace": normalized_trace,
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
|
||||||
|
|
||||||
|
func take_new_requests(frame_index: int) -> Array[Dictionary]:
|
||||||
|
var result: Array[Dictionary] = []
|
||||||
|
if frame_index < 0 or frame_index <= _last_drained_frame:
|
||||||
|
return result
|
||||||
|
_last_drained_frame = frame_index
|
||||||
|
var pending: Array[Dictionary] = []
|
||||||
|
for request in _pending_by_id.values():
|
||||||
|
pending.append((request as Dictionary).duplicate(true))
|
||||||
|
pending.sort_custom(_request_before)
|
||||||
|
for index in mini(MAX_NEW_REQUESTS_PER_FRAME, pending.size()):
|
||||||
|
var request: Dictionary = pending[index]
|
||||||
|
var stable_id: StringName = request["stable_id"]
|
||||||
|
_pending_by_id.erase(stable_id)
|
||||||
|
_in_flight_ids[stable_id] = true
|
||||||
|
request["reason_trace"].append("granted_frame_%d" % frame_index)
|
||||||
|
result.append(request)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func complete(stable_id: StringName) -> bool:
|
||||||
|
return _in_flight_ids.erase(stable_id)
|
||||||
|
|
||||||
|
|
||||||
|
func cancel(stable_id: StringName) -> bool:
|
||||||
|
var changed := _pending_by_id.erase(stable_id)
|
||||||
|
return _in_flight_ids.erase(stable_id) or changed
|
||||||
|
|
||||||
|
|
||||||
|
func get_pending_ids() -> Array[StringName]:
|
||||||
|
var result: Array[StringName] = []
|
||||||
|
for stable_id in _pending_by_id:
|
||||||
|
result.append(StringName(stable_id))
|
||||||
|
result.sort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func get_in_flight_ids() -> Array[StringName]:
|
||||||
|
var result: Array[StringName] = []
|
||||||
|
for stable_id in _in_flight_ids:
|
||||||
|
result.append(StringName(stable_id))
|
||||||
|
result.sort()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func clear() -> void:
|
||||||
|
_pending_by_id.clear()
|
||||||
|
_in_flight_ids.clear()
|
||||||
|
_last_drained_frame = -1
|
||||||
|
|
||||||
|
|
||||||
|
func _request_before(first: Dictionary, second: Dictionary) -> bool:
|
||||||
|
if int(first["priority"]) != int(second["priority"]):
|
||||||
|
return int(first["priority"]) > int(second["priority"])
|
||||||
|
return String(first["stable_id"]) < String(second["stable_id"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://buboke5ox8pvq
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
class_name PresentationRelevancePolicy
|
||||||
|
extends RefCounted
|
||||||
|
|
||||||
|
const DEFAULT_DETAIL_LIMIT := 40
|
||||||
|
const MAX_DETAIL_LIMIT := 40
|
||||||
|
const IMPORTANCE_SCORE_SCALE := 1_000_000
|
||||||
|
const NAMED_SCORE_BONUS := 125_000
|
||||||
|
const MAX_DISTANCE_PENALTY := 1_000_000
|
||||||
|
|
||||||
|
const PIN_FIELDS := {
|
||||||
|
"pinned": "pin_explicit",
|
||||||
|
"player_affecting_threat": "pin_player_affecting_threat",
|
||||||
|
"situation_participant": "pin_situation_participant",
|
||||||
|
"commitment_participant": "pin_commitment_participant",
|
||||||
|
"leader": "pin_leader",
|
||||||
|
"unique_owner": "pin_unique_owner",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ALLOWED_DESCRIPTOR_FIELDS := {
|
||||||
|
"stable_id": true,
|
||||||
|
"context_id": true,
|
||||||
|
"address": true,
|
||||||
|
"importance": true,
|
||||||
|
"named": true,
|
||||||
|
"loaded": true,
|
||||||
|
"pinned": true,
|
||||||
|
"player_affecting_threat": true,
|
||||||
|
"situation_participant": true,
|
||||||
|
"commitment_participant": true,
|
||||||
|
"leader": true,
|
||||||
|
"unique_owner": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func select_detailed_visuals(
|
||||||
|
active_context: Dictionary,
|
||||||
|
player_address: SpatialAddress,
|
||||||
|
candidate_descriptors: Array,
|
||||||
|
detail_limit: int = DEFAULT_DETAIL_LIMIT
|
||||||
|
) -> Dictionary:
|
||||||
|
var errors: Array[String] = []
|
||||||
|
var normalized_context := _normalize_context(active_context, errors)
|
||||||
|
_validate_player_address(player_address, normalized_context, errors)
|
||||||
|
if detail_limit < 1 or detail_limit > MAX_DETAIL_LIMIT:
|
||||||
|
errors.append("detail_limit must be between 1 and %d" % MAX_DETAIL_LIMIT)
|
||||||
|
if not errors.is_empty():
|
||||||
|
return _invalid_result(detail_limit, errors)
|
||||||
|
|
||||||
|
var ranked: Array[Dictionary] = []
|
||||||
|
var rejected: Array[Dictionary] = []
|
||||||
|
var seen_ids: Dictionary = {}
|
||||||
|
for candidate_index in candidate_descriptors.size():
|
||||||
|
var raw_candidate: Variant = candidate_descriptors[candidate_index]
|
||||||
|
var candidate_errors: Array[String] = []
|
||||||
|
var normalized := _normalize_candidate(raw_candidate, candidate_index, candidate_errors)
|
||||||
|
if not candidate_errors.is_empty():
|
||||||
|
errors.append_array(candidate_errors)
|
||||||
|
continue
|
||||||
|
var stable_id: StringName = normalized["stable_id"]
|
||||||
|
if seen_ids.has(stable_id):
|
||||||
|
errors.append("duplicate candidate stable_id '%s'" % stable_id)
|
||||||
|
continue
|
||||||
|
seen_ids[stable_id] = true
|
||||||
|
var scope_rejection := _scope_rejection(normalized, normalized_context)
|
||||||
|
if not scope_rejection.is_empty():
|
||||||
|
rejected.append(_rejected_scope_decision(normalized, scope_rejection))
|
||||||
|
continue
|
||||||
|
ranked.append(_rank_candidate(normalized, player_address))
|
||||||
|
|
||||||
|
if not errors.is_empty():
|
||||||
|
return _invalid_result(detail_limit, errors)
|
||||||
|
|
||||||
|
ranked.sort_custom(_rank_before)
|
||||||
|
var mandatory_count := 0
|
||||||
|
for entry in ranked:
|
||||||
|
if bool(entry["mandatory"]):
|
||||||
|
mandatory_count += 1
|
||||||
|
if mandatory_count > detail_limit:
|
||||||
|
return _pin_overflow_result(detail_limit, mandatory_count, ranked, rejected)
|
||||||
|
|
||||||
|
var selected_ids: Array[StringName] = []
|
||||||
|
var selected_details: Array[Dictionary] = []
|
||||||
|
for rank_index in ranked.size():
|
||||||
|
var entry: Dictionary = ranked[rank_index]
|
||||||
|
var decision := _decision_from_ranked_entry(entry)
|
||||||
|
if rank_index < detail_limit:
|
||||||
|
decision["selected"] = true
|
||||||
|
decision["reason_trace"].append(
|
||||||
|
(
|
||||||
|
"selected_mandatory_pin"
|
||||||
|
if bool(entry["mandatory"])
|
||||||
|
else "selected_rank_%d" % rank_index
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selected_ids.append(entry["stable_id"])
|
||||||
|
selected_details.append(decision)
|
||||||
|
else:
|
||||||
|
decision["reason_trace"].append("rejected_detail_capacity_rank_%d" % rank_index)
|
||||||
|
rejected.append(decision)
|
||||||
|
|
||||||
|
rejected.sort_custom(_stable_id_before)
|
||||||
|
var decisions: Array[Dictionary] = selected_details.duplicate(true)
|
||||||
|
decisions.append_array(rejected)
|
||||||
|
decisions.sort_custom(_stable_id_before)
|
||||||
|
return {
|
||||||
|
"valid": true,
|
||||||
|
"detail_limit": detail_limit,
|
||||||
|
"candidate_count": candidate_descriptors.size(),
|
||||||
|
"eligible_count": ranked.size(),
|
||||||
|
"mandatory_count": mandatory_count,
|
||||||
|
"selected_ids": selected_ids,
|
||||||
|
"selected_details": selected_details,
|
||||||
|
"rejected_ids": _decision_ids(rejected),
|
||||||
|
"decisions": decisions,
|
||||||
|
"errors": [] as Array[String],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func get_decision(result: Dictionary, stable_id: StringName) -> Dictionary:
|
||||||
|
for value in result.get("decisions", []):
|
||||||
|
var decision := value as Dictionary
|
||||||
|
if StringName(decision.get("stable_id", &"")) == stable_id:
|
||||||
|
return decision.duplicate(true)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
func _normalize_context(active_context: Dictionary, errors: Array[String]) -> Dictionary:
|
||||||
|
var result := {}
|
||||||
|
for field in ["context_id", "world_id", "location_id"]:
|
||||||
|
var value: Variant = active_context.get(field)
|
||||||
|
if (value is not String and value is not StringName) or String(value).is_empty():
|
||||||
|
errors.append("active_context.%s must be a non-empty stable ID" % field)
|
||||||
|
continue
|
||||||
|
result[field] = StringName(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _validate_player_address(
|
||||||
|
player_address: SpatialAddress, active_context: Dictionary, errors: Array[String]
|
||||||
|
) -> void:
|
||||||
|
if player_address == null or not player_address.is_valid():
|
||||||
|
errors.append("player_address must be a valid SpatialAddress")
|
||||||
|
return
|
||||||
|
if active_context.is_empty():
|
||||||
|
return
|
||||||
|
if player_address.get_world_id() != active_context.get("world_id", &""):
|
||||||
|
errors.append("player_address belongs to another world")
|
||||||
|
if player_address.get_location_id() != active_context.get("location_id", &""):
|
||||||
|
errors.append("player_address belongs to another location")
|
||||||
|
|
||||||
|
|
||||||
|
func _normalize_candidate(
|
||||||
|
raw_candidate: Variant, candidate_index: int, errors: Array[String]
|
||||||
|
) -> Dictionary:
|
||||||
|
if raw_candidate is not Dictionary:
|
||||||
|
errors.append("candidate %d must be a Dictionary descriptor" % candidate_index)
|
||||||
|
return {}
|
||||||
|
var descriptor: Dictionary = raw_candidate
|
||||||
|
for field in descriptor:
|
||||||
|
if not ALLOWED_DESCRIPTOR_FIELDS.has(String(field)):
|
||||||
|
errors.append("candidate %d has unsupported field '%s'" % [candidate_index, field])
|
||||||
|
|
||||||
|
var stable_id_value: Variant = descriptor.get("stable_id")
|
||||||
|
var context_id_value: Variant = descriptor.get("context_id")
|
||||||
|
var stable_id := _normalize_descriptor_id(stable_id_value, candidate_index, "stable_id", errors)
|
||||||
|
var context_id := _normalize_descriptor_id(
|
||||||
|
context_id_value, candidate_index, "context_id", errors
|
||||||
|
)
|
||||||
|
var address := descriptor.get("address") as SpatialAddress
|
||||||
|
if address == null or not address.is_valid():
|
||||||
|
errors.append("candidate %d address must be a valid SpatialAddress" % candidate_index)
|
||||||
|
elif address.get_world_id().is_empty() or address.get_location_id().is_empty():
|
||||||
|
errors.append("candidate %d address must name a world and location" % candidate_index)
|
||||||
|
|
||||||
|
var importance_value: Variant = descriptor.get("importance")
|
||||||
|
var importance := 0.0
|
||||||
|
if importance_value is not int and importance_value is not float:
|
||||||
|
errors.append("candidate %d importance must be numeric" % candidate_index)
|
||||||
|
else:
|
||||||
|
importance = float(importance_value)
|
||||||
|
if not is_finite(importance) or importance < 0.0 or importance > 1.0:
|
||||||
|
errors.append(
|
||||||
|
"candidate %d importance must be finite and within [0, 1]" % candidate_index
|
||||||
|
)
|
||||||
|
|
||||||
|
var normalized := {
|
||||||
|
"stable_id": stable_id,
|
||||||
|
"context_id": context_id,
|
||||||
|
"address": address.duplicate_address() if address != null else null,
|
||||||
|
"importance": importance,
|
||||||
|
"named": _normalize_bool(descriptor, "named", candidate_index, errors, false),
|
||||||
|
"loaded": _normalize_bool(descriptor, "loaded", candidate_index, errors, false),
|
||||||
|
}
|
||||||
|
for pin_field in PIN_FIELDS:
|
||||||
|
normalized[pin_field] = _normalize_bool(
|
||||||
|
descriptor, String(pin_field), candidate_index, errors, false
|
||||||
|
)
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
func _normalize_descriptor_id(
|
||||||
|
value: Variant, candidate_index: int, field: String, errors: Array[String]
|
||||||
|
) -> StringName:
|
||||||
|
if (value is not String and value is not StringName) or String(value).is_empty():
|
||||||
|
errors.append("candidate %d %s must be a non-empty stable ID" % [candidate_index, field])
|
||||||
|
return &""
|
||||||
|
return StringName(value)
|
||||||
|
|
||||||
|
|
||||||
|
func _normalize_bool(
|
||||||
|
descriptor: Dictionary,
|
||||||
|
field: String,
|
||||||
|
candidate_index: int,
|
||||||
|
errors: Array[String],
|
||||||
|
default_value: bool
|
||||||
|
) -> bool:
|
||||||
|
if not descriptor.has(field):
|
||||||
|
return default_value
|
||||||
|
var value: Variant = descriptor[field]
|
||||||
|
if value is not bool:
|
||||||
|
errors.append("candidate %d %s must be bool" % [candidate_index, field])
|
||||||
|
return default_value
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
func _scope_rejection(candidate: Dictionary, active_context: Dictionary) -> String:
|
||||||
|
if candidate["context_id"] != active_context["context_id"]:
|
||||||
|
return "rejected_inactive_context"
|
||||||
|
var address: SpatialAddress = candidate["address"]
|
||||||
|
if address.get_world_id() != active_context["world_id"]:
|
||||||
|
return "rejected_different_world"
|
||||||
|
if address.get_location_id() != active_context["location_id"]:
|
||||||
|
return "rejected_different_location"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
func _rejected_scope_decision(candidate: Dictionary, reason: String) -> Dictionary:
|
||||||
|
var pin_reasons := _pin_reasons(candidate)
|
||||||
|
var trace: Array[String] = [reason]
|
||||||
|
if not pin_reasons.is_empty():
|
||||||
|
trace.append("pin_ignored_outside_active_scope")
|
||||||
|
trace.append_array(pin_reasons)
|
||||||
|
return {
|
||||||
|
"stable_id": candidate["stable_id"],
|
||||||
|
"selected": false,
|
||||||
|
"mandatory": not pin_reasons.is_empty(),
|
||||||
|
"score_units": 0,
|
||||||
|
"distance_millimeters": -1,
|
||||||
|
"reason_trace": trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _rank_candidate(candidate: Dictionary, player_address: SpatialAddress) -> Dictionary:
|
||||||
|
var address: SpatialAddress = candidate["address"]
|
||||||
|
var distance_millimeters := roundi(
|
||||||
|
player_address.get_position().distance_to(address.get_position()) * 1000.0
|
||||||
|
)
|
||||||
|
var importance_units := roundi(float(candidate["importance"]) * IMPORTANCE_SCORE_SCALE)
|
||||||
|
var named_bonus := NAMED_SCORE_BONUS if bool(candidate["named"]) else 0
|
||||||
|
var distance_penalty := mini(distance_millimeters, MAX_DISTANCE_PENALTY)
|
||||||
|
var pin_reasons := _pin_reasons(candidate)
|
||||||
|
var trace: Array[String] = [
|
||||||
|
"eligible_active_context",
|
||||||
|
"importance_units_%d" % importance_units,
|
||||||
|
"distance_millimeters_%d" % distance_millimeters,
|
||||||
|
]
|
||||||
|
if bool(candidate["named"]):
|
||||||
|
trace.append("named_entity_bonus_%d" % NAMED_SCORE_BONUS)
|
||||||
|
if bool(candidate["loaded"]):
|
||||||
|
trace.append("load_state_ignored")
|
||||||
|
trace.append_array(pin_reasons)
|
||||||
|
return {
|
||||||
|
"stable_id": candidate["stable_id"],
|
||||||
|
"mandatory": not pin_reasons.is_empty(),
|
||||||
|
"score_units": importance_units + named_bonus - distance_penalty,
|
||||||
|
"distance_millimeters": distance_millimeters,
|
||||||
|
"reason_trace": trace,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _pin_reasons(candidate: Dictionary) -> Array[String]:
|
||||||
|
var result: Array[String] = []
|
||||||
|
for field in PIN_FIELDS:
|
||||||
|
if bool(candidate.get(field, false)):
|
||||||
|
result.append(String(PIN_FIELDS[field]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _decision_from_ranked_entry(entry: Dictionary) -> Dictionary:
|
||||||
|
return {
|
||||||
|
"stable_id": entry["stable_id"],
|
||||||
|
"selected": false,
|
||||||
|
"mandatory": entry["mandatory"],
|
||||||
|
"score_units": entry["score_units"],
|
||||||
|
"distance_millimeters": entry["distance_millimeters"],
|
||||||
|
"reason_trace": (entry["reason_trace"] as Array).duplicate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _pin_overflow_result(
|
||||||
|
detail_limit: int, mandatory_count: int, ranked: Array[Dictionary], rejected: Array[Dictionary]
|
||||||
|
) -> Dictionary:
|
||||||
|
var decisions: Array[Dictionary] = rejected.duplicate(true)
|
||||||
|
for entry in ranked:
|
||||||
|
var decision := _decision_from_ranked_entry(entry)
|
||||||
|
decision["reason_trace"].append("rejected_unsatisfiable_pin_overflow")
|
||||||
|
decisions.append(decision)
|
||||||
|
decisions.sort_custom(_stable_id_before)
|
||||||
|
return {
|
||||||
|
"valid": false,
|
||||||
|
"detail_limit": detail_limit,
|
||||||
|
"candidate_count": ranked.size() + rejected.size(),
|
||||||
|
"eligible_count": ranked.size(),
|
||||||
|
"mandatory_count": mandatory_count,
|
||||||
|
"selected_ids": [] as Array[StringName],
|
||||||
|
"selected_details": [] as Array[Dictionary],
|
||||||
|
"rejected_ids": _decision_ids(decisions),
|
||||||
|
"decisions": decisions,
|
||||||
|
"errors":
|
||||||
|
[
|
||||||
|
(
|
||||||
|
"mandatory_pin_overflow: %d mandatory candidates exceed detail limit %d"
|
||||||
|
% [mandatory_count, detail_limit]
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _invalid_result(detail_limit: int, errors: Array[String]) -> Dictionary:
|
||||||
|
errors.sort()
|
||||||
|
return {
|
||||||
|
"valid": false,
|
||||||
|
"detail_limit": detail_limit,
|
||||||
|
"candidate_count": 0,
|
||||||
|
"eligible_count": 0,
|
||||||
|
"mandatory_count": 0,
|
||||||
|
"selected_ids": [] as Array[StringName],
|
||||||
|
"selected_details": [] as Array[Dictionary],
|
||||||
|
"rejected_ids": [] as Array[StringName],
|
||||||
|
"decisions": [] as Array[Dictionary],
|
||||||
|
"errors": errors.duplicate(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func _decision_ids(decisions: Array[Dictionary]) -> Array[StringName]:
|
||||||
|
var result: Array[StringName] = []
|
||||||
|
for decision in decisions:
|
||||||
|
result.append(StringName(decision["stable_id"]))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
func _rank_before(first: Dictionary, second: Dictionary) -> bool:
|
||||||
|
if bool(first["mandatory"]) != bool(second["mandatory"]):
|
||||||
|
return bool(first["mandatory"])
|
||||||
|
if int(first["score_units"]) != int(second["score_units"]):
|
||||||
|
return int(first["score_units"]) > int(second["score_units"])
|
||||||
|
if int(first["distance_millimeters"]) != int(second["distance_millimeters"]):
|
||||||
|
return int(first["distance_millimeters"]) < int(second["distance_millimeters"])
|
||||||
|
return String(first["stable_id"]) < String(second["stable_id"])
|
||||||
|
|
||||||
|
|
||||||
|
func _stable_id_before(first: Dictionary, second: Dictionary) -> bool:
|
||||||
|
return String(first["stable_id"]) < String(second["stable_id"])
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://dtxbsn1ykdj0d
|
||||||
Reference in New Issue
Block a user