474 lines
23 KiB
GDScript
474 lines
23 KiB
GDScript
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)
|