Preserve approved pixel tower prototype as development baseline
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
extends SceneTree
|
||||
|
||||
const CatalogType := preload("res://scripts/data/mvp_content_catalog.gd")
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
_test_baseline_catalog()
|
||||
_test_guest_combinations()
|
||||
_test_six_need_schema()
|
||||
_test_staff_hire_boundary()
|
||||
_test_unique_pair_and_defensive_copies()
|
||||
_test_invalid_content_is_fail_closed()
|
||||
_test_type_coercion_is_rejected()
|
||||
_test_fixed_ids_namespaces_and_resource_paths()
|
||||
_test_unique_character_safety_contract()
|
||||
_test_numeric_and_container_limits()
|
||||
_test_json_key_collisions()
|
||||
_test_error_order_is_deterministic()
|
||||
if _failures == 0:
|
||||
print("PASS: MvpContentCatalog (%d checks)" % _checks)
|
||||
else:
|
||||
push_error("FAIL: MvpContentCatalog (%d of %d checks failed)" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _test_baseline_catalog() -> void:
|
||||
var catalog := CatalogType.new()
|
||||
var report: Dictionary = catalog.rebuild()
|
||||
_expect(bool(report[&"valid"]), "MVP character catalog passes strict schema validation")
|
||||
_expect(int(report[&"error_count"]) == 0, "baseline character data has no validation errors")
|
||||
_expect(int(report[&"counts"]["guest_classes"]) == 8, "all eight guest classes are indexed")
|
||||
_expect(int(report[&"counts"]["species"]) == 4, "all four independent species are indexed")
|
||||
_expect(int(report[&"counts"]["staff_candidates"]) == 20, "all twenty free-hire candidates are indexed")
|
||||
_expect(int(report[&"counts"]["unique_characters"]) == 2, "one heroine and one rival are indexed")
|
||||
_expect(catalog.get_guest_class_ids() == [
|
||||
&"cleric", &"diplomat", &"mage", &"merchant", &"noble", &"outsider", &"rogue", &"warrior",
|
||||
], "guest classes use stable lexical order independent of JSON order")
|
||||
_expect(catalog.get_species_ids() == [
|
||||
&"beastfolk", &"demonfolk", &"elf", &"human",
|
||||
], "species use stable lexical order")
|
||||
_expect(catalog.get_staff_candidate_ids().front() == &"staff_akane", "candidate IDs are stable and namespaced")
|
||||
_expect(catalog.get_staff_candidate_ids().back() == &"staff_tessa", "all candidate IDs participate in deterministic order")
|
||||
|
||||
|
||||
func _test_guest_combinations() -> void:
|
||||
var catalog := CatalogType.new()
|
||||
catalog.rebuild()
|
||||
var definitions := catalog.build_all_guest_definitions()
|
||||
_expect(definitions.size() == 32, "eight classes and four species produce 32 independent archetypes")
|
||||
var ids: Array[StringName] = []
|
||||
for definition in definitions:
|
||||
ids.append(definition.guest_type_id)
|
||||
_expect(definition.is_valid(), "generated archetype %s satisfies GuestDefinition invariants" % definition.guest_type_id)
|
||||
_expect(definition.adult_verified, "generated archetype %s is adult-content gated" % definition.guest_type_id)
|
||||
var unique_ids: Dictionary = {}
|
||||
for definition_id: StringName in ids:
|
||||
unique_ids[definition_id] = true
|
||||
_expect(unique_ids.size() == 32 and not unique_ids.has(&""), "all generated archetype IDs are non-empty and unique")
|
||||
var warrior_elf = catalog.build_guest_definition(&"warrior", &"elf")
|
||||
_expect(warrior_elf != null and warrior_elf.guest_type_id == &"warrior_elf", "class and species IDs form a reproducible archetype ID")
|
||||
_expect(warrior_elf.wealth_min == 330 and warrior_elf.wealth_max == 990, "species wealth multiplier uses integer basis points")
|
||||
_expect(warrior_elf.base_patience == 70 and warrior_elf.risk_tolerance == 70, "species modifiers compose with class values once")
|
||||
_expect(&"combatant" in warrior_elf.tags and &"elf" in warrior_elf.tags, "class and species tags remain separate and both survive composition")
|
||||
_expect(warrior_elf.preference_weights[&"quiet"] == 8, "species preferences extend class preferences")
|
||||
_expect(warrior_elf.need_rates[&"sleep"] == 5, "species Need modifiers compose on the canonical Sleep axis")
|
||||
_expect(catalog.build_guest_definition(&"missing", &"human") == null, "unknown class fails closed")
|
||||
_expect(catalog.build_guest_definition(&"warrior", &"missing") == null, "unknown species fails closed")
|
||||
|
||||
|
||||
func _test_six_need_schema() -> void:
|
||||
var catalog := CatalogType.new()
|
||||
catalog.rebuild()
|
||||
var definition = catalog.build_guest_definition(&"diplomat", &"demonfolk")
|
||||
var need_ids: Array = definition.need_rates.keys()
|
||||
need_ids.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b))
|
||||
_expect(need_ids == [&"hunger", &"hygiene", &"intimacy", &"leisure", &"medical", &"sleep"], "generated definitions expose exactly the canonical six Need IDs")
|
||||
_expect(not definition.need_rates.has(&"safety") and not definition.need_rates.has(&"social"), "Safety and social context are not persisted as Needs")
|
||||
_expect(definition.need_rates[&"leisure"] == 5, "species Leisure modifier composes without being misread as Medical")
|
||||
|
||||
var documents := _load_documents()
|
||||
documents[&"guest_classes"]["entries"][0]["need_rates"].erase("hygiene")
|
||||
documents[&"guest_classes"]["entries"][0]["need_rates"]["safety"] = 1
|
||||
var report := catalog.rebuild_from_documents(documents)
|
||||
_expect(&"unknown_map_key" in _issue_codes(report[&"errors"]), "legacy Need names are rejected by the current content schema")
|
||||
documents = _load_documents()
|
||||
documents[&"guest_classes"]["entries"][0]["need_rates"].erase("hygiene")
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"missing_map_key" in _issue_codes(report[&"errors"]), "all six class Need rates are required")
|
||||
documents = _load_documents()
|
||||
documents[&"species"]["entries"][0]["need_rate_modifiers"]["rest"] = -1
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"unknown_map_key" in _issue_codes(report[&"errors"]), "species modifiers cannot reintroduce a migration-only alias")
|
||||
|
||||
|
||||
func _test_staff_hire_boundary() -> void:
|
||||
var catalog := CatalogType.new()
|
||||
catalog.rebuild()
|
||||
for candidate_id: StringName in catalog.get_staff_candidate_ids():
|
||||
var candidate := catalog.get_entry(&"staff_candidates", candidate_id)
|
||||
_expect(bool(candidate[&"adult_verified"]) and int(candidate[&"age"]) >= 18, "%s carries explicit adult verification" % candidate_id)
|
||||
_expect(int(candidate[&"daily_wage"]) >= 0 and int(candidate[&"signing_cost"]) >= 0, "%s has non-negative employment costs" % candidate_id)
|
||||
_expect((candidate[&"skills"] as Dictionary).size() == 4, "%s defines all four dispatch skills" % candidate_id)
|
||||
var payload := catalog.staff_hire_payload(&"staff_klaus")
|
||||
_expect(payload[&"staff_id"] == &"staff_klaus" and payload[&"role_id"] == &"guard", "hire payload preserves candidate and role IDs")
|
||||
_expect(payload[&"adult_verified"] and int(payload[&"age"]) == 36, "hire payload carries the adult gate across the runtime boundary")
|
||||
_expect(payload[&"preferred_shift"] == &"day", "hire payload exposes the validated preferred shift to the scheduler adapter")
|
||||
_expect(payload[&"options"][&"daily_wage"] == 155 and payload[&"options"][&"employment_kind"] == &"FREE_HIRE", "runtime options preserve wage liability and ordinary employment kind")
|
||||
_expect(payload[&"options"][&"skills"][&"combat"] == 82, "hire payload maps static skills into StaffRuntime options")
|
||||
_expect(&"security" in payload[&"options"][&"permitted_task_tags"], "hire payload preserves permitted task tags")
|
||||
var runtime := StaffRuntime.new().configure(payload[&"staff_id"], payload[&"role_id"], payload[&"options"])
|
||||
_expect(runtime.is_valid() and runtime.daily_wage == 155 and runtime.get_employment_kind_name() == &"FREE_HIRE", "hire payload constructs an exact payroll-bearing StaffRuntime")
|
||||
_expect(catalog.staff_hire_payload(&"missing").is_empty(), "unknown candidate cannot produce a hire payload")
|
||||
|
||||
|
||||
func _test_unique_pair_and_defensive_copies() -> void:
|
||||
var catalog := CatalogType.new()
|
||||
catalog.rebuild()
|
||||
var heroine := catalog.get_entry(&"unique_characters", &"heroine_anri")
|
||||
var rival := catalog.get_entry(&"unique_characters", &"rival_rai")
|
||||
_expect(heroine[&"character_kind"] == &"heroine" and rival[&"character_kind"] == &"rival", "unique characters have distinct mechanical kinds")
|
||||
_expect(heroine[&"counterpart_id"] == &"rival_rai" and rival[&"counterpart_id"] == &"heroine_anri", "heroine and rival references are reciprocal")
|
||||
_expect(heroine[&"adult_verified"] and rival[&"adult_verified"], "both relationship participants are explicitly adult-verified")
|
||||
_expect(heroine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] == 9000, "heroine baseline effect is represented in integer basis points")
|
||||
heroine[&"display_name"] = "tampered"
|
||||
heroine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] = 1
|
||||
var pristine := catalog.get_entry(&"unique_characters", &"heroine_anri")
|
||||
_expect(pristine[&"display_name"] == "アンリ", "returned definitions cannot mutate catalog identity")
|
||||
_expect(pristine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] == 9000, "returned nested data is also a defensive copy")
|
||||
|
||||
|
||||
func _test_invalid_content_is_fail_closed() -> void:
|
||||
var documents := _load_documents()
|
||||
var staff_entries: Array = documents[&"staff_candidates"]["entries"]
|
||||
staff_entries[0]["adult_verified"] = false
|
||||
staff_entries[1]["age"] = 17
|
||||
staff_entries[2]["traits"].append("unknown_trait")
|
||||
staff_entries[3]["typo_field"] = 1
|
||||
var guest_entries: Array = documents[&"guest_classes"]["entries"]
|
||||
guest_entries[1]["id"] = guest_entries[0]["id"]
|
||||
var catalog := CatalogType.new()
|
||||
var report: Dictionary = catalog.rebuild_from_documents(documents)
|
||||
_expect(not bool(report[&"valid"]), "invalid character data rejects the whole catalog")
|
||||
_expect(catalog.get_staff_candidate_ids().is_empty(), "fail-closed rebuild exposes no partially valid candidates")
|
||||
_expect(catalog.build_all_guest_definitions().is_empty(), "fail-closed rebuild cannot generate partial archetypes")
|
||||
var codes := _issue_codes(report[&"errors"])
|
||||
_expect(&"adult_verification_required" in codes, "missing adult verification has a stable error code")
|
||||
_expect(&"adult_age_required" in codes, "underage data has a stable error code")
|
||||
_expect(&"unknown_tag" in codes, "unknown trait is rejected instead of silently ignored")
|
||||
_expect(&"unknown_field" in codes, "misspelled data fields are rejected")
|
||||
_expect(&"duplicate_id" in codes, "duplicate stable IDs are rejected")
|
||||
|
||||
|
||||
func _test_error_order_is_deterministic() -> void:
|
||||
var documents := _load_documents()
|
||||
documents[&"species"]["schema_version"] = 99
|
||||
documents[&"unique_characters"]["entries"][0]["counterpart_id"] = "missing_actor"
|
||||
var catalog := CatalogType.new()
|
||||
var first: Dictionary = catalog.rebuild_from_documents(documents)
|
||||
var second: Dictionary = catalog.rebuild_from_documents(documents)
|
||||
_expect(first[&"errors"] == second[&"errors"], "content error order is deterministic across rebuilds")
|
||||
_expect(not bool(second[&"valid"]), "unsupported schema and orphan references remain fatal")
|
||||
_expect(&"unsupported_schema" in _issue_codes(second[&"errors"]), "schema rejection has a stable code")
|
||||
_expect(&"missing_counterpart" in _issue_codes(second[&"errors"]), "orphan counterpart has a stable code")
|
||||
|
||||
|
||||
func _test_type_coercion_is_rejected() -> void:
|
||||
var documents := _load_documents()
|
||||
documents[&"species"]["schema_version"] = "1"
|
||||
documents[&"guest_classes"]["entries"][0]["wealth_min"] = "300"
|
||||
documents[&"staff_candidates"]["entries"][0]["adult_verified"] = "true"
|
||||
documents[&"staff_candidates"]["entries"][1]["age"] = "38"
|
||||
documents[&"staff_candidates"]["entries"][2]["daily_wage"] = "145"
|
||||
documents[&"unique_characters"]["entries"][0]["character_kind"] = 1
|
||||
var catalog := CatalogType.new()
|
||||
var report: Dictionary = catalog.rebuild_from_documents(documents)
|
||||
var codes := _issue_codes(report[&"errors"])
|
||||
_expect(not bool(report[&"valid"]), "stringified booleans and numbers do not pass strict content validation")
|
||||
_expect(&"unsupported_schema" in codes, "string schema version is not coerced")
|
||||
_expect(&"invalid_wealth_range" in codes, "string wealth is not coerced")
|
||||
_expect(&"adult_verification_required" in codes, "string adult flag is not truthy-coerced")
|
||||
_expect(&"adult_age_required" in codes, "string age is not coerced")
|
||||
_expect(&"negative_value" in codes, "string employment cost is not coerced")
|
||||
_expect(&"unknown_character_kind" in codes, "numeric character kind is not coerced")
|
||||
|
||||
|
||||
func _test_fixed_ids_namespaces_and_resource_paths() -> void:
|
||||
var documents := _load_documents()
|
||||
documents[&"guest_classes"]["entries"][0]["id"] = "replacement_class"
|
||||
var catalog := CatalogType.new()
|
||||
var report := catalog.rebuild_from_documents(documents)
|
||||
_expect(&"mvp_id_set_mismatch" in _issue_codes(report[&"errors"]), "same-count replacement cannot silently change the fixed MVP ID set")
|
||||
_expect(not bool(report[&"valid"]), "fixed-ID mismatch fails closed")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"staff_candidates"]["entries"][0]["id"] = "akane"
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"invalid_namespace" in _issue_codes(report[&"errors"]), "free-hire actor IDs must retain the staff namespace")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"species"]["entries"][0]["tags"] = ["elf"]
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"species_identity_mismatch" in _issue_codes(report[&"errors"]), "species identity cannot be changed by swapping its tag")
|
||||
|
||||
var paths := CatalogType.DEFAULT_PATHS.duplicate(true)
|
||||
paths[&"species"] = "res://data/characters/../characters/mvp_species.json"
|
||||
report = catalog.rebuild(paths)
|
||||
_expect(&"invalid_resource_path" in _issue_codes(report[&"errors"]), "resource traversal segments are rejected before file parsing")
|
||||
_expect(catalog.get_species_ids().is_empty(), "invalid resource paths expose no stale prior catalog")
|
||||
|
||||
paths = CatalogType.DEFAULT_PATHS.duplicate(true)
|
||||
paths.erase(&"species")
|
||||
report = catalog.rebuild(paths)
|
||||
_expect(&"missing_content_type" in _issue_codes(report[&"errors"]), "custom path sets must name every required document")
|
||||
|
||||
|
||||
func _test_unique_character_safety_contract() -> void:
|
||||
var documents := _load_documents()
|
||||
var heroine: Dictionary = documents[&"unique_characters"]["entries"][0]
|
||||
heroine["traits"].erase("unsellable")
|
||||
var catalog := CatalogType.new()
|
||||
var report := catalog.rebuild_from_documents(documents)
|
||||
_expect(&"unique_safety_traits_required" in _issue_codes(report[&"errors"]), "unique actors cannot lose the unsellable safety marker")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"staff_candidates"]["entries"][0]["traits"].append("unique")
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"reserved_trait" in _issue_codes(report[&"errors"]), "free-hire candidates cannot impersonate unique actors")
|
||||
|
||||
documents = _load_documents()
|
||||
heroine = documents[&"unique_characters"]["entries"][0]
|
||||
heroine["mechanical_effects"]["arbitrary_runtime_hook"] = 1
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
var codes := _issue_codes(report[&"errors"])
|
||||
_expect(&"invalid_mechanical_effect" in codes, "unknown mechanical effect keys are rejected")
|
||||
_expect(&"effect_kind_mismatch" in codes, "effects cannot cross the heroine/rival capability boundary")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"unique_characters"]["entries"][0]["mechanical_effects"]["assigned_floor_patience_loss_multiplier_bp"] = 2_000
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "basis-point effects below the global multiplier floor are rejected")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"unique_characters"]["entries"][1]["mechanical_effects"]["base_shield_attack"] = 101
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "relationship attack data cannot exceed its bounded runtime scale")
|
||||
|
||||
|
||||
func _test_numeric_and_container_limits() -> void:
|
||||
var documents := _load_documents()
|
||||
documents[&"staff_candidates"]["entries"][0]["age"] = CatalogType.MAX_AGE + 1
|
||||
var catalog := CatalogType.new()
|
||||
var report := catalog.rebuild_from_documents(documents)
|
||||
_expect(&"adult_age_required" in _issue_codes(report[&"errors"]), "implausibly large ages do not enter persistent actor data")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"guest_classes"]["entries"][0]["wealth_max"] = CatalogType.MAX_GOLD_VALUE + 1
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"invalid_wealth_range" in _issue_codes(report[&"errors"]), "money values are bounded before basis-point multiplication")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"staff_candidates"]["entries"][0]["daily_wage"] = INF
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"negative_value" in _issue_codes(report[&"errors"]), "non-finite numeric input is never accepted as an integer")
|
||||
|
||||
documents = _load_documents()
|
||||
documents[&"staff_candidates"]["entries"][0]["display_name"] = "unsafe\nname"
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(&"missing_display_name" in _issue_codes(report[&"errors"]), "control characters are rejected from player-visible names")
|
||||
|
||||
documents = _load_documents()
|
||||
for raw_document: Variant in documents.values():
|
||||
for raw_entry: Variant in raw_document["entries"]:
|
||||
for index in 40:
|
||||
raw_entry["unexpected_%02d" % index] = index
|
||||
report = catalog.rebuild_from_documents(documents)
|
||||
_expect(int(report[&"error_count"]) == CatalogType.MAX_ERRORS, "adversarial documents cannot amplify validation reports beyond the error cap")
|
||||
_expect(bool(report[&"errors_truncated"]), "capped validation reports explicitly disclose truncation")
|
||||
_expect(not bool(report[&"valid"]), "error-report bounding never weakens fail-closed validation")
|
||||
|
||||
|
||||
func _test_json_key_collisions() -> void:
|
||||
var catalog := CatalogType.new()
|
||||
_expect(
|
||||
catalog._validate_json_key_structure("{\"id\":1,\"id\":2}") == &"duplicate_json_key",
|
||||
"duplicate JSON object keys are rejected before the parser can overwrite one value"
|
||||
)
|
||||
_expect(
|
||||
catalog._validate_json_key_structure("{\"id\":1,\"\\u0069d\":2}") == &"duplicate_json_key",
|
||||
"escaped and literal forms of the same JSON key collide canonically"
|
||||
)
|
||||
_expect(
|
||||
catalog._validate_json_key_structure("{\"outer\":{\"age\":1,\"age\":2}}") == &"duplicate_json_key",
|
||||
"duplicate-key detection covers nested content maps"
|
||||
)
|
||||
_expect(
|
||||
catalog._validate_json_key_structure("[".repeat(CatalogType.MAX_JSON_DEPTH + 1) + "0" + "]".repeat(CatalogType.MAX_JSON_DEPTH + 1)) == &"json_structure_limit_exceeded",
|
||||
"deeply nested JSON is rejected before recursive validation can exhaust the stack"
|
||||
)
|
||||
for path: String in CatalogType.DEFAULT_PATHS.values():
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
_expect(catalog._validate_json_key_structure(file.get_as_text()).is_empty(), "%s has a unique bounded JSON key structure" % path.get_file())
|
||||
|
||||
var documents := _load_documents()
|
||||
documents[&"species"]["entries"][0][42] = "not_a_field_name"
|
||||
var report := catalog.rebuild_from_documents(documents)
|
||||
_expect(&"invalid_key_type" in _issue_codes(report[&"errors"]), "non-string object keys are rejected without coercion")
|
||||
|
||||
|
||||
func _load_documents() -> Dictionary:
|
||||
var result: Dictionary = {}
|
||||
for content_type: StringName in [&"guest_classes", &"species", &"staff_candidates", &"unique_characters"]:
|
||||
var path := String(CatalogType.DEFAULT_PATHS[content_type])
|
||||
var file := FileAccess.open(path, FileAccess.READ)
|
||||
result[content_type] = JSON.parse_string(file.get_as_text())
|
||||
return result
|
||||
|
||||
|
||||
func _issue_codes(raw_issues: Variant) -> Array[StringName]:
|
||||
var result: Array[StringName] = []
|
||||
if raw_issues is Array:
|
||||
for raw_issue: Variant in raw_issues:
|
||||
if raw_issue is Dictionary:
|
||||
var code := StringName(raw_issue.get(&"code", raw_issue.get("code", "")))
|
||||
if not result.has(code):
|
||||
result.append(code)
|
||||
return result
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ce2yaa87mm67b
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
uid://cbf77pwt7oqjy
|
||||
@@ -0,0 +1,415 @@
|
||||
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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dt1eshc6c6rrf
|
||||
@@ -0,0 +1,500 @@
|
||||
extends SceneTree
|
||||
|
||||
|
||||
class DenyingOperationsCoordinator:
|
||||
extends OperationsCoordinator
|
||||
|
||||
func can_start_construction_or_investment() -> Dictionary:
|
||||
return {
|
||||
&"allowed": false,
|
||||
&"reason": &"ARREARS_REMAIN",
|
||||
&"arrears_total": 25,
|
||||
&"consecutive_insolvency_count": 1,
|
||||
}
|
||||
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
var _clock: Variant
|
||||
var _state: Variant
|
||||
var _runtime: Variant
|
||||
var _direct_command_id := 900_000
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("MVP: Runtime content, hiring, and progression integration")
|
||||
_test_catalog_bootstrap_and_queries()
|
||||
_test_progression_resource_and_guest_facts()
|
||||
_test_catalog_staff_hire_transaction()
|
||||
_test_rank_two_three_and_atomic_failure()
|
||||
_test_v3_migration_and_v4_tamper_guards()
|
||||
_test_twenty_four_hour_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 := 82_001) -> 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(bool(report.get(&"valid", false)), "Runtime initializes with validated content and Progression")
|
||||
|
||||
|
||||
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_catalog_bootstrap_and_queries() -> void:
|
||||
_reset_runtime(82_001)
|
||||
var report: Dictionary = _runtime.get_mvp_content_report()
|
||||
_expect(bool(report[&"valid"]), "MVP catalog report is valid")
|
||||
_expect(int(report[&"counts"]["guest_classes"]) == 8, "catalog exposes eight Guest classes")
|
||||
_expect(int(report[&"counts"]["species"]) == 4, "catalog exposes four independent species")
|
||||
_expect(int(report[&"counts"]["staff_candidates"]) == 20, "catalog exposes twenty fixed Staff candidates")
|
||||
_expect(int(report[&"counts"]["unique_characters"]) == 2, "catalog exposes both unique characters")
|
||||
var catalog_ids: Array[StringName] = _runtime.get_mvp_guest_definition_ids()
|
||||
_expect(catalog_ids.size() == 32, "Runtime registers every class/species Guest definition")
|
||||
_expect(_runtime.guest_service.get_definitions().size() == 35, "legacy three Guest definitions coexist with the 32 catalog definitions")
|
||||
var sorted_ids := catalog_ids.duplicate()
|
||||
sorted_ids.sort_custom(func(left: StringName, right: StringName) -> bool: return String(left) < String(right))
|
||||
_expect(catalog_ids == sorted_ids, "Runtime catalog Guest IDs use stable canonical order")
|
||||
var all_registered := true
|
||||
for guest_type_id: StringName in catalog_ids:
|
||||
if _runtime.guest_service.get_definition(guest_type_id) == null:
|
||||
all_registered = false
|
||||
break
|
||||
_expect(all_registered, "all 32 catalog Guest definitions are queryable by stable ID")
|
||||
|
||||
report[&"counts"]["guest_classes"] = 0
|
||||
_expect(int(_runtime.get_mvp_content_report()[&"counts"]["guest_classes"]) == 8, "content report query is a defensive copy")
|
||||
var candidate: Dictionary = _runtime.get_mvp_staff_candidate_payload(&"staff_akane")
|
||||
(candidate[&"options"][&"skills"] as Dictionary)[&"service"] = -1
|
||||
_expect(
|
||||
int(_runtime.get_mvp_staff_candidate_payload(&"staff_akane")[&"options"][&"skills"][&"service"]) == 68,
|
||||
"Staff candidate payload query is a defensive copy"
|
||||
)
|
||||
var progression_report: Dictionary = _runtime.get_progression_report()
|
||||
(progression_report[&"snapshot"][&"metrics"] as Dictionary)["asset_gold"] = 0
|
||||
_expect(int(_runtime.progression_service.metrics[&"asset_gold"]) == 5_000, "Progression report query is a defensive copy")
|
||||
|
||||
|
||||
func _test_progression_resource_and_guest_facts() -> void:
|
||||
_reset_runtime(82_002)
|
||||
var faith: CommandResult = _runtime.submit_command(
|
||||
&"change_resources", {&"deltas": {&"faith": 40}},
|
||||
&"player", &"", 50, -1, &"mvp.progression.faith"
|
||||
)
|
||||
_expect(faith.is_success(), "positive Faith resource change succeeds")
|
||||
_expect(_state.get_resource(&"faith") == 40, "positive Faith changes live GameState")
|
||||
_expect(int(_runtime.progression_service.metrics[&"faith_lifetime"]) == 40, "positive Faith increments lifetime progression atomically")
|
||||
var spawned: CommandResult = _runtime.submit_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": &"catalog_guest_one",
|
||||
&"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0],
|
||||
&"gold": 50,
|
||||
&"options": {&"current_cell": Vector3i.ZERO},
|
||||
},
|
||||
&"world", &"catalog_guest_one", 40, -1, &"mvp.progression.guest_one"
|
||||
)
|
||||
_expect(spawned.is_success(), "catalog Guest can spawn through Runtime")
|
||||
_expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 1, "successful Guest spawn increments lifetime Guest count once")
|
||||
var duplicate: CommandResult = _runtime.submit_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": &"catalog_guest_one",
|
||||
&"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0],
|
||||
&"options": {&"current_cell": Vector3i.ZERO},
|
||||
},
|
||||
&"world", &"catalog_guest_one", 40, -1, &"mvp.progression.guest_duplicate"
|
||||
)
|
||||
_expect(not duplicate.is_success(), "duplicate Guest spawn is rejected")
|
||||
_expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 1, "failed Guest spawn cannot increment lifetime progression")
|
||||
|
||||
_expect(
|
||||
_runtime.progression_service.update_metrics({&"faith_lifetime": ProgressionService.MAX_SAFE_INTEGER}),
|
||||
"overflow rollback fixture reaches the maximum lifetime Faith"
|
||||
)
|
||||
var state_before: Dictionary = _state.create_snapshot()
|
||||
var economy_before: Dictionary = _runtime.economy_service.create_snapshot()
|
||||
var progression_before: Dictionary = _runtime.progression_service.create_snapshot()
|
||||
var overflow: CommandResult = _runtime._execute_change_resources(_direct_command(
|
||||
&"change_resources", {&"deltas": {&"faith": 1}}
|
||||
))
|
||||
_expect(not overflow.is_success(), "lifetime Faith overflow rejects the resource command")
|
||||
_expect(_state.create_snapshot() == state_before, "failed lifetime update restores GameState exactly")
|
||||
_expect(_runtime.economy_service.create_snapshot() == economy_before, "failed lifetime update restores Economy exactly")
|
||||
_expect(_runtime.progression_service.create_snapshot() == progression_before, "failed lifetime update restores Progression exactly")
|
||||
|
||||
|
||||
func _test_catalog_staff_hire_transaction() -> void:
|
||||
_reset_runtime(82_003)
|
||||
var bypass: CommandResult = _runtime.submit_command(
|
||||
&"register_staff",
|
||||
{
|
||||
&"staff_id": &"staff_akane",
|
||||
&"role_id": &"guard",
|
||||
&"options": {&"current_cell": Vector3i.ZERO},
|
||||
},
|
||||
&"player", &"staff_akane", 80, -1, &"mvp.hire.bypass"
|
||||
)
|
||||
_expect(not bypass.is_success(), "generic Staff registration cannot bypass a reserved catalog candidate")
|
||||
var cash_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT)
|
||||
var hired: CommandResult = _runtime.submit_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO},
|
||||
&"player", &"staff_akane", 80, -1, &"mvp.hire.akane"
|
||||
)
|
||||
_expect(hired.is_success(), "validated Staff candidate hires atomically")
|
||||
var staff: StaffRuntime = _runtime.staff_service.get_staff(&"staff_akane")
|
||||
var shift: ShiftInstance = _runtime.staff_service.get_shift(StringName(hired.details[&"shift_id"]))
|
||||
_expect(staff != null and staff.role_id == &"receptionist", "hire uses the catalog role as the sole source of truth")
|
||||
_expect(staff != null and staff.daily_wage == 120, "FREE_HIRE retains its catalog daily wage")
|
||||
_expect(staff != null and staff.get_employment_kind_name() == &"FREE_HIRE", "ordinary catalog hire records explicit FREE_HIRE employment")
|
||||
_expect(staff != null and int(staff.skills[&"service"]) == 68, "hire copies catalog skills without caller inference")
|
||||
_expect(staff != null and staff.permitted_task_tags == [&"reception", &"service"], "hire copies canonical permitted Task tags")
|
||||
_expect(shift != null and shift.template_id == &"DAY", "hire creates the candidate's preferred Shift")
|
||||
_expect(_runtime.staff_service.get_daily_payroll_total() == 120, "hired candidate participates in deterministic daily payroll")
|
||||
_expect(
|
||||
_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == cash_before - 180,
|
||||
"signing cost is paid exactly once through the Gold ledger"
|
||||
)
|
||||
_expect(_state.get_resource(&"gold") == cash_before - 180, "ledger signing cost synchronizes GameState Gold")
|
||||
_expect(_runtime.path_service.has_actor_projection(&"staff:staff_akane"), "hired Staff is projected into Path traffic")
|
||||
_expect(_runtime.get_hired_staff_candidate_ids() == [&"staff_akane"], "Runtime records the candidate as permanently hired")
|
||||
var saw_hire_event := false
|
||||
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
||||
if event.event_type == &"STAFF_CANDIDATE_HIRED" and &"staff_akane" in event.entity_ids:
|
||||
saw_hire_event = true
|
||||
_expect(saw_hire_event, "successful hire publishes a structured DomainEvent")
|
||||
|
||||
var replay_cash: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT)
|
||||
var replay: CommandResult = _runtime.submit_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO},
|
||||
&"player", &"staff_akane", 80, -1, &"mvp.hire.akane"
|
||||
)
|
||||
_expect(not replay.is_success() and replay.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY, "resent hire command is recognized by its idempotency key")
|
||||
_expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == replay_cash, "resent hire never charges the signing cost twice")
|
||||
var duplicate: CommandResult = _runtime.submit_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO},
|
||||
&"player", &"staff_akane", 80, -1, &"mvp.hire.akane.other"
|
||||
)
|
||||
_expect(not duplicate.is_success(), "same candidate cannot be hired under a different request")
|
||||
_expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == replay_cash, "duplicate-candidate rejection spends no Gold")
|
||||
var original_coordinator: Variant = _runtime.operations_coordinator
|
||||
var original_operations_enabled: bool = bool(_runtime._operations_enabled)
|
||||
var staff_before_gate: Dictionary = _runtime.staff_service.create_snapshot()
|
||||
var economy_before_gate: Dictionary = _runtime.economy_service.create_snapshot()
|
||||
var hired_before_gate: Array[StringName] = _runtime.get_hired_staff_candidate_ids()
|
||||
_runtime.operations_coordinator = DenyingOperationsCoordinator.new()
|
||||
_runtime._operations_enabled = true
|
||||
var gated: CommandResult = _runtime._execute_hire_staff_candidate(_direct_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_tessa", &"current_cell": Vector3i.ZERO}
|
||||
))
|
||||
_runtime.operations_coordinator = original_coordinator
|
||||
_runtime._operations_enabled = original_operations_enabled
|
||||
_expect(
|
||||
not gated.is_success()
|
||||
and StringName(gated.details.get(&"reason", &"")) == &"ARREARS_REMAIN",
|
||||
"Operations arrears gate rejects a new wage-bearing Staff contract"
|
||||
)
|
||||
_expect(
|
||||
_runtime.staff_service.create_snapshot() == staff_before_gate
|
||||
and _runtime.economy_service.create_snapshot() == economy_before_gate
|
||||
and _runtime.get_hired_staff_candidate_ids() == hired_before_gate,
|
||||
"rejected Operations-gated hire spends nothing and mutates no Staff history"
|
||||
)
|
||||
|
||||
var staff_before: Dictionary = _runtime.staff_service.create_snapshot()
|
||||
var economy_before: Dictionary = _runtime.economy_service.create_snapshot()
|
||||
var game_state_before: Dictionary = _state.create_snapshot()
|
||||
var path_before: Dictionary = _runtime.path_service.create_snapshot()
|
||||
var credits_before: Dictionary = _runtime._movement_credits.duplicate(true)
|
||||
var events_before: Dictionary = _runtime.domain_events.create_snapshot()
|
||||
var ids_before: Dictionary = _runtime.id_generator.create_snapshot()
|
||||
var hired_ids_before: Array[StringName] = _runtime.get_hired_staff_candidate_ids()
|
||||
var callback := Callable(_runtime, "_on_staff_registered")
|
||||
_runtime.staff_service.staff_registered.disconnect(callback)
|
||||
var forced_failure: CommandResult = _runtime._execute_hire_staff_candidate(_direct_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_bardo", &"current_cell": Vector3i.ZERO}
|
||||
))
|
||||
_runtime.staff_service.staff_registered.connect(callback)
|
||||
_expect(not forced_failure.is_success(), "missing Path projection forces a post-payment hire failure")
|
||||
_expect(_runtime.staff_service.create_snapshot() == staff_before, "failed hire restores Staff and Shift state exactly")
|
||||
_expect(_runtime.economy_service.create_snapshot() == economy_before, "failed hire restores the ledger exactly")
|
||||
_expect(_state.create_snapshot() == game_state_before, "failed hire restores GameState exactly")
|
||||
_expect(_runtime.path_service.create_snapshot() == path_before, "failed hire restores Path exactly")
|
||||
_expect(_runtime._movement_credits == credits_before, "failed hire restores movement credits exactly")
|
||||
_expect(_runtime.domain_events.create_snapshot() == events_before, "failed hire restores DomainEvents exactly")
|
||||
_expect(_runtime.id_generator.create_snapshot() == ids_before, "failed hire restores stable ID counters exactly")
|
||||
_expect(_runtime.get_hired_staff_candidate_ids() == hired_ids_before, "failed hire leaves candidate history unchanged")
|
||||
var canonical_save: Dictionary = _runtime.create_snapshot()
|
||||
var wage_tamper: Dictionary = canonical_save.duplicate(true)
|
||||
for raw_staff: Dictionary in wage_tamper[&"systems"][&"runtime"][&"staff"][&"staff"]:
|
||||
if String(raw_staff[&"staff_id"]) == "staff_akane":
|
||||
raw_staff[&"daily_wage"] = 0
|
||||
break
|
||||
var live_before: Dictionary = _runtime.create_snapshot()
|
||||
_expect(not _runtime.restore_snapshot(wage_tamper), "v4 restore rejects a hired candidate whose catalog wage was altered")
|
||||
_expect(_runtime.create_snapshot() == live_before, "catalog Staff crosslink rejection is atomic")
|
||||
|
||||
|
||||
func _test_rank_two_three_and_atomic_failure() -> void:
|
||||
_reset_runtime(82_004)
|
||||
var rank_before: Dictionary = _runtime.progression_service.create_snapshot()
|
||||
var world_before: Dictionary = _runtime.world_service.create_snapshot()
|
||||
var unmet: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command(
|
||||
&"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two_unmet"}
|
||||
))
|
||||
_expect(not unmet.is_success(), "Rank 2 rejects unmet reputation and asset requirements")
|
||||
_expect(_runtime.progression_service.create_snapshot() == rank_before, "failed eligibility check leaves Progression exact")
|
||||
_expect(_runtime.world_service.create_snapshot() == world_before, "failed eligibility check leaves World exact")
|
||||
|
||||
_expect(_runtime._execute_update_progression_metrics(_direct_command(
|
||||
&"update_progression_metrics", {&"metrics": {&"reputation": 25}}
|
||||
)).is_success(), "Rank 2 reputation fact updates")
|
||||
_expect(_runtime._execute_change_resources(_direct_command(
|
||||
&"change_resources", {&"deltas": {&"gold": 5_000}}
|
||||
)).is_success(), "Rank 2 asset threshold is funded")
|
||||
var promoted_two: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command(
|
||||
&"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two"}
|
||||
))
|
||||
_expect(promoted_two.is_success(), "eligible Rank 2 promotion succeeds")
|
||||
_expect(_runtime.progression_service.current_rank == 2 and _state.world_tree_rank == 2, "Progression and GameState commit Rank 2 together")
|
||||
_expect(_runtime.world_service.get_bounds() == ProgressionService.RANK_BOUNDS[2], "Rank 2 commits canonical World bounds")
|
||||
var replay_before: Dictionary = _runtime.create_snapshot()
|
||||
var replay: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command(
|
||||
&"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two"}
|
||||
))
|
||||
_expect(replay.is_success() and replay.is_replay, "same rank request ID replays the original success")
|
||||
_expect(_runtime.create_snapshot() == replay_before, "rank request replay is completely read-only")
|
||||
var skip: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command(
|
||||
&"set_world_tree_rank", {&"rank": 4, &"request_id": &"rank_four_skip"}
|
||||
))
|
||||
_expect(not skip.is_success() and _runtime.progression_service.current_rank == 2, "rank progression cannot skip Rank 3")
|
||||
var rank_three_guests_ok := true
|
||||
var rank_guest_definition: StringName = _runtime.get_mvp_guest_definition_ids()[0]
|
||||
for index in range(300):
|
||||
var guest_id := StringName("rank_three_guest_%03d" % index)
|
||||
var spawned: CommandResult = _runtime._execute_spawn_guest(_direct_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": guest_id,
|
||||
&"guest_type_id": rank_guest_definition,
|
||||
&"gold": 0,
|
||||
&"options": {
|
||||
&"current_cell": Vector3i.ZERO,
|
||||
&"planned_exit_at": 10_000,
|
||||
},
|
||||
}
|
||||
))
|
||||
rank_three_guests_ok = rank_three_guests_ok and spawned.is_success()
|
||||
var lifetime_updated: CommandResult = _runtime._execute_update_progression_metrics(_direct_command(
|
||||
&"update_progression_metrics",
|
||||
{&"metrics": {&"faith_lifetime": 100}}
|
||||
))
|
||||
_expect(
|
||||
rank_three_guests_ok and lifetime_updated.is_success() \
|
||||
and int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 300,
|
||||
"Rank 3 lifetime facts derive from 300 authoritative Guest records"
|
||||
)
|
||||
var promoted_three: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command(
|
||||
&"set_world_tree_rank", {&"rank": 3, &"request_id": &"rank_three"}
|
||||
))
|
||||
_expect(promoted_three.is_success(), "eligible Rank 3 promotion succeeds")
|
||||
_expect(_runtime.progression_service.current_rank == 3 and _state.world_tree_rank == 3, "Progression and GameState commit Rank 3 together")
|
||||
var saw_rank_event := false
|
||||
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
||||
if event.event_type == &"WORLD_TREE_RANK_ADVANCED" and int(event.payload.get(&"rank", 0)) == 3:
|
||||
saw_rank_event = true
|
||||
_expect(saw_rank_event, "successful promotion publishes WORLD_TREE_RANK_ADVANCED")
|
||||
|
||||
_reset_runtime(82_005)
|
||||
_runtime.progression_service.update_metrics({&"reputation": 25})
|
||||
_runtime._execute_change_resources(_direct_command(&"change_resources", {&"deltas": {&"gold": 5_000}}))
|
||||
_expect(_runtime._expand_world_to_rank(3), "rank apply-failure fixture expands World and Path coherently")
|
||||
var atomic_before := {
|
||||
&"progression": _runtime.progression_service.create_snapshot(),
|
||||
&"world": _runtime.world_service.create_snapshot(),
|
||||
&"path": _runtime.path_service.create_snapshot(),
|
||||
&"state": _state.create_snapshot(),
|
||||
&"credits": _runtime._movement_credits.duplicate(true),
|
||||
&"events": _runtime.domain_events.create_snapshot(),
|
||||
&"ids": _runtime.id_generator.create_snapshot(),
|
||||
}
|
||||
var apply_failure: CommandResult = _runtime._execute_set_world_tree_rank(_direct_command(
|
||||
&"set_world_tree_rank", {&"rank": 2, &"request_id": &"rank_two_apply_failure"}
|
||||
))
|
||||
_expect(not apply_failure.is_success(), "World/Path apply failure rejects promotion")
|
||||
_expect(_runtime.progression_service.create_snapshot() == atomic_before[&"progression"], "failed rank apply restores Progression exactly")
|
||||
_expect(_runtime.world_service.create_snapshot() == atomic_before[&"world"], "failed rank apply restores World exactly")
|
||||
_expect(_runtime.path_service.create_snapshot() == atomic_before[&"path"], "failed rank apply restores Path exactly")
|
||||
_expect(_state.create_snapshot() == atomic_before[&"state"], "failed rank apply restores GameState exactly")
|
||||
_expect(_runtime._movement_credits == atomic_before[&"credits"], "failed rank apply restores movement credits exactly")
|
||||
_expect(_runtime.domain_events.create_snapshot() == atomic_before[&"events"], "failed rank apply restores DomainEvents exactly")
|
||||
_expect(_runtime.id_generator.create_snapshot() == atomic_before[&"ids"], "failed rank apply restores stable IDs exactly")
|
||||
|
||||
|
||||
func _test_v3_migration_and_v4_tamper_guards() -> void:
|
||||
_reset_runtime(82_006)
|
||||
_runtime._execute_change_resources(_direct_command(&"change_resources", {&"deltas": {&"faith": 12}}))
|
||||
for index in range(2):
|
||||
var guest_id := StringName("legacy_guest_%d" % index)
|
||||
var spawned: CommandResult = _runtime._execute_spawn_guest(_direct_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": guest_id,
|
||||
&"guest_type_id": _runtime.get_mvp_guest_definition_ids()[index],
|
||||
&"gold": 0,
|
||||
&"options": {&"current_cell": Vector3i.ZERO},
|
||||
}
|
||||
))
|
||||
_expect(spawned.is_success(), "legacy migration fixture Guest %d spawns" % index)
|
||||
_runtime.guest_service.despawn_guest(&"legacy_guest_1", _clock.absolute_minute)
|
||||
var v3: Dictionary = _runtime.create_snapshot()
|
||||
var v3_runtime: Dictionary = v3[&"systems"][&"runtime"]
|
||||
v3_runtime[&"runtime_schema_version"] = 3
|
||||
v3_runtime.erase(&"operations")
|
||||
v3_runtime.erase(&"catalog_manifest")
|
||||
v3_runtime.erase(&"progression")
|
||||
v3_runtime.erase(&"hired_staff_candidate_ids")
|
||||
for infrastructure_key: StringName in [
|
||||
&"excavation", &"spatial_subslots", &"route_projection",
|
||||
&"facility_infrastructure",
|
||||
]:
|
||||
v3_runtime.erase(infrastructure_key)
|
||||
_expect(_runtime.restore_snapshot(v3), "Runtime v3 Rank 1 snapshot migrates without guessed promotion evidence")
|
||||
_expect(_runtime.progression_service.current_rank == 1, "v3 migration creates fresh Rank 1 Progression")
|
||||
_expect(int(_runtime.progression_service.metrics[&"asset_gold"]) == _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT), "v3 migration derives asset Gold from the authoritative ledger")
|
||||
_expect(int(_runtime.progression_service.metrics[&"faith_lifetime"]) == 12, "v3 migration derives lifetime Faith from current Faith")
|
||||
_expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 2, "v3 migration derives lifetime Guests from active plus persistent records")
|
||||
_expect(_runtime.get_mvp_guest_definition_ids().size() == 32, "v3 migration registers the current 32 catalog definitions")
|
||||
var migrated: Dictionary = _runtime.create_snapshot()
|
||||
_expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 6, "v3 migration emits canonical Runtime schema v6")
|
||||
var digest := SimulationDigest.compute(migrated)
|
||||
_expect(_runtime.restore_snapshot(migrated), "canonical v5 snapshot restores")
|
||||
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v5 restore is exact")
|
||||
|
||||
var progressed_legacy: Dictionary = v3.duplicate(true)
|
||||
progressed_legacy[&"game_state"][&"world_tree_rank"] = 2
|
||||
var live_before: Dictionary = _runtime.create_snapshot()
|
||||
_expect(not _runtime.restore_snapshot(progressed_legacy), "v3 Rank 2 snapshot is rejected because promotion evidence is absent")
|
||||
_expect(_runtime.create_snapshot() == live_before, "rejected progressed v3 migration is atomic")
|
||||
|
||||
var corruptions: Array[Dictionary] = []
|
||||
var manifest: Dictionary = migrated.duplicate(true)
|
||||
manifest[&"systems"][&"runtime"][&"catalog_manifest"][&"guest_definition_ids"][0] = "tampered_guest"
|
||||
corruptions.append({&"label": "catalog manifest", &"snapshot": manifest})
|
||||
var definition: Dictionary = migrated.duplicate(true)
|
||||
var catalog_id := String(_runtime.get_mvp_guest_definition_ids()[0])
|
||||
for raw_definition: Dictionary in definition[&"systems"][&"runtime"][&"guests"][&"definitions"]:
|
||||
if String(raw_definition[&"guest_type_id"]) == catalog_id:
|
||||
raw_definition[&"wealth_min"] = int(raw_definition[&"wealth_min"]) + 1
|
||||
break
|
||||
corruptions.append({&"label": "catalog Guest definition", &"snapshot": definition})
|
||||
var progression: Dictionary = migrated.duplicate(true)
|
||||
progression[&"systems"][&"runtime"][&"progression"][&"current_rank"] = 2
|
||||
corruptions.append({&"label": "Progression history", &"snapshot": progression})
|
||||
var rank: Dictionary = migrated.duplicate(true)
|
||||
rank[&"game_state"][&"world_tree_rank"] = 2
|
||||
corruptions.append({&"label": "GameState rank", &"snapshot": rank})
|
||||
var bounds: Dictionary = migrated.duplicate(true)
|
||||
bounds[&"systems"][&"runtime"][&"world"][&"bounds"][&"max_x"] = 13
|
||||
corruptions.append({&"label": "World rank bounds", &"snapshot": bounds})
|
||||
var hired_manifest: Dictionary = migrated.duplicate(true)
|
||||
hired_manifest[&"systems"][&"runtime"][&"hired_staff_candidate_ids"] = ["not_a_candidate"]
|
||||
corruptions.append({&"label": "hired candidate manifest", &"snapshot": hired_manifest})
|
||||
for corruption: Dictionary in corruptions:
|
||||
live_before = _runtime.create_snapshot()
|
||||
_expect(
|
||||
not _runtime.restore_snapshot(corruption[&"snapshot"])
|
||||
and _runtime.create_snapshot() == live_before,
|
||||
"%s tamper is rejected atomically" % corruption[&"label"]
|
||||
)
|
||||
|
||||
|
||||
func _test_twenty_four_hour_replay() -> void:
|
||||
_reset_runtime(82_007)
|
||||
var spawn: CommandResult = _runtime._execute_spawn_guest(_direct_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": &"replay_guest",
|
||||
&"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0],
|
||||
&"gold": 100,
|
||||
&"options": {&"current_cell": Vector3i.ZERO, &"planned_exit_at": _clock.absolute_minute + 2_000},
|
||||
}
|
||||
))
|
||||
var hire: CommandResult = _runtime._execute_hire_staff_candidate(_direct_command(
|
||||
&"hire_staff_candidate", {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}
|
||||
))
|
||||
_expect(spawn.is_success() and hire.is_success(), "24-hour replay fixture creates catalog Guest and Staff")
|
||||
var start: Dictionary = _runtime.create_snapshot()
|
||||
var second_clock = load("res://autoload/game_clock.gd").new()
|
||||
var second_state = load("res://autoload/game_state.gd").new()
|
||||
var second_runtime = load("res://autoload/simulation_runtime.gd").new()
|
||||
second_clock.reset(1, 6, 0)
|
||||
second_state.reset()
|
||||
second_runtime.initialize(second_clock, second_state, 82_007)
|
||||
second_clock.set_paused(true)
|
||||
_expect(second_runtime.restore_snapshot(start), "24-hour replay clone restores exact v4 start")
|
||||
_clock.set_paused(false)
|
||||
second_clock.set_paused(false)
|
||||
_clock.advance_minutes(1_440)
|
||||
second_clock.advance_minutes(1_440)
|
||||
_expect(
|
||||
SimulationDigest.compute(_runtime.create_snapshot()) \
|
||||
== SimulationDigest.compute(second_runtime.create_snapshot()),
|
||||
"catalog, Staff hire, Progression, and six-Need state replay exactly for 24 hours"
|
||||
)
|
||||
second_runtime.free()
|
||||
second_clock.free()
|
||||
second_state.free()
|
||||
|
||||
|
||||
func _direct_command(command_type: StringName, payload: Dictionary) -> SimulationCommand:
|
||||
_direct_command_id += 1
|
||||
return SimulationCommand.new(
|
||||
_direct_command_id,
|
||||
_clock.absolute_minute,
|
||||
_clock.absolute_minute,
|
||||
100,
|
||||
command_type,
|
||||
&"test",
|
||||
&"",
|
||||
payload,
|
||||
&""
|
||||
)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b46fufnam07fa
|
||||
@@ -0,0 +1,484 @@
|
||||
extends SceneTree
|
||||
|
||||
const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY
|
||||
const RUNTIME_SNAPSHOT_VERSION := 6
|
||||
|
||||
var _checks := 0
|
||||
var _failures := 0
|
||||
|
||||
|
||||
func _initialize() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
print("MVP: production Operations acceptance")
|
||||
_test_waiting_choice_and_command_exactly_once()
|
||||
_test_twenty_four_hour_actual_tick_smoke()
|
||||
_test_full_partial_payroll_bankruptcy_and_v6_tamper()
|
||||
_test_fourteen_day_boundary_snapshot_determinism()
|
||||
await process_frame
|
||||
if _failures == 0:
|
||||
print("PASS: %d checks" % _checks)
|
||||
else:
|
||||
push_error("FAIL: %d of %d checks failed" % [_failures, _checks])
|
||||
quit(_failures)
|
||||
|
||||
|
||||
func _new_fixture(seed: int, enable_operations := true) -> Dictionary:
|
||||
var clock: Variant = load("res://autoload/game_clock.gd").new()
|
||||
var state: Variant = load("res://autoload/game_state.gd").new()
|
||||
var runtime: Variant = load("res://autoload/simulation_runtime.gd").new()
|
||||
clock.reset(1, 6, 0)
|
||||
state.reset()
|
||||
var initialized: Dictionary = runtime.initialize(clock, state, seed)
|
||||
clock.set_paused(true)
|
||||
_expect(bool(initialized.get(&"valid", false)), "fixture Runtime initializes")
|
||||
var initial_snapshot: Dictionary = runtime.create_snapshot()
|
||||
_expect(
|
||||
int(initial_snapshot[&"systems"][&"runtime"].get(
|
||||
&"runtime_schema_version", -1
|
||||
)) == RUNTIME_SNAPSHOT_VERSION,
|
||||
"fixture emits Runtime snapshot schema v6"
|
||||
)
|
||||
if enable_operations:
|
||||
var enabled: Dictionary = runtime.enable_production_operations()
|
||||
_expect(bool(enabled.get(&"ok", false)), "fixture enables production Operations")
|
||||
return {&"clock": clock, &"state": state, &"runtime": runtime}
|
||||
|
||||
|
||||
func _free_fixture(fixture: Dictionary) -> void:
|
||||
var runtime: Variant = fixture.get(&"runtime")
|
||||
var clock: Variant = fixture.get(&"clock")
|
||||
var state: Variant = fixture.get(&"state")
|
||||
if runtime != null:
|
||||
runtime.free()
|
||||
if clock != null:
|
||||
clock.free()
|
||||
if state != null:
|
||||
state.free()
|
||||
|
||||
|
||||
# Long acceptance slices deliberately exercise the production Operations
|
||||
# adapter without claiming to execute unrelated actor AI on all 20,160 minutes.
|
||||
# Aggregate clocks are aligned solely to preserve the Runtime transaction
|
||||
# precondition; the separate 24-hour smoke above covers the actual Tick pipeline.
|
||||
# One final rich snapshot (and the midpoint restore) is the Operations oracle.
|
||||
func _advance_operations_boundary_slice(fixture: Dictionary, amount: int) -> int:
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var advanced := 0
|
||||
while advanced < amount:
|
||||
if runtime.operations_coordinator != null \
|
||||
and runtime.operations_coordinator.is_terminal():
|
||||
break
|
||||
# Operations has no recurring boundary finer than 30 minutes. The
|
||||
# coordinator still performs its own unthinned internal minute traversal;
|
||||
# unrelated Runtime AI is intentionally outside this accelerated oracle.
|
||||
var step := mini(30, amount - advanced)
|
||||
var target: int = clock.absolute_minute + step
|
||||
clock.absolute_minute = target
|
||||
clock.call("_sync_calendar_fields")
|
||||
clock.snapshot_version += step
|
||||
runtime.call("_advance_operations_time", target)
|
||||
if bool(runtime.get_operations_report().get(&"runtime_failure_latched", false)):
|
||||
break
|
||||
if runtime.operations_coordinator.is_terminal():
|
||||
# A terminal daily close commits TIME_BOUNDARIES and deliberately skips
|
||||
# the Runtime suffix; its other aggregates therefore remain at X - 1.
|
||||
_align_operations_fixture_clocks(runtime, target - 1)
|
||||
advanced += step
|
||||
break
|
||||
# These acceptance fixtures contain no Facilities, logistics, raids, or
|
||||
# Staff tasks. Their clocks are aligned for Operations cross-link checks;
|
||||
# their skipped actor/Shift behavior is explicitly outside this fast oracle.
|
||||
_align_operations_fixture_clocks(runtime, target)
|
||||
advanced += step
|
||||
return advanced
|
||||
|
||||
|
||||
func _align_operations_fixture_clocks(runtime: Variant, minute: int) -> void:
|
||||
runtime.inventory_service.current_minute = minute
|
||||
runtime.facility_service.current_minute = minute
|
||||
runtime.guest_service.current_minute = minute
|
||||
runtime.staff_service.current_minute = minute
|
||||
runtime.authority_service.current_minute = minute
|
||||
runtime.defense_service.current_minute = minute
|
||||
runtime.construction_service.current_minute = minute
|
||||
runtime.excavation_service.current_minute = minute
|
||||
|
||||
|
||||
func _operations_slice_is_healthy(fixture: Dictionary) -> bool:
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var report: Dictionary = runtime.get_operations_report()
|
||||
var foreground: EventInstance = runtime.event_service.get_foreground_instance()
|
||||
return not bool(report.get(&"runtime_failure_latched", true)) \
|
||||
and int(report.get(&"deferred_runtime_minute", -2)) == -1 \
|
||||
and not runtime.operations_coordinator.pause_requested \
|
||||
and not runtime.operations_coordinator.is_terminal() \
|
||||
and not runtime.operations_coordinator.is_external_quarantined() \
|
||||
and (foreground == null or foreground.state != EventInstance.State.WAITING_CHOICE)
|
||||
|
||||
|
||||
func _test_waiting_choice_and_command_exactly_once() -> void:
|
||||
var fixture := _new_fixture(73_001)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
var state: Variant = fixture[&"state"]
|
||||
|
||||
var queued: CommandResult = runtime.event_service.enqueue_candidate(
|
||||
&"relationship.threshold",
|
||||
[],
|
||||
clock.absolute_minute,
|
||||
{&"deduplication_key": &"operations-acceptance-choice"}
|
||||
)
|
||||
var started: CommandResult = runtime.event_service.start_next_event(
|
||||
clock.absolute_minute, {}
|
||||
)
|
||||
_expect(
|
||||
queued.is_success() and started.is_success()
|
||||
and bool(started.details.get(&"waiting_for_choice", false)),
|
||||
"foreground Event enters WAITING_CHOICE"
|
||||
)
|
||||
clock.set_paused(false)
|
||||
var before_choice_minute: int = clock.absolute_minute
|
||||
clock.advance_minutes(5)
|
||||
var foreground: EventInstance = runtime.event_service.get_foreground_instance()
|
||||
_expect(
|
||||
clock.paused and clock.absolute_minute == before_choice_minute + 1,
|
||||
"WAITING_CHOICE pauses at the first committed minute and stops catch-up"
|
||||
)
|
||||
_expect(
|
||||
foreground != null and foreground.state == EventInstance.State.WAITING_CHOICE,
|
||||
"paused foreground ownership remains intact"
|
||||
)
|
||||
if foreground != null:
|
||||
var choice_result: CommandResult = runtime.submit_command(
|
||||
&"submit_event_choice",
|
||||
{
|
||||
&"event_instance_id": foreground.event_instance_id,
|
||||
&"choice_instance_id": foreground.choice_instance_id,
|
||||
&"choice_id": &"acknowledge",
|
||||
},
|
||||
&"player",
|
||||
foreground.event_instance_id,
|
||||
100,
|
||||
-1,
|
||||
&"operations.acceptance.choice"
|
||||
)
|
||||
_expect(
|
||||
choice_result.is_success()
|
||||
and runtime.event_service.get_foreground_instance() == null
|
||||
and not clock.paused,
|
||||
"submitting the stable Choice token resolves the Event and resumes time"
|
||||
)
|
||||
|
||||
clock.set_paused(true)
|
||||
var gold_before: int = state.get_resource(&"gold")
|
||||
var first: CommandResult = runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"gold": 17}},
|
||||
&"player",
|
||||
&"",
|
||||
50,
|
||||
-1,
|
||||
&"operations.acceptance.exactly-once"
|
||||
)
|
||||
var gold_after_first: int = state.get_resource(&"gold")
|
||||
var retry: CommandResult = runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"gold": 17}},
|
||||
&"player",
|
||||
&"",
|
||||
50,
|
||||
-1,
|
||||
&"operations.acceptance.exactly-once"
|
||||
)
|
||||
_expect(
|
||||
first.is_success() and gold_after_first == gold_before + 17,
|
||||
"first public command commits its resource delta once"
|
||||
)
|
||||
_expect(
|
||||
not retry.is_success()
|
||||
and retry.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY
|
||||
and state.get_resource(&"gold") == gold_after_first,
|
||||
"transport retry with the same idempotency key cannot apply twice"
|
||||
)
|
||||
var command_snapshot: Dictionary = runtime.create_snapshot()
|
||||
_expect(
|
||||
runtime.restore_snapshot(command_snapshot),
|
||||
"command history survives an exact v6 snapshot round trip"
|
||||
)
|
||||
var restored_retry: CommandResult = runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"gold": 17}},
|
||||
&"player",
|
||||
&"",
|
||||
50,
|
||||
-1,
|
||||
&"operations.acceptance.exactly-once"
|
||||
)
|
||||
_expect(
|
||||
not restored_retry.is_success()
|
||||
and restored_retry.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY
|
||||
and state.get_resource(&"gold") == gold_after_first,
|
||||
"restored idempotency history still rejects the same command"
|
||||
)
|
||||
_free_fixture(fixture)
|
||||
|
||||
|
||||
func _test_twenty_four_hour_actual_tick_smoke() -> void:
|
||||
var fixture := _new_fixture(73_004)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
var start_minute: int = clock.absolute_minute
|
||||
clock.advance_minutes(SimulationKernel.MINUTES_PER_DAY)
|
||||
_expect(
|
||||
clock.absolute_minute - start_minute == SimulationKernel.MINUTES_PER_DAY,
|
||||
"actual SimulationKernel ticks complete an uninterrupted 24-hour smoke slice"
|
||||
)
|
||||
_expect(
|
||||
_operations_slice_is_healthy(fixture)
|
||||
and runtime.operations_coordinator.current_minute == clock.absolute_minute
|
||||
and runtime.operations_coordinator.get_day_summaries().size() == 1,
|
||||
"24-hour actual-tick slice has no Choice, deferred minute, quarantine, or terminal stop"
|
||||
)
|
||||
var snapshot: Dictionary = runtime.create_snapshot()
|
||||
_expect(
|
||||
runtime.restore_snapshot(snapshot)
|
||||
and runtime.latest_digest == SimulationDigest.compute(snapshot),
|
||||
"24-hour actual-tick v6 snapshot round trip is exact"
|
||||
)
|
||||
_free_fixture(fixture)
|
||||
|
||||
|
||||
func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void:
|
||||
var fixture := _new_fixture(73_002, false)
|
||||
var runtime: Variant = fixture[&"runtime"]
|
||||
var clock: Variant = fixture[&"clock"]
|
||||
var state: Variant = fixture[&"state"]
|
||||
var candidate: Dictionary = runtime.get_mvp_staff_candidate_payload(&"staff_akane")
|
||||
var daily_wage := int(candidate[&"options"][&"daily_wage"])
|
||||
var hired: CommandResult = runtime.submit_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO},
|
||||
&"player",
|
||||
&"staff_akane",
|
||||
80,
|
||||
-1,
|
||||
&"operations.acceptance.hire"
|
||||
)
|
||||
_expect(hired.is_success() and daily_wage > 50, "payroll fixture hires one wage-bearing Staff member")
|
||||
var enabled: Dictionary = runtime.enable_production_operations()
|
||||
_expect(bool(enabled.get(&"ok", false)), "payroll fixture enables Operations after the pre-opening hire")
|
||||
|
||||
var full_advanced := _advance_operations_boundary_slice(
|
||||
fixture, SimulationKernel.MINUTES_PER_DAY
|
||||
)
|
||||
var full: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000001")
|
||||
_expect(
|
||||
full_advanced == SimulationKernel.MINUTES_PER_DAY
|
||||
and _operations_slice_is_healthy(fixture)
|
||||
and clock.absolute_minute == SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
||||
and int(full.get(&"new_wages_total", -1)) == daily_wage
|
||||
and int(full.get(&"paid_total", -1)) == daily_wage
|
||||
and int(full.get(&"unpaid_after_total", -1)) == 0,
|
||||
"Day 1 payroll is paid in full and closes at exactly 06:00"
|
||||
)
|
||||
|
||||
var cash_now: int = runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT)
|
||||
var drained: CommandResult = runtime.submit_command(
|
||||
&"change_resources",
|
||||
{&"deltas": {&"gold": -(cash_now - 50)}},
|
||||
&"player",
|
||||
&"",
|
||||
90,
|
||||
-1,
|
||||
&"operations.acceptance.cash-drain"
|
||||
)
|
||||
_expect(
|
||||
drained.is_success()
|
||||
and runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) == 50,
|
||||
"fixture leaves exactly 50 Gold for a deterministic partial payroll"
|
||||
)
|
||||
var partial_advanced := _advance_operations_boundary_slice(
|
||||
fixture, SimulationKernel.MINUTES_PER_DAY
|
||||
)
|
||||
var partial: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000002")
|
||||
_expect(
|
||||
partial_advanced == SimulationKernel.MINUTES_PER_DAY
|
||||
and _operations_slice_is_healthy(fixture)
|
||||
and clock.absolute_minute == 2 * SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
||||
and int(partial.get(&"new_wages_total", -1)) == daily_wage
|
||||
and int(partial.get(&"paid_total", -1)) == 50
|
||||
and int(partial.get(&"unpaid_after_total", -1)) == daily_wage - 50,
|
||||
"Day 2 payroll pays available Gold only and carries exact arrears"
|
||||
)
|
||||
|
||||
var terminal_advanced := _advance_operations_boundary_slice(
|
||||
fixture, 2 * SimulationKernel.MINUTES_PER_DAY
|
||||
)
|
||||
var coordinator: OperationsCoordinator = runtime.operations_coordinator
|
||||
var last_three_negative := true
|
||||
for day in [2, 3, 4]:
|
||||
var summary: Dictionary = coordinator.get_day_summary(day)
|
||||
last_three_negative = last_three_negative \
|
||||
and int(summary.get(&"net_profit", 0)) < 0 \
|
||||
and int((summary.get(&"settlement", {}) as Dictionary).get(
|
||||
&"arrears_total", 0
|
||||
)) > 0
|
||||
_expect(
|
||||
terminal_advanced == 2 * SimulationKernel.MINUTES_PER_DAY
|
||||
and clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
||||
and last_three_negative
|
||||
and coordinator.get_consecutive_insolvency_count() == 3
|
||||
and coordinator.get_terminal_state() == &"BANKRUPT",
|
||||
"three consecutive negative closes with arrears enter BANKRUPT at the third boundary"
|
||||
)
|
||||
var terminal_snapshot: Dictionary = runtime.create_snapshot()
|
||||
var terminal_digest := SimulationDigest.compute(terminal_snapshot)
|
||||
_expect(
|
||||
runtime.restore_snapshot(terminal_snapshot),
|
||||
"BANKRUPT terminal v6 snapshot restores without clearing terminal state"
|
||||
)
|
||||
clock.advance_minutes(180)
|
||||
_expect(
|
||||
clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60
|
||||
and SimulationDigest.compute(runtime.create_snapshot()) == terminal_digest,
|
||||
"BANKRUPT is terminal: later time requests are exact no-ops"
|
||||
)
|
||||
_expect(
|
||||
not bool(runtime.acknowledge_operations_pause().get(&"ok", true)),
|
||||
"BANKRUPT cannot be acknowledged away"
|
||||
)
|
||||
|
||||
_assert_v6_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99,
|
||||
"v6 rejects an out-of-range Operations action-point total"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"next_command_id"] = int(
|
||||
tampered[&"systems"][&"runtime"][&"next_command_id"]
|
||||
) + 1,
|
||||
"v6 rejects command high-water gaps"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
var accepted: Array = tampered[&"systems"][&"runtime"][&"command_queue"][&"accepted"]
|
||||
if not accepted.is_empty():
|
||||
accepted[0][&"idempotency_key"] = &"tampered.accepted.key",
|
||||
"v6 rejects accepted-command/idempotency cross-link tampering"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
var summaries: Array = tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"day_summaries"]
|
||||
summaries[0][&"category_totals"][&"wages"] = int(
|
||||
summaries[0][&"category_totals"][&"wages"]
|
||||
) + 1,
|
||||
"v6 rejects DaySummary/payroll wage reconciliation tampering"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"unexpected_v6_field"] = true,
|
||||
"v6 rejects unknown Runtime fields"
|
||||
)
|
||||
_assert_v6_tamper_rejected(
|
||||
runtime,
|
||||
terminal_snapshot,
|
||||
func(tampered: Dictionary) -> void:
|
||||
tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true,
|
||||
"v6 rejects unknown Coordinator fields"
|
||||
)
|
||||
_free_fixture(fixture)
|
||||
|
||||
|
||||
func _assert_v6_tamper_rejected(
|
||||
runtime: Variant,
|
||||
canonical: Dictionary,
|
||||
mutator: Callable,
|
||||
label: String
|
||||
) -> void:
|
||||
var before: Dictionary = runtime.create_snapshot()
|
||||
var tampered: Dictionary = canonical.duplicate(true)
|
||||
mutator.call(tampered)
|
||||
var rejected: bool = not runtime.restore_snapshot(tampered)
|
||||
_expect(rejected and runtime.create_snapshot() == before, label + " atomically")
|
||||
|
||||
|
||||
func _test_fourteen_day_boundary_snapshot_determinism() -> void:
|
||||
var seed := 73_003
|
||||
var continuous := _new_fixture(seed)
|
||||
var continuous_clock: Variant = continuous[&"clock"]
|
||||
var continuous_runtime: Variant = continuous[&"runtime"]
|
||||
var continuous_start: int = continuous_clock.absolute_minute
|
||||
var first_advanced := _advance_operations_boundary_slice(
|
||||
continuous, FOURTEEN_DAYS_MINUTES / 2
|
||||
)
|
||||
var midpoint: Dictionary = continuous_runtime.create_snapshot()
|
||||
_expect(
|
||||
first_advanced == FOURTEEN_DAYS_MINUTES / 2
|
||||
and continuous_clock.absolute_minute == continuous_start + FOURTEEN_DAYS_MINUTES / 2
|
||||
and _operations_slice_is_healthy(continuous),
|
||||
"boundary-accelerated midpoint is captured after seven healthy days"
|
||||
)
|
||||
var second_advanced := _advance_operations_boundary_slice(
|
||||
continuous, FOURTEEN_DAYS_MINUTES / 2
|
||||
)
|
||||
var continuous_snapshot: Dictionary = continuous_runtime.create_snapshot()
|
||||
var continuous_digest := SimulationDigest.compute(continuous_snapshot)
|
||||
_expect(
|
||||
first_advanced + second_advanced == FOURTEEN_DAYS_MINUTES
|
||||
and continuous_clock.absolute_minute - continuous_start == FOURTEEN_DAYS_MINUTES
|
||||
and _operations_slice_is_healthy(continuous),
|
||||
"continuous boundary-accelerated Operations advances exactly 14 days = 20,160 minutes"
|
||||
)
|
||||
_expect(
|
||||
continuous_runtime.operations_coordinator.get_day_summaries().size() == 14,
|
||||
"continuous boundary run commits all fourteen daily closes"
|
||||
)
|
||||
|
||||
var resumed := _new_fixture(seed, false)
|
||||
var resumed_clock: Variant = resumed[&"clock"]
|
||||
var resumed_runtime: Variant = resumed[&"runtime"]
|
||||
var restored: bool = resumed_runtime.restore_snapshot(midpoint)
|
||||
_expect(restored, "fresh Runtime restores the enabled v6 midpoint snapshot")
|
||||
var resumed_advanced := 0
|
||||
if restored:
|
||||
resumed_advanced = _advance_operations_boundary_slice(
|
||||
resumed, FOURTEEN_DAYS_MINUTES / 2
|
||||
)
|
||||
var resumed_snapshot: Dictionary = resumed_runtime.create_snapshot()
|
||||
_expect(
|
||||
restored and resumed_advanced == FOURTEEN_DAYS_MINUTES / 2
|
||||
and resumed_clock.absolute_minute == 6 * 60 + FOURTEEN_DAYS_MINUTES
|
||||
and _operations_slice_is_healthy(resumed),
|
||||
"restored boundary-accelerated Operations completes seven healthy days"
|
||||
)
|
||||
_expect(
|
||||
restored and SimulationDigest.compute(resumed_snapshot) == continuous_digest
|
||||
and resumed_snapshot == continuous_snapshot,
|
||||
"continuous and midpoint-restored 14-day boundary runs have identical snapshot and Digest"
|
||||
)
|
||||
_free_fixture(continuous)
|
||||
_free_fixture(resumed)
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://cmfsedkuddbhg
|
||||
@@ -0,0 +1,381 @@
|
||||
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("MVP: Runtime operations integration")
|
||||
_reset_runtime()
|
||||
_test_disabled_compatibility_and_enable()
|
||||
_test_enabled_snapshot_round_trip()
|
||||
_test_seed_424242_second_arrival_boundary()
|
||||
_test_after_mutation_notifications_are_atomic()
|
||||
_test_deferred_failure_backlog_and_exact_retry()
|
||||
_test_daily_payroll_financials_and_crosslink_tamper()
|
||||
_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 = 20260827) -> 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)
|
||||
_expect(bool(report.get(&"valid", false)), "Runtime initializes before Operations opt-in")
|
||||
_clock.set_paused(true)
|
||||
|
||||
|
||||
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_disabled_compatibility_and_enable() -> void:
|
||||
var disabled: Dictionary = _runtime.get_operations_report()
|
||||
_expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests")
|
||||
var disabled_snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var disabled_runtime: Dictionary = disabled_snapshot[&"systems"][&"runtime"]
|
||||
_expect(int(disabled_runtime[&"runtime_schema_version"]) == 6, "Runtime snapshot advances to schema v6")
|
||||
_expect(
|
||||
disabled_runtime[&"operations"] == {
|
||||
&"wrapper_version": 1,
|
||||
&"enabled": false,
|
||||
&"personal_action_points": 0,
|
||||
&"runtime_failure_latched": false,
|
||||
&"runtime_failure_report": {},
|
||||
&"deferred_runtime_minute": -1,
|
||||
&"coordinator": {},
|
||||
},
|
||||
"disabled Operations wrapper has one canonical shape"
|
||||
)
|
||||
var enabled: Dictionary = _runtime.enable_production_operations()
|
||||
var enabled_ok := bool(enabled.get(&"ok", false))
|
||||
_expect(enabled_ok, "Production Operations opt in succeeds only at Day 1 06:00")
|
||||
if not enabled_ok:
|
||||
print(" enable diagnostic: %s" % JSON.stringify(enabled, "", true, true))
|
||||
return
|
||||
_expect(
|
||||
_runtime.operations_coordinator.current_minute == 360,
|
||||
"enable processes the 06:00 boundary from minute 359 exactly once"
|
||||
)
|
||||
_expect(
|
||||
int(_runtime.get_operations_report()[&"personal_action_points"]) == 2,
|
||||
"first morning boundary restores two personal action points"
|
||||
)
|
||||
|
||||
|
||||
func _test_enabled_snapshot_round_trip() -> void:
|
||||
if _runtime.operations_coordinator == null:
|
||||
return
|
||||
var snapshot: Dictionary = _runtime.create_snapshot()
|
||||
var expected_digest := SimulationDigest.compute(snapshot)
|
||||
_expect(_runtime.restore_snapshot(snapshot), "enabled Runtime v5 snapshot restores")
|
||||
_expect(_runtime.latest_digest == expected_digest, "enabled Runtime v5 round trip is exact")
|
||||
var operations: Dictionary = _runtime.get_operations_report()
|
||||
_expect(
|
||||
operations[&"enabled"] and _runtime.operations_coordinator.current_minute == _clock.absolute_minute,
|
||||
"restored Coordinator clock cross-link remains exact"
|
||||
)
|
||||
|
||||
|
||||
func _test_seed_424242_second_arrival_boundary() -> void:
|
||||
_reset_runtime(424242)
|
||||
var enabled: Dictionary = _runtime.enable_production_operations()
|
||||
_expect(bool(enabled.get(&"ok", false)), "seed 424242 enables production Operations")
|
||||
if not bool(enabled.get(&"ok", false)):
|
||||
return
|
||||
var before_count: int = _runtime.guest_service.guest_count()
|
||||
_clock.advance_minutes(30)
|
||||
var report: Dictionary = _runtime.get_operations_report()
|
||||
_expect(
|
||||
_clock.absolute_minute == 390 \
|
||||
and int(report.get(&"deferred_runtime_minute", -2)) == -1 \
|
||||
and not bool(report.get(&"runtime_failure_latched", true)) \
|
||||
and not _runtime.operations_coordinator.is_external_quarantined(),
|
||||
"seed 424242 completes the minute 390 arrival boundary without quarantine"
|
||||
)
|
||||
_expect(
|
||||
_runtime.operations_coordinator.get_population_count() \
|
||||
== _runtime.guest_service.guest_count() \
|
||||
and _runtime.guest_service.guest_count() >= before_count,
|
||||
"second arrival keeps Coordinator and Guest population cross-linked"
|
||||
)
|
||||
|
||||
|
||||
func _test_after_mutation_notifications_are_atomic() -> void:
|
||||
_reset_runtime(424242)
|
||||
_expect(
|
||||
bool(_runtime.enable_production_operations().get(&"ok", false)),
|
||||
"after-mutation rollback fixture enables Operations"
|
||||
)
|
||||
_clock.advance_minutes(29)
|
||||
var before := {
|
||||
&"economy": _runtime.economy_service.create_snapshot(),
|
||||
&"guests": _runtime.guest_service.create_snapshot(),
|
||||
&"path": _runtime.path_service.create_snapshot(),
|
||||
&"movement": _runtime._movement_credits.duplicate(true),
|
||||
&"progression": _runtime.progression_service.create_snapshot(),
|
||||
&"events": _runtime.domain_events.create_snapshot(),
|
||||
&"ids": _runtime.id_generator.create_snapshot(),
|
||||
}
|
||||
var signals := {&"guest": 0, &"economy": 0, &"arrival": 0}
|
||||
_runtime.guest_service.guest_spawned.connect(
|
||||
func(_guest: GuestRuntime) -> void: signals[&"guest"] += 1
|
||||
)
|
||||
_runtime.economy_service.transaction_posted.connect(
|
||||
func(_transaction_id: StringName, _entries: Array) -> void:
|
||||
signals[&"economy"] += 1
|
||||
)
|
||||
_runtime.operations_coordinator.arrivals_committed.connect(
|
||||
func(_report: Dictionary) -> void: signals[&"arrival"] += 1
|
||||
)
|
||||
_runtime.set_operations_test_boundary_hook(
|
||||
&"ARRIVAL_PLAN",
|
||||
func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary:
|
||||
return {
|
||||
&"ok": phase != _runtime.OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE,
|
||||
&"reason": &"FORCED_AFTER_MUTATION_FAILURE",
|
||||
}
|
||||
)
|
||||
_clock.advance_minutes(1)
|
||||
var after := {
|
||||
&"economy": _runtime.economy_service.create_snapshot(),
|
||||
&"guests": _runtime.guest_service.create_snapshot(),
|
||||
&"path": _runtime.path_service.create_snapshot(),
|
||||
&"movement": _runtime._movement_credits.duplicate(true),
|
||||
&"progression": _runtime.progression_service.create_snapshot(),
|
||||
&"events": _runtime.domain_events.create_snapshot(),
|
||||
&"ids": _runtime.id_generator.create_snapshot(),
|
||||
}
|
||||
_expect(
|
||||
_clock.absolute_minute == 390 and _clock.paused and before == after,
|
||||
"failure after the first arrival mutation restores every external domain exactly"
|
||||
)
|
||||
_expect(
|
||||
int(signals[&"guest"]) == 0 and int(signals[&"economy"]) == 0 \
|
||||
and int(signals[&"arrival"]) == 0,
|
||||
"rolled-back arrival leaks no Guest, Economy, or Coordinator success signal"
|
||||
)
|
||||
_runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", Callable())
|
||||
var retried: Dictionary = _runtime.acknowledge_operations_pause()
|
||||
_expect(
|
||||
bool(retried.get(&"ok", false)) and int(signals[&"guest"]) > 0 \
|
||||
and int(signals[&"economy"]) > 0 and int(signals[&"arrival"]) == 1,
|
||||
"successful retry flushes each staged service notification after commit"
|
||||
)
|
||||
|
||||
|
||||
func _test_deferred_failure_backlog_and_exact_retry() -> void:
|
||||
_reset_runtime(424242)
|
||||
_expect(
|
||||
bool(_runtime.enable_production_operations().get(&"ok", false)),
|
||||
"deferred retry fixture enables Operations"
|
||||
)
|
||||
_clock.advance_minutes(29)
|
||||
var scheduled: CommandResult = _runtime.submit_command(
|
||||
&"spawn_guest",
|
||||
{
|
||||
&"guest_id": &"scheduled_boundary_guest",
|
||||
&"guest_type_id": _runtime.get_mvp_guest_definition_ids()[0],
|
||||
&"gold": 25,
|
||||
&"options": {
|
||||
&"current_cell": Vector3i.ZERO,
|
||||
&"planned_exit_at": 2_000,
|
||||
},
|
||||
},
|
||||
&"test", &"scheduled_boundary_guest", 100, 390,
|
||||
&"operations.test.scheduled.spawn"
|
||||
)
|
||||
_expect(scheduled.is_success(), "minute 390 command is queued before the boundary")
|
||||
var counts := {&"guest": 0, &"arrival": 0, &"hook": 0}
|
||||
_runtime.guest_service.guest_spawned.connect(
|
||||
func(_guest: GuestRuntime) -> void: counts[&"guest"] += 1
|
||||
)
|
||||
_runtime.operations_coordinator.arrivals_committed.connect(
|
||||
func(_report: Dictionary) -> void: counts[&"arrival"] += 1
|
||||
)
|
||||
_runtime.set_operations_test_boundary_hook(
|
||||
&"ARRIVAL_PLAN",
|
||||
func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary:
|
||||
counts[&"hook"] += 1
|
||||
return {
|
||||
&"ok": false,
|
||||
&"reason": &"FORCED_PREPARE_FAILURE",
|
||||
&"phase": phase,
|
||||
}
|
||||
)
|
||||
_clock.set_speed(4)
|
||||
var processed: int = _clock.process_real_delta(5.0)
|
||||
var failed_report: Dictionary = _runtime.get_operations_report()
|
||||
_expect(
|
||||
processed == 1 and _clock.absolute_minute == 390 and _clock.paused,
|
||||
"boundary failure stops an eight-tick backlog at the failed minute"
|
||||
)
|
||||
_expect(
|
||||
int(failed_report[&"deferred_runtime_minute"]) == 390 \
|
||||
and failed_report[&"runtime_failure_latched"] \
|
||||
and _runtime.operations_coordinator.current_minute == 389,
|
||||
"failed boundary persists one canonical deferred shape"
|
||||
)
|
||||
_expect(
|
||||
_runtime.guest_service.has_guest(&"scheduled_boundary_guest") \
|
||||
and int(counts[&"guest"]) == 1 \
|
||||
and int(counts[&"arrival"]) == 0,
|
||||
"pre-boundary command commits once while failed callback emits no arrival notification"
|
||||
)
|
||||
var interrupted: Dictionary = _runtime.create_snapshot()
|
||||
_expect(
|
||||
_runtime.restore_snapshot(interrupted),
|
||||
"deferred v5 snapshot restores before acknowledgement"
|
||||
)
|
||||
_runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", Callable())
|
||||
var retry_guest_signals := {&"count": 0}
|
||||
_runtime.guest_service.guest_spawned.connect(
|
||||
func(_guest: GuestRuntime) -> void: retry_guest_signals[&"count"] += 1
|
||||
)
|
||||
var acknowledged: Dictionary = _runtime.acknowledge_operations_pause()
|
||||
_expect(
|
||||
bool(acknowledged.get(&"ok", false)) \
|
||||
and bool(acknowledged.get(&"suffix_replayed", false)) \
|
||||
and _clock.absolute_minute == 390 \
|
||||
and _runtime.operations_coordinator.current_minute == 390,
|
||||
"acknowledgement retries the same boundary and suffix exactly once"
|
||||
)
|
||||
var completion_count := 0
|
||||
for event: SimulationDomainEvent in _runtime.domain_events.recent_events():
|
||||
if event.event_type == &"COMMAND_COMPLETED" \
|
||||
and event.causation_command_id == scheduled.command_id:
|
||||
completion_count += 1
|
||||
_expect(
|
||||
completion_count == 1 \
|
||||
and _runtime.command_queue.processed_count() == 1 \
|
||||
and _runtime.guest_service.current_minute == 390 \
|
||||
and int(retry_guest_signals[&"count"]) \
|
||||
== _runtime.operations_coordinator.get_arrival_reports()[-1][&"total_count"],
|
||||
"retry never replays the command and advances the skipped Runtime suffix once"
|
||||
)
|
||||
var completed: Dictionary = _runtime.create_snapshot()
|
||||
_expect(_runtime.restore_snapshot(completed), "completed deferred retry round-trips exactly")
|
||||
|
||||
|
||||
func _test_daily_payroll_financials_and_crosslink_tamper() -> void:
|
||||
_reset_runtime(811_004)
|
||||
var hired: CommandResult = _runtime.submit_command(
|
||||
&"hire_staff_candidate",
|
||||
{&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO},
|
||||
&"player", &"staff_akane", 100, -1, &"operations.payroll.hire.akane"
|
||||
)
|
||||
_expect(hired.is_success(), "daily payroll fixture hires one catalog Staff member")
|
||||
_expect(
|
||||
bool(_runtime.enable_production_operations().get(&"ok", false)),
|
||||
"daily payroll fixture enables Operations after hiring"
|
||||
)
|
||||
_clock.advance_minutes(1_440)
|
||||
var summary: Dictionary = _runtime.operations_coordinator.get_day_summary(1)
|
||||
var settlement: Dictionary = summary.get(&"settlement", {})
|
||||
_expect(
|
||||
_clock.absolute_minute == 1_800 \
|
||||
and int(summary.get(&"closed_at", -1)) == 1_800 \
|
||||
and int(summary[&"category_totals"][&"wages"]) == 120,
|
||||
"06:00 closes Day 1 once and accrues only the new daily wage"
|
||||
)
|
||||
_expect(
|
||||
int(settlement.get(&"paid_total", -1)) == 120 \
|
||||
and int(settlement.get(&"arrears_total", -1)) == 0 \
|
||||
and _runtime.staff_service.get_staff(&"staff_akane").unpaid_wages == 0,
|
||||
"sufficient Gold pays the full lexical payroll with no arrears"
|
||||
)
|
||||
var wage_transaction: StringName = _runtime.economy_service.get_transaction_id_for_idempotency_key(
|
||||
&"operations.wage.day.00000001"
|
||||
)
|
||||
var wage_entries: Array[LedgerEntry] = _runtime.economy_service.get_transaction_entries(wage_transaction)
|
||||
_expect(
|
||||
wage_entries.size() == 1 \
|
||||
and wage_entries[0].category == &"WAGE" \
|
||||
and wage_entries[0].absolute_minute == 1_800 \
|
||||
and wage_entries[0].amount == 120,
|
||||
"paid payroll posts one canonical WAGE ledger line at the closing boundary"
|
||||
)
|
||||
var canonical: Dictionary = _runtime.create_snapshot()
|
||||
_expect(_runtime.restore_snapshot(canonical), "Day 1 payroll/P&L v5 snapshot round-trips")
|
||||
|
||||
var signing_category: Dictionary = canonical.duplicate(true)
|
||||
for raw_entry: Dictionary in signing_category[&"systems"][&"runtime"][&"economy"][&"entries"]:
|
||||
if String(raw_entry[&"category"]) == "STAFF_SIGNING_COST":
|
||||
raw_entry[&"category"] = "TRANSFER"
|
||||
break
|
||||
_expect(
|
||||
_rejects_atomically(signing_category),
|
||||
"closed non-WAGE ledger category tamper is rejected atomically"
|
||||
)
|
||||
var wage_category: Dictionary = canonical.duplicate(true)
|
||||
for raw_entry: Dictionary in wage_category[&"systems"][&"runtime"][&"economy"][&"entries"]:
|
||||
if String(raw_entry[&"category"]) == "WAGE":
|
||||
raw_entry[&"category"] = "TRANSFER"
|
||||
break
|
||||
_expect(
|
||||
_rejects_atomically(wage_category),
|
||||
"Staff settlement cannot outlive its canonical WAGE ledger evidence"
|
||||
)
|
||||
var lifetime: Dictionary = canonical.duplicate(true)
|
||||
lifetime[&"systems"][&"runtime"][&"progression"][&"metrics"][&"guest_lifetime"] += 1
|
||||
_expect(
|
||||
_rejects_atomically(lifetime),
|
||||
"Progression lifetime Guest inflation is rejected against active plus persistent Guests"
|
||||
)
|
||||
var plan_class: Dictionary = canonical.duplicate(true)
|
||||
var arrival_reports: Array = plan_class[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"arrival_reports"]
|
||||
var changed_plan := false
|
||||
for report: Dictionary in arrival_reports:
|
||||
if not (report[&"plans"] as Array).is_empty():
|
||||
var plan: Dictionary = report[&"plans"][0]
|
||||
plan[&"guest_class"] = "noble" if String(plan[&"guest_class"]) != "noble" else "laborer"
|
||||
changed_plan = true
|
||||
break
|
||||
_expect(
|
||||
changed_plan and _rejects_atomically(plan_class),
|
||||
"arrival plan class cannot diverge from its persisted Guest definition"
|
||||
)
|
||||
var downgraded: Dictionary = canonical.duplicate(true)
|
||||
downgraded[&"systems"][&"runtime"][&"runtime_schema_version"] = 4
|
||||
_expect(
|
||||
_rejects_atomically(downgraded),
|
||||
"relabelled enabled v5 cannot discard Operations history through v4 migration"
|
||||
)
|
||||
|
||||
|
||||
func _rejects_atomically(candidate: Dictionary) -> bool:
|
||||
var before: Dictionary = _runtime.create_snapshot()
|
||||
return not _runtime.restore_snapshot(candidate) \
|
||||
and _runtime.create_snapshot() == before
|
||||
|
||||
|
||||
func _expect(condition: bool, label: String) -> void:
|
||||
_checks += 1
|
||||
if condition:
|
||||
print(" ✓ %s" % label)
|
||||
return
|
||||
_failures += 1
|
||||
push_error(" ✗ %s" % label)
|
||||
@@ -0,0 +1 @@
|
||||
uid://dxchxjl371ti
|
||||
Reference in New Issue
Block a user