Preserve production code, regression fixtures, audio assets and provenance, test infrastructure, and the original-completion plan and progress ledger. Final game and Windows acceptance remain pending; excluded local artifacts and native experiments are not part of this snapshot.
576 lines
31 KiB
GDScript
576 lines
31 KiB
GDScript
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_failed_rollback_is_fatal()
|
|
_test_failed_cost_release_retains_tokens()
|
|
_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)
|
|
|
|
|
|
func _test_failed_rollback_is_fatal() -> void:
|
|
var cases: Array[Dictionary] = [
|
|
{&"name":&"rollback",&"policy":EventDefinitionType.TransactionPolicy.ROLLBACK_ALL},
|
|
{&"name":&"branch",&"policy":EventDefinitionType.TransactionPolicy.BRANCH_FAILURE},
|
|
{&"name":&"automatic",&"policy":EventDefinitionType.TransactionPolicy.BRANCH_FAILURE,&"auto":true},
|
|
{&"name":&"commit",&"policy":EventDefinitionType.TransactionPolicy.ROLLBACK_ALL,&"commit":true},
|
|
{&"name":&"skip_self",&"policy":EventDefinitionType.TransactionPolicy.SKIP_FAILED,&"self":true},
|
|
{&"name":&"rollback_self",&"policy":EventDefinitionType.TransactionPolicy.ROLLBACK_ALL,&"self":true},
|
|
{&"name":&"branch_self",&"policy":EventDefinitionType.TransactionPolicy.BRANCH_FAILURE,&"self":true},
|
|
]
|
|
for row: Dictionary in cases:
|
|
var calls: Array[StringName] = []
|
|
var participant := {&"value":0,&"branch":0,&"released":0}
|
|
var service := EventServiceType.new().configure_callbacks(
|
|
Callable(),
|
|
func(action: EventAction, phase: StringName, _context: Dictionary, token: Variant) -> Dictionary:
|
|
if phase == &"validate": return {&"ok":true}
|
|
if phase == &"rollback":
|
|
calls.append(action.action_id)
|
|
if action.action_id == &"second": return {&"ok":false,&"error":"explicit participant refusal"}
|
|
participant[&"value"] = int(token)
|
|
return {&"ok":true}
|
|
if action.action_id == &"fallback": participant[&"branch"] += 1
|
|
if bool(action.payload.get(&"fail",false)):
|
|
if row.get(&"self",false): participant[&"value"] += 7
|
|
return {&"ok":false,&"fatal":bool(row.get(&"self",false)),&"error":"explicit failed apply"}
|
|
var previous: int = participant[&"value"]
|
|
participant[&"value"] += int(action.payload.get(&"delta",0))
|
|
return {&"ok":true,&"token":previous},
|
|
func(_cost: Dictionary, _context: Dictionary) -> Dictionary: return {&"ok":true,&"token":&"reserved"},
|
|
func(_token: Variant, _context: Dictionary) -> Dictionary:
|
|
participant[&"released"] += 1
|
|
return {&"ok":true},
|
|
func(_token: Variant, _context: Dictionary) -> Dictionary: return {&"ok":not bool(row.get(&"commit",false)),&"error":"explicit commit refusal"}
|
|
)
|
|
var event_id := StringName("failed.rollback."+String(row[&"name"]))
|
|
var actions: Array = [_delta_action(&"first",2),_delta_action(&"second",3)]
|
|
if not row.get(&"commit",false): actions.append(_failing_action(&"fail"))
|
|
var definition := _choice_definition(event_id,10,row[&"policy"],actions,{&"gold":1})
|
|
definition.failure_actions = [_delta_action(&"fallback",4)]
|
|
if row.get(&"auto",false):
|
|
definition.foreground = false
|
|
definition.choices = []
|
|
definition.automatic_actions.assign(actions)
|
|
_expect(service.register_definition(definition).is_success(),"rollback refusal definition registers")
|
|
var instance_id := StringName(String(event_id)+".instance")
|
|
var result: CommandResult
|
|
if row.get(&"auto",false):
|
|
_expect(service.enqueue_candidate(event_id,[],0,{&"event_instance_id":instance_id}).is_success(),"automatic rollback refusal enqueue")
|
|
result = service.start_next_event(0)
|
|
else: result = _run_choice(service,event_id,instance_id)
|
|
_expect(not result.is_success() and bool(result.details.get(&"fatal",false)),"refused rollback or failed self restoration propagates fatal outcome")
|
|
_expect(service.get_instance(instance_id).state == EventInstanceType.State.ERROR and service.pause_requested,"fatal event stops at ERROR with pause requested")
|
|
_expect(participant[&"branch"] == 0,"no fallback executes on unverified rollback")
|
|
if row[&"policy"] == EventDefinitionType.TransactionPolicy.SKIP_FAILED:
|
|
_expect(calls.is_empty() and participant[&"value"] == 12,"skip policy stops on fatal self restoration without further actions")
|
|
else:
|
|
_expect(calls == [&"second",&"first"] and participant[&"value"] == 0,"all prior restorations attempted in reverse order despite refusal")
|
|
if not row.get(&"auto",false):
|
|
_expect(participant[&"released"] == 1,"fatal choice releases reservation once")
|
|
var instance: EventInstance = service.get_instance(instance_id)
|
|
var before: Dictionary = service.create_snapshot()
|
|
var replay := service.submit_choice(instance_id,instance.choice_instance_id,&"accept",0)
|
|
_expect(replay.is_replay and not replay.is_success() and bool(replay.details.get(&"fatal",false)),"fatal outcome is cached for choice replay")
|
|
_expect(var_to_bytes(before) == var_to_bytes(service.create_snapshot()) and participant[&"released"] == 1,"fatal replay does not mutate event state or release twice")
|
|
_expect(service.validate_invariants()[&"ok"],"fatal event lock and queue invariants remain valid")
|
|
|
|
|
|
func _test_failed_cost_release_retains_tokens() -> void:
|
|
for mode: StringName in [&"action", &"commit", &"cancel", &"missing", &"finish", &"no_releaser"]:
|
|
var calls: Array[StringName] = []
|
|
var state := {&"value": 0, &"available": true, &"refuse": true}
|
|
var service := EventServiceType.new().configure_callbacks(
|
|
func(_actor: StringName) -> bool: return state[&"available"],
|
|
func(action: EventAction, phase: StringName, _context: Dictionary, token: Variant) -> Dictionary:
|
|
if phase == &"apply":
|
|
if bool(action.payload.get(&"fail", false)): return {&"ok": false, &"error": "explicit apply refusal"}
|
|
var before := int(state[&"value"])
|
|
state[&"value"] += int(action.payload.get(&"delta", 0))
|
|
return {&"ok": true, &"token": before}
|
|
if phase == &"rollback": state[&"value"] = int(token)
|
|
return {&"ok": true},
|
|
func(cost: Dictionary, _context: Dictionary) -> Dictionary: return {&"ok": true, &"token": cost.duplicate(true)},
|
|
func(token: Variant, _context: Dictionary) -> Dictionary:
|
|
calls.append(StringName(str(token.get(&"gold", 0))))
|
|
return {&"ok": not state[&"refuse"], &"error": "explicit release refusal"},
|
|
func(_token: Variant, _context: Dictionary) -> Dictionary: return {&"ok": mode != &"commit", &"error": "explicit commit refusal"}
|
|
)
|
|
var actions: Array = [_delta_action(&"primary", 4)]
|
|
if mode in [&"action", &"no_releaser"]: actions.append(_failing_action(&"fail"))
|
|
var definition := _choice_definition(StringName("release_" + mode), 10, EventDefinitionType.TransactionPolicy.ROLLBACK_ALL, actions, {&"gold": 100})
|
|
definition.choices.append(EventChoiceType.new().configure(&"other", &"other", [], {&"gold": 200}, {}, []))
|
|
_expect(service.register_definition(definition).is_success(), "%s release definition registers" % mode)
|
|
service.enqueue_candidate(definition.event_id, [&"actor"], 0, {&"event_instance_id": &"release_instance"})
|
|
var started := service.start_next_event(0)
|
|
var instance := service.get_instance(&"release_instance")
|
|
_expect(service.reserve_choice_cost(instance.event_instance_id, &"accept", 0).is_success(), "%s actual reservation created" % mode)
|
|
if mode in [&"action", &"commit", &"cancel", &"missing", &"finish"]:
|
|
_expect(service.reserve_choice_cost(instance.event_instance_id, &"other", 0).is_success(), "%s second reservation created" % mode)
|
|
if mode == &"no_releaser": service._cost_releaser = Callable()
|
|
var result: CommandResult
|
|
if mode == &"cancel":
|
|
result = service.cancel_event(instance.event_instance_id)
|
|
elif mode == &"missing":
|
|
state[&"available"] = false
|
|
service.audit_actor_availability(1)
|
|
else:
|
|
result = service.submit_choice(instance.event_instance_id, started.details[&"choice_instance_id"], &"accept", 0)
|
|
if result != null:
|
|
_expect(not result.is_success() and result.details.get(&"fatal", false), "%s release failure cannot report success" % mode)
|
|
_expect(instance.state == EventInstanceType.State.ERROR and service.pause_requested, "%s release failure stops as ERROR" % mode)
|
|
_expect(service.get_actor_locks().is_empty() and service.get_foreground_instance() == null, "%s releases event ownership" % mode)
|
|
var expected_ids: Array[StringName] = [&"accept"]
|
|
if mode in [&"action", &"commit", &"cancel", &"missing"]: expected_ids.append(&"other")
|
|
if mode == &"finish": expected_ids = [&"other"]
|
|
_expect(instance.reserved_costs.keys().size() == expected_ids.size(), "%s retains every refused token only" % mode)
|
|
for id: StringName in expected_ids:
|
|
_expect(instance.reserved_costs.has(id), "%s retains token %s" % [mode, id])
|
|
var expected_calls := 2 if mode in [&"action", &"commit", &"cancel", &"missing"] else 0 if mode == &"no_releaser" else 1
|
|
_expect(calls.size() == expected_calls, "%s attempts each release once" % mode)
|
|
_expect(state[&"value"] == (4 if mode == &"finish" else 0), "%s retains actual action outcome" % mode)
|
|
var snapshot := service.create_snapshot()
|
|
var restored := EventServiceType.new()
|
|
restored.register_definition(definition)
|
|
_expect(restored.restore_snapshot(snapshot) and restored.create_snapshot() == snapshot, "%s ERROR with unreleased tokens restores whole" % mode)
|
|
_expect(restored.validate_invariants()[&"ok"], "%s retained ERROR token invariants pass" % mode)
|
|
if mode not in [&"cancel", &"missing"]:
|
|
var before := service.create_snapshot()
|
|
var replay := service.submit_choice(instance.event_instance_id, started.details[&"choice_instance_id"], &"accept", 0)
|
|
_expect(not replay.is_success() and replay.is_replay and service.create_snapshot() == before and calls.size() == expected_calls, "%s failure cache replay never refunds again" % mode)
|