From 7c0f02a5cf48f4ed99cfa4ef0dc853b9a655eceb Mon Sep 17 00:00:00 2001 From: Rijad Zuzo Date: Wed, 12 Aug 2026 21:22:10 +0200 Subject: [PATCH] feat: bound active regional presentation --- .../test_presentation_relevance_policy.gd | 227 +++++++++++ .../test_presentation_relevance_policy.gd.uid | 1 + .../PresentationNavRequestBudget.gd | 77 ++++ .../PresentationNavRequestBudget.gd.uid | 1 + .../PresentationRelevancePolicy.gd | 363 ++++++++++++++++++ .../PresentationRelevancePolicy.gd.uid | 1 + 6 files changed, 670 insertions(+) create mode 100644 tests/unit/test_presentation_relevance_policy.gd create mode 100644 tests/unit/test_presentation_relevance_policy.gd.uid create mode 100644 world/presentation/PresentationNavRequestBudget.gd create mode 100644 world/presentation/PresentationNavRequestBudget.gd.uid create mode 100644 world/presentation/PresentationRelevancePolicy.gd create mode 100644 world/presentation/PresentationRelevancePolicy.gd.uid diff --git a/tests/unit/test_presentation_relevance_policy.gd b/tests/unit/test_presentation_relevance_policy.gd new file mode 100644 index 0000000..412d56f --- /dev/null +++ b/tests/unit/test_presentation_relevance_policy.gd @@ -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 diff --git a/tests/unit/test_presentation_relevance_policy.gd.uid b/tests/unit/test_presentation_relevance_policy.gd.uid new file mode 100644 index 0000000..7edc637 --- /dev/null +++ b/tests/unit/test_presentation_relevance_policy.gd.uid @@ -0,0 +1 @@ +uid://bgonghipke3xq diff --git a/world/presentation/PresentationNavRequestBudget.gd b/world/presentation/PresentationNavRequestBudget.gd new file mode 100644 index 0000000..44259ce --- /dev/null +++ b/world/presentation/PresentationNavRequestBudget.gd @@ -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"]) diff --git a/world/presentation/PresentationNavRequestBudget.gd.uid b/world/presentation/PresentationNavRequestBudget.gd.uid new file mode 100644 index 0000000..6991933 --- /dev/null +++ b/world/presentation/PresentationNavRequestBudget.gd.uid @@ -0,0 +1 @@ +uid://buboke5ox8pvq diff --git a/world/presentation/PresentationRelevancePolicy.gd b/world/presentation/PresentationRelevancePolicy.gd new file mode 100644 index 0000000..9218427 --- /dev/null +++ b/world/presentation/PresentationRelevancePolicy.gd @@ -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"]) diff --git a/world/presentation/PresentationRelevancePolicy.gd.uid b/world/presentation/PresentationRelevancePolicy.gd.uid new file mode 100644 index 0000000..41e28b6 --- /dev/null +++ b/world/presentation/PresentationRelevancePolicy.gd.uid @@ -0,0 +1 @@ +uid://dtxbsn1ykdj0d