426 lines
17 KiB
GDScript
426 lines
17 KiB
GDScript
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)
|