Add supply auto-purchase and preserve save recovery state

This commit is contained in:
Takeshi
2026-10-02 07:35:03 +09:00
parent 285528b7a3
commit 39f6068461
44 changed files with 2625 additions and 61 deletions
+9
View File
@@ -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_original_facility_dimensions.gd
$(GODOT) --headless --path . --script res://tests/s1/test_world_service.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_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_path_congestion.gd
$(GODOT) --headless --path . --script res://tests/s1/test_construction_service.gd $(GODOT) --headless --path . --script res://tests/s1/test_construction_service.gd
$(GODOT) --headless --path . --script res://tests/s1/test_excavation_service.gd $(GODOT) --headless --path . --script res://tests/s1/test_excavation_service.gd
@@ -86,6 +87,9 @@ test-s5:
test-s6: test-s6:
$(GODOT) --headless --path . --script res://tests/s6/test_save_operations.gd $(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_performance_budget.gd
$(GODOT) --headless --path . --script res://tests/s6/test_runtime_save_lifecycle.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_main_dashboard.gd
$(GODOT) --headless --path . --script res://tests/ui/test_staff_management.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_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_business_management.gd
$(GODOT) --headless --path . --script res://tests/ui/test_elevator_management.gd $(GODOT) --headless --path . --script res://tests/ui/test_elevator_management.gd
$(MAKE) test-opening $(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_save_migration.gd
$(GODOT) --headless --path . --script res://tests/supply/test_supply_logistics_bridge.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_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 $(GODOT) --headless --path . --script res://tests/supply/test_normal_supply_chain.gd
capture-supply: capture-supply:
+40 -7
View File
@@ -19,7 +19,10 @@ const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE"
const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE" const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE"
const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE" const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE"
const WAGE_EXPENSE_ACCOUNT := &"WAGE_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 CLEANING_RUNTIME_SNAPSHOT_VERSION := 12
const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11 const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11
const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd") const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd")
@@ -110,6 +113,7 @@ var logistics_dispatcher := LogisticsDispatcher.new()
var logistics_requests: Dictionary = {} var logistics_requests: Dictionary = {}
var kitchen_production := KitchenProductionService.new() var kitchen_production := KitchenProductionService.new()
var supply_management := SupplyManagementService.new() var supply_management := SupplyManagementService.new()
var auto_purchase_plans := AutoPurchasePlansType.new()
var supply_logistics := SupplyLogisticsBridge.new() var supply_logistics := SupplyLogisticsBridge.new()
var elevators := ElevatorService.new() var elevators := ElevatorService.new()
var legacy_stairs := LegacyStairService.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(&"complete_progression_quest", Callable(self, "_execute_complete_progression_quest"))
register_command_executor(&"acknowledge_operations_pause", Callable(self, "_execute_acknowledge_operations_pause")) 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(&"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(&"set_kitchen_production", Callable(self, "_execute_set_kitchen_production"))
register_command_executor(&"add_inventory_stack", Callable(self, "_execute_add_inventory_stack")) register_command_executor(&"add_inventory_stack", Callable(self, "_execute_add_inventory_stack"))
register_command_executor(&"create_logistics_request", Callable(self, "_execute_create_logistics_request")) 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: func create_snapshot() -> Dictionary:
var rng_data := {} var rng_data := {}
var stream_ids: Array = _rng_streams.keys() for stream_id: StringName in _sorted_name_keys(_rng_streams):
stream_ids.sort()
for stream_id: Variant in stream_ids:
var stream: DeterministicRng = _rng_streams[stream_id] var stream: DeterministicRng = _rng_streams[stream_id]
rng_data[String(stream_id)] = stream.snapshot() rng_data[String(stream_id)] = stream.snapshot()
var owned_systems := { var owned_systems := {
@@ -531,6 +534,7 @@ func create_snapshot() -> Dictionary:
&"upkeep_settlement": upkeep_settlement.create_snapshot(), &"upkeep_settlement": upkeep_settlement.create_snapshot(),
&"inventory": inventory_service.create_snapshot(), &"inventory": inventory_service.create_snapshot(),
&"kitchen_production": kitchen_production.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(),
&"auto_purchase_plans": auto_purchase_plans.create_snapshot(),
&"elevators": elevators.create_snapshot(), &"elevators": elevators.create_snapshot(),
&"legacy_stairs": legacy_stairs.create_snapshot(), &"legacy_stairs": legacy_stairs.create_snapshot(),
&"logistics_dispatcher": logistics_dispatcher.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, UPKEEP_RUNTIME_SNAPSHOT_VERSION,
WEATHER_RUNTIME_SNAPSHOT_VERSION, WEATHER_RUNTIME_SNAPSHOT_VERSION,
CLEANING_RUNTIME_SNAPSHOT_VERSION, CLEANING_RUNTIME_SNAPSHOT_VERSION,
EVENT_THREAT_RUNTIME_SNAPSHOT_VERSION,
RUNTIME_SNAPSHOT_VERSION, RUNTIME_SNAPSHOT_VERSION,
]: ]:
return false return false
@@ -649,6 +654,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
runtime_current_keys.append(&"weather") runtime_current_keys.append(&"weather")
if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION: if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION:
runtime_current_keys.append(&"facility_cleanliness") 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) \ 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(&"runtime_schema_version", null)) != TYPE_INT \
or typeof(runtime_data.get(&"world_seed", 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 return false
if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"legacy_stairs", null) is Dictionary: if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"legacy_stairs", null) is Dictionary:
return false 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 := MvpContentCatalog.new()
var restored_catalog_report := restored_catalog.rebuild() var restored_catalog_report := restored_catalog.rebuild()
if not bool(restored_catalog_report.get(&"valid", false)): 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() var restored_queue := CommandQueue.new()
if not restored_queue.restore_snapshot(runtime_data[&"command_queue"]): if not restored_queue.restore_snapshot(runtime_data[&"command_queue"]):
return false 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() var restored_id_generator := StableIdGenerator.new()
if not restored_id_generator.restore_snapshot(runtime_data[&"id_generator"]): if not restored_id_generator.restore_snapshot(runtime_data[&"id_generator"]):
return false return false
@@ -1203,6 +1221,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
return false return false
if restored_kitchen.current_minute != restored_runtime_domain_minute or not restored_kitchen.validate_crosslinks(): if restored_kitchen.current_minute != restored_runtime_domain_minute or not restored_kitchen.validate_crosslinks():
return false 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): if not SupplyLogisticsBridge.validate_crosslinks(restored_logistics, restored_staff, restored_inventory, restored_facilities):
return false return false
var restored_weather := WeatherTimeline.new() var restored_weather := WeatherTimeline.new()
@@ -1271,6 +1293,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo
economy_service = restored_economy economy_service = restored_economy
inventory_service = restored_inventory inventory_service = restored_inventory
kitchen_production = restored_kitchen kitchen_production = restored_kitchen
auto_purchase_plans = restored_auto_purchase
logistics_dispatcher = restored_dispatcher logistics_dispatcher = restored_dispatcher
logistics_requests = restored_logistics logistics_requests = restored_logistics
facility_service = restored_facilities facility_service = restored_facilities
@@ -1966,6 +1989,7 @@ func _build_operations_external_state() -> Dictionary:
&"upkeep_settlement": upkeep_settlement.create_snapshot(), &"upkeep_settlement": upkeep_settlement.create_snapshot(),
&"inventory": inventory_service.create_snapshot(), &"inventory": inventory_service.create_snapshot(),
&"kitchen_production": kitchen_production.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(),
&"auto_purchase_plans": auto_purchase_plans.create_snapshot(),
&"facilities": facility_service.create_snapshot(), &"facilities": facility_service.create_snapshot(),
&"facility_cleanliness": facility_cleanliness.create_snapshot(), &"facility_cleanliness": facility_cleanliness.create_snapshot(),
&"guests": guest_service.create_snapshot(), &"guests": guest_service.create_snapshot(),
@@ -2023,7 +2047,7 @@ func _restore_operations_external_state(token: Variant) -> bool:
var saved: Dictionary = token var saved: Dictionary = token
var required_dictionary_keys: Array[StringName] = [ var required_dictionary_keys: Array[StringName] = [
&"facility_upkeep", &"upkeep_settlement", &"facility_cleanliness", &"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", &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"legacy_stairs",
&"excavation", &"spatial_subslots", &"route_projection", &"excavation", &"spatial_subslots", &"route_projection",
&"facility_infrastructure", &"facility_infrastructure",
@@ -2066,6 +2090,7 @@ func _restore_operations_external_state(token: Variant) -> bool:
var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"]) var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"])
facilities_ok = facility_cleanliness.restore_snapshot(saved.get(&"facility_cleanliness", {})) and facilities_ok facilities_ok = facility_cleanliness.restore_snapshot(saved.get(&"facility_cleanliness", {})) and facilities_ok
var kitchen_ok := kitchen_production.restore_snapshot(saved[&"kitchen_production"]) 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 guests_ok := guest_service.restore_snapshot(saved[&"guests"])
var staff_ok := staff_service.restore_snapshot(saved[&"staff"]) var staff_ok := staff_service.restore_snapshot(saved[&"staff"])
var relationships_ok := relationship_service.restore_snapshot(saved[&"relationships"]) var relationships_ok := relationship_service.restore_snapshot(saved[&"relationships"])
@@ -2116,7 +2141,7 @@ func _restore_operations_external_state(token: Variant) -> bool:
rng_ok = false rng_ok = false
_discard_operations_domain_events() _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 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 events_ok and authority_ok and world_ok and spatial_ok and path_ok \
and route_ok and infrastructure_ok and excavation_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(economy_service.create_snapshot(), saved[&"economy"]) \
or not _strict_variants_equal(inventory_service.create_snapshot(), saved[&"inventory"]) \ 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(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_service.create_snapshot(), saved[&"facilities"]) \
or not _strict_variants_equal(facility_cleanliness.create_snapshot(), saved.get(&"facility_cleanliness", {})) \ or not _strict_variants_equal(facility_cleanliness.create_snapshot(), saved.get(&"facility_cleanliness", {})) \
or not _strict_variants_equal(guest_service.create_snapshot(), saved[&"guests"]) \ 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 facility_service.current_minute = _clock.absolute_minute
_configure_facility_boundaries() _configure_facility_boundaries()
_connect_facility_signals() _connect_facility_signals()
auto_purchase_plans = AutoPurchasePlansType.new()
kitchen_production = KitchenProductionService.new() kitchen_production = KitchenProductionService.new()
kitchen_production.configure(inventory_service, facility_service, definition_registry, _clock.absolute_minute) 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 result.command_id = command.command_id
if result.is_success(): if result.is_success():
kitchen_production.remove_plan(instance_id) kitchen_production.remove_plan(instance_id)
auto_purchase_plans.remove_warehouse(instance_id)
return result return result
@@ -8184,6 +8212,7 @@ func _advance_inventory_and_logistics(absolute_minute: int) -> void:
inventory_service.current_minute = absolute_minute inventory_service.current_minute = absolute_minute
inventory_service.process_expired(absolute_minute, &"inventory.waste") inventory_service.process_expired(absolute_minute, &"inventory.waste")
if int(_state.get_resource(&"mana")) > 0: if int(_state.get_resource(&"mana")) > 0:
supply_management.advance_auto_purchases(self, absolute_minute)
supply_management.ensure_deliveries(self, absolute_minute) supply_management.ensure_deliveries(self, absolute_minute)
supply_logistics.tick(absolute_minute) supply_logistics.tick(absolute_minute)
@@ -12138,6 +12167,10 @@ func _execute_purchase_supplies(command: SimulationCommand) -> CommandResult:
return supply_management.purchase(self, command) 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: func _execute_set_kitchen_production(command: SimulationCommand) -> CommandResult:
return supply_management.set_production(self, command) 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. ## Read-only saved-game preflight used by the load menu before confirmation.
func preview_facility_dimensions_at_path(save_path: String) -> Dictionary: 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 if not loaded[&"ok"]: return loaded
var isolated: Variant = get_script().new() var isolated: Variant = get_script().new()
var preview_clock: Variant = load("res://autoload/game_clock.gd").new() var preview_clock: Variant = load("res://autoload/game_clock.gd").new()
+140 -19
View File
@@ -14,6 +14,10 @@ const AUTOSAVE_SUFFIX := ".autosave."
const MIGRATION_BACKUP_SUFFIX := ".pre_migration.bak" const MIGRATION_BACKUP_SUFFIX := ".pre_migration.bak"
const SAVE_TRANSACTION_SUFFIX := ".save_transaction" const SAVE_TRANSACTION_SUFFIX := ".save_transaction"
const SAVE_TRANSACTION_MARKER := "THE_TREE_FORMAL_SAVE_V1\n" 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() 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) 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: static func get_performance_report(metric_id: StringName) -> Dictionary:
return _performance_budget.get_report(metric_id) return _performance_budget.get_report(metric_id)
@@ -314,9 +331,11 @@ static func _load_recovery_candidate_impl(
if not resolved[&"ok"]: if not resolved[&"ok"]:
return resolved return resolved
var target_path: String = resolved[&"path"] var target_path: String = resolved[&"path"]
var formal_reconcile := _reconcile_interrupted_formal_save(target_path) # Explicit candidate selection is a read-only operation. Normal load and
if not formal_reconcile[&"ok"]: # load_with_recovery retain transaction reconciliation at their own entry.
return formal_reconcile var formal_guard := _guard_identified_formal_format(target_path)
if not formal_guard[&"ok"]:
return formal_guard
var candidate_path := "" var candidate_path := ""
match candidate_id: match candidate_id:
&"formal": &"formal":
@@ -393,12 +412,29 @@ static func _migrate_save_impl(
if not resolved[&"ok"]: if not resolved[&"ok"]:
return resolved return resolved
var target_path: String = resolved[&"path"] var target_path: String = resolved[&"path"]
var formal_reconcile := _reconcile_interrupted_formal_save(target_path) var formal_guard := _guard_identified_formal_format(target_path)
if not formal_reconcile[&"ok"]: var migrating_registered_legacy := false
return formal_reconcile if not formal_guard[&"ok"]:
var reconcile := _reconcile_interrupted_migration(target_path) if formal_guard[&"code"] != &"UNSUPPORTED_FORMAT": return formal_guard
if not reconcile[&"ok"]: var identified_version := int(formal_guard[&"identified_format_version"])
return reconcile 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] = [ var operation_paths: Array[String] = [
target_path, target_path,
target_path + ".migration.tmp", target_path + ".migration.tmp",
@@ -734,10 +770,16 @@ static func _write_package_directory(
if make_error != OK: if make_error != OK:
return _failure(&"CREATE_DIRECTORY_FAILED", error_string(make_error)) return _failure(&"CREATE_DIRECTORY_FAILED", error_string(make_error))
var raw_world := var_to_bytes(snapshot) 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) var compressed_world := raw_world.compress(FileAccess.COMPRESSION_ZSTD)
if compressed_world.is_empty() and not raw_world.is_empty(): if compressed_world.is_empty() and not raw_world.is_empty():
_remove_tree(package_path) _remove_tree(package_path)
return _failure(&"COMPRESSION_FAILED", WORLD_FILE) 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 checksum := _sha256(compressed_world)
var world_error := _write_world( var world_error := _write_world(
package_path.path_join(WORLD_FILE), package_path.path_join(WORLD_FILE),
@@ -775,7 +817,11 @@ static func _write_package_directory(
continue continue
if not manifest.has(key): if not manifest.has(key):
manifest[key] = metadata[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: if manifest_error != OK:
_remove_tree(package_path) _remove_tree(package_path)
return _failure(&"WRITE_MANIFEST_FAILED", error_string(manifest_error)) 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) var manifest_file := FileAccess.open(manifest_path, FileAccess.READ)
if manifest_file == null: if manifest_file == null:
return _failure(&"INVALID_MANIFEST", error_string(FileAccess.get_open_error())) 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() var manifest_text := manifest_file.get_as_text()
manifest_file = null 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: if not manifest_value is Dictionary:
return _failure(&"INVALID_MANIFEST", manifest_path) return _failure(&"INVALID_MANIFEST", manifest_path)
var manifest: Dictionary = manifest_value var manifest: Dictionary = manifest_value
var manifest_version := int(manifest.get("format_version", manifest.get(&"format_version", -1))) var version_value: Variant = manifest.get("format_version", -1)
if manifest_version < 0: # Godot JSON decodes numbers as floats. Permit exact integral JSON numbers,
return _failure(&"INVALID_MANIFEST", "missing format_version") # 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: if expected_format_version >= 0 and manifest_version != expected_format_version:
return _failure(&"UNSUPPORTED_FORMAT", str(manifest_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())) return _failure(&"READ_WORLD_FAILED", error_string(FileAccess.get_open_error()))
if world_file.get_length() < 16: if world_file.get_length() < 16:
return _failure(&"INVALID_WORLD_HEADER", world_path) 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 magic := world_file.get_buffer(4).get_string_from_ascii()
var stored_version := world_file.get_32() var stored_version := world_file.get_32()
var uncompressed_size := world_file.get_64() 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() var compressed_size := world_file.get_length() - world_file.get_position()
if compressed_size < 0: if compressed_size < 0:
return _failure(&"INVALID_WORLD_HEADER", world_path) return _failure(&"INVALID_WORLD_HEADER", world_path)
var compressed_world := world_file.get_buffer(compressed_size) var compressed_world := world_file.get_buffer(compressed_size)
if magic != MAGIC or stored_version != manifest_version: if magic != MAGIC or stored_version != manifest_version:
return _failure(&"INVALID_WORLD_HEADER", world_path) return _failure(&"INVALID_WORLD_HEADER", world_path)
var expected_checksum := str(manifest.get("checksum_world", manifest.get(&"checksum_world", ""))) var checksum_value: Variant = manifest.get("checksum_world", null)
if expected_checksum.is_empty() or _sha256(compressed_world) != expected_checksum: 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) return _failure(&"CHECKSUM_MISMATCH", world_path)
var manifest_size := int(manifest.get("uncompressed_size", manifest.get(&"uncompressed_size", -1))) # Older packages may omit this duplicate size field; the binary header still
if manifest_size >= 0 and manifest_size != uncompressed_size: # supplies a required bounded size. If present it must be exact and agree.
return _failure(&"SIZE_MISMATCH", world_path) 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) var raw_world := compressed_world.decompress(uncompressed_size, FileAccess.COMPRESSION_ZSTD)
if raw_world.size() != uncompressed_size: if raw_world.size() != uncompressed_size:
return _failure(&"DECOMPRESSION_FAILED", world_path) 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 ## Resolves a normal-save promotion interrupted after the old formal package
## moved to .bak. Only checksum/schema-verified packages may become formal. ## moved to .bak. Only checksum/schema-verified packages may become formal.
## The marker is durable, but verified .tmp/.bak artifacts also recover saves ## 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 var marker_path := target_path + SAVE_TRANSACTION_SUFFIX
if not _operation_paths_are_safe([target_path, temporary_path, backup_path, marker_path]): 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") 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) 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): if marker_exists and not _formal_transaction_marker_is_valid(marker_path):
return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path) return _failure(&"TRANSACTION_MARKER_CONFLICT", marker_path)
+20 -3
View File
@@ -15,16 +15,24 @@ static func capture(clock: SimulationKernel, state: Node, extra := {}) -> Dictio
static func validate(snapshot: Dictionary) -> Dictionary: static func validate(snapshot: Dictionary) -> Dictionary:
var errors: Array[String] = [] 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") errors.append("unsupported_schema_version")
if not snapshot.get(&"clock", null) is Dictionary: if not snapshot.get(&"clock", null) is Dictionary:
errors.append("missing_clock") errors.append("missing_clock")
else: else:
var clock_data: Dictionary = snapshot[&"clock"] 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") 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") 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: if not snapshot.get(&"game_state", null) is Dictionary:
errors.append("missing_game_state") errors.append("missing_game_state")
return { return {
@@ -32,6 +40,15 @@ static func validate(snapshot: Dictionary) -> Dictionary:
&"errors": errors, &"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: static func restore(snapshot: Dictionary, clock: SimulationKernel, state: Node) -> bool:
var validation := validate(snapshot) var validation := validate(snapshot)
if not validation[&"ok"]: if not validation[&"ok"]:
+4 -2
View File
@@ -1542,6 +1542,8 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
var state_index := RaidState.keys().find(String(_value(raw, &"state", &""))) var state_index := RaidState.keys().find(String(_value(raw, &"state", &"")))
if raid_id.is_empty() or state_index < 0: if raid_id.is_empty() or state_index < 0:
return {&"ok": false, &"error": "Raid snapshot ID or state is invalid."} 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 := { var raid := {
&"raid_id": raid_id, &"raid_id": raid_id,
&"source_id": StringName(_value(raw, &"source_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)), &"multiple_entry_penalty": int(_value(raw, &"multiple_entry_penalty", -1)),
&"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])), &"entry_points": _sorted_unique_names(_value(raw, &"entry_points", [])),
&"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])), &"objective_ids": _sorted_unique_names(_value(raw, &"objective_ids", [])),
&"waves": [], &"waves": parsed_waves,
&"combatants": {}, &"combatants": {},
&"assets": {}, &"assets": {},
&"queued_skills": {}, &"queued_skills": {},
@@ -1588,7 +1590,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary:
wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", [])) wave[&"objective_ids"] = _sorted_unique_names(_value(wave, &"objective_ids", []))
wave[&"state"] = StringName(_value(wave, &"state", &"")) wave[&"state"] = StringName(_value(wave, &"state", &""))
wave[&"result_reason"] = StringName(_value(wave, &"result_reason", &"")) 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", []): for raw_combatant: Variant in _value(raw, &"combatants", []):
if not raw_combatant is Dictionary: if not raw_combatant is Dictionary:
return {&"ok": false, &"error": "Malformed defense combatant snapshot."} return {&"ok": false, &"error": "Malformed defense combatant snapshot."}
@@ -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
@@ -0,0 +1 @@
uid://dxvu3dy4bl6sj
+131 -7
View File
@@ -41,28 +41,149 @@ func purchase(runtime: Variant, command: SimulationCommand) -> CommandResult:
var warehouse_id := StringName(payload[&"warehouse_id"]) var warehouse_id := StringName(payload[&"warehouse_id"])
var item_id := StringName(payload[&"item_id"]) var item_id := StringName(payload[&"item_id"])
var quantity := int(payload[&"quantity"]) 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) var quote := preview_purchase(runtime, warehouse_id, item_id, quantity)
if not bool(quote[&"ok"]): 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 facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id)
var item := SupplyCatalog.get_item(item_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 inventory_before: Dictionary = runtime.inventory_service.create_snapshot()
var economy_before: Dictionary = runtime.economy_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"])) 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)): if not bool(stocked.get(&"ok", false)):
return _failure(command, &"INVENTORY_ERROR") runtime.inventory_service.restore_snapshot(inventory_before)
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.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(): if not paid.is_success():
runtime.inventory_service.restore_snapshot(inventory_before) runtime.inventory_service.restore_snapshot(inventory_before)
runtime.economy_service.restore_snapshot(economy_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"])): if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])):
runtime.inventory_service.restore_snapshot(inventory_before) runtime.inventory_service.restore_snapshot(inventory_before)
runtime.economy_service.restore_snapshot(economy_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"]}) 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: 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) var quote := preview_purchase(runtime, facility.instance_id, SupplyCatalog.RAW_FOOD, 1)
row[&"can_receive"] = bool(quote[&"ok"]) row[&"can_receive"] = bool(quote[&"ok"])
row[&"reason_codes"] = [] if bool(quote[&"ok"]) else [quote[&"reason"]] 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) warehouses.append(row)
&"kitchen": &"kitchen":
row.merge(runtime.kitchen_production.get_report(facility.instance_id), true) row.merge(runtime.kitchen_production.get_report(facility.instance_id), true)
+99 -5
View File
@@ -11,6 +11,7 @@ signal action_requested(action: StringName)
const KEY_PREFIX := "ui.supply." const KEY_PREFIX := "ui.supply."
const PURCHASE: StringName = &"purchase_supplies" const PURCHASE: StringName = &"purchase_supplies"
const PRODUCTION: StringName = &"set_kitchen_production" const PRODUCTION: StringName = &"set_kitchen_production"
const AUTO_PURCHASE: StringName = &"set_supply_auto_purchase"
const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"} const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"}
const STATE_NAMES := { const STATE_NAMES := {
&"SUSPENDED": "破壊・摘発による休止", &"SUSPENDED": "破壊・摘発による休止",
@@ -56,6 +57,8 @@ const REASONS := {
&"NO_POWER": "施設へ魔力が届いていません。", &"CLOSED": "施設が閉鎖されています。", &"NO_POWER": "施設へ魔力が届いていません。", &"CLOSED": "施設が閉鎖されています。",
&"NOT_KITCHEN": "厨房を選んでください。", &"INVALID_TARGET": "目標在庫の上限を確認してください。", &"NOT_KITCHEN": "厨房を選んでください。", &"INVALID_TARGET": "目標在庫の上限を確認してください。",
&"BANKRUPT": "運営終了後は変更できません。", &"UNAVAILABLE": "現在は操作できません。", &"BANKRUPT": "運営終了後は変更できません。", &"UNAVAILABLE": "現在は操作できません。",
&"OPERATIONS_BLOCKED": "運営の停止理由を確認し、再開してください。",
&"REPLAY_MISMATCH": "仕入れ履歴と現在の設定を確認してください。",
} }
var summary_label: Label var summary_label: Label
@@ -65,6 +68,10 @@ var quantity_spin: SpinBox
var purchase_quote_label: Label var purchase_quote_label: Label
var purchase_button: Button var purchase_button: Button
var warehouse_stock_label: Label 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 kitchen_option: OptionButton
var production_enabled: CheckButton var production_enabled: CheckButton
var target_spin: SpinBox var target_spin: SpinBox
@@ -84,6 +91,7 @@ var _report: Dictionary = {}
var _quote: Dictionary = {} var _quote: Dictionary = {}
var _pages: Dictionary = {} var _pages: Dictionary = {}
var _kitchen_drafts: Dictionary = {} var _kitchen_drafts: Dictionary = {}
var _auto_purchase_drafts: Dictionary = {}
var _pending_commands: Dictionary = {} var _pending_commands: Dictionary = {}
var _queue_instance_id := 0 var _queue_instance_id := 0
var _sequence := 1 var _sequence := 1
@@ -152,6 +160,23 @@ func _build_interface() -> void:
purchase_page.add_child(purchase_quote_label) purchase_page.add_child(purchase_quote_label)
purchase_button = _button("購入", "PurchaseSuppliesButton", submit_purchase) purchase_button = _button("購入", "PurchaseSuppliesButton", submit_purchase)
purchase_page.add_child(purchase_button) 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"] var kitchen_page: VBoxContainer = _pages[&"kitchen"]
kitchen_page.add_child(_label("設定する厨房")) kitchen_page.add_child(_label("設定する厨房"))
kitchen_option = _option("SupplyKitchenOption") kitchen_option = _option("SupplyKitchenOption")
@@ -211,6 +236,7 @@ func refresh() -> void:
if queue != null and queue.get_instance_id() != _queue_instance_id: if queue != null and queue.get_instance_id() != _queue_instance_id:
_queue_instance_id = queue.get_instance_id() _queue_instance_id = queue.get_instance_id()
_kitchen_drafts.clear() _kitchen_drafts.clear()
_auto_purchase_drafts.clear()
for picker: OptionButton in [warehouse_option, item_option, kitchen_option]: for picker: OptionButton in [warehouse_option, item_option, kitchen_option]:
picker.get_popup().hide() picker.get_popup().hide()
_synchronize_commands(queue) _synchronize_commands(queue)
@@ -246,11 +272,13 @@ func _refresh_purchase() -> void:
# can see why it cannot be bought instead of silently reducing its quantity. # 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)))) quantity_spin.max_value = maxi(int(quantity_spin.value), maxi(1, int(_quote.get(&"max_quantity", 1))))
var pending := _has_pending(PURCHASE) 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() quantity_spin.editable = not pending and not catalog.is_empty() and not warehouse.is_empty()
warehouse_option.disabled = pending or _rows(&"warehouses").is_empty() warehouse_option.disabled = pending or setting_pending or _rows(&"warehouses").is_empty()
item_option.disabled = pending or _rows(&"catalog").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.disabled = pending or not bool(_quote.get(&"ok", false))
purchase_button.text = "購入を受理済み…" if pending else "購入" purchase_button.text = "購入を受理済み…" if pending else "購入"
_refresh_auto_purchase(warehouse)
if catalog.is_empty() or warehouse.is_empty(): if catalog.is_empty() or warehouse.is_empty():
purchase_quote_label.text = "受入倉庫と品目を選んでください。" purchase_quote_label.text = "受入倉庫と品目を選んでください。"
return return
@@ -259,6 +287,66 @@ func _refresh_purchase() -> void:
purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE")) 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: func _refresh_kitchen() -> void:
selected_kitchen_id = _fill_option(kitchen_option, _rows(&"kitchens"), &"facility_id", selected_kitchen_id, "完成した厨房がありません") selected_kitchen_id = _fill_option(kitchen_option, _rows(&"kitchens"), &"facility_id", selected_kitchen_id, "完成した厨房がありません")
var row := _row(&"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) _set_result(_reason_text(details.get(&"reason", &"UNAVAILABLE")), false)
return return
var payload: Dictionary = context[&"payload"] 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", &"")) var id := StringName(payload.get(&"facility_id", &""))
_kitchen_drafts.erase(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) _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) _sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1)
for command: Dictionary in queue.create_snapshot().get("pending", []): for command: Dictionary in queue.create_snapshot().get("pending", []):
var type := StringName(command.get("command_type", &"")) 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)} _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: func _has_pending(type: StringName, facility_id: StringName = &"") -> bool:
for context: Dictionary in _pending_commands.values(): 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 true
return false return false
+6
View File
@@ -1306,6 +1306,12 @@ func find_nearest_walkable_recovery(
func create_snapshot() -> Dictionary: 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() var connector_ids: Array = _connectors.keys()
connector_ids.sort_custom(Callable(self, "_string_name_less")) connector_ids.sort_custom(Callable(self, "_string_name_less"))
var saved_connectors: Array = [] var saved_connectors: Array = []
@@ -51,6 +51,7 @@ func run() -> void:
expect(not runtime.restore_snapshot(bad) and runtime.create_snapshot()==intact, "missing live facility cleanliness rejects") expect(not runtime.restore_snapshot(bad) and runtime.create_snapshot()==intact, "missing live facility cleanliness rejects")
var legacy: Dictionary=intact.duplicate(true) var legacy: Dictionary=intact.duplicate(true)
legacy[&"systems"][&"runtime"][&"runtime_schema_version"]=11 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") 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.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") expect(runtime.create_snapshot()[&"systems"][&"runtime"][&"weather"]==intact[&"systems"][&"runtime"][&"weather"], "v11 weather survives migration unchanged")
@@ -16,6 +16,7 @@ func run() -> void:
var original: Dictionary = runtime.create_snapshot() var original: Dictionary = runtime.create_snapshot()
var legacy := original.duplicate(true) var legacy := original.duplicate(true)
legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 10 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(&"weather")
legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") 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") 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")
+8
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
uid://dqjyetek8aurm
+1
View File
@@ -2,6 +2,7 @@ extends RefCounted
## Build a genuine pre-v10 fixture only when no upkeep ledger history exists. ## Build a genuine pre-v10 fixture only when no upkeep ledger history exists.
static func strip_empty_upkeep(data: Dictionary) -> void: 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"]: for entry: Dictionary in data[&"economy"][&"entries"]:
assert(String(entry.get(&"category", "")) not in ["MAINTENANCE_ACCRUAL", "MAINTENANCE_PAYMENT"]) assert(String(entry.get(&"category", "")) not in ["MAINTENANCE_ACCRUAL", "MAINTENANCE_PAYMENT"])
data.erase(&"weather") data.erase(&"weather")
@@ -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(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") _expect(_runtime.get_mvp_guest_definition_ids().size() == 32, "v3 migration registers the current 32 catalog definitions")
var migrated: Dictionary = _runtime.create_snapshot() 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) var digest := SimulationDigest.compute(migrated)
_expect(_runtime.restore_snapshot(migrated), "canonical v9 snapshot restores") _expect(_runtime.restore_snapshot(migrated), "canonical v9 snapshot restores")
_expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v9 restore is exact") _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v9 restore is exact")
@@ -1,7 +1,7 @@
extends SceneTree extends SceneTree
const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY
const RUNTIME_SNAPSHOT_VERSION := 13 const RUNTIME_SNAPSHOT_VERSION := 14
var _checks := 0 var _checks := 0
var _failures := 0 var _failures := 0
@@ -39,7 +39,7 @@ func _new_fixture(seed: int, enable_operations := true) -> Dictionary:
int(initial_snapshot[&"systems"][&"runtime"].get( int(initial_snapshot[&"systems"][&"runtime"].get(
&"runtime_schema_version", -1 &"runtime_schema_version", -1
)) == RUNTIME_SNAPSHOT_VERSION, )) == RUNTIME_SNAPSHOT_VERSION,
"fixture emits Runtime snapshot schema v10" "fixture emits Runtime snapshot schema v14"
) )
if enable_operations: if enable_operations:
var enabled: Dictionary = runtime.enable_production_operations() var enabled: Dictionary = runtime.enable_production_operations()
@@ -58,7 +58,7 @@ func _test_disabled_compatibility_and_enable() -> void:
_expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests") _expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests")
var disabled_snapshot: Dictionary = _runtime.create_snapshot() var disabled_snapshot: Dictionary = _runtime.create_snapshot()
var disabled_runtime: Dictionary = disabled_snapshot[&"systems"][&"runtime"] 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( _expect(
disabled_runtime[&"operations"] == { disabled_runtime[&"operations"] == {
&"wrapper_version": 1, &"wrapper_version": 1,
+4 -2
View File
@@ -25,9 +25,11 @@ func _run() -> void:
func _test_value_types() -> 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 = [ var values: Array = [
null, false, true, -9223372036854775807, 9223372036854775807, 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), 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), 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), 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. # keep their type tags apart even when the textual schemas are identical.
for index in 60: for index in 60:
var row := {} var row := {}
var keys: Array = ["z", "aa", "日本語", "a", "\u0000", "", "001"] var keys: Array = ["z", "aa", "日本語", "a", String.chr(0xfffd), "", "001"]
if index % 2 == 1: if index % 2 == 1:
keys.reverse() keys.reverse()
for key: String in keys: for key: String in keys:
@@ -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)
@@ -0,0 +1 @@
uid://cqhxb665hdby6
@@ -1,6 +1,6 @@
extends SceneTree extends SceneTree
const RUNTIME_SNAPSHOT_VERSION := 13 const RUNTIME_SNAPSHOT_VERSION := 14
const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [
&"excavation", &"excavation",
&"spatial_subslots", &"spatial_subslots",
@@ -186,7 +186,7 @@ func _test_runtime_surface(fixture: Dictionary) -> bool:
var snapshot: Dictionary = runtime.create_snapshot() var snapshot: Dictionary = runtime.create_snapshot()
var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"]
var current_version := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION 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 ready = ready and current_version
for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS:
var valid_dictionary := runtime_data.get(key, null) is Dictionary 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") _expect(first_runtime.restore_snapshot(legacy), "v5 proxy snapshot migrates through Runtime restore")
var first_migrated: Dictionary = first_runtime.create_snapshot() var first_migrated: Dictionary = first_runtime.create_snapshot()
_expect( _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" "v5 migration is immediately re-emitted as v9"
) )
_expect( _expect(
+2 -2
View File
@@ -318,7 +318,7 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void:
_clock.advance_one_minute() _clock.advance_one_minute()
var snapshot: Dictionary = _runtime.create_snapshot() var snapshot: Dictionary = _runtime.create_snapshot()
var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] 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") _expect(not runtime_data[&"movement_credits"].is_empty(), "mid-move snapshot persists fixed-point movement credits")
var digest := SimulationDigest.compute(snapshot) var digest := SimulationDigest.compute(snapshot)
_expect(_runtime.restore_snapshot(snapshot), "valid mid-move v9 snapshot restores") _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") _expect(_runtime.restore_snapshot(legacy_v2), "legacy Runtime v2 deterministically rebuilds Path actors and credits")
var migrated: Dictionary = _runtime.create_snapshot() var migrated: Dictionary = _runtime.create_snapshot()
_expect( _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"][&"path"][&"actor_cells"].size() == 2
and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2 and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2
and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]), and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]),
+3 -2
View File
@@ -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") _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() var legacy: Dictionary = _runtime.create_snapshot()
legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 12 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") (legacy[&"systems"][&"runtime"][&"defense"][&"raids"] as Array)[0].erase(&"event_source")
var clean: Dictionary = legacy[&"systems"][&"runtime"][&"facility_cleanliness"].duplicate(true) 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(_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") _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() 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) var missing_clean: Dictionary = legacy.duplicate(true)
missing_clean[&"systems"][&"runtime"].erase(&"facility_cleanliness") 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") _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") _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 future: Dictionary = _runtime.create_snapshot()
var current: Dictionary = future.duplicate(true) 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") _expect(not _runtime.restore_snapshot(future) and _runtime.create_snapshot() == current, "unknown future runtime schema remains rejected")
@@ -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)
@@ -0,0 +1 @@
uid://dl6brsqwn5lr8
+222
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
uid://bn6w8j53gr8ql
+145
View File
@@ -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)
@@ -0,0 +1 @@
uid://bn6i1dsht6xtp
@@ -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)
@@ -0,0 +1 @@
uid://b35pphufmkii8
+365
View File
@@ -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)
@@ -0,0 +1 @@
uid://camsaleipkl22
@@ -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))
@@ -0,0 +1 @@
uid://pyqycsjyyeuo
@@ -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
@@ -0,0 +1 @@
uid://cqccwfjhooq1t
+2 -2
View File
@@ -19,7 +19,7 @@ func _run() -> void:
source[&"clock"].advance_minutes(3) source[&"clock"].advance_minutes(3)
var canonical: Dictionary = source_runtime.create_snapshot() var canonical: Dictionary = source_runtime.create_snapshot()
var canonical_data: Dictionary = canonical[&"systems"][&"runtime"] 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") _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 := canonical.duplicate(true)
var legacy_data: Dictionary = legacy[&"systems"][&"runtime"] 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(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") _expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v9")
var migrated: Dictionary = first_runtime.create_snapshot() 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"] 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[&"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") _expect(kitchen[&"current_minute"] == source[&"clock"].absolute_minute, "legacy production clock binds to the restored Runtime minute")
+1 -1
View File
@@ -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.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") _expect(bool(_runtime.load_from_path(save_path).get(&"ok", false)), "boarded disk slot reloads")
var snapshot: Dictionary = _runtime.create_snapshot() 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") _expect(_runtime.restore_snapshot(snapshot), "mid-ride Runtime snapshot restores actor/cabin/Path ownership")
var shaft: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] var shaft: Dictionary = _runtime.elevators.get_report()[&"shafts"][0]
var busy := _cmd(&"demolish_facility", {&"instance_id": shaft[&"module_ids"][1]}) var busy := _cmd(&"demolish_facility", {&"instance_id": shaft[&"module_ids"][1]})
@@ -68,7 +68,7 @@ func _run() -> void:
for guest: GuestRuntime in _runtime.guest_service.get_guests(): 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") _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() 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") _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 = canonical.duplicate(true)
forged[&"systems"][&"runtime"][&"path"][&"transit_actor_ids"].pop_back() forged[&"systems"][&"runtime"][&"path"][&"transit_actor_ids"].pop_back()
+1 -1
View File
@@ -15,7 +15,7 @@ func _run() -> void:
var clock: Variant = fixture[&"clock"] var clock: Variant = fixture[&"clock"]
clock.advance_minutes(3) clock.advance_minutes(3)
var current: Dictionary = runtime.create_snapshot() 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 legacy := current.duplicate(true)
var data: Dictionary = legacy[&"systems"][&"runtime"] var data: Dictionary = legacy[&"systems"][&"runtime"]
data[&"runtime_schema_version"] = 7 data[&"runtime_schema_version"] = 7
+156
View File
@@ -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")
@@ -0,0 +1 @@
uid://ddpkpqypwj4f0