Preserve approved pixel tower prototype as development baseline

This commit is contained in:
Codex
2026-09-09 07:51:16 +09:00
commit 423fd6dd2e
915 changed files with 95193 additions and 0 deletions
@@ -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
+441
View File
@@ -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