P18-01-CODE: 原本関係モデル・旧値保全・Runtime保存と読取経路

This commit is contained in:
Agent Control Runner
2026-10-13 14:25:06 +09:00
parent d2a1f80a61
commit f76ad96990
12 changed files with 702 additions and 11 deletions
@@ -0,0 +1,38 @@
class_name OriginalRelationshipState
extends RefCounted
## Explicit facts only. Numeric values are not production initialization approval.
## No update, attack, reduction, threshold or route policy is defined here.
const FIELDS := ["subject_id", "protagonist_id", "rival_id", "affection", "lust", "netorare", "shield_current", "shield_max"]
var _payload: Dictionary = {}
static func from_snapshot(value: Variant) -> OriginalRelationshipState:
if not value is Dictionary or value.size() != FIELDS.size():
return null
for field: String in FIELDS:
if not value.has(field):
return null
for field: String in ["subject_id", "protagonist_id", "rival_id"]:
if not (value[field] is String or value[field] is StringName) or String(value[field]).is_empty():
return null
if value["subject_id"] == value["protagonist_id"] or value["subject_id"] == value["rival_id"] or value["protagonist_id"] == value["rival_id"]:
return null
for field: String in ["affection", "lust", "netorare", "shield_current", "shield_max"]:
if not (value[field] is int or value[field] is float):
return null
if value[field] is float and not is_finite(value[field]):
return null
if value["shield_max"] != value["affection"] or value["shield_current"] < 0 or value["shield_current"] > value["shield_max"]:
return null
var state := OriginalRelationshipState.new()
state._payload = value.duplicate(true)
return state
func create_snapshot() -> Dictionary:
return _payload.duplicate(true)
func subject_id() -> StringName:
return StringName(_payload.get("subject_id", ""))
@@ -0,0 +1 @@
uid://cqxwpbkgjtnsa
+173 -11
View File
@@ -9,8 +9,9 @@ signal route_lock_changed(relationship_id: StringName, old_lock: int, new_lock:
const RelationshipStateType = preload("res://scripts/relationship/relationship_state.gd")
const CommandResultType = preload("res://scripts/core/command_result.gd")
const OriginalStateType = preload("res://scripts/relationship/original_relationship_state.gd")
const SNAPSHOT_VERSION := 1
const SNAPSHOT_VERSION := 2
const DAILY_AUTOMATIC_DELTA_LIMIT := 10
const GATED_THRESHOLDS: Array[int] = [50, 80]
const STAGE_THRESHOLDS: Array[int] = [20, 50, 80, 100]
@@ -22,6 +23,46 @@ var _pair_index: Dictionary = {}
var _threshold_candidates: Array[Dictionary] = []
var _next_candidate_sequence := 1
var _candidate_callback := Callable()
var _original_records: Dictionary = {}
var _original_identity_validator := Callable()
var _legacy_raw: Dictionary = {}
var _legacy_baselines: Dictionary = {}
var _container_raw: Dictionary = {}
func configure_original_identity_validator(callback: Callable) -> RelationshipService:
_original_identity_validator = callback
return self
func register_original_relationship(payload: Variant) -> CommandResult:
var state := OriginalStateType.from_snapshot(payload)
if state == null:
return _invalid("Original relationship requires complete explicit facts.")
if not _original_identity_validator.is_valid() or not bool(_original_identity_validator.call(state.create_snapshot())):
return _invalid("Original relationship identities are not registered.")
if _original_records.has(state.subject_id()):
return _duplicate("Original relationship subject", state.subject_id())
_original_records[state.subject_id()] = state.create_snapshot()
last_error = ""
return _success({&"subject_id": state.subject_id()})
func get_original_relationship(subject_id: StringName) -> Dictionary:
if _original_records.has(subject_id):
var result: Dictionary = _original_records[subject_id].duplicate(true)
result[&"configured"] = true
return result
return {&"configured": false, &"subject_id": subject_id,
&"protagonist_id": null, &"rival_id": null, &"affection": null,
&"lust": null, &"netorare": null, &"shield_current": null, &"shield_max": null}
func validate_original_identities() -> bool:
for payload: Dictionary in _original_records.values():
if not _original_identity_validator.is_valid() or not bool(_original_identity_validator.call(payload.duplicate(true))):
return false
return true
func configure_candidate_callback(callback: Callable = Callable()) -> RelationshipService:
@@ -329,6 +370,8 @@ func consume_threshold_candidate(candidate_id: StringName) -> Dictionary:
func validate_invariants() -> Dictionary:
var errors: Array[String] = []
if not validate_original_identities():
errors.append("Original relationship identity reference is invalid.")
var seen_pairs: Dictionary = {}
for relationship_id: StringName in _sorted_name_keys(_relationships):
var state := get_relationship(relationship_id)
@@ -347,22 +390,92 @@ func validate_invariants() -> Dictionary:
func create_snapshot() -> Dictionary:
var relationships: Array[Dictionary] = []
for state: RelationshipState in get_relationships():
relationships.append(state.create_snapshot())
var current := state.create_snapshot()
var original: Dictionary = _legacy_baselines.get(state.relationship_id, {})
var raw: Dictionary = {}
for entry: Dictionary in _legacy_raw.get(&"relationships", []):
if StringName(_value(entry, &"relationship_id", "")) == state.relationship_id:
raw = entry
break
relationships.append(_merge_legacy_changes(raw, original, current) if not original.is_empty() else current)
var candidates := _threshold_candidates.duplicate(true)
candidates.sort_custom(
func(a: Dictionary, b: Dictionary) -> bool:
return String(a.get(&"candidate_id", "")) < String(b.get(&"candidate_id", ""))
)
return {
&"version": SNAPSHOT_VERSION,
&"relationships": relationships,
&"threshold_candidates": candidates,
&"next_candidate_sequence": _next_candidate_sequence,
}
var legacy := _legacy_raw.duplicate(true)
legacy[&"version"] = 1
# Preserve input collection order as well as every untouched raw field.
if _legacy_raw.has(&"relationships"):
var ordered: Array[Dictionary] = []
for raw: Dictionary in _legacy_raw[&"relationships"]:
for entry: Dictionary in relationships:
if _value(entry, &"relationship_id", "") == _value(raw, &"relationship_id", ""):
ordered.append(entry)
for entry: Dictionary in relationships:
if not ordered.has(entry):
ordered.append(entry)
relationships = ordered
if legacy.get(&"relationships") != relationships:
legacy[&"relationships"] = relationships
var current_candidates := _threshold_candidates.duplicate(true) if not _legacy_raw.is_empty() else candidates
if legacy.get(&"threshold_candidates") != current_candidates:
legacy[&"threshold_candidates"] = current_candidates
legacy[&"next_candidate_sequence"] = _next_candidate_sequence
var result := _container_raw.duplicate(true)
result[&"version"] = SNAPSHOT_VERSION
result[&"legacy"] = legacy
# Compatibility projection for existing snapshot consumers; the nested v1
# record remains lossless, including extension names reserved by v2.
if result.get(&"relationships") != relationships:
result[&"relationships"] = relationships.duplicate(true)
var originals: Array[Dictionary] = []
for payload: Dictionary in _original_records.values():
originals.append(payload.duplicate(true))
if result.get(&"original_relationships") != originals:
result[&"original_relationships"] = originals
return result
static func _merge_legacy_changes(raw: Dictionary, baseline: Dictionary, current: Dictionary) -> Dictionary:
var result := raw.duplicate(true)
for key: Variant in baseline:
if not current.has(key):
result.erase(key)
for key: Variant in current:
if baseline.has(key) and current[key] == baseline[key]:
continue
if current[key] is Dictionary and baseline.get(key) is Dictionary and result.get(key) is Dictionary:
result[key] = _merge_legacy_changes(result[key], baseline[key], current[key])
else:
result[key] = current[key]
return result
func restore_snapshot(snapshot: Dictionary) -> bool:
var parsed := _parse_snapshot(snapshot)
var version: Variant = _value(snapshot, &"version", null)
if not version is int or version not in [1, SNAPSHOT_VERSION]:
last_error = "Unsupported Relationship snapshot version."
return false
var legacy: Variant = snapshot if version == 1 else _value(snapshot, &"legacy", null)
var originals: Variant = [] if version == 1 else _value(snapshot, &"original_relationships", null)
if not legacy is Dictionary or not originals is Array:
last_error = "Malformed Relationship snapshot container."
return false
if version == SNAPSHOT_VERSION and _value(snapshot, &"relationships", null) != _value(legacy, &"relationships", null):
last_error = "Legacy compatibility projection disagrees with its source."
return false
var original_records: Dictionary = {}
for raw: Variant in originals:
var state := OriginalStateType.from_snapshot(raw)
if state == null or original_records.has(state.subject_id()):
last_error = "Invalid or duplicate original relationship."
return false
if not _original_identity_validator.is_valid() or not bool(_original_identity_validator.call(state.create_snapshot())):
last_error = "Invalid original relationship identity reference."
return false
original_records[state.subject_id()] = state.create_snapshot()
var parsed := _parse_snapshot(legacy)
if not bool(parsed.get(&"ok", false)):
last_error = String(parsed.get(&"error", "Invalid Relationship snapshot."))
return false
@@ -370,12 +483,18 @@ func restore_snapshot(snapshot: Dictionary) -> bool:
_pair_index = parsed[&"pair_index"]
_threshold_candidates = parsed[&"threshold_candidates"]
_next_candidate_sequence = int(parsed[&"next_candidate_sequence"])
_original_records = original_records
_legacy_raw = legacy.duplicate(true)
_container_raw = snapshot.duplicate(true) if version == SNAPSHOT_VERSION else {}
_legacy_baselines = {}
for state: RelationshipState in get_relationships():
_legacy_baselines[state.relationship_id] = state.create_snapshot()
last_error = ""
return true
func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
if int(_value(snapshot, &"version", 0)) != SNAPSHOT_VERSION:
if not _value(snapshot, &"version", null) is int or _value(snapshot, &"version", null) != 1:
return {&"ok": false, &"error": "Unsupported Relationship snapshot version."}
var raw_relationships: Variant = _value(snapshot, &"relationships", null)
var raw_candidates: Variant = _value(snapshot, &"threshold_candidates", null)
@@ -384,7 +503,7 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
var relationships: Dictionary = {}
var pair_index: Dictionary = {}
for raw_state: Variant in raw_relationships:
if not raw_state is Dictionary:
if not raw_state is Dictionary or not _legacy_types_valid(raw_state):
return {&"ok": false, &"error": "Relationship snapshot entry is malformed."}
var state := RelationshipStateType.from_snapshot(raw_state)
if not state.is_valid() or relationships.has(state.relationship_id):
@@ -398,6 +517,9 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
for raw_candidate: Variant in raw_candidates:
if not raw_candidate is Dictionary:
return {&"ok": false, &"error": "Threshold candidate is malformed."}
for key: String in ["candidate_id", "relationship_id"]:
if not (raw_candidate.get(key) is String or raw_candidate.get(key) is StringName):
return {&"ok": false, &"error": "Threshold candidate identity type is invalid."}
var candidate: Dictionary = raw_candidate.duplicate(true)
var candidate_id := StringName(candidate.get(&"candidate_id", candidate.get("candidate_id", "")))
var relationship_id := StringName(candidate.get(&"relationship_id", candidate.get("relationship_id", "")))
@@ -405,6 +527,8 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
return {&"ok": false, &"error": "Threshold candidate reference is invalid."}
candidate_ids[candidate_id] = true
candidates.append(candidate)
if not _value(snapshot, &"next_candidate_sequence", null) is int:
return {&"ok": false, &"error": "Relationship candidate sequence type is invalid."}
var next_sequence := int(_value(snapshot, &"next_candidate_sequence", 0))
if next_sequence < 1:
return {&"ok": false, &"error": "Relationship candidate sequence is invalid."}
@@ -417,6 +541,44 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary:
}
static func _legacy_types_valid(raw: Dictionary) -> bool:
for key: String in ["relationship_id", "actor_a_id", "actor_b_id"]:
if not (raw.get(key) is String or raw.get(key) is StringName):
return false
for key: String in ["shield_max", "shield_current", "last_event_at", "daily_attack_count", "attack_day", "metric_delta_day"]:
if raw.has(key) and not raw[key] is int:
return false
for key: String in ["metrics", "action_counts", "daily_metric_deltas", "flags", "crossed_thresholds"]:
if raw.has(key) and not raw[key] is Dictionary:
return false
for key: String in ["trust", "respect", "affection", "fear", "dependence", "affinity", "obligation"]:
var metrics: Dictionary = raw.get("metrics", raw)
if metrics.has(key) and not metrics[key] is int:
return false
for key: String in ["action_counts", "daily_metric_deltas"]:
for value: Variant in raw.get(key, {}).values():
if not value is int:
return false
for key: String in ["action_counts", "flags"]:
for identity: Variant in raw.get(key, {}):
if not (identity is String or identity is StringName):
return false
for value: Variant in raw.get("flags", {}).values():
if not value is bool:
return false
for value: Variant in raw.get("crossed_thresholds", {}).values():
if not value is Array:
return false
for threshold: Variant in value:
if not threshold is int:
return false
if raw.has("route_id") and not (raw["route_id"] is String or raw["route_id"] is StringName):
return false
if raw.has("route_lock") and not (raw["route_lock"] is int or raw["route_lock"] is String or raw["route_lock"] is StringName):
return false
return true
func _register_threshold_candidate(
state: RelationshipState,
metric_id: StringName,