Files
TheTree_mock/tests/mvp/test_progression_service.gd
T

416 lines
24 KiB
GDScript

extends SceneTree
const ProgressionType := preload("res://scripts/progression/progression_service.gd")
var _checks := 0
var _failures := 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_rank_bounds_and_evidence()
_test_metrics_are_atomic_and_lifetime_monotonic()
_test_rank_one_through_seven()
_test_external_transaction_and_idempotency()
_test_failed_rollback_quarantines_progression()
_test_callback_reentry_and_internal_tamper()
_test_snapshot_round_trip_and_atomic_rejection()
_test_promotion_evidence_and_legacy_migration()
_test_input_and_history_bounds()
if _failures == 0:
print("PASS: ProgressionService (%d checks)" % _checks)
else:
push_error("FAIL: ProgressionService (%d of %d checks failed)" % [_failures, _checks])
quit(_failures)
func _test_rank_bounds_and_evidence() -> void:
var service := ProgressionType.new()
_expect(service.configure(), "progression starts at Rank 1")
_expect(service.current_rank == 1, "configured rank is authoritative")
_expect(service.get_rank_bounds(1) == {&"min_x":-12, &"max_x":12, &"min_y":0, &"max_y":6}, "Rank 1 bounds match the system specification")
_expect(service.get_rank_bounds(3) == {&"min_x":-24, &"max_x":24, &"min_y":-8, &"max_y":16}, "Rank 3 MVP bounds include eight underground levels")
_expect(service.get_rank_bounds(7) == {&"min_x":-60, &"max_x":60, &"min_y":-40, &"max_y":56}, "Rank 7 bounds match the final expansion")
_expect(service.get_rank_bounds(8).is_empty(), "unknown rank has no implicit clamped bounds")
var report := service.evaluate_rank(2)
_expect(not report[&"eligible"] and report[&"reason"] == &"REQUIREMENTS_NOT_MET", "Rank 2 reports unmet requirements")
_expect(_requirement_ids(report) == [&"reputation", &"asset_gold"], "Rank 2 evidence order is stable and player-readable")
_expect(report[&"requirements"][0][&"current"] == 0 and report[&"requirements"][0][&"required"] == 25, "eligibility evidence includes current and required values")
var skip := service.evaluate_rank(3)
_expect(not skip[&"eligible"] and skip[&"reason"] == &"TARGET_MUST_BE_NEXT_RANK", "rank skipping is rejected before metric evaluation")
func _test_metrics_are_atomic_and_lifetime_monotonic() -> void:
var service := ProgressionType.new()
service.configure()
_expect(service.update_metrics({&"reputation": 25, &"asset_gold": 10_000, &"faith_lifetime": 100}), "valid metric patch commits atomically")
var before := service.metrics.duplicate(true)
_expect(not service.update_metrics({&"reputation": 101, &"asset_gold": 20_000}), "out-of-range percentage rejects the whole patch")
_expect(service.metrics == before, "failed metric patch preserves every prior metric")
_expect(not service.update_metrics({&"faith_lifetime": 99}), "lifetime Faith cannot decrease after spending current Faith")
_expect(not service.update_metrics({&"guest_lifetime": -1}), "lifetime guest count cannot become negative")
_expect(not service.update_metrics({&"unknown": 1}), "unknown progression metric is rejected")
_expect(not service.update_metrics({&"asset_gold": "10000"}), "string numbers are not coerced into progression facts")
_expect(service.metrics == before, "all rejected patches leave progression facts unchanged")
_expect(service.complete_quest(&"important_rank_5"), "valid stable quest ID records once")
_expect(service.complete_quest(&"important_rank_5"), "completing the same quest is idempotent")
_expect(service.completed_quest_ids.size() == 1, "idempotent quest completion creates no duplicate")
_expect(not service.complete_quest(&"Bad Quest"), "invalid quest ID cannot enter persistent progression")
func _test_rank_one_through_seven() -> void:
var service := ProgressionType.new()
service.configure()
service.update_metrics({&"reputation":25, &"asset_gold":10_000})
_expect(service.request_rank_up(&"rank_up_2", 2).is_success(), "Rank 2 unlocks at Reputation 25 and assets 10,000G")
service.update_metrics({&"faith_lifetime":100, &"guest_lifetime":300})
_expect(service.request_rank_up(&"rank_up_3", 3).is_success(), "Rank 3 unlocks at lifetime Faith 100 and 300 guests")
service.update_metrics({
&"reputation":45,
&"asset_gold":50_000,
&"active_above_ground_facilities":1,
&"active_underground_facilities":1,
})
_expect(service.request_rank_up(&"rank_up_4", 4).is_success(), "Rank 4 additionally requires active facilities above and below ground")
service.update_metrics({&"faith_lifetime":500})
var rank_five_missing := service.evaluate_rank(5)
_expect(not rank_five_missing[&"eligible"], "Rank 5 does not unlock from numbers alone")
service.complete_quest(&"important_rank_5")
_expect(service.request_rank_up(&"rank_up_5", 5).is_success(), "Rank 5 requires its explicit important quest")
service.update_metrics({&"underground_influence":65})
var rank_six := service.evaluate_rank(6)
_expect(rank_six[&"eligible"], "Rank 6 accepts underground influence as the specified Reputation alternative")
_expect(service.request_rank_up(&"rank_up_6", 6).is_success(), "Rank 6 commits through the alternative branch")
service.update_metrics({&"faith_lifetime":1500, &"reputation":60})
var rank_seven_missing := service.evaluate_rank(7)
_expect(not rank_seven_missing[&"eligible"], "Rank 7 still requires the main chapter flag")
service.complete_quest(&"main_chapter_complete")
_expect(service.request_rank_up(&"rank_up_7", 7).is_success(), "Rank 7 requires Faith, both progress axes, dual operation and chapter completion")
_expect(service.current_rank == 7, "rank progression is monotonic through the final rank")
_expect(not service.request_rank_up(&"rank_up_8", 8).is_success(), "Rank 8 is rejected")
func _test_external_transaction_and_idempotency() -> void:
var service := _eligible_rank_two_service()
var external := {&"rank":1, &"bounds":service.get_rank_bounds(1), &"apply_calls":0, &"rollback_calls":0}
var capture := func(_current: int, _target: int) -> Dictionary:
return {&"ok":true, &"snapshot":{&"rank":external[&"rank"], &"bounds":external[&"bounds"].duplicate(true)}}
var apply := func(target: int, bounds: Dictionary) -> Dictionary:
external[&"apply_calls"] += 1
external[&"rank"] = target
external[&"bounds"] = bounds.duplicate(true)
return {&"ok":true}
var rollback := func(snapshot: Dictionary) -> Dictionary:
external[&"rollback_calls"] += 1
external[&"rank"] = snapshot[&"rank"]
external[&"bounds"] = snapshot[&"bounds"].duplicate(true)
return {&"ok":true}
var result := service.request_rank_up(&"atomic_rank_2", 2, capture, apply, rollback)
_expect(result.is_success(), "eligible rank-up commits through capture/apply boundary")
_expect(service.current_rank == 2 and external[&"rank"] == 2, "internal rank and external world commit together")
_expect(external[&"bounds"] == service.get_rank_bounds(2), "external world receives exact target bounds")
var replay := service.request_rank_up(&"atomic_rank_2", 2)
_expect(replay.is_success() and replay.is_replay, "same request ID and target returns its recorded success")
_expect(external[&"apply_calls"] == 1, "idempotent replay does not apply external expansion twice")
var conflict := service.request_rank_up(&"atomic_rank_2", 3)
_expect(not conflict.is_success() and conflict.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, "same request ID with another target conflicts")
var partial := service.request_rank_up(&"partial_callbacks", 3, capture, Callable(), rollback)
_expect(not partial.is_success() and partial.code == CommandResult.Code.INVALID_PAYLOAD, "partial callback set is rejected before mutation")
var strict_callbacks := _eligible_rank_two_service()
var bad_capture := func(_current: int, _target: int) -> Dictionary:
return {&"ok":"true", &"snapshot":{}}
var bad_capture_result := strict_callbacks.request_rank_up(&"bad_capture_type", 2, bad_capture, apply, rollback)
_expect(not bad_capture_result.is_success() and external[&"apply_calls"] == 1, "truthy string capture status is rejected before external apply")
var string_apply := func(_target: int, _bounds: Dictionary) -> Dictionary:
return {&"ok":"true"}
var strict_rollback := func(_snapshot: Dictionary) -> Dictionary:
return {&"ok":true}
var bad_apply_result := strict_callbacks.request_rank_up(&"bad_apply_type", 2, capture, string_apply, strict_rollback)
_expect(not bad_apply_result.is_success() and strict_callbacks.current_rank == 1, "truthy string apply status is treated as failure and rolled back")
func _test_failed_rollback_quarantines_progression() -> void:
var rolled_back := _eligible_rank_two_service()
var external_rank := 1
var capture := func(_current: int, _target: int) -> Dictionary:
return {&"ok":true, &"snapshot":external_rank}
var failed_apply := func(target: int, _bounds: Dictionary) -> Dictionary:
external_rank = target
return {&"ok":false}
var successful_rollback := func(snapshot: int) -> Dictionary:
external_rank = snapshot
return {&"ok":true}
var rolled_back_result := rolled_back.request_rank_up(
&"failed_but_rolled_back", 2, capture, failed_apply, successful_rollback
)
_expect(not rolled_back_result.is_success(), "failed external expansion rejects rank-up")
_expect(rolled_back.current_rank == 1 and external_rank == 1, "successful rollback restores both sides to Rank 1")
_expect(not rolled_back.quarantined, "verified rollback keeps progression available for a later request")
var quarantined := _eligible_rank_two_service()
external_rank = 1
var failed_rollback := func(_snapshot: int) -> Dictionary:
return {&"ok":false}
var quarantined_result := quarantined.request_rank_up(
&"rollback_failure", 2, capture, failed_apply, failed_rollback
)
_expect(not quarantined_result.is_success() and quarantined_result.code == CommandResult.Code.TRANSACTION_CLOSED, "rollback failure closes the progression boundary")
_expect(quarantined.quarantined and quarantined.quarantine_reason == &"RANK_ROLLBACK_FAILED", "rollback failure records a durable quarantine reason")
_expect(not quarantined.update_metrics({&"reputation":30}), "quarantine blocks later metric mutation")
_expect(not quarantined.request_rank_up(&"retry_after_quarantine", 2).is_success(), "quarantine blocks retry and prevents double expansion")
func _test_callback_reentry_and_internal_tamper() -> void:
var service := _eligible_rank_two_service()
var external_rank := 1
var observations := {&"metric_update":true, &"nested":null}
var capture := func(_current: int, _target: int) -> Dictionary:
return {&"ok":true, &"snapshot":external_rank}
var reentrant_apply := func(target: int, _bounds: Dictionary) -> Dictionary:
observations[&"metric_update"] = service.update_metrics({&"reputation":99})
observations[&"nested"] = service.request_rank_up(&"nested_rank_request", 2)
external_rank = target
return {&"ok":true}
var rollback := func(snapshot: int) -> Dictionary:
external_rank = snapshot
return {&"ok":true}
var result := service.request_rank_up(&"guarded_rank_request", 2, capture, reentrant_apply, rollback)
_expect(result.is_success(), "blocked reentry cannot prevent an otherwise valid outer promotion")
_expect(not bool(observations[&"metric_update"]), "rank callback cannot mutate eligibility metrics reentrantly")
var nested := observations[&"nested"] as CommandResult
_expect(nested != null and not nested.is_success() and nested.code == CommandResult.Code.TRANSACTION_CLOSED, "nested promotion is rejected while the outer transaction is active")
_expect(service.metrics[&"reputation"] == 25 and service.current_rank == 2, "blocked callback reentry leaves one exact promotion and original evidence")
_expect(service.create_snapshot()[&"processed_requests"].size() == 1, "reentry guard creates no hidden second idempotency record")
var tampered := _eligible_rank_two_service()
external_rank = 1
var tampering_apply := func(target: int, _bounds: Dictionary) -> Dictionary:
external_rank = target
tampered.metrics[&"reputation"] = 99
return {&"ok":true}
result = tampered.request_rank_up(&"tampered_apply", 2, capture, tampering_apply, rollback)
_expect(not result.is_success() and result.code == CommandResult.Code.TRANSACTION_APPLY_FAILED, "direct internal mutation by an apply callback invalidates the transaction")
_expect(tampered.current_rank == 1 and tampered.metrics[&"reputation"] == 25 and external_rank == 1, "tampered apply rolls both internal and external state back exactly")
_expect(not tampered.quarantined, "verified rollback after callback tamper keeps later promotion available")
var rollback_tampered := _eligible_rank_two_service()
external_rank = 1
var failed_apply := func(target: int, _bounds: Dictionary) -> Dictionary:
external_rank = target
return {&"ok":false}
var mutating_rollback := func(snapshot: int) -> Dictionary:
external_rank = snapshot
rollback_tampered.current_rank = 7
return {&"ok":true}
result = rollback_tampered.request_rank_up(&"tampered_rollback", 2, capture, failed_apply, mutating_rollback)
_expect(not result.is_success() and rollback_tampered.current_rank == 1, "internal state is restored even when a successful rollback callback tampers with it")
_expect(external_rank == 1 and not rollback_tampered.quarantined, "successful external rollback remains authoritative after internal tamper repair")
var valid_capture_tampered := _eligible_rank_two_service()
var callback_counts := {&"apply":0, &"rollback":0}
var mutating_capture := func(_current: int, _target: int) -> Dictionary:
valid_capture_tampered.metrics[&"reputation"] = 99
return {&"ok":true, &"snapshot":1}
var counted_apply := func(_target: int, _bounds: Dictionary) -> Dictionary:
callback_counts[&"apply"] += 1
return {&"ok":true}
var counted_rollback := func(_snapshot: int) -> Dictionary:
callback_counts[&"rollback"] += 1
return {&"ok":true}
result = valid_capture_tampered.request_rank_up(&"valid_capture_tamper", 2, mutating_capture, counted_apply, counted_rollback)
_expect(not result.is_success() and callback_counts[&"apply"] == 0, "valid-looking capture tamper is detected before external apply")
_expect(callback_counts[&"rollback"] == 1 and valid_capture_tampered.metrics[&"reputation"] == 25, "capture tamper invokes one rollback and restores internal eligibility")
var capture_tampered := _eligible_rank_two_service()
var bad_capture := func(_current: int, _target: int) -> Dictionary:
capture_tampered.current_rank = 7
return {&"ok":false}
result = capture_tampered.request_rank_up(&"tampered_capture", 2, bad_capture, reentrant_apply, rollback)
_expect(not result.is_success() and result.code == CommandResult.Code.TRANSACTION_CLOSED, "invalid capture that mutates progression enters quarantine")
_expect(capture_tampered.current_rank == 1 and capture_tampered.quarantine_reason == &"RANK_CALLBACK_TAMPERED", "capture tamper restores the last valid rank and records the uncertainty reason")
func _test_snapshot_round_trip_and_atomic_rejection() -> void:
var source := _eligible_rank_two_service()
source.complete_quest(&"important_rank_5")
source.request_rank_up(&"snapshot_rank_2", 2)
var snapshot := source.create_snapshot()
var restored := ProgressionType.new()
_expect(restored.restore_snapshot(snapshot), "valid progression snapshot restores")
_expect(restored.create_snapshot() == snapshot, "progression snapshot round trip is exact")
var json_snapshot: Variant = JSON.parse_string(JSON.stringify(snapshot))
var json_restored := ProgressionType.new()
var json_ok := json_snapshot is Dictionary and json_restored.restore_snapshot(json_snapshot)
_expect(json_ok, "JSON-decoded string-key snapshot restores through the same strict boundary (%s)" % json_restored.last_error)
_expect(json_restored.create_snapshot() == snapshot, "JSON transport preserves all promotion evidence exactly")
var before := restored.create_snapshot()
var missing_metric := snapshot.duplicate(true)
missing_metric[&"metrics"].erase("asset_gold")
_expect(not restored.restore_snapshot(missing_metric), "snapshot missing one progression metric is rejected")
_expect(restored.create_snapshot() == before, "missing-metric rejection is atomic")
var bad_fingerprint := snapshot.duplicate(true)
bad_fingerprint[&"processed_requests"][0][&"fingerprint"] = "rank:7"
_expect(not restored.restore_snapshot(bad_fingerprint), "request fingerprint inconsistent with recorded rank is rejected")
_expect(restored.create_snapshot() == before, "fingerprint rejection preserves live progression")
var bad_bounds := snapshot.duplicate(true)
bad_bounds[&"processed_requests"][0][&"details"][&"bounds"][&"max_x"] = 999
_expect(not restored.restore_snapshot(bad_bounds), "derived rank bounds cannot be tampered in a snapshot")
_expect(restored.create_snapshot() == before, "derived-bounds rejection is atomic")
var bad_quarantine := snapshot.duplicate(true)
bad_quarantine[&"quarantined"] = true
bad_quarantine[&"quarantine_reason"] = ""
_expect(not restored.restore_snapshot(bad_quarantine), "quarantine boolean requires a non-empty reason")
_expect(restored.create_snapshot() == before, "quarantine mismatch rejection is atomic")
var string_rank := snapshot.duplicate(true)
string_rank[&"current_rank"] = "2"
_expect(not restored.restore_snapshot(string_rank), "string rank is not coerced during restore")
_expect(restored.create_snapshot() == before, "type rejection preserves live progression")
var string_version := snapshot.duplicate(true)
string_version[&"version"] = "1"
_expect(not restored.restore_snapshot(string_version), "string schema version is not coerced during restore")
_expect(restored.create_snapshot() == before, "schema type rejection is atomic")
var unknown_root := snapshot.duplicate(true)
unknown_root[&"unexpected"] = true
_expect(not restored.restore_snapshot(unknown_root), "unknown root snapshot field is rejected")
_expect(restored.create_snapshot() == before, "unknown-field rejection preserves live progression")
var duplicate_rank_history := snapshot.duplicate(true)
var duplicate_record: Dictionary = duplicate_rank_history[&"processed_requests"][0].duplicate(true)
duplicate_record[&"request_id"] = "snapshot_rank_2_b"
duplicate_record[&"details"][&"request_id"] = &"snapshot_rank_2_b"
duplicate_rank_history[&"processed_requests"].append(duplicate_record)
_expect(not restored.restore_snapshot(duplicate_rank_history), "two successful request IDs cannot claim the same one-time rank transition")
_expect(restored.create_snapshot() == before, "duplicate-transition rejection is atomic")
func _test_promotion_evidence_and_legacy_migration() -> void:
var source := _eligible_rank_two_service()
source.request_rank_up(&"evidence_rank_2", 2)
var snapshot := source.create_snapshot()
var evidence: Dictionary = snapshot[&"processed_requests"][0][&"details"][&"eligibility"]
_expect(snapshot[&"version"] == 2, "promotion evidence is stored in Progression Snapshot 2")
_expect(evidence[&"eligible"] and evidence[&"current_rank"] == 1 and evidence[&"target_rank"] == 2, "saved eligibility header binds the exact one-step transition")
_expect(_requirement_ids(evidence) == [&"reputation", &"asset_gold"], "saved eligibility retains deterministic condition order")
var restored := ProgressionType.new()
_expect(restored.configure(), "evidence rejection target starts from a known valid state")
var before := restored.create_snapshot()
var below_threshold := snapshot.duplicate(true)
below_threshold[&"processed_requests"][0][&"details"][&"eligibility"][&"requirements"][0][&"current"] = 24
_expect(not restored.restore_snapshot(below_threshold), "promotion history below its threshold is rejected")
_expect(restored.create_snapshot() == before, "invalid promotion evidence rejection is atomic")
var changed_requirement := snapshot.duplicate(true)
changed_requirement[&"processed_requests"][0][&"details"][&"eligibility"][&"requirements"][0][&"required"] = 24
_expect(not restored.restore_snapshot(changed_requirement), "saved threshold definitions cannot be weakened")
_expect(restored.create_snapshot() == before, "threshold tamper preserves the live progression")
var missing_history := snapshot.duplicate(true)
missing_history[&"processed_requests"] = []
_expect(not restored.restore_snapshot(missing_history), "Rank 2 cannot be restored without its Rank 2 promotion record")
_expect(restored.create_snapshot() == before, "incomplete-history rejection is atomic")
var rank_three := _rank_three_service()
var lifetime_tamper := rank_three.create_snapshot()
lifetime_tamper[&"metrics"]["faith_lifetime"] = 99
_expect(not restored.restore_snapshot(lifetime_tamper), "current lifetime Faith cannot fall below its saved Rank 3 evidence")
_expect(restored.create_snapshot() == before, "lifetime-history mismatch preserves the live progression")
var rank_five := _rank_five_service()
var quest_tamper := rank_five.create_snapshot()
quest_tamper[&"completed_quest_ids"].erase("important_rank_5")
_expect(not restored.restore_snapshot(quest_tamper), "a monotonic quest used for Rank 5 cannot disappear from the snapshot")
_expect(restored.create_snapshot() == before, "quest-history mismatch is rejected atomically")
var legacy_source := ProgressionType.new()
legacy_source.configure(1, {&"reputation":10}, [&"tutorial_complete"])
var legacy_rank_one := legacy_source.create_snapshot()
legacy_rank_one[&"version"] = 1
_expect(restored.restore_snapshot(legacy_rank_one), "unambiguous legacy Rank 1 snapshot migrates safely")
_expect(restored.create_snapshot()[&"version"] == 2 and restored.current_rank == 1, "legacy restore is immediately re-emitted as Snapshot 2")
var legacy_progressed := snapshot.duplicate(true)
legacy_progressed[&"version"] = 1
legacy_progressed[&"processed_requests"][0][&"details"].erase(&"eligibility")
var migrated_before := restored.create_snapshot()
_expect(not restored.restore_snapshot(legacy_progressed), "legacy progressed snapshot without condition evidence is rejected as ambiguous")
_expect(restored.create_snapshot() == migrated_before, "ambiguous legacy rejection preserves the migrated Rank 1 state")
func _test_input_and_history_bounds() -> void:
var service := ProgressionType.new()
_expect(not service.configure(2), "configuration cannot bypass the one-rank-at-a-time promotion boundary")
_expect(service.configure(), "valid Rank 1 configuration remains available after rejection")
_expect(not service.update_metrics({&"asset_gold":ProgressionType.MAX_SAFE_INTEGER + 1}), "metrics above the JSON-safe integer range are rejected")
_expect(not service.update_metrics({&"asset_gold":INF}), "non-finite metrics are rejected")
var oversized_quests: Array = []
for index in ProgressionType.MAX_COMPLETED_QUESTS + 1:
oversized_quests.append("quest_%03d" % index)
_expect(not service.configure(1, {}, oversized_quests), "oversized initial quest input is rejected before per-entry work")
service = _eligible_rank_two_service()
var invalid_id := service.request_rank_up(&"rank request with spaces", 2)
_expect(not invalid_id.is_success() and invalid_id.code == CommandResult.Code.INVALID_PAYLOAD, "idempotency keys must use stable lowercase IDs")
_expect(service.current_rank == 1, "invalid idempotency key cannot mutate rank")
var impossible := service.create_snapshot()
impossible[&"current_rank"] = 7
var before := service.create_snapshot()
_expect(not service.restore_snapshot(impossible), "fabricated high rank without contiguous promotion history is rejected")
_expect(service.create_snapshot() == before, "fabricated-rank rejection is atomic")
func _eligible_rank_two_service() -> ProgressionService:
var service := ProgressionType.new()
service.configure(1, {&"reputation":25, &"asset_gold":10_000})
return service
func _rank_three_service() -> ProgressionService:
var service := _eligible_rank_two_service()
service.request_rank_up(&"history_rank_2", 2)
service.update_metrics({&"faith_lifetime":100, &"guest_lifetime":300})
service.request_rank_up(&"history_rank_3", 3)
return service
func _rank_five_service() -> ProgressionService:
var service := _rank_three_service()
service.update_metrics({
&"reputation":45,
&"asset_gold":50_000,
&"active_above_ground_facilities":1,
&"active_underground_facilities":1,
})
service.request_rank_up(&"history_rank_4", 4)
service.update_metrics({&"faith_lifetime":500})
service.complete_quest(&"important_rank_5")
service.request_rank_up(&"history_rank_5", 5)
return service
func _requirement_ids(report: Dictionary) -> Array[StringName]:
var result: Array[StringName] = []
for requirement: Dictionary in report[&"requirements"]:
result.append(StringName(requirement[&"id"]))
return result
func _expect(condition: bool, label: String) -> void:
_checks += 1
if condition:
print(" ✓ %s" % label)
return
_failures += 1
push_error(" ✗ %s" % label)