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.
622 lines
33 KiB
GDScript
622 lines
33 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()
|
|
_test_event_ledger_observer_boundaries()
|
|
_test_paid_choice_attempt_lifecycle()
|
|
_test_legacy_paid_choice_tokens()
|
|
_test_paid_reservation_failure_boundaries()
|
|
_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)
|
|
|
|
|
|
func _test_event_ledger_observer_boundaries() -> void:
|
|
var cases: Array[Dictionary] = [
|
|
{&"name":&"success",&"policy":&"ROLLBACK_ALL",&"amount":1000,&"cash":3000,&"count":2,&"ok":true},
|
|
{&"name":&"rollback",&"policy":&"ROLLBACK_ALL",&"amount":3000,&"cash":5000,&"count":0,&"ok":false},
|
|
{&"name":&"skip",&"policy":&"SKIP_FAILED",&"amount":3000,&"cash":2000,&"count":1,&"ok":true},
|
|
{&"name":&"branch",&"policy":&"BRANCH_FAILURE",&"amount":3000,&"cash":4000,&"count":1,&"ok":true},
|
|
{&"name":&"auto_success",&"policy":&"ROLLBACK_ALL",&"amount":1000,&"cash":3000,&"count":2,&"ok":true,&"auto":true},
|
|
{&"name":&"auto_rollback",&"policy":&"ROLLBACK_ALL",&"amount":3000,&"cash":5000,&"count":0,&"ok":false,&"auto":true},
|
|
{&"name":&"cost_success",&"policy":&"ROLLBACK_ALL",&"amount":1000,&"cash":2900,&"count":4,&"ok":true,&"cost":100},
|
|
{&"name":&"cost_rollback",&"policy":&"ROLLBACK_ALL",&"amount":3000,&"cash":5000,&"count":2,&"ok":false,&"cost":1000},
|
|
]
|
|
for row: Dictionary in cases:
|
|
_reset_runtime(981009)
|
|
var name := String(row[&"name"])
|
|
var event_id := StringName("observer."+name)
|
|
var actions: Array[Dictionary] = []
|
|
for id: StringName in [&"first", &"second"]:
|
|
actions.append({&"action_id":id,&"type":&"ADD_LEDGER_ENTRY",&"payload":{&"from_account":&"PLAYER_CASH",&"to_account":_runtime.OPERATING_EXPENSE_ACCOUNT,&"amount":row[&"amount"]}})
|
|
var automatic: bool = row.get(&"auto",false)
|
|
var definition := {&"event_id":event_id,&"category":&"FLAVOR",&"priority":70,&"foreground":not automatic,&"transaction_policy":row[&"policy"],&"failure_actions":[{&"action_id":&"branch",&"type":&"ADD_LEDGER_ENTRY",&"payload":{&"from_account":&"PLAYER_CASH",&"to_account":_runtime.OPERATING_EXPENSE_ACCOUNT,&"amount":1000}}]}
|
|
if automatic:
|
|
definition[&"automatic_actions"]=actions
|
|
else:
|
|
definition[&"choices"]=[{&"choice_id":&"choose",&"text_key":&"observer.choose",&"cost":{&"gold":row.get(&"cost",0)},&"actions":actions}]
|
|
_expect(_runtime.submit_command(&"register_event_definition",{&"definition":definition}).is_success(),name+" public registration")
|
|
_expect(_runtime.submit_command(&"enqueue_event",{&"event_id":event_id,&"actor_ids":[&"player"]}).is_success(),name+" public enqueue")
|
|
var notifications: Array[Dictionary]=[]
|
|
_runtime.economy_service.transaction_posted.connect(func(id: StringName, entries: Array) -> void:
|
|
var copied: Array[Dictionary]=[]
|
|
for entry: LedgerEntry in entries:copied.append(entry.to_dictionary())
|
|
notifications.append({&"id":id,&"entries":copied,&"cash":_runtime.economy_service.get_balance(&"PLAYER_CASH"),&"mirror":_state.get_resource(&"gold")}))
|
|
_start_next_foreground_minute()
|
|
var payload: Dictionary={}
|
|
var result: CommandResult
|
|
if not automatic:
|
|
var foreground: EventInstance=_runtime.event_service.get_foreground_instance()
|
|
_expect(foreground!=null,name+" real foreground choice")
|
|
if foreground==null:continue
|
|
payload={&"event_instance_id":foreground.event_instance_id,&"choice_instance_id":foreground.choice_instance_id,&"choice_id":&"choose"}
|
|
result=_runtime.submit_command(&"submit_event_choice",payload)
|
|
_expect(result.is_success()==bool(row[&"ok"]),name+" actual choice outcome")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH")==int(row[&"cash"])and _state.get_resource(&"gold")==int(row[&"cash"]),name+" final ledger/mirror exact")
|
|
_expect(notifications.size()==int(row[&"count"]),name+" only surviving receipts notified once")
|
|
var seen: Dictionary={}
|
|
for notification: Dictionary in notifications:
|
|
var id: StringName=notification[&"id"]
|
|
var entries: Array[Dictionary]=[]
|
|
for entry: LedgerEntry in _runtime.economy_service.get_transaction_entries(id):entries.append(entry.to_dictionary())
|
|
_expect(not seen.has(id)and var_to_bytes(entries)==var_to_bytes(notification[&"entries"]),name+" notified receipt exists exactly in final ledger")
|
|
_expect(notification[&"cash"]==int(row[&"cash"])and notification[&"mirror"]==int(row[&"cash"]),name+" observer sees only final authoritative state")
|
|
seen[id]=true
|
|
_expect(_runtime.economy_service.notification_scope_depth()==0,name+" all nested scopes closed")
|
|
if not automatic:
|
|
_clock.set_paused(true)
|
|
var economy_before: Dictionary=_runtime.economy_service.create_snapshot()
|
|
var count_before: int=notifications.size()
|
|
var replay: CommandResult=_runtime.submit_command(&"submit_event_choice",payload)
|
|
_expect(replay.is_replay and replay.is_success()==bool(row[&"ok"]),name+" cached choice replays its original outcome")
|
|
_expect(var_to_bytes(economy_before)==var_to_bytes(_runtime.economy_service.create_snapshot())and notifications.size()==count_before,name+" replay neither posts nor notifies twice")
|
|
|
|
|
|
func _lifecycle_event(name: StringName, fail_a: bool) -> EventInstance:
|
|
var actions: Array[Dictionary] = []
|
|
if fail_a:
|
|
for id: StringName in [&"first", &"second"]:
|
|
actions.append({&"action_id":id,&"type":&"ADD_LEDGER_ENTRY",&"payload":{&"from_account":&"PLAYER_CASH",&"to_account":_runtime.OPERATING_EXPENSE_ACCOUNT,&"amount":3000}})
|
|
var definition := {&"event_id":name,&"category":&"FLAVOR",&"priority":70,&"foreground":true,&"transaction_policy":&"ROLLBACK_ALL",&"choices":[{&"choice_id":&"a",&"text_key":&"lifecycle.a",&"cost":{&"gold":100},&"actions":actions},{&"choice_id":&"b",&"text_key":&"lifecycle.b",&"cost":{&"gold":200},&"actions":[]}]}
|
|
_expect(_runtime.submit_command(&"register_event_definition",{&"definition":definition}).is_success(),"lifecycle definition registers")
|
|
_expect(_runtime.submit_command(&"enqueue_event",{&"event_id":name,&"actor_ids":[&"player"],&"options":{&"event_instance_id":name}}).is_success(),"lifecycle event queues")
|
|
_clock.advance_minutes(1)
|
|
var event: EventInstance = _runtime.event_service.get_foreground_instance()
|
|
_expect(event != null and event.event_instance_id == name,"lifecycle actual foreground")
|
|
return event
|
|
|
|
|
|
func _lifecycle_reserve(event: EventInstance, choice: StringName, key: StringName) -> CommandResult:
|
|
return _runtime.submit_command(&"reserve_event_choice_cost",{&"event_instance_id":event.event_instance_id,&"choice_id":choice},&"player",&"",100,-1,key)
|
|
|
|
|
|
func _lifecycle_choose(event: EventInstance, choice: StringName, key: StringName) -> CommandResult:
|
|
return _runtime.submit_command(&"submit_event_choice",{&"event_instance_id":event.event_instance_id,&"choice_instance_id":event.choice_instance_id,&"choice_id":choice},&"player",&"",100,-1,key)
|
|
|
|
|
|
func _test_paid_choice_attempt_lifecycle() -> void:
|
|
_reset_runtime(4_107)
|
|
var event := _lifecycle_event(&"multi_cost",false)
|
|
_expect(_lifecycle_reserve(event,&"a",&"multi.a").is_success(),"first paid choice reserves")
|
|
_expect(_lifecycle_reserve(event,&"b",&"multi.b").is_success(),"distinct affordable choice reserves independently")
|
|
var a: Dictionary = event.reserved_costs[&"a"][&"token"]
|
|
var b: Dictionary = event.reserved_costs[&"b"][&"token"]
|
|
_expect(a[&"reservation_id"] != b[&"reservation_id"],"actual paid reservations have distinct persistent identities")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4700 and _runtime.economy_service.get_balance(&"EVENT_ESCROW:multi_cost") == 300,"both paid reservations in real ledger")
|
|
var before: Dictionary = _runtime.economy_service.create_snapshot()
|
|
_expect(_lifecycle_reserve(event,&"a",&"multi.a.again").is_success(),"existing reservation remains replayable")
|
|
_expect(var_to_bytes(before) == var_to_bytes(_runtime.economy_service.create_snapshot()),"existing reservation never double debits")
|
|
var saved: Dictionary = _runtime.create_snapshot()
|
|
_reset_runtime(4_108)
|
|
_expect(_runtime.restore_snapshot(saved) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(saved),"entire multi-reservation snapshot restores exactly")
|
|
event = _runtime.event_service.get_foreground_instance()
|
|
_expect(_lifecycle_choose(event,&"b",&"multi.choose.b").is_success(),"restored B commits and unused A refunds")
|
|
_expect(event.state == EventInstance.State.COMPLETED and event.reserved_costs.is_empty(),"restored paid event completes with no escrow tokens")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4800 and _runtime.economy_service.get_balance(&"EVENT_ESCROW:multi_cost") == 0,"selected200G only charged after refund100G")
|
|
_expect(_runtime.id_generator.get_counter(&"event_cost") == 2,"persisted reservation ID counter restored")
|
|
|
|
_reset_runtime(4_109)
|
|
event = _lifecycle_event(&"retry_cost",true)
|
|
var old_choice := event.choice_instance_id
|
|
var failed := _lifecycle_choose(event,&"a",&"retry.a")
|
|
_expect(not failed.is_success() and event.state == EventInstance.State.WAITING_CHOICE,"ordinary failed action remains waiting after rollback")
|
|
_expect(event.choice_instance_id != old_choice,"fully rolled back attempt gets a fresh active choice token")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 5000 and event.reserved_costs.is_empty(),"failed attempt returns actual funds")
|
|
before = _runtime.create_snapshot()
|
|
var replay: CommandResult = _runtime.event_service.submit_choice(event.event_instance_id,old_choice,&"a",_clock.absolute_minute)
|
|
_expect(replay.is_replay and not replay.is_success() and var_to_bytes(before) == var_to_bytes(_runtime.create_snapshot()),"old failed attempt retains whole replay no-op")
|
|
_expect(_lifecycle_reserve(event,&"a",&"retry.reserve").is_success(),"same affordable choice can reserve after a complete refund")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4900,"new retry reservation physically charges once")
|
|
_expect(_lifecycle_choose(event,&"b",&"retry.choose.b").is_success(),"fresh different choice never replays previous failure")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 4800 and event.state == EventInstance.State.COMPLETED,"retry B pays200G and refunds unused A")
|
|
_expect(event.reserved_costs.is_empty() and _runtime.economy_service.get_balance(&"EVENT_ESCROW:retry_cost") == 0,"retry leaves no unpaid or unused reservations")
|
|
_expect(_runtime.id_generator.get_counter(&"event_cost") == 3,"successful new reservations advance only their existing ID namespace")
|
|
|
|
|
|
func _test_legacy_paid_choice_tokens() -> void:
|
|
for mode: StringName in [&"commit", &"cancel"]:
|
|
_reset_runtime(4_110)
|
|
var name := StringName("legacy_cost_"+mode)
|
|
var event := _lifecycle_event(name,false)
|
|
# Reproduce the actual old three-field token and instance-level ledger key.
|
|
_runtime.event_service.configure_callbacks(
|
|
Callable(_runtime,"_event_actor_exists"),Callable(_runtime,"_execute_event_action_boundary"),
|
|
func(cost: Dictionary, context: Dictionary) -> Dictionary:
|
|
var id := StringName(context[&"event_instance_id"])
|
|
var escrow := StringName("EVENT_ESCROW:"+String(id))
|
|
_runtime.economy_service.register_account(escrow)
|
|
var result: CommandResult = _runtime.economy_service.transfer(&"PLAYER_CASH",escrow,int(cost[&"gold"]),_clock.absolute_minute,&"EVENT_COST_RESERVED",id,StringName("event.cost.reserve."+String(id)))
|
|
return {&"ok":result.is_success(),&"token":{&"event_instance_id":id,&"cost":cost.duplicate(true),&"escrow_account":escrow}},
|
|
Callable(_runtime,"_release_event_cost"),Callable(_runtime,"_commit_event_cost"),Callable(_runtime,"_event_entity_exists"),Callable(_runtime,"_event_path_exists")
|
|
)
|
|
_expect(_lifecycle_reserve(event,&"a",&"legacy.reserve").is_success(),"actual legacy100G reservation")
|
|
_expect(not event.reserved_costs[&"a"][&"token"].has(&"reservation_id"),"legacy token retains exactly old representation")
|
|
var saved: Dictionary = _runtime.create_snapshot()
|
|
_reset_runtime(4_111)
|
|
_expect(_runtime.restore_snapshot(saved) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(saved),"legacy cost token whole snapshot restores unchanged")
|
|
event = _runtime.event_service.get_foreground_instance()
|
|
if mode == &"commit":
|
|
_expect(_lifecycle_choose(event,&"a",&"legacy.choose").is_success(),"restored legacy cost commits via old key")
|
|
else:
|
|
_expect(_runtime.event_service.cancel_event(event.event_instance_id).is_success(),"restored legacy cost cancels via old refund key")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == (4900 if mode == &"commit" else 5000),"legacy cash exact after commit or refund")
|
|
_expect(_runtime.economy_service.get_balance(StringName("EVENT_ESCROW:"+String(name))) == 0 and event.reserved_costs.is_empty(),"legacy escrow and token cleared once")
|
|
_expect(_runtime.id_generator.get_counter(&"event_cost") == 0,"legacy restore never invents a new reservation ID")
|
|
|
|
|
|
func _test_paid_reservation_failure_boundaries() -> void:
|
|
_reset_runtime(4_112)
|
|
var event := _lifecycle_event(&"reserve_failure",false)
|
|
var receipts: Array[Dictionary] = []
|
|
_runtime.economy_service.transaction_posted.connect(func(id: StringName, entries: Array) -> void:
|
|
var copied: Array[Dictionary] = []
|
|
for entry: LedgerEntry in entries: copied.append(entry.to_dictionary())
|
|
receipts.append({&"id":id,&"entries":copied}))
|
|
var choice: EventChoice = _runtime.event_service.get_definition(event.source_event_id).get_choice(&"a")
|
|
choice.cost = {&"gold":6000}
|
|
var ledger_before: Dictionary = _runtime.economy_service.create_snapshot()
|
|
_expect(not _lifecycle_reserve(event,&"a",&"unfunded.gold").is_success(),"unaffordable gold reserve rejects")
|
|
_expect(var_to_bytes(ledger_before) == var_to_bytes(_runtime.economy_service.create_snapshot()) and _state.get_resource(&"gold") == 5000,"gold reserve failure retains full ledger and mirror")
|
|
_expect(_runtime.id_generator.get_counter(&"event_cost") == 0 and event.reserved_costs.is_empty(),"failed gold reserve consumes no cost identity or token")
|
|
var mana_before: int = _state.get_resource(&"mana")
|
|
choice.cost = {&"gold":100,&"mana":mana_before+1}
|
|
_expect(not _lifecycle_reserve(event,&"a",&"unfunded.mixed").is_success(),"mixed resource reserve rejects after real gold transfer")
|
|
_expect(var_to_bytes(ledger_before) == var_to_bytes(_runtime.economy_service.create_snapshot()),"mixed resource failure reverses the entire real ledger")
|
|
_expect(_state.get_resource(&"gold") == 5000 and _state.get_resource(&"mana") == mana_before,"mixed cost failure retains all resources")
|
|
_expect(receipts.is_empty(),"rolled-back public reserve never publishes a ghost receipt")
|
|
_expect(_runtime.economy_service.notification_scope_depth() == 0 and _runtime.id_generator.get_counter(&"event_cost") == 0 and event.reserved_costs.is_empty(),"mixed reserve closes scopes and restores cost identity")
|
|
|
|
_reset_runtime(4_113)
|
|
event = _lifecycle_event(&"commit_retry",false)
|
|
var old_choice := event.choice_instance_id
|
|
_runtime.event_service._cost_committer = func(_token: Variant, _context: Dictionary) -> Dictionary: return {&"ok":false,&"error":"explicit commit refusal"}
|
|
var result := _lifecycle_choose(event,&"a",&"commit.refused")
|
|
_expect(not result.is_success() and event.state == EventInstance.State.WAITING_CHOICE,"recoverable commit failure remains waiting")
|
|
_expect(event.choice_instance_id != old_choice and event.reserved_costs.is_empty(),"fully refunded commit failure advances attempt and clears token")
|
|
_expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 5000 and _state.get_resource(&"gold") == 5000,"commit refusal refunds real gold")
|
|
var before: Dictionary = _runtime.create_snapshot()
|
|
var replay: CommandResult = _runtime.event_service.submit_choice(event.event_instance_id,old_choice,&"a",_clock.absolute_minute)
|
|
_expect(replay.is_replay and not replay.is_success() and var_to_bytes(before) == var_to_bytes(_runtime.create_snapshot()),"old commit failure remains exact replay noop")
|
|
_runtime.event_service._cost_committer = Callable(_runtime,"_commit_event_cost")
|
|
_expect(_lifecycle_choose(event,&"b",&"commit.alternative").is_success(),"alternative after refunded commit failure succeeds")
|
|
_expect(event.state == EventInstance.State.COMPLETED and _runtime.economy_service.get_balance(&"PLAYER_CASH") == 4800,"commit retry finishes and charges B only")
|
|
_expect(_runtime.economy_service.notification_scope_depth() == 0 and _runtime.id_generator.get_counter(&"event_cost") == 2,"commit retry closes scopes with persistent distinct identities")
|