Files
gamedev-the-steward/world/presentation/PresentationRelevancePolicy.gd
T
2026-08-12 21:22:10 +02:00

364 lines
12 KiB
GDScript

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"])