diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index b2eca36..27bb139 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -330,6 +330,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S register_command_executor(&"begin_staff_task", Callable(self, "_execute_begin_staff_task")) register_command_executor(&"progress_staff_task", Callable(self, "_execute_progress_staff_task")) register_command_executor(&"create_relationship", Callable(self, "_execute_create_relationship")) + register_command_executor(&"register_original_relationship", Callable(self, "_execute_register_original_relationship")) register_command_executor(&"change_relationship", Callable(self, "_execute_change_relationship")) register_command_executor(&"attack_relationship_shield", Callable(self, "_execute_attack_relationship_shield")) register_command_executor(&"request_route_lock", Callable(self, "_execute_request_route_lock")) @@ -1054,8 +1055,15 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo ): return false var restored_relationships := RelationshipService.new() + restored_relationships.configure_original_identity_validator( + func(payload: Dictionary) -> bool: return _original_relationship_identities_valid(payload, restored_staff) + ) if not restored_relationships.restore_snapshot(runtime_data[&"relationships"]): return false + for relationship: RelationshipState in restored_relationships.get_relationships(): + for actor_id: StringName in [relationship.actor_a_id, relationship.actor_b_id]: + if actor_id != &"player" and not restored_staff.has_staff(actor_id) and not restored_guests.has_guest(actor_id): + return false var restored_events := EventService.new() for raw_definition: Variant in runtime_data[&"event_definitions"]: var definition := EventDefinition.from_variant(raw_definition) @@ -5378,6 +5386,9 @@ func _initialize_s4_relationship_events() -> void: func _configure_relationship_event_boundaries() -> void: if relationship_service == null or event_service == null: return + relationship_service.configure_original_identity_validator( + func(payload: Dictionary) -> bool: return _original_relationship_identities_valid(payload, staff_service) + ) relationship_service.configure_candidate_callback( Callable(self, "_on_relationship_threshold_candidate") ) @@ -6759,6 +6770,30 @@ func _execute_progress_staff_task(command: SimulationCommand) -> CommandResult: result.command_id = command.command_id return result +func _original_relationship_identities_valid(payload: Dictionary, staff_registry: StaffService) -> bool: + if payload.get("protagonist_id") != "player" or staff_registry == null: + return false + var subject := StringName(payload.get("subject_id", "")) + var rival := StringName(payload.get("rival_id", "")) + if subject == &"player" or rival == &"player" or subject == rival or not staff_registry.has_staff(subject): + return false + var heroine_definition := mvp_content_catalog.get_entry(&"unique_characters", subject) + var rival_definition := mvp_content_catalog.get_entry(&"unique_characters", rival) + var subject_staff := staff_registry.get_staff(subject) + return subject_staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE \ + and heroine_definition.get(&"role_id") == String(subject_staff.role_id) \ + and heroine_definition.get(&"character_kind") == "heroine" \ + and rival_definition.get(&"character_kind") == "rival" \ + and heroine_definition.get(&"counterpart_id") == String(rival) \ + and rival_definition.get(&"counterpart_id") == String(subject) + + +func _execute_register_original_relationship(command: SimulationCommand) -> CommandResult: + var result := relationship_service.register_original_relationship(command.payload) + result.command_id = command.command_id + return result + + func _execute_create_relationship(command: SimulationCommand) -> CommandResult: var actor_a_id := StringName(command.payload.get(&"actor_a_id", command.payload.get("actor_a_id", ""))) var actor_b_id := StringName(command.payload.get(&"actor_b_id", command.payload.get("actor_b_id", ""))) @@ -10859,6 +10894,7 @@ func get_staff_management_report() -> Dictionary: &"can_edit": block_reason.is_empty(), &"assignment_block_reason": block_reason, &"unique_contract": _staff_unique_contract_report(staff), + &"original_relationship": relationship_service.get_original_relationship(staff.staff_id), }) var facilities: Array[Dictionary] = [] for facility: FacilityInstance in facility_service.get_instances(): diff --git a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json index 9438e65..5474439 100644 --- a/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json +++ b/docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json @@ -2,6 +2,28 @@ "schema_version": 1, "scope": "exact per-suite completion label/schema; all check counts from actual raw only", "rows": [ + { + "test": "tests/s4/test_original_relationship_model.gd", + "template": "Original relationship model: checks, failures", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "ad509f5fb67ab91e1d1d99a08af51e4695e411b9a2541ea63ee22b1555cb0924", + "authority": { + "path": "docs/spec/acceptance/P18-01-code-r01.json", + "scope": "new backend suite marker; native execution pending" + } + }, + { + "test": "tests/s4/test_original_relationship_runtime.gd", + "template": "Original relationship runtime: checks, failures", + "required": true, + "fixed_check_count_used": false, + "source_sha256_at_schema_audit": "374f3d31a65d2f2b62dfef29721c8c838575b856f4fa5a0bca31c6ba4c7e09dd", + "authority": { + "path": "docs/spec/acceptance/P18-01-code-r01.json", + "scope": "new backend suite marker; native execution pending" + } + }, { "test": "tests/ui/test_selection_departure.gd", "template": "Selection departure: checks, failures", diff --git a/docs/plans/original-completion-2026-10-02/execution/p18-01-code/IMPLEMENTATION.md b/docs/plans/original-completion-2026-10-02/execution/p18-01-code/IMPLEMENTATION.md new file mode 100644 index 0000000..453eeb2 --- /dev/null +++ b/docs/plans/original-completion-2026-10-02/execution/p18-01-code/IMPLEMENTATION.md @@ -0,0 +1,79 @@ +# P18-01-CODE implementation handoff + +Status: submitted for Runner validation; no acceptance or unit completion claimed. + +Scope: dedicated original relationship state, lossless legacy snapshot preservation, +Runtime registration and read projection, and the contract-listed tests and collector. +Production initial values and gameplay coefficients are not supplied or inferred. + +Validation: worker static inspection only; authoritative native local and affected +validation, independent review, and integration belong to Runner. + +Source inspection: HEAD matches d2a1f80a613e7c77b02b941026bc49c2d85db863. +The acceptance specification without its final newline matches the contract SHA256 +04e9ae701b6ee1e399542564add695ca7fce54499e52dd2e907b1dbdc23a366d. + +## Submitted behavior + +- OriginalRelationshipState stores explicit directional subject/protagonist/rival + facts with Affection, Lust, Netorare and shield current/max. All fields are + required for a configured record. Numeric inputs must be finite numbers; + shield current must be nonnegative and at most max, and max equals Affection. + No additional emotion ranges, coefficients or production values are imposed. +- RelationshipService retains its five-axis API. Its v2 snapshot contains the + whole v1 payload under `legacy`, plus separate `original_relationships`. + Top-level `relationships` is a compatibility projection for existing consumers; + disagreement with `legacy.relationships` is rejected. Additional legacy fields, + raw aliases, collection order and untouched values survive migration. The + existing normalized state remains usable; only actual legacy changes are merged + into retained raw facts. Aliases never initialize original fields. +- Registration uses `register_original_relationship` with the complete payload. + Runtime validates the protagonist against `player`, the subject against hired + UNIQUE staff and its canonical role, and heroine/rival identities and reciprocal + counterpart references against the existing unique-character catalog. Rival + catalog membership does not invent recruitment. No new original update policy, + contact, route, attack, threshold or duel behavior is defined. +- Snapshot restore validates candidates before replacing live services, then + rebinds the identity validator to live staff. Existing legacy actor references + must resolve against player, restored staff or restored guests. +- Every staff-management row exposes `original_relationship`. Missing records + have `configured: false` and null values, distinct from explicit zero. Configured + projections are copies, so reads and caller edits cannot change service state. + +## Validation and handoff + +Two new s4 suites exercise explicit/unset/zero data, malformed and nonfinite values, +identity invariants, lossless raw aliases/extensions, new and mixed round trips, +atomic service/Runtime rejection, actual hiring/commands, restore/rebind and reports. +Existing test bodies and limits are unchanged. New marker and suite-policy rows +are pinned to the exact new test sources. + +`python3 -B tools/run_p18_relationship_tests.py --prepare-only` previews the exact +seven-suite roster, source bytes, capacity and all-script parse count without +engines, profiles, stage creation or test execution. The native collector reuses +run_all_tests.source_refs/execute_phase and the existing Guard, owned_outer, +diagnostic, artifact, PID, marker and cleanup gates. It stops at the first failure; +Runner owns repair/validation sequencing and independent review. + +Worker checks: Python AST syntax, JSON/source-marker consistency, allowed-path +inspection, unchanged pre-existing marker/policy records, and git diff whitespace. +Native import, GDScript parse and all seven suites remain **unexecuted** by worker. +Worker reinspection on 2026-10-13 confirmed the existing submitted implementation +and repeated Python AST, specification digest, exact new marker/policy source +hashes, unchanged existing marker/policy records, and `git diff --check`. +Prepare-only exited 0: seven suites, 1,311 source files / 67,287,056 bytes, +308 parse paths, nine future native phases, 381,859,856 conservative required +bytes and 1,773,187,072 available bytes. Engines and stages were both zero; +actual checks were null and formal acceptance was false. Capacity is a preview +at that instant, not a reservation or native result. +An initial shell here-document temporary-file attempt was denied with +`operation not permitted`; normal allowed-path patch edits worked. No isolation +rule or permission was relaxed. + +P18-C-1 through P18-C-3 are implementation submissions, not accepted results. +P18-C-4 awaits Runner native local/affected evidence, separate Opus review, +integration and final affected validation. The original P18-01 unit remains +in_progress: ordinary Japanese UI rendering/equality, approved production values, +all-seven recruitment, final full required roster and applicable Windows final +acceptance remain outstanding. No git writes, other agents or Runner controls +were used by worker. diff --git a/scripts/relationship/original_relationship_state.gd b/scripts/relationship/original_relationship_state.gd new file mode 100644 index 0000000..8acb3b6 --- /dev/null +++ b/scripts/relationship/original_relationship_state.gd @@ -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", "")) diff --git a/scripts/relationship/original_relationship_state.gd.uid b/scripts/relationship/original_relationship_state.gd.uid new file mode 100644 index 0000000..fde971a --- /dev/null +++ b/scripts/relationship/original_relationship_state.gd.uid @@ -0,0 +1 @@ +uid://cqxwpbkgjtnsa diff --git a/scripts/relationship/relationship_service.gd b/scripts/relationship/relationship_service.gd index 1d1e093..35d4670 100644 --- a/scripts/relationship/relationship_service.gd +++ b/scripts/relationship/relationship_service.gd @@ -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, diff --git a/tests/s4/test_original_relationship_model.gd b/tests/s4/test_original_relationship_model.gd new file mode 100644 index 0000000..d2a2c81 --- /dev/null +++ b/tests/s4/test_original_relationship_model.gd @@ -0,0 +1,110 @@ +extends SceneTree + +const Model = preload("res://scripts/relationship/original_relationship_state.gd") +const Service = preload("res://scripts/relationship/relationship_service.gd") +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _expect(value: bool, message: String) -> void: + _checks += 1 + if not value: + _failures += 1 + push_error(message) + + +static func fixture() -> Dictionary: + # Validation fixtures, never approved initialization or range limits. + return {"subject_id": "heroine_anri", "protagonist_id": "player", "rival_id": "rival_rai", + "affection": 142.5, "lust": 23.25, "netorare": 11, "shield_current": 90.5, "shield_max": 142.5} + + +func _service() -> RelationshipService: + return Service.new().configure_original_identity_validator( + func(p: Dictionary) -> bool: + return p["subject_id"] == "heroine_anri" and p["protagonist_id"] == "player" and p["rival_id"] == "rival_rai" + ) + + +func _run() -> void: + var service := _service() + var before := var_to_bytes(service.create_snapshot()) + var unset := service.get_original_relationship(&"heroine_anri") + _expect(not unset[&"configured"] and unset[&"affection"] == null, "missing record is unset") + unset[&"affection"] = 999 + _expect(var_to_bytes(service.create_snapshot()) == before, "reads own their dictionaries") + var payload := fixture() + var payload_bytes := var_to_bytes(payload) + _expect(service.register_original_relationship(payload).is_success(), "complete directional fixture registers") + payload["affection"] = 0 + _expect(service.get_original_relationship(&"heroine_anri")["affection"] == 142.5, "caller cannot mutate stored facts") + _expect(not service.register_original_relationship(fixture()).is_success(), "duplicate subject rejected") + var saved := service.create_snapshot() + var restored := _service() + _expect(restored.restore_snapshot(saved), "new-only v2 restores") + _expect(var_to_bytes(restored.create_snapshot()) == var_to_bytes(saved), "new-only exact bytes") + _expect(var_to_bytes(fixture()) == payload_bytes, "fixture is explicit and stable") + for field: String in Model.FIELDS: + var missing := fixture() + missing.erase(field) + _expect(Model.from_snapshot(missing) == null, "missing required field " + field) + for value: Variant in [null, true, "12", INF, NAN, {}, []]: + var wrong := fixture() + wrong["lust"] = value + _expect(Model.from_snapshot(wrong) == null, "wrong/nonfinite numeric value rejected") + for patch: Dictionary in [{"shield_max": 1}, {"shield_current": -1}, {"shield_current": 143}, {"rival_id": "player"}, {"subject_id": "player"}]: + var wrong := fixture() + wrong.merge(patch, true) + _expect(Model.from_snapshot(wrong) == null, "value/identity invariant rejected") + var zero := fixture() + for field: String in ["affection", "lust", "netorare", "shield_current", "shield_max"]: + zero[field] = 0 + _expect(Model.from_snapshot(zero) != null, "explicit zero is configured") + var legacy := {"version": 1, "relationships": [{"relationship_id": "old", "actor_a_id": "player", "actor_b_id": "heroine_anri", + "metrics": {"affinity": 117, "dependence": 29, "extension": {"opaque": [3, "keep"]}}, "extra": ["raw", 2.5]}], + "threshold_candidates": [], "next_candidate_sequence": 1, "extension": {"version": 70}} + var legacy_bytes := var_to_bytes(legacy) + var migrated := _service() + _expect(migrated.restore_snapshot(legacy), "legacy aliases still usable") + _expect(migrated.get_relationship(&"old").get_metric(&"affection") == 100, "legacy normalization behavior retained") + _expect(not migrated.get_original_relationship(&"heroine_anri")[&"configured"], "legacy never configures original facts") + _expect(var_to_bytes(migrated.create_snapshot()[&"legacy"]) == legacy_bytes, "all raw legacy values and extensions preserved") + _expect(var_to_bytes(legacy) == legacy_bytes, "legacy input bytes untouched") + _expect(migrated.register_original_relationship(fixture()).is_success(), "mixed state registers independently") + var mixed := migrated.create_snapshot() + _expect(restored.restore_snapshot(mixed), "mixed snapshot restores") + _expect(var_to_bytes(restored.create_snapshot()) == var_to_bytes(mixed), "mixed exact bytes") + _expect(migrated.apply_value_change(&"old", &"trust", 3, 0).is_success(), "legacy changes still apply") + var changed: Dictionary = migrated.create_snapshot()[&"legacy"] + _expect(changed["relationships"][0]["metrics"]["affinity"] == 117 and changed["relationships"][0]["metrics"]["trust"] == 3, "changed metric preserves raw alias") + for version: Variant in [0, 3, "2", 2.0, true]: + var bad := mixed.duplicate(true) + bad[&"version"] = version + _rejected(restored, bad) + var duplicate := mixed.duplicate(true) + duplicate[&"original_relationships"].append(fixture()) + _rejected(restored, duplicate) + for patch: Dictionary in [{"rival_id": "unknown"}, {"protagonist_id": "unknown"}, {"lust": false}, {"shield_max": 10}]: + var bad := mixed.duplicate(true) + bad[&"original_relationships"][0].merge(patch, true) + _rejected(restored, bad) + var bad_legacy := legacy.duplicate(true) + bad_legacy["relationships"][0]["metrics"]["affinity"] = "117" + _rejected(restored, bad_legacy) + bad_legacy = legacy.duplicate(true) + bad_legacy["relationships"].append(bad_legacy["relationships"][0].duplicate(true)) + _rejected(restored, bad_legacy) + print("Original relationship model: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + + +func _rejected(service: RelationshipService, payload: Dictionary) -> void: + var before := var_to_bytes(service.create_snapshot()) + var input := var_to_bytes(payload) + _expect(not service.restore_snapshot(payload), "corrupt snapshot rejected") + _expect(var_to_bytes(service.create_snapshot()) == before, "rejection preserves prior state") + _expect(var_to_bytes(payload) == input, "rejection preserves input bytes") diff --git a/tests/s4/test_original_relationship_model.gd.uid b/tests/s4/test_original_relationship_model.gd.uid new file mode 100644 index 0000000..0b2111c --- /dev/null +++ b/tests/s4/test_original_relationship_model.gd.uid @@ -0,0 +1 @@ +uid://cqxwpbkgjtnsb diff --git a/tests/s4/test_original_relationship_runtime.gd b/tests/s4/test_original_relationship_runtime.gd new file mode 100644 index 0000000..f2aa656 --- /dev/null +++ b/tests/s4/test_original_relationship_runtime.gd @@ -0,0 +1,78 @@ +extends SceneTree + +const Fixtures = preload("res://tests/s4/test_original_relationship_model.gd") +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _expect(value: bool, message: String) -> void: + _checks += 1 + if not value: + _failures += 1 + push_error(message) + + +func _run() -> void: + var clock = load("res://autoload/game_clock.gd").new() + var state = load("res://autoload/game_state.gd").new() + var runtime = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + _expect(runtime.initialize(clock, state, 18001)[&"valid"], "Runtime initialized") + clock.set_paused(true) + var fixture := Fixtures.fixture() + _expect(not runtime.relationship_service.register_original_relationship(fixture).is_success(), "unhired subject rejected by service boundary") + _expect(runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": &"heroine_anri"}).is_success(), "actual approved Anri hire") + var read_before := var_to_bytes(runtime.create_snapshot()) + var report: Dictionary = runtime.get_staff_management_report() + var found := false + for row: Dictionary in report[&"staff"]: + if row[&"staff_id"] == &"heroine_anri": + found = true + _expect(not row[&"original_relationship"][&"configured"] and row[&"original_relationship"][&"affection"] == null, "actual report exposes unset") + _expect(found, "hired staff appears in actual report") + _expect(var_to_bytes(runtime.create_snapshot()) == read_before, "unset report read has no mutation") + var old_events: Array = runtime.domain_events.create_snapshot()["recent_events"] + _expect(runtime.submit_command(&"register_original_relationship", fixture).is_success(), "registered Runtime command stores explicit fixture") + var new_events: Array = runtime.domain_events.create_snapshot()["recent_events"] + _expect(new_events.size() == old_events.size() + 1 and new_events[-1][&"event_type"] == "COMMAND_COMPLETED", "only existing command completion event is emitted") + _expect(runtime.relationship_service.create_relationship(&"legacy", &"heroine_anri", &"player", {&"affinity": 44}).is_success(), "legacy service remains usable") + var saved: Dictionary = runtime.create_snapshot() + var input := var_to_bytes(saved) + _expect(runtime.restore_snapshot(saved), "mixed Runtime snapshot restores") + _expect(var_to_bytes(runtime.create_snapshot()) == input, "Runtime snapshot exact round trip") + _expect(var_to_bytes(saved) == input, "restore preserves caller bytes") + read_before = var_to_bytes(runtime.create_snapshot()) + report = runtime.get_staff_management_report() + for row: Dictionary in report[&"staff"]: + if row[&"staff_id"] == &"heroine_anri": + var original: Dictionary = row[&"original_relationship"] + _expect(original[&"configured"] and original["affection"] == 142.5 and original["shield_max"] == 142.5, "restored report equals stored original values") + original["affection"] = -99 + _expect(var_to_bytes(runtime.create_snapshot()) == read_before, "configured report owns projection containers") + for patch: Dictionary in [{"subject_id": "missing"}, {"protagonist_id": "rival_rai"}, {"rival_id": "missing"}, {"shield_current": 999}, {"lust": "bad"}]: + var corrupt := saved.duplicate(true) + corrupt[&"systems"][&"runtime"][&"relationships"][&"original_relationships"][0].merge(patch, true) + var bad_bytes := var_to_bytes(corrupt) + var service_before: Variant = runtime.relationship_service + _expect(not runtime.restore_snapshot(corrupt), "bad original Runtime restore rejected") + _expect(runtime.relationship_service == service_before, "failed restore preserves service identity") + _expect(var_to_bytes(runtime.create_snapshot()) == read_before, "failed restore preserves whole Runtime") + _expect(var_to_bytes(corrupt) == bad_bytes, "failed restore preserves input") + var v1 := saved.duplicate(true) + v1[&"systems"][&"runtime"][&"relationships"] = saved[&"systems"][&"runtime"][&"relationships"][&"legacy"].duplicate(true) + _expect(runtime.restore_snapshot(v1), "Runtime accepts genuine v1 relationship payload") + _expect(not runtime.relationship_service.get_original_relationship(&"heroine_anri")[&"configured"], "v1 restore does not map legacy affection") + var invalid := fixture.duplicate(true) + invalid["rival_id"] = "missing" + _expect(not runtime.relationship_service.register_original_relationship(invalid).is_success(), "restore rebind keeps identity validator") + _expect(runtime.relationship_service.register_original_relationship(fixture).is_success(), "restored service accepts correct identities") + runtime.free() + clock.free() + state.free() + print("Original relationship runtime: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) diff --git a/tests/s4/test_original_relationship_runtime.gd.uid b/tests/s4/test_original_relationship_runtime.gd.uid new file mode 100644 index 0000000..039532f --- /dev/null +++ b/tests/s4/test_original_relationship_runtime.gd.uid @@ -0,0 +1 @@ +uid://cqxwpbkgjtnsc diff --git a/tools/regression/suite-policy.json b/tools/regression/suite-policy.json index dd284ae..63d34d8 100644 --- a/tools/regression/suite-policy.json +++ b/tools/regression/suite-policy.json @@ -1,6 +1,20 @@ { "schema_version": 1, "suite_policies": { + "tests/s4/test_original_relationship_model.gd": { + "source_sha256": "ad509f5fb67ab91e1d1d99a08af51e4695e411b9a2541ea63ee22b1555cb0924", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false + }, + "tests/s4/test_original_relationship_runtime.gd": { + "source_sha256": "374f3d31a65d2f2b62dfef29721c8c838575b856f4fa5a0bca31c6ba4c7e09dd", + "ordinary_cleanup_prepared": false, + "ordinary_control_labels": [], + "old_bounded_teardown": false, + "new_audio_QA": false + }, "tests/ui/test_selection_departure.gd": { "source_sha256": "3137f36f547514f25ce6ac775fa7a5141d12f83ca4b95da0be45fecfafe9d9b4", "ordinary_cleanup_prepared": true, diff --git a/tools/run_p18_relationship_tests.py b/tools/run_p18_relationship_tests.py new file mode 100644 index 0000000..93c5d2d --- /dev/null +++ b/tools/run_p18_relationship_tests.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Runner-owned narrow P18 collection using the unchanged guarded phase protocol.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import sys +import uuid + +sys.dont_write_bytecode = True +import run_all_tests as shared + +ROSTER = [ + 'tests/s4/test_original_relationship_model.gd', + 'tests/s4/test_original_relationship_runtime.gd', + 'tests/s4/test_relationship_event_service.gd', + 'tests/s4/test_runtime_relationship_events.gd', + 'tests/s6/test_runtime_snapshot_serialization.gd', + 'tests/s6/test_runtime_save_lifecycle.gd', + 'tests/ui/test_staff_management.gd', +] + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--project-root', type=Path, default=Path(__file__).resolve().parent.parent) + parser.add_argument('--output', type=Path) + parser.add_argument('--godot', default='godot') + parser.add_argument('--timeout', type=int, default=1200) + parser.add_argument('--prepare-only', action='store_true') + args = parser.parse_args(argv) + root = args.project_root.resolve() + out = (args.output or root / 'artifacts' / ('p18-relationship-' + uuid.uuid4().hex)).resolve() + shared.require(args.timeout > 0, 'positive timeout') + refs = shared.source_refs(root) + # Refuse mixed collector/support checkouts; all actual Python source is pinned. + for name in ['tools/run_all_tests.py', 'tools/run_p18_relationship_tests.py', 'tools/strict_test_markers.py']: + shared.require(refs[name]['sha256'] == shared.sha(shared.HERE / Path(name).name), 'actual collector source ' + name) + for file in shared.SUPPORT.iterdir(): + if file.is_file(): + shared.require(refs['tools/regression/' + file.name]['sha256'] == shared.sha(file), 'actual support source ' + file.name) + shared.require(all(name in refs for name in ROSTER), 'complete narrow roster') + shared.require(set(ROSTER).issubset(shared.roster(root)), 'normal discovery includes narrow roster') + markers = shared.module('p18_exact_markers', shared.HERE / 'strict_test_markers.py') + contract_path = root / 'docs/plans/original-completion-2026-10-02/execution/completion_marker_contracts.json' + contract_sha = shared.sha(contract_path) + contracts = markers.load_contracts(contract_path) + shared.require(all(name in contracts for name in ROSTER), 'complete marker roster') + policy = shared.read(shared.SUPPORT / 'suite-policy.json') + shared.policy_coverage(ROSTER, refs, policy) + for test in ROSTER: + shared.phase_policy(test, Path(refs[test]['path']), policy['suite_policies']) + for test in ROSTER[:2]: + shared.require(contracts[test]['source_sha256_at_schema_audit'] == refs[test]['sha256'], 'new strict source-hashed marker') + parse_paths = ['res://' + name for name in sorted(refs) if name.endswith('.gd')] + source_bytes = sum(pin['bytes'] for pin in refs.values()) + ancestor = out.parent + while not ancestor.exists(): + ancestor = ancestor.parent + free = shutil.disk_usage(ancestor).free + needed = source_bytes + shared.MIN_SPACE + preview = dict(roster=ROSTER, source_files=len(refs), source_bytes=source_bytes, + free_bytes=free, conservative_required_bytes=needed, capacity_available=free >= needed, + parse_paths=len(parse_paths), future_native_phases=len(ROSTER) + 2, + actual_checks=None, engines=0, stages=0, formal_acceptance=False) + if args.prepare_only: + print(json.dumps(preview, ensure_ascii=False)) + return 0 + shared.require(free >= needed, 'fresh source and phase disk admission') + outer = shared.module('p18_owned_outer', shared.SUPPORT / 'owned_outer.py') + shared.require(not outer.native.helper['live_engines'](), 'another Godot owns CPU') + engine = shutil.which(args.godot) if not Path(args.godot).is_absolute() else args.godot + shared.require(engine and Path(engine).is_file(), 'native launcher exists') + engine = str(Path(engine).resolve()) + out.mkdir(parents=True, exist_ok=False) + stage = out / 'stage' + stage.mkdir() + (out / 'starters').mkdir() + (out / 'bootstrap').mkdir() + for name, pin in shared.read(shared.SUPPORT / 'bootstrap-scripts.json').items(): + data = pin['text'].encode('utf-8') + shared.require(hashlib.sha256(data).hexdigest() == pin['sha256'], 'unchanged external bootstrap bytes') + (out / 'bootstrap' / name).write_bytes(data) + protected = shared.default_player(root) + expected = {name: {'bytes': pin['bytes'], 'sha256': pin['sha256']} for name, pin in refs.items()} + selected = {'root_before': refs, 'map': {name: pin['sha256'] for name, pin in refs.items()}, 'timeout': args.timeout} + for name, pin in refs.items(): + target = stage / name + target.parent.mkdir(parents=True, exist_ok=True) + shared.copy_source(Path(pin['path']), target) + shared.require(shared.inventory(stage, stage=True) == expected, 'complete private source copy') + public = (stage / 'project.godot').read_bytes() + shared.write(out / 'parse-paths.json', parse_paths) + run_id = uuid.uuid4().hex + shared.write(out / 'runner-input.json', dict(protocol='guarded-p18-v1', run_id=run_id, + owner_runner_pid=os.getpid(), project_root=str(root), stage=str(stage), roster=ROSTER, + selected_source_map=selected['map'], marker_contract_sha256=contract_sha, + runner_sha256=shared.sha(Path(__file__)), shared_runner_sha256=shared.sha(shared.HERE / 'run_all_tests.py'), + marker_module_sha256=shared.sha(shared.HERE / 'strict_test_markers.py'), + engine=engine, engine_sha256=shared.sha(engine), default_player_before=protected, + formal_acceptance=False, original_unit_complete=False)) + shared.write(out / 'source-start.json', refs) + diagnostics = shared.module('p18_diagnostics', shared.SUPPORT / 'diagnostic_gate.py') + owners = shared.module('p18_owners', shared.SUPPORT / 'ordinary_proof_gate.py') + audio = shared.module('p18_audio', shared.SUPPORT / 'audio_evidence_gate.py') + phases, rows, previous = [], [], {} + error = None + try: + for phase, test in [('import', None), ('parse', None)] + [(str(i).zfill(3), name) for i, name in enumerate(ROSTER)]: + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged') + row = shared.execute_phase(engine, stage, out, phase, test, selected, expected, previous, + policy, contracts, parse_paths, public, protected, root, outer, markers, diagnostics, owners, audio) + phases.append(row) + if test: + row.update(sequence=len(rows), godot=engine) + rows.append(row) + (out / 'all-tests.json').write_text(json.dumps(rows, ensure_ascii=False, indent=2) + '\n') + print(('PASS ' if row['passed'] else 'FAIL ') + test, flush=True) + shared.require(row['passed'], 'first failed phase stops without retry: ' + phase) + except BaseException as caught: + error = type(caught).__name__ + ': ' + str(caught) + finally: + cleanup, cleanup_errors = outer.finish_captured(outer.OWNED) + guard_errors = [] + try: + shared.require(shared.source_refs(root) == refs, 'whole source unchanged') + shared.require(shared.sha(contract_path) == contract_sha, 'marker contract unchanged at end') + shared.require(shared.default_player(root) == protected, 'default profile untouched') + shared.require(shared.inventory(stage, stage=True) == dict(expected, **previous), 'whole stage exact') + except BaseException as caught: + guard_errors.append(type(caught).__name__ + ': ' + str(caught)) + ended = bool(outer.OWNED) and all(row['engine_ended'] is True for row in cleanup) + passed = error is None and not cleanup_errors and not guard_errors and ended and len(phases) == len(ROSTER) + 2 and all(row['passed'] for row in phases) + context = shared.totals(rows, ROSTER) + shared.write(out / 'runner-ended.json', dict(context, protocol='guarded-p18-v1', run_id=run_id, + owner_runner_pid=os.getpid(), passed=passed, engine_ended=ended, + actual_captured_PIDs=[process.pid for process in outer.OWNED], final_owned_cleanup=cleanup, + cleanup_errors=cleanup_errors, guard_errors=guard_errors, error=error, phases=phases, + all_tests_sha256=shared.sha(out / 'all-tests.json') if (out / 'all-tests.json').exists() else None, + default_player_after=shared.default_player(root), formal_acceptance=False, + original_unit_complete=False, independent_review='pending', native_Windows='pending')) + print(json.dumps(dict(context, passed=passed, error=error, cleanup_errors=cleanup_errors, guard_errors=guard_errors)), flush=True) + return 0 if passed else 1 + + +if __name__ == '__main__': + raise SystemExit(main())