Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://bp0j42sq0baq4
|
||||
@@ -0,0 +1,441 @@
|
||||
extends SceneTree
|
||||
|
||||
const RelationshipStateType = preload("res://scripts/relationship/relationship_state.gd")
|
||||
const RelationshipServiceType = preload("res://scripts/relationship/relationship_service.gd")
|
||||
const EventConditionType = preload("res://scripts/events/event_condition.gd")
|
||||
const EventActionType = preload("res://scripts/events/event_action.gd")
|
||||
const EventChoiceType = preload("res://scripts/events/event_choice.gd")
|
||||
const EventDefinitionType = preload("res://scripts/events/event_definition.gd")
|
||||
const EventInstanceType = preload("res://scripts/events/event_instance.gd")
|
||||
const EventServiceType = preload("res://scripts/events/event_service.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S4: Relationship and Event Service")
|
||||
_test_relationship_pair_metrics_and_thresholds()
|
||||
_test_shield_penalty_and_route_confirmation()
|
||||
_test_relationship_snapshot_atomicity()
|
||||
_test_condition_tree()
|
||||
_test_foreground_queue_and_choice_idempotency()
|
||||
_test_missing_actor_releases_lock_and_cost()
|
||||
_test_content_replacement_boundary()
|
||||
_test_transaction_policies()
|
||||
_test_event_snapshot_round_trip_and_atomicity()
|
||||
_test_event_recovery_lock_scope_and_strict_filter_parse()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_relationship_pair_metrics_and_thresholds() -> void:
|
||||
var service := RelationshipServiceType.new()
|
||||
var created = service.create_relationship(
|
||||
&"rel_main",
|
||||
&"zoe",
|
||||
&"alex",
|
||||
{
|
||||
&"trust": 20,
|
||||
&"respect": 30,
|
||||
&"affection": 45,
|
||||
&"fear": 5,
|
||||
&"dependence": 10,
|
||||
&"shield_max": 40,
|
||||
&"shield_current": 35,
|
||||
}
|
||||
)
|
||||
_expect(created.is_success(), "relationship registers")
|
||||
var state := service.get_relationship(&"rel_main")
|
||||
_expect(state.actor_a_id == &"alex" and state.actor_b_id == &"zoe", "actor pair normalizes lexically")
|
||||
_expect(service.find_relationship(&"alex", &"zoe") == state, "normalized pair lookup is direction independent")
|
||||
_expect(not service.create_relationship(&"rel_duplicate", &"alex", &"zoe").is_success(), "duplicate normalized actor pair is rejected")
|
||||
_expect(state.get_metric(&"trust") == 20 and state.get_metric(&"respect") == 30, "five canonical metrics are retained")
|
||||
|
||||
var gated = service.apply_value_change(&"rel_main", &"affection", 10, 100)
|
||||
_expect(gated.is_success() and state.get_metric(&"affection") == 49, "background change stops before gated threshold 50")
|
||||
_expect(gated.details[&"gated_threshold"] == 50, "gated change exposes its required threshold Event")
|
||||
var candidates := service.get_threshold_candidates()
|
||||
_expect(candidates.size() == 1 and candidates[0][&"requires_unlock_event"], "threshold crossing registers a candidate without immediate playback")
|
||||
var unlocked = service.apply_value_change(
|
||||
&"rel_main",
|
||||
&"affection",
|
||||
1,
|
||||
101,
|
||||
{&"important": true, &"allow_threshold_crossing": true}
|
||||
)
|
||||
_expect(unlocked.is_success() and state.get_metric(&"affection") == 50, "explicit important Event may cross the gated threshold")
|
||||
service.apply_value_change(&"rel_main", &"trust", 50, 102)
|
||||
_expect(state.get_metric(&"trust") == 30, "automatic relationship drift is limited to +10 per day")
|
||||
service.apply_value_change(&"rel_main", &"trust", 50, 1_500)
|
||||
_expect(state.get_metric(&"trust") == 40, "daily automatic limit resets on a new day")
|
||||
|
||||
|
||||
func _test_shield_penalty_and_route_confirmation() -> void:
|
||||
var service := RelationshipServiceType.new()
|
||||
service.create_relationship(
|
||||
&"rel_shield",
|
||||
&"owner",
|
||||
&"target",
|
||||
{&"shield_max": 100, &"shield_current": 100}
|
||||
)
|
||||
var state := service.get_relationship(&"rel_shield")
|
||||
var damages: Array[int] = []
|
||||
var penalties: Array[int] = []
|
||||
for minute: int in range(4):
|
||||
var result = service.apply_shield_attack(
|
||||
&"rel_shield",
|
||||
&"repeat_action",
|
||||
minute,
|
||||
{&"base_damage": 20, &"max_damage": 20, &"daily_limit": 4}
|
||||
)
|
||||
damages.append(int(result.details[&"damage"]))
|
||||
penalties.append(int(result.details[&"penalty_percent"]))
|
||||
_expect(penalties == [0, 25, 50, 75], "repeat Action applies 0/25/50/75 percent penalty stages")
|
||||
_expect(damages == [20, 15, 10, 5], "repeat penalty changes deterministic Shield damage")
|
||||
_expect(not service.apply_shield_attack(&"rel_shield", &"repeat_action", 4, {&"base_damage": 20}).is_success(), "daily Action use limit is enforced")
|
||||
service.apply_shield_attack(&"rel_shield", &"fresh_action", 5, {&"base_damage": 500, &"max_damage": 500})
|
||||
_expect(state.shield_current == 0, "Shield never falls below zero")
|
||||
var next_day = service.apply_shield_attack(&"rel_shield", &"repeat_action", 1_440, {&"base_damage": 20, &"max_damage": 20})
|
||||
_expect(next_day.is_success() and next_day.details[&"penalty_percent"] == 0, "repeat count resets at the day boundary")
|
||||
|
||||
var direct = service.request_route_lock(
|
||||
&"rel_shield",
|
||||
RelationshipStateType.RouteLock.EXCLUSIVE_LOCKED,
|
||||
&"route_a"
|
||||
)
|
||||
_expect(not direct.is_success() and state.route_lock == RelationshipStateType.RouteLock.NONE, "exclusive route cannot lock without confirmation")
|
||||
_expect(service.request_route_lock(&"rel_shield", RelationshipStateType.RouteLock.EXCLUSIVE_PENDING, &"route_a").is_success(), "exclusive route first enters pending state")
|
||||
_expect(not service.confirm_route_lock(&"rel_shield", &"route_b").is_success(), "mismatched route confirmation is rejected")
|
||||
_expect(service.confirm_route_lock(&"rel_shield", &"route_a").is_success(), "matching explicit confirmation locks the route")
|
||||
|
||||
|
||||
func _test_relationship_snapshot_atomicity() -> void:
|
||||
var service := RelationshipServiceType.new()
|
||||
service.create_relationship(&"rel_save", &"b", &"a", {&"trust": 12, &"shield_max": 50, &"shield_current": 25})
|
||||
service.apply_value_change(&"rel_save", &"trust", 5, 20)
|
||||
var snapshot: Dictionary = service.create_snapshot()
|
||||
var restored := RelationshipServiceType.new()
|
||||
_expect(restored.restore_snapshot(snapshot), "valid Relationship snapshot restores")
|
||||
_expect(restored.create_snapshot() == snapshot, "Relationship snapshot round trips exactly")
|
||||
var before: Dictionary = restored.create_snapshot()
|
||||
var corrupt := snapshot.duplicate(true)
|
||||
corrupt[&"version"] = 999
|
||||
_expect(not restored.restore_snapshot(corrupt), "unsupported Relationship snapshot is rejected")
|
||||
_expect(restored.create_snapshot() == before, "failed Relationship restore is atomic")
|
||||
|
||||
|
||||
func _test_condition_tree() -> void:
|
||||
var condition := EventConditionType.new().configure(
|
||||
EventConditionType.Operator.ALL,
|
||||
[],
|
||||
null,
|
||||
[
|
||||
EventConditionType.new().configure(EventConditionType.Operator.GTE, "relationship.trust", 50),
|
||||
EventConditionType.new().configure(EventConditionType.Operator.HAS_FLAG, "flags", &"met_before"),
|
||||
EventConditionType.new().configure(EventConditionType.Operator.TIME_IN_WINDOW, "absolute_minute", [1_200, 300]),
|
||||
EventConditionType.new().configure(EventConditionType.Operator.ENTITY_EXISTS, "actor_id"),
|
||||
]
|
||||
)
|
||||
var context := {
|
||||
&"relationship": {&"trust": 60},
|
||||
&"flags": {&"met_before": true},
|
||||
&"absolute_minute": 1_300,
|
||||
&"actor_id": &"actor_a",
|
||||
}
|
||||
var callbacks := {&"entity_exists": func(actor_id: Variant) -> bool: return actor_id == &"actor_a"}
|
||||
_expect(condition.is_valid() and condition.evaluate(context, callbacks), "nested data Condition evaluates comparisons, flag, overnight time and entity callback")
|
||||
var clone := EventConditionType.from_variant(condition.create_snapshot())
|
||||
_expect(clone != null and clone.evaluate(context, callbacks), "Condition snapshot preserves deterministic evaluation")
|
||||
context[&"relationship"][&"trust"] = 49
|
||||
_expect(not clone.evaluate(context, callbacks), "ALL condition fails when one child changes")
|
||||
|
||||
|
||||
func _test_foreground_queue_and_choice_idempotency() -> void:
|
||||
var actor_live := {&"actor_a": true, &"actor_b": true}
|
||||
var applied := {&"count": 0}
|
||||
var service := EventServiceType.new().configure_callbacks(
|
||||
func(actor_id: StringName) -> bool: return bool(actor_live.get(actor_id, false)),
|
||||
func(action: EventAction, phase: StringName, _context: Dictionary, token: Variant) -> Dictionary:
|
||||
if phase == &"validate":
|
||||
return {&"ok": true}
|
||||
if phase == &"apply":
|
||||
var old_value := int(applied[&"count"])
|
||||
applied[&"count"] = old_value + int(action.payload.get(&"delta", 0))
|
||||
return {&"ok": true, &"token": old_value}
|
||||
if phase == &"rollback":
|
||||
applied[&"count"] = int(token)
|
||||
return {&"ok": true}
|
||||
)
|
||||
service.register_definition(_choice_definition(&"low", 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [_delta_action(&"low_add", 1)]))
|
||||
service.register_definition(_choice_definition(&"high", 50, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [_delta_action(&"high_add", 3)]))
|
||||
service.enqueue_candidate(&"low", [&"actor_b"], 0, {&"event_instance_id": &"event_low"})
|
||||
service.enqueue_candidate(&"high", [&"actor_a"], 0, {&"event_instance_id": &"event_high"})
|
||||
_expect(_instance_ids(service.get_queued_instances()) == [&"event_high", &"event_low"], "Event queue sorts priority descending then deterministic instance ID")
|
||||
var started = service.start_next_event(0)
|
||||
_expect(started.is_success() and started.details[&"event_instance_id"] == &"event_high", "highest priority foreground Event starts first")
|
||||
_expect(service.get_actor_locks() == {&"actor_a": &"event_high"}, "foreground Event locks all actors by stable ID")
|
||||
_expect(not service.start_next_event(0).is_success(), "only one foreground Event may run at a time")
|
||||
var choice_token: StringName = started.details[&"choice_instance_id"]
|
||||
var first = service.submit_choice(&"event_high", choice_token, &"accept", 0)
|
||||
var second = service.submit_choice(&"event_high", choice_token, &"accept", 0)
|
||||
_expect(first.is_success() and second.is_success() and second.is_replay, "duplicate Choice submission returns cached replay")
|
||||
_expect(applied[&"count"] == 3, "duplicate Choice never applies Actions twice")
|
||||
_expect(service.get_actor_locks().is_empty(), "completed Event releases actor locks")
|
||||
_expect(service.start_next_event(1).is_success(), "next foreground Event starts after lock release")
|
||||
|
||||
|
||||
func _test_missing_actor_releases_lock_and_cost() -> void:
|
||||
var actor_live := {&"actor_a": true}
|
||||
var released: Array = []
|
||||
var committed: Array = []
|
||||
var service := EventServiceType.new().configure_callbacks(
|
||||
func(actor_id: StringName) -> bool: return bool(actor_live.get(actor_id, false)),
|
||||
func(_action: EventAction, _phase: StringName, _context: Dictionary, _token: Variant) -> Dictionary:
|
||||
return {&"ok": true},
|
||||
func(cost: Dictionary, _context: Dictionary) -> Dictionary:
|
||||
return {&"ok": true, &"token": {&"reserved_gold": int(cost.get(&"gold", 0))}},
|
||||
func(token: Variant, _context: Dictionary) -> bool:
|
||||
released.append(token)
|
||||
return true,
|
||||
func(token: Variant, _context: Dictionary) -> bool:
|
||||
committed.append(token)
|
||||
return true
|
||||
)
|
||||
var definition := _choice_definition(&"missing_actor", 20, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, [_delta_action(&"noop", 0)], {&"gold": 25})
|
||||
service.register_definition(definition)
|
||||
service.enqueue_candidate(&"missing_actor", [&"actor_a"], 10, {&"event_instance_id": &"event_missing"})
|
||||
var started = service.start_next_event(10)
|
||||
_expect(started.is_success(), "missing-actor fixture starts while actor exists")
|
||||
_expect(service.reserve_choice_cost(&"event_missing", &"accept", 10).is_success(), "Choice cost reserves while UI is waiting")
|
||||
actor_live[&"actor_a"] = false
|
||||
var audit := service.audit_actor_availability(11)
|
||||
_expect(audit.size() == 1 and service.get_instance(&"event_missing").state == EventInstanceType.State.CANCELLED, "actor disappearance cancels Event by default policy")
|
||||
_expect(service.get_actor_locks().is_empty(), "actor disappearance always releases foreground lock")
|
||||
_expect(released.size() == 1 and committed.is_empty(), "actor disappearance releases reserved Cost without committing it")
|
||||
|
||||
|
||||
func _test_content_replacement_boundary() -> void:
|
||||
var service := EventServiceType.new()
|
||||
service.register_definition(EventDefinitionType.new().configure(&"safe_business", {
|
||||
&"content_rating": &"GENERAL",
|
||||
&"content_tags": [&"business_conflict"],
|
||||
}))
|
||||
service.register_definition(EventDefinitionType.new().configure(&"restricted_with_replacement", {
|
||||
&"content_rating": &"RESTRICTED",
|
||||
&"content_tags": [&"restricted_route"],
|
||||
&"filter_behavior": &"REPLACE",
|
||||
&"replacement_event_id": &"safe_business",
|
||||
}))
|
||||
service.register_definition(EventDefinitionType.new().configure(&"restricted_missing", {
|
||||
&"content_rating": &"RESTRICTED",
|
||||
&"content_tags": [&"restricted_route"],
|
||||
&"filter_behavior": &"REPLACE",
|
||||
&"replacement_event_id": &"missing_safe_event",
|
||||
}))
|
||||
service.configure_content_filter(
|
||||
EventDefinitionType.FilterBehavior.REPLACE,
|
||||
EventDefinitionType.ContentRating.GENERAL,
|
||||
[&"restricted_route"]
|
||||
)
|
||||
var replaced := service.enqueue_candidate(&"restricted_with_replacement", [], 0, {&"event_instance_id": &"event_replaced"})
|
||||
_expect(replaced.is_success() and replaced.details[&"resolved_event_id"] == &"safe_business", "restricted Event resolves only to configured safe replacement ID")
|
||||
var disabled := service.enqueue_candidate(&"restricted_missing", [], 0)
|
||||
_expect(not disabled.is_success() and disabled.details[&"reason"] == &"REPLACEMENT_MISSING", "missing replacement safely disables candidate")
|
||||
_expect(not service.validate_definitions()[&"ok"], "definition audit reports missing replacement boundary")
|
||||
|
||||
|
||||
func _test_transaction_policies() -> void:
|
||||
var rollback_state := {&"value": 0}
|
||||
var rollback_service: Variant = _transaction_service(rollback_state)
|
||||
rollback_service.register_definition(_choice_definition(
|
||||
&"rollback_event",
|
||||
10,
|
||||
EventDefinitionType.TransactionPolicy.ROLLBACK_ALL,
|
||||
[_delta_action(&"add_first", 5), _failing_action(&"fail_second")]
|
||||
))
|
||||
var rollback_result = _run_choice(rollback_service, &"rollback_event", &"rollback_instance")
|
||||
_expect(not rollback_result.is_success() and rollback_state[&"value"] == 0, "ROLLBACK_ALL reverses prior Action after later failure")
|
||||
|
||||
var skip_state := {&"value": 0}
|
||||
var skip_service: Variant = _transaction_service(skip_state)
|
||||
skip_service.register_definition(_choice_definition(
|
||||
&"skip_event",
|
||||
10,
|
||||
EventDefinitionType.TransactionPolicy.SKIP_FAILED,
|
||||
[_delta_action(&"add_one", 2), _failing_action(&"skip_me"), _delta_action(&"add_two", 3)]
|
||||
))
|
||||
var skip_result = _run_choice(skip_service, &"skip_event", &"skip_instance")
|
||||
_expect(skip_result.is_success() and skip_state[&"value"] == 5, "SKIP_FAILED keeps successful Actions and skips only failed Action")
|
||||
_expect(skip_result.details[&"skipped_action_ids"] == [&"skip_me"], "SKIP_FAILED reports deterministic skipped Action IDs")
|
||||
|
||||
var branch_state := {&"value": 0}
|
||||
var branch_service: Variant = _transaction_service(branch_state)
|
||||
var branch_definition := _choice_definition(
|
||||
&"branch_event",
|
||||
10,
|
||||
EventDefinitionType.TransactionPolicy.BRANCH_FAILURE,
|
||||
[_delta_action(&"primary_add", 9), _failing_action(&"primary_fail")]
|
||||
)
|
||||
branch_definition.failure_actions = [_delta_action(&"failure_branch_add", 4)]
|
||||
branch_service.register_definition(branch_definition)
|
||||
var branch_result = _run_choice(branch_service, &"branch_event", &"branch_instance")
|
||||
_expect(branch_result.is_success() and branch_state[&"value"] == 4, "BRANCH_FAILURE rolls back primary Actions then applies failure branch")
|
||||
_expect(branch_result.details[&"branched_failure"], "BRANCH_FAILURE result marks selected failure branch")
|
||||
|
||||
|
||||
func _test_event_snapshot_round_trip_and_atomicity() -> void:
|
||||
var service := EventServiceType.new()
|
||||
service.register_definition(_choice_definition(&"save_event", 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, []))
|
||||
service.enqueue_candidate(&"save_event", [&"actor_a"], 100, {&"event_instance_id": &"save_instance"})
|
||||
service.start_next_event(100)
|
||||
var snapshot: Dictionary = service.create_snapshot()
|
||||
var restored := EventServiceType.new()
|
||||
restored.register_definition(_choice_definition(&"save_event", 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, []))
|
||||
_expect(restored.restore_snapshot(snapshot), "Event queue, Choice token and Actor Locks restore")
|
||||
_expect(restored.create_snapshot() == snapshot, "active foreground Event snapshot round trips exactly")
|
||||
_expect(restored.validate_invariants()[&"ok"], "restored EventService satisfies Actor Lock invariants")
|
||||
var before: Dictionary = restored.create_snapshot()
|
||||
var corrupt := snapshot.duplicate(true)
|
||||
corrupt[&"actor_locks"] = {&"wrong_actor": &"save_instance"}
|
||||
_expect(not restored.restore_snapshot(corrupt), "orphaned Actor Lock snapshot is rejected")
|
||||
_expect(restored.create_snapshot() == before, "failed Event restore is atomic")
|
||||
|
||||
|
||||
func _test_event_recovery_lock_scope_and_strict_filter_parse() -> void:
|
||||
var definition := _choice_definition(
|
||||
&"recover_event",
|
||||
10,
|
||||
EventDefinitionType.TransactionPolicy.ROLLBACK_ALL,
|
||||
[]
|
||||
)
|
||||
var source := EventServiceType.new()
|
||||
source.register_definition(definition)
|
||||
source.enqueue_candidate(&"recover_event", [&"actor_a"], 10, {&"event_instance_id": &"recover_instance"})
|
||||
source.start_next_event(10)
|
||||
var terminal_snapshot: Dictionary = source.create_snapshot()
|
||||
terminal_snapshot[&"instances"][0][&"state"] = "COMPLETED"
|
||||
terminal_snapshot[&"active_deduplication_keys"] = {}
|
||||
var recovered := EventServiceType.new()
|
||||
recovered.register_definition(definition)
|
||||
_expect(recovered.restore_snapshot_with_lock_recovery(terminal_snapshot), "recovery accepts the terminal foreground's exact Actor Lock set")
|
||||
_expect(recovered.get_actor_locks().is_empty() and recovered.get_foreground_instance() == null, "terminal recovery releases only the matching foreground pointer and lock")
|
||||
_expect(recovered.last_recovery_repair_report[&"released_actor_ids"] == [&"actor_a"], "terminal recovery report names the exact released Actor Lock")
|
||||
|
||||
var live_before: Dictionary = recovered.create_snapshot()
|
||||
var mixed_corruption: Dictionary = terminal_snapshot.duplicate(true)
|
||||
mixed_corruption[&"actor_locks"][&"unrelated_actor"] = "recover_instance"
|
||||
_expect(not recovered.restore_snapshot_with_lock_recovery(mixed_corruption), "valid foreground lock mixed with unrelated lock corruption is hard-rejected")
|
||||
_expect(recovered.create_snapshot() == live_before, "unrelated lock rejection preserves the live Event state atomically")
|
||||
|
||||
var missing_foreground: Dictionary = terminal_snapshot.duplicate(true)
|
||||
missing_foreground[&"instances"] = []
|
||||
var missing_recovered := EventServiceType.new()
|
||||
missing_recovered.register_definition(definition)
|
||||
_expect(missing_recovered.restore_snapshot_with_lock_recovery(missing_foreground), "missing foreground recovery releases locks that still name only the missing foreground ID")
|
||||
_expect(missing_recovered.last_recovery_repair_report[&"reason"] == &"MISSING_FOREGROUND" and missing_recovered.last_recovery_repair_report[&"released_actor_ids"] == [&"actor_a"], "missing foreground report preserves the exact released Actor IDs and reason")
|
||||
|
||||
var unknown_mode: Dictionary = live_before.duplicate(true)
|
||||
unknown_mode[&"filter_mode"] = "UNKNOWN_FILTER"
|
||||
_expect(not recovered.restore_snapshot(unknown_mode), "unknown serialized Content Filter behavior is rejected instead of falling back")
|
||||
_expect(recovered.create_snapshot() == live_before, "unknown filter rejection preserves the live Event state")
|
||||
var out_of_range_rating: Dictionary = live_before.duplicate(true)
|
||||
out_of_range_rating[&"maximum_content_rating"] = 99
|
||||
_expect(not recovered.restore_snapshot(out_of_range_rating), "out-of-range serialized Content Rating is rejected instead of clamped")
|
||||
_expect(recovered.create_snapshot() == live_before, "out-of-range rating rejection preserves the live Event state")
|
||||
|
||||
var serialized_definition: Dictionary = definition.create_snapshot()
|
||||
serialized_definition[&"filter_behavior"] = "UNKNOWN_FILTER"
|
||||
_expect(EventDefinitionType.from_variant(serialized_definition) == null, "serialized EventDefinition rejects an unknown FilterBehavior")
|
||||
serialized_definition = definition.create_snapshot()
|
||||
serialized_definition[&"content_rating"] = 99
|
||||
_expect(EventDefinitionType.from_variant(serialized_definition) == null, "serialized EventDefinition rejects an out-of-range ContentRating")
|
||||
var lenient_input := EventDefinitionType.new().configure(&"settings_boundary", {&"filter_behavior": "UNKNOWN_FILTER"})
|
||||
_expect(lenient_input.filter_behavior == EventDefinitionType.FilterBehavior.NONE, "non-snapshot configuration keeps its explicit lenient fallback boundary")
|
||||
|
||||
|
||||
func _transaction_service(state: Dictionary) -> Variant:
|
||||
return EventServiceType.new().configure_callbacks(
|
||||
Callable(),
|
||||
func(action: EventAction, phase: StringName, _context: Dictionary, token: Variant) -> Dictionary:
|
||||
if phase == &"validate":
|
||||
return {&"ok": true}
|
||||
if phase == &"apply":
|
||||
if bool(action.payload.get(&"fail", false)):
|
||||
return {&"ok": false, &"error": "requested failure"}
|
||||
var old_value := int(state[&"value"])
|
||||
state[&"value"] = old_value + int(action.payload.get(&"delta", 0))
|
||||
return {&"ok": true, &"token": old_value}
|
||||
if phase == &"rollback":
|
||||
state[&"value"] = int(token)
|
||||
return {&"ok": true}
|
||||
)
|
||||
|
||||
|
||||
func _run_choice(service: Variant, event_id: StringName, instance_id: StringName) -> CommandResult:
|
||||
service.enqueue_candidate(event_id, [], 0, {&"event_instance_id": instance_id})
|
||||
var started = service.start_next_event(0)
|
||||
return service.submit_choice(instance_id, started.details[&"choice_instance_id"], &"accept", 0)
|
||||
|
||||
|
||||
func _choice_definition(
|
||||
event_id: StringName,
|
||||
priority: int,
|
||||
policy: EventDefinition.TransactionPolicy,
|
||||
actions: Array,
|
||||
cost: Dictionary = {}
|
||||
) -> EventDefinition:
|
||||
var choice := EventChoiceType.new().configure(
|
||||
&"accept",
|
||||
StringName("choice.%s.accept" % event_id),
|
||||
[],
|
||||
cost,
|
||||
{},
|
||||
actions
|
||||
)
|
||||
return EventDefinitionType.new().configure(event_id, {
|
||||
&"category": &"RELATION",
|
||||
&"priority": priority,
|
||||
&"foreground": true,
|
||||
&"transaction_policy": policy,
|
||||
&"choices": [choice],
|
||||
})
|
||||
|
||||
|
||||
func _delta_action(action_id: StringName, delta: int) -> EventAction:
|
||||
return EventActionType.new().configure(
|
||||
action_id,
|
||||
EventActionType.Type.ADD_LEDGER_ENTRY,
|
||||
{&"delta": delta}
|
||||
)
|
||||
|
||||
|
||||
func _failing_action(action_id: StringName) -> EventAction:
|
||||
return EventActionType.new().configure(
|
||||
action_id,
|
||||
EventActionType.Type.ADD_LEDGER_ENTRY,
|
||||
{&"fail": true}
|
||||
)
|
||||
|
||||
|
||||
func _instance_ids(instances: Array[EventInstance]) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
for instance: EventInstance in instances:
|
||||
result.append(instance.event_instance_id)
|
||||
return result
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ok %s" % label)
|
||||
else:
|
||||
_failures += 1
|
||||
push_error(" not ok %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://rqdt3la3n4hp
|
||||
@@ -0,0 +1,425 @@
|
||||
extends SceneTree
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("S4: runtime relationship and event integration")
|
||||
_test_threshold_foreground_acknowledgement_and_idempotency()
|
||||
_test_paid_choice_escrow_and_commit()
|
||||
_test_missing_actor_releases_reservation_and_lock()
|
||||
_test_waiting_choice_snapshot_exact_replay()
|
||||
_cleanup()
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _reset_runtime(seed: int) -> void:
|
||||
_cleanup()
|
||||
_clock = load("res://autoload/game_clock.gd").new()
|
||||
_state = load("res://autoload/game_state.gd").new()
|
||||
_runtime = load("res://autoload/simulation_runtime.gd").new()
|
||||
_clock.reset(1, 6, 0)
|
||||
_state.reset()
|
||||
var report: Dictionary = _runtime.initialize(_clock, _state, seed)
|
||||
_clock.set_paused(true)
|
||||
_expect(report[&"valid"], "runtime initializes S4 definitions")
|
||||
_expect(_runtime.event_service.get_definition(&"relationship.threshold") != null, "canonical relationship threshold Event is registered")
|
||||
|
||||
|
||||
func _cleanup() -> void:
|
||||
if _runtime != null:
|
||||
_runtime.free()
|
||||
if _clock != null:
|
||||
_clock.free()
|
||||
if _state != null:
|
||||
_state.free()
|
||||
_runtime = null
|
||||
_clock = null
|
||||
_state = null
|
||||
|
||||
|
||||
func _test_threshold_foreground_acknowledgement_and_idempotency() -> void:
|
||||
_reset_runtime(4_001)
|
||||
_expect(_spawn_guest(&"relation_guest", 150).is_success(), "relationship actor is created through Runtime Command")
|
||||
var created = _runtime.submit_command(
|
||||
&"create_relationship",
|
||||
{
|
||||
&"relationship_id": &"relationship.runtime.threshold",
|
||||
&"actor_a_id": &"player",
|
||||
&"actor_b_id": &"relation_guest",
|
||||
&"options": {
|
||||
&"trust": 35,
|
||||
&"respect": 40,
|
||||
&"affection": 45,
|
||||
&"fear": 5,
|
||||
&"dependence": 10,
|
||||
&"shield_max": 60,
|
||||
&"shield_current": 60,
|
||||
},
|
||||
},
|
||||
&"player",
|
||||
&"relation_guest",
|
||||
80,
|
||||
-1,
|
||||
&"s4.relationship.create.threshold"
|
||||
)
|
||||
_expect(created.is_success(), "Relationship is created through Runtime Command")
|
||||
var relationship = _runtime.relationship_service.get_relationship(&"relationship.runtime.threshold")
|
||||
_expect(relationship != null and relationship.actor_a_id == &"player" and relationship.actor_b_id == &"relation_guest", "Runtime preserves normalized relationship actors")
|
||||
|
||||
var changed = _runtime.submit_command(
|
||||
&"change_relationship",
|
||||
{
|
||||
&"relationship_id": relationship.relationship_id,
|
||||
&"metric_id": &"affection",
|
||||
&"delta": 10,
|
||||
},
|
||||
&"player",
|
||||
relationship.relationship_id,
|
||||
80,
|
||||
-1,
|
||||
&"s4.relationship.affection.gate"
|
||||
)
|
||||
_expect(changed.is_success() and changed.details[&"gated_threshold"] == 50, "Affection change reports the 50 threshold gate")
|
||||
_expect(relationship.get_metric(&"affection") == 49, "background calculation cannot cross 50 before Event acknowledgement")
|
||||
_expect(_runtime.relationship_service.get_threshold_candidates().size() == 1, "threshold crossing creates one deterministic relationship candidate")
|
||||
_expect(_runtime.event_service.get_queued_instances().size() == 1, "candidate is projected into EventService without immediate playback")
|
||||
|
||||
_clock.set_paused(false)
|
||||
_clock.advance_one_minute()
|
||||
var foreground = _runtime.event_service.get_foreground_instance()
|
||||
_expect(foreground != null and foreground.source_event_id == &"relationship.threshold", "relationship candidate starts as the foreground Event")
|
||||
_expect(_clock.paused, "foreground Choice automatically pauses Simulation")
|
||||
_expect(_runtime.event_service.get_actor_locks().size() == 2, "foreground threshold Event locks both relationship actors")
|
||||
var instance_id: StringName = foreground.event_instance_id
|
||||
var choice_instance_id: StringName = foreground.choice_instance_id
|
||||
var acknowledged = _runtime.submit_command(
|
||||
&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": instance_id,
|
||||
&"choice_instance_id": choice_instance_id,
|
||||
&"choice_id": &"acknowledge",
|
||||
},
|
||||
&"player",
|
||||
instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.threshold.acknowledge.first"
|
||||
)
|
||||
_expect(acknowledged.is_success(), "foreground acknowledgement resolves through Runtime Command")
|
||||
_expect(relationship.get_metric(&"affection") == 50, "acknowledged threshold Event applies the gated Affection point")
|
||||
_expect(_runtime.relationship_service.get_threshold_candidates().is_empty(), "resolved Event consumes its relationship candidate")
|
||||
_expect(_runtime.event_service.get_actor_locks().is_empty() and _runtime.event_service.get_foreground_instance() == null, "resolved Event releases every Actor Lock")
|
||||
_expect(not _clock.paused, "Runtime resumes when no foreground Event remains")
|
||||
|
||||
_clock.set_paused(true)
|
||||
var replayed = _runtime.submit_command(
|
||||
&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": instance_id,
|
||||
&"choice_instance_id": choice_instance_id,
|
||||
&"choice_id": &"acknowledge",
|
||||
},
|
||||
&"player",
|
||||
instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.threshold.acknowledge.transport-retry"
|
||||
)
|
||||
_expect(replayed.is_success() and replayed.is_replay, "same choice_instance_id returns EventService idempotent replay")
|
||||
_expect(relationship.get_metric(&"affection") == 50, "choice replay does not apply relationship Actions twice")
|
||||
_expect(_count_domain_events(&"EVENT_CHOICE_RESOLVED", instance_id) == 1, "choice replay emits no duplicate resolution Domain Event")
|
||||
_expect(_runtime.relationship_service.validate_invariants()[&"ok"], "threshold flow preserves Relationship invariants")
|
||||
_expect(_runtime.event_service.validate_invariants()[&"ok"], "threshold flow preserves Event and Actor Lock invariants")
|
||||
|
||||
|
||||
func _test_paid_choice_escrow_and_commit() -> void:
|
||||
_reset_runtime(4_002)
|
||||
var registered = _register_paid_event_definition(&"event.paid.commit", 125)
|
||||
_expect(registered.is_success(), "paid EventDefinition registers through Runtime Command")
|
||||
var enqueued = _runtime.submit_command(
|
||||
&"enqueue_event",
|
||||
{
|
||||
&"event_id": &"event.paid.commit",
|
||||
&"actor_ids": [&"player"],
|
||||
&"options": {&"event_instance_id": &"event_instance.paid.commit"},
|
||||
},
|
||||
&"player",
|
||||
&"event_instance.paid.commit",
|
||||
60,
|
||||
-1,
|
||||
&"s4.event.paid.enqueue"
|
||||
)
|
||||
_expect(enqueued.is_success(), "paid Event candidate enters Runtime queue")
|
||||
_start_next_foreground_minute()
|
||||
var foreground = _runtime.event_service.get_foreground_instance()
|
||||
_expect(foreground != null and foreground.event_instance_id == &"event_instance.paid.commit", "paid Event reaches foreground Choice")
|
||||
var cash_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var reserved = _runtime.submit_command(
|
||||
&"reserve_event_choice_cost",
|
||||
{
|
||||
&"event_instance_id": foreground.event_instance_id,
|
||||
&"choice_id": &"purchase",
|
||||
},
|
||||
&"player",
|
||||
foreground.event_instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.event.paid.reserve"
|
||||
)
|
||||
var escrow_account := &"EVENT_ESCROW:event_instance.paid.commit"
|
||||
_expect(reserved.is_success(), "paid Choice reserves Cost through Runtime boundary")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 125, "Gold leaves PLAYER_CASH exactly once at reservation")
|
||||
_expect(_runtime.economy_service.get_balance(escrow_account) == 125, "reserved Gold is held in Event-specific escrow")
|
||||
_expect(_state.get_resource(&"gold") == cash_before - 125, "GameState Gold projection follows escrow transfer")
|
||||
|
||||
var committed = _runtime.submit_command(
|
||||
&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": foreground.event_instance_id,
|
||||
&"choice_instance_id": foreground.choice_instance_id,
|
||||
&"choice_id": &"purchase",
|
||||
},
|
||||
&"player",
|
||||
foreground.event_instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.event.paid.commit"
|
||||
)
|
||||
_expect(committed.is_success(), "paid Choice commits after revalidation")
|
||||
_expect(_runtime.economy_service.get_balance(escrow_account) == 0, "committed Event leaves no Gold in escrow")
|
||||
_expect(_runtime.economy_service.get_balance(&"OPERATING_EXPENSE") == 125, "escrow Gold posts to the configured expense account")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 125, "Cost commit never charges PLAYER_CASH a second time")
|
||||
_expect(_runtime.event_service.has_flag(&"paid_choice_completed"), "paid Choice Action applies only after Cost reservation")
|
||||
_expect(_runtime.economy_service.validate_ledger()[&"ok"], "escrow reserve and commit remain double-entry balanced")
|
||||
_expect(_runtime.event_service.validate_invariants()[&"ok"], "paid Choice completion preserves Event invariants")
|
||||
|
||||
|
||||
func _test_missing_actor_releases_reservation_and_lock() -> void:
|
||||
_reset_runtime(4_003)
|
||||
var registered_staff = _runtime.submit_command(
|
||||
&"register_staff",
|
||||
{
|
||||
&"staff_id": &"temporary_actor",
|
||||
&"role_id": &"service",
|
||||
&"options": {},
|
||||
},
|
||||
&"player",
|
||||
&"temporary_actor",
|
||||
80,
|
||||
-1,
|
||||
&"s4.actor.temporary.register"
|
||||
)
|
||||
_expect(registered_staff.is_success(), "temporary foreground actor registers through Runtime")
|
||||
_expect(_register_paid_event_definition(&"event.actor.missing", 80).is_success(), "missing-actor fixture EventDefinition registers")
|
||||
var enqueued = _runtime.submit_command(
|
||||
&"enqueue_event",
|
||||
{
|
||||
&"event_id": &"event.actor.missing",
|
||||
&"actor_ids": [&"temporary_actor"],
|
||||
&"options": {&"event_instance_id": &"event_instance.actor.missing"},
|
||||
},
|
||||
&"player",
|
||||
&"event_instance.actor.missing",
|
||||
60,
|
||||
-1,
|
||||
&"s4.actor.missing.enqueue"
|
||||
)
|
||||
_expect(enqueued.is_success(), "missing-actor fixture enters Event queue")
|
||||
_start_next_foreground_minute()
|
||||
var foreground = _runtime.event_service.get_foreground_instance()
|
||||
_expect(foreground != null and _runtime.event_service.get_actor_locks().has(&"temporary_actor"), "foreground Event holds temporary actor lock")
|
||||
var cash_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH")
|
||||
var reserved = _runtime.submit_command(
|
||||
&"reserve_event_choice_cost",
|
||||
{
|
||||
&"event_instance_id": foreground.event_instance_id,
|
||||
&"choice_id": &"purchase",
|
||||
},
|
||||
&"player",
|
||||
foreground.event_instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.actor.missing.reserve"
|
||||
)
|
||||
_expect(reserved.is_success() and _runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 80, "missing-actor fixture owns reserved Gold")
|
||||
_expect(_runtime.staff_service.remove_staff(&"temporary_actor", &"TEST_REMOVED", true, _clock.absolute_minute).is_success(), "actor disappears through StaffService lifecycle")
|
||||
_clock.set_paused(false)
|
||||
_clock.advance_one_minute()
|
||||
var instance = _runtime.event_service.get_instance(&"event_instance.actor.missing")
|
||||
_expect(instance != null and instance.state == EventInstance.State.CANCELLED, "Runtime audit cancels Event whose actor disappeared")
|
||||
_expect(_runtime.event_service.get_actor_locks().is_empty() and _runtime.event_service.get_foreground_instance() == null, "actor disappearance releases Lock and foreground ownership")
|
||||
_expect(_runtime.economy_service.get_balance(&"EVENT_ESCROW:event_instance.actor.missing") == 0, "actor disappearance empties Event escrow")
|
||||
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before and _state.get_resource(&"gold") == cash_before, "actor disappearance refunds reserved Gold exactly once")
|
||||
_expect(_runtime.economy_service.get_balance(&"OPERATING_EXPENSE") == 0, "cancelled missing-actor Event never commits Cost")
|
||||
_expect(_runtime.economy_service.validate_ledger()[&"ok"], "missing-actor refund preserves ledger invariants")
|
||||
_expect(_runtime.event_service.validate_invariants()[&"ok"], "missing-actor recovery preserves Event invariants")
|
||||
|
||||
|
||||
func _test_waiting_choice_snapshot_exact_replay() -> void:
|
||||
_reset_runtime(4_004)
|
||||
_expect(_spawn_guest(&"snapshot_relation_guest", 175).is_success(), "snapshot relationship actor is created")
|
||||
var relationship_id := &"relationship.runtime.snapshot"
|
||||
var created = _runtime.submit_command(
|
||||
&"create_relationship",
|
||||
{
|
||||
&"relationship_id": relationship_id,
|
||||
&"actor_a_id": &"player",
|
||||
&"actor_b_id": &"snapshot_relation_guest",
|
||||
&"options": {&"affection": 45, &"shield_max": 50, &"shield_current": 50},
|
||||
},
|
||||
&"player",
|
||||
relationship_id,
|
||||
80,
|
||||
-1,
|
||||
&"s4.snapshot.relationship.create"
|
||||
)
|
||||
_expect(created.is_success(), "snapshot Relationship registers")
|
||||
var gated = _runtime.submit_command(
|
||||
&"change_relationship",
|
||||
{&"relationship_id": relationship_id, &"metric_id": &"affection", &"delta": 10},
|
||||
&"player",
|
||||
relationship_id,
|
||||
80,
|
||||
-1,
|
||||
&"s4.snapshot.relationship.gate"
|
||||
)
|
||||
_expect(gated.is_success(), "snapshot scenario creates threshold Event candidate")
|
||||
_start_next_foreground_minute()
|
||||
var foreground = _runtime.event_service.get_foreground_instance()
|
||||
_expect(foreground != null and _clock.paused, "snapshot is captured while foreground Choice is waiting")
|
||||
var instance_id: StringName = foreground.event_instance_id
|
||||
var choice_instance_id: StringName = foreground.choice_instance_id
|
||||
# Save capture occurs after tick completion, including the incremented Kernel
|
||||
# snapshot_version; this is the exact boundary replayed below.
|
||||
var waiting_snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var waiting_digest: String = SimulationDigest.compute(waiting_snapshot)
|
||||
_expect(waiting_digest.length() == 64, "waiting-Choice snapshot produces a stable digest")
|
||||
|
||||
var first_resolution = _runtime.submit_command(
|
||||
&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": instance_id,
|
||||
&"choice_instance_id": choice_instance_id,
|
||||
&"choice_id": &"acknowledge",
|
||||
},
|
||||
&"player",
|
||||
instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.snapshot.choice.resolve"
|
||||
)
|
||||
_expect(first_resolution.is_success(), "waiting Choice resolves before rewind")
|
||||
var final_digest: String = _runtime.latest_digest
|
||||
_expect(_runtime.relationship_service.get_relationship(relationship_id).get_metric(&"affection") == 50, "first timeline reaches acknowledged threshold")
|
||||
|
||||
var restored_ok: bool = _runtime.restore_snapshot(waiting_snapshot)
|
||||
_expect(restored_ok, "Runtime restores a snapshot containing Choice token and Actor Locks")
|
||||
_expect(_runtime.latest_digest == waiting_digest, "waiting-Choice restore reproduces exact digest")
|
||||
var restored_foreground = _runtime.event_service.get_foreground_instance()
|
||||
_expect(restored_foreground != null and restored_foreground.event_instance_id == instance_id, "restored Runtime recovers the same foreground Event")
|
||||
_expect(restored_foreground.choice_instance_id == choice_instance_id and _runtime.event_service.get_actor_locks().size() == 2, "Choice token and Actor Locks restore exactly")
|
||||
_expect(_clock.paused and _runtime.relationship_service.get_relationship(relationship_id).get_metric(&"affection") == 49, "restore returns to pre-acknowledgement paused relationship state")
|
||||
|
||||
var replay_resolution = _runtime.submit_command(
|
||||
&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": instance_id,
|
||||
&"choice_instance_id": choice_instance_id,
|
||||
&"choice_id": &"acknowledge",
|
||||
},
|
||||
&"player",
|
||||
instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"s4.snapshot.choice.resolve"
|
||||
)
|
||||
_expect(replay_resolution.is_success(), "restored Choice accepts the same deterministic Command")
|
||||
_expect(_runtime.latest_digest == final_digest, "Choice replay after restore reproduces exact final digest")
|
||||
_expect(_runtime.relationship_service.get_relationship(relationship_id).get_metric(&"affection") == 50, "replayed timeline reaches threshold once")
|
||||
_expect(_runtime.relationship_service.validate_invariants()[&"ok"], "restored relationship state satisfies invariants")
|
||||
_expect(_runtime.event_service.validate_invariants()[&"ok"], "restored Event queue and Lock state satisfy invariants")
|
||||
_expect(_runtime.economy_service.validate_ledger()[&"ok"], "snapshot replay preserves economic invariants")
|
||||
|
||||
|
||||
func _spawn_guest(guest_id: StringName, gold: int) -> CommandResult:
|
||||
return _runtime.submit_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": guest_id,
|
||||
&"guest_type_id": &"traveler",
|
||||
&"gold": gold,
|
||||
},
|
||||
&"player",
|
||||
guest_id,
|
||||
50,
|
||||
-1,
|
||||
StringName("s4.guest.spawn.%s" % guest_id)
|
||||
)
|
||||
|
||||
|
||||
func _register_paid_event_definition(event_id: StringName, gold_cost: int) -> CommandResult:
|
||||
return _runtime.submit_command(
|
||||
&"register_event_definition",
|
||||
{&"definition": {
|
||||
&"event_id": event_id,
|
||||
&"category": &"RELATIONSHIP",
|
||||
&"priority": 70,
|
||||
&"foreground": true,
|
||||
&"transaction_policy": &"ROLLBACK_ALL",
|
||||
&"missing_actor_policy": &"CANCEL",
|
||||
&"content_rating": &"GENERAL",
|
||||
&"choices": [{
|
||||
&"choice_id": &"purchase",
|
||||
&"text_key": StringName("event.%s.purchase" % event_id),
|
||||
&"cost": {&"gold": gold_cost},
|
||||
&"actions": [{
|
||||
&"action_id": &"paid_choice_flag",
|
||||
&"type": &"SET_FLAG",
|
||||
&"payload": {&"flag_id": &"paid_choice_completed"},
|
||||
}],
|
||||
}],
|
||||
}},
|
||||
&"player",
|
||||
event_id,
|
||||
60,
|
||||
-1,
|
||||
StringName("s4.event.definition.%s" % event_id)
|
||||
)
|
||||
|
||||
|
||||
func _start_next_foreground_minute() -> void:
|
||||
_clock.set_paused(false)
|
||||
_clock.advance_one_minute()
|
||||
|
||||
|
||||
func _count_domain_events(event_type: StringName, entity_id: StringName) -> int:
|
||||
var count := 0
|
||||
for event in _runtime.domain_events.recent_events():
|
||||
if event.event_type == event_type and (
|
||||
entity_id in event.entity_ids
|
||||
or StringName(event.payload.get(&"event_instance_id", &"")) == entity_id
|
||||
):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dfrs30r1g0jqe
|
||||
Reference in New Issue
Block a user