1119 lines
46 KiB
GDScript
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,
|
|
}
|