diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index b2eca36..b158a27 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -7573,18 +7573,38 @@ func _execute_leave_facility_queue(command: SimulationCommand) -> CommandResult: return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, command.command_id, failure_message) return CommandResult.succeeded(command.command_id, {&"request_id": request_id, &"reason": &"GUEST_LEFT"}) -func _execute_build_facility(command: SimulationCommand) -> CommandResult: - var operations_gate := _operations_construction_investment_gate(command.command_id) +func preview_build_facility(definition_id: StringName, origin: Vector3i, rotation: int) -> Dictionary: + var definition := definition_registry.get_facility(definition_id) + var failure := _build_facility_precheck(0, {&"definition_id": definition_id, &"origin": origin, &"rotation": rotation}) + if failure != null and definition == null: + var code := String(failure.details.get(&"reason", CommandResult.Code.keys()[failure.code])) + return {&"can_build": false, &"error_codes": [code], + &"reasons": [{&"code": code, &"message": failure.message, &"cells": [], &"details": failure.details.duplicate(true)}], + &"cells": []} + var report: Dictionary = construction_service.validate_placement(definition, origin, rotation).to_dictionary() + if failure != null: + report[&"can_build"] = false + var code := String(failure.details.get(&"reason", CommandResult.Code.keys()[failure.code])) + var codes: Array = Array(report.get(&"error_codes", [])) + var reasons: Array = Array(report.get(&"reasons", [])) + codes.push_front(code) + reasons.push_front({&"code": code, &"message": failure.message, &"cells": [], &"details": failure.details.duplicate(true)}) + report[&"error_codes"] = codes + report[&"reasons"] = reasons + return report + +func _build_facility_precheck(command_id: int, payload: Dictionary) -> CommandResult: + var operations_gate := _operations_construction_investment_gate(command_id) if operations_gate != null: return operations_gate - var definition_id := StringName(command.payload.get(&"definition_id", command.payload.get("definition_id", ""))) + var definition_id := StringName(payload.get(&"definition_id", payload.get("definition_id", ""))) var definition := definition_registry.get_facility(definition_id) if definition == null: - return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Facility definition was not found.") + return CommandResult.failed(CommandResult.Code.NOT_FOUND, command_id, "Facility definition was not found.") if _facility_definition_is_unsupported_vertical_proxy(definition): return CommandResult.failed( CommandResult.Code.VALIDATION_FAILED, - command.command_id, + command_id, "Vertical infrastructure topology is not supported.", { &"reason": FacilityInfrastructureProjector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY, @@ -7592,18 +7612,28 @@ func _execute_build_facility(command: SimulationCommand) -> CommandResult: &"unchanged": true, } ) - var origin_value: Variant = _parse_world_cell(command.payload.get(&"origin", command.payload.get("origin", null))) + var origin_value: Variant = _parse_world_cell(payload.get(&"origin", payload.get("origin", null))) if origin_value == null: - return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "origin must be Vector3i or [x, y, layer].") + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command_id, "origin must be Vector3i or [x, y, layer].") var origin: Vector3i = origin_value if definition.id in [&"guest_stairs", &"service_stairs"] and not path_service.has_cell(origin + Vector3i(0, -1, 0)): - return _staff_assignment_failure(command.command_id, &"STAIR_LANDING_MISSING", "先に階段の一つ下の階に通路を作ってください。") + return _staff_assignment_failure(command_id, &"STAIR_LANDING_MISSING", "先に階段の一つ下の階に通路を作ってください。") for shaft: Dictionary in elevators.get_report()[&"shafts"]: if _elevator_touches_module(shaft, definition, origin) and _elevator_shaft_busy(shaft): - return _staff_assignment_failure(command.command_id, &"ELEVATOR_BUSY", "Wait until the shaft is idle before extending it.") - var rotation := int(command.payload.get(&"rotation", command.payload.get("rotation", 0))) + return _staff_assignment_failure(command_id, &"ELEVATOR_BUSY", "Wait until the shaft is idle before extending it.") + var rotation := int(payload.get(&"rotation", payload.get("rotation", 0))) if rotation not in definition.allowed_rotations: - return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "The selected rotation is not allowed.") + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command_id, "The selected rotation is not allowed.") + return null + +func _execute_build_facility(command: SimulationCommand) -> CommandResult: + var failure := _build_facility_precheck(command.command_id, command.payload) + if failure != null: + return failure + var definition_id := StringName(command.payload.get(&"definition_id", command.payload.get("definition_id", ""))) + var definition := definition_registry.get_facility(definition_id) + var origin: Vector3i = _parse_world_cell(command.payload.get(&"origin", command.payload.get("origin", null))) + var rotation := int(command.payload.get(&"rotation", command.payload.get("rotation", 0))) var result := construction_service.start_construction( definition, origin, diff --git a/tests/transport/test_runtime_build_preview.gd b/tests/transport/test_runtime_build_preview.gd new file mode 100644 index 0000000..259afb2 --- /dev/null +++ b/tests/transport/test_runtime_build_preview.gd @@ -0,0 +1,147 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _serial := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + var base_script: Variant = load("res://autoload/simulation_runtime.gd") + if base_script == null or not base_script.can_instantiate(): + push_error("Runtime preview base script could not be loaded.") + quit(1) + return + # Seed diagnostic switches isolate precedence; the operations gate itself is real. + var diagnostic_script := GDScript.new() + diagnostic_script.source_code = """extends "res://autoload/simulation_runtime.gd" +var block_operations := false +var block_vertical := false +func _operations_total_arrears() -> int: + return 1 if block_operations else super._operations_total_arrears() +func _facility_definition_is_unsupported_vertical_proxy(definition: Variant) -> bool: + return block_vertical or super._facility_definition_is_unsupported_vertical_proxy(definition) +""" + var reload_error := diagnostic_script.reload() + if reload_error != OK: + push_error("Runtime preview diagnostic script reload failed: %s" % reload_error) + quit(1) + return + _runtime = diagnostic_script.new() + if _runtime == null: + push_error("Runtime preview diagnostic instance is null.") + quit(1) + return + for method: StringName in [&"initialize", &"create_snapshot", &"enable_production_operations"]: + if not _runtime.has_method(method): + push_error("Runtime preview diagnostic instance lacks method: %s" % method) + quit(1) + return + var infrastructure_projector: Variant = load("res://scripts/world/facility_infrastructure_projector.gd") + _reset() + var cell := Vector3i(-8, 2, 0) + _failure_case(&"unknown.preview.definition", cell, 0, "NOT_FOUND", "Facility definition was not found.") + check(_runtime.enable_production_operations().get(&"ok", false), "enable real operations gate") + _runtime.block_operations = true + _runtime.block_vertical = true + _failure_case(&"unknown.preview.definition", cell, 999, "UNPAID_EXPENSES", "Operations currently block construction and investment.") + _failure_case(&"guest_stairs", cell, 999, "UNPAID_EXPENSES", "Operations currently block construction and investment.") + _runtime.block_operations = false + _failure_case(&"guest_stairs", cell, 999, String(infrastructure_projector.REASON_UNSUPPORTED_VERTICAL_TOPOLOGY), "Vertical infrastructure topology is not supported.") + _reset() + _failure_case(&"guest_stairs", cell, 999, "STAIR_LANDING_MISSING", "先に階段の一つ下の階に通路を作ってください。") + _failure_case(&"guest_corridor", cell, 999, "INVALID_PAYLOAD", "The selected rotation is not allowed.") + # origin INVALID_PAYLOAD cannot be reached through preview's Vector3i API. + _reset() + var lower_stairs := _command(&"guest_stairs", Vector3i(4, 1, 0), 0) + check(lower_stairs.is_success(), "build real lower stairs: %s" % lower_stairs.message) + _clock.advance_minutes(_runtime.definition_registry.get_facility(&"guest_stairs").build_minutes) + var lower_exists := false + for facility: Dictionary in _runtime.construction_service.get_facilities(): + if facility[&"origin"] == Vector3i(4, 1, 0): + lower_exists = true + check(lower_exists, "real lower stairs construction completes") + var report := _pure_preview(&"guest_stairs", Vector3i(4, 2, 0), 0) + var accepted: Variant = _command(&"guest_stairs", Vector3i(4, 2, 0), 0) + if not (report[&"can_build"] and accepted.is_success()): + var first_reason: Dictionary = report[&"reasons"][0] if not report[&"reasons"].is_empty() else {} + var command_code := String(accepted.details.get(&"reason", CommandResult.Code.keys()[accepted.code])) + print("Runtime preview positive: can_build=%s error_codes=%s first_code=%s first_message=%s command_code=%s command_message=%s" % [report[&"can_build"], JSON.stringify(report[&"error_codes"]).left(256), JSON.stringify(first_reason.get(&"code", "none")).left(256), JSON.stringify(first_reason.get(&"message", "none")).left(512), JSON.stringify(command_code).left(256), JSON.stringify(accepted.message).left(512)]) + check(report[&"can_build"] and accepted.is_success(), "positive landing preview and command agree") + _reset() + check(_command(&"guest_elevator", Vector3i.ZERO, 0).is_success(), "build real elevator") + _clock.advance_minutes(310) + check(_runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "hire elevator actor") + var access: Array[StringName] = [&"staff"] + var queued: Dictionary = _runtime.elevators.request_trip(&"staff:staff_akane", Vector3i.ZERO, Vector3i(0, 1, 0), _clock.absolute_minute, access) + check(queued.get(&"ok", false), "queue real elevator trip") + _failure_case(&"guest_elevator", Vector3i(0, 2, 0), 999, "ELEVATOR_BUSY", "Wait until the shaft is idle before extending it.") + _runtime.free() + _clock.free() + _state.free() + print("Runtime build preview: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) + +func _reset() -> void: + _runtime.block_operations = false + _runtime.block_vertical = false + _clock.reset(1, 6, 0) + _state.reset() + check(_runtime.initialize(_clock, _state, 90416)[&"valid"], "initialize preview fixture") + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _clock.set_paused(true) + +func _pure_preview(id: StringName, cell: Vector3i, rotation: int) -> Dictionary: + var before := var_to_bytes(_runtime.create_snapshot()) + # CommandQueue contains the accepted command log and processed receipts. + var log_before := var_to_bytes(_runtime.command_queue.create_snapshot()) + var commands_before: int = _runtime.command_queue.get_accepted_command_metadata().size() + var processed_before: int = _runtime.command_queue.processed_count() + var events_before: int = _runtime.domain_events.recent_events().size() + var report: Dictionary = _runtime.preview_build_facility(id, cell, rotation) + var after := var_to_bytes(_runtime.create_snapshot()) + var log_after := var_to_bytes(_runtime.command_queue.create_snapshot()) + var commands_after: int = _runtime.command_queue.get_accepted_command_metadata().size() + var processed_after: int = _runtime.command_queue.processed_count() + var events_after: int = _runtime.domain_events.recent_events().size() + print("Runtime preview proof: case=%s cell=%s rotation=%d snapshot_before=%s snapshot_after=%s queue_before=%s queue_after=%s accepted_before=%d accepted_after=%d processed_before=%d processed_after=%d events_before=%d events_after=%d can_build=%s error_codes=%s" % [JSON.stringify(String(id)).left(256), cell, rotation, _sha256(before), _sha256(after), _sha256(log_before), _sha256(log_after), commands_before, commands_after, processed_before, processed_after, events_before, events_after, report[&"can_build"], JSON.stringify(report[&"error_codes"]).left(256)]) + check(before == after, "preview preserves typed snapshot bytes") + check(log_before == log_after, "preview preserves command log bytes") + check(commands_before == commands_after and processed_before == processed_after, "preview preserves command counts") + check(events_before == events_after, "preview preserves event count") + return report + +func _sha256(bytes: PackedByteArray) -> String: + var context := HashingContext.new() + context.start(HashingContext.HASH_SHA256) + context.update(bytes) + return context.finish().hex_encode() + +func _failure_case(id: StringName, cell: Vector3i, rotation: int, code: String, message: String) -> void: + var report := _pure_preview(id, cell, rotation) + check(not report[&"can_build"] and report[&"error_codes"][0] == code, "preview first code: " + code) + check(report[&"reasons"][0][&"code"] == code and report[&"reasons"][0][&"message"] == message, "preview real message: " + code) + var refused := _command(id, cell, rotation) + var command_code := String(refused.details.get(&"reason", CommandResult.Code.keys()[refused.code])) + check(not refused.is_success() and command_code == code, "submit code agrees: " + code) + check(refused.message == message and refused.message == report[&"reasons"][0][&"message"], "submit message agrees: " + code) + check(refused.details == report[&"reasons"][0][&"details"], "submit details agree: " + code) + if _runtime.definition_registry.get_facility(id) == null: + check(report.size() == 4 and report[&"cells"].is_empty() and report[&"reasons"].size() == 1 and report[&"error_codes"].size() == 1, "unknown definition returns only precheck summary") + +func _command(id: StringName, cell: Vector3i, rotation: int) -> CommandResult: + _serial += 1 + return _runtime.submit_command(&"build_facility", {&"definition_id": id, &"origin": cell, &"rotation": rotation}, &"manager", &"", 0, -1, StringName("runtime.preview.%d" % _serial)) + +func check(ok: bool, message: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(message) + quit(1) diff --git a/tests/transport/test_runtime_build_preview.gd.uid b/tests/transport/test_runtime_build_preview.gd.uid new file mode 100644 index 0000000..bf5d81c --- /dev/null +++ b/tests/transport/test_runtime_build_preview.gd.uid @@ -0,0 +1 @@ +uid://dqx7m2k8n4vsa