extends SceneTree const PreviewServiceType = preload("res://scripts/relationship/placement_risk_preview_service.gd") var _checks := 0 var _failures := 0 func _initialize() -> void: call_deferred("_run") func _run() -> void: print("S4: Placement Risk Preview Service") _test_half_open_multiday_contact_and_replacement() _test_deterministic_order_across_facilities() _test_high_contact_branch_and_route_confirmation() _test_content_filter_changes_presentation_only() _test_determinism_purity_and_statelessness() _test_avoidance_suggestions_reduce_contact_and_risk() _test_strict_fail_closed_schema() _test_collection_limits_and_derived_values() if _failures == 0: print("PASS: %d checks" % _checks) else: push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) quit(_failures) func _test_half_open_multiday_contact_and_replacement() -> void: var service := PreviewServiceType.new() var adjacent := _base_request(false) adjacent[&"period"] = {&"start_at": 0, &"end_at": 120} adjacent[&"actors"][0][&"existing_shifts"] = [ _shift("hero_edge", "shared", 0, 60), ] adjacent[&"actors"][1][&"existing_shifts"] = [ _shift("rival_edge", "shared", 60, 120), ] var adjacent_result: Dictionary = service.preview(adjacent) _expect(adjacent_result[&"ok"], "adjacent half-open fixture previews") _expect(adjacent_result[&"totals"][&"contact_minutes"] == 0, "adjacent half-open Shifts add no phantom contact") _expect(adjacent_result[&"totals"][&"shield_attack_candidates"] == 0, "adjacent half-open Shifts add no attack candidate") var one_minute := adjacent.duplicate(true) one_minute[&"actors"][1][&"existing_shifts"][0][&"start_at"] = 59 var one_minute_result: Dictionary = service.preview(one_minute) _expect(one_minute_result[&"ok"], "one-minute overlap fixture previews") _expect(one_minute_result[&"totals"][&"contact_minutes"] == 1, "half-open overlap counts the exact minute") var edge_interval: Dictionary = one_minute_result[&"relationships"][0][&"contacts_by_facility"][0][&"intervals"][0] _expect(edge_interval[&"start_at"] == 59 and edge_interval[&"end_at"] == 60, "half-open interval bounds remain exact") var high_result: Dictionary = service.preview(_base_request(true)) _expect(high_result[&"ok"], "multi-day proposal fixture previews") var relationship: Dictionary = high_result[&"relationships"][0] _expect(relationship[&"total_contact_minutes"] == 240, "same-ID proposals replace existing rival Shifts instead of appending them") _expect(relationship[&"contacts_by_facility"].size() == 1, "shared contacts group by facility") var intervals: Array = relationship[&"contacts_by_facility"][0][&"intervals"] _expect(intervals.size() == 2, "separate days retain separate overlap intervals") _expect( intervals[0][&"start_at"] == 90 and intervals[0][&"end_at"] == 240 and intervals[0][&"minutes"] == 150, "adjacent heroine Shifts merge without duplicate minutes" ) _expect(intervals[0][&"heroine_shift_ids"] == ["hero_day_a", "hero_day_b"], "merged interval preserves deterministic source Shift IDs") _expect( intervals[1][&"start_at"] == 1_500 and intervals[1][&"end_at"] == 1_590 and intervals[1][&"minutes"] == 90, "absolute-minute contact works across the day boundary" ) func _test_high_contact_branch_and_route_confirmation() -> void: var service := PreviewServiceType.new() var low: Dictionary = service.preview(_base_request(false)) var high: Dictionary = service.preview(_base_request(true)) _expect(low[&"ok"] and high[&"ok"], "low and high contact branches both preview") _expect(low[&"totals"][&"contact_minutes"] == 0, "separate existing facilities are a zero-contact branch") _expect(low[&"totals"][&"shield_attack_candidates"] == 0, "low-contact branch creates no Shield attack candidates") _expect(low[&"totals"][&"triggerable_events"] == 0, "low-contact branch creates no Event candidates") _expect(high[&"totals"][&"shield_attack_candidates"] == 5, "only high contact creates the deterministic Shield attack sequence") _expect(high[&"totals"][&"triggerable_events"] == 1, "only high contact unlocks the threshold Event") var relationship: Dictionary = high[&"relationships"][0] var triggers: Array[int] = [] for attack: Dictionary in relationship[&"shield_attack_candidates"]: triggers.append(int(attack[&"trigger_at"])) _expect(triggers == [119, 179, 239, 1_529, 1_589], "Shield candidates use stable 30-minute then 60-minute trigger points") _expect(relationship[&"projected_values"][&"shield_current"] == 0, "candidate mechanics clamp projected Shield at zero") _expect(relationship[&"projected_values"][&"rival_bond"] == 49, "post-break candidates project bounded Rival Bond gains") var candidate: Dictionary = relationship[&"triggerable_events"][0] _expect(candidate[&"event_id"] == "danger_event", "eligible Event is selected by mechanical thresholds") _expect(_reason_codes(candidate[&"reasons"]) == [ "CONTACT_THRESHOLD_MET", "SHIELD_THRESHOLD_MET", "RIVAL_BOND_RANGE_MET", "SHARED_FACILITY", ], "Event candidate reports every deterministic eligibility reason") _expect(candidate[&"mechanics"][&"route_lock_requested"] == "EXCLUSIVE_LOCKED", "candidate retains requested exclusive Route Lock") _expect(candidate[&"mechanics"][&"route_lock_preview"] == "EXCLUSIVE_PENDING", "preview downgrades unconfirmed exclusive lock to pending") _expect(not candidate[&"mechanics"][&"route_lock_confirmed"], "Event candidate never claims an unconfirmed Route Lock") var warning: Dictionary = relationship[&"route_lock_warning"] _expect(warning[&"confirmation_required"], "exclusive candidate emits a confirmation-required warning") _expect(not warning[&"preview_confirms_lock"], "read-only preview never confirms Route Lock") _expect(warning[&"projected_route_lock"] == "EXCLUSIVE_PENDING", "mechanical summary keeps Route Lock pending") _expect(relationship[&"risk_level"] == "CRITICAL", "confirmation-required high-contact branch is critical risk") var soft_request := _base_request(true) soft_request[&"events"][1][&"route_lock"] = "SOFT" soft_request[&"events"][1][&"route_id"] = "" var soft_result: Dictionary = service.preview(soft_request) _expect(soft_result[&"relationships"][0][&"route_lock_warning"][&"projected_route_lock"] == "SOFT", "non-exclusive candidate Route Lock is still projected mechanically") _expect(not soft_result[&"relationships"][0][&"route_lock_warning"][&"confirmation_required"], "SOFT Route Lock does not invent an exclusive confirmation") var vulnerable_low := _base_request(false) vulnerable_low[&"relationships"][0][&"shield_current"] = 0 vulnerable_low[&"relationships"][0][&"rival_bond"] = 90 _expect(service.preview(vulnerable_low)[&"relationships"][0][&"risk_level"] == "NONE", "zero-contact placement is not mislabeled from vulnerable baseline values alone") func _test_deterministic_order_across_facilities() -> void: var service := PreviewServiceType.new() var request := _base_request(false) request[&"period"] = {&"start_at": 0, &"end_at": 200} request[&"actors"][0][&"existing_shifts"] = [ _shift("hero_zeta", "zeta", 0, 60), _shift("hero_alpha", "alpha", 100, 160), ] request[&"actors"][1][&"existing_shifts"] = [ _shift("rival_zeta", "zeta", 0, 60), _shift("rival_alpha", "alpha", 100, 160), ] var result: Dictionary = service.preview(request) _expect(result[&"ok"], "multi-facility ordering fixture previews") var relationship: Dictionary = result[&"relationships"][0] _expect( [ relationship[&"contacts_by_facility"][0][&"facility_id"], relationship[&"contacts_by_facility"][1][&"facility_id"], ] == ["alpha", "zeta"], "facility summaries use stable lexical order" ) var triggers: Array[int] = [] for attack: Dictionary in relationship[&"shield_attack_candidates"]: triggers.append(int(attack[&"trigger_at"])) _expect(triggers == [29, 129], "cross-facility Shield candidates use chronological mechanical order") func _test_content_filter_changes_presentation_only() -> void: var service := PreviewServiceType.new() var outputs: Dictionary = {} for mode: String in ["NONE", "FADE", "REPLACE", "DISABLE"]: var request := _base_request(true) request[&"content_filter"][&"mode"] = mode outputs[mode] = service.preview(request) _expect(outputs[mode][&"ok"], "%s Content Filter preview succeeds" % mode) var baseline: Dictionary = outputs["NONE"][&"relationships"][0] var expected_modes := { "NONE": "FULL", "FADE": "FADE", "REPLACE": "REPLACE", "DISABLE": "DISABLE", } for mode: String in ["NONE", "FADE", "REPLACE", "DISABLE"]: var current: Dictionary = outputs[mode][&"relationships"][0] _expect(current[&"mechanical_summary"] == baseline[&"mechanical_summary"], "%s preserves risk, cost, cooldown, lock, and projected values" % mode) _expect(current[&"contacts_by_facility"] == baseline[&"contacts_by_facility"], "%s preserves mechanical contact intervals" % mode) _expect(current[&"shield_attack_candidates"] == baseline[&"shield_attack_candidates"], "%s preserves Shield attack mechanics" % mode) _expect(current[&"triggerable_events"][0][&"mechanics"] == baseline[&"triggerable_events"][0][&"mechanics"], "%s preserves Event mechanics" % mode) _expect(current[&"triggerable_events"][0][&"reasons"] == baseline[&"triggerable_events"][0][&"reasons"], "%s preserves Event reasons" % mode) _expect(current[&"triggerable_events"][0][&"presentation"][&"mode"] == expected_modes[mode], "%s changes only the Event presentation mode" % mode) _expect(outputs["REPLACE"][&"relationships"][0][&"triggerable_events"][0][&"presentation"][&"display_event_id"] == "safe_notice", "REPLACE displays the validated safe replacement") _expect(not outputs["DISABLE"][&"relationships"][0][&"triggerable_events"][0][&"presentation"][&"visible"], "DISABLE hides presentation without removing mechanics") func _test_determinism_purity_and_statelessness() -> void: var service := PreviewServiceType.new() var request := _base_request(true) var before := request.duplicate(true) var first: Dictionary = service.preview(request) var second: Dictionary = service.preview(request) _expect(first == second, "identical request produces exact deterministic output") _expect(request == before, "preview does not mutate any nested input data") var shuffled := request.duplicate(true) shuffled[&"actors"].reverse() shuffled[&"events"].reverse() for actor: Dictionary in shuffled[&"actors"]: actor[&"existing_shifts"].reverse() actor[&"proposed_shifts"].reverse() _expect(service.preview(shuffled) == first, "input array order does not change canonical Preview order") var low: Dictionary = service.preview(_base_request(false)) var third: Dictionary = service.preview(request) _expect(low[&"totals"][&"contact_minutes"] == 0 and third == first, "interleaved preview calls retain no service state") _expect(not service.has_method("create_snapshot") and not service.has_method("restore_snapshot"), "stateless preview exposes no snapshot lifecycle") _expect(not service.has_method("configure_callbacks"), "pure preview exposes no callback or re-entry surface") func _test_avoidance_suggestions_reduce_contact_and_risk() -> void: var service := PreviewServiceType.new() var request := _base_request(true) var original: Dictionary = service.preview(request) var relationship: Dictionary = original[&"relationships"][0] var suggestions: Array = relationship[&"avoidance_suggestions"] _expect(suggestions.size() == 2, "preview returns both facility and time separation suggestions") _expect(suggestions[0][&"kind"] == "CHANGE_FACILITY" and suggestions[1][&"kind"] == "SEPARATE_TIME", "avoidance suggestions have deterministic kind order") for suggestion: Dictionary in suggestions: _expect(suggestion[&"reduction_minutes"] > 0, "%s advertises only a positive deterministic reduction" % suggestion[&"kind"]) var applied := request.duplicate(true) _replace_actor_proposal(applied, String(suggestion[&"actor_id"]), suggestion[&"proposed_shift"]) var reduced: Dictionary = service.preview(applied) _expect(reduced[&"ok"], "%s proposal is directly previewable" % suggestion[&"kind"]) _expect(reduced[&"totals"][&"contact_minutes"] == suggestion[&"contact_minutes_after"], "%s predicted contact matches reapplied result" % suggestion[&"kind"]) _expect(reduced[&"totals"][&"contact_minutes"] < original[&"totals"][&"contact_minutes"], "%s lowers shared-facility contact" % suggestion[&"kind"]) _expect(reduced[&"totals"][&"shield_attack_candidates"] < original[&"totals"][&"shield_attack_candidates"], "%s lowers Shield attack candidates" % suggestion[&"kind"]) _expect(_risk_rank(reduced[&"relationships"][0][&"risk_level"]) < _risk_rank(relationship[&"risk_level"]), "%s lowers the resulting risk level" % suggestion[&"kind"]) _expect(request == _base_request(true), "applying suggestions to copies leaves source proposal unchanged") func _test_strict_fail_closed_schema() -> void: var service := PreviewServiceType.new() _expect_rejected(service.preview(null), "INVALID_TYPE", "non-Dictionary request is rejected") var unknown_top := _base_request(false) unknown_top[&"derived_risk"] = "NONE" _expect_rejected(service.preview(unknown_top), "INVALID_SCHEMA", "unknown top-level field is rejected") var missing_top := _base_request(false) missing_top.erase(&"events") _expect_rejected(service.preview(missing_top), "INVALID_SCHEMA", "missing top-level field is rejected") var fake_version := _base_request(false) fake_version[&"schema_version"] = "1" _expect_rejected(service.preview(fake_version), "UNSUPPORTED_SCHEMA", "numeric-string schema version is rejected") var fake_time := _base_request(false) fake_time[&"period"][&"start_at"] = 0.0 _expect_rejected(service.preview(fake_time), "INVALID_TYPE", "float period bound is rejected") var long_period := _base_request(false) long_period[&"period"][&"end_at"] = 14 * 1_440 + 1 _expect_rejected(service.preview(long_period), "OUT_OF_RANGE", "period beyond the 14-day limit is rejected") var actor_unknown := _base_request(false) actor_unknown[&"actors"][0][&"derived_availability"] = true _expect_rejected(service.preview(actor_unknown), "INVALID_SCHEMA", "unknown actor field is rejected") var role_fake := _base_request(false) role_fake[&"actors"][0][&"role"] = "heroine" _expect_rejected(service.preview(role_fake), "INVALID_ENUM", "case-forged role enum is rejected") var shift_unknown := _base_request(false) shift_unknown[&"actors"][0][&"existing_shifts"][0][&"duration"] = 120 _expect_rejected(service.preview(shift_unknown), "INVALID_SCHEMA", "derived Shift duration is rejected") var shift_string := _base_request(false) shift_string[&"actors"][0][&"existing_shifts"][0][&"start_at"] = "60" _expect_rejected(service.preview(shift_string), "INVALID_TYPE", "numeric-string Shift bound is rejected") var double_booked := _base_request(true) double_booked[&"actors"][1][&"proposed_shifts"][1][&"start_at"] = 200 double_booked[&"actors"][1][&"proposed_shifts"][1][&"end_at"] = 300 _expect_rejected(service.preview(double_booked), "ACTOR_DOUBLE_BOOKED", "overlapping effective Shift placement is rejected") var filter_unknown := _base_request(false) filter_unknown[&"content_filter"][&"visible"] = false _expect_rejected(service.preview(filter_unknown), "INVALID_SCHEMA", "unknown Content Filter field is rejected") var bad_filter_enum := _base_request(false) bad_filter_enum[&"content_filter"][&"mode"] = "HIDE" _expect_rejected(service.preview(bad_filter_enum), "INVALID_ENUM", "unknown Content Filter mode is rejected") var event_unknown := _base_request(false) event_unknown[&"events"][0][&"eligible"] = true _expect_rejected(service.preview(event_unknown), "INVALID_SCHEMA", "derived Event eligibility is rejected") var event_missing := _base_request(false) event_missing[&"events"][0].erase(&"cooldown_minutes") _expect_rejected(service.preview(event_missing), "INVALID_SCHEMA", "missing Event field is rejected") var event_float := _base_request(false) event_float[&"events"][0][&"cost_gold"] = 75.0 _expect_rejected(service.preview(event_float), "INVALID_TYPE", "float Event cost is rejected") var event_route := _base_request(false) event_route[&"events"][0][&"route_lock"] = "LOCKED" _expect_rejected(service.preview(event_route), "INVALID_ENUM", "forged Route Lock enum is rejected") var event_relationship := _base_request(false) event_relationship[&"events"][0][&"relationship_id"] = "missing" _expect_rejected(service.preview(event_relationship), "MISSING_RELATIONSHIP", "Event cannot reference an unknown relationship") var duplicate_pair := _base_request(false) var repeated_relationship: Dictionary = duplicate_pair[&"relationships"][0].duplicate(true) repeated_relationship[&"relationship_id"] = "duplicate_pair" duplicate_pair[&"relationships"].append(repeated_relationship) _expect_rejected(service.preview(duplicate_pair), "DUPLICATE_PAIR", "duplicate heroine-rival pair cannot double-count Preview totals") var missing_replacement := _base_request(true) missing_replacement[&"content_filter"][&"mode"] = "REPLACE" missing_replacement[&"events"][1][&"replacement_event_id"] = "missing" _expect_rejected(service.preview(missing_replacement), "REPLACEMENT_MISSING", "REPLACE fails closed when safe replacement is absent") var unsafe_replacement := _base_request(true) unsafe_replacement[&"content_filter"][&"mode"] = "REPLACE" unsafe_replacement[&"events"][0][&"content_rating"] = "RESTRICTED" _expect_rejected(service.preview(unsafe_replacement), "REPLACEMENT_UNSAFE", "REPLACE rejects a filtered replacement chain") func _test_collection_limits_and_derived_values() -> void: var service := PreviewServiceType.new() var too_many_actors := _base_request(false) while too_many_actors[&"actors"].size() <= 64: too_many_actors[&"actors"].append(too_many_actors[&"actors"][0].duplicate(true)) _expect_rejected(service.preview(too_many_actors), "COLLECTION_LIMIT", "actor collection upper bound is enforced before parsing") var too_many_shifts := _base_request(false) too_many_shifts[&"actors"][0][&"existing_shifts"] = [] for index: int in range(65): too_many_shifts[&"actors"][0][&"existing_shifts"].append(_shift("h_%02d" % index, "solo", index * 10, index * 10 + 5)) _expect_rejected(service.preview(too_many_shifts), "COLLECTION_LIMIT", "per-list Shift upper bound is enforced") var too_many_effective := _base_request(false) too_many_effective[&"actors"][0][&"existing_shifts"] = [] for index: int in range(64): too_many_effective[&"actors"][0][&"existing_shifts"].append(_shift("h_%02d" % index, "solo", index * 10, index * 10 + 5)) too_many_effective[&"actors"][0][&"proposed_shifts"] = [_shift("h_new", "solo", 700, 705)] _expect_rejected(service.preview(too_many_effective), "COLLECTION_LIMIT", "combined effective Shift upper bound prevents overlap-pair truncation") var too_many_events := _base_request(false) too_many_events[&"events"] = [] for _index: int in range(257): too_many_events[&"events"].append(_safe_event()) _expect_rejected(service.preview(too_many_events), "COLLECTION_LIMIT", "Event definition upper bound is enforced before parsing") var forged_shield := _base_request(false) forged_shield[&"relationships"][0][&"shield_current"] = 90 forged_shield[&"relationships"][0][&"shield_max"] = 80 _expect_rejected(service.preview(forged_shield), "INVALID_DERIVED_VALUE", "forged current Shield above Shield Max is rejected") var duplicate_tag := _base_request(false) duplicate_tag[&"events"][1][&"content_tags"] = ["intimacy", "intimacy"] _expect_rejected(service.preview(duplicate_tag), "DUPLICATE_VALUE", "duplicate content tags are rejected") func _base_request(high_contact: bool) -> Dictionary: var rival_proposals: Array[Dictionary] = [] if high_contact: rival_proposals = [ _shift("rival_day", "shared", 90, 240), _shift("rival_next_day", "shared", 1_500, 1_590), ] return { &"schema_version": 1, &"period": {&"start_at": 0, &"end_at": 2_880}, &"actors": [ { &"actor_id": "heroine", &"role": "HEROINE", &"existing_shifts": [ _shift("hero_day_a", "shared", 60, 180), _shift("hero_day_b", "shared", 180, 300), _shift("hero_next_day", "shared", 1_500, 1_620), ], &"proposed_shifts": [], &"mechanical_effects": { &"base_shield_attack": 0, &"night_shield_attack_bonus": 0, }, }, { &"actor_id": "rival", &"role": "RIVAL", &"existing_shifts": [ _shift("rival_day", "archive", 90, 240), _shift("rival_next_day", "archive", 1_500, 1_590), ], &"proposed_shifts": rival_proposals, &"mechanical_effects": { &"base_shield_attack": 8, &"night_shield_attack_bonus": 4, }, }, ], &"relationships": [ { &"relationship_id": "main_pair", &"heroine_id": "heroine", &"rival_id": "rival", &"affection": 55, &"rival_bond": 40, &"shield_current": 20, &"shield_max": 100, &"route_lock": "NONE", &"route_id": "", &"target_resistance": 0, &"monitoring_bonus": 0, }, ], &"events": [_safe_event(), _danger_event()], &"content_filter": { &"mode": "NONE", &"maximum_rating": "GENERAL", &"disabled_tags": [], }, } func _safe_event() -> Dictionary: return { &"event_id": "safe_notice", &"relationship_id": "main_pair", &"priority": 1, &"min_contact_minutes": 1_000, &"max_shield_current": 100, &"min_rival_bond": 0, &"max_rival_bond": 100, &"required_facility_id": "", &"shield_damage": 0, &"rival_bond_delta": 0, &"cost_gold": 0, &"cooldown_minutes": 0, &"route_lock": "NONE", &"route_id": "", &"content_rating": "GENERAL", &"content_tags": [], &"filter_behavior": "NONE", &"replacement_event_id": "", &"display_name": "A quiet scheduling notice", } func _danger_event() -> Dictionary: return { &"event_id": "danger_event", &"relationship_id": "main_pair", &"priority": 100, &"min_contact_minutes": 120, &"max_shield_current": 0, &"min_rival_bond": 0, &"max_rival_bond": 100, &"required_facility_id": "shared", &"shield_damage": 0, &"rival_bond_delta": 5, &"cost_gold": 75, &"cooldown_minutes": 720, &"route_lock": "EXCLUSIVE_LOCKED", &"route_id": "rival_route", &"content_rating": "RESTRICTED", &"content_tags": ["intimacy"], &"filter_behavior": "NONE", &"replacement_event_id": "safe_notice", &"display_name": "Restricted encounter", } func _shift(shift_id: String, facility_id: String, start_at: int, end_at: int) -> Dictionary: return { &"shift_id": shift_id, &"facility_id": facility_id, &"start_at": start_at, &"end_at": end_at, } func _replace_actor_proposal(request: Dictionary, actor_id: String, proposal: Dictionary) -> void: for actor: Dictionary in request[&"actors"]: if actor[&"actor_id"] != actor_id: continue var replaced := false for index: int in range(actor[&"proposed_shifts"].size()): if actor[&"proposed_shifts"][index][&"shift_id"] == proposal[&"shift_id"]: actor[&"proposed_shifts"][index] = proposal.duplicate(true) replaced = true break if not replaced: actor[&"proposed_shifts"].append(proposal.duplicate(true)) return func _reason_codes(reasons: Array) -> Array[String]: var result: Array[String] = [] for reason: Dictionary in reasons: result.append(String(reason[&"code"])) return result func _risk_rank(level: String) -> int: return ["NONE", "LOW", "MEDIUM", "HIGH", "CRITICAL"].find(level) func _expect_rejected(result: Dictionary, code: String, message: String) -> void: _expect(not bool(result.get(&"ok", true)) and result.get(&"error_code", "") == code, message) func _expect(condition: bool, message: String) -> void: _checks += 1 if condition: return _failures += 1 push_error("FAIL: %s" % message)