diff --git a/Makefile b/Makefile index 0c88048..76009c4 100644 --- a/Makefile +++ b/Makefile @@ -40,6 +40,7 @@ test-s1: $(GODOT) --headless --path . --script res://tests/s1/test_original_facility_dimensions.gd $(GODOT) --headless --path . --script res://tests/s1/test_world_service.gd $(GODOT) --headless --path . --script res://tests/s1/test_path_service.gd + $(GODOT) --headless --path . --script res://tests/s1/test_path_snapshot_transit_guard.gd $(GODOT) --headless --path . --script res://tests/s1/test_path_congestion.gd $(GODOT) --headless --path . --script res://tests/s1/test_construction_service.gd $(GODOT) --headless --path . --script res://tests/s1/test_excavation_service.gd @@ -86,6 +87,9 @@ test-s5: test-s6: $(GODOT) --headless --path . --script res://tests/s6/test_save_operations.gd + $(GODOT) --headless --path . --script res://tests/s6/test_save_preflight.gd + $(GODOT) --headless --path . --script res://tests/s6/test_save_recovery_preservation.gd + $(GODOT) --headless --path . --script res://tests/s6/test_runtime_snapshot_serialization.gd $(GODOT) --headless --path . --script res://tests/s6/test_performance_budget.gd $(GODOT) --headless --path . --script res://tests/s6/test_runtime_save_lifecycle.gd @@ -131,6 +135,7 @@ test-ui: $(GODOT) --headless --path . --script res://tests/ui/test_main_dashboard.gd $(GODOT) --headless --path . --script res://tests/ui/test_staff_management.gd $(GODOT) --headless --path . --script res://tests/ui/test_supply_management.gd + $(GODOT) --headless --path . --script res://tests/ui/test_supply_auto_purchase_ui.gd $(GODOT) --headless --path . --script res://tests/ui/test_business_management.gd $(GODOT) --headless --path . --script res://tests/ui/test_elevator_management.gd $(MAKE) test-opening @@ -183,6 +188,10 @@ test-supply: $(GODOT) --headless --path . --script res://tests/supply/test_supply_save_migration.gd $(GODOT) --headless --path . --script res://tests/supply/test_supply_logistics_bridge.gd $(GODOT) --headless --path . --script res://tests/supply/test_supply_management_service.gd + $(GODOT) --headless --path . --script res://tests/supply/test_supply_auto_purchase.gd + $(GODOT) --headless --path . --script res://tests/supply/test_normal_auto_purchase_chain.gd + $(GODOT) --headless --path . --script res://tests/supply/test_supply_auto_purchase_boundaries.gd + $(GODOT) --headless --path . --script res://tests/supply/test_supply_auto_purchase_destruction.gd $(GODOT) --headless --path . --script res://tests/supply/test_normal_supply_chain.gd capture-supply: diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index 4047725..8fc1a2d 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -19,7 +19,10 @@ const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE" const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE" const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE" const WAGE_EXPENSE_ACCOUNT := &"WAGE_EXPENSE" -const RUNTIME_SNAPSHOT_VERSION := 13 +const RUNTIME_SNAPSHOT_VERSION := 14 +const AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION := 14 +const EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION := 13 +const AutoPurchasePlansType := preload("res://scripts/supply/supply_auto_purchase_plans.gd") const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12 const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11 const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd") @@ -110,6 +113,7 @@ var logistics_dispatcher := LogisticsDispatcher.new() var logistics_requests: Dictionary = {} var kitchen_production := KitchenProductionService.new() var supply_management := SupplyManagementService.new() +var auto_purchase_plans := AutoPurchasePlansType.new() var supply_logistics := SupplyLogisticsBridge.new() var elevators := ElevatorService.new() var legacy_stairs := LegacyStairService.new() @@ -300,6 +304,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S register_command_executor(&"complete_progression_quest", Callable(self, "_execute_complete_progression_quest")) register_command_executor(&"acknowledge_operations_pause", Callable(self, "_execute_acknowledge_operations_pause")) register_command_executor(&"purchase_supplies", Callable(self, "_execute_purchase_supplies")) + register_command_executor(&"set_supply_auto_purchase", Callable(self, "_execute_set_supply_auto_purchase")) register_command_executor(&"set_kitchen_production", Callable(self, "_execute_set_kitchen_production")) register_command_executor(&"add_inventory_stack", Callable(self, "_execute_add_inventory_stack")) register_command_executor(&"create_logistics_request", Callable(self, "_execute_create_logistics_request")) @@ -502,9 +507,7 @@ func get_rng_stream(stream_id: StringName) -> DeterministicRng: func create_snapshot() -> Dictionary: var rng_data := {} - var stream_ids: Array = _rng_streams.keys() - stream_ids.sort() - for stream_id: Variant in stream_ids: + for stream_id: StringName in _sorted_name_keys(_rng_streams): var stream: DeterministicRng = _rng_streams[stream_id] rng_data[String(stream_id)] = stream.snapshot() var owned_systems := { @@ -531,6 +534,7 @@ func create_snapshot() -> Dictionary: &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(), + &"auto_purchase_plans": auto_purchase_plans.create_snapshot(), &"elevators": elevators.create_snapshot(), &"legacy_stairs": legacy_stairs.create_snapshot(), &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), @@ -610,6 +614,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo UPKEEP_RUNTIME_SNAPSHOT_VERSION, WEATHER_RUNTIME_SNAPSHOT_VERSION, CLEANING_RUNTIME_SNAPSHOT_VERSION, + EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION, RUNTIME_SNAPSHOT_VERSION, ]: return false @@ -649,6 +654,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo runtime_current_keys.append(&"weather") if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append(&"facility_cleanliness") + if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append(&"auto_purchase_plans") if not _dictionary_has_exact_canonical_keys(runtime_data, runtime_current_keys) \ or typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \ or typeof(runtime_data.get(&"world_seed", null)) != TYPE_INT \ @@ -688,6 +695,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"legacy_stairs", null) is Dictionary: return false + if runtime_schema_version < AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"auto_purchase_plans"): + return false + if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"auto_purchase_plans", null) is Dictionary: + return false var restored_catalog := MvpContentCatalog.new() var restored_catalog_report := restored_catalog.rebuild() if not bool(restored_catalog_report.get(&"valid", false)): @@ -738,6 +749,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo var restored_queue := CommandQueue.new() if not restored_queue.restore_snapshot(runtime_data[&"command_queue"]): return false + if runtime_schema_version < AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION: + # Old saves must not reinterpret an unsupported queued action as a new + # paid feature when loading into this release. Failed historical actions + # remain ordinary recorded history; only resumable pending work is gated. + for row: Dictionary in restored_queue.create_snapshot()["pending"]: + if row["command_type"] == "set_supply_auto_purchase": + return false var restored_id_generator := StableIdGenerator.new() if not restored_id_generator.restore_snapshot(runtime_data[&"id_generator"]): return false @@ -1203,6 +1221,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false if restored_kitchen.current_minute != restored_runtime_domain_minute or not restored_kitchen.validate_crosslinks(): return false + var restored_auto_purchase := AutoPurchasePlansType.new() + if runtime_schema_version >= AUTO_PURCHASE_RUNTIME_SNAPSHOT_VERSION: + if not restored_auto_purchase.restore_snapshot(runtime_data[&"auto_purchase_plans"], restored_facilities, restored_economy, restored_runtime_domain_minute): + return false if not SupplyLogisticsBridge.validate_crosslinks(restored_logistics, restored_staff, restored_inventory, restored_facilities): return false var restored_weather := WeatherTimeline.new() @@ -1271,6 +1293,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo economy_service = restored_economy inventory_service = restored_inventory kitchen_production = restored_kitchen + auto_purchase_plans = restored_auto_purchase logistics_dispatcher = restored_dispatcher logistics_requests = restored_logistics facility_service = restored_facilities @@ -1966,6 +1989,7 @@ func _build_operations_external_state() -> Dictionary: &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(), + &"auto_purchase_plans": auto_purchase_plans.create_snapshot(), &"facilities": facility_service.create_snapshot(), &"facility_cleanliness": facility_cleanliness.create_snapshot(), &"guests": guest_service.create_snapshot(), @@ -2023,7 +2047,7 @@ func _restore_operations_external_state(token: Variant) -> bool: var saved: Dictionary = token var required_dictionary_keys: Array[StringName] = [ &"facility_upkeep", &"upkeep_settlement", &"facility_cleanliness", - &"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"facilities", &"guests", + &"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"auto_purchase_plans", &"facilities", &"guests", &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"legacy_stairs", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", @@ -2066,6 +2090,7 @@ func _restore_operations_external_state(token: Variant) -> bool: var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"]) facilities_ok = facility_cleanliness.restore_snapshot(saved.get(&"facility_cleanliness", {})) and facilities_ok var kitchen_ok := kitchen_production.restore_snapshot(saved[&"kitchen_production"]) + var auto_purchase_ok := auto_purchase_plans.restore_snapshot(saved[&"auto_purchase_plans"], facility_service, economy_service, int(saved[&"captured_minute"])) var guests_ok := guest_service.restore_snapshot(saved[&"guests"]) var staff_ok := staff_service.restore_snapshot(saved[&"staff"]) var relationships_ok := relationship_service.restore_snapshot(saved[&"relationships"]) @@ -2116,7 +2141,7 @@ func _restore_operations_external_state(token: Variant) -> bool: rng_ok = false _discard_operations_domain_events() - var participants_ok := upkeep_ok and state_ok and command_queue_ok and economy_ok and inventory_ok and kitchen_ok \ + var participants_ok := upkeep_ok and state_ok and command_queue_ok and economy_ok and inventory_ok and kitchen_ok and auto_purchase_ok \ and facilities_ok and guests_ok and staff_ok and relationships_ok \ and events_ok and authority_ok and world_ok and spatial_ok and path_ok \ and route_ok and infrastructure_ok and excavation_ok \ @@ -2173,6 +2198,7 @@ func _operations_external_state_matches(saved: Dictionary) -> bool: or not _strict_variants_equal(economy_service.create_snapshot(), saved[&"economy"]) \ or not _strict_variants_equal(inventory_service.create_snapshot(), saved[&"inventory"]) \ or not _strict_variants_equal(kitchen_production.create_snapshot(), saved[&"kitchen_production"]) \ + or not _strict_variants_equal(auto_purchase_plans.create_snapshot(), saved[&"auto_purchase_plans"]) \ or not _strict_variants_equal(facility_service.create_snapshot(), saved[&"facilities"]) \ or not _strict_variants_equal(facility_cleanliness.create_snapshot(), saved.get(&"facility_cleanliness", {})) \ or not _strict_variants_equal(guest_service.create_snapshot(), saved[&"guests"]) \ @@ -5081,6 +5107,7 @@ func _initialize_s2_operations() -> void: facility_service.current_minute = _clock.absolute_minute _configure_facility_boundaries() _connect_facility_signals() + auto_purchase_plans = AutoPurchasePlansType.new() kitchen_production = KitchenProductionService.new() kitchen_production.configure(inventory_service, facility_service, definition_registry, _clock.absolute_minute) @@ -7541,6 +7568,7 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: result.command_id = command.command_id if result.is_success(): kitchen_production.remove_plan(instance_id) + auto_purchase_plans.remove_warehouse(instance_id) return result @@ -8184,6 +8212,7 @@ func _advance_inventory_and_logistics(absolute_minute: int) -> void: inventory_service.current_minute = absolute_minute inventory_service.process_expired(absolute_minute, &"inventory.waste") if int(_state.get_resource(&"mana")) > 0: + supply_management.advance_auto_purchases(self, absolute_minute) supply_management.ensure_deliveries(self, absolute_minute) supply_logistics.tick(absolute_minute) @@ -12138,6 +12167,10 @@ func _execute_purchase_supplies(command: SimulationCommand) -> CommandResult: return supply_management.purchase(self, command) +func _execute_set_supply_auto_purchase(command: SimulationCommand) -> CommandResult: + return supply_management.set_auto_purchase(self, command) + + func _execute_set_kitchen_production(command: SimulationCommand) -> CommandResult: return supply_management.set_production(self, command) @@ -12965,7 +12998,7 @@ func _prepare_construction_dimension_snapshot(job_id: StringName) -> Dictionary: ## Read-only saved-game preflight used by the load menu before confirmation. func preview_facility_dimensions_at_path(save_path: String) -> Dictionary: - var loaded := SaveService.load_snapshot(save_path) + var loaded := SaveService.inspect_snapshot(save_path) if not loaded[&"ok"]: return loaded var isolated: Variant = get_script().new() var preview_clock: Variant = load("res://autoload/game_clock.gd").new() diff --git a/scripts/core/save_service.gd b/scripts/core/save_service.gd index 69d6ed8..01439f0 100644 --- a/scripts/core/save_service.gd +++ b/scripts/core/save_service.gd @@ -14,6 +14,10 @@ const AUTOSAVE_SUFFIX := ".autosave." const MIGRATION_BACKUP_SUFFIX := ".pre_migration.bak" const SAVE_TRANSACTION_SUFFIX := ".save_transaction" const SAVE_TRANSACTION_MARKER := "THE_TREE_FORMAL_SAVE_V1\n" +# Technical resource limits, independent of gameplay/content values. Check +# authoritative file lengths and advertised expansion before allocating reads. +const MAX_WORLD_BYTES := 256 * 1024 * 1024 +const MAX_MANIFEST_BYTES := 4 * 1024 * 1024 static var _performance_budget := SimulationPerformanceBudget.new() @@ -48,6 +52,19 @@ static func load_snapshot(save_path: String) -> Dictionary: return _complete_timed_operation(result, &"load", started_at_us) +## Reads only the named formal package. Unlike load_snapshot, this never +## reconciles, promotes, removes or migrates interrupted-save artifacts. A +## missing formal package stays missing even when a .tmp/.bak could recover it. +static func inspect_snapshot(save_path: String) -> Dictionary: + var started_at_us := Time.get_ticks_usec() + var resolved := _resolve_safe_save_path(save_path) + var result: Dictionary = resolved + if resolved[&"ok"]: + result = _load_snapshot_absolute(resolved[&"path"], FORMAT_VERSION) + result[&"read_only"] = true + return _complete_timed_operation(result, &"load", started_at_us) + + static func get_performance_report(metric_id: StringName) -> Dictionary: return _performance_budget.get_report(metric_id) @@ -314,9 +331,11 @@ static func _load_recovery_candidate_impl( if not resolved[&"ok"]: return resolved var target_path: String = resolved[&"path"] - var formal_reconcile := _reconcile_interrupted_formal_save(target_path) - if not formal_reconcile[&"ok"]: - return formal_reconcile + # Explicit candidate selection is a read-only operation. Normal load and + # load_with_recovery retain transaction reconciliation at their own entry. + var formal_guard := _guard_identified_formal_format(target_path) + if not formal_guard[&"ok"]: + return formal_guard var candidate_path := "" match candidate_id: &"formal": @@ -393,12 +412,29 @@ static func _migrate_save_impl( if not resolved[&"ok"]: return resolved var target_path: String = resolved[&"path"] - var formal_reconcile := _reconcile_interrupted_formal_save(target_path) - if not formal_reconcile[&"ok"]: - return formal_reconcile - var reconcile := _reconcile_interrupted_migration(target_path) - if not reconcile[&"ok"]: - return reconcile + var formal_guard := _guard_identified_formal_format(target_path) + var migrating_registered_legacy := false + if not formal_guard[&"ok"]: + if formal_guard[&"code"] != &"UNSUPPORTED_FORMAT": return formal_guard + var identified_version := int(formal_guard[&"identified_format_version"]) + if identified_version > target_version: + return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [identified_version, target_version]) + if registry == null or not replacement_snapshot.is_empty(): + return formal_guard + if target_version != FORMAT_VERSION or identified_version >= target_version \ + or formal_guard[&"identified_format_versions"].size() != 1: + return formal_guard + for version in range(identified_version, target_version): + if not registry.has_step(version): + return _failure(&"MIGRATION_STEP_MISSING", "missing adjacent migration %d -> %d" % [version, version + 1]) + # A registered old formal source is decoded/migrated directly. Do not + # reconcile stale normal/migration artifacts before source validation. + migrating_registered_legacy = true + if not migrating_registered_legacy: + var formal_reconcile := _reconcile_interrupted_formal_save(target_path) + if not formal_reconcile[&"ok"]: return formal_reconcile + var reconcile := _reconcile_interrupted_migration(target_path) + if not reconcile[&"ok"]: return reconcile var operation_paths: Array[String] = [ target_path, target_path + ".migration.tmp", @@ -734,10 +770,16 @@ static func _write_package_directory( if make_error != OK: return _failure(&"CREATE_DIRECTORY_FAILED", error_string(make_error)) var raw_world := var_to_bytes(snapshot) + if not _world_size_is_supported(raw_world.size()): + _remove_tree(package_path) + return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "uncompressed world exceeds technical resource limit") var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD) if compressed_world.is_empty() and not raw_world.is_empty(): _remove_tree(package_path) return _failure(&"COMPRESSION_FAILED", WORLD_FILE) + if not _world_size_is_supported(compressed_world.size()): + _remove_tree(package_path) + return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "compressed world exceeds technical resource limit") var checksum := _sha256(compressed_world) var world_error := _write_world( package_path.path_join(WORLD_FILE), @@ -775,7 +817,11 @@ static func _write_package_directory( continue if not manifest.has(key): manifest[key] = metadata[key] - var manifest_error := _write_text(package_path.path_join(MANIFEST_FILE), JSON.stringify(manifest, "\t")) + var manifest_text := JSON.stringify(manifest, "\t") + if manifest_text.to_utf8_buffer().size() > MAX_MANIFEST_BYTES: + _remove_tree(package_path) + return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "manifest exceeds technical resource limit") + var manifest_error := _write_text(package_path.path_join(MANIFEST_FILE), manifest_text) if manifest_error != OK: _remove_tree(package_path) return _failure(&"WRITE_MANIFEST_FAILED", error_string(manifest_error)) @@ -813,15 +859,23 @@ static func _load_snapshot_absolute( var manifest_file := FileAccess.open(manifest_path, FileAccess.READ) if manifest_file == null: return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error())) + if manifest_file.get_length() > MAX_MANIFEST_BYTES: + return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "manifest exceeds technical resource limit") var manifest_text := manifest_file.get_as_text() manifest_file = null - var manifest_value: Variant = JSON.parse_string(manifest_text) + var parser := JSON.new() + if parser.parse(manifest_text) != OK: + return _failure(&"INVALID_MANIFEST", manifest_path) + var manifest_value: Variant = parser.data if not manifest_value is Dictionary: return _failure(&"INVALID_MANIFEST", manifest_path) var manifest: Dictionary = manifest_value - var manifest_version := int(manifest.get("format_version", manifest.get(&"format_version", -1))) - if manifest_version < 0: - return _failure(&"INVALID_MANIFEST", "missing format_version") + var version_value: Variant = manifest.get("format_version", -1) + # Godot JSON decodes numbers as floats. Permit exact integral JSON numbers, + # while rejecting strings, booleans and fractions instead of coercing them. + if not _is_integral_json_number(version_value, 0, 0xffffffff): + return _failure(&"INVALID_MANIFEST", "format_version must be an unsigned integer") + var manifest_version := int(version_value) if expected_format_version >= 0 and manifest_version != expected_format_version: return _failure(&"UNSUPPORTED_FORMAT", str(manifest_version)) @@ -832,21 +886,33 @@ static func _load_snapshot_absolute( return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error())) if world_file.get_length() < 16: return _failure(&"INVALID_WORLD_HEADER", world_path) + if world_file.get_length() - 16 > MAX_WORLD_BYTES: + return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "compressed world exceeds technical resource limit") var magic := world_file.get_buffer(4).get_string_from_ascii() var stored_version := world_file.get_32() var uncompressed_size := world_file.get_64() + if not _world_size_is_supported(uncompressed_size): + return _failure(&"SAVE_SIZE_LIMIT_EXCEEDED", "uncompressed world exceeds technical resource limit") var compressed_size := world_file.get_length() - world_file.get_position() if compressed_size < 0: return _failure(&"INVALID_WORLD_HEADER", world_path) var compressed_world := world_file.get_buffer(compressed_size) if magic != MAGIC or stored_version != manifest_version: return _failure(&"INVALID_WORLD_HEADER", world_path) - var expected_checksum := str(manifest.get("checksum_world", manifest.get(&"checksum_world", ""))) - if expected_checksum.is_empty() or _sha256(compressed_world) != expected_checksum: + var checksum_value: Variant = manifest.get("checksum_world", null) + if not checksum_value is String or checksum_value.length() != 64 or not checksum_value.is_valid_hex_number(false): + return _failure(&"INVALID_MANIFEST", "checksum_world must be a SHA-256 string") + var expected_checksum: String = checksum_value + if _sha256(compressed_world) != expected_checksum: return _failure(&"CHECKSUM_MISMATCH", world_path) - var manifest_size := int(manifest.get("uncompressed_size", manifest.get(&"uncompressed_size", -1))) - if manifest_size >= 0 and manifest_size != uncompressed_size: - return _failure(&"SIZE_MISMATCH", world_path) + # Older packages may omit this duplicate size field; the binary header still + # supplies a required bounded size. If present it must be exact and agree. + if manifest.has("uncompressed_size"): + var size_value: Variant = manifest["uncompressed_size"] + if not _is_integral_json_number(size_value, 1, MAX_WORLD_BYTES): + return _failure(&"INVALID_MANIFEST", "uncompressed_size must be a supported positive integer") + if int(size_value) != uncompressed_size: + return _failure(&"SIZE_MISMATCH", world_path) var raw_world := compressed_world.decompress(uncompressed_size, FileAccess.COMPRESSION_ZSTD) if raw_world.size() != uncompressed_size: return _failure(&"DECOMPRESSION_FAILED", world_path) @@ -866,6 +932,58 @@ static func _load_snapshot_absolute( } +static func _is_integral_json_number(value: Variant, minimum: int, maximum: int) -> bool: + if typeof(value) == TYPE_INT: + return value >= minimum and value <= maximum + if typeof(value) != TYPE_FLOAT or not is_finite(value): + return false + return value >= minimum and value <= maximum and value == floor(value) + + +static func _world_size_is_supported(size: int) -> bool: + return size > 0 and size <= MAX_WORLD_BYTES + + +## Probe only bounded metadata and the fixed binary prefix. An identifiable +## unsupported formal format must be rejected before any transaction artifact +## is reconciled, even when its compressed payload/checksum is damaged. +static func _guard_identified_formal_format(target_path: String) -> Dictionary: + var versions: Array[int] = [] + var manifest_path := target_path.path_join(MANIFEST_FILE) + if FileAccess.file_exists(manifest_path): + if not _package_file_is_safe_for_read(manifest_path): + return _failure(&"UNSAFE_PATH", manifest_path) + var manifest_file := FileAccess.open(manifest_path, FileAccess.READ) + if manifest_file == null: + return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error())) + if manifest_file.get_length() <= MAX_MANIFEST_BYTES: + var parser := JSON.new() + if parser.parse(manifest_file.get_as_text()) == OK and parser.data is Dictionary: + var value: Variant = parser.data.get("format_version", null) + if _is_integral_json_number(value, 0, 0xffffffff): + versions.append(int(value)) + manifest_file = null + var world_path := target_path.path_join(WORLD_FILE) + if FileAccess.file_exists(world_path): + if not _package_file_is_safe_for_read(world_path): + return _failure(&"UNSAFE_PATH", world_path) + var world_file := FileAccess.open(world_path, FileAccess.READ) + if world_file == null: + return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error())) + if world_file.get_length() >= 8 and world_file.get_buffer(4).get_string_from_ascii() == MAGIC: + var version := world_file.get_32() + if version not in versions: versions.append(version) + world_file = null + for version: int in versions: + if version != FORMAT_VERSION: + var failure := _failure(&"UNSUPPORTED_FORMAT", str(version)) + failure[&"identified_format_version"] = version + failure[&"identified_format_versions"] = versions + failure[&"formal_and_artifacts_preserved"] = true + return failure + return {&"ok": true, &"code": &"OK"} + + ## Resolves a normal-save promotion interrupted after the old formal package ## moved to .bak. Only checksum/schema-verified packages may become formal. ## The marker is durable, but verified .tmp/.bak artifacts also recover saves @@ -876,6 +994,9 @@ static func _reconcile_interrupted_formal_save(target_path: String) -> Dictionar var marker_path := target_path + SAVE_TRANSACTION_SUFFIX if not _operation_paths_are_safe([target_path, temporary_path, backup_path, marker_path]): return _failure(&"UNSAFE_PATH", "formal save recovery path contains a symbolic link") + var formal_guard := _guard_identified_formal_format(target_path) + if not formal_guard[&"ok"]: + return formal_guard var marker_exists := FileAccess.file_exists(marker_path) or DirAccess.dir_exists_absolute(marker_path) if marker_exists and not _formal_transaction_marker_is_valid(marker_path): return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path) diff --git a/scripts/core/simulation_snapshot.gd b/scripts/core/simulation_snapshot.gd index e1cd087..97c4c2d 100644 --- a/scripts/core/simulation_snapshot.gd +++ b/scripts/core/simulation_snapshot.gd @@ -15,16 +15,24 @@ static func capture(clock: SimulationKernel, state: Node, extra := {}) -> Dictio static func validate(snapshot: Dictionary) -> Dictionary: var errors: Array[String] = [] - if int(snapshot.get(&"schema_version", -1)) != SCHEMA_VERSION: + var schema_value: Variant = snapshot.get(&"schema_version", null) + if not _is_nonnegative_integer(schema_value) or int(schema_value) != SCHEMA_VERSION: errors.append("unsupported_schema_version") if not snapshot.get(&"clock", null) is Dictionary: errors.append("missing_clock") else: var clock_data: Dictionary = snapshot[&"clock"] - if int(clock_data.get(&"absolute_minute", -1)) < 0: + if not _is_nonnegative_integer(clock_data.get(&"absolute_minute", null)): errors.append("invalid_absolute_minute") - if int(clock_data.get(&"speed", 0)) not in [1, 2, 3, 4]: + var speed_value: Variant = clock_data.get(&"speed", null) + if not _is_nonnegative_integer(speed_value) or int(speed_value) not in [1, 2, 3, 4]: errors.append("invalid_speed") + # Legacy envelopes may omit these fields and retain restore_clock's + # paused=true/version=0 defaults. Present fields cannot be coerced. + if typeof(clock_data.get(&"paused", true)) != TYPE_BOOL: + errors.append("invalid_paused") + if not _is_nonnegative_integer(clock_data.get(&"snapshot_version", 0)): + errors.append("invalid_snapshot_version") if not snapshot.get(&"game_state", null) is Dictionary: errors.append("missing_game_state") return { @@ -32,6 +40,15 @@ static func validate(snapshot: Dictionary) -> Dictionary: &"errors": errors, } +static func _is_nonnegative_integer(value: Variant) -> bool: + if typeof(value) == TYPE_INT: + return value >= 0 + # JSON numbers are floats, but exact integral values remain compatible. + # Avoid an overflowing float-to-int conversion at the signed int64 limit. + if typeof(value) != TYPE_FLOAT or not is_finite(value): + return false + return value >= 0 and value < 9223372036854775808.0 and value == floor(value) + static func restore(snapshot: Dictionary, clock: SimulationKernel, state: Node) -> bool: var validation := validate(snapshot) if not validation[&"ok"]: diff --git a/scripts/defense/defense_service.gd b/scripts/defense/defense_service.gd index 5d8fa33..461e728 100644 --- a/scripts/defense/defense_service.gd +++ b/scripts/defense/defense_service.gd @@ -1542,6 +1542,8 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary: var state_index := RaidState.keys().find(String(_value(raw, &"state", &""))) if raid_id.is_empty() or state_index < 0: return {&"ok": false, &"error": "Raid snapshot ID or state is invalid."} + # Normal creation owns Array[Dictionary]; restores keep the same container type. + var parsed_waves: Array[Dictionary] = [] var raid := { &"raid_id": raid_id, &"source_id": StringName(_value(raw, &"source_id", &"")), @@ -1564,7 +1566,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary: &"multiple_entry_penalty": int(_value(raw, &"multiple_entry_penalty", -1)), &"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])), &"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])), - &"waves": [], + &"waves": parsed_waves, &"combatants": {}, &"assets": {}, &"queued_skills": {}, @@ -1588,7 +1590,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary: wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", [])) wave[&"state"] = StringName(_value(wave, &"state", &"")) wave[&"result_reason"] = StringName(_value(wave, &"result_reason", &"")) - (raid[&"waves"] as Array).append(wave) + parsed_waves.append(wave) for raw_combatant: Variant in _value(raw, &"combatants", []): if not raw_combatant is Dictionary: return {&"ok": false, &"error": "Malformed defense combatant snapshot."} diff --git a/scripts/supply/supply_auto_purchase_plans.gd b/scripts/supply/supply_auto_purchase_plans.gd new file mode 100644 index 0000000..c7d917a --- /dev/null +++ b/scripts/supply/supply_auto_purchase_plans.gd @@ -0,0 +1,276 @@ +class_name SupplyAutoPurchasePlans +extends RefCounted + +## Owned player intent and the last actual attempt. No plan is inferred on load. +## Current catalogue prices/storage are reused; target stock is player input. +const Catalog := preload("res://scripts/supply/supply_catalog.gd") +const SNAPSHOT_VERSION := 1 +const RECEIPT_PREFIX := "supply_purchase.auto.v1:" +const MAX_SIGNED_INT_TEXT := "9223372036854775807" +const RESULT_PURCHASED := &"PURCHASED" +const FAILED_RESULTS: Array[StringName] = [ + &"INVENTORY_ERROR", &"PAYMENT_FAILED", &"INSUFFICIENT_FUNDS", &"STORAGE_FULL", + &"FACILITY_CLOSED", &"INVALID_QUANTITY", &"ITEM_NOT_PURCHASABLE", &"NOT_WAREHOUSE", + &"REPLAY_MISMATCH", +] + +var last_error := "" +var _plans: Dictionary = {} + + +func get_plan(warehouse_id: StringName, item_id: StringName) -> Dictionary: + if _plans.has(warehouse_id) and (_plans[warehouse_id] as Dictionary).has(item_id): + return (_plans[warehouse_id][item_id] as Dictionary).duplicate(true) + return _empty_plan(warehouse_id, item_id) + + +func get_plans() -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + for warehouse_id: StringName in _sorted_names(_plans): + for item_id: StringName in _sorted_names(_plans[warehouse_id]): + rows.append(get_plan(warehouse_id, item_id)) + return rows + + +func set_plan(warehouse_id: StringName, item_id: StringName, enabled: bool, + target_stock: int, facilities: FacilityService) -> Dictionary: + var validation := validate_intent(warehouse_id, item_id, enabled, target_stock, facilities) + if not bool(validation[&"ok"]): + return validation + var plan := get_plan(warehouse_id, item_id) + plan[&"enabled"] = enabled + plan[&"target_stock"] = target_stock + if not _plans.has(warehouse_id): + _plans[warehouse_id] = {} + _plans[warehouse_id][item_id] = plan + last_error = "" + return {&"ok": true, &"plan": plan.duplicate(true)} + + +func record_attempt(warehouse_id: StringName, item_id: StringName, minute: int, + result: CommandResult, economy: EconomyService) -> bool: + if not _plans.has(warehouse_id) or not (_plans[warehouse_id] as Dictionary).has(item_id): + return false + var plan: Dictionary = _plans[warehouse_id][item_id] + if minute < 0 or minute <= int(plan[&"last_attempt_minute"]): + return false + var reason: StringName = RESULT_PURCHASED if result.is_success() \ + else StringName(result.details.get(&"reason", &"PAYMENT_FAILED")) + if reason != RESULT_PURCHASED and reason not in FAILED_RESULTS: + return false + var transaction_id := StringName(result.details.get(&"transaction_id", &"")) if result.is_success() else &"" + var quantity: int = int(result.details.get(&"quantity", 0)) if result.is_success() else 0 + var unit_price: int = int(result.details.get(&"unit_price", 0)) if result.is_success() else 0 + var total_cost: int = int(result.details.get(&"total_cost", 0)) if result.is_success() else 0 + if result.is_success() and (not valid_charge(quantity, unit_price, total_cost) or not valid_receipt(economy, warehouse_id, item_id, minute, transaction_id, total_cost, quantity, unit_price)): + return false + plan[&"last_attempt_minute"] = minute + plan[&"last_result"] = reason + plan[&"last_transaction_id"] = String(transaction_id) + plan[&"last_quantity"] = quantity + plan[&"last_unit_price"] = unit_price + plan[&"last_total_cost"] = total_cost + return true + + +func remove_warehouse(warehouse_id: StringName) -> void: + _plans.erase(warehouse_id) + + +func create_snapshot() -> Dictionary: + return {&"version": SNAPSHOT_VERSION, &"plans": get_plans()} + + +func restore_snapshot(snapshot: Dictionary, facilities: FacilityService, + economy: EconomyService, minute: int) -> bool: + last_error = "" + if not _keys_match(snapshot, [&"version", &"plans"]) \ + or typeof(snapshot[&"version"]) != TYPE_INT or snapshot[&"version"] != SNAPSHOT_VERSION \ + or not snapshot[&"plans"] is Array or facilities == null or economy == null or minute < 0: + return _restore_failure("Invalid automatic purchase snapshot.") + # At most one plan per real warehouse/catalog item; input cannot broaden it. + var maximum_rows := facilities.get_instances().size() * Catalog.get_purchase_items().size() + if (snapshot[&"plans"] as Array).size() > maximum_rows: + return _restore_failure("Too many automatic purchase plans.") + var candidate: Dictionary = {} + for value: Variant in snapshot[&"plans"]: + if not value is Dictionary: + return _restore_failure("Automatic purchase row is not a Dictionary.") + var row: Dictionary = value + if not _keys_match(row, [&"warehouse_id", &"item_id", &"enabled", &"target_stock", + &"last_attempt_minute", &"last_result", &"last_transaction_id", &"last_quantity", &"last_unit_price", &"last_total_cost"]) \ + or typeof(row[&"warehouse_id"]) != TYPE_STRING or typeof(row[&"item_id"]) != TYPE_STRING \ + or not _text(row[&"warehouse_id"]) or not _text(row[&"item_id"]) \ + or typeof(row[&"enabled"]) != TYPE_BOOL or typeof(row[&"target_stock"]) != TYPE_INT \ + or typeof(row[&"last_attempt_minute"]) != TYPE_INT \ + or typeof(row[&"last_result"]) != TYPE_STRING_NAME \ + or typeof(row[&"last_transaction_id"]) != TYPE_STRING \ + or typeof(row[&"last_quantity"]) != TYPE_INT or typeof(row[&"last_unit_price"]) != TYPE_INT or typeof(row[&"last_total_cost"]) != TYPE_INT: + return _restore_failure("Invalid automatic purchase row fields/types.") + var warehouse_id := StringName(row[&"warehouse_id"]) + var item_id := StringName(row[&"item_id"]) + if not bool(validate_intent(warehouse_id, item_id, row[&"enabled"], row[&"target_stock"], facilities)[&"ok"]): + return _restore_failure("Automatic purchase plan does not match its warehouse/catalog.") + if candidate.has(warehouse_id) and (candidate[warehouse_id] as Dictionary).has(item_id): + return _restore_failure("Duplicate automatic purchase plan.") + var attempted := int(row[&"last_attempt_minute"]) + var reason: StringName = row[&"last_result"] + var transaction_id := StringName(row[&"last_transaction_id"]) + var quantity: int = row[&"last_quantity"] + var unit_price: int = row[&"last_unit_price"] + var total_cost: int = row[&"last_total_cost"] + if reason == RESULT_PURCHASED and not valid_charge(quantity, unit_price, total_cost): + return _restore_failure("Saved automatic purchase quote is invalid.") + if reason != RESULT_PURCHASED and (quantity != 0 or unit_price != 0 or total_cost != 0): + return _restore_failure("Unpurchased automatic intent retained a charge.") + if attempted < -1 or attempted > minute \ + or (attempted == -1 and (not reason.is_empty() or not transaction_id.is_empty())) \ + or (attempted >= 0 and reason != RESULT_PURCHASED and reason not in FAILED_RESULTS): + return _restore_failure("Invalid automatic purchase attempt state.") + if attempted >= 0: + var paid_id := economy.get_transaction_id_for_idempotency_key(order_key(warehouse_id, item_id, attempted)) + if reason == RESULT_PURCHASED: + if not valid_receipt(economy, warehouse_id, item_id, attempted, transaction_id, total_cost, quantity, unit_price): + return _restore_failure("Automatic purchase receipt differs from its unique ledger transaction.") + elif not transaction_id.is_empty() or (not paid_id.is_empty() and (reason != &"REPLAY_MISMATCH" or valid_receipt(economy, warehouse_id, item_id, attempted, paid_id))): + return _restore_failure("Failed automatic purchase retained its own payment.") + if not candidate.has(warehouse_id): + candidate[warehouse_id] = {} + candidate[warehouse_id][item_id] = row.duplicate(true) + _plans = candidate + return true + + +static func receipt_memo(item_id: StringName, quantity: int, unit_price: int, total_cost: int) -> StringName: + if String(item_id).strip_edges().is_empty() or not valid_charge(quantity, unit_price, total_cost): + return &"" + # Character length, not delimiter escaping, keeps arbitrary item punctuation + # unambiguous. Only automatic payments use this immutable historical quote. + return StringName(RECEIPT_PREFIX + "%d:%s:%d:%d:%d" % [ + String(item_id).length(), item_id, quantity, unit_price, total_cost]) + + +static func decode_receipt_memo(memo: StringName) -> Dictionary: + var text := String(memo) + if not text.begins_with(RECEIPT_PREFIX): return {} + var body := text.substr(RECEIPT_PREFIX.length()) + var delimiter := body.find(":") + if delimiter <= 0: return {} + var item_length := _positive_decimal(body.substr(0, delimiter)) + var remainder := body.substr(delimiter + 1) + if item_length <= 0 or item_length >= remainder.length() or remainder.substr(item_length, 1) != ":": + return {} + var item_text := remainder.substr(0, item_length) + if item_text.strip_edges().is_empty(): return {} + var amounts := remainder.substr(item_length + 1).split(":", true) + if amounts.size() != 3: return {} + var quantity := _positive_decimal(amounts[0]) + var unit_price := _positive_decimal(amounts[1]) + var total_cost := _positive_decimal(amounts[2]) + if not valid_charge(quantity, unit_price, total_cost) \ + or receipt_memo(StringName(item_text), quantity, unit_price, total_cost) != memo: + return {} + return {&"item_id": item_text, &"quantity": quantity, &"unit_price": unit_price, &"total_cost": total_cost} + + +static func receipt_from_ledger(economy: EconomyService, warehouse_id: StringName, + item_id: StringName, minute: int, transaction_id: StringName) -> Dictionary: + if economy == null or transaction_id.is_empty() \ + or economy.get_transaction_id_for_idempotency_key(order_key(warehouse_id, item_id, minute)) != transaction_id: + return {} + var entries: Array = economy.get_transaction_entries(transaction_id) + if entries.size() != 1: return {} + var entry: LedgerEntry = entries[0] + var receipt := decode_receipt_memo(entry.memo_key) + if receipt.is_empty() or receipt[&"item_id"] != String(item_id) \ + or entry.absolute_minute != minute or entry.source_entity_id != warehouse_id \ + or entry.category != &"OPERATING_EXPENSE" or not entry.related_transaction_id.is_empty() \ + or entry.credit_account != &"PLAYER_CASH" or entry.debit_account != &"OPERATING_EXPENSE" \ + or entry.amount != receipt[&"total_cost"]: + return {} + return receipt + + +static func valid_receipt(economy: EconomyService, warehouse_id: StringName, + item_id: StringName, minute: int, transaction_id: StringName, total_cost: int = -1, + quantity: int = -1, unit_price: int = -1) -> bool: + var receipt := receipt_from_ledger(economy, warehouse_id, item_id, minute, transaction_id) + return not receipt.is_empty() and (total_cost < 0 or receipt[&"total_cost"] == total_cost) \ + and (quantity < 0 or receipt[&"quantity"] == quantity) \ + and (unit_price < 0 or receipt[&"unit_price"] == unit_price) + + +static func _positive_decimal(text: String) -> int: + # Validate textual range before to_int: no float conversion or saturating + # overflow can reinterpret a corrupt receipt as another positive amount. + if text.is_empty() or text.length() > MAX_SIGNED_INT_TEXT.length() or text.substr(0, 1) == "0": + return 0 + for index: int in text.length(): + var code := text.unicode_at(index) + if code < 48 or code > 57: return 0 + if text.length() == MAX_SIGNED_INT_TEXT.length() and text > MAX_SIGNED_INT_TEXT: return 0 + return text.to_int() + + +static func valid_charge(quantity: int, unit_price: int, total_cost: int) -> bool: + # Existing purchase input ceiling; division avoids multiplication overflow. + @warning_ignore("integer_division") + return quantity > 0 and quantity <= 10000 and unit_price > 0 and total_cost > 0 and total_cost / quantity == unit_price and total_cost % quantity == 0 + + +static func validate_intent(warehouse_id: StringName, item_id: StringName, enabled: bool, + target_stock: int, facilities: FacilityService) -> Dictionary: + var facility: FacilityInstance = facilities.get_instance(warehouse_id) if facilities != null else null + if facility == null or facility.definition_id != &"warehouse": + return {&"ok": false, &"reason": &"NOT_WAREHOUSE"} + var item := Catalog.get_item(item_id) + if item.is_empty() or not bool(item.get(&"purchasable", false)): + return {&"ok": false, &"reason": &"ITEM_NOT_PURCHASABLE"} + var maximum := maximum_target(warehouse_id, item_id, facilities) + if target_stock < 0 or target_stock > maximum or (enabled and target_stock == 0): + return {&"ok": false, &"reason": &"INVALID_TARGET", &"maximum_target": maximum} + return {&"ok": true, &"maximum_target": maximum} + + +static func maximum_target(warehouse_id: StringName, item_id: StringName, facilities: FacilityService) -> int: + var facility: FacilityInstance = facilities.get_instance(warehouse_id) if facilities != null else null + var item := Catalog.get_item(item_id) + if facility == null or facility.definition_id != &"warehouse" or item.is_empty() \ + or not bool(item.get(&"purchasable", false)) or int(item.get(&"unit_weight", 0)) <= 0: + return 0 + @warning_ignore("integer_division") + return Catalog.storage_capacity(facility.definition_id) / int(item[&"unit_weight"]) + + +static func order_key(warehouse_id: StringName, item_id: StringName, minute: int) -> StringName: + # Length encoding avoids punctuation collisions. This is not a command ID. + return StringName("supply.auto.%d:%s.%d:%s.%d" % [ + String(warehouse_id).length(), warehouse_id, String(item_id).length(), item_id, minute]) + + +static func _empty_plan(warehouse_id: StringName, item_id: StringName) -> Dictionary: + return {&"warehouse_id": String(warehouse_id), &"item_id": String(item_id), &"enabled": false, + &"target_stock": 0, &"last_attempt_minute": -1, &"last_result": &"", &"last_transaction_id": "", &"last_quantity": 0, &"last_unit_price": 0, &"last_total_cost": 0} + + +static func _sorted_names(values: Dictionary) -> Array[StringName]: + var ids: Array[StringName] = [] + for value: Variant in values: ids.append(StringName(value)) + ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return ids + + +static func _keys_match(value: Dictionary, expected: Array[StringName]) -> bool: + if value.size() != expected.size(): return false + for key: Variant in value: + if typeof(key) != TYPE_STRING_NAME or key not in expected: return false + return true + + +static func _text(value: Variant) -> bool: + return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and not String(value).strip_edges().is_empty() + + +func _restore_failure(message: String) -> bool: + last_error = message + return false diff --git a/scripts/supply/supply_auto_purchase_plans.gd.uid b/scripts/supply/supply_auto_purchase_plans.gd.uid new file mode 100644 index 0000000..def817b --- /dev/null +++ b/scripts/supply/supply_auto_purchase_plans.gd.uid @@ -0,0 +1 @@ +uid://dxvu3dy4bl6sj diff --git a/scripts/supply/supply_management_service.gd b/scripts/supply/supply_management_service.gd index 4e3d4aa..88125fe 100644 --- a/scripts/supply/supply_management_service.gd +++ b/scripts/supply/supply_management_service.gd @@ -41,28 +41,149 @@ func purchase(runtime: Variant, command: SimulationCommand) -> CommandResult: var warehouse_id := StringName(payload[&"warehouse_id"]) var item_id := StringName(payload[&"item_id"]) var quantity := int(payload[&"quantity"]) + return _purchase_paid(runtime, warehouse_id, item_id, quantity, + runtime._runtime_event_minute(), StringName("supply.purchase.%d" % command.command_id), command.command_id) + + +func _purchase_paid(runtime: Variant, warehouse_id: StringName, item_id: StringName, + quantity: int, minute: int, ledger_key: StringName, command_id: int = 0, record_auto_attempt: bool = false) -> CommandResult: + # Shared transaction only; internal replenishment does not enqueue a command. var quote := preview_purchase(runtime, warehouse_id, item_id, quantity) if not bool(quote[&"ok"]): - return _failure(command, StringName(quote[&"reason"]), quote) + return _purchase_failure(command_id, StringName(quote[&"reason"]), quote) var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id) var item := SupplyCatalog.get_item(item_id) - var minute: int = runtime._runtime_event_minute() var inventory_before: Dictionary = runtime.inventory_service.create_snapshot() var economy_before: Dictionary = runtime.economy_service.create_snapshot() + var plans_before: Dictionary = runtime.auto_purchase_plans.create_snapshot() if record_auto_attempt else {} var stocked: Dictionary = runtime.inventory_service.add_stack(facility.local_inventory_id, item_id, quantity, 100, minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"])) if not bool(stocked.get(&"ok", false)): - return _failure(command, &"INVENTORY_ERROR") - var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, StringName("supply.purchase.%d" % command.command_id), &"supply_purchase", &"", false) + runtime.inventory_service.restore_snapshot(inventory_before) + runtime.economy_service.restore_snapshot(economy_before) + return _purchase_failure(command_id, &"INVENTORY_ERROR") + var memo: StringName = SupplyAutoPurchasePlans.receipt_memo(item_id, quantity, int(item[&"unit_price"]), int(quote[&"total_cost"])) if record_auto_attempt else &"supply_purchase" + var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, ledger_key, memo, &"", false) if not paid.is_success(): runtime.inventory_service.restore_snapshot(inventory_before) runtime.economy_service.restore_snapshot(economy_before) - return _failure(command, &"PAYMENT_FAILED") + return _purchase_failure(command_id, &"PAYMENT_FAILED") + if record_auto_attempt and not runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, CommandResult.succeeded(0, {&"transaction_id": paid.details[&"transaction_id"], &"quantity": quantity, &"unit_price": item[&"unit_price"], &"total_cost": quote[&"total_cost"]}), runtime.economy_service): + runtime.inventory_service.restore_snapshot(inventory_before) + runtime.economy_service.restore_snapshot(economy_before) + return _purchase_failure(command_id, &"PAYMENT_FAILED") if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])): runtime.inventory_service.restore_snapshot(inventory_before) runtime.economy_service.restore_snapshot(economy_before) - return _failure(command, &"PAYMENT_FAILED") + if record_auto_attempt: + runtime.auto_purchase_plans.restore_snapshot(plans_before, runtime.facility_service, runtime.economy_service, minute) + return _purchase_failure(command_id, &"PAYMENT_FAILED") quote.merge({&"warehouse_id": warehouse_id, &"item_id": item_id, &"quantity": quantity, &"transaction_id": paid.details[&"transaction_id"]}) - return CommandResult.succeeded(command.command_id, quote) + return CommandResult.succeeded(command_id, quote) + + +func set_auto_purchase(runtime: Variant, command: SimulationCommand) -> CommandResult: + var payload := command.payload + if not _exact_keys(payload, [&"warehouse_id", &"item_id", &"enabled", &"target_stock"]) \ + or not _text(payload[&"warehouse_id"]) or not _text(payload[&"item_id"]) \ + or typeof(payload[&"enabled"]) != TYPE_BOOL or typeof(payload[&"target_stock"]) != TYPE_INT: + return _failure(command, &"INVALID_PAYLOAD") + var changed: Dictionary = runtime.auto_purchase_plans.set_plan( + StringName(payload[&"warehouse_id"]), StringName(payload[&"item_id"]), + payload[&"enabled"], payload[&"target_stock"], runtime.facility_service) + if not bool(changed.get(&"ok", false)): + return _failure(command, StringName(changed.get(&"reason", &"INVALID_TARGET")), changed) + return CommandResult.succeeded(command.command_id, changed[&"plan"]) + + +func auto_purchase_report(runtime: Variant, warehouse_id: StringName, item_id: StringName) -> Dictionary: + var row: Dictionary = runtime.auto_purchase_plans.get_plan(warehouse_id, item_id) + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id) + row[&"maximum_target"] = SupplyAutoPurchasePlans.maximum_target(warehouse_id, item_id, runtime.facility_service) + row[&"physical_quantity"] = runtime.inventory_service.total_quantity(facility.local_inventory_id, item_id) if facility != null else 0 + row[&"incoming_quantity"] = _incoming_quantity(runtime, facility.local_inventory_id, item_id) if facility != null else 0 + row[&"deficit"] = maxi(int(row[&"target_stock"]) - int(row[&"physical_quantity"]) - int(row[&"incoming_quantity"]), 0) + row[&"quote"] = preview_purchase(runtime, warehouse_id, item_id, int(row[&"deficit"])) if int(row[&"deficit"]) > 0 else {} + row[&"reason"] = &"" + if not bool(row[&"enabled"]): + row[&"state"] = &"DISABLED" + elif bool(runtime.get_operations_report().get(&"tick_blocked", false)): + row[&"state"] = &"BLOCKED" + row[&"reason"] = &"OPERATIONS_BLOCKED" + elif int(runtime._state.get_resource(&"mana")) <= 0: + row[&"state"] = &"BLOCKED" + row[&"reason"] = &"NO_POWER" + elif _receiver_will_close(facility, runtime._clock.absolute_minute): + row[&"state"] = &"BLOCKED" + row[&"reason"] = &"FACILITY_CLOSED" + elif int(row[&"last_attempt_minute"]) == runtime._clock.absolute_minute: + row[&"state"] = &"ORDERED_THIS_MINUTE" if row[&"last_result"] == SupplyAutoPurchasePlans.RESULT_PURCHASED else &"BLOCKED" + if row[&"state"] == &"BLOCKED": row[&"reason"] = row[&"last_result"] + elif int(row[&"deficit"]) == 0: + row[&"state"] = &"TARGET_REACHED" + elif bool((row[&"quote"] as Dictionary).get(&"ok", false)): + row[&"state"] = &"READY" + else: + row[&"state"] = &"BLOCKED" + row[&"reason"] = (row[&"quote"] as Dictionary).get(&"reason", &"UNAVAILABLE") + return row + + +func advance_auto_purchases(runtime: Variant, minute: int) -> Array[Dictionary]: + var results: Array[Dictionary] = [] + if bool(runtime.get_operations_report().get(&"tick_blocked", false)) or int(runtime._state.get_resource(&"mana")) <= 0: + return results + for plan: Dictionary in runtime.auto_purchase_plans.get_plans(): + if not bool(plan[&"enabled"]) or int(plan[&"last_attempt_minute"]) >= minute: + continue + var warehouse_id := StringName(plan[&"warehouse_id"]) + var item_id := StringName(plan[&"item_id"]) + var row := auto_purchase_report(runtime, warehouse_id, item_id) + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id) + if int(row[&"deficit"]) <= 0 or facility == null or not facility.allows_logistics() or _receiver_will_close(facility, minute): + continue + var ledger_key := SupplyAutoPurchasePlans.order_key(warehouse_id, item_id, minute) + var prior_id: StringName = runtime.economy_service.get_transaction_id_for_idempotency_key(ledger_key) + var result: CommandResult + if prior_id.is_empty(): + if row[&"state"] != &"READY" or not bool((row[&"quote"] as Dictionary).get(&"ok", false)): + continue + result = _purchase_paid(runtime, warehouse_id, item_id, int(row[&"deficit"]), minute, ledger_key, 0, true) + else: + # A committed key replays its original item/quantity/price, even if + # today's quote is unaffordable. It never stocks, charges or notifies. + var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(runtime.economy_service, warehouse_id, item_id, minute, prior_id) + if receipt.is_empty(): + result = _purchase_failure(0, &"REPLAY_MISMATCH") + else: + result = CommandResult.succeeded(0, {&"transaction_id": prior_id, &"quantity": receipt[&"quantity"], &"unit_price": receipt[&"unit_price"], &"total_cost": receipt[&"total_cost"], &"replayed": true}) + if (not result.is_success() or not prior_id.is_empty()) and not runtime.auto_purchase_plans.record_attempt(warehouse_id, item_id, minute, result, runtime.economy_service): + push_error("Automatic purchase could not record its committed attempt.") + return results + results.append({&"warehouse_id": warehouse_id, &"item_id": item_id, + &"minute": minute, &"result": result.to_dictionary()}) + return results + + +func _receiver_will_close(facility: FacilityInstance, minute: int) -> bool: + # LOGISTICS precedes FACILITY_COMPLETION. Honor an already scheduled close + # without applying policy early or changing manual purchase/phase ordering. + return facility != null and facility.policy_pending_effective_at >= 0 \ + and facility.policy_pending_effective_at <= minute \ + and facility.policy_pending.has(&"business_open") \ + and not bool(facility.policy_pending[&"business_open"]) + + +func _purchase_failure(command_id: int, reason: StringName, details: Dictionary = {}) -> CommandResult: + var result := details.duplicate(true) + result[&"reason"] = reason + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command_id, "Supply command rejected: %s" % reason, result) + + +func _exact_keys(value: Dictionary, expected: Array[StringName]) -> bool: + if value.size() != expected.size(): return false + for key: Variant in value: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(key) not in expected: return false + return true func set_production(runtime: Variant, command: SimulationCommand) -> CommandResult: @@ -90,6 +211,9 @@ func get_report(runtime: Variant) -> Dictionary: var quote := preview_purchase(runtime, facility.instance_id, SupplyCatalog.RAW_FOOD, 1) row[&"can_receive"] = bool(quote[&"ok"]) row[&"reason_codes"] = [] if bool(quote[&"ok"]) else [quote[&"reason"]] + row[&"auto_purchase_plans"] = [] + for item: Dictionary in SupplyCatalog.get_purchase_items(): + (row[&"auto_purchase_plans"] as Array).append(auto_purchase_report(runtime, facility.instance_id, StringName(item[&"item_id"]))) warehouses.append(row) &"kitchen": row.merge(runtime.kitchen_production.get_report(facility.instance_id), true) diff --git a/scripts/ui/supply_management_panel.gd b/scripts/ui/supply_management_panel.gd index 2c9e024..e65bb6f 100644 --- a/scripts/ui/supply_management_panel.gd +++ b/scripts/ui/supply_management_panel.gd @@ -11,6 +11,7 @@ signal action_requested(action: StringName) const KEY_PREFIX := "ui.supply." const PURCHASE: StringName = &"purchase_supplies" const PRODUCTION: StringName = &"set_kitchen_production" +const AUTO_PURCHASE: StringName = &"set_supply_auto_purchase" const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"} const STATE_NAMES := { &"SUSPENDED": "破壊・摘発による休止", @@ -56,6 +57,8 @@ const REASONS := { &"NO_POWER": "施設へ魔力が届いていません。", &"CLOSED": "施設が閉鎖されています。", &"NOT_KITCHEN": "厨房を選んでください。", &"INVALID_TARGET": "目標在庫の上限を確認してください。", &"BANKRUPT": "運営終了後は変更できません。", &"UNAVAILABLE": "現在は操作できません。", + &"OPERATIONS_BLOCKED": "運営の停止理由を確認し、再開してください。", + &"REPLAY_MISMATCH": "仕入れ履歴と現在の設定を確認してください。", } var summary_label: Label @@ -65,6 +68,10 @@ var quantity_spin: SpinBox var purchase_quote_label: Label var purchase_button: Button var warehouse_stock_label: Label +var auto_purchase_enabled: CheckButton +var auto_purchase_target: SpinBox +var auto_purchase_button: Button +var auto_purchase_status: Label var kitchen_option: OptionButton var production_enabled: CheckButton var target_spin: SpinBox @@ -84,6 +91,7 @@ var _report: Dictionary = {} var _quote: Dictionary = {} var _pages: Dictionary = {} var _kitchen_drafts: Dictionary = {} +var _auto_purchase_drafts: Dictionary = {} var _pending_commands: Dictionary = {} var _queue_instance_id := 0 var _sequence := 1 @@ -152,6 +160,23 @@ func _build_interface() -> void: purchase_page.add_child(purchase_quote_label) purchase_button = _button("購入", "PurchaseSuppliesButton", submit_purchase) purchase_page.add_child(purchase_button) + auto_purchase_enabled = CheckButton.new() + auto_purchase_enabled.name = "SupplyAutoPurchaseEnabled" + auto_purchase_enabled.text = "自動仕入れ(不足分を有料購入)" + for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]: + auto_purchase_enabled.add_theme_color_override(color_name, Color("55371e")) + auto_purchase_enabled.add_theme_color_override("font_disabled_color", Color("766650")) + LayerSkin.register_toggle_text(auto_purchase_enabled, LayerSkin.active_layer(self)) + auto_purchase_enabled.toggled.connect(func(_value: bool) -> void: _store_auto_purchase_draft()) + purchase_page.add_child(auto_purchase_enabled) + purchase_page.add_child(_label("倉庫の目標在庫 · 選択した品目")) + auto_purchase_target = _spin("SupplyAutoPurchaseTarget", 0, 0) + auto_purchase_target.value_changed.connect(func(_value: float) -> void: _store_auto_purchase_draft()) + purchase_page.add_child(auto_purchase_target) + auto_purchase_button = _button("自動仕入れ設定を保存", "SaveSupplyAutoPurchaseButton", submit_auto_purchase) + purchase_page.add_child(auto_purchase_button) + auto_purchase_status = _label("", "SupplyAutoPurchaseStatus") + purchase_page.add_child(auto_purchase_status) var kitchen_page: VBoxContainer = _pages[&"kitchen"] kitchen_page.add_child(_label("設定する厨房")) kitchen_option = _option("SupplyKitchenOption") @@ -211,6 +236,7 @@ func refresh() -> void: if queue != null and queue.get_instance_id() != _queue_instance_id: _queue_instance_id = queue.get_instance_id() _kitchen_drafts.clear() + _auto_purchase_drafts.clear() for picker: OptionButton in [warehouse_option, item_option, kitchen_option]: picker.get_popup().hide() _synchronize_commands(queue) @@ -246,11 +272,13 @@ func _refresh_purchase() -> void: # can see why it cannot be bought instead of silently reducing its quantity. quantity_spin.max_value = maxi(int(quantity_spin.value), maxi(1, int(_quote.get(&"max_quantity", 1)))) var pending := _has_pending(PURCHASE) + var setting_pending := _has_pending(AUTO_PURCHASE) quantity_spin.editable = not pending and not catalog.is_empty() and not warehouse.is_empty() - warehouse_option.disabled = pending or _rows(&"warehouses").is_empty() - item_option.disabled = pending or _rows(&"catalog").is_empty() + warehouse_option.disabled = pending or setting_pending or _rows(&"warehouses").is_empty() + item_option.disabled = pending or setting_pending or _rows(&"catalog").is_empty() purchase_button.disabled = pending or not bool(_quote.get(&"ok", false)) purchase_button.text = "購入を受理済み…" if pending else "購入" + _refresh_auto_purchase(warehouse) if catalog.is_empty() or warehouse.is_empty(): purchase_quote_label.text = "受入倉庫と品目を選んでください。" return @@ -259,6 +287,66 @@ func _refresh_purchase() -> void: purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE")) +func _refresh_auto_purchase(warehouse: Dictionary) -> void: + var row: Dictionary = {} + for value: Variant in warehouse.get(&"auto_purchase_plans", []): + if value is Dictionary and StringName(value.get(&"item_id", &"")) == selected_item_id: + row = value + break + var draft: Dictionary = _auto_purchase_drafts.get(_auto_purchase_key(), row) + var pending := _has_pending(AUTO_PURCHASE) + var target := int(draft.get(&"target_stock", 0)) + var maximum := int(row.get(&"maximum_target", 0)) + var enabled := bool(draft.get(&"enabled", false)) + auto_purchase_enabled.disabled = pending or row.is_empty() + auto_purchase_enabled.set_pressed_no_signal(enabled) + auto_purchase_target.min_value = 0 + auto_purchase_target.max_value = maxi(maximum, target) + auto_purchase_target.set_value_no_signal(target) + auto_purchase_target.editable = not pending and not row.is_empty() + auto_purchase_button.disabled = pending or row.is_empty() or target > maximum or (enabled and target <= 0) + auto_purchase_button.text = "設定を受理済み…" if pending else "自動仕入れ設定を保存" + if row.is_empty(): + auto_purchase_status.text = "完成した倉庫と購入できる品目を選んでください。" + return + var state := StringName(row.get(&"state", &"DISABLED")) + var state_text := "停止中" + match state: + &"READY": state_text = "不足分の入荷待ち" + &"TARGET_REACHED": state_text = "目標在庫に到達" + &"ORDERED_THIS_MINUTE": state_text = "入庫済み · 次の不足判定を待機" + &"BLOCKED": state_text = _reason_text(row.get(&"reason", &"UNAVAILABLE")) + auto_purchase_status.text = "現在の設定:%s · 目標 %d / 上限 %d\n%s\n倉庫の現物 %d · 入荷予定 %d" % [ + "ON" if bool(row.get(&"enabled", false)) else "OFF", row.get(&"target_stock", 0), maximum, + state_text, row.get(&"physical_quantity", 0), row.get(&"incoming_quantity", 0)] + var quote: Dictionary = row.get(&"quote", {}) + if int(row.get(&"deficit", 0)) > 0 and not quote.is_empty(): + auto_purchase_status.text += "\n不足 %d · 今の購入見積 %dG" % [row[&"deficit"], quote.get(&"total_cost", 0)] + if row.get(&"last_result", &"") == &"PURCHASED": + auto_purchase_status.text += "\n前回入庫 %d個 · 代金 %dG(単価 %dG)" % [row[&"last_quantity"], row[&"last_total_cost"], row[&"last_unit_price"]] + if _auto_purchase_drafts.has(_auto_purchase_key()): + auto_purchase_status.text += "\n未保存の変更です。設定を保存すると反映します。" + auto_purchase_status.text += "\n在庫が減ると目標まで不足分を購入します。料金は手動購入と同じです。" + + +func _auto_purchase_key() -> String: + return "%d:%s%d:%s" % [String(selected_warehouse_id).length(), selected_warehouse_id, + String(selected_item_id).length(), selected_item_id] + + +func _store_auto_purchase_draft() -> void: + if _refreshing or selected_warehouse_id.is_empty() or selected_item_id.is_empty(): return + _auto_purchase_drafts[_auto_purchase_key()] = {&"enabled": auto_purchase_enabled.button_pressed, + &"target_stock": int(auto_purchase_target.value)} + refresh() + + +func submit_auto_purchase() -> void: + if selected_warehouse_id.is_empty() or selected_item_id.is_empty() or _has_pending(AUTO_PURCHASE): return + _submit(AUTO_PURCHASE, {&"warehouse_id": selected_warehouse_id, &"item_id": selected_item_id, + &"enabled": auto_purchase_enabled.button_pressed, &"target_stock": int(auto_purchase_target.value)}) + + func _refresh_kitchen() -> void: selected_kitchen_id = _fill_option(kitchen_option, _rows(&"kitchens"), &"facility_id", selected_kitchen_id, "完成した厨房がありません") var row := _row(&"kitchens", &"facility_id", selected_kitchen_id) @@ -365,7 +453,13 @@ func _show_result(result: Variant, context: Dictionary) -> void: _set_result(_reason_text(details.get(&"reason", &"UNAVAILABLE")), false) return var payload: Dictionary = context[&"payload"] - if context[&"type"] == PRODUCTION: + if context[&"type"] == AUTO_PURCHASE: + var warehouse_id := StringName(payload.get(&"warehouse_id", &"")) + var item_id := StringName(payload.get(&"item_id", &"")) + _auto_purchase_drafts.erase("%d:%s%d:%s" % [String(warehouse_id).length(), warehouse_id, String(item_id).length(), item_id]) + _set_result("%s:自動仕入れ%s、目標在庫 %dに変更しました。" % [ + _facility_by_id(warehouse_id), "ON" if bool(details.get(&"enabled", false)) else "OFF", details.get(&"target_stock", 0)], true) + elif context[&"type"] == PRODUCTION: var id := StringName(payload.get(&"facility_id", &"")) _kitchen_drafts.erase(id) _set_result("%s:自動生産%s、目標在庫 %dに変更しました。" % [_facility_by_id(id), "ON" if bool(details.get(&"enabled", payload.get(&"enabled", false))) else "OFF", details.get(&"target_stock", payload.get(&"target_stock", 0))], true) @@ -382,13 +476,13 @@ func _synchronize_commands(queue: Variant) -> void: _sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1) for command: Dictionary in queue.create_snapshot().get("pending", []): var type := StringName(command.get("command_type", &"")) - if type in [PURCHASE, PRODUCTION]: + if type in [PURCHASE, PRODUCTION, AUTO_PURCHASE]: _pending_commands[int(command.get("command_id", 0))] = {&"type": type, &"payload": command.get("payload", {}).duplicate(true)} func _has_pending(type: StringName, facility_id: StringName = &"") -> bool: for context: Dictionary in _pending_commands.values(): - if context[&"type"] == type and (facility_id.is_empty() or StringName(context[&"payload"].get(&"facility_id", &"")) == facility_id): + if context[&"type"] == type and (facility_id.is_empty() or StringName(context[&"payload"].get(&"facility_id", context[&"payload"].get(&"warehouse_id", &""))) == facility_id): return true return false diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd index ed4f3ca..6f4644c 100644 --- a/scripts/world/path_service.gd +++ b/scripts/world/path_service.gd @@ -1306,6 +1306,12 @@ func find_nearest_walkable_recovery( func create_snapshot() -> Dictionary: + # A nested sorted-name read can otherwise abort to an empty typed array + # while this outer capture still returns a seemingly valid Dictionary. + for transit_actor_id: Variant in _transit_actor_ids: + if typeof(transit_actor_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {} + var connector_ids: Array = _connectors.keys() connector_ids.sort_custom(Callable(self, "_string_name_less")) var saved_connectors: Array = [] diff --git a/tests/environment/test_runtime_cleanliness.gd b/tests/environment/test_runtime_cleanliness.gd index cb6937a..9556ae9 100644 --- a/tests/environment/test_runtime_cleanliness.gd +++ b/tests/environment/test_runtime_cleanliness.gd @@ -51,6 +51,7 @@ func run() -> void: expect(not runtime.restore_snapshot(bad) and runtime.create_snapshot()==intact, "missing live facility cleanliness rejects") var legacy: Dictionary=intact.duplicate(true) legacy[&"systems"][&"runtime"][&"runtime_schema_version"]=11 + preload("res://tests/fixtures/pre_auto_purchase_runtime.gd").strip_empty(legacy[&"systems"][&"runtime"]) legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") expect(runtime.restore_snapshot(legacy) and runtime.facility_cleanliness.get_facility(id)["current"]==100, "v11 initializes cleanliness without guessing past dirt") expect(runtime.create_snapshot()[&"systems"][&"runtime"][&"weather"]==intact[&"systems"][&"runtime"][&"weather"], "v11 weather survives migration unchanged") diff --git a/tests/environment/test_runtime_weather.gd b/tests/environment/test_runtime_weather.gd index 579ece1..b0cc358 100644 --- a/tests/environment/test_runtime_weather.gd +++ b/tests/environment/test_runtime_weather.gd @@ -16,6 +16,7 @@ func run() -> void: var original: Dictionary = runtime.create_snapshot() var legacy := original.duplicate(true) legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 10 + preload("res://tests/fixtures/pre_auto_purchase_runtime.gd").strip_empty(legacy[&"systems"][&"runtime"]) legacy[&"systems"][&"runtime"].erase(&"weather") legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute") diff --git a/tests/fixtures/pre_auto_purchase_runtime.gd b/tests/fixtures/pre_auto_purchase_runtime.gd new file mode 100644 index 0000000..de22702 --- /dev/null +++ b/tests/fixtures/pre_auto_purchase_runtime.gd @@ -0,0 +1,8 @@ +extends RefCounted + +## A genuine pre-v14 fixture must have no player automatic purchase intent. +static func strip_empty(data: Dictionary) -> void: + assert(data.has(&"auto_purchase_plans")) + var plans: Dictionary = data[&"auto_purchase_plans"] + assert(plans[&"version"] == 1 and (plans[&"plans"] as Array).is_empty()) + data.erase(&"auto_purchase_plans") diff --git a/tests/fixtures/pre_auto_purchase_runtime.gd.uid b/tests/fixtures/pre_auto_purchase_runtime.gd.uid new file mode 100644 index 0000000..e850e14 --- /dev/null +++ b/tests/fixtures/pre_auto_purchase_runtime.gd.uid @@ -0,0 +1 @@ +uid://dqjyetek8aurm diff --git a/tests/fixtures/pre_upkeep_runtime.gd b/tests/fixtures/pre_upkeep_runtime.gd index f8ba547..69e26a1 100644 --- a/tests/fixtures/pre_upkeep_runtime.gd +++ b/tests/fixtures/pre_upkeep_runtime.gd @@ -2,6 +2,7 @@ extends RefCounted ## Build a genuine pre-v10 fixture only when no upkeep ledger history exists. static func strip_empty_upkeep(data: Dictionary) -> void: + preload("res://tests/fixtures/pre_auto_purchase_runtime.gd").strip_empty(data) for entry: Dictionary in data[&"economy"][&"entries"]: assert(String(entry.get(&"category", "")) not in ["MAINTENANCE_ACCRUAL", "MAINTENANCE_PAYMENT"]) data.erase(&"weather") diff --git a/tests/mvp/test_runtime_content_progression.gd b/tests/mvp/test_runtime_content_progression.gd index 2121792..895ffca 100644 --- a/tests/mvp/test_runtime_content_progression.gd +++ b/tests/mvp/test_runtime_content_progression.gd @@ -404,7 +404,7 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: _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"]) == 13, "v3 migration emits canonical Runtime schema v13") + _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 14, "v3 migration emits canonical Runtime schema v14") var digest := SimulationDigest.compute(migrated) _expect(_runtime.restore_snapshot(migrated), "canonical v9 snapshot restores") _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v9 restore is exact") diff --git a/tests/mvp/test_runtime_operations_acceptance.gd b/tests/mvp/test_runtime_operations_acceptance.gd index a69526d..e30601f 100644 --- a/tests/mvp/test_runtime_operations_acceptance.gd +++ b/tests/mvp/test_runtime_operations_acceptance.gd @@ -1,7 +1,7 @@ extends SceneTree const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY -const RUNTIME_SNAPSHOT_VERSION := 13 +const RUNTIME_SNAPSHOT_VERSION := 14 var _checks := 0 var _failures := 0 @@ -39,7 +39,7 @@ func _new_fixture(seed: int, enable_operations := true) -> Dictionary: int(initial_snapshot[&"systems"][&"runtime"].get( &"runtime_schema_version", -1 )) == RUNTIME_SNAPSHOT_VERSION, - "fixture emits Runtime snapshot schema v10" + "fixture emits Runtime snapshot schema v14" ) if enable_operations: var enabled: Dictionary = runtime.enable_production_operations() diff --git a/tests/mvp/test_runtime_operations_integration.gd b/tests/mvp/test_runtime_operations_integration.gd index eaee7f1..fe173c9 100644 --- a/tests/mvp/test_runtime_operations_integration.gd +++ b/tests/mvp/test_runtime_operations_integration.gd @@ -58,7 +58,7 @@ func _test_disabled_compatibility_and_enable() -> void: _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"]) == 13, "Runtime snapshot advances to schema v13") + _expect(int(disabled_runtime[&"runtime_schema_version"]) == 14, "Runtime snapshot advances to schema v14") _expect( disabled_runtime[&"operations"] == { &"wrapper_version": 1, diff --git a/tests/performance/test_digest_performance.gd b/tests/performance/test_digest_performance.gd index 4063a94..5b3cc0b 100644 --- a/tests/performance/test_digest_performance.gd +++ b/tests/performance/test_digest_performance.gd @@ -25,9 +25,11 @@ func _run() -> void: func _test_value_types() -> void: + # Escaped NUL was parsed as U+FFFD; keep identical values without import diagnostics. + # Runtime NUL-attempt coverage remains in _test_text_chunk_boundaries. var values: Array = [ null, false, true, -9223372036854775807, 9223372036854775807, - 0, 0.0, -0.0, -0.125, 1.25e100, "", "厨房\u0000🌳", &"", &"運搬", + 0, 0.0, -0.0, -0.125, 1.25e100, "", "厨房" + String.chr(0xfffd) + "🌳", &"", &"運搬", Vector2(-1.5, 2.25), Vector2i(-3, 7), Rect2(-1.5, 2.25, 3.5, 4.75), Rect2i(-3, 7, 2, 8), Vector3(-1.5, 2.25, 3.5), Vector3i(-3, 7, 2), Vector4(-1.5, 2.25, 3.5, 4.75), Vector4i(-3, 7, 2, 8), Color(0.25, 0.5, 0.75, 1), @@ -53,7 +55,7 @@ func _test_dictionary_layouts() -> void: # keep their type tags apart even when the textual schemas are identical. for index in 60: var row := {} - var keys: Array = ["z", "aa", "日本語", "a", "\u0000", "", "001"] + var keys: Array = ["z", "aa", "日本語", "a", String.chr(0xfffd), "", "001"] if index % 2 == 1: keys.reverse() for key: String in keys: diff --git a/tests/s1/test_path_snapshot_transit_guard.gd b/tests/s1/test_path_snapshot_transit_guard.gd new file mode 100644 index 0000000..952bce9 --- /dev/null +++ b/tests/s1/test_path_snapshot_transit_guard.gd @@ -0,0 +1,48 @@ +extends SceneTree +## A corrupt transit ID must reject the entire save projection without losing +## real cabin occupants or making the infrastructure collision check fail open. +var checks := 0 +var failures := 0 + + +func _initialize() -> void: + call_deferred("run") + + +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(label) + + +func run() -> void: + var path := PathService.new() + var cells: Array[Vector3i] = [Vector3i.ZERO, Vector3i(0, 1, 0)] + expect(path.rebuild_graph(cells), "build a normal graph") + expect(path.configure_elevator(&"lift", cells[0], cells[1], {&"cabin_capacity": 2}), "configure a real cabin") + for actor: StringName in [&"guest:a", &"guest:b"]: + expect(path.project_actor_position(actor, cells[0], 10), "position cabin passenger") + expect(path.enqueue_elevator_request(&"lift", { + &"request_id": StringName("ride." + String(actor)), &"unit_id": actor, + &"floor_from": 0, &"floor_to": 1, &"enqueued_at": 10, + })[&"ok"], "queue real passenger") + expect(path.dequeue_elevator_boarding(&"lift", 0, 11, 2).size() == 2, "board both passengers") + var valid: Dictionary = path.create_snapshot() + expect(valid[&"transit_actor_ids"].size() == 2, "normal save keeps both cabin occupants") + for bad_id: Variant in [123, true, 1.25, Vector3i(3, 2, 1), null]: + path._transit_actor_ids[bad_id] = true + var before := var_to_bytes(path._transit_actor_ids) + var saved: Dictionary = path.create_snapshot() + expect(saved.is_empty(), "unsupported transit ID rejects the whole projection") + expect(var_to_bytes(path._transit_actor_ids) == before, "refusal preserves all transit IDs") + expect(FacilityInfrastructureProjector._has_managed_namespace_collision(SpatialSubslotService.new(), path, {}), "consumer refuses rejected snapshot") + saved[&"caller"] = true + expect(path.create_snapshot().is_empty(), "repeat refusal returns a new empty projection") + path._transit_actor_ids.erase(bad_id) + expect(var_to_bytes(path.create_snapshot()) == var_to_bytes(valid), "correction recovers both original occupants") + var restored := PathService.new() + expect(restored.restore_snapshot(valid) and restored.rebuild_graph(cells), "save restores real passenger references") + expect(var_to_bytes(restored.create_snapshot()) == var_to_bytes(valid), "restored full save matches original") + print("Path snapshot transit guard: %d checks, %d failures" % [checks, failures]) + quit(1 if failures else 0) diff --git a/tests/s1/test_path_snapshot_transit_guard.gd.uid b/tests/s1/test_path_snapshot_transit_guard.gd.uid new file mode 100644 index 0000000..61433dc --- /dev/null +++ b/tests/s1/test_path_snapshot_transit_guard.gd.uid @@ -0,0 +1 @@ +uid://cqhxb665hdby6 diff --git a/tests/s1/test_runtime_infrastructure_integration.gd b/tests/s1/test_runtime_infrastructure_integration.gd index fb3ed15..9f336c2 100644 --- a/tests/s1/test_runtime_infrastructure_integration.gd +++ b/tests/s1/test_runtime_infrastructure_integration.gd @@ -1,6 +1,6 @@ extends SceneTree -const RUNTIME_SNAPSHOT_VERSION := 13 +const RUNTIME_SNAPSHOT_VERSION := 14 const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ &"excavation", &"spatial_subslots", @@ -186,7 +186,7 @@ func _test_runtime_surface(fixture: Dictionary) -> bool: var snapshot: Dictionary = runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] var current_version := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION - _expect(current_version, "Runtime emits schema v13 with infrastructure and weather state") + _expect(current_version, "Runtime emits schema v14 with infrastructure and weather state") ready = ready and current_version for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: var valid_dictionary := runtime_data.get(key, null) is Dictionary @@ -923,7 +923,7 @@ func _test_v5_proxy_migration_determinism() -> void: _expect(first_runtime.restore_snapshot(legacy), "v5 proxy snapshot migrates through Runtime restore") var first_migrated: Dictionary = first_runtime.create_snapshot() _expect( - int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 13, + int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 14, "v5 migration is immediately re-emitted as v9" ) _expect( diff --git a/tests/s3/test_runtime_path_traffic.gd b/tests/s3/test_runtime_path_traffic.gd index 0cec20c..92c6dad 100644 --- a/tests/s3/test_runtime_path_traffic.gd +++ b/tests/s3/test_runtime_path_traffic.gd @@ -318,7 +318,7 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _clock.advance_one_minute() var snapshot: Dictionary = _runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] - _expect(int(runtime_data[&"runtime_schema_version"]) == 13, "Runtime snapshot advertises schema v13") + _expect(int(runtime_data[&"runtime_schema_version"]) == 14, "Runtime snapshot advertises schema v14") _expect(not runtime_data[&"movement_credits"].is_empty(), "mid-move snapshot persists fixed-point movement credits") var digest := SimulationDigest.compute(snapshot) _expect(_runtime.restore_snapshot(snapshot), "valid mid-move v9 snapshot restores") @@ -376,7 +376,7 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _expect(_runtime.restore_snapshot(legacy_v2), "legacy Runtime v2 deterministically rebuilds Path actors and credits") var migrated: Dictionary = _runtime.create_snapshot() _expect( - int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 13 + int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 14 and migrated[&"systems"][&"runtime"][&"path"][&"actor_cells"].size() == 2 and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2 and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]), diff --git a/tests/s5/test_runtime_event_defense.gd b/tests/s5/test_runtime_event_defense.gd index e03724b..34f1016 100644 --- a/tests/s5/test_runtime_event_defense.gd +++ b/tests/s5/test_runtime_event_defense.gd @@ -129,12 +129,13 @@ func _test_save_branch_provenance_corruption_and_legacy() -> void: _expect(_submit(&"create_defense_raid", {&"raid_id": &"raid.legacy.manual", &"definition": _threat_definition(&"objective.event.legacy")}).is_success(), "real manual raid exists before legacy provenance migration") var legacy: Dictionary = _runtime.create_snapshot() legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 12 + preload("res://tests/fixtures/pre_auto_purchase_runtime.gd").strip_empty(legacy[&"systems"][&"runtime"]) (legacy[&"systems"][&"runtime"][&"defense"][&"raids"] as Array)[0].erase(&"event_source") var clean: Dictionary = legacy[&"systems"][&"runtime"][&"facility_cleanliness"].duplicate(true) _expect(_runtime.restore_snapshot(legacy) and _runtime.facility_cleanliness.create_snapshot() == clean, "version-12 save keeps its required cleaning state when migrating to event schema 13") _expect(not clean["facilities"].is_empty() and _runtime.defense_service.get_raid(&"raid.legacy.manual")[&"event_source"].is_empty(), "legacy migration preserves a nonempty cleanliness participant and old manual raid") var migrated: Dictionary = _runtime.create_snapshot() - _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "legacy12 re-emits current13 without losing required12 data") + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 14, "legacy12 re-emits current14 without losing required12 data") var missing_clean: Dictionary = legacy.duplicate(true) missing_clean[&"systems"][&"runtime"].erase(&"facility_cleanliness") _expect(not _runtime.restore_snapshot(missing_clean) and _runtime.create_snapshot() == migrated, "legacy12 missing mandatory cleanliness is rejected atomically") @@ -143,7 +144,7 @@ func _test_save_branch_provenance_corruption_and_legacy() -> void: _expect(not _runtime.restore_snapshot(malformed_clean) and _runtime.create_snapshot() == migrated, "legacy12 malformed mandatory cleanliness is rejected atomically") var future: Dictionary = _runtime.create_snapshot() var current: Dictionary = future.duplicate(true) - future[&"systems"][&"runtime"][&"runtime_schema_version"] = 14 + future[&"systems"][&"runtime"][&"runtime_schema_version"] = 15 _expect(not _runtime.restore_snapshot(future) and _runtime.create_snapshot() == current, "unknown future runtime schema remains rejected") diff --git a/tests/s6/test_runtime_snapshot_serialization.gd b/tests/s6/test_runtime_snapshot_serialization.gd new file mode 100644 index 0000000..5b4061f --- /dev/null +++ b/tests/s6/test_runtime_snapshot_serialization.gd @@ -0,0 +1,121 @@ +extends SceneTree + +# Genuine Runtime command fixture. No direct raid, RNG, ID or event mutation. +# JSON/old untyped-wave compatibility is a separate Defense input contract. +var checks := 0 +var failures := 0 +var runtime: Variant +var clock: Variant +var state: Variant + +func _initialize() -> void: + call_deferred("run") + +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("Runtime snapshot serialization: " + label) + +func run() -> void: + runtime = load("res://autoload/simulation_runtime.gd").new() + clock = load("res://autoload/game_clock.gd").new() + state = load("res://autoload/game_state.gd").new() + clock.reset(1, 6, 0) + state.reset() + var initialized: Dictionary = runtime.initialize(clock, state, 4260214) + clock.set_paused(true) + expect(initialized.get(&"valid", false), "current Runtime initializes") + if not initialized.get(&"valid", false): + finish() + return + var created: CommandResult = runtime.submit_command(&"create_defense_raid", { + &"raid_id": &"serialization.raid", + &"definition": { + &"source_id": &"serialization.disclosed_fixture", + &"entry_points": [&"gate.north"], + &"objective_ids": [&"serialization.objective"], + &"round_minutes": 5, + &"max_duration_minutes": 30, + &"retreat_policy": &"HOLD", + &"defender_retreat_threshold": 0, + &"terrain_bonus": 0, + &"command_bonus": 0, + &"surprise_penalty": 0, + &"multiple_entry_penalty": 0, + &"waves": [{&"wave_id": &"serialization.wave", &"threat_strength": 10, &"max_rounds": 6, &"retreat_threshold": 0}], + }, + }) + expect(created.is_success(), "ordinary Runtime command creates genuine raid and wave") + if not created.is_success(): + finish() + return + var expected: Dictionary = runtime.create_snapshot() + var data := runtime_data(expected) + expect(data[&"runtime_schema_version"] == runtime.RUNTIME_SNAPSHOT_VERSION, "snapshot is the actual current schema") + var waves: Array = saved_waves(expected) + expect(waves.is_typed() and waves.get_typed_builtin() == TYPE_DICTIONARY, "genuine created waves are Array[Dictionary]") + expect(waves.size() == 1 and waves[0][&"wave_id"] == &"serialization.wave", "ordinary source wave identity and order are explicit") + var rng_keys: Array[String] = [] + var all_rng_keys_are_strings := true + for key: Variant in (data[&"rng_streams"] as Dictionary).keys(): + all_rng_keys_are_strings = all_rng_keys_are_strings and typeof(key) == TYPE_STRING + rng_keys.append(String(key)) + var lexical_keys := rng_keys.duplicate() + lexical_keys.sort() + expect(all_rng_keys_are_strings and rng_keys == lexical_keys, "RNG snapshot String keys are lexically ordered") + var expected_bytes := var_to_bytes(expected) + for iteration: int in 3: + expect(runtime.restore_snapshot(expected), "same genuine checkpoint restore%d succeeds" % iteration) + var after: Dictionary = runtime.create_snapshot() + var after_data := runtime_data(after) + var after_waves: Array = saved_waves(after) + expect(after_waves.is_typed() and after_waves.get_typed_builtin() == TYPE_DICTIONARY, "restore%d retains wave container declaration" % iteration) + expect(var_to_bytes(after_waves) == var_to_bytes(waves), "restore%d retains all wave values and element order" % iteration) + expect(var_to_bytes(after_data[&"rng_streams"]) == var_to_bytes(data[&"rng_streams"]), "restore%d retains every RNG value and lexical key order" % iteration) + expect(var_to_bytes(after_data[&"id_generator"]) == var_to_bytes(data[&"id_generator"]) and var_to_bytes(after_data[&"domain_events"]) == var_to_bytes(data[&"domain_events"]), "restore%d retains all IDs and domain event sequence" % iteration) + expect(var_to_bytes(after) == expected_bytes, "restore%d is exact whole typed binary state" % iteration) + # Older Defense restore paths exported identical wave values with an untyped + # container. Such input stays loadable and re-exports the normal canonical type. + var legacy := expected.duplicate(true) + var untyped_waves: Array = [] + for wave: Dictionary in waves: + untyped_waves.append(wave.duplicate(true)) + runtime_data(legacy)[&"defense"][&"raids"][0][&"waves"] = untyped_waves + expect(not untyped_waves.is_typed() and var_to_bytes(legacy) != expected_bytes, "old untyped-wave input is an actual typed-byte difference") + expect(runtime.restore_snapshot(legacy), "old untyped-wave Runtime snapshot remains accepted") + var canonical: Dictionary = runtime.create_snapshot() + var canonical_waves: Array = saved_waves(canonical) + expect(canonical_waves.is_typed() and canonical_waves.get_typed_builtin() == TYPE_DICTIONARY, "old input exports canonical Dictionary wave type") + expect(var_to_bytes(canonical_waves) == var_to_bytes(waves), "old input preserves wave values and element order") + expect(var_to_bytes(canonical) == expected_bytes, "old input changes only missing wave declaration and reproduces genuine whole canonical state") + var defense_snapshot: Dictionary = runtime.defense_service.create_snapshot() + var json_input: Variant = JSON.parse_string(JSON.stringify(defense_snapshot)) + var json_restored := DefenseService.new() + expect(json_input is Dictionary and json_restored.restore_snapshot(json_input), "existing JSON Defense transport remains accepted") + var json_output: Dictionary = json_restored.create_snapshot() + var json_waves: Array = json_output[&"raids"][0][&"waves"] + expect(json_waves.is_typed() and json_waves.get_typed_builtin() == TYPE_DICTIONARY, "JSON waves export canonical Dictionary container type") + expect(JSON.stringify(json_output) == JSON.stringify(defense_snapshot), "JSON value/order transport contract is retained separately from typed binary transport") + var malformed: Dictionary = defense_snapshot.duplicate(true) + malformed[&"raids"][0][&"waves"] = [42] + var json_before := var_to_bytes(json_output) + expect(not json_restored.restore_snapshot(malformed) and var_to_bytes(json_restored.create_snapshot()) == json_before, "non-Dictionary wave input rejects and preserves every live typed byte") + var string_waves: Array[String] = ["malformed wave"] + malformed = defense_snapshot.duplicate(true) + malformed[&"raids"][0][&"waves"] = string_waves + expect(not json_restored.restore_snapshot(malformed) and var_to_bytes(json_restored.create_snapshot()) == json_before, "typed String waves reject without container mutation or type error") + finish() + +func runtime_data(snapshot: Dictionary) -> Dictionary: + return snapshot[&"systems"][&"runtime"] + +func saved_waves(snapshot: Dictionary) -> Array: + return runtime_data(snapshot)[&"defense"][&"raids"][0][&"waves"] + +func finish() -> void: + if runtime != null: runtime.free() + if clock != null: clock.free() + if state != null: state.free() + print("Runtime snapshot serialization: %d checks, %d failures" % [checks, failures]) + quit(failures) diff --git a/tests/s6/test_runtime_snapshot_serialization.gd.uid b/tests/s6/test_runtime_snapshot_serialization.gd.uid new file mode 100644 index 0000000..acfb90e --- /dev/null +++ b/tests/s6/test_runtime_snapshot_serialization.gd.uid @@ -0,0 +1 @@ +uid://dl6brsqwn5lr8 diff --git a/tests/s6/test_save_preflight.gd b/tests/s6/test_save_preflight.gd new file mode 100644 index 0000000..f15343c --- /dev/null +++ b/tests/s6/test_save_preflight.gd @@ -0,0 +1,222 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _prefix := "" +var _runtime: Variant +var _source: Dictionary + +func _initialize() -> void: call_deferred("_run") + +func _run() -> void: + _prefix = "user://tests/save_preflight_%d" % OS.get_process_id() + _runtime = root.get_node("SimulationRuntime") + var clock: Variant = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + clock.reset(1,6,0); state.reset(); _runtime.initialize(clock,state,42602); clock.set_paused(true) + _source = _runtime.create_snapshot() + _test_read_only_preview_and_recovery() + _test_manifest_types_and_versions() + _test_resource_limits_and_corruption() + _test_runtime_schema_rejection() + _test_envelope_rejection_and_legacy_defaults() + _measure_fixture_sizes() + print("Save preflight: %d checks, %d failures" % [_checks, _failures]) + quit(_failures) + +func _test_read_only_preview_and_recovery() -> void: + var path := _prefix + "/read_only" + _expect(SaveService.save_snapshot(path,_source)[&"ok"],"write valid disposable Runtime save") + var before := _slot_fingerprint(path) + var inspected := SaveService.inspect_snapshot(path) + _expect(inspected[&"ok"] and inspected[&"read_only"] and inspected[&"snapshot"] == _source,"inspection returns exact formal snapshot") + _expect(_slot_fingerprint(path) == before,"valid inspection leaves every package byte unchanged") + _write_marker(path); before = _slot_fingerprint(path) + inspected = SaveService.inspect_snapshot(path) + _expect(inspected[&"ok"] and _slot_fingerprint(path) == before,"inspection preserves stale marker and formal bytes") + var live: Dictionary = _runtime.create_snapshot() + var preview: Dictionary = _runtime.preview_facility_dimensions_at_path(path) + _expect(preview[&"ok"] and _runtime.create_snapshot() == live,"dimension preview preserves live Runtime") + _expect(_slot_fingerprint(path) == before,"dimension preview does not reconcile transaction artifacts") + _expect(SaveService.load_snapshot(path)[&"ok"] and not FileAccess.file_exists(ProjectSettings.globalize_path(path) + SaveService.SAVE_TRANSACTION_SUFFIX),"ordinary load retains interrupted-save reconciliation") + var absolute := ProjectSettings.globalize_path(path) + _expect(SaveService.save_snapshot(path+".tmp",_source)[&"ok"],"write verified interrupted candidate") + _expect(DirAccess.rename_absolute(absolute,absolute+".bak") == OK,"hold formal source at crash boundary") + _write_marker(path); before = _slot_fingerprint(path) + inspected = SaveService.inspect_snapshot(path) + _expect(not inspected[&"ok"] and inspected[&"code"] == &"SAVE_NOT_FOUND" and inspected[&"read_only"],"inspection refuses to promote missing formal") + preview = _runtime.preview_facility_dimensions_at_path(path) + _expect(not preview[&"ok"] and _runtime.create_snapshot() == live,"missing-formal preview refuses without live mutation") + _expect(_slot_fingerprint(path) == before,"rejected preview preserves candidate, backup and marker SHA") + var recovered := SaveService.load_snapshot(path) + _expect(recovered[&"ok"] and recovered[&"interrupted_save_reconciled"] and recovered[&"snapshot"] == _source,"ordinary load still promotes verified candidate") + SaveService.delete_save(path) + +func _test_manifest_types_and_versions() -> void: + var path := _prefix+"/manifest" + _expect(SaveService.save_snapshot(path,_source)[&"ok"],"write manifest fixture") + var manifest_path := ProjectSettings.globalize_path(path).path_join(SaveService.MANIFEST_FILE) + var original := FileAccess.get_file_as_string(manifest_path) + for key: String in ["format_version","uncompressed_size","checksum_world"]: + var values: Array = [true,"1",1.5,null] + if key == "format_version": values.append_array([-1,4294967296.0]) + if key == "uncompressed_size": values.append_array([0,-1]) + if key == "checksum_world": values = [true,123,null,"bad_hash"] + for value: Variant in values: + var manifest: Dictionary = JSON.parse_string(original) + manifest[key] = value + _write_text(manifest_path,JSON.stringify(manifest)) + _assert_read_rejection(path,&"INVALID_MANIFEST","malformed %s %s" % [key,str(value)]) + var missing: Dictionary = JSON.parse_string(original) + missing.erase("format_version"); _write_text(manifest_path,JSON.stringify(missing)) + _assert_read_rejection(path,&"INVALID_MANIFEST","missing format version") + missing = JSON.parse_string(original) + missing.erase("uncompressed_size"); _write_text(manifest_path,JSON.stringify(missing)) + _expect(SaveService.inspect_snapshot(path)[&"ok"],"legacy missing duplicate size retains bounded binary compatibility") + _write_text(manifest_path,original) + for version: int in [0,2]: + var manifest: Dictionary = JSON.parse_string(original) + manifest["format_version"] = version; _write_text(manifest_path,JSON.stringify(manifest)) + var world := FileAccess.open(ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE),FileAccess.READ_WRITE) + world.seek(4); world.store_32(version); world = null + _assert_read_rejection(path,&"UNSUPPORTED_FORMAT","old or unknown package version %d" % version) + var before := _slot_fingerprint(path) + var migration := SaveService.migrate_save(path,SaveMigrationRegistry.new()) + _expect(not migration[&"ok"] and _slot_fingerprint(path) == before,"unregistered old migration or future downgrade preserves source") + SaveService.delete_save(path) + +func _test_resource_limits_and_corruption() -> void: + var path := _prefix+"/limits" + _expect(SaveService.save_snapshot(path,_source)[&"ok"],"write resource boundary fixture") + var absolute := ProjectSettings.globalize_path(path) + var world_path := absolute.path_join(SaveService.WORLD_FILE) + var manifest_path := absolute.path_join(SaveService.MANIFEST_FILE) + var original_world := FileAccess.get_file_as_bytes(world_path) + var original_manifest := FileAccess.get_file_as_string(manifest_path) + for size: int in [0,-1,SaveService.MAX_WORLD_BYTES,SaveService.MAX_WORLD_BYTES+1]: + _write_bytes(world_path,original_world) + var world := FileAccess.open(world_path,FileAccess.READ_WRITE) + world.seek(8); world.store_64(size); world = null + # The inclusive limit reaches the independent manifest mismatch before + # decompression; over-limit values reject before any expansion allocation. + var code: StringName = &"SIZE_MISMATCH" if size == SaveService.MAX_WORLD_BYTES else &"SAVE_SIZE_LIMIT_EXCEEDED" + _assert_read_rejection(path,code,"advertised expansion boundary %d" % size) + _write_bytes(world_path,original_world) + var world := FileAccess.open(world_path,FileAccess.READ_WRITE) + world.seek(SaveService.MAX_WORLD_BYTES+16); world.store_8(0); world = null + _assert_read_rejection(path,&"SAVE_SIZE_LIMIT_EXCEEDED","oversized compressed file rejected before bulk read") + _write_bytes(world_path,original_world) + _write_text(manifest_path," ".repeat(SaveService.MAX_MANIFEST_BYTES+1)) + _assert_read_rejection(path,&"SAVE_SIZE_LIMIT_EXCEEDED","oversized manifest rejected before text read") + _write_text(manifest_path,"{invalid JSON") + _assert_read_rejection(path,&"INVALID_MANIFEST","malformed JSON") + _write_text(manifest_path,original_manifest) + var corrupt := original_world.duplicate() + corrupt[corrupt.size()-1] = corrupt[corrupt.size()-1] ^ 0xff + _write_bytes(world_path,corrupt); _assert_read_rejection(path,&"CHECKSUM_MISMATCH","corrupt world checksum") + _write_bytes(world_path,original_world.slice(0,15)); _assert_read_rejection(path,&"INVALID_WORLD_HEADER","truncated binary header") + _write_bytes(world_path,original_world) + var before := _slot_fingerprint(path) + var too_large := SaveService.save_snapshot(path,_source,{&"custom_note":"x".repeat(SaveService.MAX_MANIFEST_BYTES)}) + _expect(not too_large[&"ok"] and too_large[&"code"] == &"SAVE_SIZE_LIMIT_EXCEEDED","writer refuses manifest beyond reader resource limit") + _expect(_slot_fingerprint(path) == before,"oversized save preserves formal and backup SHA") + SaveService.delete_save(path) + +func _test_runtime_schema_rejection() -> void: + for mutation: String in ["unknown","missing","string","extra"]: + var candidate := _source.duplicate(true) + var data: Dictionary = candidate[&"systems"][&"runtime"] + match mutation: + "unknown": data[&"runtime_schema_version"] = 999 + "missing": data.erase(&"facilities") + "string": data[&"runtime_schema_version"] = "13" + "extra": data[&"unknown_feature"] = {} + var original := candidate.duplicate(true) + var live: Dictionary = _runtime.create_snapshot() + _expect(not _runtime.restore_snapshot(candidate),"Runtime rejects %s current-schema payload" % mutation) + _expect(candidate == original and _runtime.create_snapshot() == live,"Runtime rejection preserves source and live state for %s" % mutation) + +func _measure_fixture_sizes() -> void: + var raw := var_to_bytes(_source) + var world := WorldService.new() + var harness := preload("res://tests/performance/perf_mvp_001_harness.gd").new() + var cells: Array[Vector3i] = harness._populate_world(world,Vector2i(200,50)) + var world_raw := var_to_bytes(world.create_snapshot()) + _expect(cells.size() == 20000,"resource sizing uses existing PERF 20000-cell world fixture") + print("SAVE_RESOURCE_SIZE_REPORT=%s" % JSON.stringify({ + "starter_runtime_raw_bytes":raw.size(),"starter_runtime_compressed_bytes":raw.compress(FileAccess.COMPRESSION_ZSTD).size(), + "world_cells":cells.size(),"world_only_raw_bytes":world_raw.size(),"world_only_compressed_bytes":world_raw.compress(FileAccess.COMPRESSION_ZSTD).size(), + "world_limit_bytes":SaveService.MAX_WORLD_BYTES,"manifest_limit_bytes":SaveService.MAX_MANIFEST_BYTES,"full_runtime_20000_cell_acceptance":false, + })) + +func _test_envelope_rejection_and_legacy_defaults() -> void: + var clock: Variant = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + var source := SimulationSnapshot.capture(clock,state) + for row: Array in [ + [&"schema_version","1"],[&"schema_version",true],[&"schema_version",1.5], + [&"absolute_minute","360"],[&"absolute_minute",-1],[&"absolute_minute",360.5], + [&"speed",true],[&"speed",1.5],[&"speed",0],[&"paused","false"], + [&"snapshot_version",-1],[&"snapshot_version","0"],[&"snapshot_version",1.5], + ]: + var candidate := source.duplicate(true) + if row[0] == &"schema_version": candidate[row[0]] = row[1] + else: candidate[&"clock"][row[0]] = row[1] + var original := candidate.duplicate(true) + var live: Dictionary = _runtime.create_snapshot() + _expect(not SimulationSnapshot.validate(candidate)[&"ok"],"envelope rejects %s = %s" % [row[0],str(row[1])]) + _expect(not SimulationSnapshot.restore(candidate,clock,state) and not _runtime.restore_snapshot(candidate),"invalid envelope refuses both restore entrypoints") + _expect(candidate == original and _runtime.create_snapshot() == live,"envelope rejection preserves source and complete live Runtime") + var json_numeric := source.duplicate(true) + json_numeric[&"schema_version"] = 1.0 + json_numeric[&"clock"][&"absolute_minute"] = 360.0 + json_numeric[&"clock"][&"speed"] = 1.0 + json_numeric[&"clock"][&"snapshot_version"] = 0.0 + _expect(SimulationSnapshot.validate(json_numeric)[&"ok"],"exact integral JSON numbers remain valid generic envelopes") + var legacy := source.duplicate(true) + legacy[&"clock"].erase(&"paused"); legacy[&"clock"].erase(&"snapshot_version") + _expect(SimulationSnapshot.validate(legacy)[&"ok"],"absent legacy pause/version retain compatible defaults") + legacy[&"clock"][&"speed"] = 4 + _expect(SimulationSnapshot.restore(legacy,clock,state) and clock.speed == 3,"legacy x4 still resumes at x3 through validated envelope") + _expect(_runtime.restore_snapshot(_source),"restore original live state after legacy compatibility probe") + +func _assert_read_rejection(path: String,code: StringName,label: String) -> void: + var before := _slot_fingerprint(path) + var inspected := SaveService.inspect_snapshot(path) + _expect(not inspected[&"ok"] and inspected[&"code"] == code,label+": correct rejection") + _expect(_slot_fingerprint(path) == before,label+": all disk SHA unchanged") + +func _slot_fingerprint(path: String) -> Dictionary: + var absolute := ProjectSettings.globalize_path(path) + var parent := DirAccess.open(absolute.get_base_dir()) + var result := {} + if parent == null: return result + for file: String in parent.get_files(): + if file.begins_with(absolute.get_file()): result[file] = FileAccess.get_sha256(absolute.get_base_dir().path_join(file)) + for directory: String in parent.get_directories(): + if directory.begins_with(absolute.get_file()): result[directory+"/"] = _tree_fingerprint(absolute.get_base_dir().path_join(directory)) + return result + +func _tree_fingerprint(path: String) -> Dictionary: + var dir := DirAccess.open(path) + var result := {} + for file: String in dir.get_files(): result[file] = FileAccess.get_sha256(path.path_join(file)) + for child: String in dir.get_directories(): result[child+"/"] = _tree_fingerprint(path.path_join(child)) + return result + +func _write_marker(path: String) -> void: + _write_text(ProjectSettings.globalize_path(path)+SaveService.SAVE_TRANSACTION_SUFFIX,SaveService.SAVE_TRANSACTION_MARKER) + +func _write_text(path: String,value: String) -> void: + var file := FileAccess.open(path,FileAccess.WRITE) + file.store_string(value) + +func _write_bytes(path: String,value: PackedByteArray) -> void: + var file := FileAccess.open(path,FileAccess.WRITE) + file.store_buffer(value) + +func _expect(ok: bool,label: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(label) diff --git a/tests/s6/test_save_preflight.gd.uid b/tests/s6/test_save_preflight.gd.uid new file mode 100644 index 0000000..8fb081e --- /dev/null +++ b/tests/s6/test_save_preflight.gd.uid @@ -0,0 +1 @@ +uid://bn6w8j53gr8ql diff --git a/tests/s6/test_save_recovery_preservation.gd b/tests/s6/test_save_recovery_preservation.gd new file mode 100644 index 0000000..6919735 --- /dev/null +++ b/tests/s6/test_save_recovery_preservation.gd @@ -0,0 +1,145 @@ +extends SceneTree +# Candidate acceptance/reproduction driver. Disposable user:// fixtures only. +# On the unpatched base, EXPECT failures expose the two preservation defects. +var _checks := 0 +var _failures := 0 +var _source: Dictionary +var _prefix := "" +func _initialize() -> void: call_deferred("_run") +func _run() -> void: + _prefix = "user://tests/recovery_readonly_%d" % OS.get_process_id() + var clock: Variant = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + clock.reset(1,6,0); clock.set_paused(true); state.reset() + _source = SimulationSnapshot.capture(clock,state) + _test_explicit_read_only() + _test_identified_unknown_preservation() + _test_current_corruption_recovery() + _test_registered_legacy_migration() + print("Recovery read-only candidate: %d checks, %d failures" % [_checks,_failures]) + quit(_failures) +func _test_explicit_read_only() -> void: + for formal_present: bool in [true,false]: + var path := _prefix + "/explicit_%s" % str(formal_present) + _setup(path) + if not formal_present: _remove_tree(ProjectSettings.globalize_path(path)) + var before := _fingerprint(path) + var result := SaveService.load_recovery_candidate(path,&"backup") + _expect(result[&"ok"] and result[&"read_only"] and result[&"snapshot"] == _source,"explicit backup is readable with/without formal") + _expect(_fingerprint(path)==before,"explicit backup selection never promotes, removes or migrates artifacts") + var normal := SaveService.load_snapshot(path) + _expect(normal[&"ok"],"ordinary load still reconciles stale marker or missing formal") + _expect(not FileAccess.file_exists(ProjectSettings.globalize_path(path)+SaveService.SAVE_TRANSACTION_SUFFIX),"ordinary load owns marker cleanup") + _cleanup(path) +func _test_identified_unknown_preservation() -> void: + var operations := ["load_snapshot","load_with_recovery","inspect_recovery_candidates","migrate_save","load_recovery_candidate","save_snapshot","replace_snapshot_with_backup","inspect_snapshot"] + for variant: String in ["matching_future","corrupt_future","header_only_future","manifest_only_future","unsupported_legacy"]: + for operation: String in operations: + var path := _prefix + "/%s_%s" % [variant,operation] + _setup(path) + var version := 0 if variant=="unsupported_legacy" else 2 + _set_version(path,version,variant!="header_only_future",variant!="manifest_only_future") + if variant=="header_only_future": _write_text(ProjectSettings.globalize_path(path).path_join(SaveService.MANIFEST_FILE),"{bad json") + if variant=="corrupt_future": + var world_path := ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE) + var bytes := FileAccess.get_file_as_bytes(world_path) + bytes[bytes.size()-1] = bytes[bytes.size()-1] ^ 0xff + _write_bytes(world_path,bytes) + var before := _fingerprint(path) + var result: Dictionary + match operation: + "load_snapshot": result=SaveService.load_snapshot(path) + "load_with_recovery": result=SaveService.load_with_recovery(path) + "inspect_recovery_candidates": result=SaveService.inspect_recovery_candidates(path) + "migrate_save": result=SaveService.migrate_save(path,SaveMigrationRegistry.new()) + "load_recovery_candidate": result=SaveService.load_recovery_candidate(path,&"backup") + "save_snapshot": result=SaveService.save_snapshot(path,_source) + "replace_snapshot_with_backup": + var candidate := _source.duplicate(true) + candidate[&"clock"][&"absolute_minute"] += 1 + result=SaveService.replace_snapshot_with_backup(path,_source,candidate) + "inspect_snapshot": result=SaveService.inspect_snapshot(path) + _expect(not result[&"ok"],"%s rejects %s formal instead of silently loading old backup" % [operation,variant]) + _expect(_fingerprint(path)==before,"%s preserves formal and every related artifact for %s" % [operation,variant]) + _cleanup(path) +func _test_current_corruption_recovery() -> void: + var path := _prefix + "/current_corruption" + _setup(path) + var world_path := ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE) + var bytes := FileAccess.get_file_as_bytes(world_path);bytes[bytes.size()-1] = bytes[bytes.size()-1] ^ 0xff + _write_bytes(world_path,bytes) + var result := SaveService.load_snapshot(path) + _expect(result[&"ok"] and result[&"interrupted_save_reconciled"],"damaged recognized current formal retains authorized transaction recovery") + _expect(result[&"snapshot"] == _source,"recognized current transaction recovers exact verified snapshot") + _cleanup(path) +func _test_registered_legacy_migration() -> void: + var path := _prefix + "/registered_legacy" + _setup(path);_set_version(path,0,true,true) + var before_world := FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(path).path_join(SaveService.WORLD_FILE)) + var before_artifacts := _fingerprint(path) + var registry := SaveMigrationRegistry.new() + _expect(registry.register_step(0,&"registered_zero_to_one",Callable(self,"_migrate_legacy"))[&"ok"],"register explicit known legacy migration") + var result := SaveService.migrate_save(path,registry) + _expect(result[&"ok"] and result[&"backup_created"],"explicit known legacy chain remains supported") + _expect(FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(path+SaveService.MIGRATION_BACKUP_SUFFIX).path_join(SaveService.WORLD_FILE))==before_world,"legacy migration preserves exact original binary backup") + _expect(SaveService.inspect_snapshot(path)[&"ok"],"migrated formal is current-loadable") + var after_artifacts := _fingerprint(path) + var owned_name := ProjectSettings.globalize_path(path).get_file() + for suffix: String in [".tmp/",".bak/",SaveService.SAVE_TRANSACTION_SUFFIX]: + _expect(after_artifacts.get(owned_name+suffix)==before_artifacts.get(owned_name+suffix),"registered legacy migration does not pre-clean stale normal artifact "+suffix) + _cleanup(path) + path = _prefix + "/registered_legacy_failure" + _setup(path);_set_version(path,0,true,true) + before_artifacts = _fingerprint(path) + registry = SaveMigrationRegistry.new() + registry.register_step(0,&"explicit_reject",Callable(self,"_reject_legacy")) + result = SaveService.migrate_save(path,registry) + _expect(not result[&"ok"] and result[&"code"]==&"FIXTURE_REJECTED","registered legacy callback failure is returned") + _expect(_fingerprint(path)==before_artifacts,"failed legacy migration preserves source and stale normal artifacts") + _cleanup(path) +func _migrate_legacy(payload: Dictionary,_from: int,_to: int) -> Dictionary:return payload +func _reject_legacy(_payload: Dictionary,_from: int,_to: int) -> Dictionary:return {&"ok":false,&"code":&"FIXTURE_REJECTED"} +func _setup(path: String) -> void: + _expect(SaveService.save_snapshot(path,_source)[&"ok"],"write disposable formal") + _expect(SaveService.save_snapshot(path+".bak",_source)[&"ok"],"write disposable verified backup") + _expect(SaveService.save_snapshot(path+".tmp",_source)[&"ok"],"write disposable verified candidate") + _write_text(ProjectSettings.globalize_path(path)+SaveService.SAVE_TRANSACTION_SUFFIX,SaveService.SAVE_TRANSACTION_MARKER) +func _set_version(path: String,version: int,manifest: bool,header: bool) -> void: + var absolute := ProjectSettings.globalize_path(path) + if manifest: + var value: Dictionary=JSON.parse_string(FileAccess.get_file_as_string(absolute.path_join(SaveService.MANIFEST_FILE))) + value["format_version"]=version;_write_text(absolute.path_join(SaveService.MANIFEST_FILE),JSON.stringify(value)) + if header: + var file:=FileAccess.open(absolute.path_join(SaveService.WORLD_FILE),FileAccess.READ_WRITE) + file.seek(4);file.store_32(version) +func _fingerprint(path: String) -> Dictionary: + var absolute:=ProjectSettings.globalize_path(path);var parent:=DirAccess.open(absolute.get_base_dir());var result:={} + for file: String in parent.get_files(): + if file.begins_with(absolute.get_file()):result[file]=FileAccess.get_sha256(absolute.get_base_dir().path_join(file)) + for directory: String in parent.get_directories(): + if directory.begins_with(absolute.get_file()):result[directory+"/"]=_tree(absolute.get_base_dir().path_join(directory)) + return result +func _tree(path: String) -> Dictionary: + var dir:=DirAccess.open(path);var result:={} + for file: String in dir.get_files():result[file]=FileAccess.get_sha256(path.path_join(file)) + for child: String in dir.get_directories():result[child+"/"]=_tree(path.path_join(child)) + return result +func _cleanup(path: String) -> void: + var absolute:=ProjectSettings.globalize_path(path);var parent:=DirAccess.open(absolute.get_base_dir()) + for file: String in parent.get_files(): + if file.begins_with(absolute.get_file()):DirAccess.remove_absolute(absolute.get_base_dir().path_join(file)) + for directory: String in parent.get_directories(): + if directory.begins_with(absolute.get_file()):_remove_tree(absolute.get_base_dir().path_join(directory)) +func _remove_tree(path: String) -> void: + var dir:=DirAccess.open(path) + if dir==null:return + for file: String in dir.get_files():DirAccess.remove_absolute(path.path_join(file)) + for child: String in dir.get_directories():_remove_tree(path.path_join(child)) + DirAccess.remove_absolute(path) +func _write_text(path: String,value: String) -> void: + var file:=FileAccess.open(path,FileAccess.WRITE);file.store_string(value) +func _write_bytes(path: String,value: PackedByteArray) -> void: + var file:=FileAccess.open(path,FileAccess.WRITE);file.store_buffer(value) +func _expect(ok: bool,label: String) -> void: + _checks+=1 + if not ok:_failures+=1;push_error(label) diff --git a/tests/s6/test_save_recovery_preservation.gd.uid b/tests/s6/test_save_recovery_preservation.gd.uid new file mode 100644 index 0000000..b7c030b --- /dev/null +++ b/tests/s6/test_save_recovery_preservation.gd.uid @@ -0,0 +1 @@ +uid://bn6i1dsht6xtp diff --git a/tests/supply/test_normal_auto_purchase_chain.gd b/tests/supply/test_normal_auto_purchase_chain.gd new file mode 100644 index 0000000..467a2fe --- /dev/null +++ b/tests/supply/test_normal_auto_purchase_chain.gd @@ -0,0 +1,262 @@ +extends SceneTree + +## A real inn from the default 5,000 G / 50 materials. No inserted guests, +## inventory, employees, completed construction, or free resource commands. +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _ids: Dictionary = {} +var _saw_loaded := false +var _saw_movement := false +var _saw_batch := false +var _save_checked := false +var _last_porter_cell := Vector3i.ZERO +var _save_path := "user://tests/normal_auto_supply_%d/cargo" % OS.get_process_id() +var _main: Control + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 424242) + _clock.set_paused(true) + root.size = Vector2i(1440, 810) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + _main.set("manual_save_path", "user://tests/supply_auto_normal_%d/manual" % OS.get_process_id()) + root.add_child(_main) + await process_frame + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect(_state.get_resource(&"gold") == 5000 and _state.get_resource(&"materials") == 50, "ordinary starting resources") + for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)], [&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]: + if not _command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}): + _finish() + return + await _advance(180) + _command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)}) + _command(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(-4, 1, 0)}) + await _advance(60) + _command(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(-4, 2, 0)}) + await _advance(10) + for x: int in [-5, -6]: + _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(x, 2, 0)}) + await _advance(10) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + _ids[facility.definition_id] = facility.instance_id + for assignment: Array in [[&"staff_akane", &"reception"], [&"staff_gina", &"warehouse"], [&"staff_chika", &"kitchen"], [&"staff_iris", &"cafeteria"], [&"staff_juno", &"cafeteria"]]: + if not _command(&"hire_staff_candidate", {&"candidate_id": assignment[0]}) or not _command(&"set_staff_work_assignment", {&"staff_id": assignment[0], &"facility_id": _ids[assignment[1]], &"template_id": &"DAY"}): + _finish() + return + _expect(_state.get_resource(&"materials") == 4, "all buildings and the staff door fit within the starting material budget") + var warehouse: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"warehouse"]) + var cash_before: int = _state.get_resource(&"gold") + _command(&"purchase_supplies", {&"warehouse_id": warehouse.instance_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40}) + _expect(_state.get_resource(&"gold") == cash_before - 120 and _runtime.inventory_service.total_quantity(warehouse.local_inventory_id, SupplyCatalog.RAW_FOOD) == 40, "40 raw food costs exactly 120 G and arrives only in the warehouse") + _main.call("_activate_management_mode", &"product") + var auto_panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + _expect(auto_panel != null, "normal product UI exposes automatic paid ordering") + if auto_panel == null: _finish(); return + auto_panel.auto_purchase_enabled.button_pressed = true + auto_panel.auto_purchase_target.value = 20 # Explicit player-input fixture; not a default balance value. + auto_panel.auto_purchase_button.pressed.emit() + _expect(_runtime.auto_purchase_plans.get_plan(warehouse.instance_id, SupplyCatalog.RAW_FOOD)[&"enabled"], "normal product controls arm player-selected target20") + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8}) + await _advance(120) + _expect(_saw_movement and _saw_loaded, "an employed porter walks the real route and carries purchased food") + _expect(_saw_batch, "the kitchen starts autonomous production without a guest request") + _expect(_save_checked, "a mid-delivery disk save restores ownership and replays exactly") + _expect(_completed_deliveries(SupplyCatalog.RAW_FOOD) > 0 and _completed_deliveries(SupplyCatalog.BASIC_MEAL) > 0, "both warehouse-to-kitchen and kitchen-to-cafeteria deliveries complete") + _expect(_sales(&"cafeteria") > 0, "natural guests pay for meals made from purchased food") + await _capture() + if OS.get_environment("THE_TREE_SUPPLY_PREVIEW") == "1": + _start_preview() + return + if OS.get_environment("THE_TREE_SUPPLY_CAPTURE_ONLY") == "1": + _finish() + return + for day: int in range(1, 4): + await _advance(360 + 1440 * day - _clock.absolute_minute) + var summaries: Array = _runtime.operations_coordinator.get_day_summaries() + _expect(summaries.size() == day and bool(summaries[-1].get(&"balanced", false)), "business day %d closes with a reconciled ledger" % day) + _expect(_state.get_resource(&"gold") >= 0, "day %d retains cash after real payroll and purchases" % day) + _expect(int(summaries[-1].get(&"arrears_total", -1)) == 0, "day %d pays all real wages without arrears" % day) + _check_auto_financial_close(summaries[-1]) + _expect(_auto_order_count() >= 2, "at least two real shortages cause paid automatic orders without another manual restock") + _expect(_sales(&"standard_room") > 0, "the same inn earns lodging revenue") + _expect(_sales(&"reception") == 0, "three days do not depend on artificial reception revenue") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "three-day supply history remains restorable") + var evidence := {"checks": _checks, "failures": _failures, "meal_sales": _sales(&"cafeteria"), "lodging_sales": _sales(&"standard_room"), "raw_deliveries": _completed_deliveries(SupplyCatalog.RAW_FOOD), "meal_deliveries": _completed_deliveries(SupplyCatalog.BASIC_MEAL), "cash": _state.get_resource(&"gold"), "closes": _runtime.operations_coordinator.get_day_summaries(), "supply": _runtime.get_supply_management_report(), "upkeep": _runtime.upkeep_settlement.get_report(), "maintenance_expense": _runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE)} + var evidence_dir := "res://artifacts/supply-auto-chain" if OS.get_environment("THE_TREE_BUSINESS_ACCEPTANCE") == "1" else "res://artifacts/supply-auto-chain" + if not OS.get_environment("THE_TREE_SUPPLY_EVIDENCE_DIR").is_empty(): + evidence_dir = OS.get_environment("THE_TREE_SUPPLY_EVIDENCE_DIR") + var evidence_path := ProjectSettings.globalize_path(evidence_dir.path_join("normal-play-report.json")) + DirAccess.make_dir_recursive_absolute(evidence_path.get_base_dir()) + var file := FileAccess.open(evidence_path, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(evidence, "\t")) + print("NORMAL_AUTO_PURCHASE_REPORT ", JSON.stringify({"cash": evidence["cash"], "meal_sales": evidence["meal_sales"], "raw_deliveries": evidence["raw_deliveries"], "meal_deliveries": evidence["meal_deliveries"]})) + _finish() + + +func _auto_order_count() -> int: + var count := 0 + for row: Dictionary in _runtime.economy_service.create_snapshot()[&"idempotency"]: + if String(row[&"key"]).begins_with("supply.auto."): count += 1 + return count + + +func _check_auto_financial_close(summary: Dictionary) -> void: + var day := int(summary[&"day"]) + var start: int = _runtime.operations_coordinator.get_financial_period_start(day) + var end := int(summary[&"closed_at"]) + var purchase_expense := 0 + var signing_expense := 0 + var auto_expense := 0 + var cash_in := 0 + var cash_out := 0 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + if entry.category != &"INITIALIZATION": + if entry.debit_account == &"PLAYER_CASH": cash_in += entry.amount + if entry.credit_account == &"PLAYER_CASH": cash_out += entry.amount + if entry.absolute_minute < start or entry.absolute_minute >= end: continue + if entry.category == &"STAFF_SIGNING_COST": signing_expense += entry.amount + if entry.category == &"OPERATING_EXPENSE": + purchase_expense += entry.amount + if entry.source_entity_id == _ids[&"warehouse"] and _runtime.economy_service.get_transaction_id_for_idempotency_key( + SupplyAutoPurchasePlans.order_key(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, entry.absolute_minute)) == entry.transaction_id: + auto_expense += entry.amount + var receipt: Dictionary = SupplyAutoPurchasePlans.receipt_from_ledger(_runtime.economy_service, _ids[&"warehouse"], SupplyCatalog.RAW_FOOD, entry.absolute_minute, entry.transaction_id) + _expect(not receipt.is_empty() and receipt[&"quantity"] * 3 == entry.amount and receipt[&"unit_price"] == 3 and receipt[&"total_cost"] == entry.amount, "actual automatic receipt retains current normal3G quote and unchanged cost/source/category") + _expect(summary[&"category_totals"][&"cost_of_goods"] == purchase_expense + signing_expense, "day%d actual purchase+contract cost exactly reconciles daily cost_of_goods" % day) + _expect(int(summary[&"category_totals"][&"cost_of_goods"]) >= auto_expense, "automatic payment is included in operating expense once") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 5000 + cash_in - cash_out, "real initial cash+all ledger flows exactly equals player cash") + var warehouse_row: Dictionary = summary[&"facility_profit_loss"].get(_ids[&"warehouse"], {}) + _expect(not warehouse_row.is_empty() and int(warehouse_row.get(&"expenses", 0)) >= auto_expense, "actual warehouse P&L retains automatic expense attribution") + + +func _advance(minutes: int) -> void: + for index: int in maxi(minutes, 0): + var before: int = _clock.absolute_minute + _clock.advance_minutes(1) + if _clock.absolute_minute != before + 1: + _expect(false, "clock must progress: %s" % _runtime.get_operations_report()) + return + var porter: StaffRuntime = _runtime.staff_service.get_staff(&"staff_gina") + if porter != null: + _saw_movement = _saw_movement or porter.current_cell != _last_porter_cell + _last_porter_cell = porter.current_cell + if not porter.carried_inventory_id.is_empty(): + _saw_loaded = true + if not _save_checked: + _check_loaded_save() + if _ids.has(&"kitchen"): + _saw_batch = _saw_batch or not _runtime.kitchen_production.get_report(_ids[&"kitchen"]).get(&"active_batch", {}).is_empty() + if index % 120 == 119: + print("SUPPLY_PROGRESS ", _clock.formatted_time(), " meals=", _sales(&"cafeteria")) + await process_frame + + +func _check_loaded_save() -> void: + _save_checked = true + var saved: Dictionary = _runtime.save_to_path(_save_path) + _expect(bool(saved.get(&"ok", false)), "mid-delivery save writes to an isolated slot") + var before: Dictionary = _runtime.create_snapshot() + var loaded: Dictionary = _runtime.load_from_path(_save_path) + _expect(bool(loaded.get(&"ok", false)), "mid-delivery disk restore: %s" % loaded) + var normalized: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(5) + var expected: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(normalized), "mid-delivery checkpoint rewinds") + _clock.advance_minutes(5) + _expect(_runtime.create_snapshot() == expected, "mid-delivery replay preserves exact inventory, tasks, positions, and ledger") + _expect(_runtime.restore_snapshot(before), "resume the observed minute after replay verification") + + +func _completed_deliveries(item: StringName) -> int: + var count := 0 + for request: LogisticsRequest in _runtime.logistics_requests.values(): + if String(request.request_id).begins_with("supply.") and request.state == LogisticsRequest.State.COMPLETED and request.item_requirements.has(item): + count += 1 + return count + + +func _sales(definition_id: StringName) -> int: + var total := 0 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + if entry.category == &"SERVICE_SALE" and entry.source_entity_id == _ids.get(definition_id, &"missing"): + total += entry.amount + return total + + +func _command(type: StringName, payload: Dictionary) -> bool: + var result: CommandResult = _runtime.submit_command(type, payload) + _expect(result.is_success(), "%s %s: %s" % [type, payload, result.message]) + return result.is_success() + + +func _capture() -> void: + if OS.get_environment("THE_TREE_SUPPLY_CAPTURE") != "1": + return + await process_frame + _main.call("_activate_management_mode", &"product") + var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + for tab: StringName in [&"purchase", &"kitchen", &"delivery"]: + panel.select_tab(tab) + await process_frame + await RenderingServer.frame_post_draw + var path := ProjectSettings.globalize_path("res://artifacts/supply-auto-chain/%s.png" % tab) + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + root.get_texture().get_image().save_png(path) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error("FAIL: " + message) + + +func _finish() -> void: + SaveService.delete_save(_save_path) + print("Normal auto purchase chain: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) + + +func _start_preview() -> void: + if _failures > 0: + _finish() + return + SaveService.delete_save(_save_path) + root.title = "The Tree · 仕入れと厨房の試遊(保存なし)" + var load_menu := _main.find_child("SaveLoadMenu", true, false) as MenuButton + if load_menu != null: + load_menu.disabled = true + load_menu.tooltip_text = "この試遊は保存・読込を行いません。" + for node: Node in _main.find_children("*", "Button", true, false): + var button := node as Button + for connection: Dictionary in button.pressed.get_connections(): + var callback: Callable = connection[&"callable"] + if callback.get_method() in [&"_save_manual", &"_load_manual"]: + button.disabled = true + button.tooltip_text = "この試遊は保存しません。" + if OS.get_environment("THE_TREE_BUSINESS_PREVIEW") == "1": + root.title = "The Tree · 料金と営業の試遊(保存なし)" + _main.call("_activate_management_mode", &"business") + var business := _main.find_child("BusinessManagementPanel", true, false) + business.call("select_facility", _ids[&"cafeteria"]) + _main.call("_set_status", "料金と営業状態を変更できます。Spaceで再開すると、客の行動に反映されます(保存なし)。", true, true) + else: + _main.call("_activate_management_mode", &"product") + var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + panel.select_tab(&"kitchen") + _main.call("_set_status", "通常予算から育てた宿の試遊です。Spaceで再開し、商品タブで仕入れ・調理・配送を確認できます(保存なし)。", true, true) + _clock.set_paused(true) + print("SUPPLY_PREVIEW_READY checks=%d failures=0" % _checks) diff --git a/tests/supply/test_normal_auto_purchase_chain.gd.uid b/tests/supply/test_normal_auto_purchase_chain.gd.uid new file mode 100644 index 0000000..01f1a90 --- /dev/null +++ b/tests/supply/test_normal_auto_purchase_chain.gd.uid @@ -0,0 +1 @@ +uid://b35pphufmkii8 diff --git a/tests/supply/test_supply_auto_purchase.gd b/tests/supply/test_supply_auto_purchase.gd new file mode 100644 index 0000000..c852b3e --- /dev/null +++ b/tests/supply/test_supply_auto_purchase.gd @@ -0,0 +1,365 @@ +extends SceneTree + +## Real command/ledger boundaries; failure injections are disclosed separately. +class RejectingPaymentEconomy: + extends EconomyService + func transfer(_from: StringName, _to: StringName, _amount: int, + _minute: int = -1, _category: StringName = DEFAULT_CATEGORY, + _source: StringName = &"", _key: StringName = &"", _memo: StringName = &"", + _related: StringName = &"", _notify := true) -> CommandResult: + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Payment refusal fixture") + +class RejectingNotificationEconomy: + extends EconomyService + func commit_transaction_notification(_transaction: StringName) -> bool: + return false + +class ProbedInventory: + extends InventoryService + var transaction_boundary: Dictionary = {} + func add_stack(owner: StringName, item: StringName, quantity: int, + quality: int = DEFAULT_QUALITY, expiry: int = -1, created: int = -1, + weight: int = DEFAULT_UNIT_WEIGHT, stack_id: StringName = &"") -> Dictionary: + transaction_boundary = create_snapshot() + return super.add_stack(owner, item, quantity, quality, expiry, created, weight, stack_id) + +class PartialFailInventory: + extends ProbedInventory + func add_stack(owner: StringName, item: StringName, quantity: int, + quality: int = DEFAULT_QUALITY, expiry: int = -1, created: int = -1, + weight: int = DEFAULT_UNIT_WEIGHT, stack_id: StringName = &"") -> Dictionary: + super.add_stack(owner, item, quantity, quality, expiry, created, weight, stack_id) + return {&"ok": false, &"code": &"INJECTED_AFTER_STOCK"} + +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _warehouse: StringName +var _built: Dictionary +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _clock.set_process(false) + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 420_501)[&"valid"], "canonical Runtime initializes") + _clock.set_paused(true) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect(_runtime.submit_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}).is_success(), "ordinary paid warehouse build") + _clock.advance_minutes(180) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": _warehouse = facility.instance_id + _expect(not _warehouse.is_empty(), "ordinary construction yields a warehouse") + if _warehouse.is_empty(): _finish(); return + _built = _runtime.create_snapshot() + _test_receipt_codec() + _test_default_manual_and_paid_order() + _test_invalid_input_and_corrupt_save() + _test_atomic_failures_and_same_minute_retry() + _test_hold_disable_and_removal() + _test_actual_budget_power_reservation_and_terminal() + _test_multiple_warehouse_order() + _test_legacy13_and_composed_rollback() + _finish() + +func _plan(enabled: bool, target: int, key: StringName = &"") -> CommandResult: + return _runtime.submit_command(&"set_supply_auto_purchase", {&"warehouse_id": _warehouse, + &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": enabled, &"target_stock": target}, &"player", _warehouse, 75, -1, key) + +func _stock() -> int: + var facility: FacilityInstance = _runtime.facility_service.get_instance(_warehouse) + return _runtime.inventory_service.total_quantity(facility.local_inventory_id, SupplyCatalog.RAW_FOOD) + +func _test_receipt_codec() -> void: + var memo := SupplyAutoPurchasePlans.receipt_memo(SupplyCatalog.RAW_FOOD, 6, 3, 18) + var decoded := SupplyAutoPurchasePlans.decode_receipt_memo(memo) + _expect(decoded == {&"item_id": String(SupplyCatalog.RAW_FOOD), &"quantity": 6, &"unit_price": 3, &"total_cost": 18}, "canonical versioned actual quote codec is lossless") + decoded[&"quantity"] = 3 + _expect(SupplyAutoPurchasePlans.decode_receipt_memo(memo)[&"quantity"] == 6, "decoded receipt is independently owned") + var punctuation: StringName = &"item:part.日本語" + _expect(SupplyAutoPurchasePlans.decode_receipt_memo(SupplyAutoPurchasePlans.receipt_memo(punctuation, 6, 3, 18))[&"item_id"] == String(punctuation), "length encoding handles punctuation and Unicode without ambiguity") + var prefix: String = SupplyAutoPurchasePlans.RECEIPT_PREFIX + "19:ingredient/raw_food:" + for malformed: String in ["", "supply_purchase", prefix + "3:6:18:extra", prefix + "6:03:18", prefix + "6:+3:18", prefix + "6:3.0:18", prefix + "6:3e0:18", prefix + "6:-3:18", prefix + "6:0:0", prefix + "6:3:019", prefix + "6:3:19", prefix + "10001:3:30003", prefix + "1:9223372036854775808:9223372036854775808", prefix + "2:9223372036854775807:9223372036854775807", "supply_purchase.auto.v2:19:ingredient/raw_food:6:3:18", "supply_purchase.auto.v1:019:ingredient/raw_food:6:3:18", "supply_purchase.auto.v1:18:ingredient/raw_food:6:3:18", "supply_purchase.auto.v1:9223372036854775808:ingredient/raw_food:6:3:18", "supply_purchase.auto.v1:1: :6:3:18"]: + _expect(SupplyAutoPurchasePlans.decode_receipt_memo(StringName(malformed)).is_empty(), "malformed/noncanonical/overflow quote memo is rejected: " + malformed) + var max_memo: StringName = &"supply_purchase.auto.v1:1:x:1:9223372036854775807:9223372036854775807" + _expect(SupplyAutoPurchasePlans.decode_receipt_memo(max_memo).get(&"unit_price", 0) == 9223372036854775807, "signed64 canonical maximum parses without floating conversion or overflow") + +func _test_default_manual_and_paid_order() -> void: + _expect(_runtime.auto_purchase_plans.get_plans().is_empty(), "new game has no inferred automatic intent") + var before: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(1) + _expect(_stock() == 0 and _runtime.economy_service.create_snapshot() == before[&"systems"][&"runtime"][&"economy"], "default disabled tick spends and stocks nothing") + var manual: CommandResult = _runtime.submit_command(&"purchase_supplies", {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 4}) + _expect(manual.is_success() and int(manual.details.get(&"total_cost", -1)) == 12 and _stock() == 4, "manual contract still charges4 food exactly12G") + var transaction_id := StringName(manual.details.get(&"transaction_id", &"")) + var entries: Array = _runtime.economy_service.get_transaction_entries(transaction_id) + _expect(entries.size() == 1 and entries[0].category == &"OPERATING_EXPENSE" and entries[0].memo_key == &"supply_purchase", "manual category/memo remain unchanged") + _expect(_runtime.economy_service.get_transaction_id_for_idempotency_key(StringName("supply.purchase.%d" % manual.command_id)) == transaction_id, "manual ledger key still uses accepted commandID") + var configured: CommandResult = _plan(true, 10, &"auto.test.intent") + _expect(configured.is_success() and _stock() == 4, "player intent does not immediately stock or pay") + var armed: Dictionary = _runtime.create_snapshot() + var cash: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var notifications: Array[Dictionary] = [] + _runtime.economy_service.transaction_posted.connect(func(id: StringName, ledger_rows: Array) -> void: + var rows: Array[Dictionary] = [] + for entry: LedgerEntry in ledger_rows: rows.append(entry.to_dictionary()) + notifications.append({&"transaction_id": id, &"entries": rows})) + _clock.advance_minutes(1) + var paid: Dictionary = _runtime.create_snapshot() + var row: Dictionary = _runtime.auto_purchase_plans.get_plan(_warehouse, SupplyCatalog.RAW_FOOD) + _expect(_stock() == 10 and _runtime.economy_service.get_balance(&"PLAYER_CASH") == cash - 18, "ordinary tick buys the exact deficit6 at existing3G") + _expect(_state.get_resource(&"gold") == cash - 18 and row[&"last_result"] == &"PURCHASED" and row[&"last_attempt_minute"] == _clock.absolute_minute, "cash mirror and saved receipt record the real purchase") + var receipt := SupplyAutoPurchasePlans.receipt_from_ledger(_runtime.economy_service, _warehouse, SupplyCatalog.RAW_FOOD, _clock.absolute_minute, StringName(row[&"last_transaction_id"])) + _expect(receipt == {&"item_id": String(SupplyCatalog.RAW_FOOD), &"quantity": 6, &"unit_price": 3, &"total_cost": 18}, "independent actual ledger memo anchors item/quantity/historic price/total") + _expect(notifications.size() == 1 and notifications[0][&"transaction_id"] == StringName(row[&"last_transaction_id"]) and notifications[0][&"entries"].size() == 1 and notifications[0][&"entries"][0][&"memo_key"] == String(SupplyAutoPurchasePlans.receipt_memo(SupplyCatalog.RAW_FOOD, 6, 3, 18)), "normal successful automatic purchase delivers one actual quoted ledger notification") + _expect((_runtime.economy_service.get("_pending_transaction_notifications") as Dictionary).is_empty(), "normal automatic notification is committed without pending residue") + _expect(paid[&"systems"][&"runtime"][&"next_command_id"] == armed[&"systems"][&"runtime"][&"next_command_id"] and var_to_bytes(paid[&"systems"][&"runtime"][&"command_queue"]) == var_to_bytes(armed[&"systems"][&"runtime"][&"command_queue"]), "automatic order allocates no fake player command or queue history") + _expect(var_to_bytes(paid[&"systems"][&"runtime"][&"rng_streams"]) == var_to_bytes(armed[&"systems"][&"runtime"][&"rng_streams"]), "automatic purchase makes no RNG draw") + _expect(_runtime.supply_management.advance_auto_purchases(_runtime, _clock.absolute_minute).is_empty() and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "same-minute reentry is exact no-op") + _expect(notifications.size() == 1, "same-minute automatic reentry does not duplicate actual notification") + _expect(_runtime.restore_snapshot(armed), "whole pre-order snapshot restores") + _clock.advance_minutes(1) + _expect(var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "saved pre-order branch replays all typed state/order/events/IDs exactly") + _expect(_runtime.restore_snapshot(paid) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "paid order/current14 receipt roundtrip is exact") + var duplicate_before: Dictionary = _runtime.create_snapshot() + var duplicate: CommandResult = _plan(true, 10, &"auto.test.intent") + _expect(not duplicate.is_success() and duplicate.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY and not duplicate.is_replay and _stock() == 10, "already accepted intent key is rejected by the canonical command queue") + _expect(var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(duplicate_before), "duplicate intent refusal changes no plan, inventory, ledger, IDs, queue or Runtime state") + var owned: Dictionary = _runtime.auto_purchase_plans.get_plan(_warehouse, SupplyCatalog.RAW_FOOD) + owned[&"enabled"] = false + _expect(_runtime.auto_purchase_plans.get_plan(_warehouse, SupplyCatalog.RAW_FOOD)[&"enabled"], "returned plan is owned") + +func _test_invalid_input_and_corrupt_save() -> void: + _expect(_runtime.restore_snapshot(_built), "reset to paid real warehouse fixture") + var before: Dictionary = _runtime.create_snapshot() + for value: Variant in [true, -1, 101, 1.5, "4", NAN, INF]: + var result: CommandResult = _runtime.supply_management.set_auto_purchase(_runtime, + SimulationCommand.new(999, _clock.absolute_minute, _clock.absolute_minute, 75, + &"set_supply_auto_purchase", &"player", _warehouse, + {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": value})) + _expect(not result.is_success() and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(before), "invalidtarget rejects atomically: %s" % value) + for patch: Dictionary in [ + {&"warehouse_id": &"missing", &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 1}, + {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.BASIC_MEAL, &"enabled": true, &"target_stock": 1}, + {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": "true", &"target_stock": 1}, + {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 0}, + {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 1, &"unit_price": 0}]: + var result: CommandResult = _runtime.supply_management.set_auto_purchase(_runtime, SimulationCommand.new(999,0,0,75,&"set_supply_auto_purchase",&"player",_warehouse,patch)) + _expect(not result.is_success() and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(before), "forged/wrongintent changes no state") + _expect(_plan(true, 6).is_success(), "configure a canonical saved plan") + var canonical: Dictionary = _runtime.create_snapshot() + for mutation: String in ["missing", "version", "duplicate", "unknown", "target", "bool", "clock", "receipt", "price", "extra"]: + var bad: Dictionary = canonical.duplicate(true) + var data: Dictionary = bad[&"systems"][&"runtime"] + var plans: Dictionary = data[&"auto_purchase_plans"] + var row: Dictionary = plans[&"plans"][0] + match mutation: + "missing": data.erase(&"auto_purchase_plans") + "version": plans[&"version"] = 2 + "duplicate": (plans[&"plans"] as Array).append(row.duplicate(true)) + "unknown": row[&"warehouse_id"] = "missing" + "target": row[&"target_stock"] = 101 + "bool": row[&"enabled"] = 1 + "clock": row[&"last_attempt_minute"] = _clock.absolute_minute + 1 + "receipt": row[&"last_transaction_id"] = "txn.foreign" + "price": row[&"last_unit_price"] = 0.0 + "extra": row[&"unit_price"] = 0 + _expect(not _runtime.restore_snapshot(bad) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(canonical), "corrupt14 plan rejected without mutation: " + mutation) + +func _test_atomic_failures_and_same_minute_retry() -> void: + for kind: String in ["payment", "notification", "partial_stock"]: + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "configure independent failure fixture: " + kind) + var economy_before: Dictionary = _runtime.economy_service.create_snapshot() + var inventory_before: Dictionary = _runtime.inventory_service.create_snapshot() + var probe: ProbedInventory = PartialFailInventory.new() if kind == "partial_stock" else ProbedInventory.new() + _expect(probe.restore_snapshot(inventory_before), "Inventory probe starts from full actual snapshot: " + kind) + _runtime.inventory_service = probe + if kind != "partial_stock": + var rejecting: EconomyService = RejectingPaymentEconomy.new() if kind == "payment" else RejectingNotificationEconomy.new() + _expect(rejecting.restore_snapshot(economy_before), "injected Economy starts from actual snapshot") + _runtime.economy_service = rejecting + _runtime.call("_connect_economy_signals") + var notifications: Array[StringName] = [] + _runtime.economy_service.transaction_posted.connect(func(id: StringName, _entries: Array) -> void: notifications.append(id)) + var pending_before: Dictionary = (_runtime.economy_service.get("_pending_transaction_notifications") as Dictionary).duplicate(true) + _clock.advance_minutes(1) + _expect(_stock() == 0 and var_to_bytes(_runtime.economy_service.create_snapshot()) == var_to_bytes(economy_before), "failed order keeps actual cash and all ledger bytes unchanged: " + kind) + _expect(not probe.transaction_boundary.is_empty() and var_to_bytes(_runtime.inventory_service.create_snapshot()) == var_to_bytes(probe.transaction_boundary), "transaction-boundary rollback restores full Inventory IDs/reservations/history/order, allowing the preceding ordinary clock advancement: " + kind) + _expect(probe.transaction_boundary.get(&"current_minute", -1) == _clock.absolute_minute and _clock.absolute_minute > int(inventory_before[&"current_minute"]), "Inventory probe observes the actual next-minute transaction boundary: " + kind) + _expect(notifications.is_empty() and var_to_bytes(_runtime.economy_service.get("_pending_transaction_notifications")) == var_to_bytes(pending_before), "failed automatic transaction emits no notification and leaves the observed pending set exact: " + kind) + var failed: Dictionary = _runtime.create_snapshot() + var row: Dictionary = _runtime.auto_purchase_plans.get_plan(_warehouse, SupplyCatalog.RAW_FOOD) + _expect(row[&"last_attempt_minute"] == _clock.absolute_minute and row[&"last_result"] != &"PURCHASED", "actual failure stays recorded in same minute: " + kind) + _expect(_runtime.restore_snapshot(failed), "recorded failure restores into regular services: " + kind) + _expect(_runtime.supply_management.advance_auto_purchases(_runtime, _clock.absolute_minute).is_empty() and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(failed), "same-minute regular retry cannot hide the actual failure: " + kind) + _clock.advance_minutes(1) + _expect(_stock() == 6 and _runtime.economy_service.get_balance(&"PLAYER_CASH") == int(_built[&"game_state"][&"resources"][&"gold"]) - 18, "next ordinary tick retries exactly once: " + kind) + +func _test_hold_disable_and_removal() -> void: + _expect(_runtime.restore_snapshot(_built), "hold fixture restores") + _expect(_plan(true, 6).is_success(), "enable before normal closure") + _expect(_runtime.submit_command(&"set_facility_policy", {&"facility_id": _warehouse, &"patch": {&"business_open": false}}).is_success(), "normal closure precedes auto tick") + var cash: int = _state.get_resource(&"gold") + _clock.advance_minutes(1) + _expect(_stock() == 0 and _state.get_resource(&"gold") == cash, "closed warehouse does not stock/pay") + _expect(_plan(false, 0).is_success(), "setting disable works despite closed receiver") + _expect(_runtime.submit_command(&"set_facility_policy", {&"facility_id": _warehouse, &"patch": {&"business_open": true}}).is_success(), "normal opening restores logistical eligibility") + _clock.advance_minutes(1) + _expect(_stock() == 0, "disabled plan stays off after reopening") + _expect(_plan(true, 6).is_success(), "enable again") + _clock.advance_minutes(1) + var paid: Dictionary = _runtime.create_snapshot() + var removal: CommandResult = _runtime.submit_command(&"demolish_facility", {&"instance_id": _warehouse}) + _expect(not removal.is_success() and _runtime.auto_purchase_plans.create_snapshot() == paid[&"systems"][&"runtime"][&"auto_purchase_plans"], "stock-bearing failed removal preserves plan") + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "future closure fixture resets") + cash = _state.get_resource(&"gold") + _expect(_runtime.submit_command(&"set_facility_policy", {&"facility_id": _warehouse, &"patch": {&"business_open": false}, &"effective_at": _clock.absolute_minute + 1}).is_success(), "normal future closure is scheduled for next tick") + _clock.advance_minutes(1) + _expect(_stock() == 0 and _state.get_resource(&"gold") == cash and not _runtime.facility_service.get_instance(_warehouse).operator_open, "due closure blocks auto purchase before FacilityCompletion without early policy mutation") + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "empty warehouse with explicit intent exists") + _expect(_runtime.submit_command(&"demolish_facility", {&"instance_id": _warehouse}).is_success() and _runtime.auto_purchase_plans.get_plans().is_empty(), "successful empty warehouse removal prunes its intent") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "removed warehouse history/ledger remains restorable") + +func _test_actual_budget_power_reservation_and_terminal() -> void: + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "configure a paid budget boundary") + var cash: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + _expect(_runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", cash - 17, _clock.absolute_minute, &"CONSTRUCTION", &"", &"auto.acceptance.budget").is_success(), "disclosed ledger cash fixture leaves17G") + _clock.advance_minutes(1) + var row: Dictionary = _runtime.supply_management.auto_purchase_report(_runtime, _warehouse, SupplyCatalog.RAW_FOOD) + _expect(_stock() == 0 and _state.get_resource(&"gold") == 17 and row[&"reason"] == &"INSUFFICIENT_FUNDS", "full deficit18G never silently becomes partial free or affordable17G order") + _expect(row[&"last_attempt_minute"] == -1, "ineligible quote does not fabricate an executed order") + _expect(_runtime.economy_service.transfer(&"CONSTRUCTION_EXPENSE", &"PLAYER_CASH", 1, _clock.absolute_minute, &"CONSTRUCTION", &"", &"auto.acceptance.budget.refund").is_success(), "same actual ledger can return1G") + _clock.advance_minutes(1) + _expect(_stock() == 6 and _state.get_resource(&"gold") == 0, "eligible full order charges exactly available18G") + var paid: Dictionary = _runtime.create_snapshot() + var manual_key: StringName = &"auto.acceptance.manual.replacement" + var manual: CommandResult = _runtime.submit_command(&"purchase_supplies", {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"player", _warehouse, 75, -1, manual_key) + _expect(not manual.is_success(), "manual insufficient cash remains rejected") + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "reserved-stock fixture resets") + _expect(_runtime.submit_command(&"purchase_supplies", {&"warehouse_id": _warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 6}).is_success(), "reserved stock starts from an actual paid order") + var facility: FacilityInstance = _runtime.facility_service.get_instance(_warehouse) + var reservation: Dictionary = _runtime.inventory_service.reserve(facility.local_inventory_id, SupplyCatalog.RAW_FOOD, 6, &"disclosed.reserve.fixture", &"TEST", _clock.absolute_minute) + _expect(reservation.get(&"ok", false), "disclosed Inventory reservation retains physical stock") + cash = _state.get_resource(&"gold") + _clock.advance_minutes(1) + _expect(_stock() == 6 and _runtime.inventory_service.available_quantity(facility.local_inventory_id, SupplyCatalog.RAW_FOOD) == 0 and _state.get_resource(&"gold") == cash, "reserved physical units are not mistaken for missing stock or double-purchased") + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "power boundary resets") + _state.set_resource(&"mana", 0) # Disclosed resource boundary, not natural Mana generation. + cash = _state.get_resource(&"gold") + _clock.advance_minutes(1) + _expect(_stock() == 0 and _state.get_resource(&"gold") == cash, "Mana0 stops automatic paid order") + _expect(_runtime.restore_snapshot(_built) and _plan(true, 6).is_success(), "paused real-time boundary resets") + var paused: Dictionary = _runtime.create_snapshot() + _expect(_clock.process_real_delta(60.0) == 0 and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paused), "paused real clock causes no automatic purchase or ID/timer change") + var collision_key: StringName = SupplyAutoPurchasePlans.order_key(_warehouse, SupplyCatalog.RAW_FOOD, _clock.absolute_minute) + _expect(_runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", 1, _clock.absolute_minute, &"CONSTRUCTION", _warehouse, collision_key).is_success(), "disclosed foreign ledger-key fixture uses a valid ordinary transfer") + cash = _state.get_resource(&"gold") + var collisions: Array = _runtime.supply_management.advance_auto_purchases(_runtime, _clock.absolute_minute) + _expect(collisions.size() == 1 and _stock() == 0 and _state.get_resource(&"gold") == cash and _runtime.auto_purchase_plans.get_plan(_warehouse, SupplyCatalog.RAW_FOOD)[&"last_result"] == &"REPLAY_MISMATCH", "foreign ledger namespace cannot be treated as a free stocked purchase") + var collision: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(collision) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(collision), "foreign-key failure receipt remains restorable without making it purchase income") + _expect(_runtime.restore_snapshot(paid), "restore an actual paid receipt for corruption tests") + var corrupt: Dictionary = paid.duplicate(true) + corrupt[&"systems"][&"runtime"][&"auto_purchase_plans"][&"plans"][0][&"last_transaction_id"] = String(_runtime.economy_service.get_entries()[0].transaction_id) + _expect(not _runtime.restore_snapshot(corrupt) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "an existing unrelated actual transaction cannot replace the automatic receipt") + for field: StringName in [&"last_quantity", &"last_unit_price", &"last_total_cost"]: + corrupt = paid.duplicate(true) + corrupt[&"systems"][&"runtime"][&"auto_purchase_plans"][&"plans"][0][field] += 1 + _expect(not _runtime.restore_snapshot(corrupt) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "saved auto quantity/price/total quote cannot disagree with payment: %s" % field) + for pair: Vector2i in [Vector2i(3, 6), Vector2i(2, 9), Vector2i(1, 18), Vector2i(9, 2)]: + corrupt = paid.duplicate(true) + var changed_row: Dictionary = corrupt[&"systems"][&"runtime"][&"auto_purchase_plans"][&"plans"][0] + changed_row[&"last_quantity"] = pair.x + changed_row[&"last_unit_price"] = pair.y + _expect(not _runtime.restore_snapshot(corrupt) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "coordinated factorization retaining actual18G cannot rewrite paid6x3G quote: %s" % pair) + var receipt_id: String = paid[&"systems"][&"runtime"][&"auto_purchase_plans"][&"plans"][0][&"last_transaction_id"] + for malformed: StringName in [&"supply_purchase", SupplyAutoPurchasePlans.receipt_memo(SupplyCatalog.BASIC_MEAL, 6, 3, 18), SupplyAutoPurchasePlans.receipt_memo(SupplyCatalog.RAW_FOOD, 3, 6, 18), &"supply_purchase.auto.v1:19:ingredient/raw_food:6:3:18:extra"]: + corrupt = paid.duplicate(true) + for entry: Dictionary in corrupt[&"systems"][&"runtime"][&"economy"][&"entries"]: + if entry[&"transaction_id"] == receipt_id: entry[&"memo_key"] = String(malformed) + _expect(not _runtime.restore_snapshot(corrupt) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(paid), "ledger quote missing/wrong item/re-factored/noncanonical is rejected against independent plan atomically: %s" % malformed) + # Operations can only be enabled at fresh Day1 06:00 (360), before building. + # Keep the normal paid construction and intent after that canonical start. + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 420_501)[&"valid"], "fresh canonical bankruptcy fixture initializes") + _clock.set_paused(true) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + var started: Dictionary = _runtime.enable_production_operations() + _expect(started[&"ok"] and _clock.absolute_minute == 360, "actual bankruptcy boundary enables ordinary daily operation at fresh Day1 06:00") + _expect(_runtime.submit_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}).is_success(), "terminal fixture uses ordinary paid warehouse construction") + _clock.advance_minutes(180) + _warehouse = &"" + for endpoint: FacilityInstance in _runtime.facility_service.get_instances(): + if endpoint.definition_id == &"warehouse": _warehouse = endpoint.instance_id + _expect(not _warehouse.is_empty() and _plan(true, 6).is_success(), "terminal fixture configures explicit player intent after ordinary warehouse completion") + _clock.advance_minutes(4320 - _clock.absolute_minute) + _expect(_runtime.operations_coordinator.get_day_summaries().size() == 3 and _runtime.operations_coordinator.get_terminal_state() == &"BANKRUPT", "warehouse-only actual costs reach existing three-deficit terminal rule") + var terminal: Dictionary = _runtime.create_snapshot() + _expect(_runtime.supply_management.advance_auto_purchases(_runtime, _clock.absolute_minute + 1).is_empty() and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(terminal), "BANKRUPT blocks every further internal purchase without fake commands") + _expect(_runtime.restore_snapshot(terminal), "terminal intent/ledger/current14 save still restores") + +func _test_multiple_warehouse_order() -> void: + _expect(_runtime.restore_snapshot(_built), "multiple actual warehouses fixture starts with one paid unit") + _expect(_runtime.submit_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(2,0,1)}).is_success(), "ordinary second warehouse construction is paid") + _clock.advance_minutes(180) + var ids: Array[StringName] = [] + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": ids.append(facility.instance_id) + ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + _expect(ids.size() == 2, "two actual warehouse endpoints exist") + if ids.size() != 2: return + for index: int in [1, 0]: + _expect(_runtime.submit_command(&"set_supply_auto_purchase", {&"warehouse_id": ids[index], &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 1}).is_success(), "reverse player setting order does not define payment priority") + var cash: int = _state.get_resource(&"gold") + _expect(_runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", cash - 3, _clock.absolute_minute, &"CONSTRUCTION", &"", &"auto.acceptance.multi_budget").is_success(), "disclosed shared cash fixture funds exactly one existing3G unit") + var before: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(1) + var after: Dictionary = _runtime.create_snapshot() + _expect(_runtime.inventory_service.total_quantity(_runtime.facility_service.get_instance(ids[0]).local_inventory_id, SupplyCatalog.RAW_FOOD) == 1 and _runtime.inventory_service.total_quantity(_runtime.facility_service.get_instance(ids[1]).local_inventory_id, SupplyCatalog.RAW_FOOD) == 0, "explicit lexicographic warehouse/item ordering deterministically spends shared cash once") + _expect(_runtime.restore_snapshot(before), "multiple-plan pre-order snapshot restores") + _clock.advance_minutes(1) + _expect(var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(after), "restored multiple-plan allocation reproduces every typed field and event in order") + +func _test_legacy13_and_composed_rollback() -> void: + _expect(_runtime.restore_snapshot(_built), "legacy fixture resets") + var legacy: Dictionary = _built.duplicate(true) + legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 13 + legacy[&"systems"][&"runtime"].erase(&"auto_purchase_plans") + _expect(_runtime.restore_snapshot(legacy) and _runtime.auto_purchase_plans.get_plans().is_empty(), "valid13 snapshot migrates to disabled14 without purchase inference") + var migrated: Dictionary = _runtime.create_snapshot() + var disguised: Dictionary = legacy.duplicate(true) + disguised[&"systems"][&"runtime"][&"auto_purchase_plans"] = {&"version": 1, &"plans": []} + _expect(not _runtime.restore_snapshot(disguised) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(migrated), "oldversion cannot covertly retain new aggregate") + _clock.set_paused(false) + _expect(_plan(true, 6).is_success(), "actual current14 pending automatic intent queues") + _clock.set_paused(true) + var pending: Dictionary = _runtime.create_snapshot() + var disguised_pending: Dictionary = pending.duplicate(true) + disguised_pending[&"systems"][&"runtime"][&"runtime_schema_version"] = 13 + disguised_pending[&"systems"][&"runtime"].erase(&"auto_purchase_plans") + _expect(not _runtime.restore_snapshot(disguised_pending) and var_to_bytes(_runtime.create_snapshot()) == var_to_bytes(pending), "old13 cannot reinterpret a covert queued automatic order as paid feature intent") + _expect(_runtime.restore_snapshot(_built), "reset after pending downgrade refusal") + _expect(_plan(true, 6).is_success(), "intent exists before composed rollback token") + var token: Dictionary = _runtime.call("_capture_operations_external_state") + _expect(not token.is_empty() and token.has(&"auto_purchase_plans"), "Operations rollback includes owned plan state") + _expect(_runtime.auto_purchase_plans.set_plan(_warehouse, SupplyCatalog.RAW_FOOD, false, 0, _runtime.facility_service)[&"ok"], "disclosed boundary mutation fixture changes only owned intent") + _expect(_runtime.call("_restore_operations_external_state", token) and _runtime.call("_operations_external_state_matches", token), "composed rollback restores exact plan and other participants") + _runtime.call("_discard_operations_domain_events") + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error("FAIL: " + message) + +func _finish() -> void: + print("SUPPLY_AUTO_PURCHASE checks=%d failures=%d" % [_checks, _failures]) + quit(_failures) diff --git a/tests/supply/test_supply_auto_purchase.gd.uid b/tests/supply/test_supply_auto_purchase.gd.uid new file mode 100644 index 0000000..7281f68 --- /dev/null +++ b/tests/supply/test_supply_auto_purchase.gd.uid @@ -0,0 +1 @@ +uid://camsaleipkl22 diff --git a/tests/supply/test_supply_auto_purchase_boundaries.gd b/tests/supply/test_supply_auto_purchase_boundaries.gd new file mode 100644 index 0000000..5c273a1 --- /dev/null +++ b/tests/supply/test_supply_auto_purchase_boundaries.gd @@ -0,0 +1,264 @@ +extends SceneTree + +## Supplemental P05 QA only. Actual paid construction/purchases and porter paths +## are used. Capacity pressure, cargo lot expiry, callback failures and a ledger +## refund are explicit boundary fixtures, not normal-business success evidence. +var runtime: Variant +var clock: Variant +var state: Variant +var warehouse: StringName +var second: StringName +var kitchen: StringName +var checks := 0 +var failures := 0 +var output := "" +var observations: Array[Dictionary] = [] +const PORTER := &"staff_gina" +const RETURN_REQUEST := &"supply.boundary_return" + +func _initialize() -> void: + call_deferred("_run") + +func expect(ok: bool, label: String) -> void: + checks += 1 + if ok: + print(" ✓ ", label) + else: + failures += 1 + push_error(label) + +func _run() -> void: + output = OS.get_environment("THE_TREE_AUTO_BOUNDARY_OUTPUT") + var user_dir := OS.get_environment("THE_TREE_AUTO_PURCHASE_EXPECTED_USER_DIR") + var res_dir := OS.get_environment("THE_TREE_AUTO_PURCHASE_EXPECTED_RES_DIR") + var profile := OS.get_environment("THE_TREE_AUTO_PURCHASE_EXPECTED_PROFILE") + var external_evidence: bool = not output.is_empty() + var valid := false + if external_evidence: + valid = output.is_absolute_path() and DirAccess.dir_exists_absolute(output) and not FileAccess.file_exists(output.path_join("boundaries.json")) and user_dir.is_absolute_path() and OS.get_user_data_dir() == user_dir and res_dir.is_absolute_path() and ProjectSettings.globalize_path("res://").simplify_path().trim_suffix("/") == res_dir.simplify_path().trim_suffix("/") and not profile.is_empty() and bool(ProjectSettings.get_setting("application/config/use_custom_user_dir", false)) and ProjectSettings.get_setting("application/config/custom_user_dir_name", "") == profile + else: + # Standard regression needs no external output/guards. Use only a new + # process-specific tests child, never a player slot or res:// source file. + var relative := "user://tests/supply_auto_boundaries_%d_%d" % [OS.get_process_id(), Time.get_ticks_usec()] + output = ProjectSettings.globalize_path(relative).simplify_path() + var allowed_parent := OS.get_user_data_dir().path_join("tests").simplify_path() + valid = user_dir.is_empty() and res_dir.is_empty() and profile.is_empty() and output.get_base_dir() == allowed_parent and not DirAccess.dir_exists_absolute(output) + if valid: valid = DirAccess.make_dir_recursive_absolute(output) == OK + profile = ProjectSettings.get_setting("application/config/custom_user_dir_name", "") + if not valid: + push_error("P05_AUTO_BOUNDARIES requires exact fresh actual profile/resource/output guards") + quit(91) + return + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + state = root.get_node("GameState") + clock.set_process(false) + _storage_and_incoming() + _local_power_and_suspension() + _operations_deferred() + _operations_quarantine() + _unpaid_expenses() + _returned_cargo() + var file := FileAccess.open(output.path_join("boundaries.json"), FileAccess.WRITE) + expect(file != null, "fresh boundary summary opens") + if file != null: + file.store_string(JSON.stringify({"checks": checks, "failures": failures, "actual_user_data_dir": OS.get_user_data_dir(), "actual_resource_dir": ProjectSettings.globalize_path("res://"), "actual_profile": profile, "output": output, "external_evidence": external_evidence, "observations": observations, "normal_business_acceptance": false, "p05_full_acceptance": false}, "\t", true, true) + "\n") + file.close() + print("Supply auto purchase boundaries: %d checks, %d failures" % [checks, failures]) + print("P05_AUTO_BOUNDARY_OUTPUT ", output) + quit(1 if failures > 0 else 0) + +func fresh(rows: Array = [[&"warehouse", Vector3i(-8, 0, 1)]], operations := false) -> bool: + warehouse = &"" + second = &"" + kitchen = &"" + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 420_505)[&"valid"], "canonical14 Runtime resets") + clock.set_paused(true) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + if operations: + expect(runtime.enable_production_operations()[&"ok"], "actual Operations enable at original Day1 06:00 before construction") + for row: Array in rows: + expect(runtime.submit_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}).is_success(), "ordinary paid build: %s" % row[0]) + clock.advance_minutes(180) + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": + if warehouse.is_empty(): warehouse = facility.instance_id + else: second = facility.instance_id + if facility.definition_id == &"kitchen": kitchen = facility.instance_id + expect(not warehouse.is_empty(), "real warehouse completed") + return not warehouse.is_empty() + +func inv(id: StringName) -> StringName: + return runtime.facility_service.get_instance(id).local_inventory_id + +func stock(id: StringName = &"") -> int: + if id.is_empty(): id = warehouse + return runtime.inventory_service.total_quantity(inv(id), SupplyCatalog.RAW_FOOD) + +func plan(target: int, minute: int = -1) -> CommandResult: + return runtime.submit_command(&"set_supply_auto_purchase", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": target}, &"player", warehouse, 75, minute) + +func record(label: String, before: Dictionary, report: Dictionary) -> void: + var after: Dictionary = runtime.create_snapshot() + for row: Array in [["before", before], ["after", after]]: + var path: String = output.path_join(label + "-" + String(row[0]) + ".var") + expect(not FileAccess.file_exists(path), "fresh actual typed boundary snapshot: " + label + "/" + String(row[0])) + var file := FileAccess.open(path, FileAccess.WRITE) + expect(file != null, "typed boundary evidence opens") + if file != null: + file.store_buffer(var_to_bytes(row[1])) + file.close() + observations.append({"boundary": label, "report": report, "before_digest": SimulationDigest.compute(before), "after_digest": SimulationDigest.compute(after), "whole_bytes_equal": var_to_bytes(before) == var_to_bytes(after), "checks_so_far": checks, "failures_so_far": failures}) + +func _storage_and_incoming() -> void: + if not fresh([[&"warehouse", Vector3i(-8, 0, 1)], [&"warehouse", Vector3i(2, 0, 1)]]): return + expect(not second.is_empty(), "actual second warehouse is inbound source") + if second.is_empty(): return + # Disclosed capacity-pressure seam: actual supported other item, not invented + # commodity/capacity or ordinary free-stock success evidence. + expect(runtime.inventory_service.add_stack(inv(warehouse), SupplyCatalog.BASIC_MEAL, 95, 100, clock.absolute_minute + 720, clock.absolute_minute, 1)[&"ok"], "disclosed95 other-item pressure occupies canonical100 capacity") + expect(runtime.submit_command(&"purchase_supplies", {&"warehouse_id": second, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 6}).is_success(), "inbound6 are actually paid at the source") + expect(runtime.submit_command(&"create_logistics_request", {&"request_id": &"supply.boundary_inbound", &"source_inventory_id": inv(second), &"destination_inventory_id": inv(warehouse), &"item_requirements": {SupplyCatalog.RAW_FOOD: 6}}).is_success(), "actual reserved inbound6 request targets the receiver") + expect(plan(10).is_success(), "player arms10 actual enabled auto target") + var before: Dictionary = runtime.create_snapshot() + var report: Dictionary = runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD) + expect(report[&"enabled"] and report[&"incoming_quantity"] == 6 and report[&"deficit"] == 4 and report[&"reason"] == &"STORAGE_FULL", "other95 plus actual inbound6 blocks remaining auto4 with STORAGE_FULL") + expect(runtime.supply_management.advance_auto_purchases(runtime, clock.absolute_minute).is_empty() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "STORAGE_FULL enabled auto changes no whole typed state or payment") + record("storage-full-with-inbound", before, report) + var reserved: Dictionary = runtime.inventory_service.reserve(inv(warehouse), SupplyCatalog.BASIC_MEAL, 95, &"boundary.pressure.release", &"TEST", clock.absolute_minute) + expect(reserved.get(&"ok", false), "disclosed pressure release uses actual reservation API") + var ids: Array[StringName] = [] + for id: Variant in reserved.get(&"reservation_ids", []): ids.append(StringName(id)) + expect(runtime.inventory_service.consume_reservations(ids, &"boundary.pressure.consume", clock.absolute_minute).get(&"ok", false), "pressure releases only its disclosed95 cooked meals") + before = runtime.create_snapshot() + var cash: int = state.get_resource(&"gold") + clock.advance_minutes(1) + expect(stock() == 4 and state.get_resource(&"gold") == cash - 12, "released capacity buys actual deficit4 once, retaining inbound6 deduction") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "enabled auto plus real reserved inbound14 saves strictly") + record("inbound-deducted-purchase", before, runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD)) + +func _local_power_and_suspension() -> void: + for kind: String in ["local-power", "suspension"]: + if not fresh(): return + expect(plan(6).is_success(), "arm enabled auto before " + kind) + var conditions: Dictionary = {&"power_available": false} if kind == "local-power" else {&"suspension_reason": &"RAIDED"} + expect(runtime.facility_service.set_operational_status(warehouse, conditions, clock.absolute_minute).is_success(), "disclosed public operational boundary: " + kind) + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse) + expect(state.get_resource(&"mana") > 0 and not facility.allows_logistics(), "global Mana remains available while warehouse local condition blocks " + kind) + var before: Dictionary = runtime.create_snapshot() + var report: Dictionary = runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD) + expect(report[&"enabled"] and report[&"state"] == &"BLOCKED" and report[&"reason"] == &"FACILITY_CLOSED", "owned auto report exposes local blocked receiver: " + kind) + expect(runtime.supply_management.advance_auto_purchases(runtime, clock.absolute_minute).is_empty() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "local stopped enabled auto spends/stocks/notifies nothing: " + kind) + record(kind, before, report) + +func _operations_deferred() -> void: + if not fresh([[&"warehouse", Vector3i(-8, 0, 1)]], true): return + var due: int = clock.absolute_minute + 30 + expect(plan(6, due).is_success(), "actual auto intent is queued for failing Operations boundary") + expect(runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: return {&"ok": false, &"reason": &"DISCLOSED_BOUNDARY_PREPARE_FAILURE", &"phase": phase}), "disclosed actual prepare fault seam registered") + clock.advance_minutes(30) + var operations: Dictionary = runtime.get_operations_report() + expect(operations[&"tick_blocked"] and operations[&"runtime_failure_latched"] and operations[&"deferred_runtime_minute"] == due and runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)[&"enabled"], "actual deferred suffix is blocked with actual enabled auto intent") + var before: Dictionary = runtime.create_snapshot() + expect(stock() == 0 and runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD)[&"reason"] == &"OPERATIONS_BLOCKED", "deferred boundary never purchases before its skipped suffix") + expect(runtime.supply_management.advance_auto_purchases(runtime, due).is_empty() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "deferred enabled auto is whole typed no-op") + record("operations-deferred", before, operations) + expect(runtime.restore_snapshot(before), "actual deferred enabled-auto14 snapshot strictly restores") + var cash: int = state.get_resource(&"gold") + var acknowledged: Dictionary = runtime.acknowledge_operations_pause() + expect(acknowledged.get(&"ok", false) and acknowledged.get(&"suffix_replayed", false) and stock() == 6 and state.get_resource(&"gold") == cash - 18, "explicit actual retry runs deferred suffix and paid auto6 exactly once") + var completed: Dictionary = runtime.create_snapshot() + expect(runtime.supply_management.advance_auto_purchases(runtime, due).is_empty() and var_to_bytes(completed) == var_to_bytes(runtime.create_snapshot()), "retried actual suffix cannot purchase twice in the same minute") + +func _quarantine_arrival(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == OperationsCoordinator.PHASE_APPLY: + return {&"ok": false, &"reason": &"DISCLOSED_APPLY_FAILURE"} + if phase == OperationsCoordinator.PHASE_ROLLBACK: + return {&"ok": false, &"reason": &"DISCLOSED_ROLLBACK_UNAVAILABLE"} + return runtime.call("_operations_arrival_boundary", payload, phase, token) + +func _operations_quarantine() -> void: + if not fresh([[&"warehouse", Vector3i(-8, 0, 1)]], true): return + var due: int = clock.absolute_minute + 30 + expect(plan(6, due).is_success(), "actual auto intent is queued for quarantined boundary") + # Public callback contract, no manual tick_blocked/quarantine flag mutation. + runtime.operations_coordinator.configure_callbacks(Callable(runtime, "_operations_daily_boundary"), Callable(self, "_quarantine_arrival"), Callable(runtime, "_operations_morning_boundary"), Callable(runtime, "_operations_midnight_upkeep_boundary")) + clock.advance_minutes(30) + var operations: Dictionary = runtime.get_operations_report() + expect(runtime.operations_coordinator.is_external_quarantined() and operations[&"tick_blocked"] and runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)[&"enabled"], "actual external rollback failure quarantines owning Coordinator with enabled auto") + var before: Dictionary = runtime.create_snapshot() + expect(stock() == 0 and runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD)[&"reason"] == &"OPERATIONS_BLOCKED", "actual quarantine exposes blocked enabled auto") + expect(runtime.supply_management.advance_auto_purchases(runtime, due).is_empty() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "quarantined enabled auto is whole typed no-op") + expect(not runtime.acknowledge_operations_pause().get(&"ok", true), "normal acknowledgement cannot clear external uncertainty") + record("operations-quarantine", before, {"operations": operations, "quarantine": runtime.operations_coordinator.get_quarantine_report()}) + +func _unpaid_expenses() -> void: + if not fresh([[&"warehouse", Vector3i(-8, 0, 1)]], true): return + clock.advance_minutes(1439 - clock.absolute_minute) + var cash: int = state.get_resource(&"gold") + expect(runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", cash, clock.absolute_minute, &"CONSTRUCTION", warehouse, &"boundary.disclosed.budget").is_success(), "disclosed real ledger budget fixture spends cash before midnight") + clock.advance_minutes(1) + var debt: int = runtime.upkeep_settlement.get_arrears_total() + expect(debt > 0 and runtime.operations_coordinator.get_arrears_total() == debt and state.get_resource(&"gold") == 0, "actual midnight accrual creates unpaid upkeep, not crafted debt") + expect(runtime.economy_service.transfer(&"CONSTRUCTION_EXPENSE", &"PLAYER_CASH", 18, clock.absolute_minute, &"CONSTRUCTION_REFUND", warehouse, &"boundary.disclosed.refund").is_success(), "disclosed actual ledger refund funds a6-food order") + expect(plan(6).is_success(), "player arms auto while actual debt remains unpaid") + var before: Dictionary = runtime.create_snapshot() + var settlement: Dictionary = runtime.upkeep_settlement.create_snapshot() + clock.advance_minutes(1) + expect(stock() == 6 and state.get_resource(&"gold") == 0 and runtime.upkeep_settlement.get_arrears_total() == debt, "actual18G paid auto preserves unpaid expense instead of erasing or netting debt") + expect(var_to_bytes(settlement) == var_to_bytes(runtime.upkeep_settlement.create_snapshot()), "procurement leaves all unpaid settlement receipts/history/IDs exact") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "actual paid auto plus unpaid expense14 strictly restores") + record("unpaid-expense-procurement", before, {"actual_arrears": debt, "settlement": settlement, "ordinary_sales_fixture": false}) + +func _returned_cargo() -> void: + if not fresh([[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]): return + expect(not kitchen.is_empty(), "real kitchen destination completes") + if kitchen.is_empty(): return + expect(runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": PORTER}).is_success(), "ordinary paid hire provides real porter") + expect(runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": PORTER, &"facility_id": warehouse, &"template_id": &"DAY"}).is_success(), "real recurring shift assigns warehouse porter") + for minute: int in range(40): + if runtime.staff_service.get_staff(PORTER).state == StaffRuntime.State.AVAILABLE: break + clock.advance_minutes(1) + expect(runtime.staff_service.get_staff(PORTER).state == StaffRuntime.State.AVAILABLE, "porter physically checks in") + expect(runtime.submit_command(&"purchase_supplies", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}).is_success(), "all cargo8 originate in ordinary paid purchase") + expect(runtime.submit_command(&"create_logistics_request", {&"request_id": RETURN_REQUEST, &"source_inventory_id": inv(warehouse), &"destination_inventory_id": inv(kitchen), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}).is_success(), "actual reserved request starts porter delivery") + for minute: int in range(40): + if runtime.logistics_requests[RETURN_REQUEST].state == LogisticsRequest.State.DELIVERING: break + clock.advance_minutes(1) + var request: LogisticsRequest = runtime.logistics_requests[RETURN_REQUEST] + var cargo: StringName = StringName("cargo:%s" % PORTER) + expect(request.state == LogisticsRequest.State.DELIVERING and stock() == 0 and runtime.inventory_service.total_quantity(cargo, SupplyCatalog.RAW_FOOD) == 8, "real employee picks up8 paid stock into owned cargo") + if request.state != LogisticsRequest.State.DELIVERING: return + clock.advance_minutes(2) + # Disclosed expiry-lot seam preserves the exact8 paid units. No extra or free + # stock is inserted; only half the existing lot expires at the next minute. + var inventory: Dictionary = runtime.inventory_service.create_snapshot() + for owner: Dictionary in inventory[&"inventories"]: + if owner[&"owner_inventory_id"] == String(cargo): + var lot: Dictionary = owner[&"stacks"][0] + lot[&"quantity"] = 4 + var expiring: Dictionary = lot.duplicate(true) + expiring[&"stack_id"] = "boundary-expiring-paid-lot" + expiring[&"expires_at"] = clock.absolute_minute + 1 + (owner[&"stacks"] as Array).append(expiring) + (inventory[&"known_stack_ids"] as Array).append("boundary-expiring-paid-lot") + expect(runtime.inventory_service.restore_snapshot(inventory), "disclosed expiry split preserves8 actual paid units") + clock.advance_minutes(1) + request = runtime.logistics_requests[RETURN_REQUEST] + expect(runtime.supply_logistics.is_returning(RETURN_REQUEST) and runtime.inventory_service.total_quantity(cargo, SupplyCatalog.RAW_FOOD) == 4, "actual bridge retargets unspoiled paid4 as return cargo") + expect(plan(4).is_success(), "player enables auto4 while actual4 cargo are returning") + var before: Dictionary = runtime.create_snapshot() + var report: Dictionary = runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD) + expect(report[&"enabled"] and report[&"physical_quantity"] == 0 and report[&"incoming_quantity"] == 4 and report[&"deficit"] == 0 and report[&"state"] == &"TARGET_REACHED", "actual return cargo4 satisfies target despite warehouse physical0") + expect(runtime.supply_management.advance_auto_purchases(runtime, clock.absolute_minute).is_empty() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), "actual incoming return suppresses duplicate whole-state purchase") + record("return-cargo-pending", before, report) + var cash: int = state.get_resource(&"gold") + for minute: int in range(40): + if request.state == LogisticsRequest.State.CANCELLED: break + clock.advance_minutes(1) + expect(request.state == LogisticsRequest.State.CANCELLED and request.state_reason == "CARGO_EXPIRED_RETURNED" and stock() == 4 and state.get_resource(&"gold") == cash, "actual porter return unloads paid4 with no duplicate auto charge") + expect(runtime.inventory_service.total_quantity(cargo, SupplyCatalog.RAW_FOOD) == 0 and runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and SupplyLogisticsBridge.validate_crosslinks(runtime.logistics_requests, runtime.staff_service, runtime.inventory_service, runtime.facility_service), "actual return clears cargo/task/reservations/carrier ownership") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "actual completed return plus enabled intent strictly restores14") + record("return-cargo-completed", before, runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD)) diff --git a/tests/supply/test_supply_auto_purchase_boundaries.gd.uid b/tests/supply/test_supply_auto_purchase_boundaries.gd.uid new file mode 100644 index 0000000..bdfb6b4 --- /dev/null +++ b/tests/supply/test_supply_auto_purchase_boundaries.gd.uid @@ -0,0 +1 @@ +uid://pyqycsjyyeuo diff --git a/tests/supply/test_supply_auto_purchase_destruction.gd b/tests/supply/test_supply_auto_purchase_destruction.gd new file mode 100644 index 0000000..f60297e --- /dev/null +++ b/tests/supply/test_supply_auto_purchase_destruction.gd @@ -0,0 +1,274 @@ +extends SceneTree + +## Artifact-only acceptance input, not a new standard test or production value. +## The warehouse is normally paid/built, full durability10000, and auto6 is paid. +## A disclosed existing Defense command fixture attacks it through real automatic +## 5-minute rounds. No pre-damage, direct status/suspension, mock outcome or clock +## assignment is used. Threat10000/interval5 are existing test inputs; 201 rounds +## and1010 minutes merely allow real max50 damage/round to exhaust full10000. +## Mana0/recovery uses the public resource seam and actual Runtime projection; +## this is not a claim of natural outage/thief/bankruptcy threat generation. + +const SAVE_PATH := "user://p05-enabled-auto-destruction.ttwb" +const RAID := &"p05.auto.destroy.raid" +const SOURCE_PATHS: Array[String] = [ + "autoload/simulation_runtime.gd", "scripts/supply/supply_management_service.gd", + "scripts/supply/supply_auto_purchase_plans.gd", "scripts/ui/supply_management_panel.gd", + "scripts/defense/defense_service.gd", "scripts/facility/facility_instance.gd", + "scripts/facility/facility_service.gd", "scripts/mana/mana_power_projection.gd", + "scripts/core/save_service.gd", "scripts/core/simulation_snapshot.gd", +] +var runtime: Variant +var clock: Variant +var state: Variant +var warehouse: StringName = &"" +var output := "" +var mode := "" +var save_path := SAVE_PATH +var checks := 0 +var failures := 0 +var purchase_notifications := 0 +var summary: Dictionary = {} + +func _initialize() -> void: + call_deferred("_run") + +func expect(ok: bool, label: String) -> void: + checks += 1 + if ok: print(" ✓ ", label) + else: + failures += 1 + push_error("P05_AUTO_DESTRUCTION: " + label) + +func _run() -> void: + var args := OS.get_cmdline_user_args() + if args.size() == 1 and args[0] == "--write": mode = "write" + elif args.size() == 1 and args[0] == "--read": mode = "read" + output = OS.get_environment("THE_TREE_AUTO_DESTRUCTION_OUTPUT") + var user_dir := OS.get_environment("THE_TREE_AUTO_PURCHASE_EXPECTED_USER_DIR") + var res_dir := OS.get_environment("THE_TREE_AUTO_PURCHASE_EXPECTED_RES_DIR") + var profile := OS.get_environment("THE_TREE_AUTO_PURCHASE_EXPECTED_PROFILE") + var any_external_guard := false + for key: String in ["THE_TREE_AUTO_DESTRUCTION_OUTPUT", "THE_TREE_AUTO_PURCHASE_EXPECTED_USER_DIR", "THE_TREE_AUTO_PURCHASE_EXPECTED_RES_DIR", "THE_TREE_AUTO_PURCHASE_EXPECTED_PROFILE"]: + any_external_guard = any_external_guard or OS.has_environment(key) + var guards := false + if any_external_guard: + guards = not mode.is_empty() and output.is_absolute_path() and DirAccess.dir_exists_absolute(output) and not FileAccess.file_exists(output.path_join(mode + "-summary.json")) and user_dir.is_absolute_path() and OS.get_user_data_dir() == user_dir and res_dir.is_absolute_path() and ProjectSettings.globalize_path("res://").simplify_path().trim_suffix("/") == res_dir.simplify_path().trim_suffix("/") and not profile.is_empty() and bool(ProjectSettings.get_setting("application/config/use_custom_user_dir", false)) and ProjectSettings.get_setting("application/config/custom_user_dir_name", "") == profile + elif args.is_empty(): + mode = "standard" + var nonce := "%d_%d" % [OS.get_process_id(), Time.get_ticks_usec()] + var user_test_root := "user://tests/p05_auto_destruction_" + nonce + save_path = user_test_root.path_join("formal.ttwb") + output = ProjectSettings.globalize_path(user_test_root.path_join("proofs")) + guards = not DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(user_test_root)) and DirAccess.make_dir_recursive_absolute(output) == OK + user_dir = OS.get_user_data_dir() + res_dir = ProjectSettings.globalize_path("res://") + profile = String(ProjectSettings.get_setting("application/config/custom_user_dir_name", "")) + if not guards: + push_error("P05_AUTO_DESTRUCTION exact mode/profile/resource/fresh output guards failed") + quit(91) + return + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + state = root.get_node("GameState") + clock.set_process(false) + _reset_fresh_runtime() + summary = {"mode": mode, "actual_user_data_dir": OS.get_user_data_dir(), "actual_resource_dir": ProjectSettings.globalize_path("res://"), "actual_profile": profile, "configured_attack_fixture": true, "natural_threat_generation_acceptance": false, "production_balance_change": false, "whole_P05_acceptance": false, "source_sha256": {}} + for path: String in SOURCE_PATHS: + summary["source_sha256"][path] = FileAccess.get_sha256("res://" + path) + expect(runtime.create_snapshot()[&"systems"][&"runtime"][&"runtime_schema_version"] == 14, "exact current14 snapshot required") + if mode in ["write", "standard"]: _write_phase() + if mode == "standard" and failures == 0: + _reset_fresh_runtime() + _read_phase() + elif mode == "read": _read_phase() + summary["checks"] = checks + summary["failures"] = failures + summary["save_path"] = ProjectSettings.globalize_path(save_path) + summary["two_process_restarted_in_this_mode"] = mode == "read" + var file := FileAccess.open(output.path_join(mode + "-summary.json"), FileAccess.WRITE) + if file == null: + push_error("P05_AUTO_DESTRUCTION cannot open fresh summary") + quit(92) + return + file.store_string(JSON.stringify(summary, "\t", true, true) + "\n") + file.close() + print("P05_AUTO_DESTRUCTION phase=%s checks=%d failures=%d" % [mode, checks, failures]) + print("Supply auto purchase destruction: %d checks, %d failures" % [checks, failures]) + quit(1 if failures > 0 else 0) + +func _reset_fresh_runtime() -> void: + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 420_506)[&"valid"], "canonical14 Runtime initializes") + clock.set_paused(true) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + purchase_notifications = 0 + +func _command(kind: StringName, payload: Dictionary) -> CommandResult: + return runtime.submit_command(kind, payload, &"player", warehouse, 75) + +func _stock() -> int: + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse) + return runtime.inventory_service.total_quantity(facility.local_inventory_id, SupplyCatalog.RAW_FOOD) if facility != null else -1 + +func _plan() -> Dictionary: + return runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD) + +func _auto_payment_count() -> int: + var count := 0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if String(entry.memo_key).begins_with(SupplyAutoPurchasePlans.RECEIPT_PREFIX): count += 1 + return count + +func _observe_payment(_id: StringName, entries: Array) -> void: + for entry: LedgerEntry in entries: + if String(entry.memo_key).begins_with(SupplyAutoPurchasePlans.RECEIPT_PREFIX): purchase_notifications += 1 + +func _typed(label: String, value: Dictionary) -> void: + var path := output.path_join(label + ".var") + expect(not FileAccess.file_exists(path), "fresh typed proof: " + label) + if FileAccess.file_exists(path): return + var file := FileAccess.open(path, FileAccess.WRITE) + expect(file != null, "typed proof opens: " + label) + if file == null: return + file.store_buffer(var_to_bytes(value)) + file.close() + +func _write_phase() -> void: + var absolute_save := ProjectSettings.globalize_path(save_path) + expect(not DirAccess.dir_exists_absolute(absolute_save) and not FileAccess.file_exists(absolute_save), "fresh formal save never overwrites an existing package") + if DirAccess.dir_exists_absolute(absolute_save) or FileAccess.file_exists(absolute_save): return + expect(_command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(-8, 0, 1)}).is_success(), "normal paid warehouse construction begins") + clock.advance_minutes(180) + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": warehouse = facility.instance_id + expect(not warehouse.is_empty(), "normal warehouse completes without synthetic Facility instance") + if warehouse.is_empty(): return + var original: FacilityInstance = runtime.facility_service.get_instance(warehouse) + expect(original.durability == FacilityInstance.DEFAULT_DURABILITY and original.durability == 10000 and original.suspension_reason.is_empty(), "paid warehouse starts at full10000 integrity without pre-damage") + runtime.economy_service.transaction_posted.connect(_observe_payment) + var cash: int = state.get_resource(&"gold") + expect(_command(&"set_supply_auto_purchase", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 6}).is_success(), "player enables auto6 before actual combat") + clock.advance_minutes(1) + expect(_stock() == 6 and state.get_resource(&"gold") == cash - 18 and _auto_payment_count() == 1 and purchase_notifications == 1, "operating warehouse actually buys6 once for18G before attack") + var pre_combat: Dictionary = runtime.create_snapshot() + var owned_before: Dictionary = _plan() + _typed("before-configured-attack", pre_combat) + var definition := {&"source_id": &"p05.disclosed.configured_attack", &"entry_points": [&"gate.north"], &"objective_ids": [warehouse], &"round_minutes": 5, &"max_duration_minutes": 1010, &"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0, &"terrain_bonus": 0, &"command_bonus": 0, &"surprise_penalty": 0, &"multiple_entry_penalty": 0, &"waves": [{&"wave_id": &"p05.auto.destroy.wave", &"threat_strength": 10000, &"max_rounds": 201, &"retreat_threshold": 0, &"entry_points": [&"gate.north"]}]} + expect(_command(&"create_defense_raid", {&"raid_id": RAID, &"definition": definition}).is_success(), "disclosed configured attack created through actual Runtime command") + expect(_command(&"register_defense_asset", {&"raid_id": RAID, &"asset_id": warehouse, &"kind": &"OBJECTIVE", &"max_integrity": FacilityInstance.DEFAULT_DURABILITY, &"defense": 0, &"options": {&"target_order": 1}}).is_success(), "real full-durability warehouse registered as actual objective") + expect(_command(&"start_defense_raid", {&"raid_id": RAID}).is_success(), "actual raid starts without injected destruction or mock outcome") + var rng_before: Dictionary = runtime.get_rng_stream(&"defense").snapshot() + clock.advance_minutes(4) + expect(runtime.defense_service.get_raid(RAID)[&"current_round"] == 0 and runtime.get_rng_stream(&"defense").snapshot() == rng_before, "deadline-minus1 does not attack or consume RNG") + clock.advance_minutes(1) + for _index: int in range(200): + var ongoing: Dictionary = runtime.defense_service.get_raid(RAID) + if ongoing[&"state"] in DefenseService.TERMINAL_STATES: break + clock.advance_minutes(5) + var raid: Dictionary = runtime.defense_service.get_raid(RAID) + var destroyed: FacilityInstance = runtime.facility_service.get_instance(warehouse) + var damage := 0 + var target_matches := true + for round_value: Variant in (raid[&"resolved_rounds"] as Dictionary).values(): + var round_row: Dictionary = round_value + var hit: Dictionary = round_row[&"defense_damage"] + damage += int(hit.get(&"damage", 0)) + target_matches = target_matches and hit.get(&"target_type", &"") == &"ASSET" and hit.get(&"target_id", &"") == warehouse + expect(raid[&"state"] == DefenseService.RaidState.BREACHED and raid[&"current_round"] == 200 and damage == 10000 and target_matches, "200 real automatic combat rounds destroy full10000 warehouse via actual outcome boundary") + expect(destroyed.durability == 0 and destroyed.state == FacilityInstance.State.BROKEN and destroyed.suspension_reason == &"DESTROYED" and destroyed.operation_state == FacilityInstance.OperationState.SUSPENDED, "Defense terminal projection owns actual persistent DESTROYED suspension") + expect(var_to_bytes(_plan()) == var_to_bytes(owned_before) and _stock() == 6 and _auto_payment_count() == 1 and purchase_notifications == 1, "combat preserves enabled intent and its paid stock/receipt without repeat procurement") + expect(runtime.economy_service.validate_ledger()[&"ok"] and not runtime.get_operations_report()[&"tick_blocked"], "combat settles without ledger or unrelated Operations block") + # A normal post-destruction input makes a real positive deficit; no inventory + # is consumed/replaced and no destroyed/operational status is directly set. + expect(_command(&"set_supply_auto_purchase", {&"warehouse_id": warehouse, &"item_id": SupplyCatalog.RAW_FOOD, &"enabled": true, &"target_stock": 7}).is_success(), "ordinary player target7 is retained even though receiver is destroyed") + _check_blocked("post-combat", 6) + var checkpoint: Dictionary = runtime.create_snapshot() + _typed("formal-checkpoint14", checkpoint) + var saved: Dictionary = runtime.save_to_path(save_path, {&"purpose": &"p05.actual_defense_destroyed_auto"}) + expect(saved.get(&"ok", false), "actual Runtime writes fresh formal current14 package") + var inspected: Dictionary = SaveService.inspect_snapshot(save_path) + expect(inspected.get(&"ok", false) and var_to_bytes(inspected.get(&"snapshot", {})) == var_to_bytes(checkpoint), "read-only formal package contains exact whole typed checkpoint14") + summary["warehouse_id"] = String(warehouse) + summary["actual_rounds"] = raid[&"current_round"] + summary["actual_damage"] = damage + summary["actual_resolved_rounds"] = (raid[&"resolved_rounds"] as Dictionary).size() + summary["before_attack_plan"] = owned_before + summary["saved_plan"] = _plan() + summary["auto_payment_count"] = _auto_payment_count() + summary["automatic_purchase_notifications"] = purchase_notifications + +func _check_blocked(label: String, expected_stock: int) -> void: + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse) + var report: Dictionary = runtime.supply_management.auto_purchase_report(runtime, warehouse, SupplyCatalog.RAW_FOOD) + expect(facility != null and facility.durability == 0 and facility.suspension_reason == &"DESTROYED" and not facility.allows_logistics(), label + ": actual destroyed receiver rejects Logistics") + expect(report[&"enabled"] and report[&"target_stock"] == 7 and report[&"physical_quantity"] == expected_stock and report[&"incoming_quantity"] == 0 and report[&"deficit"] == 1 and report[&"reason"] == &"FACILITY_CLOSED", label + ": positive real shortage exposes destroyed facility block") + var before: Dictionary = runtime.create_snapshot() + expect(runtime.supply_management.advance_auto_purchases(runtime, clock.absolute_minute).is_empty() and var_to_bytes(before) == var_to_bytes(runtime.create_snapshot()), label + ": blocked procurement preserves every typed aggregate/ID/receipt/event/RNG byte") + _typed(label + "-blocked-before", before) + _typed(label + "-blocked-after", runtime.create_snapshot()) + +func _read_phase() -> void: + var expected_file := FileAccess.open(output.path_join("formal-checkpoint14.var"), FileAccess.READ) + var resumed_context := "new process" if mode == "read" else "fresh Runtime reinitialization" + expect(expected_file != null, resumed_context + ": retains actual whole typed checkpoint") + if expected_file == null: return + var expected_value: Variant = bytes_to_var(expected_file.get_buffer(expected_file.get_length())) + expected_file.close() + expect(expected_value is Dictionary, "prior typed checkpoint is a Dictionary") + if not expected_value is Dictionary: return + var expected: Dictionary = expected_value + var inspected: Dictionary = SaveService.inspect_snapshot(save_path) + expect(inspected.get(&"ok", false) and var_to_bytes(inspected.get(&"snapshot", {})) == var_to_bytes(expected), resumed_context + ": same exact current14 formal package") + var loaded: Dictionary = runtime.load_from_path(save_path) + expect(loaded.get(&"ok", false), resumed_context + ": ordinary Runtime load commits strict14 destroyed checkpoint") + if not loaded.get(&"ok", false): return + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": warehouse = facility.instance_id + expect(not warehouse.is_empty(), "loaded original warehouse identity remains present") + if warehouse.is_empty(): return + var normal_loaded: Dictionary = runtime.create_snapshot() + _typed("normal-runtime-load14", normal_loaded) + # SAVE_LOADED publishes a legitimate event. Strict restore of that same + # package separately proves whole-state byte equality, without hiding it. + expect(runtime.restore_snapshot(expected) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(expected), "strict14 restore reproduces every saved typed byte; normal load event kept separately") + var plan_before: Dictionary = _plan() + var inventory_before: Dictionary = runtime.inventory_service.create_snapshot() + var economy_before: Dictionary = runtime.economy_service.create_snapshot() + runtime.economy_service.transaction_posted.connect(_observe_payment) + _check_blocked("loaded-restored", 6) + var minute_before_power: int = clock.absolute_minute + var positive_mana: int = state.get_resource(&"mana") + expect(positive_mana > 0, "pre-outage reserve is actually positive") + state.set_resource(&"mana", 0) # Explicit reserve boundary, actual power projection. + clock.advance_minutes(1) + var outage_facility: FacilityInstance = runtime.facility_service.get_instance(warehouse) + expect(not outage_facility.power_available and outage_facility.durability == 0 and outage_facility.suspension_reason == &"DESTROYED", "actual Mana gate outage changes power only; destroyed status persists") + var outage: Dictionary = runtime.create_snapshot() + _typed("actual-power-outage14", outage) + expect(runtime.restore_snapshot(outage) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(outage), "strict14 outage snapshot retains destroyed facility and owned enabled plan") + state.set_resource(&"mana", positive_mana) + clock.advance_minutes(1) + var recovered_power: FacilityInstance = runtime.facility_service.get_instance(warehouse) + expect(recovered_power.power_available and recovered_power.durability == 0 and recovered_power.suspension_reason == &"DESTROYED" and recovered_power.operation_state == FacilityInstance.OperationState.SUSPENDED, "actual power recovery cannot clear persistent destruction or resume operations") + _check_blocked("power-restored", 6) + expect(var_to_bytes(_plan()) == var_to_bytes(plan_before) and _stock() == 6 and _auto_payment_count() == 1 and purchase_notifications == 0, "restored power produces no stock/payment/notification and preserves explicit intent history") + expect(clock.absolute_minute == minute_before_power + 2, "two ordinary elapsed projection ticks are separate from no-op procurement") + # Inventory/Economy current_minute legitimately advances with the two clock + # ticks. Compare complete participant state after normalizing only this field. + var inventory_after: Dictionary = runtime.inventory_service.create_snapshot() + var economy_after: Dictionary = runtime.economy_service.create_snapshot() + inventory_after[&"current_minute"] = inventory_before[&"current_minute"] + economy_after[&"current_minute"] = economy_before[&"current_minute"] + expect(var_to_bytes(inventory_after) == var_to_bytes(inventory_before) and var_to_bytes(economy_after) == var_to_bytes(economy_before), "two public Mana projection ticks preserve all stock IDs/reservations/history and payment bytes except declared clocks") + var final: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(final) and var_to_bytes(runtime.create_snapshot()) == var_to_bytes(final), "after power recovery whole14 snapshot strictly restores without implicit repair") + _typed("final14", final) + summary["warehouse_id"] = String(warehouse) + summary["same_package_two_processes"] = mode == "read" + summary["owned_plan_preserved"] = var_to_bytes(_plan()) == var_to_bytes(plan_before) + summary["actual_power_available_after_recovery"] = recovered_power.power_available + summary["persistent_suspension"] = String(recovered_power.suspension_reason) + summary["auto_payment_count"] = _auto_payment_count() + summary["automatic_purchase_notifications_after_load"] = purchase_notifications diff --git a/tests/supply/test_supply_auto_purchase_destruction.gd.uid b/tests/supply/test_supply_auto_purchase_destruction.gd.uid new file mode 100644 index 0000000..0b8db11 --- /dev/null +++ b/tests/supply/test_supply_auto_purchase_destruction.gd.uid @@ -0,0 +1 @@ +uid://cqccwfjhooq1t diff --git a/tests/supply/test_supply_save_migration.gd b/tests/supply/test_supply_save_migration.gd index ca9792d..46c6180 100644 --- a/tests/supply/test_supply_save_migration.gd +++ b/tests/supply/test_supply_save_migration.gd @@ -19,7 +19,7 @@ func _run() -> void: source[&"clock"].advance_minutes(3) var canonical: Dictionary = source_runtime.create_snapshot() var canonical_data: Dictionary = canonical[&"systems"][&"runtime"] - _expect(canonical_data[&"runtime_schema_version"] == 13, "current Runtime emits schema v13") + _expect(canonical_data[&"runtime_schema_version"] == 14, "current Runtime emits schema v14") _expect(canonical_data[&"kitchen_production"][&"current_minute"] == source[&"clock"].absolute_minute, "production clock follows Runtime clock without existing kitchens") var legacy := canonical.duplicate(true) var legacy_data: Dictionary = legacy[&"systems"][&"runtime"] @@ -35,7 +35,7 @@ func _run() -> void: _expect(legacy_data.has(&"facility_infrastructure") and legacy_data.has(&"route_projection"), "v6 fixture retains infrastructure introduced in v6") _expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v9") var migrated: Dictionary = first_runtime.create_snapshot() - _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "v6 migration re-emits current schema") + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 14, "v6 migration re-emits current schema") var kitchen: Dictionary = migrated[&"systems"][&"runtime"][&"kitchen_production"] _expect(kitchen[&"plans"].is_empty() and kitchen[&"active_batches"].is_empty(), "legacy migration does not infer kitchen production plans or batches") _expect(kitchen[&"current_minute"] == source[&"clock"].absolute_minute, "legacy production clock binds to the restored Runtime minute") diff --git a/tests/transport/test_runtime_transport.gd b/tests/transport/test_runtime_transport.gd index 12f4c5c..5ffebee 100644 --- a/tests/transport/test_runtime_transport.gd +++ b/tests/transport/test_runtime_transport.gd @@ -93,7 +93,7 @@ func _verify_mid_ride() -> bool: _expect(bool(_runtime.save_to_path(save_path).get(&"ok", false)), "boarded save writes isolated disk slot") _expect(bool(_runtime.load_from_path(save_path).get(&"ok", false)), "boarded disk slot reloads") var snapshot: Dictionary = _runtime.create_snapshot() - _expect(snapshot[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "runtime schema13 records elevator state and explicit vertical routing") + _expect(snapshot[&"systems"][&"runtime"][&"runtime_schema_version"] == 14, "runtime schema14 records elevator state and explicit vertical routing") _expect(_runtime.restore_snapshot(snapshot), "mid-ride Runtime snapshot restores actor/cabin/Path ownership") var shaft: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] var busy := _cmd(&"demolish_facility", {&"instance_id": shaft[&"module_ids"][1]}) diff --git a/tests/transport/test_transport_density_migration.gd b/tests/transport/test_transport_density_migration.gd index 65ebec2..d68e298 100644 --- a/tests/transport/test_transport_density_migration.gd +++ b/tests/transport/test_transport_density_migration.gd @@ -68,7 +68,7 @@ func _run() -> void: for guest: GuestRuntime in _runtime.guest_service.get_guests(): _expect(guest.current_cell == Vector3i.ZERO and _runtime.path_service.get_actor_projected_cell(StringName("guest:" + String(guest.guest_id))) == Vector3i.ZERO, "migration preserves actual passenger's logical boarding anchor") var canonical: Dictionary = _runtime.create_snapshot() - _expect(canonical[&"systems"][&"runtime"][&"runtime_schema_version"] == 13 and canonical[&"systems"][&"runtime"][&"path"][&"schema_version"] == 5, "resave emits current Runtime13 and Path5 only") + _expect(canonical[&"systems"][&"runtime"][&"runtime_schema_version"] == 14 and canonical[&"systems"][&"runtime"][&"path"][&"schema_version"] == 5, "resave emits current Runtime14 and Path5 only") _expect(_runtime.restore_snapshot(canonical) and _runtime.create_snapshot() == canonical, "canonical migrated save reloads exactly without applying density or stairs twice") forged = canonical.duplicate(true) forged[&"systems"][&"runtime"][&"path"][&"transit_actor_ids"].pop_back() diff --git a/tests/transport/test_transport_save.gd b/tests/transport/test_transport_save.gd index cea1297..3165efd 100644 --- a/tests/transport/test_transport_save.gd +++ b/tests/transport/test_transport_save.gd @@ -15,7 +15,7 @@ func _run() -> void: var clock: Variant = fixture[&"clock"] clock.advance_minutes(3) var current: Dictionary = runtime.create_snapshot() - _expect(current[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "Runtime emits schema13 with elevator and explicit vertical-route aggregates") + _expect(current[&"systems"][&"runtime"][&"runtime_schema_version"] == 14, "Runtime emits schema14 with elevator and explicit vertical-route aggregates") var legacy := current.duplicate(true) var data: Dictionary = legacy[&"systems"][&"runtime"] data[&"runtime_schema_version"] = 7 diff --git a/tests/ui/test_supply_auto_purchase_ui.gd b/tests/ui/test_supply_auto_purchase_ui.gd new file mode 100644 index 0000000..5d9afb5 --- /dev/null +++ b/tests/ui/test_supply_auto_purchase_ui.gd @@ -0,0 +1,156 @@ +extends SceneTree + +## Normal build/product controls exercise drafts, a real paid tick, queued Save, +## normal load, duplicate prevention and removal-free disabled restart state. +var checks := 0 +var failures := 0 +var main: Control +var panel: SupplyManagementPanel +var runtime: Variant +var clock: Variant +var state: Variant +var warehouse: StringName = &"" +var slot := "" + +func _initialize() -> void: + call_deferred("run") + +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("AUTO_PURCHASE_UI: " + message) + +func setup_scene(save_slot: String) -> void: + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + state = root.get_node("GameState") + clock.set_process(false) + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 420_502)[&"valid"], "current canonical Runtime initializes") + clock.set_paused(true) + root.size = Vector2i(1280, 720) + main = load("res://scenes/main/main.tscn").instantiate() as Control + main.manual_save_path = save_slot + root.add_child(main) + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + panel = main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + expect(panel != null, "ordinary scene owns product controls") + if panel != null: main.call("_activate_management_mode", &"product") + +func build_warehouse() -> void: + var count: int = runtime.construction_service.get_jobs().size() + main.call("_select_build_definition", &"warehouse") + main.call("_on_map_cell_clicked", Vector3i(-8, 0, 1)) + expect(runtime.construction_service.get_jobs().size() == count + 1, "normal build palette/map submits paid construction") + clock.advance_minutes(180) + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": warehouse = facility.instance_id + expect(not warehouse.is_empty(), "completed ordinary warehouse becomes real purchase endpoint") + main.call("_activate_management_mode", &"product") + panel.refresh() + expect(panel.selected_warehouse_id == warehouse and panel.selected_item_id == SupplyCatalog.RAW_FOOD, "ordinary product selects actual warehouse/catalog item") + expect(not panel.auto_purchase_enabled.button_pressed and int(panel.auto_purchase_target.value) == 0, "new warehouse is explicitly disabled with no funded default") + +func stock() -> int: + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse) + return runtime.inventory_service.total_quantity(facility.local_inventory_id, SupplyCatalog.RAW_FOOD) if facility != null else 0 + +func save_button() -> void: + for button: Node in main.find_children("*", "Button", true, false): + if button.text == "保存": + expect(not button.disabled, "normal Save button enabled") + button.pressed.emit() + expect(bool(runtime.latest_save_operation_result.get(&"ok", false)), "ordinary Save reports success") + return + expect(false, "normal Save button exists") + +func load_menu() -> void: + var menu := main.find_child("SaveLoadMenu", true, false) as MenuButton + expect(menu != null and not menu.disabled, "normal load menu enabled") + if menu != null and not menu.disabled: menu.get_popup().id_pressed.emit(0) + +func verify_order(target: int = 6) -> void: + warehouse = panel.selected_warehouse_id + var row: Dictionary = runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD) + expect(row[&"enabled"] and row[&"target_stock"] == target and row[&"last_result"] == &"PURCHASED", "normal tick retains enabled player goal and paid receipt") + expect(stock() == target and runtime.command_queue.pending_count() == 0, "actual warehouse has desired paid stock and no synthetic queued order") + expect(runtime.economy_service.get_transaction_id_for_idempotency_key(SupplyAutoPurchasePlans.order_key(warehouse, SupplyCatalog.RAW_FOOD, row[&"last_attempt_minute"])) == StringName(row[&"last_transaction_id"]), "normal restored receipt resolves real ledger key") + expect(state.get_resource(&"gold") == runtime.economy_service.get_balance(&"PLAYER_CASH"), "ordinary UI money mirror is ledger-owned") + expect(panel.auto_purchase_status.text.contains("ON") and not panel.auto_purchase_button.disabled, "ordinary UI reconstructs enabled completed controls") + +func queue_intent(target: int = 6) -> void: + var before: Dictionary = runtime.create_snapshot() + panel.auto_purchase_enabled.button_pressed = true + panel.auto_purchase_target.value = target + expect(var_to_bytes(runtime.create_snapshot()) == var_to_bytes(before), "editing toggle/target changes draft only") + expect(panel.auto_purchase_status.text.contains("未保存"), "UI distinguishes draft from active intent") + clock.set_paused(false) + panel.auto_purchase_button.pressed.emit() + clock.set_paused(true) + expect(runtime.command_queue.pending_count() == 1 and stock() == 0 and panel.auto_purchase_button.disabled, "normal Apply queues one intent without stock/payment") + panel.auto_purchase_button.pressed.emit() + expect(runtime.command_queue.pending_count() == 1, "disabled/repeated normal Apply cannot double queue") + +func run() -> void: + var explicit_slot := OS.get_environment("THE_TREE_AUTO_PURCHASE_UI_SLOT") + slot = explicit_slot if not explicit_slot.is_empty() else "user://tests/supply_auto_ui_%d_%d/manual" % [OS.get_process_id(), Time.get_ticks_usec()] + if not valid_slot(slot) or DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(slot)): + push_error("AUTO_PURCHASE_UI needs a fresh user://tests slot; player fallback forbidden") + quit(90); return + await setup_scene(slot) + if panel == null: quit(failures); return + expect(panel.auto_purchase_button.disabled, "no warehouse is a safe empty setting state") + build_warehouse() + if warehouse.is_empty(): quit(failures); return + queue_intent() + save_button() + expect(SaveService.load_snapshot(slot)[&"ok"], "pending actual command is written to isolated disk") + load_menu() + panel.refresh() + expect(runtime.command_queue.pending_count() == 1 and not runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)[&"enabled"] and panel.auto_purchase_button.disabled, "normal reload reconstructs one pending setting with no premature enabling") + var cash: int = state.get_resource(&"gold") + var next_id: int = runtime.create_snapshot()[&"systems"][&"runtime"][&"next_command_id"] + clock.advance_minutes(1) + panel.refresh() + verify_order() + expect(state.get_resource(&"gold") == cash - 18 and runtime.create_snapshot()[&"systems"][&"runtime"][&"next_command_id"] == next_id, "loaded intent charges18G once without allocating auto commandID") + var paid: Dictionary = runtime.create_snapshot() + panel.refresh() + panel.select_tab(&"delivery") + panel.select_tab(&"purchase") + expect(var_to_bytes(runtime.create_snapshot()) == var_to_bytes(paid), "product navigation/refresh mutate no gameplay") + panel.auto_purchase_enabled.button_pressed = false + panel.auto_purchase_button.pressed.emit() + expect(not runtime.auto_purchase_plans.get_plan(warehouse, SupplyCatalog.RAW_FOOD)[&"enabled"], "normal controls disable auto ordering") + save_button() + load_menu() + panel.refresh() + expect(not panel.auto_purchase_enabled.button_pressed and stock() == 6, "normal load preserves disabled intent and paid stock") + await capture("normal-auto-purchase") + SaveService.delete_save(slot) + main.queue_free() + await process_frame + print("SUPPLY_AUTO_PURCHASE_UI checks=%d failures=%d" % [checks, failures]) + quit(failures) + +func capture(label: String) -> void: + var folder := OS.get_environment("THE_TREE_AUTO_PURCHASE_UI_CAPTURE_DIR") + if folder.is_empty(): return + expect(folder.is_absolute_path() and DirAccess.dir_exists_absolute(folder) and DisplayServer.get_name() != "headless", "explicit UI capture uses an existing QA output and real renderer") + if not folder.is_absolute_path() or DisplayServer.get_name() == "headless": return + var path := folder.path_join(label + ".png") + expect(not FileAccess.file_exists(path), "UI capture is fresh") + if FileAccess.file_exists(path): return + await process_frame + await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(path) == OK, "actual product image saved for ROOT visual review") + +func valid_slot(value: String) -> bool: + if not value.begins_with("user://tests/") or value.contains("\\"): return false + for segment: String in value.trim_prefix("user://tests/").split("/", true): + if segment in ["", ".", ".."]: return false + return value.ends_with("/manual") diff --git a/tests/ui/test_supply_auto_purchase_ui.gd.uid b/tests/ui/test_supply_auto_purchase_ui.gd.uid new file mode 100644 index 0000000..6f12f82 --- /dev/null +++ b/tests/ui/test_supply_auto_purchase_ui.gd.uid @@ -0,0 +1 @@ +uid://ddpkpqypwj4f0