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, }