Files
TheTree_mock/scripts/relationship/placement_risk_preview_service.gd
T

1119 lines
46 KiB
GDScript

class_name PlacementRiskPreviewService
extends RefCounted
## Pure, fail-closed preview for relationship risks caused by proposed shifts.
##
## The service owns no state and invokes no callback. Serialized dictionaries
## are deliberately used at this boundary so UI previews cannot mutate live
## ShiftInstance, RelationshipState, or EventDefinition objects.
const SCHEMA_VERSION := 1
const MINUTES_PER_DAY := 1_440
const MAX_PERIOD_MINUTES := 14 * MINUTES_PER_DAY
const MAX_ABSOLUTE_MINUTE := 1_000_000_000
const MAX_ACTORS := 64
const MAX_SHIFTS_PER_ACTOR := 64
const MAX_RELATIONSHIPS := 32
const MAX_EVENTS := 256
const MAX_CONTACT_INTERVALS := 4_096
const MAX_ID_LENGTH := 128
const MIN_SHIELD_CONTACT_MINUTES := 30
const SHIELD_ATTACK_INTERVAL_MINUTES := 60
const MAX_ATTACK_DAMAGE := 100
const ROLE_HEROINE := "HEROINE"
const ROLE_RIVAL := "RIVAL"
const ROLE_OTHER := "OTHER"
const FILTER_MODES: Array[String] = ["NONE", "FADE", "REPLACE", "DISABLE"]
const CONTENT_RATINGS: Array[String] = ["GENERAL", "MATURE", "RESTRICTED"]
const ROUTE_LOCKS: Array[String] = [
"NONE", "SOFT", "EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED", "CLOSED",
]
func preview(request: Variant) -> Dictionary:
var parsed := _parse_request(request)
if not bool(parsed.get(&"ok", false)):
return parsed
return _build_preview(parsed[&"value"], true)
func _parse_request(request: Variant) -> Dictionary:
if not request is Dictionary:
return _error("request", "INVALID_TYPE", "Preview request must be a Dictionary.")
if not _has_exact_keys(request, [
&"schema_version", &"period", &"actors", &"relationships", &"events", &"content_filter",
]):
return _error("request", "INVALID_SCHEMA", "Preview request fields must match schema v1 exactly.")
if not request[&"schema_version"] is int or int(request[&"schema_version"]) != SCHEMA_VERSION:
return _error("schema_version", "UNSUPPORTED_SCHEMA", "Preview schema version must be integer 1.")
var period_result := _parse_period(request[&"period"])
if not bool(period_result[&"ok"]):
return period_result
var filter_result := _parse_content_filter(request[&"content_filter"])
if not bool(filter_result[&"ok"]):
return filter_result
var actors_result := _parse_actors(request[&"actors"])
if not bool(actors_result[&"ok"]):
return actors_result
var actors: Array[Dictionary] = actors_result[&"values"]
var actor_index: Dictionary = actors_result[&"index"]
var relationships_result := _parse_relationships(request[&"relationships"], actor_index)
if not bool(relationships_result[&"ok"]):
return relationships_result
var relationships: Array[Dictionary] = relationships_result[&"values"]
var relationship_ids: Dictionary = relationships_result[&"ids"]
var events_result := _parse_events(request[&"events"], relationship_ids)
if not bool(events_result[&"ok"]):
return events_result
var events: Array[Dictionary] = events_result[&"values"]
var replacement_result := _validate_replacements(events, filter_result[&"value"])
if not bool(replacement_result[&"ok"]):
return replacement_result
return {
&"ok": true,
&"value": {
&"period": period_result[&"value"],
&"actors": actors,
&"actor_index": actor_index,
&"relationships": relationships,
&"events": events,
&"content_filter": filter_result[&"value"],
},
}
func _parse_period(raw: Variant) -> Dictionary:
if not raw is Dictionary or not _has_exact_keys(raw, [&"start_at", &"end_at"]):
return _error("period", "INVALID_SCHEMA", "Period requires only start_at and end_at.")
if not raw[&"start_at"] is int or not raw[&"end_at"] is int:
return _error("period", "INVALID_TYPE", "Period bounds must be integers.")
var start_at := int(raw[&"start_at"])
var end_at := int(raw[&"end_at"])
if (
start_at < 0
or end_at <= start_at
or end_at > MAX_ABSOLUTE_MINUTE
or end_at - start_at > MAX_PERIOD_MINUTES
):
return _error("period", "OUT_OF_RANGE", "Period must be a positive half-open interval of at most 14 days.")
return {&"ok": true, &"value": {&"start_at": start_at, &"end_at": end_at}}
func _parse_content_filter(raw: Variant) -> Dictionary:
if not raw is Dictionary or not _has_exact_keys(raw, [&"mode", &"maximum_rating", &"disabled_tags"]):
return _error("content_filter", "INVALID_SCHEMA", "Content Filter fields must match exactly.")
if not raw[&"mode"] is String or String(raw[&"mode"]) not in FILTER_MODES:
return _error("content_filter.mode", "INVALID_ENUM", "Unknown Content Filter mode.")
if not raw[&"maximum_rating"] is String or String(raw[&"maximum_rating"]) not in CONTENT_RATINGS:
return _error("content_filter.maximum_rating", "INVALID_ENUM", "Unknown maximum content rating.")
var tags_result := _strict_string_array(raw[&"disabled_tags"], 64, "content_filter.disabled_tags")
if not bool(tags_result[&"ok"]):
return tags_result
return {
&"ok": true,
&"value": {
&"mode": String(raw[&"mode"]),
&"maximum_rating": String(raw[&"maximum_rating"]),
&"disabled_tags": tags_result[&"values"],
},
}
func _parse_actors(raw: Variant) -> Dictionary:
if not raw is Array or raw.is_empty() or raw.size() > MAX_ACTORS:
return _error("actors", "COLLECTION_LIMIT", "Actors must contain 1 to 64 entries.")
var actors: Array[Dictionary] = []
var actor_index: Dictionary = {}
for index: int in range(raw.size()):
var actor_raw: Variant = raw[index]
var path := "actors[%d]" % index
if not actor_raw is Dictionary or not _has_exact_keys(actor_raw, [
&"actor_id", &"role", &"existing_shifts", &"proposed_shifts", &"mechanical_effects",
]):
return _error(path, "INVALID_SCHEMA", "Actor fields must match exactly.")
if not _valid_id(actor_raw[&"actor_id"]):
return _error(path + ".actor_id", "INVALID_ID", "Actor ID must be a bounded non-empty string.")
var actor_id := String(actor_raw[&"actor_id"])
if actor_index.has(actor_id):
return _error(path + ".actor_id", "DUPLICATE_ID", "Actor IDs must be unique.")
if not actor_raw[&"role"] is String or String(actor_raw[&"role"]) not in [ROLE_HEROINE, ROLE_RIVAL, ROLE_OTHER]:
return _error(path + ".role", "INVALID_ENUM", "Unknown actor relationship role.")
var effects_result := _parse_mechanical_effects(actor_raw[&"mechanical_effects"], path + ".mechanical_effects")
if not bool(effects_result[&"ok"]):
return effects_result
var existing_result := _parse_shifts(actor_raw[&"existing_shifts"], path + ".existing_shifts")
if not bool(existing_result[&"ok"]):
return existing_result
var proposed_result := _parse_shifts(actor_raw[&"proposed_shifts"], path + ".proposed_shifts")
if not bool(proposed_result[&"ok"]):
return proposed_result
var existing: Array[Dictionary] = existing_result[&"values"]
var proposed: Array[Dictionary] = proposed_result[&"values"]
var existing_valid := _validate_single_placement(existing, path + ".existing_shifts")
if not bool(existing_valid[&"ok"]):
return existing_valid
var proposed_valid := _validate_single_placement(proposed, path + ".proposed_shifts")
if not bool(proposed_valid[&"ok"]):
return proposed_valid
var effective := _effective_shifts(existing, proposed)
if effective.size() > MAX_SHIFTS_PER_ACTOR:
return _error(path + ".effective_shifts", "COLLECTION_LIMIT", "Each actor supports at most 64 effective shifts.")
var effective_valid := _validate_single_placement(effective, path + ".effective_shifts")
if not bool(effective_valid[&"ok"]):
return effective_valid
var actor := {
&"actor_id": actor_id,
&"role": String(actor_raw[&"role"]),
&"existing_shifts": existing,
&"proposed_shifts": proposed,
&"effective_shifts": effective,
&"mechanical_effects": effects_result[&"value"],
}
actors.append(actor)
actor_index[actor_id] = actor
actors.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
return String(left[&"actor_id"]) < String(right[&"actor_id"])
)
return {&"ok": true, &"values": actors, &"index": actor_index}
func _parse_mechanical_effects(raw: Variant, path: String) -> Dictionary:
if not raw is Dictionary or not _has_exact_keys(raw, [
&"base_shield_attack", &"night_shield_attack_bonus",
]):
return _error(path, "INVALID_SCHEMA", "Mechanical effect fields must match exactly.")
for key: StringName in [&"base_shield_attack", &"night_shield_attack_bonus"]:
if not raw[key] is int or int(raw[key]) < 0 or int(raw[key]) > MAX_ATTACK_DAMAGE:
return _error(path + "." + String(key), "OUT_OF_RANGE", "Shield attack effects must be integers from 0 to 100.")
return {
&"ok": true,
&"value": {
&"base_shield_attack": int(raw[&"base_shield_attack"]),
&"night_shield_attack_bonus": int(raw[&"night_shield_attack_bonus"]),
},
}
func _parse_shifts(raw: Variant, path: String) -> Dictionary:
if not raw is Array or raw.size() > MAX_SHIFTS_PER_ACTOR:
return _error(path, "COLLECTION_LIMIT", "Each actor supports at most 64 shifts.")
var shifts: Array[Dictionary] = []
var ids: Dictionary = {}
for index: int in range(raw.size()):
var value: Variant = raw[index]
var item_path := "%s[%d]" % [path, index]
if not value is Dictionary or not _has_exact_keys(value, [
&"shift_id", &"facility_id", &"start_at", &"end_at",
]):
return _error(item_path, "INVALID_SCHEMA", "Shift fields must match exactly.")
if not _valid_id(value[&"shift_id"]) or not _valid_id(value[&"facility_id"]):
return _error(item_path, "INVALID_ID", "Shift and facility IDs must be bounded strings.")
var shift_id := String(value[&"shift_id"])
if ids.has(shift_id):
return _error(item_path + ".shift_id", "DUPLICATE_ID", "Shift IDs must be unique within one list.")
if not value[&"start_at"] is int or not value[&"end_at"] is int:
return _error(item_path, "INVALID_TYPE", "Shift bounds must be integers.")
var start_at := int(value[&"start_at"])
var end_at := int(value[&"end_at"])
if (
start_at < 0
or end_at <= start_at
or end_at > MAX_ABSOLUTE_MINUTE
or end_at - start_at > MINUTES_PER_DAY
):
return _error(item_path, "OUT_OF_RANGE", "Shift must be a positive half-open interval no longer than one day.")
ids[shift_id] = true
shifts.append({
&"shift_id": shift_id,
&"facility_id": String(value[&"facility_id"]),
&"start_at": start_at,
&"end_at": end_at,
})
_sort_shifts(shifts)
return {&"ok": true, &"values": shifts}
func _parse_relationships(raw: Variant, actors: Dictionary) -> Dictionary:
if not raw is Array or raw.is_empty() or raw.size() > MAX_RELATIONSHIPS:
return _error("relationships", "COLLECTION_LIMIT", "Relationships must contain 1 to 32 entries.")
var values: Array[Dictionary] = []
var ids: Dictionary = {}
var actor_pairs: Dictionary = {}
for index: int in range(raw.size()):
var value: Variant = raw[index]
var path := "relationships[%d]" % index
if not value is Dictionary or not _has_exact_keys(value, [
&"relationship_id", &"heroine_id", &"rival_id", &"affection", &"rival_bond",
&"shield_current", &"shield_max", &"route_lock", &"route_id",
&"target_resistance", &"monitoring_bonus",
]):
return _error(path, "INVALID_SCHEMA", "Relationship fields must match exactly.")
for id_key: StringName in [&"relationship_id", &"heroine_id", &"rival_id"]:
if not _valid_id(value[id_key]):
return _error(path + "." + String(id_key), "INVALID_ID", "Relationship IDs must be bounded strings.")
var relationship_id := String(value[&"relationship_id"])
var heroine_id := String(value[&"heroine_id"])
var rival_id := String(value[&"rival_id"])
if ids.has(relationship_id):
return _error(path + ".relationship_id", "DUPLICATE_ID", "Relationship IDs must be unique.")
if heroine_id == rival_id or not actors.has(heroine_id) or not actors.has(rival_id):
return _error(path, "MISSING_ACTOR", "Relationship actors must be distinct registered actors.")
if actors[heroine_id][&"role"] != ROLE_HEROINE or actors[rival_id][&"role"] != ROLE_RIVAL:
return _error(path, "ROLE_MISMATCH", "Relationship heroine and rival roles must match their actor records.")
if actor_pairs.has(heroine_id) and actor_pairs[heroine_id].has(rival_id):
return _error(path, "DUPLICATE_PAIR", "The same heroine and rival pair cannot be previewed twice.")
for metric_key: StringName in [
&"affection", &"rival_bond", &"shield_current", &"shield_max",
&"target_resistance", &"monitoring_bonus",
]:
if not value[metric_key] is int or int(value[metric_key]) < 0 or int(value[metric_key]) > 100:
return _error(path + "." + String(metric_key), "OUT_OF_RANGE", "Relationship values must be integers from 0 to 100.")
if int(value[&"shield_current"]) > int(value[&"shield_max"]):
return _error(path + ".shield_current", "INVALID_DERIVED_VALUE", "Current Shield cannot exceed Shield Max.")
if not value[&"route_lock"] is String or String(value[&"route_lock"]) not in ROUTE_LOCKS:
return _error(path + ".route_lock", "INVALID_ENUM", "Unknown Route Lock state.")
if not value[&"route_id"] is String or String(value[&"route_id"]).length() > MAX_ID_LENGTH:
return _error(path + ".route_id", "INVALID_ID", "Route ID must be a bounded string.")
if String(value[&"route_lock"]) in ["EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED"] and String(value[&"route_id"]).is_empty():
return _error(path + ".route_id", "MISSING_ROUTE", "Exclusive Route Lock requires a route ID.")
ids[relationship_id] = true
if not actor_pairs.has(heroine_id):
actor_pairs[heroine_id] = {}
actor_pairs[heroine_id][rival_id] = true
values.append({
&"relationship_id": relationship_id,
&"heroine_id": heroine_id,
&"rival_id": rival_id,
&"affection": int(value[&"affection"]),
&"rival_bond": int(value[&"rival_bond"]),
&"shield_current": int(value[&"shield_current"]),
&"shield_max": int(value[&"shield_max"]),
&"route_lock": String(value[&"route_lock"]),
&"route_id": String(value[&"route_id"]),
&"target_resistance": int(value[&"target_resistance"]),
&"monitoring_bonus": int(value[&"monitoring_bonus"]),
})
values.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
return String(left[&"relationship_id"]) < String(right[&"relationship_id"])
)
return {&"ok": true, &"values": values, &"ids": ids}
func _parse_events(raw: Variant, relationship_ids: Dictionary) -> Dictionary:
if not raw is Array or raw.size() > MAX_EVENTS:
return _error("events", "COLLECTION_LIMIT", "At most 256 preview Event definitions are supported.")
var values: Array[Dictionary] = []
var event_ids: Dictionary = {}
for index: int in range(raw.size()):
var value: Variant = raw[index]
var path := "events[%d]" % index
if not value is Dictionary or not _has_exact_keys(value, [
&"event_id", &"relationship_id", &"priority", &"min_contact_minutes",
&"max_shield_current", &"min_rival_bond", &"max_rival_bond", &"required_facility_id",
&"shield_damage", &"rival_bond_delta", &"cost_gold", &"cooldown_minutes",
&"route_lock", &"route_id", &"content_rating", &"content_tags",
&"filter_behavior", &"replacement_event_id", &"display_name",
]):
return _error(path, "INVALID_SCHEMA", "Preview Event fields must match exactly.")
if not _valid_id(value[&"event_id"]):
return _error(path + ".event_id", "INVALID_ID", "Event ID must be a bounded string.")
var event_id := String(value[&"event_id"])
if event_ids.has(event_id):
return _error(path + ".event_id", "DUPLICATE_ID", "Event IDs must be unique.")
if not value[&"relationship_id"] is String:
return _error(path + ".relationship_id", "INVALID_TYPE", "Event relationship ID must be a string.")
var relationship_id := String(value[&"relationship_id"])
if relationship_id != "*" and not relationship_ids.has(relationship_id):
return _error(path + ".relationship_id", "MISSING_RELATIONSHIP", "Event references an unknown relationship.")
for integer_key: StringName in [
&"priority", &"min_contact_minutes", &"max_shield_current", &"min_rival_bond",
&"max_rival_bond", &"shield_damage", &"rival_bond_delta", &"cost_gold", &"cooldown_minutes",
]:
if not value[integer_key] is int:
return _error(path + "." + String(integer_key), "INVALID_TYPE", "Event mechanical fields must be integers.")
if int(value[&"priority"]) < -1_000 or int(value[&"priority"]) > 1_000:
return _error(path + ".priority", "OUT_OF_RANGE", "Event priority is out of range.")
if int(value[&"min_contact_minutes"]) < 0 or int(value[&"min_contact_minutes"]) > MAX_PERIOD_MINUTES:
return _error(path + ".min_contact_minutes", "OUT_OF_RANGE", "Contact threshold is out of range.")
if int(value[&"max_shield_current"]) < 0 or int(value[&"max_shield_current"]) > 100:
return _error(path + ".max_shield_current", "OUT_OF_RANGE", "Shield threshold is out of range.")
if (
int(value[&"min_rival_bond"]) < 0
or int(value[&"max_rival_bond"]) > 100
or int(value[&"min_rival_bond"]) > int(value[&"max_rival_bond"])
):
return _error(path + ".rival_bond", "OUT_OF_RANGE", "Rival Bond range is invalid.")
if int(value[&"shield_damage"]) < 0 or int(value[&"shield_damage"]) > 100:
return _error(path + ".shield_damage", "OUT_OF_RANGE", "Event Shield damage is out of range.")
if int(value[&"rival_bond_delta"]) < -100 or int(value[&"rival_bond_delta"]) > 100:
return _error(path + ".rival_bond_delta", "OUT_OF_RANGE", "Event Rival Bond delta is out of range.")
if int(value[&"cost_gold"]) < 0 or int(value[&"cost_gold"]) > 1_000_000_000:
return _error(path + ".cost_gold", "OUT_OF_RANGE", "Event cost is out of range.")
if int(value[&"cooldown_minutes"]) < 0 or int(value[&"cooldown_minutes"]) > MAX_PERIOD_MINUTES:
return _error(path + ".cooldown_minutes", "OUT_OF_RANGE", "Event cooldown is out of range.")
for id_key: StringName in [&"required_facility_id", &"route_id", &"replacement_event_id"]:
if not value[id_key] is String or String(value[id_key]).length() > MAX_ID_LENGTH:
return _error(path + "." + String(id_key), "INVALID_ID", "Optional Event IDs must be bounded strings.")
if not value[&"route_lock"] is String or String(value[&"route_lock"]) not in ROUTE_LOCKS:
return _error(path + ".route_lock", "INVALID_ENUM", "Unknown Event Route Lock.")
if String(value[&"route_lock"]) in ["EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED"] and String(value[&"route_id"]).is_empty():
return _error(path + ".route_id", "MISSING_ROUTE", "Exclusive Event Route Lock requires a route ID.")
if not value[&"content_rating"] is String or String(value[&"content_rating"]) not in CONTENT_RATINGS:
return _error(path + ".content_rating", "INVALID_ENUM", "Unknown Event content rating.")
var tags_result := _strict_string_array(value[&"content_tags"], 32, path + ".content_tags")
if not bool(tags_result[&"ok"]):
return tags_result
if not value[&"filter_behavior"] is String or String(value[&"filter_behavior"]) not in FILTER_MODES:
return _error(path + ".filter_behavior", "INVALID_ENUM", "Unknown Event filter behavior.")
if String(value[&"filter_behavior"]) == "REPLACE" and String(value[&"replacement_event_id"]).is_empty():
return _error(path + ".replacement_event_id", "MISSING_REPLACEMENT", "REPLACE Event requires a replacement Event ID.")
if not value[&"display_name"] is String or String(value[&"display_name"]).is_empty() or String(value[&"display_name"]).length() > 256:
return _error(path + ".display_name", "INVALID_TEXT", "Event display name must be a bounded non-empty string.")
event_ids[event_id] = true
values.append({
&"event_id": event_id,
&"relationship_id": relationship_id,
&"priority": int(value[&"priority"]),
&"min_contact_minutes": int(value[&"min_contact_minutes"]),
&"max_shield_current": int(value[&"max_shield_current"]),
&"min_rival_bond": int(value[&"min_rival_bond"]),
&"max_rival_bond": int(value[&"max_rival_bond"]),
&"required_facility_id": String(value[&"required_facility_id"]),
&"shield_damage": int(value[&"shield_damage"]),
&"rival_bond_delta": int(value[&"rival_bond_delta"]),
&"cost_gold": int(value[&"cost_gold"]),
&"cooldown_minutes": int(value[&"cooldown_minutes"]),
&"route_lock": String(value[&"route_lock"]),
&"route_id": String(value[&"route_id"]),
&"content_rating": String(value[&"content_rating"]),
&"content_tags": tags_result[&"values"],
&"filter_behavior": String(value[&"filter_behavior"]),
&"replacement_event_id": String(value[&"replacement_event_id"]),
&"display_name": String(value[&"display_name"]),
})
values.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
if int(left[&"priority"]) != int(right[&"priority"]):
return int(left[&"priority"]) > int(right[&"priority"])
return String(left[&"event_id"]) < String(right[&"event_id"])
)
return {&"ok": true, &"values": values}
func _validate_replacements(events: Array[Dictionary], content_filter: Dictionary) -> Dictionary:
var event_index: Dictionary = {}
for event: Dictionary in events:
event_index[event[&"event_id"]] = event
for event: Dictionary in events:
var restricted := _event_is_restricted(event, content_filter)
if not restricted:
continue
var behavior := String(event[&"filter_behavior"])
if behavior == "NONE":
behavior = String(content_filter[&"mode"])
if behavior != "REPLACE":
continue
var replacement_id := String(event[&"replacement_event_id"])
if replacement_id.is_empty() or replacement_id == event[&"event_id"] or not event_index.has(replacement_id):
return _error("events." + String(event[&"event_id"]), "REPLACEMENT_MISSING", "Filtered Event replacement is missing or cyclic.")
if _event_is_restricted(event_index[replacement_id], content_filter):
return _error("events." + replacement_id, "REPLACEMENT_UNSAFE", "Replacement Event is also restricted.")
return {&"ok": true}
func _build_preview(parsed: Dictionary, include_suggestions: bool) -> Dictionary:
var relationship_previews: Array[Dictionary] = []
var total_contact := 0
var total_attacks := 0
var total_events := 0
for relationship: Dictionary in parsed[&"relationships"]:
var relationship_preview := _build_relationship_preview(parsed, relationship, include_suggestions)
relationship_previews.append(relationship_preview)
total_contact += int(relationship_preview[&"total_contact_minutes"])
total_attacks += relationship_preview[&"shield_attack_candidates"].size()
total_events += relationship_preview[&"triggerable_events"].size()
return {
&"ok": true,
&"schema_version": SCHEMA_VERSION,
&"period": parsed[&"period"].duplicate(true),
&"relationships": relationship_previews,
&"totals": {
&"contact_minutes": total_contact,
&"shield_attack_candidates": total_attacks,
&"triggerable_events": total_events,
},
}
func _build_relationship_preview(
parsed: Dictionary,
relationship: Dictionary,
include_suggestions: bool
) -> Dictionary:
var heroine: Dictionary = parsed[&"actor_index"][relationship[&"heroine_id"]]
var rival: Dictionary = parsed[&"actor_index"][relationship[&"rival_id"]]
var contact := _contact_summary(
heroine[&"effective_shifts"],
rival[&"effective_shifts"],
parsed[&"period"]
)
var attacks_result := _shield_attack_candidates(
relationship,
rival[&"mechanical_effects"],
contact[&"contacts_by_facility"]
)
var projected_shield := int(attacks_result[&"projected_shield"])
var projected_bond := int(attacks_result[&"projected_rival_bond"])
var triggerable := _triggerable_events(
parsed[&"events"],
parsed[&"content_filter"],
relationship,
contact,
projected_shield,
projected_bond
)
var route_warning := _route_lock_warning(relationship, triggerable)
var risk_level := _risk_level(
int(contact[&"total_contact_minutes"]),
attacks_result[&"candidates"].size(),
triggerable.size(),
projected_shield,
projected_bond,
bool(route_warning[&"confirmation_required"])
)
var suggestions: Array[Dictionary] = []
if include_suggestions and int(contact[&"total_contact_minutes"]) > 0:
suggestions = _avoidance_suggestions(
parsed[&"period"],
relationship,
heroine[&"effective_shifts"],
rival[&"effective_shifts"],
contact
)
var total_event_cost := 0
var max_cooldown := 0
for candidate: Dictionary in triggerable:
total_event_cost += int(candidate[&"mechanics"][&"cost_gold"])
max_cooldown = maxi(max_cooldown, int(candidate[&"mechanics"][&"cooldown_minutes"]))
return {
&"relationship_id": relationship[&"relationship_id"],
&"heroine_id": relationship[&"heroine_id"],
&"rival_id": relationship[&"rival_id"],
&"contacts_by_facility": contact[&"contacts_by_facility"],
&"total_contact_minutes": contact[&"total_contact_minutes"],
&"shield_attack_candidates": attacks_result[&"candidates"],
&"triggerable_events": triggerable,
&"current_values": {
&"affection": relationship[&"affection"],
&"rival_bond": relationship[&"rival_bond"],
&"shield_current": relationship[&"shield_current"],
&"shield_max": relationship[&"shield_max"],
},
&"projected_values": {
&"rival_bond": projected_bond,
&"shield_current": projected_shield,
},
&"risk_level": risk_level,
&"route_lock_warning": route_warning,
&"mechanical_summary": {
&"risk_level": risk_level,
&"contact_minutes": contact[&"total_contact_minutes"],
&"attack_candidate_count": attacks_result[&"candidates"].size(),
&"event_candidate_count": triggerable.size(),
&"event_cost_gold": total_event_cost,
&"maximum_cooldown_minutes": max_cooldown,
&"projected_shield": projected_shield,
&"projected_rival_bond": projected_bond,
&"projected_route_lock": route_warning[&"projected_route_lock"],
&"route_confirmation_required": route_warning[&"confirmation_required"],
},
&"avoidance_suggestions": suggestions,
}
func _contact_summary(
heroine_shifts: Array,
rival_shifts: Array,
period: Dictionary
) -> Dictionary:
var raw_overlaps: Array[Dictionary] = []
for heroine_shift: Dictionary in heroine_shifts:
for rival_shift: Dictionary in rival_shifts:
if heroine_shift[&"facility_id"] != rival_shift[&"facility_id"]:
continue
var start_at := maxi(
maxi(int(period[&"start_at"]), int(heroine_shift[&"start_at"])),
int(rival_shift[&"start_at"])
)
var end_at := mini(
mini(int(period[&"end_at"]), int(heroine_shift[&"end_at"])),
int(rival_shift[&"end_at"])
)
# Half-open intervals touch only when start < end. Adjacent shifts
# therefore contribute zero phantom minutes.
if start_at >= end_at:
continue
if raw_overlaps.size() >= MAX_CONTACT_INTERVALS:
break
raw_overlaps.append({
&"facility_id": heroine_shift[&"facility_id"],
&"start_at": start_at,
&"end_at": end_at,
&"heroine_shift_id": heroine_shift[&"shift_id"],
&"rival_shift_id": rival_shift[&"shift_id"],
})
raw_overlaps.sort_custom(_overlap_less)
var facility_ids: Array[String] = []
var by_facility: Dictionary = {}
for overlap: Dictionary in raw_overlaps:
var facility_id := String(overlap[&"facility_id"])
if not by_facility.has(facility_id):
by_facility[facility_id] = []
facility_ids.append(facility_id)
by_facility[facility_id].append(overlap)
facility_ids.sort()
var contacts: Array[Dictionary] = []
var total_contact := 0
for facility_id: String in facility_ids:
var merged := _merge_contact_intervals(by_facility[facility_id])
var facility_minutes := 0
for interval: Dictionary in merged:
facility_minutes += int(interval[&"minutes"])
total_contact += facility_minutes
contacts.append({
&"facility_id": facility_id,
&"intervals": merged,
&"contact_minutes": facility_minutes,
})
return {
&"contacts_by_facility": contacts,
&"total_contact_minutes": total_contact,
&"raw_overlaps": raw_overlaps,
}
func _merge_contact_intervals(raw_intervals: Array) -> Array[Dictionary]:
var merged: Array[Dictionary] = []
for raw: Dictionary in raw_intervals:
if merged.is_empty() or int(raw[&"start_at"]) > int(merged[-1][&"end_at"]):
merged.append({
&"start_at": int(raw[&"start_at"]),
&"end_at": int(raw[&"end_at"]),
&"minutes": int(raw[&"end_at"]) - int(raw[&"start_at"]),
&"heroine_shift_ids": [String(raw[&"heroine_shift_id"])],
&"rival_shift_ids": [String(raw[&"rival_shift_id"])],
})
continue
var current: Dictionary = merged[-1]
current[&"end_at"] = maxi(int(current[&"end_at"]), int(raw[&"end_at"]))
current[&"minutes"] = int(current[&"end_at"]) - int(current[&"start_at"])
_append_unique_string(current[&"heroine_shift_ids"], String(raw[&"heroine_shift_id"]))
_append_unique_string(current[&"rival_shift_ids"], String(raw[&"rival_shift_id"]))
current[&"heroine_shift_ids"].sort()
current[&"rival_shift_ids"].sort()
return merged
func _shield_attack_candidates(
relationship: Dictionary,
effects: Dictionary,
contacts: Array
) -> Dictionary:
var attack_points: Array[Dictionary] = []
for facility: Dictionary in contacts:
for interval: Dictionary in facility[&"intervals"]:
if int(interval[&"minutes"]) < MIN_SHIELD_CONTACT_MINUTES:
continue
var trigger_at := int(interval[&"start_at"]) + MIN_SHIELD_CONTACT_MINUTES - 1
while trigger_at < int(interval[&"end_at"]):
attack_points.append({
&"facility_id": facility[&"facility_id"],
&"contact_interval": {
&"start_at": interval[&"start_at"],
&"end_at": interval[&"end_at"],
},
&"trigger_at": trigger_at,
&"overlap_minutes": interval[&"minutes"],
})
trigger_at += SHIELD_ATTACK_INTERVAL_MINUTES
attack_points.sort_custom(_attack_point_less)
var candidates: Array[Dictionary] = []
var projected_shield := int(relationship[&"shield_current"])
var projected_bond := int(relationship[&"rival_bond"])
for index: int in range(attack_points.size()):
var point: Dictionary = attack_points[index]
var night := _is_night_minute(int(point[&"trigger_at"]))
var raw_damage := (
int(effects[&"base_shield_attack"])
+ (int(effects[&"night_shield_attack_bonus"]) if night else 0)
- int(relationship[&"target_resistance"])
- int(relationship[&"monitoring_bonus"])
)
var damage := clampi(maxi(raw_damage, 1), 1, MAX_ATTACK_DAMAGE)
var shield_before := projected_shield
var bond_before := projected_bond
var bond_delta := 0
if projected_shield > 0:
projected_shield = maxi(projected_shield - damage, 0)
else:
bond_delta = mini(3, 100 - projected_bond)
projected_bond += bond_delta
candidates.append({
&"candidate_id": "shield_attack:%s:%04d" % [relationship[&"relationship_id"], index + 1],
&"facility_id": point[&"facility_id"],
&"contact_interval": point[&"contact_interval"],
&"trigger_at": point[&"trigger_at"],
&"night_bonus_applied": night,
&"mechanics": {
&"raw_damage": raw_damage,
&"damage": damage if shield_before > 0 else 0,
&"shield_before": shield_before,
&"shield_after": projected_shield,
&"rival_bond_before": bond_before,
&"rival_bond_delta": bond_delta,
&"rival_bond_after": projected_bond,
&"cost_gold": 0,
&"cooldown_minutes": SHIELD_ATTACK_INTERVAL_MINUTES,
},
&"reasons": [
{
&"code": "SHARED_FACILITY_CONTACT",
&"facility_id": point[&"facility_id"],
&"overlap_minutes": point[&"overlap_minutes"],
},
],
})
return {
&"candidates": candidates,
&"projected_shield": projected_shield,
&"projected_rival_bond": projected_bond,
}
func _triggerable_events(
events: Array,
content_filter: Dictionary,
relationship: Dictionary,
contact: Dictionary,
projected_shield: int,
projected_bond: int
) -> Array[Dictionary]:
var event_index: Dictionary = {}
for event: Dictionary in events:
event_index[event[&"event_id"]] = event
var result: Array[Dictionary] = []
for event: Dictionary in events:
if event[&"relationship_id"] not in ["*", relationship[&"relationship_id"]]:
continue
if int(contact[&"total_contact_minutes"]) < int(event[&"min_contact_minutes"]):
continue
if projected_shield > int(event[&"max_shield_current"]):
continue
if projected_bond < int(event[&"min_rival_bond"]) or projected_bond > int(event[&"max_rival_bond"]):
continue
var facility_id := String(event[&"required_facility_id"])
var facility_contact := _facility_contact_minutes(contact[&"contacts_by_facility"], facility_id)
if not facility_id.is_empty() and facility_contact <= 0:
continue
var requested_lock := String(event[&"route_lock"])
var preview_lock := "EXCLUSIVE_PENDING" if requested_lock == "EXCLUSIVE_LOCKED" else requested_lock
result.append({
&"event_id": event[&"event_id"],
&"priority": event[&"priority"],
&"mechanics": {
&"shield_damage": event[&"shield_damage"],
&"rival_bond_delta": event[&"rival_bond_delta"],
&"cost_gold": event[&"cost_gold"],
&"cooldown_minutes": event[&"cooldown_minutes"],
&"route_lock_requested": requested_lock,
&"route_lock_preview": preview_lock,
&"route_id": event[&"route_id"],
&"route_confirmation_required": requested_lock in ["EXCLUSIVE_PENDING", "EXCLUSIVE_LOCKED"],
&"route_lock_confirmed": false,
},
&"reasons": [
{
&"code": "CONTACT_THRESHOLD_MET",
&"actual_minutes": contact[&"total_contact_minutes"],
&"required_minutes": event[&"min_contact_minutes"],
},
{
&"code": "SHIELD_THRESHOLD_MET",
&"projected_shield": projected_shield,
&"maximum_shield": event[&"max_shield_current"],
},
{
&"code": "RIVAL_BOND_RANGE_MET",
&"projected_rival_bond": projected_bond,
&"minimum": event[&"min_rival_bond"],
&"maximum": event[&"max_rival_bond"],
},
{
&"code": "SHARED_FACILITY",
&"facility_id": facility_id,
&"overlap_minutes": (
facility_contact if not facility_id.is_empty() else contact[&"total_contact_minutes"]
),
},
],
&"presentation": _event_presentation(event, content_filter, event_index),
})
return result
func _event_presentation(
event: Dictionary,
content_filter: Dictionary,
event_index: Dictionary
) -> Dictionary:
if not _event_is_restricted(event, content_filter) or content_filter[&"mode"] == "NONE":
return {
&"mode": "FULL",
&"visible": true,
&"display_event_id": event[&"event_id"],
&"display_name": event[&"display_name"],
}
var behavior := String(event[&"filter_behavior"])
if behavior == "NONE":
behavior = String(content_filter[&"mode"])
match behavior:
"FADE":
return {
&"mode": "FADE",
&"visible": true,
&"display_event_id": event[&"event_id"],
&"display_name": "Filtered relationship scene",
}
"REPLACE":
var replacement: Dictionary = event_index[event[&"replacement_event_id"]]
return {
&"mode": "REPLACE",
&"visible": true,
&"display_event_id": replacement[&"event_id"],
&"display_name": replacement[&"display_name"],
}
"DISABLE":
return {
&"mode": "DISABLE",
&"visible": false,
&"display_event_id": "",
&"display_name": "",
}
return {
&"mode": "FULL",
&"visible": true,
&"display_event_id": event[&"event_id"],
&"display_name": event[&"display_name"],
}
func _route_lock_warning(relationship: Dictionary, events: Array[Dictionary]) -> Dictionary:
var candidate_ids: Array[String] = []
var route_id := String(relationship[&"route_id"])
var confirmation_required: bool = relationship[&"route_lock"] == "EXCLUSIVE_PENDING"
var projected_lock := String(relationship[&"route_lock"])
for event: Dictionary in events:
var event_lock := String(event[&"mechanics"][&"route_lock_preview"])
if _route_lock_rank(event_lock) > _route_lock_rank(projected_lock):
projected_lock = event_lock
if not String(event[&"mechanics"][&"route_id"]).is_empty():
route_id = String(event[&"mechanics"][&"route_id"])
if not bool(event[&"mechanics"][&"route_confirmation_required"]):
continue
candidate_ids.append(String(event[&"event_id"]))
confirmation_required = true
if route_id.is_empty():
route_id = String(event[&"mechanics"][&"route_id"])
candidate_ids.sort()
if confirmation_required and projected_lock not in ["EXCLUSIVE_LOCKED", "CLOSED"]:
projected_lock = "EXCLUSIVE_PENDING"
return {
&"confirmation_required": confirmation_required,
&"preview_confirms_lock": false,
&"current_route_lock": relationship[&"route_lock"],
&"projected_route_lock": projected_lock,
&"route_id": route_id,
&"candidate_event_ids": candidate_ids,
&"message": (
"Explicit confirmation Command is required before an exclusive Route Lock."
if confirmation_required
else "No new exclusive Route Lock is projected."
),
}
func _risk_level(
contact_minutes: int,
attack_count: int,
event_count: int,
projected_shield: int,
projected_bond: int,
route_confirmation: bool
) -> String:
if route_confirmation:
return "CRITICAL"
var has_placement_risk := contact_minutes > 0 or attack_count > 0 or event_count > 0
if not has_placement_risk:
return "NONE"
if projected_bond >= 80:
return "CRITICAL"
if projected_shield == 0 or contact_minutes >= 180 or event_count >= 2:
return "HIGH"
if attack_count > 0 or event_count > 0 or contact_minutes >= 30:
return "MEDIUM"
if contact_minutes > 0:
return "LOW"
return "NONE"
func _avoidance_suggestions(
period: Dictionary,
relationship: Dictionary,
heroine_shifts: Array,
rival_shifts: Array,
contact: Dictionary
) -> Array[Dictionary]:
var raw: Array = contact[&"raw_overlaps"]
if raw.is_empty():
return []
var first: Dictionary = raw[0]
var rival_shift := _find_shift(rival_shifts, String(first[&"rival_shift_id"]))
if rival_shift.is_empty():
return []
var result: Array[Dictionary] = []
var separated_facility := _unused_preview_facility(heroine_shifts, rival_shifts)
var facility_proposal := rival_shift.duplicate(true)
facility_proposal[&"facility_id"] = separated_facility
var facility_schedule := _replace_shift(rival_shifts, facility_proposal)
var facility_after := _contact_summary(heroine_shifts, facility_schedule, period)
if int(facility_after[&"total_contact_minutes"]) < int(contact[&"total_contact_minutes"]):
result.append({
&"suggestion_id": "avoid:%s:facility:0001" % relationship[&"relationship_id"],
&"kind": "CHANGE_FACILITY",
&"actor_id": relationship[&"rival_id"],
&"reason": "Place the rival in a different facility during the conflicting Shift.",
&"proposed_shift": facility_proposal,
&"contact_minutes_before": contact[&"total_contact_minutes"],
&"contact_minutes_after": facility_after[&"total_contact_minutes"],
&"reduction_minutes": int(contact[&"total_contact_minutes"]) - int(facility_after[&"total_contact_minutes"]),
})
var time_proposal := _time_separated_shift(rival_shift, rival_shifts, heroine_shifts, period)
if not time_proposal.is_empty():
var time_schedule := _replace_shift(rival_shifts, time_proposal)
var time_after := _contact_summary(heroine_shifts, time_schedule, period)
if int(time_after[&"total_contact_minutes"]) < int(contact[&"total_contact_minutes"]):
result.append({
&"suggestion_id": "avoid:%s:time:0001" % relationship[&"relationship_id"],
&"kind": "SEPARATE_TIME",
&"actor_id": relationship[&"rival_id"],
&"reason": "Move the rival Shift to the earliest non-contact time window.",
&"proposed_shift": time_proposal,
&"contact_minutes_before": contact[&"total_contact_minutes"],
&"contact_minutes_after": time_after[&"total_contact_minutes"],
&"reduction_minutes": int(contact[&"total_contact_minutes"]) - int(time_after[&"total_contact_minutes"]),
})
return result
func _time_separated_shift(
target: Dictionary,
rival_shifts: Array,
heroine_shifts: Array,
period: Dictionary
) -> Dictionary:
var duration := int(target[&"end_at"]) - int(target[&"start_at"])
var blocked: Array[Dictionary] = []
for shift: Dictionary in heroine_shifts:
if shift[&"facility_id"] != target[&"facility_id"]:
continue
var start_at := maxi(int(period[&"start_at"]), int(shift[&"start_at"]))
var end_at := mini(int(period[&"end_at"]), int(shift[&"end_at"]))
if start_at < end_at:
blocked.append({&"start_at": start_at, &"end_at": end_at})
blocked.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
return int(left[&"start_at"]) < int(right[&"start_at"])
)
var candidate_starts: Array[int] = [int(period[&"start_at"])]
for interval: Dictionary in blocked:
candidate_starts.append(int(interval[&"end_at"]))
for shift: Dictionary in rival_shifts:
if shift[&"shift_id"] != target[&"shift_id"]:
candidate_starts.append(int(shift[&"end_at"]))
candidate_starts.sort()
var unique_starts: Array[int] = []
for candidate_start: int in candidate_starts:
if not unique_starts.has(candidate_start):
unique_starts.append(candidate_start)
for candidate_start: int in unique_starts:
var candidate_end := candidate_start + duration
if candidate_end > int(period[&"end_at"]):
continue
var proposal := target.duplicate(true)
proposal[&"start_at"] = candidate_start
proposal[&"end_at"] = candidate_end
var schedule := _replace_shift(rival_shifts, proposal)
if not bool(_validate_single_placement(schedule, "avoidance.time")[&"ok"]):
continue
if int(_contact_summary(heroine_shifts, [proposal], period)[&"total_contact_minutes"]) == 0:
return proposal
return {}
func _unused_preview_facility(heroine_shifts: Array, rival_shifts: Array) -> String:
var used: Dictionary = {}
for shift: Dictionary in heroine_shifts:
used[String(shift[&"facility_id"])] = true
for shift: Dictionary in rival_shifts:
used[String(shift[&"facility_id"])] = true
for index: int in range(1, MAX_SHIFTS_PER_ACTOR * 2 + 2):
var candidate := "preview_separate_%04d" % index
if not used.has(candidate):
return candidate
# The bounded actor schedules make this fallback unreachable, but it keeps
# the helper total if limits change without updating this loop.
return "preview_separate_fallback"
func _effective_shifts(existing: Array[Dictionary], proposed: Array[Dictionary]) -> Array[Dictionary]:
var replacements: Dictionary = {}
for shift: Dictionary in proposed:
replacements[shift[&"shift_id"]] = true
var result: Array[Dictionary] = []
for shift: Dictionary in existing:
if not replacements.has(shift[&"shift_id"]):
result.append(shift.duplicate(true))
for shift: Dictionary in proposed:
result.append(shift.duplicate(true))
_sort_shifts(result)
return result
func _replace_shift(shifts: Array, replacement: Dictionary) -> Array[Dictionary]:
var result: Array[Dictionary] = []
for shift: Dictionary in shifts:
if shift[&"shift_id"] != replacement[&"shift_id"]:
result.append(shift.duplicate(true))
result.append(replacement.duplicate(true))
_sort_shifts(result)
return result
func _validate_single_placement(shifts: Array, path: String) -> Dictionary:
var previous_end := -1
for shift: Dictionary in shifts:
if int(shift[&"start_at"]) < previous_end:
return _error(path, "ACTOR_DOUBLE_BOOKED", "One actor cannot occupy overlapping Shift intervals.")
previous_end = int(shift[&"end_at"])
return {&"ok": true}
func _find_shift(shifts: Array, shift_id: String) -> Dictionary:
for shift: Dictionary in shifts:
if shift[&"shift_id"] == shift_id:
return shift.duplicate(true)
return {}
func _facility_contact_minutes(contacts: Array, facility_id: String) -> int:
if facility_id.is_empty():
return 0
for contact: Dictionary in contacts:
if contact[&"facility_id"] == facility_id:
return int(contact[&"contact_minutes"])
return 0
func _event_is_restricted(event: Dictionary, content_filter: Dictionary) -> bool:
if CONTENT_RATINGS.find(String(event[&"content_rating"])) > CONTENT_RATINGS.find(String(content_filter[&"maximum_rating"])):
return true
for tag: String in event[&"content_tags"]:
if content_filter[&"disabled_tags"].has(tag):
return true
return false
static func _is_night_minute(absolute_minute: int) -> bool:
var minute_of_day := posmod(absolute_minute, MINUTES_PER_DAY)
return minute_of_day >= 1_080 or minute_of_day < 360
static func _route_lock_rank(value: String) -> int:
return ROUTE_LOCKS.find(value)
static func _valid_id(value: Variant) -> bool:
return value is String and not String(value).is_empty() and String(value).length() <= MAX_ID_LENGTH
static func _strict_string_array(raw: Variant, maximum: int, path: String) -> Dictionary:
if not raw is Array or raw.size() > maximum:
return _error(path, "COLLECTION_LIMIT", "String collection exceeds its bound.")
var result: Array[String] = []
for value: Variant in raw:
if not value is String or String(value).is_empty() or String(value).length() > MAX_ID_LENGTH:
return _error(path, "INVALID_TYPE", "Collection entries must be bounded non-empty strings.")
if result.has(String(value)):
return _error(path, "DUPLICATE_VALUE", "String collection values must be unique.")
result.append(String(value))
result.sort()
return {&"ok": true, &"values": result}
static func _has_exact_keys(source: Dictionary, expected: Array) -> bool:
if source.size() != expected.size():
return false
for key: Variant in expected:
if not source.has(key):
return false
return true
static func _sort_shifts(shifts: Array[Dictionary]) -> void:
shifts.sort_custom(func(left: Dictionary, right: Dictionary) -> bool:
if int(left[&"start_at"]) != int(right[&"start_at"]):
return int(left[&"start_at"]) < int(right[&"start_at"])
if int(left[&"end_at"]) != int(right[&"end_at"]):
return int(left[&"end_at"]) < int(right[&"end_at"])
return String(left[&"shift_id"]) < String(right[&"shift_id"])
)
static func _overlap_less(left: Dictionary, right: Dictionary) -> bool:
if left[&"facility_id"] != right[&"facility_id"]:
return String(left[&"facility_id"]) < String(right[&"facility_id"])
if int(left[&"start_at"]) != int(right[&"start_at"]):
return int(left[&"start_at"]) < int(right[&"start_at"])
if int(left[&"end_at"]) != int(right[&"end_at"]):
return int(left[&"end_at"]) < int(right[&"end_at"])
if left[&"heroine_shift_id"] != right[&"heroine_shift_id"]:
return String(left[&"heroine_shift_id"]) < String(right[&"heroine_shift_id"])
return String(left[&"rival_shift_id"]) < String(right[&"rival_shift_id"])
static func _attack_point_less(left: Dictionary, right: Dictionary) -> bool:
if int(left[&"trigger_at"]) != int(right[&"trigger_at"]):
return int(left[&"trigger_at"]) < int(right[&"trigger_at"])
if left[&"facility_id"] != right[&"facility_id"]:
return String(left[&"facility_id"]) < String(right[&"facility_id"])
if int(left[&"contact_interval"][&"start_at"]) != int(right[&"contact_interval"][&"start_at"]):
return int(left[&"contact_interval"][&"start_at"]) < int(right[&"contact_interval"][&"start_at"])
return int(left[&"contact_interval"][&"end_at"]) < int(right[&"contact_interval"][&"end_at"])
static func _append_unique_string(values: Array, value: String) -> void:
if not values.has(value):
values.append(value)
static func _error(path: String, code: String, message: String) -> Dictionary:
return {
&"ok": false,
&"error_code": code,
&"error_path": path,
&"message": message,
}