diff --git a/Makefile b/Makefile index b86b505..0c88048 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,10 @@ test-s0: $(GODOT) --headless --path . --script res://tests/s0/test_runtime_integration.gd test-s1: + $(GODOT) --headless --path . --script res://tests/s1/test_dimension_batch_save.gd + $(GODOT) --headless --path . --script res://tests/s1/test_facility_resize_preview.gd + $(GODOT) --headless --path . --script res://tests/s1/test_weighted_resize_runtime.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_path_service.gd $(GODOT) --headless --path . --script res://tests/s1/test_path_congestion.gd @@ -46,7 +50,14 @@ test-s1: $(GODOT) --headless --path . --script res://tests/s1/test_runtime_world_construction.gd test-s2: + $(GODOT) --headless --path . --script tests/environment/test_cleaning_access.gd + $(GODOT) --headless --path . --script tests/ui/test_cleaning_gameplay.gd + $(GODOT) --headless --path . --script tests/environment/test_runtime_cleanliness.gd + $(GODOT) --headless --path . --script res://tests/environment/test_facility_cleanliness.gd + $(GODOT) --headless --path . --script res://tests/s2/test_facility_upkeep_accrual.gd + $(GODOT) --headless --path . --script res://tests/s2/test_facility_upkeep_settlement.gd $(GODOT) --headless --path . --script res://tests/s2/test_facility_service.gd + $(GODOT) --headless --path . --script res://tests/s2/test_service_start_slots.gd $(GODOT) --headless --path . --script res://tests/s2/test_inventory_logistics.gd $(GODOT) --headless --path . --script res://tests/s2/test_economy_service.gd $(GODOT) --headless --path . --script res://tests/s2/test_ledger_backed_resources.gd @@ -79,6 +90,13 @@ test-s6: $(GODOT) --headless --path . --script res://tests/s6/test_runtime_save_lifecycle.gd test-mvp: + $(GODOT) --headless --path . --script res://tests/mvp/test_premium_room_guest_flow.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_premium_room.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_original_room_zones.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_upkeep_loss_streak.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_upkeep_legacy_midnight.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_upkeep.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_upkeep_construction_boundaries.gd $(GODOT) --headless --path . --script res://tests/mvp/test_operations_coordinator.gd $(GODOT) --headless --path . --script res://tests/mvp/test_mvp_content_catalog.gd $(GODOT) --headless --path . --script res://tests/mvp/test_progression_service.gd @@ -89,6 +107,26 @@ test-acceptance: $(GODOT) --headless --path . --script res://tests/mvp/test_runtime_operations_acceptance.gd test-ui: + $(GODOT) --headless --path . --script res://tests/mvp/test_chapel_boundaries.gd + $(GODOT) --headless --path . --script res://tests/ui/test_chapel_gameplay.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_chapel_service.gd + $(GODOT) --headless --path . --script res://tests/ui/test_dashboard_coalescing.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_anri_floor_pricing.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_fine_cleaning_effect.gd + $(GODOT) --headless --path . --script res://tests/mvp/test_amelia_donations.gd + $(GODOT) --headless --path . --script res://tests/ui/test_anri_recruitment.gd + $(GODOT) --headless --path . --script res://tests/ui/test_layer_skin.gd + $(GODOT) --headless --path . --script res://tests/ui/test_world_tree_rank.gd + $(GODOT) --headless --path . --script res://tests/ui/test_rank_pointer.gd + $(GODOT) --headless --path . --script res://tests/ui/test_layer_presentation.gd + $(GODOT) --headless --path . --script res://tests/ui/test_original_build_pointer.gd + $(GODOT) --headless --path . --script res://tests/ui/test_dimension_migration_ui.gd + python3 tools/run_upkeep_ui_flow.py --godot "$(GODOT)" + $(GODOT) --headless --path . --script res://tests/ui/test_upkeep_inspector.gd + $(GODOT) --headless --path . --script res://tests/ui/test_staff_rest.gd + $(GODOT) --headless --path . --script res://tests/ui/test_staff_rescue.gd + $(GODOT) --headless --path . --script res://tests/ui/test_guest_observation.gd + $(GODOT) --headless --path . --script res://tests/ui/test_guest_decision_log.gd $(MAKE) test-pixel-ui $(GODOT) --headless --path . --script res://tests/ui/test_main_dashboard.gd $(GODOT) --headless --path . --script res://tests/ui/test_staff_management.gd @@ -98,6 +136,12 @@ test-ui: $(MAKE) test-opening test-perf: + $(GODOT) --headless --path . --script res://tests/performance/test_facility_reachability.gd + $(GODOT) --headless --path . --script res://tests/performance/test_snapshot_ownership.gd + $(GODOT) --headless --path . --script res://tests/performance/test_world_snapshot_cache.gd + $(GODOT) --headless --path . --script res://tests/performance/test_lazy_digest.gd + $(GODOT) --headless --path . --script res://tests/performance/test_path_membership.gd + $(GODOT) --headless --path . --script res://tests/performance/test_system_optimization.gd $(GODOT) --headless --path . --script res://tests/performance/test_digest_performance.gd $(GODOT) --headless --path . --script res://tests/performance/test_perf_mvp_001.gd @@ -122,6 +166,7 @@ capture-pixel: $(GODOT) --path . --script res://tests/ui/capture_pixel_tower.gd test-pixel-ui: + $(GODOT) --headless --path . --script res://tests/ui/test_room_presentation.gd $(GODOT) --headless --path . --script res://tests/ui/test_pixel_tower.gd .PHONY: test-opening capture-opening @@ -161,6 +206,14 @@ capture-business: .PHONY: test-transport capture-transport preview-transport test-transport: + $(GODOT) --headless --path . --script res://tests/staff/test_dormitory_dimension_migration.gd + $(GODOT) --headless --path . --script res://tests/staff/test_staff_dormitory.gd + $(GODOT) --headless --path . --script res://tests/staff/test_dormitory_shift_cycle.gd + $(GODOT) --headless --path . --script res://tests/staff/test_shift_rush.gd + $(GODOT) --headless --path . --script res://tests/staff/test_staff_rest.gd + $(GODOT) --headless --path . --script res://tests/staff/test_rescue_elevator.gd + $(GODOT) --headless --path . --script res://tests/staff/test_staff_rescue.gd + $(GODOT) --headless --path . --script res://tests/staff/test_task_work_clock.gd $(MAKE) test-stairs $(GODOT) --headless --path . --script res://tests/world/test_elevator_topology.gd $(GODOT) --headless --path . --script res://tests/transport/test_elevator_service.gd @@ -201,3 +254,8 @@ export-macos: .PHONY: test-exported-opening test-exported-opening: python3 tools/verify_exported_opening.py "$(WINDOWS_PCK)" --godot "$(GODOT)" + +# Exhaustive discovery includes comparison suites outside the ordinary smoke run. +.PHONY: test-all +test-all: + python3 tools/run_all_tests.py --godot "$(GODOT)" diff --git a/README.md b/README.md index 3d743c8..6e75126 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Godotが開いたら、右上の実行ボタンまたは `F5` でプロジェクトを起動できます。エディタを開かず直接実行する場合は `make run` を使用します。 -通常の新規ゲームは一時停止から始まります。左上の開業ガイドに沿って受付・客室・通路を建て、「従業員」で雇用・配属・毎日の勤務帯を決めます。「帳簿」で翌朝06:00の決算を確認できます。具体的な最小宿の建て方は [試遊ガイド](docs/PLAYTEST_GUIDE.md#通常uiで最初の宿を開業する) を参照してください。 +通常の新規ゲームは一時停止から始まります。左上の開業ガイドに沿って受付・客室・通路を建て、「従業員」で雇用・配属・毎日の勤務帯を決めます。「帳簿」で毎日00:00の決算を確認できます。具体的な最小宿の建て方は [試遊ガイド](docs/PLAYTEST_GUIDE.md#通常uiで最初の宿を開業する) を参照してください。 ## ドット絵の世界樹を見る diff --git a/TheTree-ドット版を起動.command b/TheTree-ドット版を起動.command new file mode 100755 index 0000000..da30046 --- /dev/null +++ b/TheTree-ドット版を起動.command @@ -0,0 +1,3 @@ +#!/bin/zsh +# 同伴救護・待機室休息を反映した検証済みドット版 +open -n '/Users/korizatou114/Projects/とりにく工房ゲーム制作部/build/macos/TheTree-Labor-2026-09-29.app' diff --git a/assets/shaders/layer_lens.gdshader b/assets/shaders/layer_lens.gdshader new file mode 100644 index 0000000..9804254 --- /dev/null +++ b/assets/shaders/layer_lens.gdshader @@ -0,0 +1,20 @@ +shader_type canvas_item; +render_mode unshaded; + +uniform sampler2D screen_texture : hint_screen_texture, repeat_disable, filter_nearest; +uniform vec2 extent = vec2(1.0); +uniform vec2 center = vec2(0.5); +uniform float progress : hint_range(0.0, 1.0) = 0.0; + +void fragment() { + vec2 delta = (UV - center) * extent; + float distance_px = length(delta); + float radius = progress * length(extent); + float band = 1.0 - smoothstep(0.0, 28.0, abs(distance_px - radius)); + float fade = 1.0 - progress; + vec2 direction = delta / max(distance_px, 1.0); + vec2 offset = direction * sin((distance_px - radius) * 0.18) * band * 4.0 * fade * SCREEN_PIXEL_SIZE; + vec4 scene = texture(screen_texture, SCREEN_UV + offset); + scene.rgb += vec3(0.18, 0.28, 0.22) * band * fade; + COLOR = vec4(scene.rgb, 1.0); +} diff --git a/assets/shaders/layer_lens.gdshader.uid b/assets/shaders/layer_lens.gdshader.uid new file mode 100644 index 0000000..198ff94 --- /dev/null +++ b/assets/shaders/layer_lens.gdshader.uid @@ -0,0 +1 @@ +uid://duedneo846yck diff --git a/assets/themes/japanese_fantasy/visual-theme-v1.tres b/assets/themes/japanese_fantasy/visual-theme-v1.tres index 9cb9ace..961ef88 100644 --- a/assets/themes/japanese_fantasy/visual-theme-v1.tres +++ b/assets/themes/japanese_fantasy/visual-theme-v1.tres @@ -220,13 +220,18 @@ back_world_background = ExtResource("3_back") world_art_opacity = 0.94 grid_overlay_opacity = 0.22 facility_icons = { +"cleaning_room": SubResource("F12"), +"chapel": SubResource("F12"), "reception": SubResource("F00"), "standard_room": SubResource("F01"), +"premium_room": SubResource("F01"), "cafeteria": SubResource("F02"), "warehouse": SubResource("F03"), "kitchen": SubResource("F10"), "public_bath": SubResource("F11"), "staff_waiting_room": SubResource("F12"), +"staff_dormitory": SubResource("F01"), +"infirmary": SubResource("F12"), "security_station": SubResource("F13"), "tavern": SubResource("F20"), "massage_room": SubResource("F21"), diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index dc7d6db..ac206e1 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -3,8 +3,12 @@ extends Node signal command_completed(result: CommandResult) signal snapshot_published(snapshot: Dictionary, digest: String) +# UI subscribers only need notification; exact snapshots remain available. +signal state_updated(snapshot: Dictionary) signal definition_validation_failed(report: Dictionary) signal save_operation_completed(result: Dictionary) +# Optional, read-only UI contribution collected at the actual checkpoint. +signal checkpoint_presentation_requested(presentation: Dictionary) const DEFAULT_WORLD_SEED := 20260827 const STARTER_MANA_OUTPUT := 100 @@ -15,7 +19,15 @@ const CONSTRUCTION_EXPENSE_ACCOUNT := &"CONSTRUCTION_EXPENSE" const OPERATING_EXPENSE_ACCOUNT := &"OPERATING_EXPENSE" const AUTHORITY_FINE_ACCOUNT := &"AUTHORITY_FINE_EXPENSE" const WAGE_EXPENSE_ACCOUNT := &"WAGE_EXPENSE" -const RUNTIME_SNAPSHOT_VERSION := 9 +const RUNTIME_SNAPSHOT_VERSION := 13 +const CLEANING_RUNTIME_SNAPSHOT_VERSION := 12 +const WEATHER_RUNTIME_SNAPSHOT_VERSION := 11 +const FacilityCleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd") +const CleaningBridge = preload("res://scripts/environment/facility_cleaning_bridge.gd") +const CleaningPolicy = preload("res://scripts/environment/facility_cleaning_policy.gd") +const UPKEEP_RUNTIME_SNAPSHOT_VERSION := 10 +const WeatherTimeline = preload("res://scripts/environment/weather_timeline.gd") +const STAIRS_RUNTIME_SNAPSHOT_VERSION := 9 const TRANSPORT_RUNTIME_SNAPSHOT_VERSION := 8 const SUPPLY_RUNTIME_SNAPSHOT_VERSION := 7 const INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION := 6 @@ -24,7 +36,7 @@ const DEFENSE_RUNTIME_SNAPSHOT_VERSION := 2 const PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION := 3 const CONTENT_RUNTIME_SNAPSHOT_VERSION := 4 const OPERATIONS_RUNTIME_SNAPSHOT_VERSION := 5 -const MVP_CONTENT_MANIFEST_VERSION := 1 +const MVP_CONTENT_MANIFEST_VERSION := 2 const OPERATIONS_RUNTIME_WRAPPER_VERSION := 1 const OPERATIONS_START_MINUTE := 360 const OPERATIONS_COORDINATOR_ORIGIN_MINUTE := 359 @@ -84,6 +96,12 @@ var domain_events := DomainEventStream.new() var world_service := WorldService.new() var path_service := PathService.new() var mana_network_service := ManaNetworkService.new() +var weather := WeatherTimeline.new() +var facility_cleanliness := FacilityCleanliness.new() +var cleaning_policy := CleaningPolicy.new() +var cleaning_bridge := CleaningBridge.new() +var facility_upkeep := FacilityUpkeepAccrual.new() +var upkeep_settlement := FacilityUpkeepSettlement.new() var economy_service: EconomyService var construction_resource_state: LedgerBackedResourceState var operating_resource_state: LedgerBackedResourceState @@ -101,6 +119,10 @@ var guest_service := GuestService.new() var staff_service := StaffService.new() var mvp_content_catalog := MvpContentCatalog.new() var mvp_content_report: Dictionary = {} +# Cache only catalog-derived immutable metadata, never GuestDefinition objects. +var _mvp_manifest_cache_catalog: MvpContentCatalog +var _mvp_manifest_cache_generation := -1 +var _mvp_manifest_cache: Dictionary = {} var progression_service := ProgressionService.new() var operations_coordinator: OperationsCoordinator var relationship_service := RelationshipService.new() @@ -113,7 +135,14 @@ var spatial_subslot_service: SpatialSubslotService var route_subslot_path_adapter: RouteSubslotPathAdapter var facility_infrastructure_projector: FacilityInfrastructureProjector var latest_snapshot: Dictionary = {} -var latest_digest := "" +var _latest_digest := "" +var _latest_digest_dirty := false +var latest_digest: String: + get: + if _latest_digest_dirty: + _latest_digest = SimulationDigest.compute(latest_snapshot) + _latest_digest_dirty = false + return _latest_digest var latest_save_operation_result: Dictionary = {} var latest_restore_safety_report: Dictionary = {} var world_seed := DEFAULT_WORLD_SEED @@ -134,6 +163,11 @@ var _next_checkpoint_request_id := 1 var _initialized := false var _active_command_minute := -1 var _operations_enabled := false +# Transient in-tick execution guards. Persisted timing lives inside Defense. +var _defense_round_attempt_minute := -1 +var _defense_round_attempted_raids: Dictionary = {} +var _defense_automatic_failed_minute := -1 + var _operations_tick_blocked := false var _operations_runtime_failure_latched := false var _operations_runtime_failure_report: Dictionary = {} @@ -162,6 +196,8 @@ func _ready() -> void: func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_SEED) -> Dictionary: if _initialized and _clock != null: + _clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.0_weather") + _clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.a_upkeep") _clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.operations") _clock.unregister_tick_handler(_clock.TickPhase.APPLY_COMMANDS, &"simulation_runtime.commands") _clock.unregister_tick_handler(_clock.TickPhase.PUBLISH_SNAPSHOT, &"simulation_runtime.snapshot") @@ -177,6 +213,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S _clock.unregister_tick_handler(_clock.TickPhase.AI_DECISIONS, &"simulation_runtime.staff_ai") _clock.unregister_tick_handler(_clock.TickPhase.PUBLISH_SNAPSHOT, &"simulation_runtime.zz_save_checkpoints") _detach_defense_service(defense_service) + _clear_mvp_content_manifest_cache() _clock = clock _state = state world_seed = seed @@ -242,6 +279,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S _initialize_rng_streams() supply_logistics = SupplyLogisticsBridge.new() supply_logistics.configure(self) + cleaning_bridge.configure(self) elevators = ElevatorService.new() elevators.configure(path_service) elevators.synchronize([], _clock.absolute_minute) @@ -314,6 +352,11 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S register_command_executor(&"begin_next_defense_wave", Callable(self, "_execute_begin_next_defense_wave")) register_command_executor(&"retreat_defenders", Callable(self, "_execute_retreat_defenders")) register_command_executor(&"cancel_defense_raid", Callable(self, "_execute_cancel_defense_raid")) + # Weather commits before upkeep and arrivals, including same-minute retries. + _clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.0_weather") + _clock.register_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.0_weather", Callable(self, "_advance_weather")) + _clock.unregister_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.a_upkeep") + _clock.register_tick_handler(_clock.TickPhase.TIME_BOUNDARIES, &"simulation_runtime.a_upkeep", Callable(self, "_advance_facility_upkeep")) _clock.register_tick_handler( _clock.TickPhase.APPLY_COMMANDS, &"simulation_runtime.commands", @@ -383,17 +426,19 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S ) _initialized = true var report := definition_registry.rebuild_from_paths( - FacilityCatalog.FACILITY_PATHS + FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS) + FacilityCatalog.FACILITY_PATHS + FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS + FacilityCatalog.RESCUE_FACILITY_PATHS + FacilityCatalog.ORIGINAL_FACILITY_PATHS) var catalog_registration_ok := _catalog_definitions_are_registered(guest_service) report[&"content_catalog"] = mvp_content_report.duplicate(true) report[&"progression_initialized"] = initial_progression_ok report[&"catalog_guest_definitions_registered"] = catalog_registration_ok report[&"infrastructure_initialized"] = initial_infrastructure_ok + report[&"cleaning_policy_valid"] = not cleaning_policy.create_snapshot().is_empty() report[&"valid"] = bool(report[&"valid"]) \ and bool(mvp_content_report.get(&"valid", false)) \ and catalog_registration_ok \ and initial_progression_ok \ and initial_infrastructure_ok \ + and bool(report[&"cleaning_policy_valid"]) \ and int(_state.world_tree_rank) == ProgressionService.MIN_RANK if not report[&"valid"]: _clock.set_paused(true) @@ -421,6 +466,12 @@ func submit_command( ) -> CommandResult: if not _initialized: return CommandResult.failed(CommandResult.Code.EXECUTOR_FAILED, 0, "Simulation runtime is not initialized.") + if _operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal(): + return CommandResult.failed( + CommandResult.Code.VALIDATION_FAILED, 0, + "ゲーム終了後は操作できません。帳簿の確認・保存・読み込みは利用できます。", + {&"reason": &"BANKRUPT"} + ) var scheduled_tick := _clock.absolute_minute if execute_at < 0 else execute_at var command := SimulationCommand.new( _next_command_id, @@ -456,9 +507,10 @@ func create_snapshot() -> Dictionary: for stream_id: Variant in stream_ids: var stream: DeterministicRng = _rng_streams[stream_id] rng_data[String(stream_id)] = stream.snapshot() - return SimulationSnapshot.capture(_clock, _state, { + var owned_systems := { &"runtime": { &"runtime_schema_version": RUNTIME_SNAPSHOT_VERSION, + &"weather": weather.create_snapshot(), &"world_seed": world_seed, &"next_command_id": _next_command_id, &"staffing_projection_enabled": _staffing_projection_enabled, @@ -475,6 +527,8 @@ func create_snapshot() -> Dictionary: &"movement_credits": _serialize_movement_credits(), &"mana": mana_network_service.create_snapshot(), &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(), &"elevators": elevators.create_snapshot(), @@ -482,6 +536,7 @@ func create_snapshot() -> Dictionary: &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), &"logistics_requests": _serialize_logistics_requests(), &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), &"guests": guest_service.create_snapshot(), &"staff": staff_service.create_snapshot(), &"catalog_manifest": _create_mvp_content_manifest(mvp_content_catalog), @@ -499,7 +554,13 @@ func create_snapshot() -> Dictionary: &"route_projection": route_subslot_path_adapter.create_snapshot(), &"facility_infrastructure": facility_infrastructure_projector.create_snapshot(), }, - }) + } + # Every component above returns detached containers. Transfer this fresh + # tree into the envelope instead of deep-copying all component snapshots a + # second time. Keep generic capture's caller-owned-extra contract unchanged. + var snapshot := SimulationSnapshot.capture(_clock, _state) + snapshot[&"systems"] = owned_systems + return snapshot func restore_snapshot(snapshot: Dictionary) -> bool: return _restore_snapshot_internal(snapshot, false) @@ -545,6 +606,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION, SUPPLY_RUNTIME_SNAPSHOT_VERSION, TRANSPORT_RUNTIME_SNAPSHOT_VERSION, + STAIRS_RUNTIME_SNAPSHOT_VERSION, + UPKEEP_RUNTIME_SNAPSHOT_VERSION, + WEATHER_RUNTIME_SNAPSHOT_VERSION, + CLEANING_RUNTIME_SNAPSHOT_VERSION, RUNTIME_SNAPSHOT_VERSION, ]: return false @@ -576,8 +641,14 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo runtime_current_keys.append(&"kitchen_production") if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append(&"elevators") - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append(&"legacy_stairs") + if runtime_schema_version >= UPKEEP_RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append_array([&"facility_upkeep", &"upkeep_settlement"]) + if runtime_schema_version >= WEATHER_RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append(&"weather") + if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append(&"facility_cleanliness") if not _dictionary_has_exact_canonical_keys(runtime_data, runtime_current_keys) \ or typeof(runtime_data.get(&"runtime_schema_version", null)) != TYPE_INT \ or typeof(runtime_data.get(&"world_seed", null)) != TYPE_INT \ @@ -613,19 +684,16 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"elevators", null) is Dictionary: return false - if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"legacy_stairs"): + if runtime_schema_version < STAIRS_RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"legacy_stairs"): return false - if runtime_schema_version >= 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 var restored_catalog := MvpContentCatalog.new() var restored_catalog_report := restored_catalog.rebuild() if not bool(restored_catalog_report.get(&"valid", false)): return false if runtime_schema_version >= CONTENT_RUNTIME_SNAPSHOT_VERSION \ - and not _strict_variants_equal( - _create_mvp_content_manifest(restored_catalog), - runtime_data[&"catalog_manifest"] - ): + and not _saved_content_manifest_matches(restored_catalog, runtime_data): return false var restored_last_daily_checkpoint_day := -1 var save_lifecycle_value: Variant = runtime_data.get(&"save_lifecycle", {}) @@ -734,13 +802,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo restored_route_adapter, restored_spatial, restored_path ): return false - if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version < STAIRS_RUNTIME_SNAPSHOT_VERSION: var legacy_path_data: Dictionary = runtime_data[&"path"] if legacy_path_data.has(&"vertical_walk_requires_connector") or legacy_path_data.has(&"transit_actor_ids"): return false var restored_legacy_stairs := LegacyStairService.new() restored_legacy_stairs.configure(restored_path) - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= STAIRS_RUNTIME_SNAPSHOT_VERSION: if runtime_data[&"path"].get(&"schema_version", -1) != PathService.SNAPSHOT_VERSION \ or not restored_path.get_vertical_walk_requires_connector() \ or not restored_legacy_stairs.restore_snapshot(runtime_data[&"legacy_stairs"]): @@ -760,6 +828,24 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false elif not restored_economy.has_account(WAGE_EXPENSE_ACCOUNT): return false + var restored_upkeep := FacilityUpkeepAccrual.new() + var restored_upkeep_settlement := FacilityUpkeepSettlement.new() + if runtime_schema_version >= UPKEEP_RUNTIME_SNAPSHOT_VERSION: + if not runtime_data.get(&"facility_upkeep") is Dictionary or not runtime_data.get(&"upkeep_settlement") is Dictionary: + return false + if not restored_upkeep.restore_snapshot(runtime_data[&"facility_upkeep"]) \ + or restored_upkeep.get_current_minute() != restored_minute \ + or not restored_upkeep_settlement.restore_snapshot(runtime_data[&"upkeep_settlement"], restored_economy): + return false + if not _upkeep_accrual_crosslinks_are_valid(restored_upkeep, restored_upkeep_settlement): + return false + else: + if runtime_data.has(&"facility_upkeep") or runtime_data.has(&"upkeep_settlement") \ + or restored_economy.has_account(FacilityUpkeepSettlement.EXPENSE) \ + or restored_economy.has_account(FacilityUpkeepSettlement.PAYABLE): + return false + if not FacilityUpkeepSettlement.prepare_accounts(restored_economy) or not restored_upkeep.start_at(restored_minute)[&"ok"]: + return false var saved_resources: Variant = snapshot[&"game_state"].get(&"resources", null) if not saved_resources is Dictionary \ or int(saved_resources.get(&"gold", saved_resources.get("gold", -1))) \ @@ -896,10 +982,23 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo restored_staff, restored_economy, int(runtime_data.get(&"world_seed", DEFAULT_WORLD_SEED)), - snapshot[&"clock"].get(&"paused", false) + snapshot[&"clock"].get(&"paused", false), + restored_upkeep_settlement ) if not bool(restored_operations_state.get(&"ok", false)): return false + if bool(restored_operations_state.get(&"enabled", false)): + var restored_coordinator: OperationsCoordinator = restored_operations_state[&"coordinator"] + var expected_minimum := 0 + var expected_maximum := 0 + for period: Dictionary in restored_upkeep.get_report()[&"closed_periods"]: + if int(period[&"period_end"]) <= restored_coordinator.current_minute: + expected_minimum += 1 + if int(period[&"period_end"]) <= restored_minute: + expected_maximum += 1 + var actual_count := restored_upkeep_settlement.get_settled_period_count() + if actual_count < expected_minimum or actual_count > expected_maximum: + return false var restored_movement_credits: Variant if runtime_schema_version >= PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION: restored_movement_credits = _deserialize_movement_credits( @@ -1008,6 +1107,8 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo restored_defense.current_minute ): return false + if not _validate_event_defense_crosslinks(restored_events, restored_defense): + return false var restored_construction_resource := _create_resource_adapter( restored_economy, CONSTRUCTION_EXPENSE_ACCOUNT, @@ -1070,7 +1171,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo var present_ids: Array[StringName] = [] for staff_id: StringName in facility.active_staff_ids: var worker := restored_staff.get_staff(staff_id) - if not StringName(restored_staff.get_work_assignment(staff_id).get(&"facility_id", &"")).is_empty() and not StaffCommuteService.is_at_facility(worker, facility): + if not StringName(restored_staff.get_work_assignment(staff_id).get(&"facility_id", &"")).is_empty() and not StaffCommuteService.is_at_facility(worker, facility) and not CleaningBridge.is_deployed(worker, facility, restored_staff): if runtime_schema_version >= TRANSPORT_RUNTIME_SNAPSHOT_VERSION: return false if worker.role_id != &"porter" and worker.current_task_id.is_empty(): @@ -1093,7 +1194,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false for facility: FacilityInstance in restored_facilities.get_instances(): for staff_id: StringName in facility.active_staff_ids: - if not restored_elevators.get_actor_trip(_staff_path_actor_id(staff_id)).is_empty(): + if not restored_elevators.get_actor_trip(_staff_path_actor_id(staff_id)).is_empty() and not CleaningBridge.is_deployed(restored_staff.get_staff(staff_id), facility, restored_staff): return false var restored_kitchen := KitchenProductionService.new() restored_kitchen.configure(restored_inventory, restored_facilities, definition_registry, restored_runtime_domain_minute) @@ -1104,6 +1205,25 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo return false if not SupplyLogisticsBridge.validate_crosslinks(restored_logistics, restored_staff, restored_inventory, restored_facilities): return false + var restored_weather := WeatherTimeline.new() + if runtime_schema_version >= WEATHER_RUNTIME_SNAPSHOT_VERSION: + if not runtime_data.get(&"weather") is Dictionary or not restored_weather.restore_snapshot(runtime_data[&"weather"]) or restored_weather.get_minute() != restored_minute: + return false + elif runtime_data.has(&"weather") or not restored_weather.initialize(&"clear", restored_minute): + return false + var restored_cleanliness := FacilityCleanliness.new() + if runtime_schema_version >= CLEANING_RUNTIME_SNAPSHOT_VERSION: + if not runtime_data.get(&"facility_cleanliness") is Dictionary or not restored_cleanliness.restore_snapshot(runtime_data[&"facility_cleanliness"]): + return false + else: + if runtime_data.has(&"facility_cleanliness"): + return false + var policy_values := cleaning_policy.create_snapshot() + for facility: FacilityInstance in restored_facilities.get_instances(): + if not restored_cleanliness.register_facility(facility.instance_id, policy_values["initial_cleanliness"], policy_values["maximum_cleanliness"]): + return false + if not _validate_cleanliness_crosslinks(restored_cleanliness, restored_facilities, restored_staff, restored_path): + return false var restored_world_seed := int(runtime_data.get(&"world_seed", DEFAULT_WORLD_SEED)) var restored_streams := {} var saved_streams: Dictionary = runtime_data[&"rng_streams"] @@ -1144,6 +1264,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo _movement_credits = (restored_movement_credits as Dictionary).duplicate(true) _connect_path_performance_signal() mana_network_service = restored_mana + weather = restored_weather + facility_cleanliness = restored_cleanliness + facility_upkeep = restored_upkeep + upkeep_settlement = restored_upkeep_settlement economy_service = restored_economy inventory_service = restored_inventory kitchen_production = restored_kitchen @@ -1156,7 +1280,9 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo legacy_stairs = restored_legacy_stairs staff_commute.configure(self) supply_logistics.configure(self) + cleaning_bridge.configure(self) mvp_content_catalog = restored_catalog + _clear_mvp_content_manifest_cache() mvp_content_report = restored_catalog_report.duplicate(true) progression_service = restored_progression _hired_staff_candidate_ids = (restored_hired_staff_candidates as Dictionary).duplicate(true) @@ -1184,6 +1310,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo authority_service = restored_authority _detach_defense_service(defense_service) defense_service = restored_defense + _reset_defense_round_attempts() construction_resource_state = restored_construction_resource operating_resource_state = _create_resource_adapter( economy_service, @@ -1351,7 +1478,8 @@ func enable_production_operations() -> Dictionary: coordinator.configure_callbacks( Callable(self, "_operations_daily_boundary"), Callable(self, "_operations_arrival_boundary"), - Callable(self, "_operations_morning_boundary") + Callable(self, "_operations_morning_boundary"), + Callable(self, "_operations_midnight_upkeep_boundary") ) var demand_configuration := _configure_default_operations_demand(coordinator) if not demand_configuration.is_success(): @@ -1381,7 +1509,7 @@ func enable_production_operations() -> Dictionary: if not advanced.is_success() \ or coordinator.current_minute != _clock.absolute_minute \ or coordinator.get_population_count() != guest_service.guest_count() \ - or coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service) \ + or coordinator.get_arrears_total() != _operations_closed_arrears_total() \ or not _operations_notifications_are_committable() \ or not _commit_operations_domain_events() \ or not _commit_operations_notifications(): @@ -1450,7 +1578,7 @@ func acknowledge_operations_pause() -> Dictionary: return _operations_api_failure(&"RETRY_FAILED", deferred.message, deferred.details) if operations_coordinator.current_minute != deferred_minute \ or operations_coordinator.get_population_count() != guest_service.guest_count() \ - or operations_coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service) \ + or operations_coordinator.get_arrears_total() != _operations_closed_arrears_total() \ or not _operations_notifications_are_committable() \ or not _commit_operations_domain_events() \ or not _commit_operations_notifications(): @@ -1493,7 +1621,7 @@ func acknowledge_operations_pause() -> Dictionary: _run_operations_deferred_suffix(deferred_minute) _pause_for_waiting_foreground_event() if operations_coordinator.get_population_count() != guest_service.guest_count() \ - or operations_coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service): + or operations_coordinator.get_arrears_total() != _operations_closed_arrears_total(): _operations_runtime_failure_latched = true _operations_runtime_failure_report = { &"code": &"DEFERRED_SUFFIX_CROSSLINK_MISMATCH", @@ -1533,9 +1661,18 @@ func _run_operations_deferred_suffix(absolute_minute: int) -> void: _process_save_checkpoints(absolute_minute) +func get_operations_dashboard_report() -> Dictionary: + return {&"enabled":_operations_enabled, &"arrears_total":_operations_total_arrears(), + &"tick_blocked":_operations_tick_blocked, &"runtime_failure_latched":_operations_runtime_failure_latched, + &"personal_action_points":_personal_action_points, + &"deferred_runtime_minute":_operations_deferred_runtime_minute, + &"coordinator":operations_coordinator.get_dashboard_summary() if operations_coordinator != null else {}} + + func get_operations_report() -> Dictionary: return { &"enabled": _operations_enabled, + &"arrears_total": _operations_total_arrears(), &"tick_blocked": _operations_tick_blocked, &"runtime_failure_latched": _operations_runtime_failure_latched, &"runtime_failure_report": _operations_runtime_failure_report.duplicate(true), @@ -1552,7 +1689,7 @@ func set_operations_test_boundary_hook(operation: StringName, callback: Callable # Test-only fault injection is invoked at PREPARE and, for arrivals, once at # AFTER_FIRST_MUTATION. The latter proves staged service notifications are # discarded when a real partial apply is rolled back. - if operation not in [&"DAILY_CLOSE", &"ARRIVAL_PLAN", &"MORNING_BOUNDARY"]: + if operation not in [&"DAILY_CLOSE", &"ARRIVAL_PLAN", &"MORNING_BOUNDARY", &"MIDNIGHT_UPKEEP"]: return false if callback.is_valid(): _operations_test_boundary_hooks[operation] = callback @@ -1598,7 +1735,8 @@ func _configure_operations_callbacks() -> void: operations_coordinator.configure_callbacks( Callable(self, "_operations_daily_boundary"), Callable(self, "_operations_arrival_boundary"), - Callable(self, "_operations_morning_boundary") + Callable(self, "_operations_morning_boundary"), + Callable(self, "_operations_midnight_upkeep_boundary") ) @@ -1650,6 +1788,72 @@ func _configure_default_operations_demand(coordinator: OperationsCoordinator) -> return CommandResult.succeeded(0, {&"profile_count": seen_classes.size()}) +func get_facility_upkeep_report(facility_id: StringName) -> Dictionary: + var definition_id: StringName = &"" + var facility := facility_service.get_instance(facility_id) + if facility != null: + definition_id = facility.definition_id + elif construction_service != null: + for built: Dictionary in construction_service.get_facilities(): + if StringName(built[&"instance_id"]) == facility_id: + definition_id = StringName(built[&"definition_id"]) + break + var definition := definition_registry.get_facility(definition_id) + var balance := upkeep_settlement.get_facility_balance(facility_id) + var units := facility_upkeep.get_unsettled_units(facility_id, upkeep_settlement.get_settled_period_count()) + return { + &"facility_id": facility_id, + &"owned": definition != null, + &"daily_rate": definition.maintenance_gold if definition != null else 0, + &"unsettled_units": units + int(balance[&"fraction_units"]), + &"gold_denominator": FacilityUpkeepAccrual.MINUTES_PER_DAY, + &"arrears": balance[&"arrears"], + } + + +func _advance_facility_upkeep(minute: int) -> void: + if _operations_deferred_runtime_minute >= 0: + return + var rates: Dictionary = {} + for facility: FacilityInstance in facility_service.get_instances(): + var definition := definition_registry.get_facility(facility.definition_id) + if definition != null: + rates[String(facility.instance_id)] = definition.maintenance_gold + # Infrastructure modules are completed assets even when they have no service instance. + for facility: Dictionary in construction_service.get_facilities(): + var definition := definition_registry.get_facility(StringName(facility[&"definition_id"])) + if definition != null: + rates[String(facility[&"instance_id"])] = definition.maintenance_gold + var advanced := facility_upkeep.advance_to(minute, rates) + if not advanced[&"ok"]: + _clock.set_paused(true) + push_error("Facility upkeep accrual failed: %s" % advanced) + + +func _upkeep_accrual_crosslinks_are_valid(accrual: FacilityUpkeepAccrual, settlement: FacilityUpkeepSettlement) -> bool: + var periods: Array = accrual.get_report()[&"closed_periods"] + var receipts: Array = settlement.get_report()[&"receipts"] + if receipts.size() > periods.size(): + return false + for index in receipts.size(): + if receipts[index][&"period"] != periods[index]: + return false + return true + + +func _operations_closed_arrears_total() -> int: + # Coordinator arrears describe its last financial close. Interim calendar + # payments during a legacy 06:00 period must not rewrite that closed record. + var last_close := 0 + if operations_coordinator != null and operations_coordinator.get_open_financial_day() > 1: + last_close = operations_coordinator.get_daily_close_minute(operations_coordinator.get_open_financial_day() - 1) + return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_through(last_close) + + +func _operations_total_arrears() -> int: + return _staff_payroll_arrears_total(staff_service) + upkeep_settlement.get_arrears_total() + + func _advance_operations_time(absolute_minute: int) -> void: if not _operations_enabled or operations_coordinator == null: return @@ -1675,7 +1879,7 @@ func _advance_operations_time(absolute_minute: int) -> void: return if operations_coordinator.current_minute != absolute_minute \ or operations_coordinator.get_population_count() != guest_service.guest_count() \ - or operations_coordinator.get_arrears_total() != _staff_payroll_arrears_total(staff_service) \ + or operations_coordinator.get_arrears_total() != _operations_closed_arrears_total() \ or not _operations_notifications_are_committable() \ or not _commit_operations_domain_events() \ or not _commit_operations_notifications(): @@ -1758,9 +1962,12 @@ func _build_operations_external_state() -> Dictionary: &"command_queue": command_queue.create_snapshot(), &"next_command_id": _next_command_id, &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"kitchen_production": kitchen_production.create_snapshot(), &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), &"guests": guest_service.create_snapshot(), &"staff": staff_service.create_snapshot(), &"relationships": relationship_service.create_snapshot(), @@ -1815,6 +2022,7 @@ func _restore_operations_external_state(token: Variant) -> bool: return false var saved: Dictionary = token var required_dictionary_keys: Array[StringName] = [ + &"facility_upkeep", &"upkeep_settlement", &"facility_cleanliness", &"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"facilities", &"guests", &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"elevators", &"legacy_stairs", &"excavation", &"spatial_subslots", &"route_projection", @@ -1853,8 +2061,10 @@ func _restore_operations_external_state(token: Variant) -> bool: var state_ok: bool = _state.restore_snapshot(saved[&"game_state"]) var command_queue_ok := command_queue.restore_snapshot(saved[&"command_queue"]) var economy_ok := economy_service.restore_snapshot(saved[&"economy"]) + var upkeep_ok := facility_upkeep.restore_snapshot(saved[&"facility_upkeep"]) and upkeep_settlement.restore_snapshot(saved[&"upkeep_settlement"], economy_service) var inventory_ok := inventory_service.restore_snapshot(saved[&"inventory"]) var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"]) + facilities_ok = facility_cleanliness.restore_snapshot(saved.get(&"facility_cleanliness", {})) and facilities_ok var kitchen_ok := kitchen_production.restore_snapshot(saved[&"kitchen_production"]) var guests_ok := guest_service.restore_snapshot(saved[&"guests"]) var staff_ok := staff_service.restore_snapshot(saved[&"staff"]) @@ -1906,7 +2116,7 @@ func _restore_operations_external_state(token: Variant) -> bool: rng_ok = false _discard_operations_domain_events() - var participants_ok := 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 facilities_ok and guests_ok and staff_ok and relationships_ok \ and events_ok and authority_ok and world_ok and spatial_ok and path_ok \ and route_ok and infrastructure_ok and excavation_ok \ @@ -1958,10 +2168,13 @@ func _operations_external_state_matches(saved: Dictionary) -> bool: if not _strict_variants_equal(_state.create_snapshot(), saved[&"game_state"]) \ or not _strict_variants_equal(command_queue.create_snapshot(), saved[&"command_queue"]) \ or _next_command_id != int(saved[&"next_command_id"]) \ + or not _strict_variants_equal(facility_upkeep.create_snapshot(), saved[&"facility_upkeep"]) \ + or not _strict_variants_equal(upkeep_settlement.create_snapshot(), saved[&"upkeep_settlement"]) \ or not _strict_variants_equal(economy_service.create_snapshot(), saved[&"economy"]) \ or not _strict_variants_equal(inventory_service.create_snapshot(), saved[&"inventory"]) \ or not _strict_variants_equal(kitchen_production.create_snapshot(), saved[&"kitchen_production"]) \ or not _strict_variants_equal(facility_service.create_snapshot(), saved[&"facilities"]) \ + or not _strict_variants_equal(facility_cleanliness.create_snapshot(), saved.get(&"facility_cleanliness", {})) \ or not _strict_variants_equal(guest_service.create_snapshot(), saved[&"guests"]) \ or not _strict_variants_equal(staff_service.create_snapshot(), saved[&"staff"]) \ or not _strict_variants_equal(relationship_service.create_snapshot(), saved[&"relationships"]) \ @@ -2160,12 +2373,53 @@ func _operations_daily_payload_is_valid(payload: Dictionary) -> bool: var boundary_minute := int(payload[&"boundary_minute"]) return day > 0 \ and boundary_minute == _clock.absolute_minute \ - and boundary_minute == day * SimulationKernel.MINUTES_PER_DAY \ - + OperationsCoordinator.MORNING_MINUTE_OF_DAY \ - and int(payload[&"period_start_minute"]) == boundary_minute - SimulationKernel.MINUTES_PER_DAY \ + and boundary_minute == operations_coordinator.get_daily_close_minute(day) \ + and int(payload[&"period_start_minute"]) == operations_coordinator.get_financial_period_start(day) \ and int(payload[&"period_end_minute_exclusive"]) == boundary_minute +func _settle_upkeep_through(boundary_minute: int) -> Dictionary: + var upkeep_paid := 0 + for period: Dictionary in facility_upkeep.get_closed_periods_from(upkeep_settlement.get_settled_period_count()): + if int(period[&"period_end"]) > boundary_minute: + continue + var upkeep_result := upkeep_settlement.settle(period, economy_service) + if not upkeep_result[&"ok"]: + return {&"ok": false, &"reason": &"UPKEEP_SETTLEMENT_FAILED"} + if not upkeep_result[&"replayed"]: + for row: Dictionary in upkeep_result[&"receipt"][&"rows"]: + upkeep_paid += int(row[&"paid"]) + if not String(upkeep_result[&"transaction_id"]).is_empty() and not _stage_operations_economy_notification(CommandResult.succeeded(0, {&"transaction_id": upkeep_result[&"transaction_id"]})): + return {&"ok": false, &"reason": &"UPKEEP_NOTIFICATION_STAGE_FAILED"} + return {&"ok": true, &"paid_total": upkeep_paid} + + +func _operations_midnight_upkeep_boundary(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase == OperationsCoordinator.PHASE_CAPTURE: + var captured := _capture_operations_external_state() + return {&"ok": not captured.is_empty(), &"token": captured} + if phase == OperationsCoordinator.PHASE_ROLLBACK: + return {&"ok": _restore_operations_external_state(token)} + var minute: Variant = payload.get(&"boundary_minute") + var valid: bool = token is Dictionary and not token.is_empty() and typeof(minute) == TYPE_INT \ + and int(minute) == _clock.absolute_minute and int(minute) % 1440 == 0 + if phase == OperationsCoordinator.PHASE_VALIDATE: + return {&"ok": valid, &"token": token} + if phase != OperationsCoordinator.PHASE_APPLY or not valid: + return {&"ok": false} + var prepared := _run_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", payload, OPERATIONS_TEST_PREPARE_PHASE, token) + if not bool(prepared.get(&"ok", false)): + return prepared + var result := _settle_upkeep_through(int(minute)) + if not bool(result[&"ok"]): + return result + var after := _run_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", payload, OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE, token) + if not bool(after.get(&"ok", false)): + return after + return {&"ok": upkeep_settlement.validate_ledger(economy_service) \ + and int(_state.get_resource(&"gold")) == economy_service.get_balance(PLAYER_CASH_ACCOUNT)} + + func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dictionary: if not token is Dictionary or token.is_empty() \ or not _operations_daily_payload_is_valid(payload): @@ -2204,16 +2458,6 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti remaining_gold -= payment expected_paid_total += payment - # Category classification and facility wage allocation are read-only and can - # reject unknown input. Finish them before Staff or Economy receives a write. - var financials := _build_operations_daily_financials( - int(payload[&"period_start_minute"]), - int(payload[&"period_end_minute_exclusive"]), - int(preview.get(&"new_wages_total", -1)), - preview[&"rows"] - ) - if not bool(financials.get(&"ok", false)): - return financials var settlement_id := StringName("payroll-day-%08d" % day) var settlement := staff_service.apply_daily_payroll_settlement( settlement_id, day, payments @@ -2250,16 +2494,29 @@ func _apply_operations_daily_close(payload: Dictionary, token: Variant) -> Dicti &"reason": &"WAGE_NOTIFICATION_STAGE_FAILED", &"result": wage_payment.to_dictionary(), } + var upkeep_result := _settle_upkeep_through(int(payload[&"boundary_minute"])) + if not bool(upkeep_result[&"ok"]): + return upkeep_result + var upkeep_paid := int(upkeep_result[&"paid_total"]) + var financials := _build_operations_daily_financials( + int(payload[&"period_start_minute"]), + int(payload[&"period_end_minute_exclusive"]), + int(preview.get(&"new_wages_total", -1)), + preview[&"rows"] + ) + if not bool(financials.get(&"ok", false)): + return financials var arrears_total := int(settlement.details.get(&"unpaid_after_total", -1)) if arrears_total < 0 \ or arrears_total != _staff_payroll_arrears_total(staff_service) \ - or economy_service.get_balance(PLAYER_CASH_ACCOUNT) != available_gold - expected_paid_total \ + or economy_service.get_balance(PLAYER_CASH_ACCOUNT) != available_gold - expected_paid_total - upkeep_paid \ or int(_state.get_resource(&"gold")) != economy_service.get_balance(PLAYER_CASH_ACCOUNT) \ or not bool(economy_service.validate_ledger().get(&"ok", false)) \ or not bool(staff_service.validate_invariants().get(&"ok", false)): return {&"ok": false, &"reason": &"DAILY_CLOSE_POSTCONDITION_FAILED"} var settlement_report := settlement.details.duplicate(true) - settlement_report[&"arrears_total"] = arrears_total + settlement_report[&"upkeep_arrears_total"] = upkeep_settlement.get_arrears_total() + settlement_report[&"arrears_total"] = arrears_total + int(settlement_report[&"upkeep_arrears_total"]) return { &"ok": true, &"category_totals": financials[&"category_totals"], @@ -2293,7 +2550,7 @@ func _build_operations_daily_financials( var excluded_categories: Array[StringName] = [ &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", - &"SERVICE_CASH_SWEEP", &"WAGE", + &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", ] var category_mapping := { &"SERVICE_SALE": &"revenue", @@ -2306,6 +2563,7 @@ func _build_operations_daily_financials( &"EVENT_ACTION": &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods", + &"MAINTENANCE_ACCRUAL": &"maintenance", } var operating_refund_total := 0 for entry: LedgerEntry in economy_service.get_entries(): @@ -2327,7 +2585,7 @@ func _build_operations_daily_financials( var category: StringName = category_mapping[entry.category] category_totals[category] = int(category_totals[category]) + entry.amount if not entry.source_entity_id.is_empty() \ - and facility_service.has_instance(entry.source_entity_id) \ + and (facility_service.has_instance(entry.source_entity_id) or entry.category == &"MAINTENANCE_ACCRUAL") \ and category != &"construction_cash_flow": if not facility_rows.has(entry.source_entity_id): facility_rows[entry.source_entity_id] = { @@ -2540,7 +2798,7 @@ func _operations_arrival_boundary( return { &"ok": not captured.is_empty(), &"token": captured, - &"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0), + &"capacity_remaining": maxi(population_cap - guest_service.guest_count(), 0) if int(_state.get_resource(&"mana")) > 0 and weather.permits_new_arrivals() else 0, } if phase == OperationsCoordinator.PHASE_VALIDATE: return { @@ -2899,7 +3157,8 @@ func _parse_operations_runtime_snapshot( restored_staff: StaffService, restored_economy: EconomyService, restored_world_seed: int, - restored_clock_paused: Variant + restored_clock_paused: Variant, + restored_upkeep_settlement: FacilityUpkeepSettlement ) -> Dictionary: var disabled := { &"ok": true, @@ -2979,9 +3238,9 @@ func _parse_operations_runtime_snapshot( or restored_coordinator.get_origin_minute() != OPERATIONS_COORDINATOR_ORIGIN_MINUTE \ or not (normal_shape or failure_shape or pause_shape or terminal_shape) \ or restored_coordinator.get_population_count() != restored_guests.guest_count() \ - or restored_coordinator.get_arrears_total() != _staff_payroll_arrears_total(restored_staff) \ + or restored_coordinator.get_arrears_total() != (_staff_payroll_arrears_total(restored_staff) + restored_upkeep_settlement.get_arrears_through(restored_coordinator.get_daily_close_minute(restored_coordinator.get_open_financial_day() - 1))) \ or not _operations_payroll_crosslinks_are_valid( - restored_coordinator, restored_staff, restored_economy + restored_coordinator, restored_staff, restored_economy, restored_upkeep_settlement ) \ or not _operations_coordinator_configuration_is_canonical( restored_coordinator, restored_world_seed @@ -3099,7 +3358,8 @@ func _guest_aggregate_has_reserved_operations_id(guests: GuestService) -> bool: func _operations_payroll_crosslinks_are_valid( coordinator: OperationsCoordinator, staff: StaffService, - economy: EconomyService + economy: EconomyService, + upkeep: FacilityUpkeepSettlement ) -> bool: if coordinator == null or staff == null or economy == null: return false @@ -3145,8 +3405,7 @@ func _operations_payroll_crosslinks_are_valid( if entries.size() != 1: return false var entry: LedgerEntry = entries[0] - if entry.absolute_minute != day * SimulationKernel.MINUTES_PER_DAY \ - + OperationsCoordinator.MORNING_MINUTE_OF_DAY \ + if entry.absolute_minute != coordinator.get_daily_close_minute(day) \ or entry.debit_account != WAGE_EXPENSE_ACCOUNT \ or entry.credit_account != PLAYER_CASH_ACCOUNT \ or entry.amount != paid_total or entry.category != &"WAGE" \ @@ -3167,10 +3426,8 @@ func _operations_payroll_crosslinks_are_valid( var staff_record: Dictionary = settlements_by_day[day] var expected_categories: Variant = _operations_expected_category_totals( economy, - (day - 1) * SimulationKernel.MINUTES_PER_DAY \ - + OperationsCoordinator.MORNING_MINUTE_OF_DAY, - day * SimulationKernel.MINUTES_PER_DAY \ - + OperationsCoordinator.MORNING_MINUTE_OF_DAY, + coordinator.get_financial_period_start(day), + coordinator.get_daily_close_minute(day), int(staff_record.get(&"new_wages_total", -1)) ) if expected_categories == null \ @@ -3184,8 +3441,20 @@ func _operations_payroll_crosslinks_are_valid( summary, staff_record, staff, economy ): return false + var upkeep_debt := 0 + for receipt: Dictionary in upkeep.get_report()[&"receipts"]: + if int(receipt[&"period"][&"period_end"]) <= coordinator.get_daily_close_minute(day): + upkeep_debt = 0 + for row: Dictionary in receipt[&"rows"]: + upkeep_debt += int(row[&"arrears"]) + if summary_settlement.has(&"upkeep_arrears_total"): + if typeof(summary_settlement[&"upkeep_arrears_total"]) != TYPE_INT or int(summary_settlement[&"upkeep_arrears_total"]) != upkeep_debt: + return false + summary_settlement.erase(&"upkeep_arrears_total") + elif upkeep_debt != 0: + return false if int(summary_settlement.get(&"arrears_total", -1)) \ - != int(staff_record.get(&"unpaid_after_total", -2)) \ + != int(staff_record.get(&"unpaid_after_total", -2)) + upkeep_debt \ or summary_settlement.get(&"replayed", null) != false: return false summary_settlement.erase(&"arrears_total") @@ -3225,7 +3494,7 @@ func _operations_expected_category_totals( var excluded: Array[StringName] = [ &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", - &"SERVICE_CASH_SWEEP", &"WAGE", + &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", ] var mapping := { &"SERVICE_SALE": &"revenue", &"REFUND": &"refunds", @@ -3235,6 +3504,7 @@ func _operations_expected_category_totals( &"STAFF_SIGNING_COST": &"cost_of_goods", &"EVENT_ACTION": &"cost_of_goods", &"EVENT_COST_COMMITTED": &"cost_of_goods", &"DEFENSE_SKILL_COST": &"cost_of_goods", + &"MAINTENANCE_ACCRUAL": &"maintenance", } var operating_refunds := 0 for entry: LedgerEntry in economy.get_entries(): @@ -3381,13 +3651,14 @@ func _operations_facility_wage_summary_is_valid( var excluded: Array[StringName] = [ &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", - &"SERVICE_CASH_SWEEP", &"WAGE", &"OPERATING_REFUND", + &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", &"OPERATING_REFUND", ] var mapping := { &"SERVICE_SALE": &"revenue", &"REFUND": &"expenses", &"AUTHORITY_FINE": &"expenses", &"OPERATING_EXPENSE": &"expenses", &"STAFF_SIGNING_COST": &"expenses", &"EVENT_ACTION": &"expenses", &"EVENT_COST_COMMITTED": &"expenses", &"DEFENSE_SKILL_COST": &"expenses", + &"MAINTENANCE_ACCRUAL": &"expenses", } for entry: LedgerEntry in economy.get_entries(): if entry.absolute_minute < period_start or entry.absolute_minute >= period_end \ @@ -3497,7 +3768,7 @@ func _operations_facility_wage_summary_from_live_shifts_is_valid( var excluded: Array[StringName] = [ &"EVENT_COST_RESERVED", &"EVENT_COST_RELEASED", &"INITIALIZATION", &"TRANSFER", &"GENERATION", &"DESTRUCTION", &"MIGRATION", - &"SERVICE_CASH_SWEEP", &"WAGE", &"OPERATING_REFUND", + &"SERVICE_CASH_SWEEP", &"WAGE", &"MAINTENANCE_PAYMENT", &"OPERATING_REFUND", ] var mapping := { &"SERVICE_SALE": &"revenue", &"REFUND": &"expense", @@ -4148,6 +4419,10 @@ func _perform_checkpoint_save( # state that should resume after load rather than the temporary I/O pause. snapshot[&"clock"][&"paused"] = previous_paused var effective_metadata: Dictionary = metadata.duplicate(true) if metadata is Dictionary else {} + var presentation: Dictionary = {} + checkpoint_presentation_requested.emit(presentation) + if not effective_metadata.has(&"interface_selection") and presentation.get(&"interface_selection") is Dictionary: + effective_metadata[&"interface_selection"] = presentation[&"interface_selection"].duplicate(true) effective_metadata[&"checkpoint_reason"] = reason var result := ( SaveService.save_autosave( @@ -4576,7 +4851,7 @@ func _runtime_v5_outer_snapshot_shape_is_valid(snapshot: Dictionary) -> bool: or typeof(clock_data.get(&"absolute_minute", null)) != TYPE_INT \ or int(clock_data[&"absolute_minute"]) < 0 \ or typeof(clock_data.get(&"speed", null)) != TYPE_INT \ - or int(clock_data[&"speed"]) not in [1, 2, 4] \ + or int(clock_data[&"speed"]) not in [1, 2, 3, 4] \ or typeof(clock_data.get(&"paused", null)) != TYPE_BOOL \ or typeof(clock_data.get(&"snapshot_version", null)) != TYPE_INT \ or int(clock_data[&"snapshot_version"]) < 0: @@ -4750,6 +5025,15 @@ func _initialize_economy() -> void: _clock.absolute_minute, &"runtime.initial_player_cash" ) + facility_cleanliness = FacilityCleanliness.new() + cleaning_policy = CleaningPolicy.new() + cleaning_policy.load_file() + weather = WeatherTimeline.new() + weather.initialize(&"clear", _clock.absolute_minute) + facility_upkeep = FacilityUpkeepAccrual.new() + upkeep_settlement = FacilityUpkeepSettlement.new() + FacilityUpkeepSettlement.prepare_accounts(economy_service) + facility_upkeep.start_at(_clock.absolute_minute) construction_resource_state = _create_resource_adapter( economy_service, CONSTRUCTION_EXPENSE_ACCOUNT, @@ -4862,6 +5146,8 @@ func _initialize_s3_actors() -> void: func _configure_staff_boundaries() -> void: if staff_service == null: return + staff_service.configure_arrival_driver(Callable(self, "_handle_rescue_arrival")) + staff_service.configure_work_progress_driver(Callable(self, "_automatic_staff_work_units")) staff_service.configure_callbacks( Callable(self, "_reserve_staff_task_items"), Callable(self, "_release_staff_task_items"), @@ -4984,6 +5270,7 @@ func _connect_authority_signals() -> void: authority_service.resolution_applied.connect(Callable(self, "_on_authority_resolution_applied")) func _initialize_s5_defense() -> void: + _reset_defense_round_attempts() defense_service = DefenseService.new() defense_service.current_minute = _clock.absolute_minute _configure_defense_boundaries() @@ -5053,6 +5340,8 @@ func _configure_facility_boundaries() -> void: func _connect_facility_signals() -> void: if facility_service == null: return + facility_service.instance_registered.connect(Callable(self, "_on_cleanliness_facility_registered")) + facility_service.instance_unregistered.connect(Callable(self, "_on_cleanliness_facility_unregistered")) facility_service.facility_state_changed.connect(Callable(self, "_on_facility_state_changed")) facility_service.service_started.connect(Callable(self, "_on_service_started")) facility_service.service_interrupted.connect(Callable(self, "_on_service_interrupted")) @@ -5796,7 +6085,7 @@ func _execute_decide_guest(command: SimulationCommand) -> CommandResult: var raw_context: Variant = command.payload.get(&"context", command.payload.get("context", {})) var context: Dictionary = raw_context.duplicate(true) if raw_context is Dictionary else {} context[&"absolute_minute"] = _runtime_event_minute() - var result := guest_service.decide(guest_id, candidates, _runtime_event_minute(), context) + var result := _decide_guest_with_log(guest_id, candidates, _runtime_event_minute(), context) return _command_result_from_dictionary(command.command_id, result, "Guest decision failed.") func _execute_register_staff(command: SimulationCommand) -> CommandResult: @@ -5805,11 +6094,11 @@ func _execute_register_staff(command: SimulationCommand) -> CommandResult: var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) if staff_id.is_empty() or role_id.is_empty() or not raw_options is Dictionary: return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Staff id, role id and options are required.") - if _is_mvp_staff_candidate_id(staff_id): + if _is_mvp_staff_candidate_id(staff_id) or mvp_content_catalog.get_unique_character_ids().has(staff_id): return CommandResult.failed( CommandResult.Code.VALIDATION_FAILED, command.command_id, - "Catalog Staff candidates must be created through hire_staff_candidate." + "Catalog identities must be created through their validated recruitment flow." ) var options: Dictionary = raw_options.duplicate(true) var initial_cell_value: Variant = _parse_world_cell( @@ -5858,16 +6147,46 @@ func _execute_register_staff(command: SimulationCommand) -> CommandResult: return result +# Original 6.1 plus the approved Anri staff-screen choice. Other unique +# characters still have no approved joining timing. Zero signing cost and both +# canonical wage fields must be actual integers; payload aliases cannot qualify. +func _is_approved_free_unique_hire(candidate_id: StringName, canonical: Dictionary) -> bool: + if candidate_id != &"heroine_anri" or not mvp_content_catalog.is_valid(): + return false + var staff_id_value: Variant = canonical.get(&"staff_id", null) + var role_value: Variant = canonical.get(&"role_id", null) + var options_value: Variant = canonical.get(&"options", null) + if typeof(role_value) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(role_value) != &"innkeeper": + return false + if typeof(staff_id_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(staff_id_value) != candidate_id or not options_value is Dictionary: + return false + var options: Dictionary = options_value + var employment_value: Variant = options.get(&"employment_kind", null) + if typeof(employment_value) not in [TYPE_STRING, TYPE_STRING_NAME] \ + or StringName(employment_value) != &"UNIQUE" \ + or typeof(canonical.get(&"adult_verified", null)) != TYPE_BOOL \ + or not bool(canonical[&"adult_verified"]): + return false + for value: Variant in [canonical.get(&"signing_cost", null), canonical.get(&"daily_wage", null), options.get(&"daily_wage", null)]: + if typeof(value) != TYPE_INT or int(value) != 0: + return false + return true + + func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: - # A new employment contract creates an immediate signing expense and a - # recurring wage liability, so it follows the same Operations investment - # gate as Construction and Excavation once production operations are active. + # Ordinary contracts retain the investment gate and its existing error + # priority. Only Anri has approved free recruitment; classify that exception + # from validated canonical data, never caller-supplied costs or employment. var operations_gate := _operations_construction_investment_gate(command.command_id) - if operations_gate != null: - return operations_gate var raw_candidate_id: Variant = command.payload.get( &"candidate_id", command.payload.get("candidate_id", null) ) + if operations_gate != null and ( + typeof(raw_candidate_id) not in [TYPE_STRING, TYPE_STRING_NAME] + or StringName(raw_candidate_id) != &"heroine_anri" + ): + return operations_gate if typeof(raw_candidate_id) not in [TYPE_STRING, TYPE_STRING_NAME]: return CommandResult.failed( CommandResult.Code.INVALID_PAYLOAD, @@ -5888,6 +6207,13 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: command.command_id, "Staff candidate does not have verified adult eligibility." ) + if operations_gate != null: + var reason := StringName(operations_gate.details.get(&"reason", &"")) + var can_join_without_investment := _is_approved_free_unique_hire(candidate_id, canonical) \ + and reason in [&"ARREARS_REMAIN", &"UNPAID_EXPENSES"] \ + and not _operations_should_block_tick() + if not can_join_without_investment: + return operations_gate if _hired_staff_candidate_ids.has(candidate_id) or staff_service.has_staff(candidate_id): return CommandResult.failed( CommandResult.Code.DUPLICATE_COMMAND_ID, @@ -5926,6 +6252,8 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: var before := { &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"game_state": _state.create_snapshot(), &"staff": staff_service.create_snapshot(), &"path": path_service.create_snapshot(), @@ -5989,7 +6317,7 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: var hired_shift := staff_service.get_shift(shift_id) if not shift_result.is_success() or hired_staff == null or hired_shift == null \ or hired_staff.daily_wage != int(canonical[&"daily_wage"]) \ - or hired_staff.get_employment_kind_name() != &"FREE_HIRE" \ + or hired_staff.get_employment_kind_name() != StringName(canonical[&"options"][&"employment_kind"]) \ or not path_service.has_actor_projection(path_actor_id) \ or path_service.get_actor_projected_cell(path_actor_id) != initial_cell \ or not _movement_credits.has(path_actor_id): @@ -6008,7 +6336,7 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: &"candidate_id": candidate_id, &"role_id": StringName(canonical[&"role_id"]), &"daily_wage": int(canonical[&"daily_wage"]), - &"employment_kind": &"FREE_HIRE", + &"employment_kind": hired_staff.get_employment_kind_name(), &"signing_cost": signing_cost, &"shift_id": shift_id, &"preferred_shift": preferred_shift, @@ -6019,7 +6347,7 @@ func _execute_hire_staff_candidate(command: SimulationCommand) -> CommandResult: &"staff_id": candidate_id, &"role_id": StringName(canonical[&"role_id"]), &"daily_wage": int(canonical[&"daily_wage"]), - &"employment_kind": &"FREE_HIRE", + &"employment_kind": hired_staff.get_employment_kind_name(), &"signing_cost": signing_cost, &"shift_id": shift_id, &"player_cash": economy_service.get_balance(PLAYER_CASH_ACCOUNT), @@ -6085,14 +6413,15 @@ func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandRe return _staff_assignment_failure(command.command_id, &"STAFF_NOT_FOUND", "Staff was not found.") if template_id not in [&"DAY", &"NIGHT"]: return _staff_assignment_failure(command.command_id, &"INVALID_TEMPLATE", "Choose DAY or NIGHT.") - if not _defense_lock_owner(staff_id, &"DEFENDER").is_empty(): - return _staff_assignment_failure(command.command_id, &"DEFENSE_LOCKED", "Staff is participating in a defense operation.") - if event_service.get_actor_locks().has(staff_id): - return _staff_assignment_failure(command.command_id, &"EVENT_LOCKED", "Staff is participating in an event.") - if not staff.current_task_id.is_empty(): - return _staff_assignment_failure(command.command_id, &"STAFF_BUSY", "Finish the current task before changing assignment.") - if not staff.can_take_shift() or staff.state not in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE]: - return _staff_assignment_failure(command.command_id, &"STAFF_UNAVAILABLE", "Staff cannot change assignment in the current state.") + var block_reason := _staff_assignment_block_reason(staff) + if not block_reason.is_empty(): + var messages := { + &"DEFENSE_LOCKED": "Staff is participating in a defense operation.", + &"EVENT_LOCKED": "Staff is participating in an event.", + &"STAFF_BUSY": "Finish the current task before changing assignment.", + &"STAFF_UNAVAILABLE": "Staff cannot change assignment in the current state.", + } + return _staff_assignment_failure(command.command_id, block_reason, messages[block_reason]) if not facility_id.is_empty(): var facility := facility_service.get_instance(facility_id) if facility == null: @@ -6100,7 +6429,7 @@ func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandRe var definition := definition_registry.get_facility(facility.definition_id) if definition == null or definition.staff_max <= 0: return _staff_assignment_failure(command.command_id, &"FACILITY_UNSTAFFED", "This facility does not accept Staff assignments.") - if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles: + if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles and not _is_anri_manager(staff): return _staff_assignment_failure(command.command_id, &"ROLE_MISMATCH", "Staff role does not match this facility.") if _staff_assignment_count(facility_id, template_id, staff_id) >= definition.staff_max: return _staff_assignment_failure(command.command_id, &"FACILITY_FULL", "This shift has reached the facility Staff limit.") @@ -6120,6 +6449,19 @@ func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandRe return result +## Shared by the command and presentation; always derived from live state. +func _staff_assignment_block_reason(staff: StaffRuntime) -> StringName: + if not _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty(): + return &"DEFENSE_LOCKED" + if event_service.get_actor_locks().has(staff.staff_id): + return &"EVENT_LOCKED" + if not staff.current_task_id.is_empty(): + return &"STAFF_BUSY" + if not staff.can_take_shift() or staff.state not in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE]: + return &"STAFF_UNAVAILABLE" + return &"" + + func _staff_assignment_failure(command_id: int, reason: StringName, message: String) -> CommandResult: return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command_id, message, {&"reason": reason}) @@ -6171,6 +6513,8 @@ func _execute_create_staff_task(command: SimulationCommand) -> CommandResult: var priority := int(command.payload.get(&"priority", command.payload.get("priority", 50))) var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", ""))) var raw_options: Variant = command.payload.get(&"options", command.payload.get("options", {})) + if task_type == CleaningBridge.TASK_TYPE: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Cleaning jobs are scheduled from actual facility dirt.") if task_type.is_empty() or not raw_options is Dictionary: return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Task type and options are required.") var result := staff_service.create_task(task_type, priority, _runtime_event_minute(), raw_options, task_id) @@ -6185,6 +6529,9 @@ func _execute_begin_staff_task(command: SimulationCommand) -> CommandResult: func _execute_progress_staff_task(command: SimulationCommand) -> CommandResult: var task_id := StringName(command.payload.get(&"task_id", command.payload.get("task_id", ""))) + var task := staff_service.get_task(task_id) + if task != null and task.task_type == CleaningBridge.TASK_TYPE: + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Cleaning work advances only at its physical destination with game time.") var minutes := int(command.payload.get(&"minutes", command.payload.get("minutes", 1))) var result := staff_service.progress_task(task_id, minutes, _runtime_event_minute()) result.command_id = command.command_id @@ -6469,10 +6816,81 @@ func _execute_depart_authority_inspection(command: SimulationCommand) -> Command result.command_id = command.command_id return result +## Existing Defense commands remain authoritative. This report only explains +## why a live employee or target can be registered; no balance data is invented. +func get_defense_management_report(selected_raid_id: StringName = &"") -> Dictionary: + var raids: Array[Dictionary] = defense_service.get_management_rows() + var selected: Dictionary = {} + for row: Dictionary in raids: + if row[&"raid_id"] == selected_raid_id: + selected = row + break + if selected.is_empty() and not raids.is_empty(): + selected = raids[0] + for row: Dictionary in raids: + if int(row[&"state"]) not in DefenseService.TERMINAL_STATES: + selected = row + break + var raid_id := StringName(selected.get(&"raid_id", &"")) + var detail := defense_service.get_management_detail(raid_id) + var defenders: Array = detail.get(&"defenders", []) + var assets: Array = detail.get(&"assets", []) + var defender_owners: Dictionary = {} + var asset_owners: Dictionary = {} + for row: Dictionary in raids: + if int(row[&"state"]) in DefenseService.TERMINAL_STATES: continue + for actor_id: StringName in row[&"defender_ids"]: defender_owners[actor_id] = row[&"raid_id"] + for asset_id: StringName in row[&"asset_ids"]: asset_owners[asset_id] = row[&"raid_id"] + var preparing := int(selected.get(&"state", -1)) == DefenseService.RaidState.PREPARING + var staff_rows: Array[Dictionary] = [] + for staff: StaffRuntime in staff_service.get_staff_members(): + var entry := mvp_content_catalog.get_recruitment_entry(staff.staff_id) + var registered := false + for actor: Dictionary in defenders: + registered = registered or actor[&"actor_id"] == staff.staff_id + var reason := "" + if not preparing: reason = "準備中だけ参加者を追加できます。" + elif registered: reason = "この防衛戦に参加済みです。" + elif defender_owners.has(staff.staff_id) and defender_owners[staff.staff_id] != raid_id: reason = "別の防衛戦に参加しています。" + elif not staff.can_take_shift(): reason = "現在の体調や状態では参加できません。" + elif not staff.current_task_id.is_empty(): reason = "担当中の業務が完了するまで参加できません。" + staff_rows.append({ + &"staff_id": staff.staff_id, &"display_name": String(entry.get(&"display_name", staff.staff_id)), + &"combat": int(staff.skills.get(&"combat", 0)), &"health": staff.health, + &"fatigue": staff.fatigue, &"registered": registered, + &"can_register": reason.is_empty(), &"reason": reason, + }) + var objective_rows: Array[Dictionary] = [] + for objective_id: StringName in selected.get(&"objective_ids", []): + var facility := facility_service.get_instance(objective_id) + var definition: Variant = definition_registry.get_facility(facility.definition_id) if facility != null else null + var registered := false + for asset: Dictionary in assets: + registered = registered or asset[&"asset_id"] == objective_id + var reason := "" + if not preparing: reason = "準備中だけ防衛対象を登録できます。" + elif registered: reason = "登録済みです。" + elif facility == null: reason = "指定された施設が見つかりません。" + elif facility.durability <= 0: reason = "破壊された施設は登録できません。" + elif not facility.queue_entries.is_empty() or not facility.active_services.is_empty(): reason = "利用中または待機中の顧客がいます。" + elif asset_owners.has(objective_id) and asset_owners[objective_id] != raid_id: reason = "別の防衛戦の対象です。" + objective_rows.append({ + &"facility_id": objective_id, &"display_name": String(definition.display_name) if definition != null else String(objective_id), + &"durability": facility.durability if facility != null else 0, + &"registered": registered, &"can_register": reason.is_empty(), &"reason": reason, + }) + return {&"raids": raids, &"selected": selected, &"staff": staff_rows, + &"objectives": objective_rows, &"defenders": defenders, &"assets": assets, + &"can_control": not (_operations_enabled and operations_coordinator != null and operations_coordinator.is_terminal())} + + func _execute_create_defense_raid(command: SimulationCommand) -> CommandResult: var raw_definition: Variant = command.payload.get(&"definition", command.payload.get("definition", null)) if not raw_definition is Dictionary: return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Defense Raid definition must be a Dictionary.") + var event_source_value: Variant = raw_definition.get(&"event_source", raw_definition.get("event_source", {})) + if not event_source_value is Dictionary or not event_source_value.is_empty(): + return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Event threat provenance is reserved to the configured Event boundary.") var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) var id_before := id_generator.create_snapshot() if raid_id.is_empty(): @@ -6614,18 +7032,68 @@ func _execute_queue_defense_skill(command: SimulationCommand) -> CommandResult: func _execute_resolve_defense_round(command: SimulationCommand) -> CommandResult: var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) var resolution_id := StringName(command.payload.get(&"resolution_id", command.payload.get("resolution_id", ""))) - if not _validate_live_defense_crosslinks(): - return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Defense cross-domain links are invalid before round resolution.") - var id_before := id_generator.create_snapshot() - if resolution_id.is_empty(): - resolution_id = id_generator.next_id(&"defense_round_resolution") - var result := defense_service.resolve_next_round(raid_id, resolution_id, _defense_operation_minute()) - if not result.is_success(): - id_generator.restore_snapshot(id_before) + var result := _resolve_defense_round_internal(raid_id, resolution_id, _defense_operation_minute()) result.command_id = command.command_id result.target_id = raid_id return result + +## Both the explicit command and ordinary clock progression use the same +## transaction, generated-ID namespace and cross-domain callback boundaries. +func _resolve_defense_round_internal( + raid_id: StringName, + resolution_id: StringName, + absolute_minute: int +) -> CommandResult: + _prepare_defense_round_attempts(absolute_minute) + var already_attempted := _defense_round_attempted_raids.has(raid_id) + _defense_round_attempted_raids[raid_id] = true + var previous_command_minute := _active_command_minute + _active_command_minute = absolute_minute + if not _validate_live_defense_crosslinks(): + _active_command_minute = previous_command_minute + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Defense cross-domain links are invalid before round resolution.") + var id_before := id_generator.create_snapshot() + if resolution_id.is_empty(): + # Existing explicit IDs can occupy this namespace without advancing the + # allocator. Skip those IDs instead of replaying history as a new attack. + while id_generator.get_counter(&"defense_round_resolution") < StableIdGenerator.MAX_COUNTER: + resolution_id = id_generator.next_id(&"defense_round_resolution") + if not defense_service.has_resolution(raid_id, resolution_id): + break + if resolution_id.is_empty() or defense_service.has_resolution(raid_id, resolution_id): + id_generator.restore_snapshot(id_before) + _active_command_minute = previous_command_minute + var exhausted := CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Defense round ID namespace is exhausted.") + exhausted.target_id = raid_id + return exhausted + var result := defense_service.resolve_next_round(raid_id, resolution_id, absolute_minute) + # Reading an old canonical result is not a new round attempt. Restore only + # the guard introduced by this replay; an earlier real failure in this same + # minute must still prevent automatic progression from hiding that failure. + if result.is_replay and not already_attempted: + _defense_round_attempted_raids.erase(raid_id) + if not result.is_success(): + id_generator.restore_snapshot(id_before) + _active_command_minute = previous_command_minute + result.target_id = raid_id + return result + + +func _prepare_defense_round_attempts(absolute_minute: int) -> void: + if _defense_round_attempt_minute == absolute_minute: + return + _defense_round_attempt_minute = absolute_minute + _defense_round_attempted_raids.clear() + _defense_automatic_failed_minute = -1 + + +func _reset_defense_round_attempts() -> void: + _defense_round_attempt_minute = -1 + _defense_round_attempted_raids.clear() + _defense_automatic_failed_minute = -1 + + func _execute_begin_next_defense_wave(command: SimulationCommand) -> CommandResult: var raid_id := StringName(command.payload.get(&"raid_id", command.payload.get("raid_id", command.target_id))) if not _validate_live_defense_crosslinks(): @@ -6691,7 +7159,7 @@ func _execute_create_service_request(command: SimulationCommand) -> CommandResul return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "Service request options must be a Dictionary.") var options: Dictionary = raw_options.duplicate(true) var quoted_price := maxi( - int(options.get(&"quoted_price", options.get("quoted_price", FacilityPricing.quote(_business_base_price(instance), instance.policy_current)))), + int(options.get(&"quoted_price", options.get("quoted_price", get_facility_business_price(instance)))), 0 ) # Freeze the same price used by candidate selection and the business panel. @@ -7080,6 +7548,8 @@ func _capture_infrastructure_demolition_state() -> Dictionary: return { &"game_state": _state.create_snapshot(), &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"world": world_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), @@ -7090,6 +7560,7 @@ func _capture_infrastructure_demolition_state() -> Dictionary: &"route_projection": route_subslot_path_adapter.create_snapshot(), &"facility_infrastructure": facility_infrastructure_projector.create_snapshot(), &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), &"mana": mana_network_service.create_snapshot(), &"movement_credits": _movement_credits.duplicate(true), &"domain_events": domain_events.create_snapshot(), @@ -7101,7 +7572,7 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: for key: StringName in [ &"game_state", &"economy", &"world", &"path", &"elevators", &"legacy_stairs", &"construction", &"excavation", &"spatial_subslots", &"route_projection", - &"facility_infrastructure", &"facilities", &"mana", &"movement_credits", + &"facility_infrastructure", &"facilities", &"facility_cleanliness", &"mana", &"movement_credits", &"domain_events", &"id_generator", ]: if not snapshot.get(key, null) is Dictionary: @@ -7125,6 +7596,7 @@ func _restore_infrastructure_demolition_state(snapshot: Dictionary) -> bool: restored = construction_service.restore_snapshot(snapshot[&"construction"]) and restored restored = excavation_service.restore_snapshot(snapshot[&"excavation"]) and restored restored = facility_service.restore_snapshot(snapshot[&"facilities"]) and restored + restored = facility_cleanliness.restore_snapshot(snapshot.get(&"facility_cleanliness", {})) and restored restored = mana_network_service.restore_snapshot(snapshot[&"mana"]) and restored _movement_credits = (snapshot[&"movement_credits"] as Dictionary).duplicate(true) restored = domain_events.restore_snapshot(snapshot[&"domain_events"]) and restored @@ -7155,6 +7627,7 @@ func _infrastructure_demolition_state_matches(snapshot: Dictionary) -> bool: and _strict_variants_equal(route_subslot_path_adapter.create_snapshot(), snapshot.get(&"route_projection", {})) \ and _strict_variants_equal(facility_infrastructure_projector.create_snapshot(), snapshot.get(&"facility_infrastructure", {})) \ and _strict_variants_equal(facility_service.create_snapshot(), snapshot.get(&"facilities", {})) \ + and _strict_variants_equal(facility_cleanliness.create_snapshot(), snapshot.get(&"facility_cleanliness", {})) \ and _strict_variants_equal(mana_network_service.create_snapshot(), snapshot.get(&"mana", {})) \ and _strict_variants_equal(_movement_credits, snapshot.get(&"movement_credits", {})) \ and _strict_variants_equal(domain_events.create_snapshot(), snapshot.get(&"domain_events", {})) \ @@ -7286,6 +7759,8 @@ func _operations_construction_investment_gate(command_id: int) -> CommandResult: if not _operations_enabled or operations_coordinator == null: return null var gate := operations_coordinator.can_start_construction_or_investment() + if bool(gate.get(&"allowed", false)) and _operations_total_arrears() > 0: + gate = {&"allowed": false, &"reason": &"UNPAID_EXPENSES", &"arrears_total": _operations_total_arrears()} if bool(gate.get(&"allowed", false)): return null return CommandResult.failed( @@ -7708,7 +8183,8 @@ func _advance_inventory_and_logistics(absolute_minute: int) -> void: return inventory_service.current_minute = absolute_minute inventory_service.process_expired(absolute_minute, &"inventory.waste") - supply_management.ensure_deliveries(self, absolute_minute) + if int(_state.get_resource(&"mana")) > 0: + supply_management.ensure_deliveries(self, absolute_minute) supply_logistics.tick(absolute_minute) func _advance_facilities(absolute_minute: int) -> void: @@ -7809,14 +8285,40 @@ func _advance_authority_incidents(absolute_minute: int) -> void: func _advance_defense(absolute_minute: int) -> void: if _operations_tick_blocked: return - var advanced := defense_service.advance_time(absolute_minute) - if advanced.is_success(): + _prepare_defense_round_attempts(absolute_minute) + # A deferred operations suffix must not reattempt the same failed automatic + # batch. Resume on a later game minute, after the player resumes the clock. + if _defense_automatic_failed_minute == absolute_minute: return - _clock.set_paused(true) - _publish_actor_event(&"DEFENSE_ADVANCE_FAILED", &"defense_service", [], { - &"message": advanced.message, - &"details": advanced.details.duplicate(true), - }) + var advanced := defense_service.advance_time(absolute_minute) + if not advanced.is_success(): + _defense_automatic_failed_minute = absolute_minute + _clock.set_paused(true) + _publish_actor_event(&"DEFENSE_ADVANCE_FAILED", &"defense_service", [], { + &"message": advanced.message, + &"details": advanced.details.duplicate(true), + }) + return + # One attempt per due raid, in stable raid-ID order. No catch-up while-loop, + # invented deadline, offline replay, PREPARING start or BETWEEN_WAVES advance. + for due: Dictionary in defense_service.get_due_rounds(absolute_minute): + if _defense_automatic_failed_minute == absolute_minute: + return + var raid_id := StringName(due[&"raid_id"]) + if _defense_round_attempted_raids.has(raid_id): + continue + var resolved := _resolve_defense_round_internal(raid_id, &"", absolute_minute) + if not resolved.is_success(): + _defense_automatic_failed_minute = absolute_minute + _clock.set_paused(true) + _publish_actor_event(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"defense_service", [raid_id], { + &"round_number": due[&"round_number"], + &"earliest_minute": due[&"earliest_minute"], + &"message": resolved.message, + &"details": resolved.details.duplicate(true), + }) + return + func _advance_relationship_events(absolute_minute: int) -> void: if _operations_tick_blocked: @@ -7866,25 +8368,81 @@ func _advance_staff_ai(absolute_minute: int) -> void: return _prune_expired_staff_elevator_waits(absolute_minute) _advance_elevators(absolute_minute) + _advance_staff_rescue(absolute_minute) staff_service.advance_one_minute(absolute_minute) _prune_expired_staff_elevator_waits(absolute_minute) staff_commute.tick(absolute_minute) + _sync_facility_staffing(absolute_minute) + cleaning_bridge.tick(absolute_minute) for task: StaffTask in staff_service.get_tasks(): if task.state == StaffTask.State.RESERVED: if task.state_changed_at < absolute_minute: task.begin_moving(task.state_changed_at) - elif task.state == StaffTask.State.WORKING: - staff_service.progress_task(task.task_id, 1, absolute_minute) + elif task.state == StaffTask.State.WORKING and task.task_type == CleaningBridge.TASK_TYPE: + # Only cleaning is deferred from StaffService to these attendance and + # dependency checks. Other tasks already progressed once this minute. + if task.state_changed_at >= absolute_minute or not cleaning_bridge.task_can_work(task, staff_service.get_staff(task.assigned_staff_id)): + continue + var work_units := CleaningBridge.work_units_per_minute(staff_service.get_staff(task.assigned_staff_id)) + staff_service.progress_task(task.task_id, work_units, absolute_minute) for staff: StaffRuntime in staff_service.get_staff_members(): if absolute_minute % 30 != 0: continue - if staff.state == StaffRuntime.State.WORKING: + if staff.state == StaffRuntime.State.WORKING or (staff.state == StaffRuntime.State.AVAILABLE and staff_commute.is_present(staff, facility_service.get_instance(staff.assigned_facility_id))): staff.add_fatigue(1) + if _operations_enabled: + staff.health = maxi(staff.health - 1, 0) elif staff.state == StaffRuntime.State.OFF_DUTY: - staff.add_fatigue(-1) + var resting := get_staff_rest_report(staff.staff_id) + if not _operations_enabled or resting.get(&"resting", false): + staff.add_fatigue(-1) + if _operations_enabled and staff.health > 0: + staff.health = mini(staff.health + 1, 100) + _advance_staff_rescue(absolute_minute) _sync_facility_staffing(absolute_minute) _advance_guest_ai_decisions(absolute_minute) +## Record the actual completed choice, not a fresh prediction made by the UI. +func _decide_guest_with_log(guest_id: StringName, candidates: Array, minute: int, context: Dictionary) -> Dictionary: + var observation := guest_service.get_observation(guest_id) + var result := guest_service.decide(guest_id, candidates, minute, context) + if not bool(result.get(&"ok", false)): + return result + var candidate: Dictionary = result.get(&"candidate", {}) + var rejections := guest_service.get_last_candidate_rejections(guest_id) + var reason_counts: Dictionary = {} + for rejection: Dictionary in rejections: + var reason := String(rejection.get(&"reason", "")) + reason_counts[reason] = int(reason_counts.get(reason, 0)) + 1 + var payload := { + &"rejection_counts": reason_counts, + &"rejection_examples": rejections.slice(0, 8), + &"rejection_count": rejections.size(), + &"action": result[&"action"], + &"needs": observation.get(&"needs", {}).duplicate(true), + &"primary_needs": observation.get(&"primary_needs", []).duplicate(), + &"facility_id": candidate.get(&"facility_id", &""), + &"need_priority": candidate.get(&"need_priority", 0), + &"utility": candidate.get(&"utility", 0), + &"travel_minutes": candidate.get(&"travel_minutes", 0), + &"wait_minutes": candidate.get(&"utility_terms", {}).get(&"wait_minutes", 0), + &"estimated_total_gold": maxi(int(candidate.get(&"estimated_total_gold", candidate.get(&"price_gold", candidate.get(&"quoted_price", 0)))), 0), + } + _publish_actor_event(&"GUEST_DECISION_RECORDED", &"guest_service", [guest_id], payload) + return result + + +func get_guest_decision_record(guest_id: StringName) -> Dictionary: + var events := domain_events.recent_events() + for index in range(events.size() - 1, -1, -1): + var event: SimulationDomainEvent = events[index] + if event.event_type == &"GUEST_DECISION_RECORDED" and guest_id in event.entity_ids: + var report := event.payload.duplicate(true) + report[&"absolute_minute"] = event.absolute_minute + return report + return {} + + func _advance_guest_ai_decisions(absolute_minute: int) -> void: for guest: GuestRuntime in guest_service.get_guests(): match guest.state: @@ -7897,7 +8455,7 @@ func _advance_guest_ai_decisions(absolute_minute: int) -> void: guest_service.begin_leaving(guest.guest_id, absolute_minute, &"PLANNED_EXIT") continue guest.wallet_gold = economy_service.get_balance(_guest_wallet_account(guest.guest_id)) - guest_service.decide( + _decide_guest_with_log( guest.guest_id, _project_guest_candidates(guest.guest_id), absolute_minute, @@ -7960,6 +8518,13 @@ func _advance_runtime_path_traffic(absolute_minute: int) -> void: # The commute driver already consumed this tick, including fractional # walking credit under congestion. Do not reset it as an idle task. continue + if _operations_enabled and staff.state == StaffRuntime.State.OFF_DUTY and staff.current_task_id.is_empty(): + var rest := get_staff_rest_report(staff.staff_id) + if rest.get(&"reachable", false): + advance_staff_commute(staff.staff_id, rest[&"cell"], absolute_minute) + else: + _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) + continue var task := staff_service.get_task(staff.current_task_id) if task != null and task.state == StaffTask.State.MOVING \ and task.assigned_staff_id == staff.staff_id \ @@ -8155,6 +8720,9 @@ func _advance_staff_task_travel( var staff := staff_service.get_staff(task.assigned_staff_id) if staff == null or staff.current_task_id != task.task_id: return + if task.task_type == CleaningBridge.TASK_TYPE and not cleaning_bridge.prepare_route(task, staff): + _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) + return if staff.current_cell == task.target_cell: _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) staff_service.begin_task(task.task_id, absolute_minute) @@ -8165,11 +8733,26 @@ func _advance_staff_task_travel( [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"] ) + if task.task_type in [&"rescue_pickup", &"rescue_transport"]: + path = path_service.find_backyard_path(staff.current_cell, task.target_cell, true) if path.size() <= 1: + if task.task_type in [&"rescue_pickup", &"rescue_transport"]: + _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) + return _record_blocked_actor_wait( actor_id, staff.staff_id, &"staff", staff.current_cell, absolute_minute, 0 ) return + if task.task_type == &"rescue_transport" and elevators.owns_connector(path_service.get_step_connector(path[0], path[1], [&"staff"])): + var patient_actor := _staff_path_actor_id(task.target_entity_id) + var connector := path_service.get_step_connector(path[0], path[1], [&"staff"]) + var pair := {} + if not _elevator_extension_pending(elevators.get_shaft_for_connector(connector)): + pair = elevators.request_pair(actor_id, patient_actor, path[0], path[1], absolute_minute, [&"staff"]) + if pair.get(&"ok", false) and not pair.get(&"replay", false): + _publish_actor_event(&"ELEVATOR_RESCUE_REQUESTED", &"elevators", [actor_id, patient_actor], pair[&"trip"]) + _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) + return if _request_elevator_step(actor_id, path[0], path[1], absolute_minute, [&"staff"]): return var required_credit := _step_movement_credit(path_service, path[0], path[1], [&"staff"]) @@ -8193,6 +8776,14 @@ func _advance_staff_task_travel( staff.current_cell = previous_cell _report_path_integration_failure(actor_id, staff.staff_id, &"staff", previous_cell) return + if task.task_type == &"rescue_transport": + var patient := staff_service.get_staff(task.target_entity_id) + if patient == null or not path_service.project_actor_position(_staff_path_actor_id(patient.staff_id), staff.current_cell, absolute_minute): + staff.current_cell = previous_cell + path_service.project_actor_position(actor_id, previous_cell, absolute_minute) + return + patient.current_cell = staff.current_cell + _record_expected_actor_wait(_staff_path_actor_id(patient.staff_id), patient.current_cell, absolute_minute) var remaining_credit := accumulated - required_credit _movement_credits[actor_id] = { &"credit": remaining_credit, @@ -8362,6 +8953,12 @@ func _release_staff_task_items( return {&"ok": true} func _estimate_staff_task_travel(staff: StaffRuntime, task: StaffTask, _absolute_minute: int) -> int: + if task.task_type == CleaningBridge.TASK_TYPE and not cleaning_bridge.can_dispatch(staff): return -1 + if task.task_type in [&"rescue_pickup", &"rescue_transport"]: + if not elevators.get_actor_trip(_staff_path_actor_id(staff.staff_id)).is_empty(): + return -1 + var route := path_service.find_backyard_path(staff.current_cell, task.target_cell, true) + return _estimated_actor_path_minutes(route, [&"staff"]) if not route.is_empty() else -1 if staff.current_cell == task.target_cell: return 0 var path := path_service.find_path( @@ -8381,7 +8978,7 @@ func _sync_facility_staffing(absolute_minute: int) -> void: for facility: FacilityInstance in facility_service.get_instances(): var active_ids: Array[StringName] = [] for staff: StaffRuntime in staff_members: - if staff_commute.is_present(staff, facility): + if (staff_commute.is_present(staff, facility) or CleaningBridge.is_deployed(staff, facility, staff_service)) and not _is_anri_manager(staff): active_ids.append(staff.staff_id) facility_service.set_operational_status( facility.instance_id, @@ -8396,7 +8993,7 @@ func _project_guest_candidates(guest_id: StringName) -> Array: return candidates for facility: FacilityInstance in facility_service.get_instances(): var definition := definition_registry.get_facility(facility.definition_id) - if definition == null: + if definition == null or &"staff_only" in definition.tags: continue if &"entrance" in definition.tags and _guest_has_checked_in(guest): continue @@ -8406,6 +9003,9 @@ func _project_guest_candidates(guest_id: StringName) -> Array: has_inputs = false break if not has_inputs: + # Preserve the existing exclusion without doing unnecessary path queries, + # but let the decision evidence explain why this facility was unavailable. + candidates.append({&"facility_id": facility.instance_id, &"inputs_available": false}) continue var access := _find_facility_access_path(guest.current_cell, facility) var path: Array = access.get(&"path", []) @@ -8417,7 +9017,7 @@ func _project_guest_candidates(guest_id: StringName) -> Array: &"reachable": bool(access.get(&"reachable", false)), &"travel_minutes": _estimated_actor_path_minutes(path, []), &"expected_wait": facility.queue_entries.size() * facility.base_service_minutes / maxi(facility.capacity, 1), - &"estimated_total_gold": FacilityPricing.quote(_business_base_price(facility), facility.policy_current), + &"estimated_total_gold": get_facility_business_price(facility), &"expected_quality": clampi(facility.base_quality, 0, 100), &"need_relief": need_relief, &"risk_level": 0, @@ -8431,7 +9031,7 @@ func _project_guest_candidates(guest_id: StringName) -> Array: }) return candidates -func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: +func _facility_access_candidates(facility: FacilityInstance) -> Array: var candidate_cells: Dictionary = {} var occupied: Array[Vector3i] = facility.occupied_cells.duplicate() if occupied.is_empty(): @@ -8449,6 +9049,11 @@ func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> if a.y != b.y: return a.y < b.y return a.x < b.x ) + return ordered + + +func _find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: + var ordered := _facility_access_candidates(facility) var best_path: Array[Vector3i] = [] var best_cell := facility.origin for candidate: Vector3i in ordered: @@ -8594,13 +9199,20 @@ func _apply_authority_resolution_boundary( if phase == &"rollback": if not rollback_token is Dictionary \ or not rollback_token.get(&"economy", null) is Dictionary \ - or not rollback_token.get(&"game_state", null) is Dictionary: + or not rollback_token.get(&"game_state", null) is Dictionary \ + or not rollback_token.get(&"facilities", null) is Dictionary \ + or not rollback_token.get(&"domain_events", null) is Dictionary \ + or not rollback_token.get(&"id_generator", null) is Dictionary: return {&"ok": false, &"error": "Authority rollback token is malformed."} var economy_ok: bool = economy_service.restore_snapshot(rollback_token[&"economy"]) + var facilities_ok: bool = facility_service.restore_snapshot(rollback_token[&"facilities"]) + facilities_ok = facility_cleanliness.restore_snapshot(rollback_token.get(&"facility_cleanliness", {})) and facilities_ok + var events_ok: bool = domain_events.restore_snapshot(rollback_token[&"domain_events"]) + var ids_ok: bool = id_generator.restore_snapshot(rollback_token[&"id_generator"]) var state_ok: bool = _state.restore_snapshot(rollback_token[&"game_state"]) return { - &"ok": economy_ok and state_ok, - &"error": "Authority fine rollback failed." if not economy_ok or not state_ok else "", + &"ok": economy_ok and state_ok and facilities_ok and events_ok and ids_ok, + &"error": "Authority fine rollback failed." if not economy_ok or not state_ok or not facilities_ok or not events_ok or not ids_ok else "", } var resolution_id := StringName(payload.get(&"resolution_id", payload.get("resolution_id", ""))) var inspection_id := StringName(payload.get(&"inspection_id", payload.get("inspection_id", ""))) @@ -8621,7 +9233,13 @@ func _apply_authority_resolution_boundary( return {&"ok": false, &"error": "Authority fine idempotency key conflicts with its ledger transaction."} return {&"ok": true, &"token": { &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"game_state": _state.create_snapshot(), + &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), &"already_applied": true, &"transaction_id": existing_transaction, }} @@ -8631,11 +9249,27 @@ func _apply_authority_resolution_boundary( return {&"ok": false, &"error": "PLAYER_CASH cannot fund the Authority fine."} return {&"ok": true, &"token": { &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"game_state": _state.create_snapshot(), + &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), + &"domain_events": domain_events.create_snapshot(), + &"id_generator": id_generator.create_snapshot(), &"already_applied": false, }} if phase != &"apply" or not rollback_token is Dictionary: return {&"ok": false, &"token": rollback_token, &"error": "Unknown Authority resolution phase or token."} + if StringName(payload.get(&"result", &"")) == &"TEMPORARY_CLOSURE": + var targets: Dictionary = {} + for raw_id: Variant in payload.get(&"evidence_ids", []): + var evidence := authority_service.get_evidence(StringName(raw_id)) + if evidence != null and evidence.is_discovered() and not evidence.is_expired(absolute_minute) and facility_service.has_instance(evidence.facility_id): + targets[evidence.facility_id] = true + for target_id: StringName in _sorted_name_keys(targets): + var stopped := facility_service.set_operational_status(target_id, {&"suspension_reason": &"RAIDED"}, absolute_minute) + if not stopped.is_success(): + return {&"ok": false, &"token": rollback_token, &"error": stopped.message} if bool(rollback_token.get(&"already_applied", false)) or fine_amount == 0: return { &"ok": true, @@ -8723,6 +9357,8 @@ func _apply_defense_skill_boundary( if phase == &"validate": var token := { &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"game_state": _state.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"domain_events": domain_events.create_snapshot(), @@ -8882,6 +9518,7 @@ func _apply_defense_outcome_boundary( return {&"ok": true, &"token": { &"staff": staff_service.create_snapshot(), &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), &"domain_events": domain_events.create_snapshot(), &"id_generator": id_generator.create_snapshot(), }} @@ -8990,6 +9627,7 @@ func _restore_defense_outcome_token(token: Variant) -> Dictionary: return {&"ok": false, &"error": "Defense outcome rollback token is malformed."} var staff_ok: bool = staff_service.restore_snapshot(token[&"staff"]) var facilities_ok: bool = facility_service.restore_snapshot(token[&"facilities"]) + facilities_ok = facility_cleanliness.restore_snapshot(token.get(&"facility_cleanliness", {})) and facilities_ok var events_ok: bool = domain_events.restore_snapshot(token[&"domain_events"]) var ids_ok: bool = id_generator.restore_snapshot(token[&"id_generator"]) return { @@ -9007,16 +9645,19 @@ func _execute_event_action_boundary( EventAction.Type.ADD_LEDGER_ENTRY, EventAction.Type.ADD_OR_REMOVE_ITEM, EventAction.Type.CREATE_TASK, + EventAction.Type.CREATE_DEFENSE_RAID, EventAction.Type.SET_FACILITY_STATE, EventAction.Type.TELEPORT_ENTITY_FOR_RECOVERY, ]: return {&"not_handled": true} if phase == &"rollback": return _restore_event_action_token(rollback_token) - var validation := _validate_event_action(action) + var validation := _build_event_defense_request(action, context) if action.type == EventAction.Type.CREATE_DEFENSE_RAID else _validate_event_action(action) if not bool(validation.get(&"ok", false)) or phase == &"validate": return validation var token := _capture_event_action_token() + if action.type == EventAction.Type.CREATE_DEFENSE_RAID: + token[&"defense"] = defense_service.create_snapshot() var result := _apply_event_action(action, context) if not bool(result.get(&"ok", false)): _restore_event_action_token(token) @@ -9076,6 +9717,8 @@ func _validate_event_action(action: EventAction) -> Dictionary: func _apply_event_action(action: EventAction, context: Dictionary) -> Dictionary: var instance_id := StringName(context.get(&"event_instance_id", "")) match action.type: + EventAction.Type.CREATE_DEFENSE_RAID: + return _apply_event_defense_raid(action, context) EventAction.Type.ADD_LEDGER_ENTRY: var from_account := StringName(action.payload.get(&"from_account", action.payload.get("from_account", PLAYER_CASH_ACCOUNT))) var to_account := StringName(action.payload.get(&"to_account", action.payload.get("to_account", OPERATING_EXPENSE_ACCOUNT))) @@ -9158,10 +9801,13 @@ func _apply_event_action(action: EventAction, context: Dictionary) -> Dictionary func _capture_event_action_token() -> Dictionary: return { &"economy": economy_service.create_snapshot(), + &"facility_upkeep": facility_upkeep.create_snapshot(), + &"upkeep_settlement": upkeep_settlement.create_snapshot(), &"game_state": _state.create_snapshot(), &"inventory": inventory_service.create_snapshot(), &"staff": staff_service.create_snapshot(), &"facilities": facility_service.create_snapshot(), + &"facility_cleanliness": facility_cleanliness.create_snapshot(), &"guests": guest_service.create_snapshot(), &"path": path_service.create_snapshot(), &"elevators": elevators.create_snapshot(), @@ -9181,6 +9827,7 @@ func _restore_event_action_token(token: Variant) -> Dictionary: var inventory_ok: bool = inventory_service.restore_snapshot(token.get(&"inventory", {})) var staff_ok: bool = staff_service.restore_snapshot(token.get(&"staff", {})) var facilities_ok: bool = facility_service.restore_snapshot(token.get(&"facilities", {})) + facilities_ok = facility_cleanliness.restore_snapshot(token.get(&"facility_cleanliness", {})) and facilities_ok var guests_ok: bool = guest_service.restore_snapshot(token.get(&"guests", {})) var path_ok: bool = path_service.restore_snapshot(token.get(&"path", {})) path_ok = elevators.restore_snapshot(token.get(&"elevators", {})) and path_ok @@ -9189,10 +9836,15 @@ func _restore_event_action_token(token: Variant) -> Dictionary: var credits_ok := credits_value is Dictionary if credits_ok: _movement_credits = (credits_value as Dictionary).duplicate(true) + var defense_ok := true + if token.has(&"defense"): + # This creates PREPARING incidents only; rollback must not reset another + # raid's pre-existing same-tick automatic/manual attempt guard. + defense_ok = token[&"defense"] is Dictionary and defense_service.restore_snapshot(token[&"defense"]) var events_ok: bool = domain_events.restore_snapshot(token.get(&"domain_events", {})) var ids_ok: bool = id_generator.restore_snapshot(token.get(&"id_generator", {})) var ok: bool = economy_ok and state_ok and inventory_ok and staff_ok \ - and facilities_ok and guests_ok and path_ok and credits_ok and events_ok and ids_ok + and facilities_ok and guests_ok and path_ok and credits_ok and defense_ok and events_ok and ids_ok return {&"ok": ok, &"error": "Event Action rollback failed." if not ok else ""} func _reserve_event_cost(cost: Dictionary, context: Dictionary) -> Dictionary: @@ -9399,10 +10051,17 @@ func _validate_infrastructure_crosslinks( require_minute_alignment := true ) -> bool: if world == null or path == null or construction == null or excavation == null \ - or spatial == null or adapter == null or projector == null \ - or not adapter.is_bound_to(spatial, path) \ - or not bool(spatial.validate_invariants().get(&"ok", false)) \ - or not bool(adapter.validate_invariants().get(&"ok", false)) \ + or spatial == null or adapter == null or projector == null: + return false + # An active projector validates its bound Spatial and RouteAdapter services + # itself. Reuse that composed check only for these exact dependencies. + # Quarantine intentionally suppresses its live dependency check, so retain + # the independent validations on that path (and for a different binding). + var composed_dependencies := projector.is_bound_to(adapter, spatial, path) and not projector.is_quarantined() + if not adapter.is_bound_to(spatial, path) \ + or (not composed_dependencies and ( + not bool(spatial.validate_invariants().get(&"ok", false)) + or not bool(adapter.validate_invariants().get(&"ok", false)))) \ or not bool(projector.validate_invariants().get(&"ok", false)) \ or not bool(excavation.validate_invariants().get(&"ok", false)) \ or (require_minute_alignment \ @@ -9413,9 +10072,7 @@ func _validate_infrastructure_crosslinks( or not _same_vector3i_set( adapter.get_projected_route_cells(), spatial.get_route_cells() ) \ - or not _path_snapshot_cells_match_world( - path.create_snapshot(), adapter.get_projected_walkable_cells() - ): + or not path.matches_passable_cells(adapter.get_projected_walkable_cells()): return false var expected_projection_ids: Dictionary = {} for instance: Dictionary in construction.get_facilities(): @@ -9466,7 +10123,8 @@ func _same_vector3i_set(first: Variant, second: Variant) -> bool: for value: Variant in second: if not value is Vector3i or not expected.has(value): return false - return true + expected.erase(value) + return expected.is_empty() func _validate_runtime_crosslinks( @@ -9494,7 +10152,7 @@ func _validate_runtime_crosslinks( var assigned_staff := staff.get_staff(StringName(assignment[&"staff_id"])) var definition := definition_registry.get_facility(assigned_facility.definition_id) if assigned_staff == null or definition == null or definition.staff_max <= 0 \ - or (not definition.required_roles.is_empty() and assigned_staff.role_id not in definition.required_roles): + or (not definition.required_roles.is_empty() and assigned_staff.role_id not in definition.required_roles and not _is_anri_manager(assigned_staff)): return false var queued_links: Dictionary = {} var service_links: Dictionary = {} @@ -9504,7 +10162,8 @@ func _validate_runtime_crosslinks( var active_staff := staff.get_staff(staff_id) if active_staff == null \ or active_staff.assigned_facility_id != facility.instance_id \ - or active_staff.state != StaffRuntime.State.AVAILABLE: + or (active_staff.state != StaffRuntime.State.AVAILABLE and not CleaningBridge.is_deployed(active_staff, facility, staff)) \ + or _is_anri_manager(active_staff): return false for request: ServiceRequest in facilities.get_queue(facility.instance_id): if not _reservation_references_are_valid( @@ -9732,21 +10391,13 @@ func _advance_mana(absolute_minute: int) -> void: ) return var report := mana_network_service.advance_one_minute(absolute_minute) - if not bool(report.get(&"evaluated", false)): - return + # Finite reserve is an additional gate, even between network evaluations and + # for zero-demand facilities. The network remains the local supply authority. + preload("res://scripts/mana/mana_power_projection.gd").apply( + facility_service, mana_network_service, int(_state.get_resource(&"mana")), absolute_minute + ) for change: Dictionary in report.get(&"state_changes", []): var consumer_id := StringName(change.get(&"consumer_id", "")) - var facility := facility_service.get_instance(consumer_id) - if facility != null: - var mana_state := int(change.get(&"state", ManaNetworkService.ConsumerState.NO_POWER)) - var degraded_reasons: Array[StringName] = facility.degraded_reasons.duplicate() - degraded_reasons.erase(&"MANA_BROWNOUT") - if mana_state == ManaNetworkService.ConsumerState.DEGRADED: - degraded_reasons.append(&"MANA_BROWNOUT") - facility_service.set_operational_status(consumer_id, { - &"power_available": mana_state in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED], - &"degraded_reasons": degraded_reasons, - }, absolute_minute) _publish_domain_event(SimulationDomainEvent.new( id_generator.next_id(&"domain_event"), &"MANA_STATE_CHANGED", @@ -9893,8 +10544,8 @@ func get_mvp_staff_candidate_payload(candidate_id: StringName) -> Dictionary: ## remains authoritative and the UI never receives a mutable reference to it. func get_mvp_staff_candidate_summaries() -> Array[Dictionary]: var summaries: Array[Dictionary] = [] - for candidate_id: StringName in mvp_content_catalog.get_staff_candidate_ids(): - var entry := mvp_content_catalog.get_entry(&"staff_candidates", candidate_id) + for candidate_id: StringName in mvp_content_catalog.get_recruitable_staff_ids(): + var entry := mvp_content_catalog.get_recruitment_entry(candidate_id) var payload := mvp_content_catalog.staff_hire_payload(candidate_id) if entry.is_empty() or payload.is_empty(): continue @@ -9912,6 +10563,7 @@ func get_mvp_staff_candidate_summaries() -> Array[Dictionary]: &"preferred_shift": StringName(payload.get(&"preferred_shift", &"any")), &"skills": skills.duplicate(true), &"hired": _hired_staff_candidate_ids.has(candidate_id), + &"unique": candidate_id == &"heroine_anri", }) return summaries @@ -9921,7 +10573,7 @@ func get_mvp_staff_candidate_summaries() -> Array[Dictionary]: func get_staff_management_report() -> Dictionary: var rows: Array[Dictionary] = [] for staff: StaffRuntime in staff_service.get_staff_members(): - var entry := mvp_content_catalog.get_entry(&"staff_candidates", staff.staff_id) + var entry := mvp_content_catalog.get_recruitment_entry(staff.staff_id) var plan := staff_service.get_work_assignment(staff.staff_id) var shift := staff_service.get_shift(staff.shift_id) var template_id: StringName = &"DAY" @@ -9938,18 +10590,19 @@ func get_staff_management_report() -> Dictionary: effective_from = shift.start_at else: template_id = &"NIGHT" if StringName(entry.get(&"preferred_shift", &"day")) == &"night" else &"DAY" + var block_reason := _staff_assignment_block_reason(staff) rows.append({ &"staff_id": staff.staff_id, &"display_name": String(entry.get(&"display_name", staff.staff_id)), &"role_id": staff.role_id, &"state": staff.state, &"state_name": StaffRuntime.State.keys()[staff.state], &"assigned_facility_id": assigned_facility_id, &"active_facility_id": staff.assigned_facility_id, &"template_id": template_id, &"recurring": not plan.is_empty() and not assigned_facility_id.is_empty(), &"effective_from": effective_from, &"next_start_at": next_start_at, - &"daily_wage": staff.daily_wage, &"fatigue": staff.fatigue, &"unpaid_wages": staff.unpaid_wages, - &"current_task_id": staff.current_task_id, - &"can_edit": staff.can_take_shift() and staff.current_task_id.is_empty() - and staff.state in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE] - and _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty() - and not event_service.get_actor_locks().has(staff.staff_id), + &"daily_wage": staff.get_effective_daily_wage(), &"health": staff.health, &"fatigue": staff.fatigue, &"unpaid_wages": staff.unpaid_wages, + &"current_task_id": staff.current_task_id, &"rescue_status": _staff_rescue_status(staff.staff_id), + &"rest_status": get_staff_rest_report(staff.staff_id).get(&"label", ""), + &"can_edit": block_reason.is_empty(), + &"assignment_block_reason": block_reason, + &"unique_contract": _staff_unique_contract_report(staff), }) var facilities: Array[Dictionary] = [] for facility: FacilityInstance in facility_service.get_instances(): @@ -9964,10 +10617,74 @@ func get_staff_management_report() -> Dictionary: return {&"staff": rows, &"facilities": facilities} +## Original 6.1 / 6.2 presentation, derived from validated canonical content +## and the same planned placement as pricing. This report owns all containers; +## it does not add a hire, consent, growth policy, save field or tick-side effect. +func _staff_unique_contract_report(staff: StaffRuntime) -> Dictionary: + if staff == null or staff.employment_kind != StaffRuntime.EmploymentKind.UNIQUE or not mvp_content_catalog.is_valid(): + return {} + var definition := mvp_content_catalog.get_entry(&"unique_characters", staff.staff_id) + if definition.is_empty() or StringName(definition.get(&"role_id", &"")) != staff.role_id: + return {} + var passive_effects: Array[Dictionary] = [] + var contract := { + &"canonical_id": staff.staff_id, + &"free_employment": true, + &"free_upkeep": staff.get_effective_daily_wage() == 0, + &"permanent_removal_forbidden": true, + &"passive_effects": passive_effects, + } + # Only Anri's explicitly specified effect is presented here. Neither kind + # heroine nor a zero wage grants this effect to another employee. + if not _is_anri_manager(staff): + return contract + var effects: Dictionary = definition.get(&"mechanical_effects", {}) + if not effects.has(&"assigned_floor_revenue_multiplier_bp"): + return contract + var plan := staff_service.get_work_assignment(staff.staff_id) + var facility_id := StringName(plan.get(&"facility_id", &"")) + var facility := facility_service.get_instance(facility_id) + var effect := { + &"effect_id": &"assigned_floor_revenue_multiplier_bp", + &"display_name": "看板娘の微笑", + &"multiplier_bp": int(effects[&"assigned_floor_revenue_multiplier_bp"]), + &"scope": &"ASSIGNED_FLOOR_ALL_LAYERS", + &"applied": facility != null, + &"assigned_facility_id": facility_id, + } + if facility != null: + effect[&"floor_y"] = facility.origin.y + passive_effects.append(effect) + return contract + + func get_hired_staff_candidate_ids() -> Array[StringName]: return _sorted_name_keys(_hired_staff_candidate_ids) +func get_live_progression_report() -> Dictionary: + # Project current cash/facility facts without changing saved progression. + var projected := ProgressionService.new() + var source := progression_service.create_snapshot() + if not projected.restore_snapshot(source): + return {&"snapshot":source, &"evaluation":{&"eligible":false,&"requirements":[],&"reason":&"UNAVAILABLE"}} + var synchronized := projected.update_metrics({ + &"asset_gold":economy_service.get_balance(PLAYER_CASH_ACCOUNT), + &"active_above_ground_facilities":_count_active_facilities(facility_service,false), + &"active_underground_facilities":_count_active_facilities(facility_service,true), + }) + var evaluation := projected.evaluate_rank() + if not synchronized: + evaluation[&"eligible"] = false + evaluation[&"reason"] = &"UNAVAILABLE" + var gate := _operations_construction_investment_gate(0) + if gate != null: + evaluation[&"eligible"] = false + evaluation[&"reason"] = &"OPERATIONS_BLOCKED" + evaluation[&"block_reason"] = gate.details.get(&"reason", &"") + return {&"snapshot":projected.create_snapshot(), &"evaluation":evaluation} + + func get_progression_report(target_rank := -1) -> Dictionary: return { &"snapshot": progression_service.create_snapshot(), @@ -9975,7 +10692,55 @@ func get_progression_report(target_rank := -1) -> Dictionary: } +func _saved_content_manifest_matches(catalog: MvpContentCatalog, data: Dictionary) -> bool: + var expected := _create_mvp_content_manifest(catalog) + var saved: Variant = data.get(&"catalog_manifest", null) + if _strict_variants_equal(expected, saved): + return true + # The sole v1-to-v2 catalog addition is the chapel cleric. Require the + # complete old manifest, not an arbitrary subset of current content. + expected[&"manifest_version"] = 1 + expected[&"staff_candidate_ids"].erase("staff_uma") + if not _strict_variants_equal(expected, saved): + return false + var hired: Variant = data.get(&"hired_staff_candidate_ids", null) + var staff_section: Variant = data.get(&"staff", null) + if not hired is Array or "staff_uma" in hired or not staff_section is Dictionary: + return false + var staff_rows: Variant = staff_section.get(&"staff", null) + if not staff_rows is Array: + return false + for staff: Variant in staff_rows: + if not staff is Dictionary or String(staff.get(&"staff_id", "")) == "staff_uma": + return false + return true + + +func _clear_mvp_content_manifest_cache() -> void: + _mvp_manifest_cache_catalog = null + _mvp_manifest_cache_generation = -1 + _mvp_manifest_cache = {} + + func _create_mvp_content_manifest(catalog: MvpContentCatalog) -> Dictionary: + # Restore validates a temporary catalog through the full original builder. + # A rejected restore must not replace or invalidate the live Runtime cache. + if catalog != mvp_content_catalog: + return _build_mvp_content_manifest(catalog) + var generation := catalog.get_manifest_generation() + if _mvp_manifest_cache_catalog == catalog \ + and _mvp_manifest_cache_generation == generation: + return _mvp_manifest_cache.duplicate(true) + var manifest := _build_mvp_content_manifest(catalog) + _mvp_manifest_cache_catalog = catalog + _mvp_manifest_cache_generation = generation + _mvp_manifest_cache = manifest + # Legacy v1 matching edits its returned manifest, including a nested array. + # The private template must stay owned even on the first cache fill. + return _mvp_manifest_cache.duplicate(true) + + +func _build_mvp_content_manifest(catalog: MvpContentCatalog) -> Dictionary: var guest_definition_ids: Array[String] = [] for definition: GuestDefinition in catalog.build_all_guest_definitions(): guest_definition_ids.append(String(definition.guest_type_id)) @@ -10073,7 +10838,7 @@ func _deserialize_hired_staff_candidate_ids( catalog: MvpContentCatalog ) -> Variant: var allowed: Dictionary = {} - for candidate_id: StringName in catalog.get_staff_candidate_ids(): + for candidate_id: StringName in catalog.get_recruitable_staff_ids(): allowed[candidate_id] = true var result: Dictionary = {} var previous := "" @@ -10095,8 +10860,10 @@ func _validate_catalog_staff_crosslinks( hired_ids: Dictionary, catalog: MvpContentCatalog ) -> bool: + if hired_ids.has(&"heroine_anri") and not staff.has_staff(&"heroine_anri"): + return false var candidate_ids: Dictionary = {} - for candidate_id: StringName in catalog.get_staff_candidate_ids(): + for candidate_id: StringName in catalog.get_recruitable_staff_ids(): candidate_ids[candidate_id] = true for member: StaffRuntime in staff.get_staff_members(): if not candidate_ids.has(member.staff_id): @@ -10107,7 +10874,7 @@ func _validate_catalog_staff_crosslinks( if canonical.is_empty() \ or member.role_id != StringName(canonical[&"role_id"]) \ or member.daily_wage != int(canonical[&"daily_wage"]) \ - or member.get_employment_kind_name() != &"FREE_HIRE" \ + or member.get_employment_kind_name() != StringName(canonical[&"options"][&"employment_kind"]) \ or not _strict_variants_equal( member.permitted_task_tags, (canonical[&"options"] as Dictionary)[&"permitted_task_tags"] @@ -10126,7 +10893,7 @@ func _derive_legacy_hired_staff_candidate_ids( catalog: MvpContentCatalog ) -> Dictionary: var allowed: Dictionary = {} - for candidate_id: StringName in catalog.get_staff_candidate_ids(): + for candidate_id: StringName in catalog.get_recruitable_staff_ids(): allowed[candidate_id] = true var result: Dictionary = {} for member: StaffRuntime in staff.get_staff_members(): @@ -10643,6 +11410,9 @@ func _register_operational_facility(construction_instance: Dictionary) -> void: &"rotation": construction_instance.get(&"rotation", 0), &"occupied_cells": construction_instance.get(&"occupied_cells", []), &"local_inventory_id": StringName("facility.inventory:%s" % instance_id), + &"power_available": int(_state.get_resource(&"mana")) > 0, + &"customer_capacity": definition.customer_capacity, + &"service_attribute": definition.service_attribute, &"capacity": maxi(definition.capacity, 1), &"queue_capacity": definition.queue_capacity, &"base_service_minutes": definition.base_service_minutes, @@ -10654,6 +11424,8 @@ func _register_operational_facility(construction_instance: Dictionary) -> void: }) func _validate_service_start(request: ServiceRequest, _facility: FacilityInstance, absolute_minute: int) -> Dictionary: + if int(_state.get_resource(&"mana")) == 0: + return {&"ok": false, &"message": "The tower has no stored Mana."} for guest_id: StringName in request.guest_ids: var guest := guest_service.get_guest(guest_id) if guest == null: @@ -10753,6 +11525,23 @@ func _complete_service_transaction( economy_service.restore_snapshot(economy_before) _state.restore_snapshot(state_before) return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Facility outputs could not be committed.", output) + var progression_before: Dictionary = {} + if definition != null and definition.faith_per_service > 0: + progression_before = progression_service.create_snapshot() + var next_faith := int(progression_service.metrics[&"faith_lifetime"]) + definition.faith_per_service + if not progression_service.update_metrics({&"faith_lifetime":next_faith}) or not _state.apply_resource_deltas({&"faith":definition.faith_per_service}): + inventory_service.restore_snapshot(inventory_before) + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + progression_service.restore_snapshot(progression_before) + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Service Faith reward could not be committed.") + if definition != null and definition.cleanliness_decay_per_service > 0: + if not facility_cleanliness.record_use(facility.instance_id, service.service_id, definition.cleanliness_decay_per_service): + inventory_service.restore_snapshot(inventory_before) + economy_service.restore_snapshot(economy_before) + _state.restore_snapshot(state_before) + if not progression_before.is_empty(): progression_service.restore_snapshot(progression_before) + return CommandResult.failed(CommandResult.Code.TRANSACTION_APPLY_FAILED, 0, "Facility cleanliness could not be committed.") return true func _on_facility_state_changed(instance_id: StringName, old_state: int, new_state: int, reasons: Array[StringName]) -> void: @@ -10955,6 +11744,18 @@ func _on_staff_task_reopened(task_id: StringName, reason: StringName) -> void: _publish_actor_event(&"STAFF_TASK_REOPENED", &"staff_service", [task_id], {&"reason": reason}) func _on_staff_task_completed(task_id: StringName, staff_id: StringName) -> void: + var task := staff_service.get_task(task_id) + if task != null and task.task_type == CleaningBridge.TASK_TYPE: + if not cleaning_bridge.completed(task, staff_service.get_staff(staff_id)): + _latch_infrastructure_failure(&"CLEANING_COMPLETION_FAILED", _runtime_event_minute(), "Cleaning task effect could not be committed.") + if task != null and task.task_type == &"rescue_transport": + var patient := staff_service.get_staff(task.target_entity_id) + var clinic := facility_service.get_instance(task.rescue_facility_id) + if patient != null and clinic != null and patient.current_cell == task.target_cell and clinic.operation_state == FacilityInstance.OperationState.OPERATING: + patient.health = 100 + patient.fatigue = 0 + patient.transition_to(StaffRuntime.State.OFF_DUTY, &"RESCUE_TREATED", _runtime_event_minute()) + _publish_actor_event(&"STAFF_RESCUED", &"staff_service", [patient.staff_id, staff_id, clinic.instance_id], {}) _publish_actor_event(&"STAFF_TASK_COMPLETED", &"staff_service", [task_id, staff_id], {}) func _on_authority_heat_changed( @@ -11327,8 +12128,10 @@ func _guest_state_index(value: Variant) -> int: func _publish_snapshot(_absolute_minute: int) -> void: latest_snapshot = create_snapshot() - latest_digest = SimulationDigest.compute(latest_snapshot) - snapshot_published.emit(latest_snapshot, latest_digest) + _latest_digest_dirty = true + if not snapshot_published.get_connections().is_empty(): + snapshot_published.emit(latest_snapshot, latest_digest) + state_updated.emit(latest_snapshot) func _execute_purchase_supplies(command: SimulationCommand) -> CommandResult: @@ -11349,13 +12152,43 @@ func preview_supply_purchase(warehouse_id: StringName, item_id: StringName, quan # Existing saves retain each facility's stored base rate; reception never quotes # a new direct sale. Already queued/in-service requests retain their old quote. +func _is_anri_manager(staff: StaffRuntime) -> bool: + return staff.staff_id == &"heroine_anri" and staff.employment_kind == StaffRuntime.EmploymentKind.UNIQUE and staff.role_id == &"innkeeper" + + +func get_floor_price_multiplier(floor_y: int) -> int: + var anri := staff_service.get_staff(&"heroine_anri") + if anri == null or not _is_anri_manager(anri): return 10000 + var assignment := staff_service.get_work_assignment(anri.staff_id) + var facility := facility_service.get_instance(StringName(assignment.get(&"facility_id", &""))) + if facility == null or facility.origin.y != floor_y: return 10000 + var definition := mvp_content_catalog.get_entry(&"unique_characters", anri.staff_id) + return int(definition.get(&"mechanical_effects", {}).get(&"assigned_floor_revenue_multiplier_bp", 10000)) + + +## Original 6.2 / 6.4: Amelia's assigned chapel receives triple donations. +## This is a placement passive; attendance does not remove it. Existing service +## quotes remain frozen by ServiceRequest/ServiceInstance as for Anri. +func get_donation_multiplier(facility: FacilityInstance) -> int: + if facility.definition_id != &"chapel": return 1 + var amelia := staff_service.get_staff(&"heroine_amelia") + if amelia == null or amelia.employment_kind != StaffRuntime.EmploymentKind.UNIQUE or amelia.role_id != &"cleric": return 1 + var assignment := staff_service.get_work_assignment(amelia.staff_id) + return 3 if StringName(assignment.get(&"facility_id", &"")) == facility.instance_id else 1 + + +func get_facility_business_price(facility: FacilityInstance) -> int: + var donation := FacilityPricing.quote(_business_base_price(facility), facility.policy_current) * get_donation_multiplier(facility) + return FacilityPricing.apply_multiplier(donation, get_floor_price_multiplier(facility.origin.y)) + + func _business_base_price(facility: FacilityInstance) -> int: return 0 if facility.definition_id == &"reception" else facility.base_price func get_business_management_report() -> Dictionary: var minute: int = _clock.absolute_minute - var start := maxi(0, int(floor(float(minute - 360) / 1440.0)) * 1440 + 360) + var start := (minute / SimulationKernel.MINUTES_PER_DAY) * SimulationKernel.MINUTES_PER_DAY var totals: Dictionary = {} for entry: LedgerEntry in economy_service.get_entries(): if entry.absolute_minute < start or entry.absolute_minute > minute: @@ -11373,6 +12206,10 @@ func get_business_management_report() -> Dictionary: continue var amounts: Dictionary = totals.get(facility.instance_id, {}) var base := _business_base_price(facility) + var queued_customers: Dictionary = {} + for request: ServiceRequest in facility_service.get_queue(facility.instance_id): + for guest_id: StringName in request.guest_ids: + queued_customers[guest_id] = true rows.append({ &"facility_id": facility.instance_id, &"definition_id": facility.definition_id, @@ -11383,10 +12220,18 @@ func get_business_management_report() -> Dictionary: &"business_open": facility.operator_open, &"price_mode": StringName(facility.policy_current.get(&"price_mode", &"standard")), &"base_price": base, - &"price": FacilityPricing.quote(base, facility.policy_current), + &"price": get_facility_business_price(facility), + &"floor_price_multiplier_bp": get_floor_price_multiplier(facility.origin.y), + &"donation_multiplier": get_donation_multiplier(facility), + &"faith_per_service": definition.faith_per_service, &"can_price": base > 0, &"queued_count": facility.queue_entries.size(), &"active_count": facility.active_services.size(), + &"queued_customer_count": queued_customers.size(), + &"active_customer_count": facility.customer_count(), + &"customer_capacity": facility.customer_capacity, + &"cleanliness": facility_cleanliness.get_facility(facility.instance_id).get("current", -1), + &"cleaning_status": cleaning_bridge.status(facility.instance_id), &"today_revenue": int(amounts.get(&"revenue", 0)), &"today_expenses": int(amounts.get(&"expenses", 0)), }) @@ -11533,7 +12378,7 @@ func _advance_elevators(minute: int) -> void: if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: queued_before[trip[&"request_id"]] = true for shaft: Dictionary in elevators.get_report()[&"shafts"]: - var powered := true + var powered := int(_state.get_resource(&"mana")) > 0 for module_id: StringName in shaft[&"module_ids"]: var state := mana_network_service.get_consumer_state(module_id) if state not in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED]: @@ -11657,6 +12502,7 @@ func _estimated_actor_path_minutes(path: Array, access: Array[StringName]) -> in func _prune_expired_staff_elevator_waits(minute: int) -> void: for trip: Dictionary in elevators.get_report()[&"trips"]: + if trip.has(&"companion_id"): continue var actor_id := StringName(trip[&"actor_id"]) if trip[&"state"] != PathService.ELEVATOR_REQUEST_QUEUED or not String(actor_id).begins_with("staff:"): continue @@ -11664,6 +12510,8 @@ func _prune_expired_staff_elevator_waits(minute: int) -> void: if staff == null: elevators.cancel_actor(actor_id) continue + if staff.state == StaffRuntime.State.OFF_DUTY and get_staff_rest_report(staff.staff_id).get(&"reachable", false): + continue if not staff_service.requires_attendance(staff.staff_id): continue var task := staff_service.get_task(staff.current_task_id) @@ -11763,9 +12611,588 @@ func _stair_demolition_dependents(instance_id: StringName) -> Array[StringName]: ## Use the same entrance and horizontal access cells as ordinary Guest AI. ## Facility access_available is an operating override, not a topology cache. +func is_facility_reachable(facility_id: StringName) -> bool: + var facility := facility_service.get_instance(facility_id) + var entrance: Variant = _operations_entrance_cell() + if facility == null or entrance == null or not facility.access_available: + return false + # Read-only UI queries need existence, not the cheapest destination. + # Share Guest AI's candidates and path rules without caching topology state. + for candidate: Vector3i in _facility_access_candidates(facility): + if not path_service.find_path(entrance, candidate).is_empty(): + return true + return false + + func get_facility_access_report(facility_id: StringName) -> Dictionary: var facility := facility_service.get_instance(facility_id) var entrance: Variant = _operations_entrance_cell() if facility == null or entrance == null or not facility.access_available: return {&"reachable": false, &"path": []} return _find_facility_access_path(entrance, facility) + + +func _staff_is_being_carried(patient_id: StringName) -> bool: + for task: StaffTask in staff_service.get_tasks(): + if task.task_type == &"rescue_transport" and not task.is_terminal() and not task.assigned_staff_id.is_empty() and task.target_entity_id == patient_id: + return true + return false + +func _rescue_destination(patient_cell: Vector3i) -> Dictionary: + var best := {} + var distance := 2147483647 + for clinic: FacilityInstance in facility_service.get_instances(): + if clinic.definition_id != &"infirmary" or clinic.operation_state != FacilityInstance.OperationState.OPERATING: + continue + var cells := {clinic.origin: true} + for cell: Vector3i in clinic.occupied_cells: + for delta in [Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + cells[cell + delta] = true + var ordered := cells.keys() + ordered.sort_custom(func(a: Vector3i, b: Vector3i): return a.y < b.y if a.y != b.y else a.x < b.x) + for cell: Vector3i in ordered: + if cell.z != WorldService.LAYER_BACK: + continue + var route := path_service.find_backyard_path(patient_cell, cell, true) + if not route.is_empty() and route.size() < distance: + distance = route.size() + best = {&"facility_id": clinic.instance_id, &"cell": cell} + return best + +func _handle_rescue_arrival(task: StaffTask, carrier: StaffRuntime, minute: int) -> bool: + if task.task_type == CleaningBridge.TASK_TYPE: + return carrier.current_cell == task.target_cell and cleaning_bridge.task_can_work(task, carrier) + if task.task_type not in [&"rescue_pickup", &"rescue_transport"]: + return true + var patient := staff_service.get_staff(task.target_entity_id) + if patient == null or patient.state != StaffRuntime.State.INCAPACITATED or carrier.current_cell != task.target_cell: + return false + if task.task_type == &"rescue_transport": + var clinic := facility_service.get_instance(task.rescue_facility_id) + return clinic != null and clinic.operation_state == FacilityInstance.OperationState.OPERATING and patient.current_cell == carrier.current_cell + if patient.current_cell != carrier.current_cell: + return false + var destination := _rescue_destination(patient.current_cell) + if destination.is_empty(): + return false + task.task_type = &"rescue_transport" + task.rescue_facility_id = destination[&"facility_id"] + task.target_cell = destination[&"cell"] + task.state = StaffTask.State.MOVING + task.state_changed_at = minute + _publish_actor_event(&"STAFF_RESCUE_PICKED_UP", &"staff_service", [patient.staff_id, carrier.staff_id], {&"facility_id": task.rescue_facility_id}) + return false + +func _advance_staff_rescue(minute: int) -> void: + for task: StaffTask in staff_service.get_tasks(): + if task.task_type not in [&"rescue_pickup", &"rescue_transport"] or task.is_terminal(): + continue + var patient := staff_service.get_staff(task.target_entity_id) + var carrier := staff_service.get_staff(task.assigned_staff_id) + var invalid := patient == null or patient.state != StaffRuntime.State.INCAPACITATED + if not task.assigned_staff_id.is_empty(): + invalid = invalid or carrier == null or not carrier.can_take_shift() or carrier.current_task_id != task.task_id + if task.task_type == &"rescue_transport": + var clinic := facility_service.get_instance(task.rescue_facility_id) + invalid = invalid or carrier == null or clinic == null or clinic.operation_state != FacilityInstance.OperationState.OPERATING + if patient != null and carrier != null: + invalid = invalid or patient.current_cell != carrier.current_cell + if invalid: + if carrier != null and elevators.get_actor_trip(_staff_path_actor_id(carrier.staff_id)).get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED: + continue # Keep the pair aboard until a real or emergency landing. + staff_service.cancel_task(task.task_id, &"RESCUE_INTERRUPTED", minute) + if patient != null: elevators.cancel_actor(_staff_path_actor_id(patient.staff_id)) + if carrier != null: elevators.cancel_actor(_staff_path_actor_id(carrier.staff_id)) + for patient: StaffRuntime in staff_service.get_staff_members(): + if not _defense_lock_owner(patient.staff_id, &"DEFENDER").is_empty(): + continue + if patient.health > 0 and patient.state != StaffRuntime.State.INCAPACITATED: + continue + if not patient.current_task_id.is_empty(): + staff_service.cancel_task(patient.current_task_id, &"STAFF_COLLAPSED", minute) + if patient.state != StaffRuntime.State.INCAPACITATED or patient.state_reason.is_empty(): + patient.transition_to(StaffRuntime.State.INCAPACITATED, &"STAFF_COLLAPSED", minute) + _publish_actor_event(&"STAFF_COLLAPSED", &"staff_service", [patient.staff_id], {&"cell": patient.current_cell}) + if patient.current_cell.z == WorldService.LAYER_FRONT and patient.state_reason != &"COLLAPSE_WITNESSED": + for guest: GuestRuntime in guest_service.get_guests(): + if _authority_line_of_sight(guest.current_cell, patient.current_cell, {}): + if progression_service.update_metrics({&"reputation": maxi(int(progression_service.metrics[&"reputation"]) - 1, 0)}): + patient.state_reason = &"COLLAPSE_WITNESSED" + _publish_actor_event(&"STAFF_COLLAPSE_WITNESSED", &"staff_service", [patient.staff_id, guest.guest_id], {}) + break + var pending := false + for task: StaffTask in staff_service.get_tasks(): + if task.task_type in [&"rescue_pickup", &"rescue_transport"] and task.target_entity_id == patient.staff_id and not task.is_terminal(): + pending = true + if not elevators.get_actor_trip(_staff_path_actor_id(patient.staff_id)).is_empty(): + continue + if pending or _rescue_destination(patient.current_cell).is_empty(): + continue + staff_service.create_task(&"rescue_pickup", 95, minute, { + &"target_entity_id": patient.staff_id, &"target_cell": patient.current_cell, + &"required_role_tags": [&"medic"], &"task_tags": [&"rescue"], + &"estimated_minutes": 30, &"interrupt_level": 0, &"emergency": true, + }) + + +func get_staff_rescue_report() -> Dictionary: + var report := {&"waiting": 0, &"transporting": 0, &"treating": 0} + for patient: StaffRuntime in staff_service.get_staff_members(): + if patient.state != StaffRuntime.State.INCAPACITATED: + continue + var category: StringName = &"waiting" + for task: StaffTask in staff_service.get_tasks(): + if task.target_entity_id == patient.staff_id and task.task_type == &"rescue_transport" and not task.is_terminal(): + category = &"treating" if task.state == StaffTask.State.WORKING else &"transporting" + report[category] += 1 + return report + + +func _staff_rescue_status(staff_id: StringName) -> String: + var staff := staff_service.get_staff(staff_id) + if staff == null or staff.state != StaffRuntime.State.INCAPACITATED: + return "" + for task: StaffTask in staff_service.get_tasks(): + if task.target_entity_id != staff_id or task.is_terminal(): continue + if task.task_type == &"rescue_transport": + return "治療中" if task.state == StaffTask.State.WORKING else "搬送中" + if task.task_type == &"rescue_pickup" and not task.assigned_staff_id.is_empty(): + return "救護班が向かっています" + return "救護待ち(医療職・医務室・裏経路を確認)" + + +## Rest destinations derive from saved facilities and position, with no hidden timer. +func get_staff_rest_report(staff_id: StringName) -> Dictionary: + var staff := staff_service.get_staff(staff_id) + if staff == null or staff.state != StaffRuntime.State.OFF_DUTY or staff.health <= 0 or not staff.current_task_id.is_empty(): return {} + if not _defense_lock_owner(staff_id, &"DEFENDER").is_empty() or event_service.get_actor_locks().has(staff_id): return {} + var best := {&"reachable": false, &"resting": false, &"label": "休憩先なし(稼働中の待機室・宿舎と経路を確認)"} + var best_cost := 2147483647 + for room: FacilityInstance in facility_service.get_instances(): + if room.definition_id not in [&"staff_waiting_room", &"staff_dormitory"] or room.operation_state != FacilityInstance.OperationState.OPERATING: continue + var route := staff_commute.find_facility_access_path(staff.current_cell, room) + if not route.get(&"reachable", false): continue + var cost := path_service.calculate_path_cost(route[&"path"], [&"staff"]) + if cost >= best_cost: continue + best_cost = cost + var resting := StaffCommuteService.is_at_facility(staff, room) and not is_staff_in_transit(staff_id) + best = {&"reachable": true, &"resting": resting, &"cell": route[&"cell"], &"facility_id": room.instance_id, + &"label": ("宿舎で休憩中" if room.definition_id == &"staff_dormitory" else "待機室で休憩中") if resting else "休憩先へ移動中"} + return best + + +func _advance_weather(absolute_minute: int) -> void: + weather.advance_to(absolute_minute) + + +func get_weather_report() -> Dictionary: + return {&"kind": weather.get_kind(), &"minute": weather.get_minute()} + + +## Read-only migration preview; applying dimensions remains a separate transaction. +func preview_facility_dimension_migration(instance_id: StringName) -> Dictionary: + var instance := construction_service.get_facility(instance_id) + if instance.is_empty(): + return {&"geometry_ok": false, &"ready_to_apply": false, &"errors": [&"COMPLETED_FACILITY_REQUIRED"]} + var weights := {} + for built: Dictionary in construction_service.get_facilities(): + var definition := definition_registry.get_facility(built[&"definition_id"]) + if definition == null: + return {&"geometry_ok": false, &"ready_to_apply": false, &"errors": [&"MISSING_FACILITY_DEFINITION"]} + weights[built[&"instance_id"]] = definition.weight_per_cell + var preview: Dictionary = preload("res://scripts/construction/facility_resize_preview.gd").inspect(world_service, instance, weights[instance_id], weights) + if preview.has(&"remaining_gates"): + preview[&"remaining_gates"].erase(&"DEFINITION_WEIGHT_VALIDATION") + preview[&"definition_weights"] = weights + if preview.get(&"geometry_ok", false): + preview[&"construction_candidate"] = preload("res://scripts/construction/facility_resize_preview.gd").prepare_construction_candidate(world_service, construction_service, instance_id, weights[instance_id], weights) + preview[&"actors_on_removed_cells"] = [] + for guest: GuestRuntime in guest_service.get_guests(): + if guest.current_cell in preview.get(&"removed", []): + preview[&"actors_on_removed_cells"].append({&"kind": &"guest", &"id": guest.guest_id, &"cell": guest.current_cell}) + for worker: StaffRuntime in staff_service.get_staff_members(): + if worker.current_cell in preview.get(&"removed", []): + preview[&"actors_on_removed_cells"].append({&"kind": &"staff", &"id": worker.staff_id, &"cell": worker.current_cell}) + preview[&"construction_jobs"] = [] + for job: ConstructionJob in construction_service.get_jobs(): + preview[&"construction_jobs"].append({&"job_id": job.job_id, &"definition_id": job.definition_id, &"state": job.state}) + return preview + +## Assemble and validate a snapshot in an isolated Runtime, including access +## preservation. Disk backup, actor relocation and atomic commit remain separate. +func prepare_facility_dimension_snapshot(instance_id: StringName) -> Dictionary: + if construction_service.get_facility(instance_id).is_empty() and construction_service.get_job(instance_id) != null: + return _prepare_construction_dimension_snapshot(instance_id) + var preview := preview_facility_dimension_migration(instance_id) + var result := {&"ok": false, &"ready_to_apply": false, &"errors": [], &"preview": preview} + if not preview.get(&"construction_candidate", {}).get(&"ok", false): + result[&"errors"].append(&"CONSTRUCTION_CANDIDATE_REQUIRED") + return result + if not preview[&"actors_on_removed_cells"].is_empty(): + result[&"errors"].append(&"ACTOR_RELOCATION_REQUIRED") + return result + var candidate := create_snapshot() + var data: Dictionary = candidate[&"systems"][&"runtime"] + data[&"world"] = preview[&"construction_candidate"][&"world"].duplicate(true) + data[&"construction"] = preview[&"construction_candidate"][&"construction"].duplicate(true) + var found := false + for instance: Dictionary in data[&"facilities"][&"instances"]: + if StringName(instance[&"instance_id"]) != instance_id: continue + found = true + var cells: Array = [] + for cell: Vector3i in preview[&"target_cells"]: cells.append([cell.x, cell.y, cell.z]) + instance[&"occupied_cells"] = cells + if not found: + result[&"errors"].append(&"OPERATIONAL_FACILITY_REQUIRED") + return result + var isolated: Variant = get_script().new() + var candidate_clock: Variant = load("res://autoload/game_clock.gd").new() + var candidate_state: Variant = load("res://autoload/game_state.gd").new() + candidate_clock.reset(1, 6, 0) + candidate_state.reset() + isolated.initialize(candidate_clock, candidate_state, world_seed) + # The immutable definition resources must match the source registry, + # including any explicitly registered expansion definitions. + isolated.definition_registry = definition_registry + var restored: bool = isolated.restore_snapshot(candidate) + if restored: + result[&"routes"] = preload("res://scripts/construction/facility_resize_routes.gd").inspect(self, isolated, instance_id) + if result[&"routes"][&"errors"].is_empty() and not result[&"routes"][&"guest_replans"].is_empty(): + result[&"retargeting"] = preload("res://scripts/construction/facility_resize_routes.gd").retarget_guests(isolated, result[&"routes"]) + if result[&"retargeting"][&"ok"]: + candidate = isolated.create_snapshot() + restored = isolated.restore_snapshot(candidate) + if restored: + result[&"routes"] = preload("res://scripts/construction/facility_resize_routes.gd").inspect(self, isolated, instance_id) + isolated.free() + candidate_clock.free() + candidate_state.free() + if not restored: + result[&"errors"].append(&"RUNTIME_CANDIDATE_REJECTED") + return result + if not result[&"routes"][&"ok"]: + result[&"errors"].append(&"ROUTE_MIGRATION_REQUIRED") + return result + result[&"ok"] = true + result[&"snapshot"] = candidate + return result + +## Explicit disk migration. Source Runtime stays untouched; the formal package +## is replaced only after isolated domain/route validation and verified backup. +func migrate_facility_dimensions_at_path(save_path: String, instance_id: StringName = &"", expected_source_digest: String = "") -> Dictionary: + var loaded := SaveService.load_snapshot(save_path) + if not loaded[&"ok"]: return loaded + if not expected_source_digest.is_empty() and SimulationDigest.compute(loaded[&"snapshot"]) != expected_source_digest: + return {&"ok": false, &"code": &"MIGRATION_SOURCE_CHANGED"} + var isolated: Variant = get_script().new() + var migration_clock: Variant = load("res://autoload/game_clock.gd").new() + var migration_state: Variant = load("res://autoload/game_state.gd").new() + migration_clock.reset(1, 6, 0); migration_state.reset() + isolated.initialize(migration_clock, migration_state, world_seed) + isolated.definition_registry = definition_registry + var prepared := {&"ok": false, &"errors": [&"SOURCE_RUNTIME_REJECTED"]} + if isolated.restore_snapshot(loaded[&"snapshot"]): + if instance_id.is_empty(): + prepared = isolated.prepare_all_facility_dimension_snapshots() + else: + prepared = isolated.prepare_facility_dimension_snapshot(instance_id) + isolated.free(); migration_clock.free(); migration_state.free() + if not prepared[&"ok"]: + return {&"ok": false, &"code": &"DIMENSION_MIGRATION_REJECTED", &"details": prepared} + return SaveService.replace_snapshot_with_backup(save_path, loaded[&"snapshot"], prepared[&"snapshot"]) + +## Runs on an isolated migration Runtime; caller commits only the final snapshot. +func prepare_all_facility_dimension_snapshots() -> Dictionary: + var original := create_snapshot() + var targets: Array[StringName] = [] + var shapes: Dictionary = preload("res://scripts/construction/facility_resize_preview.gd").DIMENSIONS + for job: ConstructionJob in construction_service.get_jobs(): + if job.is_terminal() or not shapes.has(job.definition_id): continue + var size: Vector2i = shapes[job.definition_id][&"new"] + if job.rotation in [90,270]: size = Vector2i(size.y,size.x) + if not preload("res://scripts/construction/facility_resize_preview.gd")._same_cells(job.reserved_cells, world_service.cells_for_footprint(job.origin,size)): + targets.append(job.job_id) + for facility: Dictionary in construction_service.get_facilities(): + if not shapes.has(facility[&"definition_id"]): continue + var size: Vector2i = shapes[facility[&"definition_id"]][&"new"] + if facility[&"rotation"] in [90,270]: size = Vector2i(size.y,size.x) + if not preload("res://scripts/construction/facility_resize_preview.gd")._same_cells(facility[&"occupied_cells"], world_service.cells_for_footprint(facility[&"origin"],size)): + targets.append(facility[&"instance_id"]) + for id: StringName in targets: + var prepared := prepare_facility_dimension_snapshot(id) + if not prepared[&"ok"] or not restore_snapshot(prepared[&"snapshot"]): + var rolled_back := restore_snapshot(original) + return {&"ok": false, &"errors": [&"BATCH_DIMENSION_REJECTED"], &"facility_id": id, &"details": prepared, &"rolled_back": rolled_back} + return {&"ok": true, &"snapshot": create_snapshot(), &"facility_ids": targets} + +func _prepare_construction_dimension_snapshot(job_id: StringName) -> Dictionary: + var weights := {} + for facility: Dictionary in construction_service.get_facilities(): + var definition := definition_registry.get_facility(facility[&"definition_id"]) + if definition == null: return {&"ok": false, &"errors": [&"MISSING_FACILITY_DEFINITION"]} + weights[facility[&"instance_id"]] = definition.weight_per_cell + var job := construction_service.get_job(job_id) + var definition := definition_registry.get_facility(job.definition_id) + if job.is_terminal() or definition == null: + return {&"ok": false, &"errors": [&"ACTIVE_JOB_REQUIRED"]} + weights[job_id] = definition.weight_per_cell + var prepared: Dictionary = preload("res://scripts/construction/facility_resize_preview.gd").prepare_construction_candidate(world_service, construction_service, job_id, definition.weight_per_cell, weights) + if not prepared[&"ok"]: return prepared + var candidate := create_snapshot() + candidate[&"systems"][&"runtime"][&"world"] = prepared[&"world"] + candidate[&"systems"][&"runtime"][&"construction"] = prepared[&"construction"] + var isolated: Variant = get_script().new() + var candidate_clock: Variant = load("res://autoload/game_clock.gd").new() + var candidate_state: Variant = load("res://autoload/game_state.gd").new() + candidate_clock.reset(1,6,0); candidate_state.reset() + isolated.initialize(candidate_clock, candidate_state, world_seed) + isolated.definition_registry = definition_registry + var valid: bool = isolated.restore_snapshot(candidate) + isolated.free(); candidate_clock.free(); candidate_state.free() + if not valid: return {&"ok": false, &"errors": [&"RUNTIME_CANDIDATE_REJECTED"]} + return {&"ok": true, &"ready_to_apply": false, &"snapshot": candidate} + +## Read-only saved-game preflight used by the load menu before confirmation. +func preview_facility_dimensions_at_path(save_path: String) -> Dictionary: + var loaded := SaveService.load_snapshot(save_path) + if not loaded[&"ok"]: return loaded + var isolated: Variant = get_script().new() + var preview_clock: Variant = load("res://autoload/game_clock.gd").new() + var preview_state: Variant = load("res://autoload/game_state.gd").new() + preview_clock.reset(1,6,0); preview_state.reset() + isolated.initialize(preview_clock,preview_state,world_seed) + isolated.definition_registry=definition_registry + var result := {&"ok": false, &"code": &"SOURCE_RUNTIME_REJECTED"} + if isolated.restore_snapshot(loaded[&"snapshot"]): + result=isolated.prepare_all_facility_dimension_snapshots() + result[&"source_digest"]=SimulationDigest.compute(loaded[&"snapshot"]) + result[&"changes"]=[] + if result[&"ok"]: + for id: StringName in result[&"facility_ids"]: + var facility: Dictionary=isolated.construction_service.get_facility(id) + var job: ConstructionJob=isolated.construction_service.get_job(id) + var definition_id: StringName=facility.get(&"definition_id",&"") if job==null else job.definition_id + var definition: FacilityDefinition=definition_registry.get_facility(definition_id) + result[&"changes"].append({&"id":id,&"name":definition.display_name,&"size":definition.footprint,&"building":job!=null}) + result.erase(&"snapshot") + isolated.free();preview_clock.free();preview_state.free() + return result + + +func _on_cleanliness_facility_registered(facility: FacilityInstance) -> void: + var values := cleaning_policy.create_snapshot() + if facility_cleanliness.get_facility(facility.instance_id).is_empty(): + facility_cleanliness.register_facility(facility.instance_id, values["initial_cleanliness"], values["maximum_cleanliness"]) + + +func _on_cleanliness_facility_unregistered(facility_id: StringName) -> void: + for task: StaffTask in staff_service.get_tasks(): + if task.task_type == CleaningBridge.TASK_TYPE and task.target_entity_id == facility_id and not task.is_terminal(): + staff_service.cancel_task(task.task_id, &"CLEANING_TARGET_REMOVED", _runtime_event_minute()) + if not facility_cleanliness.get_facility(facility_id).is_empty(): + facility_cleanliness.unregister_facility(facility_id) + _sync_facility_staffing(_runtime_event_minute()) + + +func _validate_cleanliness_crosslinks(cleanliness: RefCounted, facilities: FacilityService, staff: StaffService, path: PathService) -> bool: + var snapshot: Dictionary = cleanliness.create_snapshot() + var instances := facilities.get_instances() + var completed_cleanings: Dictionary = {} + if snapshot["facilities"].size() != instances.size(): return false + for facility: FacilityInstance in instances: + var row: Dictionary = cleanliness.get_facility(facility.instance_id) + if row.is_empty() or row["maximum"] != 100 or row["initial"] != 100: return false + for receipt: Dictionary in snapshot["receipts"]: + if receipt["kind"] == "clean": + var task := staff.get_task(StringName(receipt["source_id"])) + if task == null or task.task_type != CleaningBridge.TASK_TYPE or task.state != StaffTask.State.COMPLETED or task.target_entity_id != StringName(receipt["facility_id"]) or receipt["amount"] != cleaning_policy.create_snapshot()["recovery_per_completed_task"]: return false + if task.progress_minutes != task.estimated_minutes: return false + completed_cleanings[task.task_id] = true + continue + if receipt["kind"] != "use": return false + var service := facilities.get_service(StringName(receipt["source_id"])) + if service == null or service.state != ServiceInstance.State.COMPLETED or service.facility_id != StringName(receipt["facility_id"]): return false + var facility := facilities.get_instance(service.facility_id) + var definition := definition_registry.get_facility(facility.definition_id) + if definition == null or receipt["amount"] != definition.cleanliness_decay_per_service: return false + var pending: Dictionary = {} + for task: StaffTask in staff.get_tasks(): + if task.task_type != CleaningBridge.TASK_TYPE: continue + if task.estimated_minutes != cleaning_policy.create_snapshot()["work_minutes"] or task.required_role_tags != [&"cleaner"]: return false + if task.state == StaffTask.State.COMPLETED and facilities.has_instance(task.target_entity_id) and not completed_cleanings.has(task.task_id): return false + if task.is_terminal(): continue + var facility := facilities.get_instance(task.target_entity_id) + if facility == null or task.target_cell not in CleaningBridge.access_cells(facility, path, false) or pending.has(task.target_entity_id): return false + pending[task.target_entity_id] = true + if not task.assigned_staff_id.is_empty(): + var worker := staff.get_staff(task.assigned_staff_id) + if not CleaningBridge.is_deployed(worker, facilities.get_instance(worker.assigned_facility_id) if worker != null else null, staff): return false + return true + +func _automatic_staff_work_units(task_id: StringName) -> int: + var task := staff_service.get_task(task_id) + # Cleaning is progressed after attendance, dependency and cancellation checks. + return 0 if task != null and task.task_type == CleaningBridge.TASK_TYPE else 1 + + +## This is a configured event sink, not a thief/riot/loan predicate. Producers +## must supply a cause identity and a complete, approved threat template. +func _build_event_defense_request(action: EventAction, context: Dictionary) -> Dictionary: + var instance_value: Variant = context.get(&"event_instance_id", context.get("event_instance_id", null)) + var source_value: Variant = context.get(&"threat_source_id", context.get("threat_source_id", null)) + var template_value: Variant = action.payload.get(&"template_id", action.payload.get("template_id", null)) + var kind_value: Variant = action.payload.get(&"source_kind", action.payload.get("source_kind", null)) + for raw: Variant in [instance_value, source_value, template_value, kind_value]: + if typeof(raw) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw).is_empty(): + return {&"ok": false, &"error": "Configured threat requires event, source, template and source-kind identities."} + var source_kind := String(kind_value).to_upper() + if source_kind not in ["THIEF", "BANKRUPTCY_RIOT", "BANK_DELINQUENCY", "AUTHORITY_ESCALATION"]: + return {&"ok": false, &"error": "Configured threat source kind is unknown."} + var identity := JSON.stringify([String(instance_value), String(action.action_id)], "", true, true) + var raid_id := StringName("event_defense_raid." + identity.sha256_text()) + var prepared := _prepare_event_threat_template(action, StringName(source_value), raid_id) + if not bool(prepared.get(&"ok", false)): + return prepared + var definition: Dictionary = prepared[&"definition"] + var digest := String(prepared[&"digest"]) + var event_source := { + "event_instance_id": String(instance_value), "event_action_id": String(action.action_id), + "template_id": String(template_value), "template_digest": digest, + "source_kind": source_kind, + } + definition[&"event_source"] = event_source + var existing := defense_service.get_raid(raid_id) + if not existing.is_empty(): + if existing.get(&"event_source", {}) != event_source or not _event_threat_template_matches_raid(existing, prepared[&"canonical_raid"]): + return {&"ok": false, &"error": "Event threat identity conflicts with an already-created incident."} + return {&"ok": true, &"raid_id": raid_id, &"definition": definition, &"is_replay": true} + for objective_id: StringName in prepared[&"objective_ids"]: + var objective := facility_service.get_instance(objective_id) + if objective == null or objective.durability <= 0: + return {&"ok": false, &"error": "Configured threat objective is unavailable."} + if not _defense_lock_owner(objective_id, &"ASSET", raid_id).is_empty(): + return {&"ok": false, &"error": "Configured threat objective already belongs to another active incident."} + return {&"ok": true, &"raid_id": raid_id, &"definition": definition, &"is_replay": false} + + +func _prepare_event_threat_template(action: EventAction, source_id: StringName, raid_id: StringName) -> Dictionary: + var raw_definition: Variant = action.payload.get(&"definition", action.payload.get("definition", null)) + if not raw_definition is Dictionary: + return {&"ok": false, &"error": "Configured threat requires its complete definition."} + var definition: Dictionary = raw_definition.duplicate(true) + # Do not silently turn service defaults into newly approved enemy values. + for field: StringName in [&"round_minutes", &"max_duration_minutes", &"defender_retreat_threshold", &"terrain_bonus", &"command_bonus", &"surprise_penalty", &"multiple_entry_penalty"]: + if typeof(definition.get(field, definition.get(String(field), null))) != TYPE_INT: + return {&"ok": false, &"error": "Configured threat requires an explicit integer: %s." % field} + var retreat_value: Variant = definition.get(&"retreat_policy", definition.get("retreat_policy", null)) + if typeof(retreat_value) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false, &"error": "Configured threat requires an explicit retreat policy."} + for field: StringName in [&"entry_points", &"objective_ids"]: + var raw_ids: Variant = definition.get(field, definition.get(String(field), null)) + if not raw_ids is Array or raw_ids.is_empty(): + return {&"ok": false, &"error": "Configured threat requires explicit %s." % field} + for raw_id: Variant in raw_ids: + if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME] or String(raw_id).is_empty(): + return {&"ok": false, &"error": "Configured threat entry/objective IDs must be nonempty names."} + var raw_waves: Variant = definition.get(&"waves", definition.get("waves", null)) + if not raw_waves is Array or raw_waves.is_empty(): + return {&"ok": false, &"error": "Configured threat requires explicit waves."} + for raw_wave: Variant in raw_waves: + if not raw_wave is Dictionary: + return {&"ok": false, &"error": "Configured threat wave must be a dictionary."} + for field: StringName in [&"threat_strength", &"max_rounds", &"retreat_threshold"]: + if typeof(raw_wave.get(field, raw_wave.get(String(field), null))) != TYPE_INT: + return {&"ok": false, &"error": "Configured threat wave requires an explicit integer: %s." % field} + definition[&"source_id"] = source_id + # An isolated aggregate validates and canonicalizes without touching live + # clocks, RNG, IDs, facilities, events or the queue. Time zero is only the + # canonical probe's timestamp, never the actual incident creation time. + definition.erase(&"event_source") + definition.erase("event_source") + var probe := DefenseService.new() + var valid := probe.create_raid(raid_id, 0, definition) + if not valid.is_success(): + return {&"ok": false, &"error": valid.message, &"details": valid.details} + var canonical: Dictionary = (probe.create_snapshot()[&"raids"] as Array)[0] + var digest := JSON.stringify(canonical, "", true, true).sha256_text() + var canonical_raid := probe.get_raid(raid_id) + return {&"ok": true, &"definition": definition, &"digest": digest, &"objective_ids": canonical_raid[&"objective_ids"], &"canonical_raid": canonical_raid} + + +func _apply_event_defense_raid(action: EventAction, context: Dictionary) -> Dictionary: + var request := _build_event_defense_request(action, context) + if not bool(request.get(&"ok", false)): + return request + if bool(request.get(&"is_replay", false)): + return {&"ok": true, &"raid_id": request[&"raid_id"], &"is_replay": true} + var advanced := defense_service.advance_time(_defense_operation_minute()) + if not advanced.is_success(): + return {&"ok": false, &"error": advanced.message} + var created := defense_service.create_raid(request[&"raid_id"], _defense_operation_minute(), request[&"definition"]) + if not created.is_success(): + return {&"ok": false, &"error": created.message} + if not _validate_live_defense_crosslinks(): + return {&"ok": false, &"error": "Configured event threat violates live Defense crosslinks."} + return {&"ok": true, &"raid_id": request[&"raid_id"], &"is_replay": false} + + +## Historical provenance is checked against the saved event definition and +## cause context, without requiring a departed guest or repaid loan to exist. +func _validate_event_defense_crosslinks(events: EventService, defense: DefenseService) -> bool: + for raid_id: StringName in defense.get_raid_ids(): + var raid := defense.get_raid(raid_id) + var source: Dictionary = raid.get(&"event_source", {}) + if source.is_empty(): + continue + var instance_id := StringName(source["event_instance_id"]) + var action_id := StringName(source["event_action_id"]) + var identity := JSON.stringify([String(instance_id), String(action_id)], "", true, true) + if raid_id != StringName("event_defense_raid." + identity.sha256_text()): + return false + var instance := events.get_instance(instance_id) + if instance == null: + return false + var source_value: Variant = instance.context.get(&"threat_source_id", instance.context.get("threat_source_id", null)) + if typeof(source_value) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(source_value) != raid[&"source_id"]: + return false + var definition := events.get_definition(instance.resolved_event_id) + if definition == null: + return false + var actions: Array[EventAction] = [] + for collection: Array in [definition.automatic_actions, definition.fallback_actions, definition.failure_actions]: + actions.append_array(collection) + for choice: EventChoice in definition.choices: + actions.append_array(choice.actions) + var matches := false + for action: EventAction in actions: + if action.action_id != action_id or action.type != EventAction.Type.CREATE_DEFENSE_RAID: + continue + if String(action.payload.get(&"template_id", action.payload.get("template_id", ""))) != source["template_id"] or String(action.payload.get(&"source_kind", action.payload.get("source_kind", ""))).to_upper() != source["source_kind"]: + continue + var prepared := _prepare_event_threat_template(action, StringName(source_value), raid_id) + if bool(prepared.get(&"ok", false)) and String(prepared[&"digest"]) == source["template_digest"] and _event_threat_template_matches_raid(raid, prepared[&"canonical_raid"]): + matches = true + break + if not matches: + return false + return true + + +## Compare the immutable creation configuration to its original template. +## Combat progress, damage, roster, timestamps and player retreat-policy edits +## remain mutable and must not be compared to a newly-created probe. +func _event_threat_template_matches_raid(raid: Dictionary, canonical: Dictionary) -> bool: + for field: StringName in [&"raid_id", &"source_id", &"max_duration_minutes", &"round_minutes", &"terrain_bonus", &"command_bonus", &"surprise_penalty", &"multiple_entry_penalty", &"entry_points", &"objective_ids"]: + if not _strict_variants_equal(raid.get(field, null), canonical.get(field, null)): + return false + var waves: Array = raid.get(&"waves", []) + var template_waves: Array = canonical.get(&"waves", []) + if waves.size() != template_waves.size(): + return false + for index: int in range(waves.size()): + var wave: Dictionary = waves[index] + var template_wave: Dictionary = template_waves[index] + for field: StringName in [&"wave_id", &"threat_strength", &"retreat_threshold", &"max_rounds", &"entry_points", &"objective_ids"]: + if not _strict_variants_equal(wave.get(field, null), template_wave.get(field, null)): + return false + return true diff --git a/data/characters/mvp_staff_candidates.json b/data/characters/mvp_staff_candidates.json index 7010629..90e3e4f 100644 --- a/data/characters/mvp_staff_candidates.json +++ b/data/characters/mvp_staff_candidates.json @@ -2,25 +2,501 @@ "schema_version": 1, "content_type": "staff_candidates", "entries": [ - {"id":"staff_akane","display_name":"アカネ","age":24,"adult_verified":true,"role_id":"receptionist","daily_wage":120,"signing_cost":180,"preferred_shift":"day","traits":["friendly"],"skills":{"combat":15,"operation":42,"service":68,"underworld":5},"permitted_task_tags":["reception","service"]}, - {"id":"staff_bardo","display_name":"バルド","age":38,"adult_verified":true,"role_id":"receptionist","daily_wage":135,"signing_cost":220,"preferred_shift":"night","traits":["calm"],"skills":{"combat":22,"operation":55,"service":62,"underworld":18},"permitted_task_tags":["reception","service"]}, - {"id":"staff_chika","display_name":"チカ","age":27,"adult_verified":true,"role_id":"cook","daily_wage":145,"signing_cost":250,"preferred_shift":"day","traits":["cleanly"],"skills":{"combat":8,"operation":76,"service":32,"underworld":4},"permitted_task_tags":["cooking","supply"]}, - {"id":"staff_doran","display_name":"ドラン","age":44,"adult_verified":true,"role_id":"cook","daily_wage":160,"signing_cost":300,"preferred_shift":"night","traits":["mentor"],"skills":{"combat":20,"operation":82,"service":28,"underworld":12},"permitted_task_tags":["cooking","supply"]}, - {"id":"staff_eri","display_name":"エリ","age":22,"adult_verified":true,"role_id":"cleaner","daily_wage":90,"signing_cost":100,"preferred_shift":"day","traits":["cleanly"],"skills":{"combat":6,"operation":61,"service":35,"underworld":3},"permitted_task_tags":["cleaning","maintenance"]}, - {"id":"staff_fugo","display_name":"フゴ","age":31,"adult_verified":true,"role_id":"cleaner","daily_wage":100,"signing_cost":130,"preferred_shift":"night","traits":["night_owl"],"skills":{"combat":18,"operation":65,"service":22,"underworld":20},"permitted_task_tags":["cleaning","maintenance"]}, - {"id":"staff_gina","display_name":"ジーナ","age":29,"adult_verified":true,"role_id":"porter","daily_wage":110,"signing_cost":150,"preferred_shift":"day","traits":["careful"],"skills":{"combat":28,"operation":70,"service":20,"underworld":10},"permitted_task_tags":["logistics","rescue","supply"]}, - {"id":"staff_haru","display_name":"ハル","age":21,"adult_verified":true,"role_id":"porter","daily_wage":95,"signing_cost":100,"preferred_shift":"night","traits":["night_owl"],"skills":{"combat":24,"operation":58,"service":25,"underworld":28},"permitted_task_tags":["logistics","rescue","supply"]}, - {"id":"staff_iris","display_name":"イリス","age":26,"adult_verified":true,"role_id":"attendant","daily_wage":130,"signing_cost":210,"preferred_shift":"day","traits":["friendly"],"skills":{"combat":10,"operation":38,"service":74,"underworld":8},"permitted_task_tags":["bath","room_service","service"]}, - {"id":"staff_juno","display_name":"ユノ","age":33,"adult_verified":true,"role_id":"attendant","daily_wage":140,"signing_cost":240,"preferred_shift":"night","traits":["calm"],"skills":{"combat":12,"operation":45,"service":78,"underworld":22},"permitted_task_tags":["bath","room_service","service"]}, - {"id":"staff_klaus","display_name":"クラウス","age":36,"adult_verified":true,"role_id":"guard","daily_wage":155,"signing_cost":280,"preferred_shift":"day","traits":["short_tempered"],"skills":{"combat":82,"operation":34,"service":18,"underworld":16},"permitted_task_tags":["defense","escort","security"]}, - {"id":"staff_lina","display_name":"リナ","age":30,"adult_verified":true,"role_id":"guard","daily_wage":150,"signing_cost":260,"preferred_shift":"night","traits":["night_owl"],"skills":{"combat":77,"operation":38,"service":25,"underworld":30},"permitted_task_tags":["defense","escort","security"]}, - {"id":"staff_marc","display_name":"マルク","age":41,"adult_verified":true,"role_id":"guard","daily_wage":170,"signing_cost":330,"preferred_shift":"any","traits":["mentor"],"skills":{"combat":88,"operation":46,"service":20,"underworld":25},"permitted_task_tags":["defense","escort","security"]}, - {"id":"staff_nadia","display_name":"ナディア","age":28,"adult_verified":true,"role_id":"bartender","daily_wage":145,"signing_cost":240,"preferred_shift":"night","traits":["merchant_minded"],"skills":{"combat":20,"operation":48,"service":76,"underworld":35},"permitted_task_tags":["bar","service","supply"]}, - {"id":"staff_otto","display_name":"オットー","age":35,"adult_verified":true,"role_id":"bartender","daily_wage":150,"signing_cost":270,"preferred_shift":"night","traits":["friendly"],"skills":{"combat":26,"operation":52,"service":79,"underworld":32},"permitted_task_tags":["bar","service","supply"]}, - {"id":"staff_petra","display_name":"ペトラ","age":25,"adult_verified":true,"role_id":"masseur","daily_wage":145,"signing_cost":260,"preferred_shift":"day","traits":["careful"],"skills":{"combat":8,"operation":35,"service":81,"underworld":18},"permitted_task_tags":["adult_service","massage","service"]}, - {"id":"staff_quinn","display_name":"クイン","age":32,"adult_verified":true,"role_id":"masseur","daily_wage":160,"signing_cost":320,"preferred_shift":"night","traits":["underworld_savvy"],"skills":{"combat":18,"operation":40,"service":84,"underworld":55},"permitted_task_tags":["adult_service","massage","service"]}, - {"id":"staff_rika","display_name":"リカ","age":23,"adult_verified":true,"role_id":"clerk","daily_wage":105,"signing_cost":140,"preferred_shift":"day","traits":["merchant_minded"],"skills":{"combat":5,"operation":67,"service":58,"underworld":7},"permitted_task_tags":["accounting","reception","trade"]}, - {"id":"staff_sven","display_name":"スヴェン","age":39,"adult_verified":true,"role_id":"maintenance","daily_wage":125,"signing_cost":190,"preferred_shift":"any","traits":["careful"],"skills":{"combat":32,"operation":74,"service":16,"underworld":15},"permitted_task_tags":["construction","maintenance","repair"]}, - {"id":"staff_tessa","display_name":"テッサ","age":34,"adult_verified":true,"role_id":"medic","daily_wage":165,"signing_cost":340,"preferred_shift":"any","traits":["calm"],"skills":{"combat":14,"operation":69,"service":63,"underworld":12},"permitted_task_tags":["medical","rescue","service"]} + { + "id": "staff_akane", + "display_name": "アカネ", + "age": 24, + "adult_verified": true, + "role_id": "receptionist", + "daily_wage": 120, + "signing_cost": 180, + "preferred_shift": "day", + "traits": [ + "friendly" + ], + "skills": { + "combat": 15, + "operation": 42, + "service": 68, + "underworld": 5 + }, + "permitted_task_tags": [ + "reception", + "service" + ] + }, + { + "id": "staff_bardo", + "display_name": "バルド", + "age": 38, + "adult_verified": true, + "role_id": "receptionist", + "daily_wage": 135, + "signing_cost": 220, + "preferred_shift": "night", + "traits": [ + "calm" + ], + "skills": { + "combat": 22, + "operation": 55, + "service": 62, + "underworld": 18 + }, + "permitted_task_tags": [ + "reception", + "service" + ] + }, + { + "id": "staff_chika", + "display_name": "チカ", + "age": 27, + "adult_verified": true, + "role_id": "cook", + "daily_wage": 145, + "signing_cost": 250, + "preferred_shift": "day", + "traits": [ + "cleanly" + ], + "skills": { + "combat": 8, + "operation": 76, + "service": 32, + "underworld": 4 + }, + "permitted_task_tags": [ + "cooking", + "supply" + ] + }, + { + "id": "staff_doran", + "display_name": "ドラン", + "age": 44, + "adult_verified": true, + "role_id": "cook", + "daily_wage": 160, + "signing_cost": 300, + "preferred_shift": "night", + "traits": [ + "mentor" + ], + "skills": { + "combat": 20, + "operation": 82, + "service": 28, + "underworld": 12 + }, + "permitted_task_tags": [ + "cooking", + "supply" + ] + }, + { + "id": "staff_eri", + "display_name": "エリ", + "age": 22, + "adult_verified": true, + "role_id": "cleaner", + "daily_wage": 90, + "signing_cost": 100, + "preferred_shift": "day", + "traits": [ + "cleanly" + ], + "skills": { + "combat": 6, + "operation": 61, + "service": 35, + "underworld": 3 + }, + "permitted_task_tags": [ + "cleaning", + "maintenance" + ] + }, + { + "id": "staff_fugo", + "display_name": "フゴ", + "age": 31, + "adult_verified": true, + "role_id": "cleaner", + "daily_wage": 100, + "signing_cost": 130, + "preferred_shift": "night", + "traits": [ + "night_owl" + ], + "skills": { + "combat": 18, + "operation": 65, + "service": 22, + "underworld": 20 + }, + "permitted_task_tags": [ + "cleaning", + "maintenance" + ] + }, + { + "id": "staff_gina", + "display_name": "ジーナ", + "age": 29, + "adult_verified": true, + "role_id": "porter", + "daily_wage": 110, + "signing_cost": 150, + "preferred_shift": "day", + "traits": [ + "careful" + ], + "skills": { + "combat": 28, + "operation": 70, + "service": 20, + "underworld": 10 + }, + "permitted_task_tags": [ + "logistics", + "rescue", + "supply" + ] + }, + { + "id": "staff_haru", + "display_name": "ハル", + "age": 21, + "adult_verified": true, + "role_id": "porter", + "daily_wage": 95, + "signing_cost": 100, + "preferred_shift": "night", + "traits": [ + "night_owl" + ], + "skills": { + "combat": 24, + "operation": 58, + "service": 25, + "underworld": 28 + }, + "permitted_task_tags": [ + "logistics", + "rescue", + "supply" + ] + }, + { + "id": "staff_iris", + "display_name": "イリス", + "age": 26, + "adult_verified": true, + "role_id": "attendant", + "daily_wage": 130, + "signing_cost": 210, + "preferred_shift": "day", + "traits": [ + "friendly" + ], + "skills": { + "combat": 10, + "operation": 38, + "service": 74, + "underworld": 8 + }, + "permitted_task_tags": [ + "bath", + "room_service", + "service" + ] + }, + { + "id": "staff_juno", + "display_name": "ユノ", + "age": 33, + "adult_verified": true, + "role_id": "attendant", + "daily_wage": 140, + "signing_cost": 240, + "preferred_shift": "night", + "traits": [ + "calm" + ], + "skills": { + "combat": 12, + "operation": 45, + "service": 78, + "underworld": 22 + }, + "permitted_task_tags": [ + "bath", + "room_service", + "service" + ] + }, + { + "id": "staff_klaus", + "display_name": "クラウス", + "age": 36, + "adult_verified": true, + "role_id": "guard", + "daily_wage": 155, + "signing_cost": 280, + "preferred_shift": "day", + "traits": [ + "short_tempered" + ], + "skills": { + "combat": 82, + "operation": 34, + "service": 18, + "underworld": 16 + }, + "permitted_task_tags": [ + "defense", + "escort", + "security" + ] + }, + { + "id": "staff_lina", + "display_name": "リナ", + "age": 30, + "adult_verified": true, + "role_id": "guard", + "daily_wage": 150, + "signing_cost": 260, + "preferred_shift": "night", + "traits": [ + "night_owl" + ], + "skills": { + "combat": 77, + "operation": 38, + "service": 25, + "underworld": 30 + }, + "permitted_task_tags": [ + "defense", + "escort", + "security" + ] + }, + { + "id": "staff_marc", + "display_name": "マルク", + "age": 41, + "adult_verified": true, + "role_id": "guard", + "daily_wage": 170, + "signing_cost": 330, + "preferred_shift": "any", + "traits": [ + "mentor" + ], + "skills": { + "combat": 88, + "operation": 46, + "service": 20, + "underworld": 25 + }, + "permitted_task_tags": [ + "defense", + "escort", + "security" + ] + }, + { + "id": "staff_nadia", + "display_name": "ナディア", + "age": 28, + "adult_verified": true, + "role_id": "bartender", + "daily_wage": 145, + "signing_cost": 240, + "preferred_shift": "night", + "traits": [ + "merchant_minded" + ], + "skills": { + "combat": 20, + "operation": 48, + "service": 76, + "underworld": 35 + }, + "permitted_task_tags": [ + "bar", + "service", + "supply" + ] + }, + { + "id": "staff_otto", + "display_name": "オットー", + "age": 35, + "adult_verified": true, + "role_id": "bartender", + "daily_wage": 150, + "signing_cost": 270, + "preferred_shift": "night", + "traits": [ + "friendly" + ], + "skills": { + "combat": 26, + "operation": 52, + "service": 79, + "underworld": 32 + }, + "permitted_task_tags": [ + "bar", + "service", + "supply" + ] + }, + { + "id": "staff_petra", + "display_name": "ペトラ", + "age": 25, + "adult_verified": true, + "role_id": "masseur", + "daily_wage": 145, + "signing_cost": 260, + "preferred_shift": "day", + "traits": [ + "careful" + ], + "skills": { + "combat": 8, + "operation": 35, + "service": 81, + "underworld": 18 + }, + "permitted_task_tags": [ + "adult_service", + "massage", + "service" + ] + }, + { + "id": "staff_quinn", + "display_name": "クイン", + "age": 32, + "adult_verified": true, + "role_id": "masseur", + "daily_wage": 160, + "signing_cost": 320, + "preferred_shift": "night", + "traits": [ + "underworld_savvy" + ], + "skills": { + "combat": 18, + "operation": 40, + "service": 84, + "underworld": 55 + }, + "permitted_task_tags": [ + "adult_service", + "massage", + "service" + ] + }, + { + "id": "staff_rika", + "display_name": "リカ", + "age": 23, + "adult_verified": true, + "role_id": "clerk", + "daily_wage": 105, + "signing_cost": 140, + "preferred_shift": "day", + "traits": [ + "merchant_minded" + ], + "skills": { + "combat": 5, + "operation": 67, + "service": 58, + "underworld": 7 + }, + "permitted_task_tags": [ + "accounting", + "reception", + "trade" + ] + }, + { + "id": "staff_sven", + "display_name": "スヴェン", + "age": 39, + "adult_verified": true, + "role_id": "maintenance", + "daily_wage": 125, + "signing_cost": 190, + "preferred_shift": "any", + "traits": [ + "careful" + ], + "skills": { + "combat": 32, + "operation": 74, + "service": 16, + "underworld": 15 + }, + "permitted_task_tags": [ + "construction", + "maintenance", + "repair" + ] + }, + { + "id": "staff_tessa", + "display_name": "テッサ", + "age": 34, + "adult_verified": true, + "role_id": "medic", + "daily_wage": 165, + "signing_cost": 340, + "preferred_shift": "any", + "traits": [ + "calm" + ], + "skills": { + "combat": 14, + "operation": 69, + "service": 63, + "underworld": 12 + }, + "permitted_task_tags": [ + "medical", + "rescue", + "service" + ] + }, + { + "id": "staff_uma", + "display_name": "ウマ", + "age": 28, + "adult_verified": true, + "role_id": "cleric", + "daily_wage": 130, + "signing_cost": 210, + "preferred_shift": "day", + "traits": [ + "calm" + ], + "skills": { + "combat": 10, + "operation": 38, + "service": 74, + "underworld": 8 + }, + "permitted_task_tags": [ + "service" + ] + } ] } diff --git a/data/characters/mvp_unique_characters.json b/data/characters/mvp_unique_characters.json index e67998b..e138793 100644 --- a/data/characters/mvp_unique_characters.json +++ b/data/characters/mvp_unique_characters.json @@ -13,7 +13,7 @@ "preferred_shift": "day", "traits": ["friendly", "unique", "unsellable"], "skills": {"combat": 24, "operation": 66, "service": 82, "underworld": 18}, - "mechanical_effects": {"assigned_floor_patience_loss_multiplier_bp": 9000} + "mechanical_effects": {"assigned_floor_revenue_multiplier_bp": 12000} }, { "id": "rival_rai", diff --git a/data/environment/cleaning_policy.json b/data/environment/cleaning_policy.json new file mode 100644 index 0000000..65e9afb --- /dev/null +++ b/data/environment/cleaning_policy.json @@ -0,0 +1,8 @@ +{ + "schema_version": 1, + "initial_cleanliness": 100, + "maximum_cleanliness": 100, + "clean_at_or_below": 75, + "work_minutes": 8, + "recovery_per_completed_task": 25 +} diff --git a/data/facilities/adult_private_room.tres b/data/facilities/adult_private_room.tres index eadaa55..5c3218c 100644 --- a/data/facilities/adult_private_room.tres +++ b/data/facilities/adult_private_room.tres @@ -24,6 +24,7 @@ construction_cost = {&"gold": 1500, &"materials": 14} build_minutes = 240 weight_per_cell = 2 upgrade_costs = Array[Dictionary]([{&"gold": 750, &"materials": 8}, {&"gold": 1500, &"materials": 14}]) +maintenance_gold = 8 maintenance_mana = 3 power_priority = 45 staff_min = 1 diff --git a/data/facilities/cafeteria.tres b/data/facilities/cafeteria.tres index 4b27c85..aed6bf9 100644 --- a/data/facilities/cafeteria.tres +++ b/data/facilities/cafeteria.tres @@ -13,6 +13,7 @@ footprint = Vector2i(4, 2) construction_cost = {&"gold": 800, &"materials": 12} build_minutes = 180 weight_per_cell = 2 +maintenance_gold = 4 maintenance_mana = 2 staff_min = 2 staff_max = 4 diff --git a/data/facilities/expansion/black_market.tres b/data/facilities/expansion/black_market.tres index d08a7b9..a8211c9 100644 --- a/data/facilities/expansion/black_market.tres +++ b/data/facilities/expansion/black_market.tres @@ -9,7 +9,7 @@ display_name = "闇市取引所" description = "希少品を高値で扱う地下取引施設。収益と引き換えに警戒と監査リスクを持つ。" zone = 3 layer = 0 -footprint = Vector2i(3, 2) +footprint = Vector2i(4, 2) construction_cost = {&"gold": 1600, &"materials": 12} build_minutes = 270 weight_per_cell = 2 diff --git a/data/facilities/expansion/chapel.tres b/data/facilities/expansion/chapel.tres new file mode 100644 index 0000000..1d13ce1 --- /dev/null +++ b/data/facilities/expansion/chapel.tres @@ -0,0 +1,28 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"chapel" +display_name = "礼拝堂" +description = "礼拝と寄付を通じて信仰を育む施設。聖職者の配置が必要。" +zone = 0 +layer = 0 +footprint = Vector2i(4, 3) +construction_cost = {&"gold": 1200, &"materials": 12} +build_minutes = 180 +maintenance_gold = 6 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"cleric"]) +primary_skill = &"service" +customer_capacity = 8 +capacity = 8 +queue_capacity = 8 +base_service_minutes = 60 +base_price = 20 +faith_per_service = 1 +service_types = Array[StringName]([&"worship"]) +default_service_type = &"worship" +tags = Array[StringName]([&"worship", &"leisure"]) diff --git a/data/facilities/expansion/cleaning_room.tres b/data/facilities/expansion/cleaning_room.tres new file mode 100644 index 0000000..f74505d --- /dev/null +++ b/data/facilities/expansion/cleaning_room.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"cleaning_room" +display_name = "清掃員室" +description = "清掃員が清掃へ向かうための裏層の拠点。" +zone = 0 +layer = 1 +footprint = Vector2i(2, 1) +construction_cost = {&"gold": 350, &"materials": 4} +build_minutes = 60 +maintenance_gold = 2 +staff_min = 1 +staff_max = 2 +required_roles = Array[StringName]([&"cleaner"]) +primary_skill = &"operation" +customer_capacity = 0 +capacity = 0 +queue_capacity = 0 +base_price = 0 +tags = Array[StringName]([&"cleaning", &"staff_support"]) diff --git a/data/facilities/expansion/premium_room.tres b/data/facilities/expansion/premium_room.tres index e154af1..25b09da 100644 --- a/data/facilities/expansion/premium_room.tres +++ b/data/facilities/expansion/premium_room.tres @@ -7,16 +7,18 @@ script = ExtResource("1") id = &"premium_room" display_name = "上級客室" description = "富裕客と長期滞在客向けの静かな地上客室。一般客室より高い品質と料金を持つ。" -zone = 1 +zone = 0 layer = 0 -footprint = Vector2i(3, 2) +footprint = Vector2i(4, 1) construction_cost = {&"gold": 900, &"materials": 10} build_minutes = 150 weight_per_cell = 2 +maintenance_gold = 5 maintenance_mana = 1 staff_min = 0 staff_max = 0 capacity = 2 +customer_capacity = 2 queue_capacity = 2 base_service_minutes = 720 base_quality = 70 diff --git a/data/facilities/expansion/staff_dormitory.tres b/data/facilities/expansion/staff_dormitory.tres index 975c00b..161ac4a 100644 --- a/data/facilities/expansion/staff_dormitory.tres +++ b/data/facilities/expansion/staff_dormitory.tres @@ -7,17 +7,19 @@ script = ExtResource("1") id = &"staff_dormitory" display_name = "従業員宿舎" description = "勤務の合間に従業員が睡眠と長時間休息を取るBACK居住区画。" -zone = 1 +zone = 0 layer = 1 -footprint = Vector2i(4, 2) +footprint = Vector2i(2, 1) construction_cost = {&"gold": 650, &"materials": 8} build_minutes = 150 weight_per_cell = 1 +maintenance_gold = 4 maintenance_mana = 1 power_priority = 35 staff_min = 0 staff_max = 0 capacity = 12 +customer_capacity = 0 queue_capacity = 0 base_service_minutes = 360 base_quality = 58 diff --git a/data/facilities/guest_corridor.tres b/data/facilities/guest_corridor.tres index f58a566..2685dbc 100644 --- a/data/facilities/guest_corridor.tres +++ b/data/facilities/guest_corridor.tres @@ -14,6 +14,7 @@ construction_cost = {&"gold": 20, &"materials": 0} build_minutes = 10 weight_per_cell = 1 pass_through = true +maintenance_gold = 1 maintenance_mana = 0 power_priority = 10 staff_min = 0 diff --git a/data/facilities/guest_elevator.tres b/data/facilities/guest_elevator.tres index 38417e2..d56b40c 100644 --- a/data/facilities/guest_elevator.tres +++ b/data/facilities/guest_elevator.tres @@ -14,6 +14,7 @@ construction_cost = {&"gold": 1500, &"materials": 15} build_minutes = 300 weight_per_cell = 3 pass_through = true +maintenance_gold = 8 maintenance_mana = 5 power_priority = 85 staff_min = 0 diff --git a/data/facilities/infrastructure/guest_stairs.tres b/data/facilities/infrastructure/guest_stairs.tres index 867e6b2..87cf8d8 100644 --- a/data/facilities/infrastructure/guest_stairs.tres +++ b/data/facilities/infrastructure/guest_stairs.tres @@ -15,6 +15,7 @@ construction_cost = {&"gold": 40, &"materials": 0} build_minutes = 10 weight_per_cell = 1 pass_through = true +maintenance_gold = 1 maintenance_mana = 0 power_priority = 10 staff_min = 0 diff --git a/data/facilities/infrastructure/service_stairs.tres b/data/facilities/infrastructure/service_stairs.tres index f31d4f1..12f9b4e 100644 --- a/data/facilities/infrastructure/service_stairs.tres +++ b/data/facilities/infrastructure/service_stairs.tres @@ -15,6 +15,7 @@ construction_cost = {&"gold": 50, &"materials": 0} build_minutes = 10 weight_per_cell = 1 pass_through = true +maintenance_gold = 1 maintenance_mana = 0 power_priority = 10 staff_min = 0 diff --git a/data/facilities/kitchen.tres b/data/facilities/kitchen.tres index bde04dc..c877fba 100644 --- a/data/facilities/kitchen.tres +++ b/data/facilities/kitchen.tres @@ -23,6 +23,7 @@ construction_cost = {&"gold": 700, &"materials": 10} build_minutes = 180 weight_per_cell = 2 upgrade_costs = Array[Dictionary]([{&"gold": 350, &"materials": 6}, {&"gold": 700, &"materials": 12}]) +maintenance_gold = 4 maintenance_mana = 2 power_priority = 80 staff_min = 1 diff --git a/data/facilities/massage_room.tres b/data/facilities/massage_room.tres index 7cd2c38..89d1963 100644 --- a/data/facilities/massage_room.tres +++ b/data/facilities/massage_room.tres @@ -13,6 +13,7 @@ footprint = Vector2i(2, 1) construction_cost = {&"gold": 800, &"materials": 8} build_minutes = 120 weight_per_cell = 1 +maintenance_gold = 4 maintenance_mana = 2 power_priority = 50 staff_min = 1 diff --git a/data/facilities/public_bath.tres b/data/facilities/public_bath.tres index 217a040..917a4b6 100644 --- a/data/facilities/public_bath.tres +++ b/data/facilities/public_bath.tres @@ -13,6 +13,7 @@ footprint = Vector2i(6, 2) construction_cost = {&"gold": 1200, &"materials": 16} build_minutes = 240 weight_per_cell = 2 +maintenance_gold = 6 maintenance_mana = 4 power_priority = 65 staff_min = 1 diff --git a/data/facilities/reception.tres b/data/facilities/reception.tres index c740c70..e2e287e 100644 --- a/data/facilities/reception.tres +++ b/data/facilities/reception.tres @@ -23,6 +23,7 @@ construction_cost = {&"gold": 500, &"materials": 8} build_minutes = 120 weight_per_cell = 2 upgrade_costs = Array[Dictionary]([{&"gold": 250, &"materials": 4}, {&"gold": 500, &"materials": 8}]) +maintenance_gold = 3 maintenance_mana = 1 power_priority = 100 staff_min = 1 diff --git a/data/facilities/rescue/infirmary.tres b/data/facilities/rescue/infirmary.tres new file mode 100644 index 0000000..4e43886 --- /dev/null +++ b/data/facilities/rescue/infirmary.tres @@ -0,0 +1,26 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"infirmary" +display_name = "医務室" +description = "基本仕様書に基づく医務室。費用・能力値は調整値。" +zone = 0 +layer = 1 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 640, &"materials": 8} +build_minutes = 80 +staff_min = 0 +staff_max = 2 +capacity = 0 +queue_staff_min = 0 +staff_max = 2 +capacity = 0 +base_service_minutes = 30 +base_quality = 50 +base_price = 0 +tags = Array[StringName]([&"medical", &"staff_only"]) + +maintenance_gold = 4 diff --git a/data/facilities/security_station.tres b/data/facilities/security_station.tres index 5d5ff65..725da9a 100644 --- a/data/facilities/security_station.tres +++ b/data/facilities/security_station.tres @@ -13,6 +13,7 @@ footprint = Vector2i(2, 2) construction_cost = {&"gold": 900, &"materials": 10} build_minutes = 180 weight_per_cell = 2 +maintenance_gold = 5 maintenance_mana = 2 power_priority = 90 staff_min = 1 diff --git a/data/facilities/service_corridor.tres b/data/facilities/service_corridor.tres index f28357b..2ac48f5 100644 --- a/data/facilities/service_corridor.tres +++ b/data/facilities/service_corridor.tres @@ -14,6 +14,7 @@ construction_cost = {&"gold": 25, &"materials": 0} build_minutes = 10 weight_per_cell = 1 pass_through = true +maintenance_gold = 1 maintenance_mana = 0 power_priority = 10 staff_min = 0 diff --git a/data/facilities/service_elevator.tres b/data/facilities/service_elevator.tres index 95f1bf4..039c665 100644 --- a/data/facilities/service_elevator.tres +++ b/data/facilities/service_elevator.tres @@ -14,6 +14,7 @@ construction_cost = {&"gold": 2200, &"materials": 20} build_minutes = 360 weight_per_cell = 3 pass_through = true +maintenance_gold = 11 maintenance_mana = 6 power_priority = 95 staff_min = 0 diff --git a/data/facilities/staff_door.tres b/data/facilities/staff_door.tres index f713add..8f590c6 100644 --- a/data/facilities/staff_door.tres +++ b/data/facilities/staff_door.tres @@ -14,6 +14,7 @@ construction_cost = {&"gold": 100, &"materials": 1} build_minutes = 30 weight_per_cell = 1 pass_through = true +maintenance_gold = 1 maintenance_mana = 0 power_priority = 95 staff_min = 0 diff --git a/data/facilities/staff_waiting_room.tres b/data/facilities/staff_waiting_room.tres index b991067..d1d695d 100644 --- a/data/facilities/staff_waiting_room.tres +++ b/data/facilities/staff_waiting_room.tres @@ -13,6 +13,7 @@ footprint = Vector2i(4, 2) construction_cost = {&"gold": 400, &"materials": 6} build_minutes = 120 weight_per_cell = 1 +maintenance_gold = 2 maintenance_mana = 0 power_priority = 30 staff_min = 0 diff --git a/data/facilities/standard_room.tres b/data/facilities/standard_room.tres index 6145601..725d173 100644 --- a/data/facilities/standard_room.tres +++ b/data/facilities/standard_room.tres @@ -7,12 +7,13 @@ script = ExtResource("1") id = &"standard_room" display_name = "一般客室" description = "冒険者と一般市民向けの基本的な宿泊施設。" -zone = 1 +zone = 0 layer = 0 footprint = Vector2i(2, 1) construction_cost = {&"gold": 350, &"materials": 5} build_minutes = 60 weight_per_cell = 1 +maintenance_gold = 2 maintenance_mana = 0 staff_min = 0 staff_max = 0 diff --git a/data/facilities/tavern.tres b/data/facilities/tavern.tres index 937a5c8..5ec9223 100644 --- a/data/facilities/tavern.tres +++ b/data/facilities/tavern.tres @@ -13,6 +13,7 @@ footprint = Vector2i(4, 2) construction_cost = {&"gold": 1000, &"materials": 12} build_minutes = 240 weight_per_cell = 2 +maintenance_gold = 5 maintenance_mana = 2 power_priority = 55 staff_min = 2 diff --git a/data/facilities/warehouse.tres b/data/facilities/warehouse.tres index 4c158fa..6ef65e8 100644 --- a/data/facilities/warehouse.tres +++ b/data/facilities/warehouse.tres @@ -13,6 +13,7 @@ footprint = Vector2i(4, 2) construction_cost = {&"gold": 450, &"materials": 10} build_minutes = 180 weight_per_cell = 2 +maintenance_gold = 3 maintenance_mana = 0 staff_min = 1 staff_max = 3 diff --git a/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md b/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md index 7bb1f7c..9ba03f0 100644 --- a/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md +++ b/docs/ART_ASSET_PRODUCTION_SPECIFICATION.md @@ -2,7 +2,9 @@ ## 1. 目的 -この文書は、本作に必要な画像を制作し、安全に差し替えるための正本とする。 +この文書は、本作に必要な画像の制作・差し替え手順を定める派生資料とする。基本仕様の原本はユーザー指定の基本仕様書であり、差異がある場合は原本を優先する。 + +2026-09-29: [原本別の素材実在・必要枠台帳](plans/original-completion-2026-09-29/execution/assets/README.md)を追加。既存素材とコード描画の候補は、完成認定と区別して記録する。 **2026-09-09更新:現在のマップは『The Tower SP』を参考にしたドット絵の断面表示を採用する。** 施設の内装と小さな人物の動きが主役で、Kenney Tiny Dungeon・Tiny Townの16px素材と専用のドット描画を使う。旧生成背景や人物Bustを現在のマップへ戻すための仕様ではない。操作と実装上の制約は [ドット絵表示と見学ガイド](PIXEL_TOWER_VISUALS.md) を参照する。 @@ -10,6 +12,8 @@ ## 2. 現在のアートディレクション +2026-09-28: ユーザー提供の『The Tower 2』実ファイルを追加参考として確認した。[素材の観察結果と現実装との差分](THE_TOWER_2_REFERENCE_2026-09-28.md) に沿って、部屋の利用状態・家具に対応した人物位置・待機列・観察時の画面構成を改善する。画像から確認できた事実と未確認の実行時挙動を区別する。ゲーム仕様はユーザー指定の基本仕様書と施設パラメータ訂正を優先する。 + - 世界樹の正面断面を大きく見せる。受付・客室・食堂などを内装で識別でき、小さな客と従業員の移動・待機・仕事が読める構成とする。 - 基本素材はKenneyの16×16pxドット絵。Nearestフィルターを使い、通常表示では整数倍率を優先する。高解像度の絵をぼかして縮小した人物をマップの主役にしない。 - 幹・葉・部屋・家具は同じドット密度と色調で専用描画する。主色は木色・クリーム・濃緑、裏方の補助色は青緑とする。 diff --git a/docs/DEBUG_COMPLETION_2026-09-27.md b/docs/DEBUG_COMPLETION_2026-09-27.md new file mode 100644 index 0000000..c05754a --- /dev/null +++ b/docs/DEBUG_COMPLETION_2026-09-27.md @@ -0,0 +1,55 @@ +# 全件修正・通しデバッグ(2026-09-27) + +## 原因と修正 + +職員配置UIの4件の失敗は、テスト用建物の上下移動を客用通路だけで構成していたことが原因。実装済みの明示的な階段必須ルールではその客室へ到達できず、開業ガイドは正しく「道をつなぐ」を返していた。 + +`test_staff_management.gd` を、初日営業の受入試験と同じく客用階段で上下をつなぐ配置へ修正した。期待値を緩めたり、到達判定・配置条件を無効にしたりしていない。途中までの階段では配属案内へ進まない負例を追加。昼勤・配属解除・夜勤予定の従来の4判定も維持し、47項目が成功した。 + +`test_first_operating_day.gd` はディスク保存からのロード後、翌朝120分の営業を継続してから同じチェックポイントを再実行し、客・従業員・会計・イベントを含む状態digestが一致する検証を追加した。 + +## 全件実行の範囲 + +`tests/**/test*.gd` と `tests/run_tests.gd` を全件確認。`test_kernel_and_save.gd` は単独起動用ではなく `run_tests.gd` から呼び出すため、重複起動せず内包試験として実行する(ソース側79エントリーポイント)。書き出したPCKを必要とする `tests/export/test_exported_opening.gd` は配布アプリ生成後に別途実行。通常の `make test` の呼び出しに含まれていなかった交通比較試験も対象とする。 + +再実行用に `tools/run_all_tests.py` と `make test-all` を追加した。失敗で後続試験を打ち切らずログと一覧を残し、1件でも失敗すれば非ゼロで終了する。ユーザーの通常セーブを使わず、既存試験の専用保存先を使用する。 + +## 通しデバッグの範囲 + +全ソース試験成功後、通常の描画を有効にした実シーンで、次を順番に確認し、すべて成功した。 + +1. 初期資金から受付・客室・階段を建設し、雇用・配属、自然入客、宿泊売上、翌朝決算、ディスク保存・ロード、翌朝の営業継続と一致再実行。 +2. 初期資金から倉庫・厨房・食堂を建設し、食材購入、運搬員の実移動、調理、配膳、自然入客の売上、搬送途中の保存復元、3営業日の仕入れ・給与・会計照合。 +3. エレベーターの建設・増設、実際の乗車・到着、乗車途中の保存復元。 +4. 客と職員のドットアニメーションの連続描画。 + +これは自動操作・実描画による上記シナリオのデバッグであり、未実装のシナリオ内容や全ルートを完走した意味ではない。各結果・画像・ログは `artifacts/debug-2026-09-27/` に保存する。 + +全件試験内の3営業日供給受入: 49項目成功。食堂売上3,000G、原材料配送30便、料理配送72便、残金2,855G。約746秒で完了。これは全件通過後の描画付き再検証とは別に記録する。 + +## 全件試験結果 + +ソース側79エントリーポイント・6,850チェック、全件PASS。配布PCK起動13チェック、2万セルのフル負荷試験24チェックも全件PASS。`all-tests-summary.json` に重複のないソース側集計を保存。全件成功を確認後に描画付きの通しデバッグを開始した。 + +配布アプリ本体はソース外の `/tmp` を作業場所として `--headless --quit-after 5` で正常起動・終了(終了コード0)を確認した。初回の検証用 `--main-pack` 指定はreleaseテンプレートの仕様により拒否されたため、その試行を合格に数えず、通常の内蔵PCK起動で再確認した。配布PCKの13項目は別途開発用Godotから実行して合格。 + +## 通しデバッグの最終結果 + +|描画付きシナリオ|結果| +|---|---| +|新規開始〜翌朝決算〜保存ロード〜翌朝の営業継続・一致再実行|46チェック成功| +|仕入れ・実搬送・調理・販売・3営業日の給与と会計照合|49チェック成功| +|昇降機建設・増設・乗車・到着・途中保存と客の重複描画防止|32チェック成功| +|ドットアニメーション|72フレーム取得、実画像を確認| + +供給シナリオの描画あり・なしで、所持金2,855G、食堂売上3,000G、宿泊売上、配送件数(原材料30便・料理72便)、3日分の決算が一致。3日とも帳簿照合済み・給与未払0。画面は開業案内、ロード後の翌朝帳簿、厨房、配送、昇降機内の乗客、客・職員のアニメーションを確認した。 + +今回の全件修正と上記範囲の通しデバッグを完了した。既知の4失敗は解消され、通し実行で新たな失敗は発生しなかった。テスト対象ソースのハッシュが実行後も一致することを確認済み。 + +- [全件集計](../artifacts/debug-2026-09-27/all-tests-summary.json) +- [完了記録](../artifacts/debug-2026-09-27/completion.json) +- [通しアニメーション](../artifacts/debug-2026-09-27/walkthrough.gif) +- [翌朝の帳簿画面](../artifacts/debug-2026-09-27/opening-images/second-morning.png) +- [昇降機の画面](../artifacts/debug-2026-09-27/elevator-cabin.png) + +起動先は `build/macos/TheTree-Verified-2026-09-27.app`。ローカルとGoogle Drive双方の `TheTree-ドット版を起動.command` をこの検証済み版へ更新した。旧アプリと通常プレイのセーブは保持している。Windows実機や未実装ルートの完走を今回の完了範囲に含めない。 diff --git a/docs/DEVELOPMENT_BACKLOG.md b/docs/DEVELOPMENT_BACKLOG.md index 281bd78..c55ca51 100644 --- a/docs/DEVELOPMENT_BACKLOG.md +++ b/docs/DEVELOPMENT_BACKLOG.md @@ -1,5 +1,11 @@ +> 原本優先: 利用者指定の基本仕様書と施設パラメータ訂正が本書に優先します。現在の差異・未完了項目は [原本との実装対応](traceability/original/IMPLEMENTATION_STATUS.md) を参照してください。 + +2026-09-29救護追記: 医療職の回収→裏通路・扉・階段での搬送→医務室治療、体力/救護状態UI、保存復元を接続。[検証記録](traceability/original/STAFF_RESCUE_VERIFICATION_2026-09-29.md)。同伴エレベーター乗車と待機室での休息を追加実装。[続報](traceability/original/LABOR_COMPLETION_2026-09-29.md)。 + # 制作課題台帳 +2026-09-29追記: 原本B-4.2の最大欲求優先AIを実装し、HUDへ来客観察パネルを追加。関連627項目を再検証し、配布版を更新した。[検証記録](traceability/original/GUEST_AI_VERIFICATION_2026-09-29.md)。天候等は継続残件。救護搬送の現行版統合は下記参照。 + 更新日: 2026-09-09。承認済み [全体制作計画](DEVELOPMENT_PLAN.md) の実行台帳。基準はドット断面版コミット `423fd6dd2e75da926e38b35ac8c435e462b168d9`、制作ブランチは `codex/first-operating-loop`。P1の通常開業ガイド・継続配属API/パネル・日次帳簿を接続し、通常初期予算での最小宿を単日受入・専用保存復元で確認した。全自動テストの構成ターゲットは成功終了。その後、有料仕入れ・厨房の自律生産・実配送を通常の商品UIへ接続した。供給込み3営業日の自動受入45項目も合格した。部品の接続、単日成立、段階全体の完成を区別する。 ## 状態の読み方 @@ -58,6 +64,12 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` ## P3:営みと初見の操作 +2026-09-29追記: T2-V01〜V05の表示改善を実装。施設状態・室内利用位置・共用通路の待機列・初期カメラと営業階への復帰・実乗客と満員表示を接続した。[最新検証と配布版](THE_TOWER_2_COMPLETION_2026-09-29.md) を参照。下記の9月28日の残件記述は履歴。P1〜P5全体、Windows実機性能、原本の未実装項目が完了したという意味ではない。 + +実機記録の受領後: [Windows実機レビュー](THE_TOWER_2_WINDOWS_REVIEW_2026-09-28.md) に基づき、T2-V03の一部として「画面移動」ボタンと左ドラッグを実装。建設・掘削との排他切替、Esc解除、ドラッグ取り残し防止を関連370項目で確認。初期カメラ・列・施設状態・混雑表現の改善は引き続き残件。 + +2026-09-28追加: ユーザー提供の『The Tower 2』を参考資料として確認。[T2-V01〜V05](THE_TOWER_2_REFERENCE_2026-09-28.md) を表示改善の残件とする。施設状態の内装表現、施設別利用位置と待機列、初期カメラ、昇降機の混雑表現を順に改善する。参照調査は完了、これらの改善実装・受入は未完了。LIFE-02の古い足スライス方式は現在の横向き専用ポーズへ置換済みであり、[2026-09-27の描画変更記録](PIXEL_ANIMATION_2026-09-27.md) を現状の根拠とする。 + | ID | 現在地 | 次の完了判定 | |---|---|---| | LIFE-01 | **一部実装済み**。Runtime位置、サービス中位置の描画投影、待機・利用・作業Badge、停止 | 実状態に基づく歩行/待機/利用/睡眠/清掃/運搬/休憩の一覧と利用位置を確定する | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index d017b9e..87bf181 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,8 +1,10 @@ # 実装状況 -この文書は仕様書そのものではなく、現在のコードがどこまで到達しているかを記録する。ゲームルールの正本は `GAME_SPECIFICATION.md`、System境界と不変条件の正本は `SYSTEM_SPECIFICATION.md` とする。 +この文書は仕様書そのものではなく、現在のコードがどこまで到達しているかを記録する。利用者指定の基本仕様書が原本であり、`GAME_SPECIFICATION.md` と `SYSTEM_SPECIFICATION.md` は派生資料として扱う。現行の判定は [原本との対応](traceability/original/IMPLEMENTATION_STATUS.md) を参照。 -更新日: 2026-09-09。ドット断面版の基準コミットは `423fd6dd2e75da926e38b35ac8c435e462b168d9`。復旧方法は [RECOVERY_GUIDE.md](RECOVERY_GUIDE.md)、承認済み制作計画の課題と検証残は [DEVELOPMENT_BACKLOG.md](DEVELOPMENT_BACKLOG.md) を参照する。 +以下は2026-09-09時点の履歴。00:00決算・x3・同伴救護・待機室休息の現行記録は [労務検証](traceability/original/LABOR_COMPLETION_2026-09-29.md) と原本対応表を優先する。 + +旧更新日: 2026-09-09。ドット断面版の基準コミットは `423fd6dd2e75da926e38b35ac8c435e462b168d9`。復旧方法は [RECOVERY_GUIDE.md](RECOVERY_GUIDE.md)、承認済み制作計画の課題と検証残は [DEVELOPMENT_BACKLOG.md](DEVELOPMENT_BACKLOG.md) を参照する。 以下のSystem別一覧は既存の実装・試験記録を引き継いでいる。直前のドット版変更では起動とUI・カメラの359項目、その後の配属APIでは103項目の個別確認記録がある。P1追加後に `make check` と `make test` の全構成ターゲットを2群に分けて実行し、全件が成功終了した。追加の配属API103項目、スタッフ画面44項目、通常開業35項目も合格。対象と制約は [P1単日検証記録](qa/P1_FIRST_OPERATING_DAY.md) に記録した。通常初期予算での最小宿は単日受入・保存復元を確認したが、14日MVP、Windows実機性能、通常操作だけによる3営業日は別の完成条件として残る。 diff --git a/docs/PIXEL_ANIMATION_2026-09-27.md b/docs/PIXEL_ANIMATION_2026-09-27.md new file mode 100644 index 0000000..af966d5 --- /dev/null +++ b/docs/PIXEL_ANIMATION_2026-09-27.md @@ -0,0 +1,20 @@ +# ドットアニメーション表示の修正(2026-09-27) + +通常の制作先 `/Users/korizatou114/Projects/とりにく工房ゲーム制作部` を変更した。Google Drive側の旧ソースは混在を避けて上書きせず、ローカルの修正版アプリを開く `TheTree-ドット版を起動.command` を双方に配置した。 + +## 変更 + +既存の部屋・家具・階層のドット断面表示とKenney素材を維持し、人物を専用の横向き16×18グリッド描画に変更。静的アトラスの足部分の移動から、8段階の脚・腕・上下動による歩行へ置き換えた。清掃、作業、食事、荷物運搬、睡眠、気絶を状態に応じて描き分ける。左右反転、既存の移動補間・速度・一時停止に対応。シミュレーションの位置や経済は変更しない。 + +既存Kenney素材には横向きの専用歩行フレームがないため、人物のみコードで独自のポーズを描画する。Kenneyの部屋用素材とライセンスは保持。The Towerの画像・キャラクターは使用していない。 + +## 検証 + +- ピクセル表示・カメラ等の既存試験238項目、ダッシュボード121項目成功。 +- `make check` 成功。 +- 1440×810、2倍ズームの実運営画面を確認。6秒・12fpsの72フレームを取得し、72枚すべて異なることとワールド領域の変化を確認。ポーズ別の網羅的映像受入ではない。 +- macOSアプリを書き出し、ソース非依存のPCK起動・初回建設13項目成功。今回は描画変更の関連試験を行い、ゲーム全体の全試験は再実行していない。 + +[動画プレビュー](../artifacts/pixel-animation-2026-09-27/preview.gif) / [確認画像](../artifacts/pixel-animation-2026-09-27/preview.png) + +起動用アプリ: `build/macos/TheTree-Pixel-2026-09-27.app`。過去の配布アプリは上書きしていない。Windows版は今回再配布していない。 diff --git a/docs/PIXEL_TOWER_VISUALS.md b/docs/PIXEL_TOWER_VISUALS.md index 8729b73..bc72e9c 100644 --- a/docs/PIXEL_TOWER_VISUALS.md +++ b/docs/PIXEL_TOWER_VISUALS.md @@ -22,8 +22,11 @@ make test-pixel-ui # ドット表示・カメラ・実施設クリックの回 | 操作 | 動作 | |---|---| +| 上部の来客ボタン | 客を選び、四欲求・所持金・忍耐・現在の目的地を確認 | | ホイール | カーソル付近を基準に拡大・縮小 | | 中ボタンドラッグ | 断面を移動 | +| 画面移動ボタン → 左ドラッグ | 断面を移動。再度ボタンを押すかEscで解除。建設・掘削選択時も解除 | +| 営業階 | 現在の層の建設済み施設にカメラを合わせる(最大200%) | | Home | 全体表示へ戻す | | G | 格子と混雑表示を切り替え | | B | 建築パレットを開閉 | @@ -31,27 +34,34 @@ make test-pixel-ui # ドット表示・カメラ・実施設クリックの回 | H | 雇用候補を開閉 | | Tab | 客用の表層 / 裏方の裏層を切り替え | | E / R | 掘削モード / 建築物を回転 | -| Space / 1・2・4 | 停止・再開 / 時間倍率を変更 | +| Space / 1・2・3 | 停止・再開 / 時間倍率を変更 | | Esc | 雇用候補・使用中のツールを解除。ツール未使用時はパネルも閉じる | | 施設をクリック | 営業・待機列・人員などの実状態を右側に表示 | -初期状態では建築パレットと運営詳細を閉じ、マップを広く見せる。詳細はマップ右側に重ねて開く。判断が必要なイベントは詳細パネルを開き、選択肢を表示する。案内は下部に常時表示する。 +初期状態では建築パレットと運営詳細を閉じ、空の宿は入口付近を200%で表示する。施設がある場合は施設範囲へ合わせる。プレイ中は自動でカメラを追従させず、必要な時に「営業階」を押す。詳細はマップ右側に重ねて開く。判断が必要なイベントは詳細パネルを開き、選択肢を表示する。案内は下部に常時表示する。 ## 現在の表示 - 世界樹、空、幹、葉、床と室内をドット主体で描く。受付のカウンター、客室のベッド、食堂の卓、厨房などを施設ごとに描き分ける。 - Kenneyの[Tiny Dungeon](https://kenney.nl/assets/tiny-dungeon)と[Tiny Town](https://kenney.nl/assets/tiny-town)を人物・小物・庭木に利用する。16×16px素材、Nearest、Mipmap無効で表示する。配布元とCC0ライセンスは[素材一覧](../assets/kenney/README.md)と各パックの`License.txt`に記録している。 -- 客と従業員はRuntime上の実在人物で、移動元と現在セルの間を補間する。同じ元絵の足のずらし・上下動・左右反転で歩行を表現し、待機・利用・作業の小さな状態表示を添える。停止中はアニメーションも止まり、人物位置は確定セルを表示する。 +- 客と従業員はRuntime上の実在人物で、移動元と現在セルの間を補間する。独自の横向きドット人物の歩行・待機・食事・睡眠・清掃・作業・運搬ポーズで表現する。停止中はアニメーションも止まり、人物位置は確定セルを表示する。 - UIは濃緑・クリーム・木色の小さな平坦な枠を使用する。従来の高解像度生成背景・人物Bust・装飾Skinは現在のマップに使用しない。小型のUIアイコンは既存Theme参照を残す。 - ズーム・移動・表示レイヤー・格子の切替は描画側だけで扱う。建築の占有セルや保存データの形式は変えない。 ## 制約と次の制作 -- Kenneyの導入パックは静止画である。現在の歩行はコードによる元絵の加工であり、手描きの歩行・食事・清掃・運搬の専用アニメーションは今後追加する。Atlasで隣にいる別人物を歩行差分として再生していない。 -- エレベーター本体の昇降・乗降・待ち行列を含む運行は未実装。施設カタログの昇降機は接続の試作用で、見学では既存の上階通路を使って人物の行動を確認する。 +- 人物は `pixel_people.gd` の独自ポーズを使う。Kenneyは家具・小物などに使用し、原作ゲームから抽出した画像は配布物に含めない。 +- エレベーターは実運行・乗降と連動する。待機者は歩ける乗り場の外側へ並べ、実乗客はかご内だけに描画する。定員・満員・停止と待機人数を表示する。入りきらない人数は重ねず「他N人」で示す。 - 見学は在庫を用意した再現用の運営場面であり、収支・配送・混雑の完成度を保証する受入試験ではない。キャプチャにはその時点の実状態が出る。 - 施設ラベルは表示幅・選択・ホバーに応じて省略する。大きい世界を全体表示すると縮小されるため、人物の動きはズームして確認する。 表示の主な実装は`scenes/main/world_preview.gd`、専用の家具描画は`scripts/ui/pixel_tower_art.gd`、UIは`scenes/main/main.gd`。見学シナリオは`scripts/ui/pixel_tower_demo.gd`、出力処理は`tests/ui/capture_pixel_tower.gd`にある。回帰テスト`tests/ui/test_pixel_tower.gd`は実受付の建設・完工を経由して、ズームと移動後の占有セル命中、表裏の選択、地下を含む座標往復、停止時アニメーション、描画操作前後のシミュレーションダイジェスト不変を確認する。 接客・宿泊サービス中の客は、経路上のアクセスセルから施設内の利用位置へ描画上だけ投影する。経路探索、権威セル、保存状態は変更しない。 + +2026-09-29: 未稼働は暗い内装、破壊・摘発後の休止は閉鎖線と文字、稼働中は実利用人数と照明で識別する。利用位置と家具を共通化し、人物を同じ枠へ循環配置しない。`customer_capacity` と在庫容量は区別する。描画は稼働状態・料金・休止解除条件を変更しない。[実装・検証記録](THE_TOWER_2_COMPLETION_2026-09-29.md)。 + + +2026-09-29: 救護中の患者を担当医療職の位置に重ねて表示し、医務室治療中はベッドに表示。治療人数は実タスクから集計。従業員パネルに体力と救護段階を表示。 + +2026-09-29続報: 業務用EVは患者と救護者の2席を表示。待機室は到着済みの非番従業員を室内に描画し、休憩人数を表示する。状態ラベルも実際の移動・到着に連動する。 diff --git a/docs/PLAYTEST_GUIDE.md b/docs/PLAYTEST_GUIDE.md index 0e3dc14..bda086d 100644 --- a/docs/PLAYTEST_GUIDE.md +++ b/docs/PLAYTEST_GUIDE.md @@ -12,7 +12,7 @@ | エディター | `make editor` | 開いた後に実行ボタンまたはF5で起動 | | 建設済みの宿を見学 | `make preview-pixel` | 受付、食堂、厨房、客室、通路、実際の客・従業員を用意。保存なし | | 時間差の画像を出力 | `make capture-pixel` | `artifacts/pixel-tower/` にPNG 2枚と運営状態JSONを生成 | -| 通常開業の単日受入 | `make test-opening` | 見学Fixtureなしで通常UIのCommand経路を使い、初売上・翌06:00・保存復元を検証 | +| 通常開業の単日受入 | `make test-opening` | 見学Fixtureなしで通常UIのCommand経路を使い、初売上・00:00決算・翌朝・保存復元を検証 | | 仕入れ・厨房・配送をすぐ試遊 | `make preview-supply` | 初期予算から通常建設・実購入した宿を12:20まで進め、停止して開く。Spaceで再開。保存なし | | 有料仕入れ・厨房・配送の検証 | `make test-supply` | 通常予算から供給を接続し、配送途中保存と3営業日の運営を検証 | | 稼働中の商品パネルを撮影 | `make capture-supply` | 通常建設・実購入で用意した初日の仕入れ/厨房/配送をPNG出力 | @@ -42,7 +42,7 @@ | 開業ガイドを折り畳む | マップ左上の「開業の手引き」の見出しをクリック | | FRONT/BACK切替 | 下部の「客用・表層」「裏方・裏層」、または `Tab` | | 一時停止/再開 | 上部の「停止/再開」、または `Space` | -| 速度変更 | 上部のx1/x2/x4、または `1`、`2`、`4` | +| 速度変更 | 上部のx1/x2/x3、または `1`、`2`、`3` | | 拡大・縮小 | マップ上でホイール。カーソル付近を基準に拡縮 | | 断面を移動 | マップ上で中ボタンドラッグ | | 全体表示に戻す | `Home` | @@ -72,29 +72,29 @@ 3. 「客用階段」を `(-4, 2)` へ配置し、10ゲーム分進めます。続いて「客用階段」を `(-4, 3)` に、「客用通路」を `(-8, 3)`、`(-7, 3)`、`(-6, 3)`、`(-5, 3)` に置き、さらに10ゲーム分進めます。階段は3セル、横の通路は4セルです。 4. `Esc`で建築を解除します。左の「従業員」から「候補を見る」または `H` で雇用候補を開き、受付係のアカネを雇用します。契約金と日給を確認してください。 5. 「雇用済み」を開いてアカネを選び、配属先を建てた受付、勤務帯を「日勤 06:00〜18:00」にして「配属・勤務帯を変更」を押します。成功表示を確認し、時間進行中の予約Commandなら次の処理まで待ちます。勤務帯は翌日以降も繰り返す計画として保存されます。 -6. `Space`で再開し、必要なら `1`または`4`で速度を選んで、自然に到着した客の宿泊を観察します。現在の宿泊サービスは完了時に売上を記帳するため、到着直後に宿泊売上が出るとは限りません。客が来ない・利用されない場合は施設を選び、接続と人員の理由を確認します。 -7. 翌朝06:00まで進め、左の「帳簿」で第1営業日の確定値を選びます。売上、運営費(契約金等)、給与、営業利益、未払、施設別損益、照合結果を確認します。00:00はまだ同じ営業日です。 +6. `Space`で再開し、必要なら `1`または`3`で速度を選んで、自然に到着した客の宿泊を観察します。現在の宿泊サービスは完了時に売上を記帳するため、到着直後に宿泊売上が出るとは限りません。客が来ない・利用されない場合は施設を選び、接続と人員の理由を確認します。 +7. 00:00まで進め、左の「帳簿」で第1営業日の確定値を選びます。売上、運営費(契約金等)、給与、営業利益、未払、施設別損益、照合結果を確認します。00:00に前日を締め、06:00に再度締めることはありません。 8. 通常モードの下部「保存」で結果を確認し、その後に時間を進めて「読込」を押します。時刻、配属計画、決算、開業達成表示が保存時点へ戻ることを確かめます。手動UIは `user://saves/manual_ui` の1枠です。 この経路はUIの建築・雇用・配属処理から実行する自動受入で確認しています。受入の途中保存は通常スロットへ触れず、専用 `user://tests/first_operating_day_/restore-check` を使用して復元後に整理します。実ユーザーによる初見30分の試遊合格や、見学Fixtureを使わない3営業日の完成とは別の判定です。 -現在の配属は経路が存在することを事前検証してから論理的に確定します。配属操作だけで人物の位置は変えず、職場まで実際に通勤して到着後に人員として稼働する統合は今後のMOVE課題です。 +現在の配属は経路が存在することを事前検証してから論理的に確定します。配属操作だけで人物の位置は変わりません。職場まで実際に通勤し、到着後に稼働人数へ加わります。 ## 単日確認の結果と読み方 -Seed `424242`、通常初期予算、受付1・一般客室2・通路7・アカネ1人の受入で、次を確認しました。客・サービスの直接挿入、無料の在庫追加、見学Fixtureは使っていません。 +Seed `424242`、通常初期予算、受付1・一般客室2・客用階段3・通路4・アカネ1人の受入で、次を確認しました。客・サービスの直接挿入、無料の在庫追加、見学Fixtureは使っていません。 | 確認項目 | 結果 | |---|---| -| 自然来客による宿泊売上 | 320G | -| 受付売上 | 6,020G | -| 翌06:00の給与 | 120G | +| 自然来客による宿泊売上 | 240G | +| 受付売上 | 0G | +| 00:00の給与 | 120G | | 未払給与 | 0G | -| 全体の営業利益 | 6,040G | +| 全体の営業利益 | -60G | | 帳簿照合 | 一致 | | 専用テスト保存からの復元 | 決算値・配属・開業達成を復元 | -受付売上が大きく、現在の数値は製品版の黒字基準や推奨バランスではありません。価格、受付の会計上の役割、宿泊料金の決済・記帳タイミングは仕様との差を調整する必要があります。建築出金は資金増減として表示し、営業利益から分けています。また、06:00の給与支払は新営業日の現金出金に、前営業日の給与費用は確定決算に表示されます。 +2026-09-29の描画付き受入結果です。契約金180Gと給与120Gを差し引いた営業利益は-60G。建築出金は資金増減として表示し、営業利益から分けています。これは固定シナリオの検証値であり、推奨バランスではありません。日次決算・給与計上は00:00です。 `make capture-opening` の出力は `artifacts/first-operating-day/opening.png`、`staffed.png`、`first-close.png` です。開始画面、配属後、翌朝の帳簿を比較できます。 @@ -146,7 +146,7 @@ Windows書き出しコマンドの存在はWindows実機合格を意味しませ `make preview-business` で、通常予算から建てた供給可能な宿を開く。Spaceで再開し、左「営業」から食堂の料金を変える。値上げ前に受付済みの客は元の見積額を払い、新しく検討する客には新料金が反映される。休業すると新規受付が止まり、待機客は別の施設を探す。すでに利用中の客はサービスを完了する。給与は休業中も発生する。 -商品で仕入れ・厨房・配送、帳簿で翌06:00の給与と施設別利益を確認する。この試遊は保存なし。新設の標準料金は食堂25G・客室120G・受付無料。旧保存の既存施設は保存された基準料金を引き継ぐ。検証結果は [営業設定QA](qa/P1_BUSINESS_MANAGEMENT.md)。 +商品で仕入れ・厨房・配送、帳簿で00:00の給与と施設別利益を確認する。この試遊は保存なし。新設の標準料金は食堂25G・客室120G・受付無料。旧保存の既存施設は保存された基準料金を引き継ぐ。検証結果は [営業設定QA](qa/P1_BUSINESS_MANAGEMENT.md)。 ## 昇降機と通勤の試遊 @@ -162,3 +162,10 @@ Windows書き出しコマンドの存在はWindows実機合格を意味しませ 完成した昇降機をクリックすると、かごの現在階、人数、待機人数、運行の停止/再開が表示されます。最近の乗車記録の平均待ちと95%の待ち時間、現在の最長待ちも確認できます。平均・95%は保持中の乗車サンプルなので、詳しい集計範囲は表示のツールチップを確認してください。 以前のセーブの上下通路は、読み込むと「既設の階段」として同じ移動時間・利用権限で使えます。小さな階段マークをクリックして接続階を確認できます。その足場を撤去した後に新しい普通の通路を置いても階段は戻らないため、必要なら階段施設を建設します。 + + +## 非番の休息と救護 + +非番の体力回復には、稼働中の裏層の待機室と到達経路が必要です。従業員は実際に移動してから休憩します。体力0の従業員には、医務室と医療職テッサを用意してください。患者と救護者は業務用EVの二人分の席を使用し、同じ便で乗降します。 + +[労務・同伴救護の検証](traceability/original/LABOR_COMPLETION_2026-09-29.md) diff --git a/docs/SYSTEM_PERFORMANCE_2026-09-27.md b/docs/SYSTEM_PERFORMANCE_2026-09-27.md new file mode 100644 index 0000000..7b04bf1 --- /dev/null +++ b/docs/SYSTEM_PERFORMANCE_2026-09-27.md @@ -0,0 +1,50 @@ +# システム処理の精査と軽量化(2026-09-27) + +対象は通常制作先 `/Users/korizatou114/Projects/とりにく工房ゲーム制作部`。既存のドット人物アニメーション変更を保持した。 + +## 精査結果と変更 + +|対象|確認した処理|対応| +|---|---|---| +|PathService._run_a_star|最小コスト候補を毎回全件走査。昇降機等の接続がある場合はヒューリスティック0となり、広い候補集合を繰り返し走査する|問い合わせ内だけの最小ヒープへ変更。同コスト時のx/y/層の順序、より良い親による再探索、動的混雑コスト、アクセス権、通行停止を維持。古い候補は取り出し時に棄却| +|WorldPreview._service|セルの位置計算からRuntimeの全プロパティ一覧を何度も生成・走査|1回の描画内で取得済みサービスを再利用| +|WorldPreview._world_bounds / _cell_dimensions / get_map_rect|同一フレームの全セルで同じ境界・拡大率・矩形を再計算|描画開始で空にしたキャッシュを共有し、描画終了で破棄。カメラ操作・保存復元・Runtime再設定を跨いで保持しない| +|Runtime._publish_snapshot / create_snapshot|毎分の各システム状態と履歴の複製・digest生成|保存整合性・イベント配信・決定性に関わるため頻度を変えない。既存digest性能テストを確認対象にした| +|FacilityService.advance_one_minute|施設状態評価、稼働中サービスの更新・完了・補充|完了コールバック中の変更検査と処理順を維持。今回は業務更新の省略を行わない| +|Runtime._advance_runtime_path_traffic|客・職員の決定的順序での移動、昇降機待ち|順序を維持。内部で利用する経路探索の改善を適用| + +ヒープの領域は問い合わせ終了で解放される。描画キャッシュは次フレームまで残さない。セーブ形式・ゲーム内時間の進み方・料金・キャラクターの位置は変更していない。 + +## 計測方法 + +同じプロセス内で変更前の線形探索と変更後を比較。1,200セル、固定シードの120経路、通行停止・動的コスト・層間接続・アクセス拒否を含む。経路配列、コスト、到達不能理由、最終スナップショットが一致することを検証した。旧アルゴリズムはテスト用fixtureに限定して保持。 + +描画は1200×700の実SubViewportを使用。初期世界、停止中、描画命令発行のCPU時間を35フレーム測定し、先頭5フレームを除外。同じ画像になることをピクセル比較した。GPU時間・画面全体のFPS・長時間運営の総所要時間の測定ではない。 + +追加試験 `tests/performance/test_system_optimization.gd` は282項目。カメラ0.5/1/2/4倍で座標の一致も比較する。`make test-perf` に組み込んだ。時間の絶対値をテストの成否条件にせず、機器負荷による不安定な失敗を避ける。 + +## 既存の失敗 + +`tests/ui/test_staff_management.gd` の職員配置ガイドに関する4項目が失敗。軽量化前のPathServiceとWorldPreviewを戻した独立コピー `/tmp/thetree-performance-baseline` でも同じ4項目が失敗することを確認した。これを軽量化の成功に含めず、既存テストの失敗として別件に残す。 + +計測・検証ログは `artifacts/performance-2026-09-27/`。新アプリは `build/macos/TheTree-Optimized-2026-09-27.app`。旧版アプリは上書きせず保持する。 + +## 計測結果 + +|対象|変更前|変更後|条件| +|---|---:|---:|---| +|経路120件・3回の中央値|1,417 ms|908 ms|約36%短縮。3回の比率は0.638〜0.650| +|描画CPUの中央値|74.251 ms|1.272 ms|初期世界30フレーム。画像は完全一致| +|描画CPUのp95|77.452 ms|1.290 ms|GPU・全体FPSを除く| + +絶対時間は開発マシンの負荷に左右される。ゲーム全体が同じ割合で速くなったことを示す数値ではない。 + +`make check`、macOS書き出し、配布PCKの初回建設13項目を通過。起動コマンドは新しい軽量化版アプリへ更新済み。 + +## 最終検証結果 + +経路・ワールド、職員・客、保存、運営、UI、初日運営、digest・性能試験を実行。重複した比較ベンチマーク3回と配布PCK試験を除き、集計は3,651チェック、うち3,647成功・既存の4失敗。失敗は上記の職員配置ガイドに限定。追加282項目はこの集計に含む。詳細は `artifacts/performance-2026-09-27/regression-results.json`。`make test` 全体の実行ではない。 + +## 後続修正 + +上記の既存4失敗は、階段必須仕様に対して試験用建物が上下を通路だけで接続していたことが原因だった。試験用建物に実階段を配置し、未接続時の負例も追加して修正済み。後続の全79エントリーポイント・6,850チェックはすべて成功。詳細は [全件修正・通しデバッグ](DEBUG_COMPLETION_2026-09-27.md) を参照。上記の3,647成功・4失敗は修正前の検証履歴として残す。 diff --git a/docs/THE_TOWER_2_COMPLETION_2026-09-29.md b/docs/THE_TOWER_2_COMPLETION_2026-09-29.md new file mode 100644 index 0000000..ca07fd3 --- /dev/null +++ b/docs/THE_TOWER_2_COMPLETION_2026-09-29.md @@ -0,0 +1,62 @@ +# The Tower 2を参考にした表示改善:実装と検証 + +2026-09-29。対象は参照調査で整理したT2-V01〜V05。制作・書き出しは `/Users/korizatou114/Projects/とりにく工房ゲーム制作部` で実施した。原本全体の完成とは区別し、原本の未実装項目は [実装対応表](traceability/original/IMPLEMENTATION_STATUS.md) に残す。 + +## 変更した動作 + +| ID | 実装結果 | 根拠 | +|---|---|---| +| T2-V01 | 未稼働は暗い内装と閉鎖線、破壊・摘発後の休止は遮断線と文字、稼働は利用人数と照明で表示。実サービスの顧客IDを集計する | `room_presentation.gd`、状態別の実描画、94項目の表示回帰 | +| T2-V02 | 客室・飲食施設の家具と利用位置を共通化。実利用者を安定したID順で置き、描画枠を循環させない。待機列は歩行可能な隣接通路へ配置し、隣接する列同士も同じ場所を使わない。収まらない人数は「他N人」で示す | 狭い部屋・6人・9人の列・複数入口の共有通路の回帰。従業員は実際に現地にいる時のみ作業台へ表示 | +| T2-V03 | 新規開業は入口付近を200%で表示。「営業階」で建設済み施設へ合わせ、Homeで全体へ戻せる。画面移動ボタン・左ドラッグを維持 | 通常予算の開業53項目、Pixel243項目、Dashboard127項目。拡大と座標命中、解像度変更、各操作モードを確認 | +| T2-V04 | 実際の待機者は乗り場外、乗車中はかご内だけへ描画。実乗客数・定員・満員・停止を表示し、待機人数と分離 | 実ElevatorServiceへ9件を要求し、4人乗車・5人待機の描画を確認。輸送と保存の既存回帰も実行 | +| T2-V05 | 停止・速度・ズーム・表裏切替・保存復元を回帰。手動読込では表示用の移動履歴を破棄して復元状態から再描画。同一状態の描画時間を比較 | 下記の全体試験・実描画・測定・配布物確認 | + +利用人数には `customer_capacity` を使い、保管量・生産量の `capacity` や待機人数と混同しない。描画は料金や稼働条件を変更せず、休止の自動解除も追加しない。原作ゲームの画像・実行ファイルは配布物に含めず、独自人物と既存Kenney素材を使う。 + +## 検証結果 + +全83本の試験は、修正後の再試験を含めて **83本PASS・未解決の失敗0**。最終集計は [verification-summary.json](../artifacts/tower2-completion-2026-09-28/verification-summary.json) に保存した。初回は82本PASS・供給シナリオ1本に修正前の列配置の型エラーを記録。最終コードで当該シナリオを再実行し、49項目・エラー0・終了コード0を確認した。初回ログは削除せず、再試験への対応を最終集計に記録している。 + +- 3営業日の通常供給シナリオ: **49項目PASS**。自然来客の食堂売上3,000G、原料配送30便・料理配送80便、現金2,855G。各日の給与未払0・帳簿照合一致、配送途中のディスクSaveと最終状態の復元を確認。 +- 新しい室内・列・実乗車・復元・カメラ回帰: **94項目PASS**。 +- 通常初期予算から建設・雇用・営業・決算・翌朝までの実描画付き受入: **53項目PASS**。 +- `make check` と `git diff --check`。エディター読み込みと通常起動を確認。 +- macOS / Windowsの出力PCKをソースフォルダーに依存しない別ディレクトリで検証: **各13項目PASS**。全18施設の読込、受付選択、実建設、500G支出、完工、次の案内を確認。 +- macOS配布実行ファイルを実描画で起動して正常終了。Windows向けEXE/PCKは書き出し済みだが、今回のWindows実機起動・入力・FPS測定は未実施。 + +### 描画時間 + +Apple M4 / Godot 4.7.2 / Compatibility / 1440×810 / 200%表示。同じ一時停止中の状態を、変更前と変更後で各80フレーム描画し、先頭10を除く70点を比較。前後のSimulationDigest一致も確認した。 + +| 表示人数 | 変更前 中央値 / P95 | 変更後 中央値 / P95 | +|---|---|---| +| 6人 | 1.750 / 1.915 ms | 1.928 / 2.041 ms | +| 200人 | 4.390 / 4.595 ms | 4.431 / 4.550 ms | + +200人の中央値増加は約0.9%。施設の状態と列情報を一描画内で共有し、輸送レポートも一度だけ取得する。これは `_draw` のCPU処理時間であり、GPUを含む全フレーム時間・Windows性能・長時間AI負荷の達成値ではない。200人は計測用に作成した静止した実GuestRuntimeであり、200人が自律営業した記録ではない。同時に他の回帰試験も動いていたため微小差を性能改善とは断定しない。 + +## 実画面と記録 + +- [稼働中](../artifacts/tower2-completion-2026-09-28/operating.png) +- [未稼働](../artifacts/tower2-completion-2026-09-28/inactive.png) +- [摘発後の休止](../artifacts/tower2-completion-2026-09-28/suspended.png) +- [裏層](../artifacts/tower2-completion-2026-09-28/back.png) +- [乗り場の待機列](../artifacts/tower2-completion-2026-09-28/lift-queue.png) +- [4人乗車・5人待機](../artifacts/tower2-completion-2026-09-28/lift-full.png) +- [通常開業の翌朝](../artifacts/tower2-completion-2026-09-28/opening-images/second-morning.png) +- [描画測定JSON](../artifacts/tower2-completion-2026-09-28/draw-benchmark.json) + +状態比較画像は条件を固定した描画Fixture。昇降機画像では実Runtimeの有料建設後に検証用9人を生成し、魔力供給をFixtureから有効にして実輸送要求を発行した。通常経営での自然来客・魔力供給の受入とは区別する。通常開業53項目と供給の通し試験は別の実シナリオで確認する。 + +## 配布ファイル + +- macOS: `build/macos/TheTree-Tower2-2026-09-29.app` +- 起動用: `TheTree-ドット版を起動.command`(ローカルとDrive側の既存起動リンクを更新) +- Windows: `build/TheTree-Windows-Tower2-2026-09-29.zip`。展開後 `build/windows-tower2-2026-09-29/TheTree.exe` を起動し、同梱PCKを同じフォルダーに置く。 + +以前のビルドは保存したまま。証跡・ハッシュは上記artifactsフォルダーに保存する。 + +## 残る範囲 + +今回のT2表示改善は、原本の全施設・経済・人物物語・天候・全ランクの統合実装や、Windows基準機での長時間実測を完了したという意味ではない。狭い室内や長い列では全員を重ねて描かず人数表示にまとめる。施設ラベルは遠景で省略されるため、詳細確認は「営業階」または選択・拡大で行う。 diff --git a/docs/THE_TOWER_2_REFERENCE_2026-09-28.md b/docs/THE_TOWER_2_REFERENCE_2026-09-28.md new file mode 100644 index 0000000..78e9bb9 --- /dev/null +++ b/docs/THE_TOWER_2_REFERENCE_2026-09-28.md @@ -0,0 +1,72 @@ +# The Tower 2:提供ファイルの確認とThe Treeへの適用 + +2026-09-29追記: T2-V01〜V05の実装・最新の検証範囲は [表示改善の完了記録](THE_TOWER_2_COMPLETION_2026-09-29.md) を参照。以下は2026-09-28時点の調査・受領記録として保存する。 + +確認日: 2026-09-28。ユーザー提供のゲームファイルを、断面表示・人物表現・施設状態の見せ方の参考として調査した。ゲームの仕様は引き続き [基本仕様書原本](traceability/original/BASELINE.txt) とユーザーの施設パラメータ訂正を優先する。本書の改善案は実装完了を意味しない。 + +追記: Windows側で作成された実機記録を受領し、[操作・動作の確認結果とThe Treeへの反映](THE_TOWER_2_WINDOWS_REVIEW_2026-09-28.md) を追加した。以下はそれ以前の静的調査記録。T2-V03の操作モード明示部分として「画面移動」ボタンと左ドラッグを実装済み。その他の残件は継続する。 + +## 取得・調査範囲 + +- [提供元のGoogle Driveファイル](https://drive.google.com/file/d/1ea5OeYIuvThC5YtTutjdbGlrZYF03QF4/view) +- ファイル名: `The Tower 2.zip`、240,984,598 bytes、530エントリ。 +- SHA-256: `cf111aff88d59de6854f4bfbddff2af62b5345f3d4c67bf4c3f52a111e3fdec1` +- 保存先: `/Users/korizatou114/Projects/reference-materials/the-tower-2/`。ZIP・抽出画像はゲームの配布用アセットに追加していない。 +- 主な内容: Windows実行ファイル1、DLL4、施設等の `.t2p` 338、MOV50、AVI10。一般的なPDF・HTML・TXT形式の説明書は一覧に見当たらない。 +- 選定した11ファイルから509リソースを列挙し、134ビットマップをPNGに変換。変換エラー0。施設・人物・昇降機・UIの主要画像を目視確認した。全338プラグインの精査ではない。 +- Windows実行環境が現在のMacにないため、ゲーム本体は未実行。フレーム順序、切替条件、歩行速度、経路選択、運行ロジック、実画面の配置は未検証。 +- `S02.avi` は240×180・15fps・28.8秒。抽出した画面は人物中心の映像で、ビル経営の操作画面ではない。ゲームプレイ映像として扱わない。 + +再確認用の外部資料: + +- [ZIP全体の一覧](/Users/korizatou114/Projects/reference-materials/the-tower-2/archive-inventory.json) +- [リソース一覧と画像寸法](/Users/korizatou114/Projects/reference-materials/the-tower-2/resource-inventory.json) +- [代表画像の拡大比較](/Users/korizatou114/Projects/reference-materials/the-tower-2/inspect/selected-details.png) +- [読み取り・画像変換スクリプト](/Users/korizatou114/Projects/reference-materials/the-tower-2/tools/inspect_resources.py) + +## 素材から直接確認したこと + +以下のIDはWindowsリソースの「種別/番号/言語」。画像全体の寸法であり、ゲーム内の表示倍率ではない。 + +| 根拠 | 確認できた表現 | The Treeで参考にする点 | +|---|---|---| +| `Cafe.t2p`、`2/1000/1041`、96×108 | カウンター・椅子・棚のある内装と、人物の少ない/多い内装の差分が縦に並ぶ | 飲食施設は実際の利用人数を家具周辺に配置して見せる。看板だけで種類を区別しない | +| `Office.t2p`、`2/1000/1041`、72×336 | 机・椅子・端末など、複数の内装と明暗差分 | 施設の用途が小さな断面でも読める家具配置。明暗差分の実際の切替条件は未確認 | +| `Single.t2p`、`2/1000/1041`、32×240 | ベッド主体の小部屋に、明暗・寝具等の差分 | 客室の利用・睡眠・清掃状態を部屋内で伝える。各差分が何の状態に対応するかは未確定 | +| `StdElev.t2p`、`2/200/1041`、28×160 | 空のかご、人物1人・複数人が入ったかご、停止を想起させる記号 | 乗車密度を可視化する。かごの人物は実乗客に対応させる | +| `Sinjyuku.t2p`、`2/300/1041`、`2/350/1041`、各16×216 | 横向き人物の複数の手足の姿勢。ほかにも多数の人物画像がある | 小さい人物でも姿勢と向きで行動を伝える。画像の並びだけから再生順・fpsは決めない | +| `T2.exe`、文字列32771〜32773 | 100%・50%・25%の表示倍率のラベル | 近景の営みと遠景の全体把握を両立する。The Treeの倍率を同じ値にする必然性はない | + +## 現在のThe Treeとの比較 + +比較には、現在のソースと [2026-09-27の実描画キャプチャ](../artifacts/midnight-2026-09-27/opening-images/first-close.png) を用いた。キャプチャは今回の変更後の画面ではない。 + +すでにあるもの: + +- `pixel_people.gd`: 横向き16×18の独自人物。歩行・待機・食事・清掃・作業・運搬・睡眠・気絶の姿勢。 +- `world_preview.gd`: 実位置に基づく移動補間、サービス利用中の室内描画、かごと乗客・扉の描画、待機人数表示。これらを未実装として作り直さない。 +- `pixel_tower_art.gd`: 施設別の家具、世界樹の断面、Kenney素材。世界樹の色調と表裏層は本作固有の表現として維持する。 + +改善が必要な箇所: + +- 部屋の描画に利用人数や稼働状態が渡っておらず、稼働は主に小さい色ランプで示す。家具・照明・人の組み合わせから状態を読める余地がある。 +- サービス利用位置は共通の4枠循環。狭い部屋や5人以上の利用で同じ位置に重なり得る。施設ごとの利用場所・実定員に合わせる必要がある。 +- 待機人数のバッジと同一セルの人物オフセットはあるが、入口・乗り場に沿った整列を優先した表現ではない。 +- かごの人物描画には表示人数の上限がある。人数が上限を超えた際にも、密度と実人数を読み取れるようにする。 +- 開業時のキャプチャでは稼働している部屋が小さく、未開発の幹と管理パネルが大きい。初期カメラと詳細パネルを改善し、運営中の断面を眺めやすくする。 + +## 次の実装・受入項目 + +これは提供素材の分析から定めた改善順序であり、原作ゲーム内部の仕様を再現したものではない。 + +| ID | 優先度 | 作業 | 完了を判定する画面・動作 | +|---|---|---|---| +| T2-V01 | 高 | 客室・食堂・受付の状態を内装、照明、家具と実利用者で表現 | 同じ施設で未稼働/稼働/破壊・摘発後の休止を識別。実利用0人/少数/満員の差を確認。休止から勝手に復帰させない | +| T2-V02 | 高 | 施設別の利用位置と入口付近の待機列 | 小部屋・5人以上・複数入口・同じ階の隣接施設で人物が重ならず、待機者と利用者が区別できる | +| T2-V03 | 高 | 初期カメラと観察時のUI占有面積を調整 | 通常初期予算から作る宿で、歩行・着席・就寝を拡大操作なしでも追える。管理パネル開閉・解像度変更で配置操作がずれない | +| T2-V04 | 中 | 昇降機の乗り場待機と乗車密度を改善 | 待機→乗車→移動→降車を追え、乗車中の人物が廊下に重複しない。満員・停止も判別できる | +| T2-V05 | 共通 | 人数・画面状態別の描画検証と性能測定 | 停止/速度変更/ズーム/表裏切替/保存復元で表示と実状態が一致。描画のための架空客や経済変更を追加せず、混雑時のフレーム時間を同条件で比較 | + +実装時は独自のドット表現と既存Kenney素材を使う。施設の「顧客同時利用最大人数」は保管・生産等にも使う `capacity` と区別し、待機列の人数とも混同しない。原本・ユーザー指定で未設定の料金加算率や休止解除条件を、参考ゲームから推定して確定しない。 + +今回の変更は参考資料の取得・分析と制作資料への反映まで。ゲームコード、保存形式、配布アプリは変更していない。したがってゲームの全件PASSや通しデバッグ完了を新たに宣言するものではない。 diff --git a/docs/THE_TOWER_2_WINDOWS_REVIEW_2026-09-28.md b/docs/THE_TOWER_2_WINDOWS_REVIEW_2026-09-28.md new file mode 100644 index 0000000..e6cb3f4 --- /dev/null +++ b/docs/THE_TOWER_2_WINDOWS_REVIEW_2026-09-28.md @@ -0,0 +1,47 @@ +# The Tower II Windows実機記録の受領と反映 + +2026-09-29追記: T2-V01〜V05の実装・最新の検証範囲は [表示改善の完了記録](THE_TOWER_2_COMPLETION_2026-09-29.md) を参照。以下は2026-09-28時点の調査・受領記録として保存する。 + +2026-09-28。ユーザーがWindows側で作成した実機確認資料を受領し、レポート全文・時刻ログ・主要スクリーンショットを確認した。本チャットでWindowsゲームを再操作した結果ではなく、提供された検証証拠のレビューである。 + +## 受領物 + +- [提供ZIP](https://drive.google.com/file/d/1ds7StSFXKCgILOHqt05RCO3BYSEn6e5J/view): `The-Tower-II-reference-record.zip`、10,674,377 bytes。 +- SHA-256: `4d0fd7f4c1e904b41444f0d6b7bf2b941049c02b915d3ab1efaf181f0ec632e1`。 +- スクリーンショット78枚、Markdown/HTMLレポート、時刻ログ、新規セーブ334,412 bytes。 +- セーブSHA-256は添付レポートの値と一致: `5a1f072eeaf8dcee364fa403235f45646ecb0bdef33c3fb9b3e3665f511ffca8`。 +- [受領レポート全文](/Users/korizatou114/Projects/reference-materials/the-tower-2/windows-reference-record/tower2-evidence/report.md)。元のZIP・画像・セーブは外部参考資料フォルダに保存し、配布ゲームへ混入させていない。 + +## 確認された範囲と限界 + +新宿西口副都心、ロビー+オフィス7区画+昇降機1基で、建設・手のひらドラッグ・倍率変更・一時停止と再開・人物の列・かご移動・別名保存後のアプリ再起動と読込が記録されている。 + +| 記録 | 判断 | +|---|---| +| ロビーの階制限、下階からはみ出す建設の拒否、工事から内装への変化 | 配置制約と施工状態を操作直後に伝えることが重要。原作の制約や金額をThe Treeに追加する根拠にはしない | +| 手のひらで約162pxのドラッグ、同程度の画面移動 | 移動ツールを明示し、左ボタンだけで断面を探索できる操作を採用する | +| 倍率ツールの右クリックで寸法約半分、左クリックで元へ戻る | 概観と近景の行き来ができる。既存The Treeのカーソル基準ホイールズームを維持する | +| 低速で20.263実秒に40ゲーム分 | 約1.97ゲーム分/秒は当該端末・区間のみの観測値。全速度や歩行フレームの確定値として使わない | +| 1階の列、かご内人物、4階へのかご移動、後の列解消 | 移動需要を断面の人物で見せる。個体追跡、定員、乗降順序、満員の積み残しは未確認 | +| 11:42に停止保存し、再起動後最初の画像が11:44・再生中。資金79,745万円、人口42人を復元 | 配置・主要数値の復元の証拠。人物ごとの状態・乱数の厳密復元や停止状態の維持を検証済みとはしない | + +画像 `21-boarding-0.png` の乗り場の列と上階のかご、`23-zoom-out-0.png` の広域表示、`28-reopened-2.png` の時刻・資金・人口をこのチャットでも目視確認した。レポートと画像の間の動作を新たな実測として補わない。 + +## 今回実装した操作改善 + +The Treeの下部ツールバーに「画面移動」ボタンを追加した。有効中は左ドラッグで断面を移動し、施設選択・建設クリックを送らない。もう一度押すかEscで通常操作に戻る。建設・掘削との切り替えは排他的とし、既存の中ボタンドラッグ、カーソル基準ズーム、Homeの全体表示も維持する。 + +ドラッグ中にアプリのフォーカスを失った場合や、画面外でボタンを離してから戻った場合は移動を解除する。カメラ操作でシミュレーション時間やRuntime状態は変えない。 + +変更先: `scenes/main/main.gd`、`scenes/main/world_preview.gd`。画像素材の追加やゲームルールの変更はない。 + +## 検証 + +- [カメラ・描画・実施設選択の回帰](../artifacts/tower2-reference-2026-09-28/pixel.log): 243項目PASS。左ドラッグ、誤選択抑止、画面外での解除、フォーカス喪失、操作後のRuntimeダイジェスト一致を含む。 +- [ダッシュボード回帰](../artifacts/tower2-reference-2026-09-28/dashboard.log): 127項目PASS。ボタン・建設・掘削・Escの切り替え、1280×720〜3840×2160の既存レイアウトチェックを含む。 +- [実描画](../artifacts/tower2-reference-2026-09-28/preview.png): 1440×810で下部ボタンと断面の表示を目視確認。時刻の異なる2枚を生成。 +- ゲーム全体の全試験は今回再実行していない。配布アプリも今回更新していないため、変更確認にはローカル制作フォルダから `make run` を使う。修正後のWindows実機操作は未実施。 + +## 残る改善 + +[T2-V01〜V05](THE_TOWER_2_REFERENCE_2026-09-28.md) のうち、今回完了したのはT2-V03の操作モード明示部分。部屋の状態表現、施設別利用位置と待機列、初期カメラ、昇降機の混雑表現、混雑時の性能比較は未完了。提供された原作の実機検証完了と、The Treeの改善実装完了を区別する。 diff --git a/docs/UI_OPERATION_SPECIFICATION.md b/docs/UI_OPERATION_SPECIFICATION.md index fdc3317..47d7fae 100644 --- a/docs/UI_OPERATION_SPECIFICATION.md +++ b/docs/UI_OPERATION_SPECIFICATION.md @@ -12,7 +12,7 @@ | 領域 | 現在の情報・操作 | 表示状態 | |---|---|---| -| 上部 | 営業日、時刻、時間帯、来客、宿泊予定、資金、Mana、資材、Faith、Heat、停止、x1/x2/x4 | 常設の小型HUD | +| 上部 | 営業日、時刻、時間帯、来客、宿泊予定、資金、Mana、資材、Faith、Heat、停止、x1/x2/x3 | 常設の小型HUD | | 左側 | 従業員、施設、商品、探索、投資、帳簿の入口 | 常設の細いカテゴリ列。入口があるだけの準備中項目を含む | | 中央 | 世界樹の断面、施設の室内、客、従業員、工事、待機、混雑、6段階の開業手引き | 手引きは左上の見出しで折り畳める。断面を広く使用 | | 右側 | 運営概要、施設状態、雇用候補/雇用済み人物の配属、帳簿、要対応イベント | `I`、カテゴリ、施設選択で必要な内容を開く重ね表示。初期は閉じる | @@ -79,15 +79,15 @@ 商品、探索、投資の準備中案内を完成した経営機能として説明しない。 -## 7. 時間進行と06:00決算 +## 7. 時間進行と00:00決算 時間操作は上部の時計の近くへ置き、停止・通常・2倍・4倍を一操作で切り替える。重要な選択肢では右パネルを開き、選択と解決後の停止解除を接続する。 -Runtimeでは00:00の暦日更新と、06:00の前営業日締め・朝運用を別の境界として扱う。帳簿は当営業日の現金入出金・うち建築・給与支払と、選んだ確定営業日の売上・運営費・給与・その他費用・利益・前日差・未払・照合・施設別損益を表示する。建築による資金減少を営業赤字と混同しない。06:00の給与現金支払が新営業日の出金、費用が前営業日の決算に属する点を案内する。 +Runtimeでは00:00に前営業日を締め、給与を計上する。06:00の朝運用では決算を重複させない。帳簿は当営業日の現金入出金・うち建築・給与支払と、選んだ確定営業日の売上・運営費・給与・その他費用・利益・前日差・未払・照合・施設別損益を表示する。建築による資金減少を営業赤字と混同しない。00:00の給与現金支払が新営業日の出金、費用が前営業日の決算に属する点を案内する。 -開業の手引きは受付→客室→接続→配属→初宿泊売上→翌朝決算の6段階を実状態から示す。達成後は売上と決算の履歴を先に評価し、夜間の勤務外や配属変更だけで達成が消えないようにする。Save復元では新しいServiceから読み直す。手引きは状態を書き換えず、ボタンから通常の建築・従業員・時間・帳簿操作へ進める。 +開業の手引きは受付→客室→接続→配属→初宿泊売上→00:00決算の6段階を実状態から示す。達成後は売上と決算の履歴を先に評価し、夜間の勤務外や配属変更だけで達成が消えないようにする。Save復元では新しいServiceから読み直す。手引きは状態を書き換えず、ボタンから通常の建築・従業員・時間・帳簿操作へ進める。 -通常初期予算での宿泊売上320G、翌06:00の給与120G・未払0・照合一致・専用保存復元を単日確認した。受付売上6,020Gと全利益6,040Gの現結果は調整対象で、価格や宿泊の決済時点を今回のUI追加で改定していない。厨房・補給・実配送を含む3営業日運営、初見試遊、製品バランスは残る。 +2026-09-29の描画付き開業受入では、通常初期予算から宿泊売上240G・受付売上0G・給与120G・営業利益-60G・未払0・照合一致と翌朝の保存再開を確認した。製品バランスと初見の人手試遊は別の判定とする。 帳簿は表示中だけ更新し、日付のPopup操作中は選択肢を再構築しない。画面を閉じれば断面の観察へ戻る。 @@ -126,4 +126,9 @@ Runtimeでは00:00の暦日更新と、06:00の前営業日締め・朝運用を 左の「営業」は商品・勤務・帳簿と排他の管理パネル。施設を選び、価格5段階と営業状態を確定する。見積額、満足補正、受付済み価格の維持、休業時の待機/利用中の挙動、給与が続くことを表示する。受付や倉庫は開閉のみ。施設ごとの未確定入力は切替で保持する。Escで閉じる。 -施設別の当日記帳は確定給与配賦を含む営業利益と区別し、06:00の帳簿へ誘導する。1280×720では既存の縦スクロールを使う。試遊は `make preview-business`、通常データの保存を更新せずSpaceで時間を再開できる。 +施設別の当日記帳は確定給与配賦を含む営業利益と区別し、00:00の帳簿へ誘導する。1280×720では既存の縦スクロールを使う。試遊は `make preview-business`、通常データの保存を更新せずSpaceで時間を再開できる。 + + +## 従業員の休息・救護表示(2026-09-29) + +体力と休息先への移動・到着、気絶後の救護待ち・迎え・搬送・治療を従業員パネルに表示する。待機室内の人物・休憩人数、医務室の患者、救護EVの2席は実際のRuntime状態から描画する。 diff --git a/docs/plans/original-completion-2026-09-29/FIRST_INCREMENT.md b/docs/plans/original-completion-2026-09-29/FIRST_INCREMENT.md new file mode 100644 index 0000000..39d0959 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/FIRST_INCREMENT.md @@ -0,0 +1,34 @@ +# 初回の実装単位:維持費を通常営業へ接続する + +計画の最初の実装単位。2026-09-29に着手。進捗・試験証拠・残件は [実装記録](execution/UPKEEP_IMPLEMENTATION.md) を参照。 + +## 着手条件 + +P00–P04の必要部分を完了し、費用計上時点・未払い・閉店中/休止中の扱いをD04/D05で整理する。原本が未指定の金額を勝手に本番値として確定しない。既存の00:00決算・3連続赤字・保存復元を保護する。 + +## 5つの小さな変更 + +| 順 | 変更 | 主な対象 | 受入 | +|---|---|---|---| +| E01 | 運用費の仕様と例 | 原子要件台帳、未設定値台帳 | 営業/閉店/休止/日跨ぎ別の期待計上を具体的な台帳例にする | +| E02 | 版付きの課金状態 | 保存/移行サービス、FacilityInstance周辺 | 旧セーブ読込時に過去費用を二重徴収しない。途中保存から再開可能 | +| E03 | 一度だけ計上する処理 | 新規の運用費サービス、EconomyService | 同一営業日/施設/費目の再送で重複しない。資金不足時の債務処理が決まった規約通り | +| E04 | Runtimeと00:00決算へ接続 | SimulationRuntime、OperationsCoordinator | 通常Tickの支出が施設別帳簿・現金・損益と一致。3連続赤字の既存判定を維持 | +| E05 | UIと縦断検証 | OperatingLedgerPanel、BusinessManagementPanel | 通常建設→営業→閉店/休止→00:00→保存→再起動を操作し内訳一致 | + +各変更で影響する既存試験を実行し、E05で全体回帰を実行する。いきなり借金・税・天候まで同時に入れず、維持費の一連の経路を完成させてからP05の次の費目へ進む。 + +## 具体的な試験ケース + +- 稼働施設1件と複数件で費目・施設・総額が一致する。 +- 建設中/閉店/休止/破壊/撤去の日跨ぎを仕様通り処理する。 +- 23:59保存→00:00→ロード再開で計上結果が一致する。 +- 課金直前/直後に保存し、再送でも二重課金しない。 +- 資金不足・部分支払い・未払いの扱いと翌日の処理が一致する。 +- 既存セーブ移行で過去確定日の収支を書き換えない。 +- 追加費用による黒字→赤字、連続2回→3回の終了を検証する。 +- 画面上の費目合計、台帳総額、施設別損益の照合差額が0。 + +## この単位の完了証拠 + +要件ID、変更ソース、採用した費用規約、試験ログ、代表セーブ、実画面、ソースハッシュを同じ検証記録へまとめる。「maintenance_goldを読むコードを追加した」だけで完了にしない。 diff --git a/docs/plans/original-completion-2026-09-29/IMPLEMENTATION_PLAN.md b/docs/plans/original-completion-2026-09-29/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..f452ef8 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/IMPLEMENTATION_PLAN.md @@ -0,0 +1,462 @@ +# 原本準拠・全実装に向けた作業計画 v2 + +作成: 2026-09-29。状態: 自己レビュー反映済みの計画。実装着手・完了を示すものではない。 + +## 1. 目標と前提 + +保存済み原本75要件群(機能・内容71、工程4)と会話で追加された施設主要10項目を、通常プレイ・保存復元・UI・配布版の証拠まで追跡する。原本の最新再取得はP00で行う。今回の計画は2026-09-29監査に基づく。 + +対象はローカル制作コピー `/Users/korizatou114/Projects/とりにく工房ゲーム制作部`。既存の変更を保全し、Driveコピーを制作対象にすり替えない。The Towerは操作/観察体験の参考であり、提供ゲームの画像・音・プログラムを取り込む前提にはしない。 + +現状: 検証済み8、一部実装52、実装あり全受入未検証2、該当機能未実装9、工程4。原本30施設は通常登録12・定義のみ3・対応定義なし15。90スイートPASSと全原本実装は別。75群は粒度が異なるため完成率の分母にしない。 + +本計画は全原本の対応先を落とさず記録するが、露骨な性的場面や性的強要の表現制作は具体化しない。該当内容は未制作のまま台帳で識別し、非露骨なイベント構造・状態・資産管理とは分ける。未制作内容が残る限り原本全実装とは認定しない。 + +## 2. 完了条件 + +各原子要件について、(1)原本条件、(2)定義、(3)実行サービス、(4)通常UI入口、(5)保存/移行、(6)境界試験、(7)画面/配布版、(8)証拠が揃うこと。関係しない項目は理由付きN/Aとし、未実装をN/Aにしない。 + +最終条件は、対応漏れ0・未承認の原本差異0・現仕様で必須の値の未設定0(明示的に未設定の拡張仕様は除く)・未制作必須資産0・重大不具合0・Windows実機の同一ビルド合格。工程4件は機能PASSから除き、現在の工程達成と過去日程の達成を区別する。ユーザーが内容を変更した場合は変更承認を記録し「改訂仕様準拠」と表記する。 + +## 3. 実装順序と到達点 + +| 段階 | 作業 | 到達点・次段階への条件 | +|---|---|---| +| 0 | P00–P02 | 原本/要件/既存回帰の基準を固定。決定待ちを切り分ける。 | +| 1 | P03–P04、P22 | 保存契約・更新順・素材仕様が揃い、手戻りを減らす。 | +| 2 | P05–P07 | 費用・停電・天候が実際の営業へ作用する。 | +| 3 | P08–P14、P23 | 原本施設の配置と営業、交通、レイヤー画面が通常操作で通る。 | +| 4 | P15–P19、P24 | 客/職員/人物/関係/イベントの状態と操作がつながる。 | +| 5 | P20–P21、P25–P26 | 進行・防衛・演出・制作内容を完成へ接続する。 | +| 6 | P27–P29 | 横断試験→性能/長期プレイ→Windows配布版→最終監査。 | + +段階番号は見通しであり、厳密な着手条件は各パッケージの依存関係。P08はP03後、P22はP01後、P23はP08/P22後に先行できる。これは作業順の分離であり、エージェントへの並列委任を自動的に許可するものではない。 + +主要な長い依存経路は P00→P01/P02→P03→P04→P05/P06→P09→P12/P14→P15→P16→P17→P18→P19→P20→P26→P27→P28→P29。素材制作・仕様回答も同程度に納期へ影響し得る。 + +## 4. 作業パッケージ + +規模は暫定の技術作業人日: S=1–3、M=3–7、L=7–15。1人日は担当者の集中作業量の単位であり、AIの実行時間や日数保証ではない。Lは着手時に縦に完結する変更へ分割する。以下の受入は共通完了条件に追加する。 + +### P00 原本・作業対象の固定 + +- 段階: 0 / 規模: S(1–3人日、暫定) +- 前提: なし +- 原本: B-1.1, B-14.1, B-14.2, B-14.3, B-14.4 +- 実装: 指定Drive原本を再照合し差分を保存。ローカル制作コピーの全ソース・現在のセーブ形式・既存成果物を識別する。今回計画の原本は保存版であり最新取得済みとは扱わない。 +- 受入: 原本SHAと変更履歴、制作対象、現在の実装証拠が再現可能。原本更新があれば影響要件を再評価。 +- 決定待ち: なし + +### P01 原子要件と受入台帳 + +- 段階: 0 / 規模: M(3–7人日、暫定) +- 前提: P00 +- 原本: B-1.1, B-1.2, B-1.3, B-1.4, B-1.5, B-1.6, B-14.1, B-14.2, B-14.3, B-14.4 +- 実装: 75複合要件を単独判定できる条件へ分割。原本の各文、追加10項目、UI入口、Runtime、保存、試験、資産、完了責任を対応付ける。 +- 受入: 全原本文に対応先があり、未設定・未実装・未検証が別状態。派生仕様の独自ルールを確定仕様に昇格させない。 +- 決定待ち: D01, D02, D03, D04, D05, D06, D07, D08, D09, D10 + +### P02 既存機能の回帰基準 + +- 段階: 0 / 規模: M(3–7人日、暫定) +- 前提: P00 +- 原本: B-3.1a, B-3.1b, B-3.3, B-3.4a, B-3.4b, B-4.2, B-4.3c, B-5.4 +- 実装: 時計、停止中建設、最高欲求、混雑、決算、赤字終了、救護/EV同伴/休息の既存証拠を新規開発の基準にする。必要な代表セーブを固定。 +- 受入: 日付・入力・乱数・セーブを固定して再実行できる。既存PASS8群を新仕様導入で失わない。 +- 決定待ち: なし + +### P03 保存形式と移行契約 + +- 段階: 1 / 規模: L(7–15人日、暫定) +- 前提: P01, P02 +- 原本: B-2.1, B-4.1, B-5.2, B-6.3, B-7.2, B-8.1, B-9.1 +- 実装: 施設、会計、客、職員、関係値、天候の新項目を版管理。原本と異なる関係値は一対一変換を仮定せず旧値を保全。移行は検証成功時のみ確定する。 +- 受入: 旧版/欠損/未知版/破損/途中書込の試験。失敗時に元セーブを変更しない。新しい状態は途中保存から同一結果で再開。 +- 決定待ち: D06, D07 + +### P04 更新順序・責務の整理 + +- 段階: 1 / 規模: M(3–7人日、暫定) +- 前提: P03 +- 原本: B-1.3, B-1.4, B-3.2, B-9.2 +- 実装: 一分更新、00:00決算、時間帯・天候更新、施設稼働判定、客選択の適用順を仕様化。新機能は分離サービスで実装しRuntimeから接続。全体の一括書換えはしない。 +- 受入: 同一Tickでの閉店/停電/退出/課金順が決定的。コマンド再送で二重課金・二重生成しない。 +- 決定待ち: D05 + +### P05 全収支・維持費・税・借入 + +- 段階: 2 / 規模: L(7–15人日、暫定) +- 前提: P04 +- 原本: B-3.4a, B-3.4b, B-9.1, B-9.2, B-9.3 +- 実装: 表/裏収入区分、維持費、返済元本/利息、税、未払いを帳簿に接続。金庫上限・購買・銀行のサービス契約を先に作る。決算損益と現金収支を区別。 +- 受入: 3営業日の費目別総額が台帳・施設別・資金と一致。借入元本を売上にしない。支払い失敗/再送/復元/3赤字を検証。 +- 決定待ち: D04, D05 + +### P06 マナ枯渇と全館停止 + +- 段階: 2 / 規模: L(7–15人日、暫定) +- 前提: P04 +- 原本: B-2.6, B-9.1, B-10.6, B-10.7 +- 実装: 供給/蓄積/消費と世界樹初期源の扱いを整理。全館停止時のサービス、EV、物流、救護、入館と安全な復帰を接続。休止状態は停電復旧で解除しない。 +- 受入: 残量境界、停止中利用者、EV乗車中、搬送中、同日復旧を試験。停止中の売上や生産が無条件に継続しない。 +- 決定待ち: D04, D05 + +### P07 天候・流行・環境 + +- 段階: 2 / 規模: L(7–15人日、暫定) +- 前提: P04 +- 原本: B-2.3, B-2.6, B-3.2, B-3.5, B-4.5, B-11.1 +- 実装: 雨/嵐/マナ霧、流行ニュース、ゾーンの採光/遮音/隠蔽、景観/清潔/騒音を段階導入。係数と乱数系列を保存しHUDと予測へ表示。 +- 受入: 嵐で新規入館0、雨で来客減/滞在延長。天候変化の瞬間と保存再開が一致。環境変更に応じて満足度と対象売上が変わる。 +- 決定待ち: D04, D05 + +### P08 施設寸法・既存配置の整合 + +- 段階: 3 / 規模: M(3–7人日、暫定) +- 前提: P03, P01 +- 原本: B-2.1, B-8.1, B-10.1, B-10.2, B-10.3, B-10.4, B-10.5, B-10.6, B-10.7 +- 実装: 上級客室4×1、宿舎2×1、闇市場4×2へ原本準拠。30施設を正規IDで管理し類似施設を代用扱いしない。旧占有・支持・接続の移行を検証。 +- 受入: 新規建設の寸法/層/地上地下が全30件で一致。旧施設の拡縮で重複・経路断を起こさず、解消不能な移行は元データを保つ。 +- 決定待ち: D06 + +### P09 施設共通契約と追加10項目 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P05, P06, P08 +- 原本: B-8.1, B-8.2, B-8.3, B-8.5, B-9.2 +- 実装: 人数専用上限、staff_min/max、3稼働状態、関連施設、サービス属性、価格、施設レベルを統合。全35既存定義を点検し原本30施設の値を台帳化。 +- 受入: 本番UI→コマンド→Runtime→人数/料金→台帳→保存まで接続。不一致属性でも利用可能。待機者と利用者を混同せず、人数超過/停止中課金/勝手な休止復帰を防ぐ。 +- 決定待ち: D01, D02, D03, D04, D05 + +### P10 宿泊・飲食施設の完成 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P09 +- 原本: B-8.2, B-10.1, B-10.2 +- 実装: 一般/上級/VIP/ドミトリー、食堂/レストラン/酒場/屋台を建設・厨房・在庫・従業員・客AI・UIへ接続。高層/景観要件と厨房依存を含む。 +- 受入: 各施設を通常パレットから建設して一営業日利用し、必要条件喪失と再開、売上、人数上限、保存を検証。 +- 決定待ち: D04 + +### P11 衛生・娯楽・礼拝施設 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P09, P07 +- 原本: B-8.2, B-10.3 +- 実装: 浴場/カジノ/闘技場/礼拝堂の役割、利用、収支、職種、信仰への接続を実装。機能ごとの通常UIを用意。 +- 受入: 4施設が定義だけでなく通常営業できる。礼拝の信仰をランク条件へ接続し、停止・退店・精算を再現。 +- 決定待ち: D04, D05 + +### P12 バックヤード施設完成 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P09 +- 原本: B-2.4, B-5.3, B-5.4, B-8.3, B-10.5 +- 実装: 宿舎・清掃員室を追加し厨房/倉庫/待機/警備/医務を統一契約へ移行。リネンシュート・隠し扉の動線も個別に追跡する。 +- 受入: 7施設と追加設備の職員行動、客の侵入禁止、在庫/清掃/休息/救護を通常プレイで検証。重複救護と孤立物資が残らない。 +- 決定待ち: D04, D05 + +### P13 特殊・地下施設の非描写システム接続 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P09, P12 +- 原本: B-1.6, B-2.5, B-8.2, B-8.3, B-10.4, B-10.6 +- 実装: 原本にある地下/特殊施設の配置、アクセス制御、役割、会計、イベント状態を接続。覗き部屋は対象施設背面と裏通路寸断、地下牢/マナ炉は明示状態遷移として追跡。表現資産は別工程。 +- 受入: 特殊条件が建設時と運用時の両方で効く。無効配置や失われた対象への操作を拒否。イベント状態を保存でき、実装範囲と未制作内容を分けて報告。 +- 決定待ち: D04, D05, D08 + +### P14 垂直交通・ワープ完成 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P06, P09 +- 原本: B-2.4, B-4.3a, B-4.3b, B-4.3c, B-8.4, B-10.7 +- 実装: 客/業務EV、階段にマナワープと費用を追加。待ち時間の経路費用、容量、職員アクセス、停止時退避を統一。魔法砲台はP21で接続。 +- 受入: 全交通を通常建設し経路選択・実移動を検証。待ち時間増で別経路を選択。ワープ中/EV中保存、マナ不足、救護同伴時の人数を検証。 +- 決定待ち: D04, D05 + +### P15 客の全パラメータ・時間帯AI + +- 段階: 4 / 規模: L(7–15人日、暫定) +- 前提: P07, P10, P11, P14, P13 +- 原本: B-3.2, B-4.1, B-4.2, B-4.4, B-4.5, B-9.4 +- 実装: 戦利品、清潔度と汚染伝播、クラス/種族反応、朝夕の移動目的、依存と信用限度の非描写状態処理を追加。最大欲求選択を維持。 +- 受入: 一般/貴族/聖職者/エルフ/魔族等で期待行動を再現。朝下り/夕上りの目的地分布、清潔伝播、装備処分・破産状態を保存再開まで検証。 +- 決定待ち: D04, D05, D08 + +### P16 雇用・職種・労務完成 + +- 段階: 4 / 規模: L(7–15人日、暫定) +- 前提: P12, P15, P13 +- 原本: B-5.1, B-5.2, B-5.3, B-5.4, B-5.5 +- 実装: 雇用区分別費用、全職種業務、生成時特性、獲得/退職/処分の非描写状態処理を整理。乱数特性は生成時に固定しセーブへ保存。 +- 受入: 求人から配置・業務・休息・精算・退職まで通常UIで通る。費用/忠誠/特性効果の変化が可視化され、ロードで特性が再抽選されない。 +- 決定待ち: D04, D05, D08 + +### P17 ユニーク7名・固有効果・成長 + +- 段階: 4 / 規模: L(7–15人日、暫定) +- 前提: P16, P05, P11 +- 原本: B-1.5, B-6.1, B-6.2, B-6.3, B-6.4, B-6.5 +- 実装: 7ヒロインと指定ライバル、無料/削除禁止、業務EXP/Lv99、固有パッシブを実装。アンリの配置階売上+20%など原本の明示数値を優先。衣装/超過勤務/離脱を状態として接続。 +- 受入: 7名全員で固有効果の適用前後、配置変更/同時配置、99上限、削除禁止を試験。24時間警告から継続/中止ができ、保存復元で効果を重複適用しない。 +- 決定待ち: D04, D07, D08 + +### P18 関係パラメータ・盾・確定遷移 + +- 段階: 4 / 規模: L(7–15人日、暫定) +- 前提: P03, P17 +- 原本: B-7.1, B-7.2, B-7.3 +- 実装: 原本の感情値と盾の非描写シミュレーションを専用モデルに分離。好感度と盾最大、軽減、接触、閾値、ルート固定を接続。既存の汎用関係値を黙って同義変換しない。 +- 受入: 境界値・盾破壊前後・同配置/別配置・一度確定したルートの再読込を試験。操作と表示の値が一致し、旧セーブ移行の意味を説明できる。 +- 決定待ち: D04, D07, D08 + +### P19 イベント・決闘・行動・履歴基盤 + +- 段階: 4 / 規模: L(7–15人日、暫定) +- 前提: P18, P05 +- 原本: B-7.1, B-7.4, B-7.5, B-7.6, B-11.3 +- 実装: 原本の決闘種別、プレイヤー行動、結果、履歴、人物外見状態を非描写のデータ/遷移/画面として実装。専用カメラは証拠IDとイベント基盤を接続。内容制作はP26に分離。 +- 受入: 各分岐が到達可能で勝敗/費用/盾/履歴を一度だけ反映。中断・対象離脱・取消・ロード後も矛盾なし。専用画面は中立テスト内容で操作を検証。 +- 決定待ち: D04, D07, D08 + +### P20 ランク・信仰・物語進行 + +- 段階: 5 / 規模: L(7–15人日、暫定) +- 前提: P05, P11, P15, P19 +- 原本: B-1.1, B-1.2, B-1.3, B-1.4, B-1.5, B-6.4, B-6.5, B-8.1, B-8.5 +- 実装: 信仰/評判/裏影響、ランク条件、施設/方針/スキル解放、章とエンドを接続。条件評価APIだけでは進行完了としない。 +- 受入: デバッグで条件値を直接書き込まず新規ゲームから最終ランクと本編終点へ到達。条件未達の理由が画面で分かり、分岐復元が一致。 +- 決定待ち: D04, D08 + +### P21 防衛・監査・銀行・砲台 + +- 段階: 5 / 規模: L(7–15人日、暫定) +- 前提: P05, P13, P14, P17 +- 原本: B-1.3, B-6.4, B-6.5, B-9.3, B-10.7, B-11.4 +- 実装: 原本の脅威発生から警告、準備、戦闘、能動スキル、魔法砲台、結果、施設損傷・休止へ接続。返済滞納と襲撃を接続。 +- 受入: API直呼びでなく通常営業から発生。勝利/敗北/退避/破壊・摘発/復元を検証。敗北で意図しない恒久ロストや勝手な休止解除がない。 +- 決定待ち: D03, D04, D05, D08 + +### P22 素材台帳・量産仕様の先行確定 + +- 段階: 1 / 規模: M(3–7人日、暫定) +- 前提: P01 +- 原本: B-12.1, B-12.3, B-12.4, B-13.1, B-13.2, B-13.3, B-13.4, B-13.5 +- 実装: 全施設Lv1〜3×表裏、7人物、状態差分、背景、UI、音響の必要枠を列挙。既存Kenney素材と新規制作を区別しサイズ/命名/ライセンス/差替契約を定義。 +- 受入: 各資産が利用要件・画面・状態・制作者・仮素材/完成品に紐付く。必要数未確定のまま完成時期を断定しない。 +- 決定待ち: D08, D09 + +### P23 タワー画面・レイヤー演出 + +- 段階: 3 / 規模: L(7–15人日、暫定) +- 前提: P08, P22 +- 原本: B-2.2, B-11.1, B-11.4, B-12.1, B-12.2, B-12.4 +- 実装: 非選択層の半透明/ワイヤー、波紋切替、上下移動/ズーム/視認性、原本に沿ったHUD配置と表裏スキンを実装。座標変換と選択判定を共通化。 +- 受入: 切替前後に選択・クリック座標が一致。パン/ズーム中の建設、異層施設の視認性、連続切替、速度x3で破綻しない。 +- 決定待ち: D09, D10 + +### P24 インスペクター・通知・操作完成 + +- 段階: 4 / 規模: L(7–15人日、暫定) +- 前提: P09, P15, P16, P18, P23, P19 +- 原本: B-11.1, B-11.2, B-11.3, B-11.4, B-13.4 +- 実装: シフトのドラッグ配置、欲求/思考ログ、感情/盾、料金内訳、稼働阻害理由、接続線、フキダシ、カットインを接続。クリックによる代替操作も用意。 +- 受入: 無効ドロップで状態を壊さず理由を表示。保存後も対象選択を回復。ログの説明と実際の選択が一致。モーダル/停止/再開の入力を検証。 +- 決定待ち: D09 + +### P25 ドット人物・施設差分・音響接続 + +- 段階: 5 / 規模: L(7–15人日、暫定) +- 前提: P22, P23, P12, P17 +- 原本: B-12.1, B-12.3, B-12.4, B-13.2, B-13.3, B-13.4, B-13.5 +- 実装: 状態駆動の歩行/作業/搬送/休息、施設Lv差分、背景、表裏BGMクロスフェード、環境/操作音を接続。実シミュレーションの状態を演出源にする。 +- 受入: 停止中は人物移動が進まない。搬送/休息は実位置に一致。音量/連続切替/ロードで二重再生しない。完成資産の欠損とライセンス記録漏れ0。 +- 決定待ち: D08, D09 + +### P26 シナリオ・立ち絵・内容資産の制作受入 + +- 段階: 5 / 規模: L(7–15人日、暫定) +- 前提: P19, P20, P22 +- 原本: B-1.1, B-1.5, B-1.6, B-7.5, B-7.6, B-12.1, B-12.3, B-13.1, B-13.5 +- 実装: 全章・人物別ルート・終点・台詞・立ち絵・音声を台帳で制作/接続/受入。成人向け内容は表現仕様と制作可否を別途確認し、今回は非露骨な構造・資産管理・分岐検証のみ計画対象とする。 +- 受入: 仮素材や未制作の場面を完了扱いしない。全必要分岐と資産の欠損を一覧化。制作できない内容は原本との差分として残し、全実装完了を宣言しない。 +- 決定待ち: D08, D09 + +### P27 統合・境界・障害復旧試験 + +- 段階: 6 / 規模: L(7–15人日、暫定) +- 前提: P10, P11, P12, P13, P14, P15, P16, P17, P18, P19, P20, P21, P24, P25, P26 +- 原本: B-1.1, B-1.3, B-14.4 +- 実装: 新規営業/停電/嵐/救護/決算/破産/襲撃/ルート進行の横断シナリオを実施。代表地点で保存・終了・起動を挟む。失敗を再現ケースに固定。 +- 受入: 全原子要件に合否証拠。進行不能・セーブ破損・帳簿不整合・原本との未承認差異0。未設定や未制作をスキップPASSしない。 +- 決定待ち: D01, D02, D03, D04, D05, D06, D07, D08, D09, D10 + +### P28 性能・操作感・長期バランス + +- 段階: 6 / 規模: L(7–15人日、暫定) +- 前提: P27 +- 原本: B-1.1, B-3.1a, B-4.3a, B-4.3c, B-11.1, B-12.1, B-14.4 +- 実装: 既存高負荷条件でCPU/経路/描画/保存を測定。通常プレイのラッシュ/満室/負荷ピークと長期経済を評価。20〜30時間本編・50時間以上全攻略は実プレイ記録で確認。 +- 受入: 既存性能予算と新しいWindows描画目標を満たす。到達不可能な収支/信仰/ランク条件なし。高速自動再生だけでプレイ時間・操作感を合格にしない。 +- 決定待ち: D04, D08, D10 + +### P29 Windows実機・配布版・最終監査 + +- 段階: 6 / 規模: M(3–7人日、暫定) +- 前提: P28 +- 原本: B-1.1, B-14.1, B-14.2, B-14.3, B-14.4 +- 実装: 実EXEで新規→建設→営業→保存→終了→再起動→継続を操作。入力/表示倍率/音/日本語パス/回復を確認し、ビルドとソースのハッシュを固定。75要件と追加仕様を最終再監査。 +- 受入: 同一リリース候補のソース試験・PCK試験・Windowsネイティブ試験・プレイ記録・資産台帳が揃う。原本差異0で初めて全実装完了。工程の過去納期達成は別判定。 +- 決定待ち: D10 + +## 5. 工数と日程の扱い + +30パッケージの技術作業単純合計は **180–390人日**。監査ベースの粗い仮置きで、確度は低い。制作量未確定の脚本/絵/音声、外部発注待ち、ユーザー判断待ち、実プレイ時間は含めていない。P26の見積は台帳・接続・受入の技術作業のみ。予備枠として技術作業の30%を別途確保する案とし、P01/P22後に実績で見直す。 + +人数や稼働率が未指定のため固定の発売日を置かない。各マイルストーン後に残工数・回答待ち・制作待ちを再見積する。原本の2027年12月マスターアップは比較対象の目標であり、本計画から達成可能と断定しない。 + +## 6. 決定待ち台帳 + +未設定値をコードの都合で確定しない。まず影響、選択肢、推奨案、検証方法を具体化し、関連実装へ固定する直前に必要事項をまとめて確認する。現在は計画作成のため、回答を待って全作業を止める必要はない。 + +| ID | 決める事項 | 決まるまで進められること | 固定が必要になる地点 | +|---|---|---| +| D01 | 欲求一致加算の額/率、不足基準、属性割当、複数属性/重複 | 計算契約、未解決状態、非一致利用、テスト入力を実装 | P09の本番課金。未設定を0円確定と扱わない | +| D02 | 全施設の顧客専用上限・職員上限、Lv別の変化 | 人数集計、上限超過拒否、データ表を作る | P09/各施設の本番値。capacityから転記しない | +| D03 | 休止からの復帰、関連施設の複数指定/AND/ORを採用するか | 現指定の永続休止・単一関連先を維持し接続試験 | 新しい解除/複数条件を追加する前。未設定のままなら追加しない | +| D04 | 原本にない経済/天候/環境/役割/技能/成長/依存係数 | 原本明示数値を固定し、未指定値の比較試験を作る | バランス用本番データの凍結前 | +| D05 | 全館停止中の搬送/EV安全処理、費用計上時点、日跨ぎ順序 | 状態遷移案・矛盾ケース・台帳例を作る | P04–P06の更新規約固定前 | +| D06 | 寸法変更で重なる旧配置と旧拡張定義の移行方法 | 移行プレビュー、バックアップ、拒否時の原状保持 | 移行不能な既存セーブへの適用前 | +| D07 | 旧5関係値から原本3値への意味付け、既存進行の扱い | 新旧を保存し、新規ゲームの原本モデルを作る | 旧プレイ進行の変換前。推測変換しない | +| D08 | ルート/章/イベントの内容、成人向け範囲、制作可能な資産、必要量 | 非露骨な状態/分岐/中立テスト資産で接続検証 | P19/P20/P26の内容凍結前。未制作を完成扱いしない | +| D09 | 立ち絵/ドット/音/声の品質と制作担当・予算・権利 | Kenney等既存素材の台帳、差替契約、必要枠の算出 | 外部発注/完成素材受入前 | +| D10 | Windows対象機スペック/解像度/描画目標と最終評価者 | 手元環境の性能・機能を計測 | 性能合否とP29。未接続端末を確認済みとしない | + +D03のようにユーザーが明示的に未設定とした拡張仕様は「決定がないと現仕様を実装できない」わけではない。単一依存・解除なしで現要件を満たし、新たな挙動を発明しない。 + +## 7. 検証計画 + +| 層 | 検証対象 | 合格証拠 | +|---|---|---| +| 仕様/データ | 75群の原子条件・30施設・追加10項目・資産 | 対応表、寸法/層/属性/役割検査、未設定台帳 | +| 部品 | 価格/会計/人数/条件/経路/関係/成長 | 境界/失敗/重複試験。実装をなぞるだけの試験は作らない | +| Runtime | 通常Tick、コマンド、停止、実利用、損益 | 同一シードの実行ログと整合性検査 | +| 保存 | 更新前後/営業中/乗車/搬送/イベント/日付境界 | 復元後同一再生、不正入力の原状保持、旧版移行 | +| UI | 通常メニューから操作し結果を観察 | 実描画の入力・画像・状態一致。サービス直呼びで代替しない | +| 配布 | ビルドに同梱されたデータ | ソース/ビルドハッシュ、PCK試験、Windows実EXE操作ログ | +| 長期 | 新規から本編完走/全分岐/経済/負荷 | 実プレイ記録、到達性表、性能計測、未解決不具合一覧 | + +P02でWindows基準環境を準備し、P10/P14/P23/P24で部分実機確認を行う。P10/P17/P20では初期/中盤/終盤のプレイ試行を行い、問題を最後まで持ち越さない。更新・経路・描画の変更は代表負荷を都度計測する。利用できない環境の結果は未検証として残す。 + +各パッケージでは関連試験を先に行い、統合点で全体回帰を実行する。全体PASS後に通しデバッグへ進み、修正が入った場合は該当回帰と通しシナリオを再実施する。過去ログを現在ビルドの証拠として無条件に使い回さない。 + +横断シナリオ: (A)新規→開業→3日決算、(B)雨→嵐→霧→回復、(C)停電とEV乗車/救護の同時発生、(D)昼夜交代ラッシュと休息、(E)施設依存喪失/破壊/摘発/保存復元、(F)最高欲求と行列/価格/階層、(G)返済滞納→襲撃→結果、(H)ランク解放と全施設、(I)人物進行と全分岐、(J)旧版移行と最終配布。 + +## 8. 性能と互換性の基準 + +既存コードの基準を継続する: simulation_x1平均4ms、simulation_x3 P95 12ms、path_query P95 2ms、主スレッド停滞最大50ms、保存最大5秒、読込最大8秒。これらはシミュレーション等の予算であり描画FPS保証ではない。対象ハードと描画目標はD10で別定義する。 + +既存の20,000マス/200客/50従業員/150施設を回帰条件とし、最終ランクで必要な規模が上回る場合は新しい負荷条件も追加。計測機・ビルド・シード・速度・観測区間・P95/最大値を保存する。メモリは複数回のロード/場面遷移後に安定するかを確認し、持続増加を調べる。 + +サイズ変更、関係値変更、マナ/会計変更は別移行段階とする。旧セーブを上書きしてから検証しない。移行に失敗したデータを「復旧成功」として進めない。 + +各パッケージの実装担当はコードと完了証拠を作成し、受入担当が原本条件を確認する。素材/内容の制作担当はP22/P26で確定する。人数・実名は未割当であり、並列体制や独立レビューを既に確保したとは扱わない。 + +## 9. 初回実装時に行う順番 + +1. P00で原本/ローカルソース/セーブの識別を固定する。 +2. P01で最初に経済・マナ・施設追加仕様の原子受入を作る。残りも台帳から落とさない。 +3. P02の既存回帰を確認し、P03の保存変更を先に実装する。 +4. P04の00:00・天候・停止・課金の順序を決め、P05を維持費→台帳→決算→UIの小さな縦断変更として通す。 +5. P06、P07へ進み、P08/P22の独立準備も順次行う。未回答項目に依存しない作業を継続する。 + +最初の維持費実装は `FIRST_INCREMENT.md` に5つの変更と具体的な受入ケースへ分割した。 + +この順番は今回の計画であり、バックグラウンド実行や定期実行の設定は行っていない。 + +## 10. 自己レビューと改訂 + +別紙 `SELF_REVIEW.md` に初案の問題点とv2の修正を記録。主な改訂は、保存移行の前倒し、経済/マナを施設量産の前に配置、素材設計の先行、通常UIと内容制作の分離、Windows実機と実プレイの独立ゲート、未設定値の局所ブロック化。 + +## 11. 原本75要件の作業対応 + +| 原本ID | 要件 | 実装/検証パッケージ | +|---|---|---| +| B-1.1 | 基本情報・Windows・2D・20〜30時間/50時間以上 | P00, P01, P20, P26, P27, P28, P29 | +| B-1.2 | 地上/地下の二面性経営・観察介入・権威/富 | P01, P20 | +| B-1.3 | 建設→集客→収益→管理→拡張防衛 | P01, P04, P20, P21, P27 | +| B-1.4 | 二層・自律AI・渋滞・信仰と依存 | P01, P04, P20 | +| B-1.5 | 世界観・主人公・7ヒロイン3運命 | P01, P17, P20, P26 | +| B-1.6 | 成人向けルートと経営的搾取 | P01, P13, P26 | +| B-2.1 | グリッド・Y正負・支持・領域拡張 | P03, P08 | +| B-2.2 | 同座標Front/Back・半透明/ワイヤー表示 | P23 | +| B-2.3 | 客エリアと景観/清潔/騒音の満足度効果 | P07 | +| B-2.4 | バックヤード・職員ドア・客通行禁止 | P12, P14 | +| B-2.5 | 覗き部屋の配置制約・通路寸断・証拠収集 | P13 | +| B-2.6 | 地上/地下の採光・マナ・遮音・隠蔽ボーナス | P06, P07 | +| B-3.1a | 10秒=1時間、Pause/x1/x2/x3 | P02, P28 | +| B-3.1b | アクティブポーズ中も建設・指示 | P02 | +| B-3.2 | 4時間帯と朝下り/夕上り/夜行動 | P04, P07, P15 | +| B-3.3 | 昼夜シフト・体力0気絶・救護・通勤混雑 | P02 | +| B-3.4a | 毎日00:00に決算レポート | P02, P05 | +| B-3.4b | 3回連続赤字でゲームオーバー | P02, P05 | +| B-3.5 | 雨・嵐・マナの霧・流行ニュース | P07 | +| B-4.1 | 所持金/戦利品・四欲求・忍耐/清潔/依存 | P03, P15 | +| B-4.2 | 最大欲求優先と距離/行列/価格選択 | P02, P15 | +| B-4.3a | A*とエレベーター待ち時間コスト | P14, P28 | +| B-4.3b | 4種垂直移動のゲーム内利用 | P14 | +| B-4.3c | 重なり時速度50%低下 | P02, P14, P28 | +| B-4.4 | 依存Lv3退店拒否・装備売却・性別別破産 | P15 | +| B-4.5 | 貴族/聖職者/エルフ/魔族のAI特性 | P07, P15 | +| B-5.1 | 一般雇用と奴隷の費用/耐性/売却区別 | P16 | +| B-5.2 | 体力/ストレス/忠誠/技能/ランダム特性 | P03, P16 | +| B-5.3 | 接客/裏方/救護/防衛等の職種 | P12, P16 | +| B-5.4 | シフト休息と気絶回収 | P02, P12, P16 | +| B-5.5 | 求人/市場/破産捕獲・退職金/売買価格 | P16 | +| B-6.1 | ユニーク無料・高成長・解雇売却不可 | P17 | +| B-6.2 | 7ヒロインの指定固有効果とライバル | P17 | +| B-6.3 | 基礎技能と業務EXP・Lv99 | P03, P17 | +| B-6.4 | 配置パッシブ・任意発動・ルート別スキル | P17, P20, P21 | +| B-6.5 | 24時間勤務警告・衣装補正・限定離脱 | P17, P20, P21 | +| B-7.1 | 三感情・盾・決闘の3ルート運命 | P18, P19 | +| B-7.2 | 好感/淫乱/NTR・好感度=盾最大・淫乱軽減 | P03, P18 | +| B-7.3 | 同配置接触→盾破壊→NTR上昇→不可逆陥落 | P18 | +| B-7.4 | 3種決闘・勝利追放/隷属/盾回復・敗北確定 | P19 | +| B-7.5 | 会話/デート/隔離・覗き・薬による各ルート | P19, P26 | +| B-7.6 | 履歴・妊娠/父親・刻印立ち絵 | P19, P26 | +| B-8.1 | 隣接/通路必須とLv拡張 | P03, P08, P09, P20 | +| B-8.2 | 表層全施設・飲食と厨房セット | P09, P10, P11, P13 | +| B-8.3 | 裏層全施設と特殊設備 | P09, P12, P13 | +| B-8.4 | EV/階段/ワープ/通路/職員ドア | P14 | +| B-8.5 | Lv解放の価格/食材/混浴/ハード/イカサマ設定 | P09, P20 | +| B-9.1 | 表金/裏金・マナ全館停止・資材 | P03, P05, P06 | +| B-9.2 | 全収益/人件費/維持/仕入/借金/ランク税 | P04, P05, P09 | +| B-9.3 | 受付/金庫/購買/闇銀行 | P05, P21 | +| B-9.4 | 依存客の娯楽優先・信用超過の破産拘束 | P15 | +| B-10.1 | 宿泊施設の寸法/層/ゾーン | P08, P10 | +| B-10.2 | 飲食施設の寸法/層/ゾーン | P08, P10 | +| B-10.3 | 衛生/娯楽施設の寸法/層/ゾーン | P08, P11 | +| B-10.4 | アングラ施設の寸法/層/ゾーン | P08, P13 | +| B-10.5 | バックヤード施設の寸法/層/ゾーン | P08, P12 | +| B-10.6 | 特殊施設の寸法/層/ゾーン | P06, P08, P13 | +| B-10.7 | インフラ/防衛施設の寸法/層/ゾーン | P06, P08, P14, P21 | +| B-11.1 | 資源/時間/天候/Rank・下部切替・スクロールズーム | P07, P23, P24, P28 | +| B-11.2 | Policy/ドラッグ配置・欲求/思考/感情グラフ/盾 | P24 | +| B-11.3 | カメラ入口・覗きアングル/撮影/介入 | P19, P24 | +| B-11.4 | グリッド/接続警告・フキダシ/ログ・カットイン | P21, P23, P24 | +| B-12.1 | 二面性・高精細ドットとアニメ立ち絵 | P22, P23, P25, P26, P28 | +| B-12.2 | 魔法レンズ風の波紋切替 | P23 | +| B-12.3 | SD各モーション・立ち絵破損/汚れ/刻印差分 | P22, P25, P26 | +| B-12.4 | 表裏UIスキン・BGMクロスフェード・環境音/SE | P22, P23, P25 | +| B-13.1 | 立ち絵/CG/差分 | P22, P26 | +| B-13.2 | SDモーション/エフェクト | P22, P25 | +| B-13.3 | Lv1〜3表裏タイル/背景 | P22, P25 | +| B-13.4 | 表裏スキン/感情/覗き素材 | P22, P24, P25 | +| B-13.5 | BGM/環境/操作/成人向けSE/声 | P22, P25, P26 | +| B-14.1 | 2026/03〜08 プロトタイプ | P00, P01, P29 | +| B-14.2 | 2026/09〜2027/02 アルファ | P00, P01, P29 | +| B-14.3 | 2027/03〜08 ベータ/成人向け | P00, P01, P29 | +| B-14.4 | 2027/09〜12 ポリッシュ | P00, P01, P27, P28, P29 | + +追加施設10項目は全件P09が統合責任を持ち、費用はP05、稼働/マナはP06、寸法はP08、施設データはP10–P13、UIはP24と連携する。 + +計画整合性は `plan_validation.json` を参照。全75要件と追加10項目の対応、依存先存在、循環なしを機械検証した。これは計画の整合性検証であり、ゲーム実装の合格ではない。 diff --git a/docs/plans/original-completion-2026-09-29/SELF_REVIEW.md b/docs/plans/original-completion-2026-09-29/SELF_REVIEW.md new file mode 100644 index 0000000..6a43b87 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/SELF_REVIEW.md @@ -0,0 +1,35 @@ +# 計画の自己レビューと改訂記録 + +2026-09-29。作成者自身によるレビュー。独立した第三者レビューや実装検証を行ったという意味ではない。 + +初案の骨格は「基盤→経済→施設→AI/人物→演出→総合試験」。この順序を、既存セーブ、通常UI、制作物、原本未設定事項、納期を壊さないかという観点で見直した。下表の修正はv2へ反映済み。 + +| No. | 初案・検討時の問題 | v2へ反映した修正 | 確認方法 | +|---|---|---|---| +| R01 | 75複合要件への紐付けだけでは、一部条件を落としても完了にできる | P01で原文単位の原子受入を作り、最後は全条件ANDで判定 | 原文行→原子条件→実装→証拠を検査 | +| R02 | 寸法や関係値を先に変えると旧セーブを破壊し得る | P03で移行契約を先行。P08/P18に保全・不一致時拒否を追加 | 配置重複/支持喪失/意味不明な変換の試験 | +| R03 | 施設量産後に維持費・マナを接続すると収支と稼働条件を作り直す | P09の前提をP05/P06/P08とする | 施設量産の着手時に共通契約の受入を確認 | +| R04 | P15の破産状態とP16の獲得処理が対象施設より先行できていた | P15/P16の依存にP13を追加 | DAG検査、通常配置先まで通す受入 | +| R05 | P24の専用イベントUIがイベント操作APIより先行できていた | P24の依存にP19を追加 | UIから選択→費用/結果→保存を確認 | +| R06 | 最後にまとめてWindows検証すると入力・描画の問題が遅れて発覚 | P02でWindows基準環境を準備し、P10/P14/P23/P24で部分実機確認 | 実EXEの操作証拠。接続不能時は未検証と記録 | +| R07 | 性能と長期バランスが最終工程に偏る | 更新/経路変更ごとに代表負荷、P10/P17/P20でプレイ試行を追加 | プロファイルと初期/中盤/終盤の収支・操作記録 | +| R08 | 未設定の複数依存や休止解除を、全実装に必須の新機能と誤解し得る | D03は拡張時のみ決定。現仕様の単一依存・解除なしは実装可能 | 最終未設定0の条件を「現仕様で必須の値」に限定 | +| R09 | 料金APIがあるだけで加算完了にできる | P09で本番属性・不足判定・人数・見積・実請求・台帳・ロードを一連で受入 | 仮値の単体試験とは別に本番データで操作する | +| R10 | 素材制作を後回しにすると必要数と外部待ちが最終日程を支配する | P22をP01直後から着手可能に。P26の技術と内容制作を分離 | 未制作枠/制作担当/ライセンスの台帳 | +| R11 | 声・CG・露骨な内容を技術パッケージに隠して全実装を約束し得る | 内容制作可能範囲を明示し、未制作を残件として扱う | 仮素材や制作範囲外をPASSにしない | +| R12 | 大きなパッケージの概算を確定納期と誤読し得る | 180–390人日は粗い技術概算と明記。制作・実プレイ・待ち・予備枠を分離 | P01/P22後、各節目で実績に基づき再見積 | +| R13 | 定義登録数19を原本30施設の充足数と混同し得る | 原本30施設と既存35定義の対応を別表で管理 | 通常登録/パレット/役割/寸法を別列で検査 | +| R14 | 項目の担当が曖昧で最後に証拠だけ残らない | 各作業に実装・受入・制作の役割を記載。担当者の実名割当は未指定のまま | 完了証拠作成を実装担当の作業に含める | +| R15 | 原本が変わるとこの計画の前提が古くなる | P00で再照合し、監査SHAを計画に保持 | 差分が出た要件のみ影響分析し再計画 | + +## 修正後の検査 + +- 全75要件に作業の対応先がある。 +- 追加施設10項目に対応先がある。 +- 存在しない依存ID、循環依存はない。 +- 検証済み8群を作り直し対象にせず、回帰対象として維持している。 +- 仕様未設定、コード未実装、内容未制作、実機未検証を別に扱っている。 +- 新規ゲームの正常系だけでなく、旧セーブ・中断・失敗・復旧を各受入に含めている。 +- 計画の整合性とゲームの実装完了を混同していない。 + +実行結果は `plan_validation.json`。以上で計画の抜け漏れが絶対にないことを保証するものではない。P01の原子要件化、P22の必要資産数確定、最初の縦断実装の実績で再レビューする。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ACTOR_SELECTION_RESTORE.md b/docs/plans/original-completion-2026-09-29/execution/ACTOR_SELECTION_RESTORE.md new file mode 100644 index 0000000..b5bb914 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ACTOR_SELECTION_RESTORE.md @@ -0,0 +1,11 @@ +# 従業員・来客の選択復元 + +2026-09-30。P24の「保存後も対象選択を回復」を従業員管理と来客観察へ接続。施設等の選択とは別の任意保存情報として、各実在IDと画面の開閉を保存する。手動保存とRuntimeが実行するチェックポイントは同じ情報収集処理を使う。 + +成功した厳格読込・復旧候補読込で各IDを独立検証し、実際に読み込んだ候補の対象を表示する。閉じていた観察画面も表示せずに対象を復元できるよう、画面生成と表示を分離。存在しないID・不正型・過長ID・未知版・旧形式はゲーム進行を壊さず通常表示へ戻す。読込失敗では現在の選択と表示を保持。未確定の配属・シフト入力は保存対象としない。 + +通常の候補ボタンで2名を雇用し、独立した来客2名をRuntimeの生成コマンドで作成。来客は境界試験用の制御された到着であり、自然入館の受入とは扱わない。通常の従業員ボタン・来客プルダウンを操作し、手動保存、厳格読込、画面破棄/再生成、閉じた画面、2世代の実自動保存候補、失敗、旧版/不正な任意情報を検証。100項目PASS。実描画101項目PASS、画像でバルドの詳細、来客selection_guest_b、所持金910G・食欲83を確認。関連7スイート552項目PASS。 + +ゲーム状態の照合では測定時間を含む通知データだけを除外。読込試行が診断イベントIDを1個採番する場合はその増分を明示照合し、他の採番器・乱数・業務状態は保持して比較する。初回のサービス直接生成は財布等のRuntime横断契約を欠いたため、正しい生成コマンドへ検証用入力を修正。修正前ログを保全した。検証ドライバーの最初の停止はダッシュボードのPASS出力形式を認識しなかったためで、ゲーム試験の失敗ではない。双方の合格形式を読めるよう修正した。 + +証拠: artifacts/actor-selection-2026-09-30/。main.before.gd、main.diff、before*.log、headless.log、rendered.log、targeted.json、verification.json、restored-actor-inspectors.png。最新144スイート全件PASS、全件PASS後の実描画開業通し103項目PASS。追加の別プロセス選択復元は書込103・読込13=116項目PASSし、保存された両ID・画面の開閉、ゲーム状態JSONと型付きdigestが一致した。証拠: artifacts/actor-selection-restart-2026-09-30/。Windows更新は同梱PCK12回・計2,094項目PASS、ZIP CRC/同梱SHA一致で完了(CURRENT_WINDOWS_PACKAGE.md)。同一ビルドWindows実機、P24全項目、原本全実装は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ADAPTER_VALIDATION_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/ADAPTER_VALIDATION_CACHE.md new file mode 100644 index 0000000..c48d862 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ADAPTER_VALIDATION_CACHE.md @@ -0,0 +1,10 @@ +# Route adapter validation cache + +P28 partial performance work. Route invariant validation now retains one private successfully parsed snapshot, like the facility projector. A fresh serialized snapshot is compared by complete content on each call; changed records/revisions/quarantine state are re-parsed. Live source/graph checks remain outside the cache. Restore still always invokes the strict parser. All snapshot arrays are independently allocated, and no save schema changes were made. This adds memory for one retained adapter snapshot; maximum-history memory acceptance remains outstanding. + +Evidence: +- Route adapter184 checks PASS, including unchanged/repaired history, fingerprint corruption without a revision change, mutated exported containers and independently changed/repaired live graph. +- Facility projector192, Runtime infrastructure162 and Runtime integration20 checks PASS on this production change. The integration suite covers disk save/load and deterministic replay. +- Small production headless fixture: adapter validation average1.423ms →0.424ms; nested projector validation2.536ms →1.533ms; x1 average16.348ms →14.359ms; x3 P9560.411ms →53.828ms. + +Performance budgets4ms/12ms remain failed. No high-load/GPU/Windows acceptance is claimed. Source/log hashes: adapter-cache-validation.json. Performance reports: production-projector-cache.json and production-adapter-cache.json. Snapshot-component diagnostic completed: production-snapshot-costs.json. Snapshot total average7.999ms; OperationsCoordinator.create_snapshot3.622ms; whole-snapshot deep copy1.095ms. These standalone component samples are not an additive phase decomposition. Source inspection identifies recursive tree/shape validation plus serialization in OperationsCoordinator; its corruption rejection contract must remain intact in the next optimization. diff --git a/docs/plans/original-completion-2026-09-29/execution/AMELIA_DONATIONS.md b/docs/plans/original-completion-2026-09-29/execution/AMELIA_DONATIONS.md new file mode 100644 index 0000000..6d093ad --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/AMELIA_DONATIONS.md @@ -0,0 +1,17 @@ +# アメリアの寄付金3倍 + +原本6.2「寄付金3倍」、6.4「配置するだけで自動発動」を礼拝堂の価格経路へ接続。ユニーク雇用・聖職者・ID heroine_amelia の人物が配属された礼拝堂を対象とし、他の礼拝堂や宿泊料金へは波及しない。出勤状態ではなく配置を参照する。 + +既存の寄付を礼拝サービス料金として扱う契約に沿い、価格方針適用後の寄付額を3倍、その後に既存のアンリ配置階補正を一度適用。標準20Gは60G、アンリ併用では72G。客の支払額と施設売上は一致し、受理済みの見積は料金変更や保存復元で書き換わらない。営業画面の現在値・変更後予測・効果説明にも反映。 + +通常加入の初期能力・加入条件は未設定のまま。試験では register_staff によるユニーク人物fixtureを使用し、その後の配置は従業員画面、来客は通常AI、礼拝・支払・信仰加算はRuntimeで動かした。アメリアの通常加入、回復速度2倍、人物の全実装を完了と扱わない。 + +検証: 専用macOS実描画39チェックPASS。料金60G、別施設・別職種・一般雇用への非適用、アンリ併用72Gと解除、礼拝中に90Gへ変更しても受理済み60G維持、復元、客支出と帳簿一致、信仰は寄付額に関係なく完了ごと1を検証。営業画面gameplay.pngで60G・寄付金3倍・信仰+1を目視確認。 + +関連回帰: 通常礼拝堂16、アンリ48、営業画面72チェックPASS。初回2回の専用試験は信仰を1で固定していたため失敗した。他の自律客も礼拝を完了して信仰2となっており、礼拝完了件数との一致に改めた。失敗ログも保持する。 + +証拠: artifacts/amelia-donations-2026-09-29/。全原本・Windows・長期プレイは未完了。通し再起動の最終結果は追記する。 + +## 通し検証完了 + +実描画の別プロセス再起動: 書込80・読込17の計97チェックPASS、両方終了コード0。書込62.68秒・読込47.65秒。source-change.jsonは空、write/read-gameplay.json一致。通常開業・3回決算の回帰であり、アメリア固有の効果試験は上記39チェックで別に検証。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ANRI_APPROVED_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/ANRI_APPROVED_INTEGRATION.md new file mode 100644 index 0000000..2d99f69 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ANRI_APPROVED_INTEGRATION.md @@ -0,0 +1,10 @@ +# アンリの通常加入・料金補正に関するユーザー決定 + +今回の会話でユーザーが以下を明示承認した。 + +1. 「従業員画面で無料加入を選ぶ」。新規ゲーム開始時の自動在籍や未指定イベント条件に置き換えない。 +2. 「利用料金を20%増やし、客の支払額と施設収入を一致させる」。架空の追加収益や補助金として20%を生成しない。 + +原本の配置フロア全施設への効果、雇用/維持費無料、解雇売却不可は維持する。加入処理、画面、配属、料金見積もり、財布/台帳、途中保存の接続は未完了。この文書は実装完了を示さない。 + +接続時の既存コード課題: staff_hire_payload と雇用済みID復元検査は一般候補のみを受け付ける。雇用後検査が FREE_HIRE 固定。候補UIと管理UIの表示名参照も一般候補のみ。アンリの innkeeper 職種と施設の required_roles の整合を明示し、一般職の偽装で代用しない。保存マニフェストの通常候補20件とユニークの別ID一覧を維持して、雇用済み集合の解釈を拡張する必要がある。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ANRI_FLOOR_PRICING.md b/docs/plans/original-completion-2026-09-29/execution/ANRI_FLOOR_PRICING.md new file mode 100644 index 0000000..ba3a3b8 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ANRI_FLOOR_PRICING.md @@ -0,0 +1,27 @@ +# アンリの配置フロア料金+20%を実行処理へ接続 + +ユーザー承認の料金方式を実装。正規加入したアンリの職種 innkeeper と UNIQUE 属性を確認し、通常の従業員配置コマンドで施設へ管理配置する。staff_max と経路の検査は維持する。女将は運搬員などの専門職に変換せず、施設の稼働最低人数には数えない。保存復元側にも同じ管理配置の職種例外を適用した。 + +適用範囲は確定した施設配置のY座標が同じフロアの全施設(表裏双方)。原本「配置するだけ」のため予定として保持される配置から導出し、通勤完了・勤務時間を新たな発動条件にしない。配置解除/配置施設不在で補正なし。同じ人物の効果を積み重ねない。基準料金に営業方針の倍率を適用した後、12000bpを一度適用し、既存料金と同じ整数Gの四捨五入を使う。無料受付は0のまま。 + +自律客の候補見積もり、通常サービス申請の既定価格、営業画面の現行価格と方針変更プレビューを共通計算へ接続。営業画面に「アンリ配置フロア:利用料金+20%」を表示する。受付後は従来通り ServiceRequest/ServiceInstance の価格を固定する。 + +Runtime22項目PASS: 通常建設した上級客室と同階裏層倉庫、業務階段、正規加入、到達可能な管理配置、別階への非適用、264Gの受付/実サービス完了/財布減算/同額施設売上/台帳整合、途中保存と解除後の220G復帰を確認。女将が倉庫の運搬員として稼働人数を満たさないことも確認。支払試験は明示的なゲスト財布・サービス申請の制御fixtureであり、自律客の来館から最終退館までの実プレイではない。 + +macOS描画ありUI19項目PASS: 無料加入と保存、通常画面で受付へ女将を配置、大浴場の24G表示、150%方針の設定後36G表示、効果の説明を確認。画像 anri-floor-assigned.png を視認した。既存営業UI65/従業員UI87もPASS。最初のRuntime試験で保存時の職種照合の不一致を検出し修正した。試験側の誤った台帳API名も修正済み。 + +証拠は artifacts/facility-dimensions-2026-09-29/anri-floor-validation.json。初回の今回画像取得で旧 anri-recruited.png が更新されたため、旧加入マニフェストの画像ハッシュは歴史的証拠。現行画像は専用名へ分離した。 + +残件: 自律来館からの通し営業、配置変更/夜勤等の追加境界、他6名の固有効果・成長・物語、Windows同一ビルド。P17全体/全原本の完了ではない。 + +## 自律客の利用と支払の追加確認 + +Runtime試験を36項目へ拡張し全PASS。通常の建設コマンドで表層階段を追加し、アンリを再配置。客の初期欲求・所持金を制御して生成した後、目的地・経路・価格を指定せず、客自身の選択、階段移動、利用開始、264Gでの宿泊完了を確認した。移動途中の保存を復元すると利用開始時の全スナップショットが一致する。財布の減算264Gと施設売上264Gも一致し、完了後の保存復元・台帳整合が通る。ランダム来館から退館までの実プレイ受入を完了した意味ではない。 + +同じ版で加入UI17項目もheadlessでPASS。今回の変更は追加試験・証拠・進捗記録であり、追加の本体修正は不要だった。証拠: `artifacts/facility-dimensions-2026-09-29/anri-autonomous-validation.json`。 + +## 配置変更・夜勤・撤去境界 + +Runtime試験48項目PASS。同じアンリを上階の昼勤配置から地上階の夜勤配置へ変更し、旧階の料金が220Gへ戻り、新階だけが12000bpとなることを確認。staff_max=0の客室への変更は拒否され、元の配置・補正が保持される。配置中の倉庫撤去はSTAFF_ASSIGNEDで拒否され、施設と効果を保持する。夜勤配置の全スナップショットを3回復元しても完全一致し、補正が累積しない。元の階への再配置でも同じ料金へ戻る。これは予定配置の切替試験であり、一晩の勤務行動すべてを検証したものではない。 + +今回追加した境界では本体不具合は検出されず、本体コードの変更はない。ログ: `artifacts/facility-dimensions-2026-09-29/anri-placement-boundaries.log`。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ANRI_INTEGRATED_UI_REGRESSION.md b/docs/plans/original-completion-2026-09-29/execution/ANRI_INTEGRATED_UI_REGRESSION.md new file mode 100644 index 0000000..952ec0d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ANRI_INTEGRATED_UI_REGRESSION.md @@ -0,0 +1,9 @@ +# Anri integrated UI regression + +make test-ui GODOT=/opt/homebrew/bin/godot completed in 106.08 seconds with exit code 0. Godot 4.7.2, macOS, headless. Source hashes remained unchanged throughout the run. + +20 direct scripts passed 992 checks. The separate-process upkeep save/write and read phases also passed. The direct scripts cover Anri pricing (48), recruitment (17), layer skin, rank, pointer controls, migration, upkeep inspector, rest, rescue, guest observer, room/pixel presentation, dashboard, staff, supply, business, elevator and first operating day (59). The latter includes opening, time progression and daily settlement. + +Evidence: artifacts/facility-dimensions-2026-09-29/anri-full-ui.log and anri-full-ui-result.json (source hashes). The fresh-process evidence was copied to anri-upkeep-write.log, anri-upkeep-read.log and anri-upkeep-result.json in that directory. + +This proves the UI regression scope only, not full original acceptance, Windows native behavior, visual acceptance, all characters/facilities, long-term progression or performance compliance. Known production performance failures and remaining work packages remain open. diff --git a/docs/plans/original-completion-2026-09-29/execution/ANRI_OPENING_GUIDE_ROLE.md b/docs/plans/original-completion-2026-09-29/execution/ANRI_OPENING_GUIDE_ROLE.md new file mode 100644 index 0000000..53dcc9c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ANRI_OPENING_GUIDE_ROLE.md @@ -0,0 +1,11 @@ +# Opening guide must require receptionist coverage + +Confirmed a user-visible integration defect: the guide counted any repeating work assignment at reception as receptionist coverage. Anri can be placed there for her floor passive but is excluded from required operational staffing. Consequently the guide could invite the player to wait for sales while no receptionist was assigned. The same error survived load. + +The guide now checks the facility definition's required roles before counting active staff, repeating plans or legacy shifts. It still accepts an actual receptionist's off-duty recurring plan and does not alter simulation staffing or Anri's floor price effect. + +Before-fix evidence: anri-guide-before.log reports 22 checks with 2 failures (manager mistaken for receptionist, including after load). After-fix Anri UI coverage: 22 checks PASS. The first-operating-day scenario additionally places Anri via staff controls after constructing reachable rooms, verifies the displayed guide remains on staff recruitment and the floor multiplier remains 12000, then restores the ordinary scenario and continues natural arrival, sales and settlement. Read its completed log for acceptance; no synthetic guest was added to that operating-day scenario. + +Evidence directory: artifacts/facility-dimensions-2026-09-29. This fix does not complete other unique characters, full original acceptance or Windows validation. + +Completed: Anri recruitment UI 22 checks PASS and first operating day 63 checks PASS, both exit code 0. Source/log hashes recorded in anri-guide-validation.json. diff --git a/docs/plans/original-completion-2026-09-29/execution/ANRI_ORIGINAL_EFFECT.md b/docs/plans/original-completion-2026-09-29/execution/ANRI_ORIGINAL_EFFECT.md new file mode 100644 index 0000000..1d0972c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ANRI_ORIGINAL_EFFECT.md @@ -0,0 +1,11 @@ +# アンリの固有効果定義を原本へ修正 + +原本 B-6.2 は「配置フロアの全施設売上+20%」、B-6.4 は配置によるパッシブ発動を指定。従来の mvp_unique_characters.json は assigned_floor_patience_loss_multiplier_bp=9000 とし、忍耐減少を軽減する別効果を定義していた。この値は原本準拠ではない。 + +現在のアンリ定義を assigned_floor_revenue_multiplier_bp=12000 へ修正し、カタログのホワイトリストと heroine 必須効果を同じキーへ変更した。旧忍耐キーの不在と12000、返却データの防御コピー、係数の検証を試験する。人物ID、年齢、他の未承認バランス値、ライバル効果はこの修正で変更しない。 + +検証: MvpContentCatalog213、Runtime content/progression108項目 PASS。後者は通常候補雇用、カタログ保存マニフェスト、改ざん拒否、24時間保存再生を含む。Runtime のマニフェストは人物IDを保持し効果の辞書を保存していないため、この定義修正に伴う保存形式変更はない。ソースとログの SHA-256 は anri-original-definition-validation.json。 + +重要な残件: 現 Runtime は unique_characters を通常の hire_staff_candidate に接続しておらず、アンリの配置も売上倍率も営業処理に適用しない。この変更は定義差異の是正のみで、B-6.2/P17 の実装完了ではない。単なる料金+20%で「売上+20%」を代用してよいとも決めていない。通常の加入/配置導線、原本全7名と職種、無料/解雇不可、施設・フロア範囲、シフトとの関係、価格と売上補正の会計契約、保存復元、表示、通常営業の受入が必要。 + +次の作業では既存の一般雇用とユニーク加入を区別し、原本に数値のない加入条件等を仮定せずに接続契約を整理する。忍耐効果が元から Runtime に効いていたとは報告しない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ANRI_RECRUITMENT.md b/docs/plans/original-completion-2026-09-29/execution/ANRI_RECRUITMENT.md new file mode 100644 index 0000000..e6c9eb5 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ANRI_RECRUITMENT.md @@ -0,0 +1,11 @@ +# アンリの無料加入を通常UIへ接続 + +ユーザー指定の「従業員画面で無料加入」を実装。一般候補20名のカタログ区分を保ち、加入可能一覧へ承認済みのアンリだけを加えた。ライバル加入条件は追加しない。アンリは女将 innkeeper/UNIQUE、契約金0/日給0、既存定義の技能・希望勤務帯で加入する。未定の業務適性は一般職の偽装や任意タグで代用しない。 + +既存の検証済み雇用トランザクションを再利用し、保存の加入済みID検査と人物属性の照合を UNIQUE に対応させた。登録の迂回は引き続き拒否し、加入済みアンリの欠落・FREE_HIREへの降格を復元時に拒否する。一般求人のID一覧と保存マニフェストの区分は変更しない。管理画面はユニーク定義の名前と女将の職種名を表示する。 + +通常画面のボタンから申請→重複送信拒否→手動保存→対照状態へ復元→読込→次の時間更新で加入→加入後保存・読込を検証。アンリ本人のUNIQUE属性、所持金不変、日給0、加入済み表示、退職拒否、不正な保存の原状保持を確認。headless13、macOS描画あり15項目PASS。anri-recruited.pngを視認し、アンリ(女将)・日給G0・未配属を確認した。 + +関連回帰: catalog213、Runtime content116(24時間保存再生を含む)、dashboard139、staff UI87項目PASS。ダッシュボードの候補数期待値は一般20+アンリ=21へ更新。初回画像取得は試験がパネルを閉じてしまっていたため、可視性を明示検査するよう修正して再取得した。 + +証拠とSHA-256: artifacts/facility-dimensions-2026-09-29/anri-recruitment-validation.json。通常配属とフロア料金+20%はまだ接続していないため、固有効果・P17・全原本の完了ではない。Windows同一ビルド未検証。 diff --git a/docs/plans/original-completion-2026-09-29/execution/BUSINESS_CUSTOMER_COUNTS.md b/docs/plans/original-completion-2026-09-29/execution/BUSINESS_CUSTOMER_COUNTS.md new file mode 100644 index 0000000..e5392d6 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/BUSINESS_CUSTOMER_COUNTS.md @@ -0,0 +1,7 @@ +# Business panel counts people separately from services + +The business report exposed active service/request counts, but the panel labelled them as people. This undercounted multi-person services and queues. Added active_customer_count (FacilityInstance.customer_count), queued_customer_count (distinct guest IDs from queued requests), and customer_capacity to the read model. Existing active_count/queued_count retain their service/request semantics. The panel now uses actual people and explicitly displays the customer-only limit;0 and unspecified(-1) remain distinct. No capacity values, billing, service lifecycle, or saved fields were changed. + +Controlled grouped UI fixture: one active service/two people and one queued request/three people; explicit limit3, unspecified and zero; original fixture restored before later tests. This verifies display/read-model semantics, not a new group-arrival gameplay feature. Business UI72 checks passed headless and native macOS; chapel16 passed; original facility control result is recorded in the validation file. Native1280x720 screenshot visually reviewed: labels and actions remain inside scrollable sidebar. + +Evidence: artifacts/business-people-2026-09-29/validation.json and logs/screenshots. Prior full128 suite and3-day run predate this display-only change. Facility customer values and other original implementation gaps remain open. diff --git a/docs/plans/original-completion-2026-09-29/execution/CHAPEL_ANRI_WINDOWS_PACKAGE.md b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_ANRI_WINDOWS_PACKAGE.md new file mode 100644 index 0000000..14c1ef2 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_ANRI_WINDOWS_PACKAGE.md @@ -0,0 +1,12 @@ +# Chapel/Anri Windows development package + +Built the current local production source using Godot4.7.2 Windows Desktop export into `build/windows/chapel-anri-2026-09-29`. Export exited0; EXE is PE32+ x86-64. Production-source/asset hashes were captured before export and remained unchanged through verification. This is an incremental development package, not completion of P29 or the original specification. + +Validation ran each external test driver from an empty temporary directory with `--main-pack`, so game data/implementation came from the PCK rather than the checkout. Opening test explicitly verified absence of raw source project/definition files:28 PASS. Anri normal recruitment/assignment/fee/SaveLoad:22 PASS. Chapel normal construction/cleric/guest/worship/payment/Faith/SaveLoad:16 PASS. The same packed chapel scenario rendered on macOS:17 PASS; packed-chapel.png was visually inspected. These are repeated/overlapping scopes, not83 independent acceptance requirements. No Windows EXE was executed. + +Source regression reference:127 source suites completed before the latest ledger label correction; the correction was subsequently covered by97 checks in the rendered two-process three-close flow. Keep those scopes separate from PCK tests. Performance budgets, full original facilities/stories/assets and Windows native acceptance remain open. + +Pack contains EXE, PCK, Japanese README with current feature/verification boundaries and Windows smoke steps, BUILD_MANIFEST.json, existing Kenney licenses/source records, and Godot license/third-party notices obtained from the installed engine's license APIs. ZIP CRC verified. Build/source/screenshot results are retained under `artifacts/chapel-package-2026-09-29`. + +ZIP: build/windows/TheTree-Windows-chapel-anri-2026-09-29.zip +SHA-256: 08c759e8d4cca0268fdd4d3be0f3f9d79bf390f9cd6deb8749d95dd6dfca6ba5 diff --git a/docs/plans/original-completion-2026-09-29/execution/CHAPEL_APPROVED_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_APPROVED_INTEGRATION.md new file mode 100644 index 0000000..c3a1175 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_APPROVED_INTEGRATION.md @@ -0,0 +1,27 @@ +# Chapel integration - approved balance and first service boundary + +User explicitly approved the proposed initial trial values: construction 1200G, materials12, duration180min; simultaneous customers8; cleric minimum1 and staff maximum2; worship60min, donation20G, completion Faith+1; daily upkeep6G. Original specifies footprint4x3, FRONT. Values are initial trial balance, not original numeric requirements. Queue capacity8, default quality50 and no mana upkeep are provisional inherited/implementation choices, not stated original values. + +Implemented a staged chapel Resource at data/facilities/expansion/chapel.tres and a nonnegative FacilityDefinition.faith_per_service property (default0). DefinitionRegistry rejects negative reward values. Runtime completion now applies the faith resource and progression faith_lifetime metric at the existing service settlement boundary after inventory/payment/output checks. Failure rolls back inventory, economy, resources and progression; the service does not complete. Existing definitions have zero reward. Duplicate paid completion is rejected by the existing payment idempotency key and terminal services do not re-complete after restore. + +The staged test explicitly adds the chapel to its registry, constructs via normal build command, and uses controlled staff availability and payer wallet. It checks dimensions, approved values, unstaffed closure, service start, no early payment/reward, midway restore, donation20G, Faith+1 and lifetime+1, completed reload without duplicate, exact replay, and failed progression with payment rollback and one recovered reward. This is not normal recruitment or autonomous guest acceptance. + +Remaining before normal catalog exposure: cleric recruitment with appropriate wage/hiring balance, catalog/save compatibility, normal construction palette and role labels, autonomous worship selection/arrival/queue/payment, staff-loss interruption, capacity8, upkeep6G and UI faith explanation, native screenshots and Windows. Do not count the chapel as a normally playable completed facility yet. + +Evidence: artifacts/facility-dimensions-2026-09-29/chapel-service.log, chapel-existing-cycle.log, chapel-anri-pricing.log. Their completed outputs are authoritative for pass counts. + +## Normal catalog and cleric recruitment + +Chapel is now registered in the original-facility catalog and main construction palette. A licensed existing theme icon is reused as a placeholder, not claimed as completed chapel art. Added regular cleric staff_uma with Japanese role label. Hiring210G/day wage130G and service skill74 use the existing attendant candidate's initial balance; these staff values are implementation trial values, not original or user-approved facility figures. Original20 candidates remain unchanged. Catalog manifest v2 adds exactly this candidate. The exact historical v1 manifest remains accepted only without the new candidate already present/hired; arbitrary missing IDs are rejected. + +New normal-scene test uses construction/hire/assignment controls, actual clerk commuting/staff projection and controlled arrival followed by autonomous guest selection, arrival, worship,20G payment and Faith/lifetime+1. It also covers original manifest migration, malformed manifest rejection and in-service/completed save. It does not prove random-arrival frequency, all-day economics, guest capacity8 or staff-loss interruption. + +Native macOS verification completed: 17 chapel gameplay checks PASS, with screenshot chapel-gameplay.png visually reviewed. The business panel shows20G per use and Faith+1 per worship completion; HUD Faith1 and today's revenue20G match actual settlement. Chapel art remains generic placeholder room art. Before the final business-text and malformed-legacy-row guard changes, the complete make test-ui passed (including 14 chapel gameplay checks); final targeted native gameplay, business and content tests cover those last changes. + +## Capacity and loss of cleric coverage + +Added tests/mvp/test_chapel_boundaries.gd using normal catalog construction, real cleric hire/assignment/attendance and controlled wallets/service requests for capacity boundaries. Eight services start and a ninth waits separately. A normal unassignment triggers WAITING_FOR_STAFF with frozen progress; no early donation or Faith occurs. The paused state and grace deadline restore exactly, and continued absence fails service without reward/charge. Restoring the staffed checkpoint then completes all nine requests for180G and Faith9, never exceeding8 simultaneous users. These first35 assertions passed. + +The same test continues in a separate fresh operating game (required06:00 start), builds and staffs a chapel and advances two normal days without inserting guests/services. It checks partial-day upkeep, the full-day6G rate, natural donation/Faith reconciliation and close/reload boundaries. Its final log, not this plan text, is the authority for full acceptance. An initial fixture attempted to enable operations midway, violating the existing start-time contract; corrected to a new game without changing production rules. + +Completed boundary/operating run: 46 checks PASS, exit0. First partial-day upkeep3G and next full-day upkeep6G; positive natural donation revenue equals Faith*20, lifetime Faith matches held Faith, ledger balanced and no Runtime failure latch. Both midnight boundaries and second-day completed close restore successfully. Evidence: chapel-boundaries-validation.json. This is one seed over two days, not a long-term economy or Windows acceptance. diff --git a/docs/plans/original-completion-2026-09-29/execution/CHAPEL_FULL_REGRESSION.md b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_FULL_REGRESSION.md new file mode 100644 index 0000000..0db0c60 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_FULL_REGRESSION.md @@ -0,0 +1,21 @@ +# Full source regression after chapel integration + +Started tools/run_all_tests.py with Godot4.7.2 and output artifacts/chapel-full-regression-2026-09-29. all-tests.json is incremental and does not establish full completion until the runner exits. Source hashes at launch are in source-start.json. The runner excludes exported-package tests and the kernel module invoked by run_tests.gd. A separate packaged-build acceptance is still required. + +Read-only audit identified obsolete21-facility expectations in Runtime integration, Runtime stairs and exported opening. The two source tests were independently run before changing them and each failed only on that old count. Updated expected count to22, including chapel, and added explicit chapel registration assertion. Targeted reruns: Runtime integration21 checks PASS; stairs67 checks PASS. Export expectations updated for the future rebuilt pack; no exported package has been verified by this action. + +Only these three test files changed after source-start.json during the sweep; the production source was not changed by this regression repair. The full runner will later execute the corrected source tests. Preserve before/after logs instead of treating the original failure as a gameplay defect. Final source hashes and the complete result must be checked when the runner finishes. + +Runner exec session18695 was confirmed live during this turn, processing test_chapel_boundaries.gd. Continue polling the same session; do not restart solely because a poll times out. + +## Public catalog lookup correction during regression + +Read-only audit found that `FacilityCatalog.by_id` omitted ORIGINAL_FACILITY_PATHS. Chapel was therefore registered in normal Runtime construction but returned null from this common query. Expanded the lookup to include original-facility paths without altering baseline/optional expansion lists. Added a contract test that every normally registered resource resolves to the identical definition through the public lookup. Before: one failure out of310 (chapel only). After:310 PASS, exit0. Evidence: catalog-lookup-repair.json and before/after logs. + +The sweep had already run the previous266-check registry test when this correction was made. Production catalog and registry test are two additional files changed since source-start.json, beyond the three count-repair test files. Therefore retain the sweep result and this targeted correction as distinct evidence; do not claim the sweep alone covered one frozen final source revision. No production caller of by_id was found in scenes, scripts or autoload besides the catalog definition; normal Runtime builds its own registry from explicit paths. + +## Completed source sweep + +Runner session18695 exited0. All127 unique source suites passed with zero failures. Recomputed discovery matches executed paths exactly (no missing or unexpected suite). `completion.json`, `all-tests.json` and `source-end.json` retain the result. Five previously documented files differ from launch hashes: three count repairs plus catalog lookup and its regression test. The latter's final310-check run separately passes. This is combined regression evidence, not a single immutable-source full sweep. Export/PCK and Windows are not included. New paired restart-debug fixture/runner were prepared separately and were not part of this discovery list. + +After this successful sweep, started rendered `run_opening_restart_flow.py` into artifacts/opening-restart-debug-2026-09-29. Read its result.json and phase logs for completion; launch alone is not acceptance. diff --git a/docs/plans/original-completion-2026-09-29/execution/CHAPEL_ROOM_PRESENTATION.md b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_ROOM_PRESENTATION.md new file mode 100644 index 0000000..eca4297 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CHAPEL_ROOM_PRESENTATION.md @@ -0,0 +1,9 @@ +# Chapel room presentation + +Replaced the generic chapel table with code-native furnishings: a world-tree glass motif, altar with cloth and candles, and paired pews separated by a central aisle. Visitor anchors share the furniture layout, cap at8 visible people, reserve upper wall space for the altar, and adapt to canonical4x3 and rotated3x4 footprints. Suppressed the generic upper-wall worker counter in this room. Simulation position, service state and finances are untouched. + +Asset sourcing: inspected the existing Kenney Tiny Town/Tiny Dungeon package inventory and existing renderer conventions. Existing packs and character/environment sprites remain under their recorded CC0 licenses (assets/kenney/sources.json and package License.txt). New altar/window/pew geometry is authored in scripts/ui/pixel_tower_art.gd rather than imported bitmap media. No new external assets or rights were introduced. The palette icon still reuses an existing generic theme icon. + +Tests cover8 anchors and non-overlap at0.5/1/2/4 scale and rotated footprint, alongside existing room state/pixel behavior. Native gameplay captures the real worship visitors,20G revenue and Faith1 in chapel-art.png. This is an initial chapel visual, not completion of the original Lv1-3 asset set, all animation states, audio or Windows acceptance. + +Evidence directory: artifacts/facility-dimensions-2026-09-29, chapel-art-rooms.log, chapel-art-pixel.log, chapel-art-native.log, chapel-art.png. The first art capture reused chapel-gameplay.png, so the older normal-validation screenshot hash is historical; the current art evidence now has its own filename and the capture harness supports THE_TREE_CHAPEL_CAPTURE_PATH for future preservation. diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_ACCESS_FOLLOWUP.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_ACCESS_FOLLOWUP.md new file mode 100644 index 0000000..a5771db --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_ACCESS_FOLLOWUP.md @@ -0,0 +1,15 @@ +# 清掃の経路・表示追補 + +2026-09-29。清掃先の最初の通行可能セルが孤立すると、別の入口が到達可能でも作業を開始できなかった。未割当の予約で到達可能な入口を選び直し、移動中の経路喪失時も同じ仕事の目的地を再評価するよう修正。到達可能な既存目的地は維持する。すべて孤立した場合は移動せず待機し、再接続後に再開する。汎用の行き詰まり救済による移動をこの待機には適用しない。 + +保存検証では、清掃目的地が施設に接する正しい位置かという条件と、その時点で歩けるかという条件を分けた。経路喪失中の待機先は幾何的に妥当な位置を維持できる。作業中の床が通行不能になれば作業を中断する。承認された8分/25回復などの値は変更していない。 + +清掃員室の営業画面を「清掃業務を有効にする」「顧客利用なし・清掃員の業務拠点」に変更し、停止時は途中作業も中断すると説明。料金のない施設を顧客受付として案内しない。 + +証拠: `artifacts/cleaning-access-2026-09-29/targeted.json` の6スイートがPASS。経路6項目は実PathServiceと配置を限定したfixtureで、孤立入口/代替/全閉塞/保存対象位置/再接続/対象撤去を検証。通常Mainの建設・雇用・配置・清掃・中断・保存再開は82項目PASS。営業画面・通勤・snapshot ownershipも再試験。`source-hashes.json`に最終ソースを固定。 + +実描画の追加結果は同ディレクトリ `native.log` と `cleaning-room-ui.png`。この再実行は同じ操作シナリオであり、独立した受入項目として重複加算しない。 + +全体回帰は既存セッション59503を継続しており、再起動していない。実行中に本追補のソース変更が入ったため、`cleanliness-runtime-2026-09-29/source-start.json` と同一の固定ソースで全件通過した証拠にはしない。本追補後の関連再試験と、既存全体回帰の結果を別々に扱う。全体終了後に失敗を確認し、最終候補の全体確認・通しデバッグへ進む。 + +なお経路fixtureだけでWindows実機や長期営業、すべての建設/撤去/勤務交代の組合せを受入済みとはしない。清掃専用モーション、満足度/汚染伝播、原本の他の残件は継続中。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_ANIMATION.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_ANIMATION.md new file mode 100644 index 0000000..8c1c545 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_ANIMATION.md @@ -0,0 +1,19 @@ +# 清掃アニメーションと実作業の一致 + +P25 / B-12.3、B-13.2 の部分対応。清掃員の職種とWORKING状態だけでモップ姿勢を選ぶ表示を修正。実際のfacility_cleanタスクがWORKINGで、現地・依存元施設・移動状態等の作業条件を満たす場合だけ清掃姿勢を選ぶ。拠点の待機はidle、移動は既存の歩行表示を使う。 + +既存PixelPeopleの独自描画モップ姿勢を利用し、外部素材の追加はない。保存形式・清掃の数値・課金には変更なし。 + +通常画面を通す清掃テスト90チェックPASS(待機、移動、現地作業、保存復元、完了、中断を含む)。macOS実描画版は画像保存1件を加え91チェックPASS。停止中のアニメーションとシミュレーション分離を含むpixel_tower、room_presentation、staff_rest、staff_rescueの4スイートPASS。working.pngで実位置と清掃中0/8分の表示を確認した。 + +証拠: artifacts/cleaning-animation-2026-09-29/。表示修正後の営業・決算・別プロセス再起動の通し再検証は同フォルダopening-restartで実行中。以前の132スイート合格は表示修正前の証拠であり、今回の全体回帰とは数えない。全P25、全原本、Windows実機の完了を意味しない。 + +## 再起動検証で判明した清潔度の順序不整合 + +opening-restartとrestart-diagnosisは再起動時の状態ダイジェスト比較で失敗。3日分の会計は一致したが、診断用に保存したwrite-gameplay.json/read-gameplay.jsonではfacility_cleanliness.facilitiesの最初の2施設だけが逆順だった。StringNameキーの既定ソートに依存していたため、清潔度スナップショットと清掃予約対象列挙をStringへの変換による明示的比較へ変更した。旧データの施設配列順は受け入れ、出力を正規順序へ揃える。schemaや数値の変更はない。 + +生成ID32件と逆順保存入力の回帰を追加。清潔度モデル49チェック、Runtime、清掃経路、清掃UI90チェックの4スイートPASS。比較から清潔度や配列を除外せず、通しデバッグをrestart-fixedで再実行中。失敗ログも保持している。 + +## 修正後の通し検証完了 + +restart-fixedは両プロセス終了コード0、書込側80・読込側17の合計97チェックPASS。全ゲーム状態ダイジェスト、保存したJSON状態、3回決算の一致を確認。source-change.jsonは空で実行中のソース変更なし。修正前の失敗は隠さず保持した。全体回帰132件の証拠は前のソースに対するもので、今回は変更対象の関連試験と通し再検証を実施した。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_APPROVAL_RECHECK.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_APPROVAL_RECHECK.md new file mode 100644 index 0000000..6883a30 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_APPROVAL_RECHECK.md @@ -0,0 +1,9 @@ +# 清掃承認値の現行実装再確認 + +2026-09-29。清掃員室・清掃業務は回答済みの初期試験値を維持。施設定義は2×1・裏層、350G・資材4・60分、清掃員1人必須/上限2人、顧客0・料金0・維持費2G/日。ポリシーは初期100・上限100、75以下で予約、現地8分、完了時25回復。未定義の汚れ量は補完していない。 + +現行ソースで再実行: 清掃モデル49、Runtime29、経路6、通常UI清掃90、合計174チェック、失敗0。通常UI清掃試験は建設・雇用・配置・実到着・8分作業・保存再現・最終作業前の閉店/停電中断を含む。 + +macOS実描画の通し検証は書込側80・別プロセス再読込側17、合計97チェックPASS、双方終了コード0。書込62.34秒、読込47.89秒。ゲーム状態JSON一致、試験中ソース変更なし。2日目朝の画面で日本語帳簿とドット施設・人物を確認。 + +証拠: `artifacts/cleaning-approved-recheck-2026-09-29/`。今回の追加機能実装ではなく、承認値の反映済み確認と現行コードの再検証。清掃固有試験と開業・再起動試験は別シナリオ。原本全項目、長期プレイ、Windows実機、性能予算の完了を意味しない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_APPROVED_FOUNDATION.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_APPROVED_FOUNDATION.md new file mode 100644 index 0000000..4be2bf3 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_APPROVED_FOUNDATION.md @@ -0,0 +1,31 @@ +# 清掃員室・清掃業務の承認済み初期試験値と実装状況 + +2026-09-29。ユーザー回答 `call_6ktCNjGSwiR0e3x5HukIZKfA` の項目0・1はいずれも「この初期値で試験・調整する」。再承認は不要。原本未指定の数値を原本由来として扱わず、試験用の承認値として管理する。 + +## 承認内容 + +清掃員室: 原本指定2×1・裏層。建築350G、資材4、工期60分、清掃員1人必須・配置上限2人、顧客同時利用0人、利用料なし、維持費2G/日。 + +清掃: 清潔度0〜100・初期100。既存の `cleanliness_decay_per_service` を利用完了時に適用。75以下で予約。現地で8分作業し、完了時25回復、上限100。未定義施設の汚れ量は補完しない。 + +## 今回の成果 + +- `data/facilities/expansion/cleaning_room.tres`: 上記初期値の施設定義。通常カタログ・建設パレットへはまだ登録していない。 +- `data/environment/cleaning_policy.json` と `scripts/environment/facility_cleaning_policy.gd`: 承認値と厳格な読込・検証。欠損、未知版、非整数、不正な境界は既存値を変更せず拒否。 +- `scripts/environment/facility_cleanliness_service.gd`: 利用・清掃完了IDによる重複反映防止、上下限、順序を保持する履歴、保存復元、破損時の原子性、施設削除。 +- `tests/environment/test_facility_cleanliness.gd`: 47 checks、0 failures。Godot4.7.2、macOS headless。`Makefile` test-s2に登録。 +- 証拠: `artifacts/cleanliness-foundation-2026-09-29/approved-policy.log`。初期の新クラスキャッシュ未更新による失敗ログも保存。editor import後にモデル試験が通過し、今回ポリシー検証追加後の47項目も通過。 + +## 次の接続と完了条件(未実装) + +1. Runtimeの施設登録・利用完了・撤去と清潔度を接続。建設・撤去・営業トランザクション失敗時に清潔度も巻き戻す。単体モデルを通常動作済みとは扱わない。 +2. Runtime保存版12を追加し、天候版11の条件を独立定数へ切り出す。旧版は既存施設を100で初期化。新版欠損・孤立施設・不正履歴を拒否。旧版試験データから新項目を除く。 +3. 稼働中の清掃員室に配置された清掃員、実到着、現地8分作業、25回復を接続。閾値75、重複予約、経路なし、勤務交代、休止、停電、撤去、途中保存を検証。既存デモの清掃タスクを本機能の完了と取り違えない。 +4. 通常カタログ・パレット、施設詳細の清潔度と清掃状況、ドット表示を接続。通常UIの建設→雇用→配置→清掃→保存再開を実証。 +5. 関連回帰と通しデバッグ、現在ビルドの配布・Windows実機確認。全原本実装の認定は引き続き未達。 + +今回の47項目は基礎モデル・定義・設定の検証であり、Runtime統合、清掃員室の通常営業、描画、Windowsの合格ではない。P07/P09/P12/P16/P24は完了に変更しない。 + +## 接続後の更新 + +通常ゲーム接続が進んだため、現在の成果と残件は `CLEANING_RUNTIME_INTEGRATION.md` を参照。上記の「未実装」は基礎モデル段階の記録として保持する。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_REGRESSION_RECHECK.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_REGRESSION_RECHECK.md new file mode 100644 index 0000000..34c1e0f --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_REGRESSION_RECHECK.md @@ -0,0 +1,21 @@ +# 清掃統合後の全体回帰と現行ソース再確認 + +全体回帰セッション59503は終了コード1で完了。131スイートのうち130がPASS、`tests/ui/test_main_dashboard.gd` が139チェック中2失敗。建設パレットとアイコン付きボタンの期待値が22のまま残っていたため、通常登録済み23施設に合わせて修正した。従業員候補の22件という別の期待値は維持。 + +途中で経路・清掃員室表示の修正が入ったため、全体回帰が同一ソースで実行されたとは認定しない。`artifacts/cleaning-regression-final-2026-09-29/recheck.py` が、最後の共通実装変更より後にログを新規作成して開始した105スイートの合格を保持し、それ以前の25件・失敗した画面試験・新規経路試験の計27件を現行ソースで再実行している。テスト自身の変更は当該エントリを再実行対象にし、Makefileの追加は直接起動されるGodotスイートの実装変更とは区別。 + +再確認の実行ハンドル26042。`selection.json`に根拠、`rechecks.json`に進捗、`source-start.json`にソース識別を記録。終了時は全132件の網羅性・失敗0・再確認中の全対象ソース不変を `verification.json` で確認する。現時点は実行中で、全132件PASSとはまだ言わない。 + +この確認が通ってから `tools/run_opening_restart_flow.py --rendered` を新しい証拠ディレクトリで実行し、3回決算とプロセスを終了した後の再起動・ロード・継続の一致を検証する。原本の全実装やWindows実機の完了を意味しない。 + +## 再確認の完了 + +27件の再実行が全件PASSで終了コード0。保持105件と合わせて132/132件を網羅し、失敗0・未実行0。`verification.json`で再確認開始/終了のソースハッシュ一致を確認した。 + +実描画の通しデバッグを `artifacts/cleaning-opening-restart-2026-09-29` へ開始(実行ハンドル19187)。このシナリオは通常開業・3回決算・別プロセスでの再読込を対象とし、清掃員の派遣固有シナリオは別の清掃UI試験で検証している。完了結果は後記。 + +## 実描画の通しデバッグ結果 + +macOS / Godot 4.7.2、実描画で書込側80・再起動側17の合計97チェックPASS、両プロセス終了コード0。通常開業から3回決算し、2日目08:00の保存を別プロセスで読み込んだ継続結果が一致。ゲーム状態比較では計測時間を含むdomain_eventsだけを除外し、他の状態・ID・乱数を比較した。 + +各日損益は-64G、+346G、+346G。3決算すべて帳簿照合一致。`source-change.json`は空配列で実行中の対象ソース変更なし。再起動後3回目決算の画像も確認し、日本語帳簿・施設名・金額の表示を確認した。証拠は`artifacts/cleaning-opening-restart-2026-09-29/`のresult.json、write.log、read.log、expected.json、3画像。Windowsネイティブ、全原本、性能予算、長期プレイの合格を示すものではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_RUNTIME_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_RUNTIME_INTEGRATION.md new file mode 100644 index 0000000..d929292 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_RUNTIME_INTEGRATION.md @@ -0,0 +1,40 @@ +# 清掃の通常ゲーム接続(2026-09-29) + +作業対象: ローカル制作コピー。承認値は `CLEANING_APPROVED_FOUNDATION.md` を継承。P03/P07/P09/P12/P16/P24の増分実装であり、各パッケージや全原本の完了判定ではない。 + +## 接続内容 + +- 清掃員室を通常カタログ・建設パレットへ登録。2×1裏層、350G・資材4・60分、清掃員1〜2人、顧客0人、維持費2G/日。通常登録施設は22→23。 +- 通常建設完了で清潔度100を作成。定義済みの利用後汚れ量を成功したサービス精算に含める。未定義の汚れ量は追加しない。撤去で当該状態・履歴と未完了の清掃作業を除去。 +- 75以下で `facility_clean` タスクを一施設一件予約。稼働中の清掃員室へ実出勤した清掃員を既存Staff/Shift/Path/Taskで派遣し、到着後8分で25回復。移動中・未完了作業には回復なし。完成IDで二重反映を防止。 +- 出張清掃中の職員は清掃員室の業務従事者として稼働を維持する。一般の固定勤務地職員の在席判定は変えない。閉鎖・停電で作業を取り消し、部分回復を与えず、汚れた施設は待機予約へ戻す。 +- 清掃の時間は通常のゲーム時間で進む。ユーザー向け汎用タスクコマンドで生成や進捗の早送りはできない。 +- Runtime保存版12。天候の導入版11を独立定数として維持。旧版は既存施設を100から開始し、過去の利用から汚れを推測しない。新版では欠落・孤立した施設や存在しない完了履歴を拒否。清掃中の人員・仕事・位置、完成後の効果履歴を復元。 +- 建設・撤去・Operations/Event/Authority/Defenseの施設を含む巻き戻しトークンへ清潔度を含める。 +- 営業画面に清潔度と清掃待ち・移動・作業分数を表示。清掃員室には既存パレットを用いた自作コード描画の棚・モップ・バケツを追加。アイコンは既存テーマのF12を再利用し、新しい外部素材は取り込んでいない。 + +## 証拠と範囲 + +`artifacts/cleanliness-runtime-2026-09-29/`: + +- `focused.log`: Runtime cleanliness 29項目PASS。通常受付建設・利用・汚れ・再開・v11移行・破損拒否・巻き戻し・撤去。清掃員派遣接続前の段階試験。 +- `targeted.json`: 関連18スイートPASS。天候、旧版移行、交通、維持費、決算、礼拝、snapshot ownership、営業画面。これも派遣接続前の段階試験で、最終ソースの全体合格とは区別。 +- `gameplay-boundaries.log`: 操作を含む81項目PASS。通常Main建設・雇用・配置を使用。汚れを直接書き換えず、25件の実サービス完了を発生させた。利用要求の投入は制御された財布/要求fixtureであり、25人全員の自然入館を検証したものではない。通常客の入館も並行して動作し、清掃後は再び汚れる。 +- `gameplay-native.log`: macOS OpenGL実描画、同81項目+画像保存1項目=82項目PASS。1280×720の営業画面で清潔度97を視認確認(清掃後に通常の客が再利用)。 +- `capture-extra.log`: 同じ操作シナリオを実描画で再実行、81項目PASS。`cleaning-working.png`に清潔度75・清掃中0/8分、`cleaning-room.png`に裏層の室内を確認。重複実行を独立受入数として加算しない。 +- `full-regression/`: 最終変更後の全ソース回帰を実行中。実行ハンドル59503。完了前に全件PASSとしない。 +- `source-start.json`: 全体回帰開始時の対象ソースSHA-256。 + +最初のUI試験の2失敗は、清掃後の新規利用による汚れを考慮せず100維持を期待したため。回復の完了記録が一件だけ存在することと、UIがその時点の実値を表示することへ受入条件を修正。ゲーム内の汚れ量は変更していない。 + +## 継続する確認・調整 + +- 全体回帰の完了確認、失敗修正、現在ソース識別、3営業日・再起動の通しデバッグ。 +- 清掃員室に残る汎用営業文言「受付」を業務に合わせる。 +- 複数入口のうち一部が孤立する配置、途中の経路喪失、休止・勤務交代・対象撤去・再開の追加境界試験。現在の予約先は安定順の最初の通行可能セルであり、別の入口だけが到達可能なケースの再選択を改善する。 +- 人物の清掃専用動作、全レベル・表裏の完成素材、清潔度から満足度/客の汚染伝播への原本接続は未完了。 +- 現行Windows配布物は今回の変更を含まない。今回の実描画はmacOSであり、Windows実機確認の代替ではない。 + +## 経路・文言の追補 + +複数入口の再選択と清掃員室専用文言を実装。`CLEANING_ACCESS_FOLLOWUP.md`を参照。全体回帰の途中変更があるため、開始時SHA固定の全件合格とは扱わない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CLEANING_WINDOWS_PACKAGE.md b/docs/plans/original-completion-2026-09-29/execution/CLEANING_WINDOWS_PACKAGE.md new file mode 100644 index 0000000..b26f4c7 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CLEANING_WINDOWS_PACKAGE.md @@ -0,0 +1,25 @@ +# 清掃業務反映 Windows 開発確認版 — 2026-09-29 + +制作元: `/Users/korizatou114/Projects/とりにく工房ゲーム制作部`。 +配布: `build/windows/TheTree-Windows-cleaning-2026-09-29.zip`。 +Godot 4.7.2、Windows x86_64 PE32+ EXEと外部PCK。通常施設23種。 + +承認された清掃員室・清掃業務の初期値、移動・現地作業・中断・保存復元・清潔度表示を配布用データへ反映。既存の礼拝堂・アンリも含む。フィーネとアメリアの効果は含むが通常加入は未接続。 + +## 配布物に対する検証 + +- 空の一時ディレクトリからPCKを読み、ソース側へのフォールバックを排除。 +- `tools/verify_exported_opening.py`: 起動・建設・ディスク保存復元28チェックPASS。 +- `tests/ui/test_cleaning_gameplay.gd`を外部ドライバーとして使用: 清掃91チェックPASS(元90に隔離確認1を追加)。 +- 同じドライバーのmacOS描画あり実行: 92チェックPASS(スクリーンショット保存1を追加)。画面の日本語、清潔度、清掃業務の専用操作を目視確認。 +- 清掃試験は作業途中のスナップショット復元と再実行一致、閉店・停電時の中断、二重完了防止を含む。 +- EXE形式確認、ZIP CRC、同梱ファイルSHA256、ビルド前後の追跡ソース変更なしを確認。 +- エンジンバージョンとKenney素材が前回配布と同じためライセンスを継承し、素材出典ファイルのハッシュ一致を確認。 + +証拠: `artifacts/cleaning-package-2026-09-29/` の export.log、packed-opening.log、packed-cleaning.log、packed-cleaning-rendered.log、packed-cleaning.png、外部ドライバー、source-start/end.json、build-validation.json。 + +## 検証範囲の限界 + +この版のWindows実機操作は未実施。macOSで同じPCKを検証した結果をWindowsネイティブ検証と扱わない。 +ソース側137スイートの検証内訳と描画あり通し97チェックは CURRENT_FULL_REGRESSION.md を参照し、今回のPCK検証とは区別する。 +原本30施設のうち通常実装16、定義のみ1、未対応13等が残る。原本全実装・長期プレイ・性能受入およびP29完了とは判定しない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/COMPACT_OPERATIONS_HUD.md b/docs/plans/original-completion-2026-09-29/execution/COMPACT_OPERATIONS_HUD.md new file mode 100644 index 0000000..4f56964 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/COMPACT_OPERATIONS_HUD.md @@ -0,0 +1,15 @@ +# Compact operations HUD projection + +Measured the main dashboard refresh: approximately9.75ms, including3.64ms spent obtaining a complete OperationsCoordinator snapshot for a short status label. Added get_dashboard_summary on the coordinator and get_operations_dashboard_report on Runtime. They return only live flags, action points, total arrears, terminal/loss/legacy status and the latest close day/profit. UI uses this compact API with old-report fallback for fixtures. Full reports and save snapshots are unchanged. Returned dictionaries/arrays are newly allocated and contain no mutable references to stored summaries. + +Dashboard136 checks PASS. Same production fixture: operations text average3635us before /11.75us after; full x1 average34.410ms /30.374ms. x3 P95124.341ms /122.392ms, still above12ms. No claim of budget compliance or GPU/Windows performance. + +Opening/save/replay test completed with58 checks,0 failures,exit0 (compact-operations-opening.log), including10 new projection-equivalence and defensive-copy checks. Evidence: compact-operations-ui.log, production-ui-costs.json, production-compact-operations.json. + +## Pause/speed projection follow-up + +The dashboard still fetched the full operations snapshot inside `_update_pause_and_speed`. Added `deferred_runtime_minute` to the compact API and switched this consumer to it, retaining fallback for older fixtures. No simulation or save contract changed. + +Targeted dashboard139 checks PASS,exit0: includes injected deferred-state warning/retry labels and clear-to-resume checks. These validate projection/display, not a complete fault-recovery scenario. Production diagnostic (`production-compact-pause.json`) reports x1 average22.057ms, x3 P9575.145ms, dashboard refresh2.009ms. Compared with the preceding compact-text run30.374ms/122.392ms, the measured update is faster; both budgets remain failed. Runs are headless, small fixture, no GPU/Windows acceptance. Guide subprofile (`production-guide-costs.json`) measured refresh1.115ms, so it did not explain the whole previous dashboard cost. + +Latest initial-day/save/replay test completed with59 checks,0 failures,exit0 (`compact-pause-opening.log`), including the added deferred-field equivalence check. Previous58-check pass applies to the preceding increment. diff --git a/docs/plans/original-completion-2026-09-29/execution/COMPOSED_INFRASTRUCTURE_VALIDATION.md b/docs/plans/original-completion-2026-09-29/execution/COMPOSED_INFRASTRUCTURE_VALIDATION.md new file mode 100644 index 0000000..43845bb --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/COMPOSED_INFRASTRUCTURE_VALIDATION.md @@ -0,0 +1,11 @@ +# Reuse composed infrastructure validation without skipping live guards + +Current-scene measurement identified snapshot publication and infrastructure validation as major synchronous costs. A trial homogeneous-key shortcut in Operations canonical-collision inspection passed its254 checks but produced no clear improvement (snapshot component2429.85→2416.4us, whole snapshot6656.8→7311.85us). That trial was fully reverted; the retained Operations source is byte-identical to the before copy. + +Retained change: Runtime infrastructure crosslink validation no longer independently repeats Spatial and RouteAdapter invariant checks when the active FacilityInfrastructureProjector already validates those exact bound services. Added a read-only identity-binding query. Different bindings and quarantine retain the independent source/adapter checks; all projector-owned history validation, live graph/managed namespace checks, excavation, route-cell and construction crosslinks remain. No revision-only cache, validation limit removal, update-rate change or snapshot-format change was introduced. + +Additional integration cases use counted dependencies to verify one source/adapter audit, exact object binding, raw source/adapter corruption rejection, quarantine fallback and different-binding fallback. Current targeted results: infrastructure180, projector192, ordinary opening/SaveLoad/replay63 checks PASS, total435. Existing rollback/callback/strict restore cases remain included in the two infrastructure suites. + +Single paired small-scene measurement: construction phase2515.5→1877.025us (~25% lower), x1 average13309.8→12849.475us (~3.5% lower), x3 P9548441→46529us (~3.9% lower). Seed424242,40 samples per speed, macOS Godot4.7.2 headless, deferred UI refresh included, autosave disabled. These are not repeated statistical estimates or full-load/Windows results. Budgets4ms/12ms remain FAILED; snapshot publication remains the larger remaining cost. + +Evidence: artifacts/runtime-profile-2026-09-29/validation.json and referenced profiles/logs. The chapel/Anri Windows ZIP was produced before this optimization and has not been rebuilt in this step. Original completion and P28 acceptance remain open. diff --git a/docs/plans/original-completion-2026-09-29/execution/CURRENT_FACILITY_AUDIT.md b/docs/plans/original-completion-2026-09-29/execution/CURRENT_FACILITY_AUDIT.md new file mode 100644 index 0000000..458af2d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CURRENT_FACILITY_AUDIT.md @@ -0,0 +1,54 @@ +# 原本30施設・現行登録監査 + +通常登録 16、定義のみ 1、対応定義なし 13。通常登録全23件のうち原本30施設以外は7件。 + +登録状態と読込済み定義の監査。実営業・保存移行・完成資産・Windows・全要件の受入完了は証明しない。 + +対応定義の寸法・層の不一致: 0件。人数専用上限が未設定: 13件。capacityから人数値を転記していない。高層・背面・騒音・サービス条件は別途受入が必要。 + +| 原本 | 施設 | ID | 登録 | 寸法 / 層 / ゾーン | 顧客人数上限 | +|---|---|---|---|---|---| +| 10.1 | 一般客室 | standard_room | 通常登録・パレット掲載 | 2×1 / FRONT / ANY | 未設定 | +| 10.1 | 上級客室 | premium_room | 通常登録・パレット掲載 | 4×1 / FRONT / ANY | 2 | +| 10.1 | VIPスイート | — | 対応定義なし | 未定義 | 未設定 | +| 10.1 | ドミトリー | — | 対応定義なし | 未定義 | 未設定 | +| 10.2 | 大衆食堂 | cafeteria | 通常登録・パレット掲載 | 4×2 / FRONT / ANY | 未設定 | +| 10.2 | レストラン | — | 対応定義なし | 未定義 | 未設定 | +| 10.2 | 酒場 | tavern | 通常登録・パレット掲載 | 4×2 / FRONT / ANY | 未設定 | +| 10.2 | 屋台 | — | 対応定義なし | 未定義 | 未設定 | +| 10.3 | 大浴場 | public_bath | 通常登録・パレット掲載 | 6×2 / FRONT / ANY | 未設定 | +| 10.3 | カジノ | — | 対応定義なし | 未定義 | 未設定 | +| 10.3 | 闘技場 | — | 対応定義なし | 未定義 | 未設定 | +| 10.3 | 礼拝堂 | chapel | 通常登録・パレット掲載 | 4×3 / FRONT / ANY | 8 | +| 10.4 | 風俗個室 | adult_private_room | 通常登録・パレット掲載 | 2×1 / FRONT / UNDERGROUND | 未設定 | +| 10.4 | 高級ソープ | — | 対応定義なし | 未定義 | 未設定 | +| 10.4 | SM拷問室 | — | 対応定義なし | 未定義 | 未設定 | +| 10.4 | 闇市場 | black_market | 定義のみ | 4×2 / FRONT / UNDERGROUND | 未設定 | +| 10.5 | 厨房 | kitchen | 通常登録・パレット掲載 | 4×2 / BACK / ANY | 未設定 | +| 10.5 | 倉庫 | warehouse | 通常登録・パレット掲載 | 4×2 / BACK / ANY | 未設定 | +| 10.5 | 待機室 | staff_waiting_room | 通常登録・パレット掲載 | 4×2 / BACK / ANY | 未設定 | +| 10.5 | 従業員寮 | staff_dormitory | 通常登録・パレット掲載 | 2×1 / BACK / ANY | 0 | +| 10.5 | 警備員詰所 | security_station | 通常登録・パレット掲載 | 2×2 / BACK / ANY | 未設定 | +| 10.5 | 清掃員室 | cleaning_room | 通常登録・パレット掲載 | 2×1 / BACK / ANY | 0 | +| 10.5 | 医務室 | infirmary | 通常登録・パレット掲載 | 4×2 / BACK / ANY | 未設定 | +| 10.6 | 覗き部屋 | — | 対応定義なし | 未定義 | 未設定 | +| 10.6 | 地下牢 | — | 対応定義なし | 未定義 | 未設定 | +| 10.6 | マナ炉 | — | 対応定義なし | 未定義 | 未設定 | +| 10.7 | 蔓の昇降機 | guest_elevator | 通常登録・パレット掲載 | 2×2 / FRONT / ANY | 未設定 | +| 10.7 | 業務用EV | service_elevator | 通常登録・パレット掲載 | 3×2 / BACK / ANY | 未設定 | +| 10.7 | ワープ | — | 対応定義なし | 未定義 | 未設定 | +| 10.7 | 魔法砲台 | — | 対応定義なし | 未定義 | 未設定 | + +## 未接続施設 + +対応定義なし: VIPスイート、ドミトリー、レストラン、屋台、カジノ、闘技場、高級ソープ、SM拷問室、覗き部屋、地下牢、マナ炉、ワープ、魔法砲台。 + +定義のみ: 闇市場。 + +## 次の受入 + +未定義施設は寸法・層だけでなく経済値、人数、業務、通常操作、保存を接続する。登録済み施設は未設定人数とサービス属性、営業時間・停止・経路・客/職員行動を個別検証する。施設追加のたびにこの監査を再実行する。 + +再現: `python3 tools/run_facility_audit.py --godot /opt/homebrew/bin/godot --output artifacts/facility-audit-current-2026-09-29`。原本SHA・入力ソースSHA・取得中の変更なしは同ディレクトリのsource-start/end/change.jsonを参照。 + +前回の15施設登録時点の記録は`FACILITY_AUDIT_BEFORE_CLEANING.md`へ保全。清掃員室の登録と一般客室・上級客室・従業員寮のゾーン修正を現行定義で再取得した。全施設一覧への入口と定義値の監査であり、登録数を完成数とは扱わない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CURRENT_FULL_LOAD.md b/docs/plans/original-completion-2026-09-29/execution/CURRENT_FULL_LOAD.md new file mode 100644 index 0000000..67dc2e2 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CURRENT_FULL_LOAD.md @@ -0,0 +1,15 @@ +# 現行ソースの指定規模・部品統合負荷試験 + +更新注記: GUIDE_ASSIGNMENT_SCAN.mdの受付担当検索の軽量化は本記録より後の変更。本記録の全回帰・配布・負荷結果にこの変更を含めない。 + +2026-09-29。PERF-MVP-001 full tierをmacOS/Apple M4、Godot4.7.2、headlessで実行。検証済み軽量化版ソースから差分0。終了コード0、24項目PASS、スキップ0。 + +実初期値: 20,000マス、200客、50職員、150施設、行列500、物流200、選択待ちイベント50。1,440分(x3、480フレーム)を完走。物流200件完了、在庫非負、貸借一致、部品スナップショット復元を確認。行列最大待ち46分、物流最大待ち4分。 + +x3平均6.948ms、P95 7.419ms(基準12ms)、最大22.204ms(主スレッド停滞基準50ms)。経路P95 0.037ms(基準2ms、5760回中の末尾512件の窓)。全シミュレーション3.365秒。 + +限界: public APIで各部品を組み合わせた専用ハーネスであり、本番SimulationRuntime・スナップショット公開・UI更新・描画を測定しない。物流更新はハーネスが担う。初期の行列と物流は処理後補充せず、終了時は双方0件。イベントは選択待ち50件。このPASSを継続満員・実営業・GPU・Windowsの合格へ拡張しない。x1、全体保存/読込所要時間、長期プレイもこの試験の対象外。 + +現行本番デモではx1平均9.372ms、x3 P95 32.728msで目標未達(SNAPSHOT_CALL_REUSE.md)。異なる試験の数値を直接の改善率として比較しない。次の性能作業は本番Runtimeの統合更新・状態公開・UIコストを対象とし、本番規模での持続負荷受入は別途必要。 + +証拠: artifacts/full-load-current-2026-09-29/{full.log,report.json,verification.json}。実行: godot --headless --path . --script res://tests/performance/test_perf_mvp_001.gd -- --full。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CURRENT_FULL_REGRESSION.md b/docs/plans/original-completion-2026-09-29/execution/CURRENT_FULL_REGRESSION.md new file mode 100644 index 0000000..a77a8e9 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CURRENT_FULL_REGRESSION.md @@ -0,0 +1,13 @@ +# 防衛画面表示更新・全回帰と通し検証 + +2026-09-30。148/148スイートPASS、657.68秒。欠落・余分・重複・失敗・実行中ソース変更0。新Event防衛186項目、表示/選択105項目を含む。 + +macOS実描画の新規開業・3回決算・保存・終了・別プロセス再起動は83+20=103項目PASS(48.06秒/32.18秒)。4日目00:00、所持金4,228G、帳簿一致、営業利益+346Gを実画像で確認した。 + +同じ凍結ソースの関連描画5実行453項目PASS。全回帰の同じソース実ログ5件720項目を参照し、新実行扱いしない。Event処理で生成した襲撃のviewport操作・戦闘途中保存・別プロセス再開は34+32=66項目PASS。状態JSONとtyped digest一致、全DomainEvent/採番/乱数を保全する。Save/Loadの2種類のI/O観測type/payloadのみ明示normalizationする。 + +防衛画面の実OptionButtonと概要には事件名と保存された元の発生日/時刻が表示され、完全IDはmetadataと選択に保持される。write/readの途中・終了の4実観測とnonce・state比較が合格、human_readable_defense_labels=true。実画像で520pxの窓幅と内部ID非表示を確認した。初回は新しい通しdriverの4bool変数の型推論でコンパイル失敗し、型注釈だけを直して再試験した。本番変更は0、失敗ログはfirst-parse-failure/へ保全している。 + +現在ソースと同じWindows版の隔離export・18PCK実行2,926項目・ZIP CRC/ファイルSHA検証がPASS。PCK描画はmacOSで実施。Windowsネイティブ操作、原本全実装、通常脅威producer、性能予算、高負荷/長期プレイは未完了。 + +証拠: artifacts/full-regression-after-event-defense-labels-2026-09-30/、artifacts/event-defense-labels-related-2026-09-30/、artifacts/event-defense-labels-restart-2026-09-30/。source-end SHA256: 0395e515c1cb090408c11c7235b36f8b2015bf350fe412b2d894b286b249ef6c。 diff --git a/docs/plans/original-completion-2026-09-29/execution/CURRENT_PERFORMANCE.md b/docs/plans/original-completion-2026-09-29/execution/CURRENT_PERFORMANCE.md new file mode 100644 index 0000000..a7aa722 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CURRENT_PERFORMANCE.md @@ -0,0 +1,9 @@ +# Current performance regression + +make test-perf and make test-perf-full both exited0 with no script errors. Path/camera compatibility282 checks PASS; digest compatibility/performance71 checks PASS; smoke24 and full24 fixture checks PASS. + +Full fixture: 20,000 cells, 200 guests, 50 staff, 150 facilities, 500 queued customers, 200 logistics requests and 50 active events, simulated1440 minutes at x3. Simulation x3 P95=7.845ms (budget12ms), path query P95=0.036ms (budget2ms), maximum measured main-thread batch22.037ms (budget50ms). No checks skipped. + +This harness composes public domain APIs and orchestrates logistics itself. It does not measure the production SimulationRuntime tick, rendered UI, GPU frame time, Windows, natural campaign play or long-term balance. Geometry helper and digest microbenchmarks are likewise not frame-rate guarantees. These passing measurements do not close P28. + +Evidence: artifacts/facility-dimensions-2026-09-29/current-performance-validation.json with host, full reports and log hashes. No production code changed in this measurement increment. diff --git a/docs/plans/original-completion-2026-09-29/execution/CURRENT_WINDOWS_PACKAGE.md b/docs/plans/original-completion-2026-09-29/execution/CURRENT_WINDOWS_PACKAGE.md new file mode 100644 index 0000000..5fdadff --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/CURRENT_WINDOWS_PACKAGE.md @@ -0,0 +1,11 @@ +# 防衛画面表示更新・Windows開発確認版 + +2026-09-30。build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip を作成した。Godot 4.7.2・debug export・Windows x86_64 PE32+。Runtime13、設定済みイベントからの防衛準備生成、事件名・元の発生日/時刻表示、完全IDによる操作と保存を含む。 + +ZIP: 73,203,090 bytes、SHA256 2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4。PCK SHA256 acd46e235282212392c99db43851285cea33afd1912b960ef92eec796c4616d8。ZIP CRC・全同梱ファイルSHA・5ライセンス/出典・マニフェスト・ソース変更0を検証した。 + +ソース148/148 PASS、開業・3決算・保存・別process再起動103、関連実描画453、イベント防衛保存再開66項目PASS。同梱PCKだけを読む隔離環境の18実行2,926項目PASS(headless2,468、macOS実描画458)。全18実行が同一PCK SHA、4枚captureを検証し、防衛画面と自動保存復旧画像を目視した。ソースの851実行入力を普通copyし、本体のeditor importなしで隔離exportした。import/exportでcopy・本体ソースの変更0。 + +Windowsネイティブ操作は未確認。通常営業から盗賊・暴動・借入滞納・摘発を自動生成するproducer、原本未指定値の決定、全施設・人物・物語・資産、性能予算・長期プレイは未完了。開発確認版であり、全原本受入や最終リリースとは扱わない。 + +証拠: artifacts/windows-event-defense-labels-release-prep-2026-09-30/。表示修正前のsealed event-defense世代準備は実行せず保全した。旧Runtime12 ZIPも保全している。 diff --git a/docs/plans/original-completion-2026-09-29/execution/DASHBOARD_REFRESH_COALESCING.md b/docs/plans/original-completion-2026-09-29/execution/DASHBOARD_REFRESH_COALESCING.md new file mode 100644 index 0000000..aea11e2 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/DASHBOARD_REFRESH_COALESCING.md @@ -0,0 +1,9 @@ +# Dashboard refresh coalescing + +Main scene snapshot notifications now schedule one deferred dashboard refresh per batch rather than rebuilding the same dashboard for every simulated minute before rendering. Snapshot publication and simulation still run each tick. Elevator pending-command synchronization, pause/speed status and daily-close detection remain synchronous. Explicit user action refreshes remain immediate. Existing UI assertions passed without timing changes. + +A counted main-scene subclass verifies three minutes still advance, three publications result in one deferred refresh, no empty duplicate refresh, the next frame refreshes again, display work cannot mutate the snapshot, and checkpoint replay remains identical: 10 checks PASS. Registered in make test-ui after it ran separately. The existing full make test-ui passed 992 direct checks plus 37 fresh-process upkeep checks (exit 0), including first operating day and daily-close UI. + +The production diagnostic now flushes pending dashboard work INSIDE each timed batch. This prevents deferred work from being hidden outside measurement. Previous retained key-budget-after baseline versus this run: x1 average 13487.4 -> 13774.475 us (no improvement demonstrated); x3 P95 51750 -> 43334 us (about 16 percent lower in this pair). Small scenario, seed 424242, 40 samples per speed, macOS Godot 4.7.2 headless. This excludes GPU rendering, is a single comparison, and is not Windows or full-load acceptance. Both production budgets still FAIL. + +Evidence in artifacts/facility-dimensions-2026-09-29: dashboard-coalescing-ui.log, dashboard-coalescing-test.log, dashboard-coalescing-profile.json, dashboard-coalescing-validation.json, and copied dashboard-coalescing-upkeep-* logs. Full original completion remains unproven. diff --git a/docs/plans/original-completion-2026-09-29/execution/DEFENSE_CLOCK.md b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_CLOCK.md new file mode 100644 index 0000000..8c57227 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_CLOCK.md @@ -0,0 +1,13 @@ +# 防衛画面・通常時計による攻防 + +2026-09-30。本番の防衛画面と ACTIVE 攻防の自動進行を接続した。新しい敵・戦闘間隔・準備締切を確定せず、既存レイドの保存済み round_minutes と取引境界を使用する。準備と次波開始は既存の明示操作を維持。 + +通常管理の防衛ボタンと警戒欄から参加者、対象施設、撤退方針、開始、次波、撤退を操作できる。選択した incident と画面の開閉を手動/自動保存へ記録する。ロード前の確認をロード後の別 queue へ適用しない。無料の敵消去となる準備取消ボタンはレビューで削除した。 + +通常時計の防衛 phase は due raid を安定したID順に1回ずつ解決する。手動の実取引失敗を同分の自動成功で隠さず、履歴再送だけでは正当な due round を抑止しない。明示IDを持つ旧有効セーブとの採番衝突を避け、ID空間の枯渇・技能費用・RNG・施設/職員結果の失敗時に巻き戻す。自動失敗では時計を停止し、同分に再試行しない。成功 restore では一時実行 guard を消去する。 + +初回の新自動周期試験188項目PASS、既存Runtime防衛162項目PASS。最新関連のheadless 1,703項目(同じ固定ソースの全回帰内で再実行した自動周期188項目を含む)と実描画5実行403項目PASS、実行中の入力差分0。証拠は `artifacts/defense-autonomy-2026-09-30/`。初回自動周期ログ後の変更は試験説明コメントだけで、最新全回帰で再実行する。独立静的レビューは `artifacts/defense-autonomy-independent-review-2026-09-30/`。 + +全回帰146/146スイートPASS、全合格後の開業・3回決算・別プロセス再開103項目PASS。さらに戦闘途中の保存から別プロセスで同じ攻防・損傷・資金・乱数・IDへ到達する描画通し試験19+19=38項目PASS。スクロールとviewport入力で通常防衛操作を確認した。同じソースのWindows PCKも16実行2,559項目PASS、ZIP生成と全同梱ファイルSHA検証が完了。Windows実機は未確認。 + +P21全体は未完了。脅威は試験fixtureで生成し、通常営業からの盗賊/暴動/銀行滞納発生、実敵コンテンツ、実技能/UI、魔法砲台、開始演出は未接続。原本全実装、同一Windows実機、性能予算、長期プレイを合格とは扱わない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/DEFENSE_HUD.md b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_HUD.md new file mode 100644 index 0000000..c0b0ff2 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_HUD.md @@ -0,0 +1,21 @@ +# 防衛状態のタワー画面表示 + +2026-09-29。P21/P24の警告表示を接続。DefenseServiceの現在状態から準備中・戦闘中・次波待ちを集計し、上部警戒欄へ襲撃件数、運営画面へ状態別件数を表示する。準備中も従業員配置がロックされるため集計に含める。終了済みの履歴は進行中件数に含めない。ツールチップで配属制限を説明する。 + +集計は戦闘履歴・参加者の全コピーを作らず、状態ごとの件数だけを返す。保存形式や襲撃発生条件、戦闘数値は変更なし。Runtimeの状態公開を受け、取消と保存復元でも表示を更新する。 + +関連試験: 防衛507、Runtime防衛135、ダッシュボード149、macOS実描画150チェックPASS、全プロセス終了コード0。実描画は同じUIシナリオの再試験+画像保存確認。準備中の表示、取消、復元、古いツールチップ解除、次波待ちと戦闘の区別、終結後除外、読み取りで原本状態を変更しないことを検証。1440×810の画像で上部・運営画面の日本語が収まることを目視。 + +証拠: artifacts/defense-hud-2026-09-29/。preparing.png、ログ、targeted-verification.json。 + +未完: 通常営業からの襲撃発生、準備・戦闘操作UI、借入滞納との接続、砲台と能動スキル、Windows実機。今回のUI試験はRuntimeコマンドで脅威を用意しており、通常営業での発生は証明しない。前回140全回帰・配布ZIPは今回の表示追加前のソースを対象とする。 + +## 全回帰で検出した配置操作への影響と修正 + +初回全回帰は140スイート中139PASS・1FAIL。従業員UIの防衛取消後の再ドラッグが2ケース失敗したため、通し検証は実行しなかった。失敗ログはartifacts/full-regression-after-defense-hud-2026-09-29/に保存。 + +襲撃の開始/終了で運営サマリーの行数を増減させると、その下の従業員ボタンが移動し、次のドラッグ開始位置が変わっていた。防衛サマリーを0件時も常設することで行の位置を固定。上部の襲撃警告は0件時に解除する。画面側の試験も0件表示を確認するよう更新。 + +修正後: 従業員UI131チェックがheadlessとmacOS実描画の両方でPASS。ダッシュボード実描画150、更新集約10チェックPASS。全プロセス終了コード0。layout-fix-verification.json参照。修正後の全回帰は別ディレクトリfull-regression-after-defense-hud-fixed-2026-09-29で再実行し、初回失敗記録を上書きしない。 + +修正後の全140スイートおよび実描画通し97項目もPASS。CURRENT_FULL_REGRESSION.md参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/DEFENSE_HUMAN_LABELS.md b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_HUMAN_LABELS.md new file mode 100644 index 0000000..4766a3c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_HUMAN_LABELS.md @@ -0,0 +1,11 @@ +# 防衛戦の原因・発生日時表示 + +2026-09-30。通し再起動画像で、83文字の内部raid IDが選択欄と概要に表示され、想定520pxの窓幅が約796pxへ広がることを確認した。 + +本番報告に保存済みevent_sourceのsource_kindとcreated_atをowned scalarとして渡し、画面には原因と発生日・時刻を表示する。同原因・同時刻の複数事件は決定的な一覧順の番号で区別する。内部IDから原因を推測しない。未知または手動登録は「防衛戦」、日時がない場合は「時刻不明」。選択metadata、コマンド対象、UI保存値には完全IDを保持する。 + +OptionButtonは最長項目への自動拡張を止め、長い表示には省略と人間向けtooltipを使う。独立した隔離コピーで関連6試験1,244項目+実描画106項目=1,350項目PASS。実画像は日本語原因・日時で520px内に表示する。検証された3ファイルをSHA比較とバックアップ後に制作コピーへ反映した。 + +修正後の全148回帰、開業再起動103、関連描画453、事件表示を含む防衛再開66項目がPASS。Windows同梱PCKの18実行2,926項目がPASS。Windowsネイティブ操作は未確認。通常営業からの脅威発生、全P21、全原本完成を証明する変更ではない。 + +証拠: artifacts/event-defense-labels-integration-2026-09-30/、artifacts/event-defense-ui-presentation-audit-2026-09-30/。 diff --git a/docs/plans/original-completion-2026-09-29/execution/DEFENSE_PREPARATION_CANCEL.md b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_PREPARATION_CANCEL.md new file mode 100644 index 0000000..e24c07e --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/DEFENSE_PREPARATION_CANCEL.md @@ -0,0 +1,17 @@ +# 防衛準備中の取消と未登録目標 + +2026-09-29。P21/P27の不具合修正。PREPARINGの襲撃は目標施設の登録前でも妥当な状態だが、取消後の検証が「全目標登録済み」を要求していたため、cancel_defense_raidが拒否され従業員の防衛ロックを解除できなかった。 + +CANCELLEDかつstarted_at == -1の、実際に戦闘を開始していない場合だけ目標登録完了の条件を免除する。戦闘開始時の全目標登録条件、開始後の取消・保存検証、その他の不変条件は維持。保存形式と数値の変更なし。 + +新規回帰で目標未登録・2目標中1件登録の双方を修正前に再現(before.log、2 failures)。修正後は取消成功、未開始・0ラウンドの維持、架空の施設を生成しないこと、JSON保存復元、取消再送の不変性を検証。開始済みに改変した破損保存は原状を保って拒否する。 + +防衛サービス494、Runtime135、従業員UI131、保存ライフサイクル96の計856チェックPASS。従業員UIは目標登録済み/未登録の双方で、ドラッグ中の防衛登録→拒否→実ファイル保存/読込→取消→再配置まで確認。macOS実描画も131チェックPASS(同一シナリオ)。通常営業からの襲撃発生の合格ではない。 + +証拠: artifacts/defense-preparation-cancel-2026-09-29/。全140スイートはfull_verify.py、実行ハンドル64718で開始。対象ソースの開始/終了SHAを比較し、全件PASSかつソース変更0の場合だけ実描画の開業・3決算・再起動フローへ進む。進捗はartifacts/full-regression-after-defense-2026-09-29/suites/all-tests.json、最終結果はverification.json。現在は実行中であり全体PASSとはしない。 + +既存Windows配布版は今回の修正未反映。Windows実機、全原本機能・資産、性能予算、長期プレイは未完了。 + +完了追補: 実行ハンドル64718は正常終了。全140スイートPASS、漏れ/重複/ソース変更0。続けて実描画の開業・決算・再起動97チェックPASS、ゲーム状態一致。CURRENT_FULL_REGRESSION.md参照。 + +配布追補: 上記検証済みソースのWindows開発確認版を作成し、同梱PCK655チェックを検証。CURRENT_WINDOWS_PACKAGE.md参照。Windows実機は未検証。 diff --git a/docs/plans/original-completion-2026-09-29/execution/DIGEST_SCALAR_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/DIGEST_SCALAR_CACHE.md new file mode 100644 index 0000000..030700d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/DIGEST_SCALAR_CACHE.md @@ -0,0 +1,9 @@ +# Bounded per-call scalar digest cache + +SimulationDigest now reuses canonical encodings for integers (up to512 values), short strings and StringNames (separate type-tagged caches, up to256 values each, length<=128). Caches exist only for one canonical encoding call. Mutable containers and floats are not cached; the v1 byte format and digest contract are unchanged. + +Final compatibility75 checks PASS, including reference-byte equality, dictionary order/type tags, depth/invalid values, long histories, cache saturation and changed inputs across calls. Synthetic5000-record history median129.949ms versus272.166ms in the original reference encoder; this ratio includes earlier optimizations and is not the gain from this patch alone. + +Same production scenario diagnostic: x1 average63.616ms before /62.711ms after, x3 P95 234.396ms before /228.869ms after; digest average26.328ms before /25.983ms after. Single-run differences are small and may include timing noise; no substantial production performance improvement is established. Both remain outside the4ms/12ms budgets. Main performance defect remains open. + +Evidence: digest-scalar-final.log, production-scalar-profile.json and production-scalar-profile.log under artifacts/facility-dimensions-2026-09-29. Next work should address the dominant complete snapshot/digest traversal and redundant infrastructure work without weakening replay correctness. diff --git a/docs/plans/original-completion-2026-09-29/execution/EVENT_DEFENSE_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/EVENT_DEFENSE_INTEGRATION.md new file mode 100644 index 0000000..0d272d9 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/EVENT_DEFENSE_INTEGRATION.md @@ -0,0 +1,17 @@ +# Event → Defense の実行・保存契約 + +2026-09-30。制作ソースへ適用済み。現在、全148スイートと同一ソースの描画・別プロセス再開・配布版を検証中。これらの完了は各最新証拠で別に判定する。 + +明示された原因ID・脅威種別・完全な戦闘テンプレートを、実際のEvent処理から防衛準備状態へ接続する。敵の強度・周期・標的を補完せず、職員配属や戦闘開始も代行しない。通常の防衛画面で準備し、既存のゲーム時計で戦闘を進める。 + +EventInstanceとactionの組から安定した襲撃IDを作り、乱数・採番・プレイヤーコマンドを消費しない。再送は進行中の損傷・名簿・退避方針を保つ。異なるテンプレートによる同IDの置換は拒否する。後続actionや実際の料金確定が失敗すると、新しい襲撃とその公開履歴を巻き戻す。以前の戦闘の同時刻再試行制御と失敗診断は保持する。 + +Runtime保存形式13へ更新し、形式12の必須清掃データを維持する。イベント原因・元テンプレートと保存された襲撃の不変構成を照合し、周期・波の強度・上限等の不一致を確定前に拒否する。合法な退避方針変更や戦闘進行は復元できる。旧手動襲撃にイベント履歴を捏造しない。 + +隔離候補の18関連スイート2,022項目と、拡張4スイート366項目を実行してPASS。後者の新Event専用試験は最終186項目。両段階には重複実行があるため合算を独立要件数にしない。初回の追加失敗試験は非終端ラウンドで終端callbackを期待して3件失敗した。終端を発生させる試験値へ修正して再実行した記録を残し、ゲームの不具合修正とは扱わない。 + +独立レビューで、未知のserialized actionを整数clampで新しい破壊的actionへ変換する不具合と、元テンプレートのdigestだけを見て実際の襲撃構成を照合しない不具合を修正した。未知actionを除去して成功扱いすることも防ぐ。 + +本実装は通常脅威producerの完成ではない。男性の所持金0・高依存、盗賊、世界樹の銀行滞納、監査の原因を同一条件に読み替えない。性別・通常利用の依存増加・原因episode・承認済み敵テンプレート等は残件。原本未指定の数値を試験値から本番へ転記していない。警告/カットインは将来commit後の通知として接続する必要があり、取消不能な同期signalを完成UIの証拠にしない。 + +実装・適用の証拠: `artifacts/event-defense-integration-2026-09-30/`。独立レビュー: `artifacts/defense-event-bridge-independent-review-2026-09-30/`。通常producerの現物監査: `artifacts/defense-bankruptcy-producer-audit-2026-09-30/`。原本全実装、全P21、性能目標、高負荷/長期プレイ、同一版Windows実機は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/EVENT_HISTORY_SNAPSHOT_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/EVENT_HISTORY_SNAPSHOT_CACHE.md new file mode 100644 index 0000000..9be8b88 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/EVENT_HISTORY_SNAPSHOT_CACHE.md @@ -0,0 +1,9 @@ +# Event ID snapshot ordering cache + +DomainEventStream converts committed IDs to typed Strings and uses native lexical sort. It caches that sorted ID list until a successful nonempty publish batch or successful restore. Every snapshot returns a separate array; callers cannot mutate the cache. Recent event payloads continue to serialize live and no published ID is discarded. Invalid batch/restore leaves canonical state intact. + +Domain-event33 checks PASS, including10 added cache/Unicode/order/defensive-copy/restore tests and existing atomic/reentrant publication tests. Runtime integration20 checks PASS, including disk digest round trip, pending-command single execution and deterministic24-hour replay. + +Production small-scenario profile: x1 average33.704ms, x3 P95136.757ms, snapshot creation average7.954ms. No clear whole-update speed improvement over the prior lazy-digest measurement; x3 was slower in this run. This cache removes repeated sorting when history has not changed, but does not establish production budget compliance. Budgets remain failed. + +Evidence under artifacts/facility-dimensions-2026-09-29: event-id-cache-regression.log, event-cache-runtime-regression.log, production-event-cache-profile.json. Full UI and Windows not rerun for this increment. diff --git a/docs/plans/original-completion-2026-09-29/execution/EXACT_CELL_SET_VALIDATION.md b/docs/plans/original-completion-2026-09-29/execution/EXACT_CELL_SET_VALIDATION.md new file mode 100644 index 0000000..4f7d09e --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/EXACT_CELL_SET_VALIDATION.md @@ -0,0 +1,9 @@ +# Exact cell-set crosslink validation + +Found a correctness issue while reviewing the remaining P28 validation overhead. Runtime._same_vector3i_set checked that every actual cell appeared in the expected set, but did not reject repeated actual cells. Equal-length arrays [A,B] and [A,A] were incorrectly accepted. The validator now removes each matched expected cell, rejecting duplicates/missing cells while retaining order independence and layer/type checks. This affects infrastructure world/route/projection crosslink checks; no schema or intended valid-save behavior changed. + +Runtime infrastructure168 checks PASS,exit0, including6 new equality boundary cases (reorder, duplicate actual, duplicate expected, empty, wrong layer, wrong type). Log: exact-cell-set-runtime.log. + +Also investigated fusing OperationsCoordinator snapshot validation and conversion. Existing230 coordinator checks passed, but small production measurements showed only3.622ms→3.459ms in coordinator serialization and x3 P9553.431ms→54.428ms. The experiment was reverted completely to the pre-experiment source (byte comparison confirmed). No new fused implementation is retained. Diagnostic reports remain as historical experiment evidence, not a performance acceptance result. + +Full make test-ui completed with exit0, unchanged source hashes and no ERROR/SCRIPT ERROR/FAIL markers. Log: post-validation-cache-full-ui.log; result: post-validation-cache-ui-result.json. Includes17 direct Godot scripts and independent upkeep write/read processes. Headless UI acceptance covers this source snapshot; Windows/native rendering and whole-original completion are not proven. Original P00–P29 scope, high-load and Windows acceptance remain incomplete. diff --git a/docs/plans/original-completion-2026-09-29/execution/FACILITY_AUDIT_BEFORE_CLEANING.md b/docs/plans/original-completion-2026-09-29/execution/FACILITY_AUDIT_BEFORE_CLEANING.md new file mode 100644 index 0000000..a42bedd --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FACILITY_AUDIT_BEFORE_CLEANING.md @@ -0,0 +1,52 @@ +# 原本30施設・現在の登録対応(2026-09-29) + +原本10.1–10.7を再照合し、通常Runtimeの登録一覧、建設パレット、FacilityCatalog.by_idが返す定義を実行して照合した。通常登録15、定義のみ1、対応定義なし14。通常カタログ全22件のうち7件はこの30施設以外であり、原本施設の代用として数えない。登録済みは全受入完了を意味しない。 + +対応定義16件の寸法・層は原本と一致する。地上/地下・高層・背面・騒音などの追加条件、実営業、保存移行、専用画像は別途受入が必要。一般客室・上級客室・従業員寮のABOVE_GROUND制限は原本10章の層指定だけでは正当化できず、残る配置差異として精査する。 + +人数専用customer_capacityが未設定(-1)の対応定義は13件。FacilityInstance.can_accept_customersはこの場合に人数専用上限を適用せず、available_slotsは既存capacityからサービス数を計算する。保管量・生産量と人数を混同した値を確定しない。バックヤード等の0指定、旅客交通の乗員容量との区分、施設ごとの人数値を整理する必要がある。 + +対応定義16件すべてでservice_attributeは空欄。属性・一致加算のモデルが存在しても、全施設で追加仕様を運用済みとは判定しない。加算額・不足基準・複数属性等は未設定のまま残す。 + +原本対応表original_facility_mapping.jsonを独立させ、旧監査facilities.jsonは歴史的証拠として保全した。礼拝堂をchapelへ対応付け、素材台帳もこの現行表を入力にする。これで礼拝堂の必要枠が未対応施設として生成される不整合を防ぐ。新表は対応関係のみを保持し、稼働検証状態はruntime-facilities.jsonで別管理する。 + +証拠: artifacts/facility-audit-2026-09-29/runtime-facilities.json、summary.json、source-hashes.json、runtime-audit.log、capture-runtime-facilities.gd。再現はローカル制作コピーから `godot --headless --path . --script res://artifacts/facility-audit-2026-09-29/capture-runtime-facilities.gd`。この取得スクリプトは監査時の対応表(旧監査+礼拝堂)を固定して再現するものであり、将来の新規施設を自動対応させるものではない。 + +次の優先作業: 人数専用値と層/ゾーン差異の解消、未接続施設の通常プレイ統合。現在の全体回帰中は本体ソースを固定し、完了後に保存・再起動を含む通しデバッグを先に行う。 + +| Original | Facility | ID | State | Dimensions / Layer | Customer limit | +|---|---|---|---|---|---| +| 10.1 | 一般客室 | standard_room | normal + palette | 2x1 / FRONT | unspecified | +| 10.1 | 上級客室 | premium_room | normal + palette | 4x1 / FRONT | 2 | +| 10.1 | VIPスイート | - | missing definition | - | unspecified | +| 10.1 | ドミトリー | - | missing definition | - | unspecified | +| 10.2 | 大衆食堂 | cafeteria | normal + palette | 4x2 / FRONT | unspecified | +| 10.2 | レストラン | - | missing definition | - | unspecified | +| 10.2 | 酒場 | tavern | normal + palette | 4x2 / FRONT | unspecified | +| 10.2 | 屋台 | - | missing definition | - | unspecified | +| 10.3 | 大浴場 | public_bath | normal + palette | 6x2 / FRONT | unspecified | +| 10.3 | カジノ | - | missing definition | - | unspecified | +| 10.3 | 闘技場 | - | missing definition | - | unspecified | +| 10.3 | 礼拝堂 | chapel | normal + palette | 4x3 / FRONT | 8 | +| 10.4 | 風俗個室 | adult_private_room | normal + palette | 2x1 / FRONT | unspecified | +| 10.4 | 高級ソープ | - | missing definition | - | unspecified | +| 10.4 | SM拷問室 | - | missing definition | - | unspecified | +| 10.4 | 闇市場 | black_market | definition only | 4x2 / FRONT | unspecified | +| 10.5 | 厨房 | kitchen | normal + palette | 4x2 / BACK | unspecified | +| 10.5 | 倉庫 | warehouse | normal + palette | 4x2 / BACK | unspecified | +| 10.5 | 待機室 | staff_waiting_room | normal + palette | 4x2 / BACK | unspecified | +| 10.5 | 従業員寮 | staff_dormitory | normal + palette | 2x1 / BACK | 0 | +| 10.5 | 警備員詰所 | security_station | normal + palette | 2x2 / BACK | unspecified | +| 10.5 | 清掃員室 | - | missing definition | - | unspecified | +| 10.5 | 医務室 | infirmary | normal + palette | 4x2 / BACK | unspecified | +| 10.6 | 覗き部屋 | - | missing definition | - | unspecified | +| 10.6 | 地下牢 | - | missing definition | - | unspecified | +| 10.6 | マナ炉 | - | missing definition | - | unspecified | +| 10.7 | 蔓の昇降機 | guest_elevator | normal + palette | 2x2 / FRONT | unspecified | +| 10.7 | 業務用EV | service_elevator | normal + palette | 3x2 / BACK | unspecified | +| 10.7 | ワープ | - | missing definition | - | unspecified | +| 10.7 | 魔法砲台 | - | missing definition | - | unspecified | + +## 配置差異の追記 + +一般客室・上級客室・従業員寮の地上限定差異は`ORIGINAL_ROOM_ZONES.md`で修正/関連検証済み。この表の登録数・清掃員室未対応表示は監査時点の記録で、清掃員室の後続統合を反映していない。最新の清掃統合は`CLEANING_RUNTIME_INTEGRATION.md`を参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FACILITY_DIMENSION_MIGRATION.md b/docs/plans/original-completion-2026-09-29/execution/FACILITY_DIMENSION_MIGRATION.md new file mode 100644 index 0000000..e26bd67 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FACILITY_DIMENSION_MIGRATION.md @@ -0,0 +1,108 @@ +# P08 施設寸法移行の事前確認 + +原本の上級客室4×1、従業員寮2×1、闇市場4×2に対し、現拡張定義は3×2、4×2、3×2。調査はfacility-dimension-preflight.json参照。 + +## 実装済み + +`facility_resize_preview.gd` はWorldServiceと現施設の明示された旧占有を受け、変更後のセル、追加/削除セル、衝突、未掘削、境界外、地下制約、支持喪失を返す。現在定義から旧占有を推測せず、旧形または変更後の形と保存の占有所有者が一致することを要求する。別WorldServiceへ複製して検証するため、成功/失敗どちらでも元ワールドの占有と版番号を変更しない。90/270度では寸法軸を交換し、層を保持。 + +14項目PASS: 上級客室の3×2→4×1、重なり拒否、上階の支持喪失、旧占有の矛盾/重複/不正ID/回転拒否、裏層の回転した寮、地下闇市場の未掘削/掘削後/境界外を実WorldServiceで確認。証拠 `artifacts/facility-dimensions-2026-09-29/validation.json`。 + +## 適用前に必要な処理 + +この判定のgeometry_okは寸法とWorldServiceの抽象的支持モデルの部分判定であり、移行実行許可ではない。ready_to_applyは常にfalse。特に周辺施設の実定義別重量、経路と職員/客の位置、建設中ジョブ、FacilityService/Construction/Pathの相互参照、バックアップと原子的保存への接続は残る。全ゲートを省略してプレビュー結果を本番へ書き戻してはならない。 + +定義の寸法・既存セーブはまだ変更していない。次はRuntimeから実重量/経路/旧配置を捕捉する移行プレビューへ接続し、変更できない配置を元データを保ったまま示す。P08全体および原本30施設の通常建設対応は未完了。 + + +## 実重量とRuntimeへの接続 + +WorldService.validate_supportへ任意の占有者別重量を追加した。指定のない既存呼出は従来どおり。未登録ID・負/非整数重量は拒否し、支持モデルと保存状態を変更しない。闇市場の拡張で、従来の簡略重量では合格するが隣接施設の実重量を加えると容量超過になる例を検証した。 + +Runtime.preview_facility_dimension_migrationは完成施設と現定義を読み、全完成施設のweight_per_cellで判定する。削除予定セルにいる客/職員と建設ジョブを報告し、実状態は変更しない。形状・支持のプレビューが通ってもready_to_applyはfalseで、移動経路/建設中/保存取引のゲートが残る。 + +試験は既存の拡張定義を明示登録し、受付を通常建設した上に旧3×2の上級客室を通常コマンドで建設。最初は地上専用施設をロビー階へ置いて拒否されたため配置を修正した。削除セル上の職員検出は、通常通行できない旧座標を診断入力として設定した限定試験。通常歩行・配置移行の受入とは区別する。初回の建設失敗と通行不能セルへの登録失敗ログも保持。 + +実重量/Runtime14項目、WorldService60、Construction105、Runtime建設37項目がPASS。証拠 weighted-final-validation.json。施設定義の寸法と既存セーブは未変更。次は経路と実占有の事前検証、UI、適用トランザクションへ接続する。 + +## Construction migration candidate (2026-09-29) + +Added prepare_construction_candidate: validate source, copy world, resize ownership, update facility and source-job cells together, then validate via ConstructionService.restore_snapshot. Active jobs are supported; costs, progress and timestamps are retained. Runtime preview exposes the candidate. No live state or disk write occurs. + +Validation: Runtime 22 checks and geometry 14 checks PASS. Initial retry changed world revision; fixed by skipping occupancy replacement when already resized. Initial failure log retained. Active-job fixture covers progress zero only; partially built continuation remains unverified. + +Remaining: valid actor relocation and routes, other Runtime crosslinks, partial-job continuation, backup and atomic application, UI and canonical dimensions. This is not full migration acceptance. + +## Partial construction and full snapshot candidate (2026-09-29) + +Runtime now assembles world, construction and operational facility geometry into a copied full snapshot. Actors on removed cells require relocation before assembly. ready_to_apply remains false: caller still needs isolated validation, route/actor checks and disk backup/atomic commit. + +Focused suite: 33 checks PASS; geometry suite: 14 PASS. Partial construction now advances 7 minutes before migration, restores, rejects duplicate work for the saved minute, completes exactly once, preserves original cost/start/duration and restores after completion. Full snapshot candidate for an unoccupied premium room restores through a separate Runtime with the fixture expansion registry. This is one fixture, not all-facility or route relocation acceptance. + +Evidence: full-snapshot-candidate.log and full-candidate-geometry.log. Canonical definitions, normal palette registration, actor relocation, route continuation and disk migration are still incomplete. + +## Route preservation gate (2026-09-29) + +prepare_facility_dimension_snapshot now restores its candidate into an isolated Runtime using the source definition registry. It compares entrance access, guests targeting the facility and staff workplace access using production route queries. Loss of existing reachability rejects the candidate; moving guests with a changed destination require explicit retargeting. No live actor relocation or disk write occurs. + +40 focused checks PASS; make check exits 0. Controlled topology cases prove that removing an upper facility row can lose access even when the path graph itself is unchanged, and that an alternative entrance still requires updating the moving guest target. The first fixture omitted the vertical connector, causing two expected-reachability failures; explicit connector setup fixes the fixture. Initial failure retained. Controlled graph/guest edits are route-unit fixtures, not normal UI play evidence. The earlier valid full Runtime restore is tested separately. + +Still pending: staff commute/task and elevator-in-flight migration acceptance, real actor relocation, automatic route retargeting, disk backup/atomic commit/UI, canonical definition changes and all-30 normal construction acceptance. The gate does not turn these remaining items into completion. + +## Candidate guest retargeting (2026-09-29) + +Added an all-or-nothing replan batch for isolated candidate guests. Every guest must still be MOVING with the expected old destination, a reachable replacement and no active elevator trip. Only path_handle target and graph revision change. Position, wallet, service state and movement credit remain untouched. Runtime candidate preparation applies the batch, captures/restores the resulting candidate again, and repeats access comparison. Live source and disk are unchanged. + +46 focused checks PASS, 91 existing Runtime path/traffic checks PASS, make check exit0. Controlled topology fixture checks invalid second replan rollback, stale-plan rejection, retained guest fields and production movement toward the replacement lower entrance. This is not normal construction/UI or a full moving-guest save migration scenario; those remain unverified. No in-flight EV retargeting or physical actor relocation is claimed. + +## Canonical dimensions applied (2026-09-29) + +Changed expansion definitions: premium_room 4x1 FRONT, staff_dormitory 2x1 BACK, black_market 4x2 FRONT underground, matching original chapter 10. Costs, duration and other fields unchanged. These expansion definitions are still outside the normal palette registry; normal all-30 construction remains incomplete. + +The old premium_room resource is archived under tests/fixtures/legacy_premium_room.tres. Migration tests reserve using this explicit old resource, then switch to current definitions before progress, candidate generation and restore. Thus legacy occupancy is tested against corrected definitions instead of leaving production data at old sizes. Existing saves are not rewritten. + +New-construction tests use real WorldService/ConstructionService and a funded test budget. All three definitions at four rotations reserve, finish, pay once and restore:63 checks PASS. Legacy Runtime migration:47 PASS. Definition registry:266 PASS. Content catalog:212 PASS. make check exits0. Migration/geometry/new-dimension tests are now included in make test-s1. Earlier preflight records are historical evidence of the old mismatch. + +Remaining: normal palette and role integration, all30 original facilities, actor relocation, staff tasks/EV migration, full moving-guest save acceptance, backup/atomic application and UI. Canonical dimension correction alone does not complete P08. + +## Verified disk migration (2026-09-29) + +SaveService.replace_snapshot_with_backup reuses the existing package migration transaction for same-format domain changes. It requires a valid candidate and an exact expected-source match, checksum-verifies candidate and backup, and atomically promotes using the existing recovery protocol. Repeated identical migration preserves the initial backup. Different package versions require their format migration first. + +Runtime.migrate_facility_dimensions_at_path loads an explicit path into an isolated Runtime, prepares/validates the dimension candidate and routes, then replaces the package with backup. It does not change live Runtime. Tests use unique disposable user save paths, not production saves. + +56 focused checks PASS,127 SaveService regression checks PASS; make check passed before the final format guard, and final Godot suites compiled that guard. Tests prove backup snapshot/bytes, repeat migration, stale-source and invalid-candidate rejection, separate-Runtime reload and live-state preservation. Existing save regression covers interruption/failed migration paths. + +This completes the explicit disk API for accepted candidates, not the whole migration user flow. Actor relocation, EV/tasks, normal UI invocation and post-migration load flow, all30 normal construction remain incomplete. + +## Staff rest and assigned-task migration gates (2026-09-29) + +Route preservation now includes off-duty workers whose selected rest facility is resized, even without a workplace assignment. Pending/assigned tasks targeting the facility or its rescue destination must retain exact state and a valid destination in the resized facility access cells. Assigned-task reachability uses normal staff routing; rescue tasks use the same backyard restriction as production movement. Changed task destinations require coordinated migration and are rejected for now rather than silently retained. + +25 focused checks PASS in dormitory-migration-tasks.log. A HEAD-version legacy4x2 dormitory fixture is built through normal construction and service stairs, then registry switches to canonical2x1. A worker resting on the valid upper stair landing preserves position through full snapshot migration and physically descends to the new lower entrance after resume. Blocking the lower entrance rejects migration without changing source. An old-upper-entry task rejects with TASK_DESTINATION_CHANGED. A retained-entry task assigned through StaffService preserves its exact snapshot after candidate restore. This covers an assigned/reserved task, not every working logistics/rescue case. + +Existing premium/disk migration56 checks PASS; make check exit0. Remaining: physical actors on removed cells, coordinated task retargeting, in-flight elevator/rescue/logistics acceptance, migration UI/load flow, all30 normal facility acceptance. No production save changed. + +## Whole-save completed-facility batch (2026-09-29) + +migrate_facility_dimensions_at_path now accepts an omitted facility ID to migrate all mismatched completed facilities. It loads once into an isolated Runtime, prepares/restores each candidate there, and commits only the final snapshot through one verified backup transaction. Already-canonical facilities are skipped. Failed candidate restores the isolated original and leaves disk/live state untouched. Active legacy construction is explicitly reported as requiring migration, not silently skipped. + +23 batch checks PASS (batch-save-final.log): premium room and dormitory old definitions built normally, one backup retains both originals, final full Runtime restores both corrected sizes, repeat preserves initial backup. A valid task at the second facility's removed entrance rejects the batch after the first candidate; rollback and unchanged formal save/live state/previous backup are verified. Existing single-facility/disk suite56 PASS. make check passed before final unchanged-shape skip; final Godot batch compiles and exercises that change. + +Remaining: active-job full Runtime migration, joint spatial rearrangements requiring coordinated ordering, actor relocation/task retargeting/in-flight transport, normal UI/load flow and all30 facility acceptance. Sequential candidate validation is not a general simultaneous layout solver. Tests use disposable saves; production saves untouched. + +## Active construction included in Runtime/disk batch (2026-09-29) + +Active legacy jobs now join the batch target list. Their world reservations and construction snapshots are converted by the existing geometry/weight gate, then validated in an isolated full Runtime before promotion. They do not fabricate an operating facility before completion. Paid cost, work units, progress and timestamps are retained. + +active-job-runtime.log:63 checks PASS. A normally started legacy premium-room job advances7minutes, is saved to a disposable package and batch-migrated with backup. The full migrated Runtime restores the four-cell reservation with original progress/cost, advances normally to an operating4x1 room, and restores after completion. Original partial-job backup remains exact. Completed batch23 and dormitory rest/task25 regression checks PASS; make check exit0. + +The earlier ACTIVE_CONSTRUCTION_MIGRATION_REQUIRED limitation is removed for candidates accepted by these gates. This evidence covers the premium partial-job fixture, not every mixed-job/actor/transport layout. Remaining: coordinated spatial ordering, actors on removed cells and lost task destinations, in-flight transport/rescue/logistics, normal migration UI/load flow and all30 facility acceptance. + +## Migration review and load UI (2026-09-29) + +The bottom toolbar now provides 読込 ▾ → 旧寸法を修正して読み込む. Preview loads the saved package into an isolated Runtime, validates the complete candidate, and lists affected facilities and target sizes before confirmation. Cancel clears the pending token. Confirmation requires the original preview digest; a changed save is rejected before migration. Successful promotion retains the source backup and strictly loads the migrated formal save, without falling back to an old backup while claiming migration success. Ordinary load retains its existing recovery behavior. + +Native macOS rendered UI handler flow:13 checks PASS, including cancel, changed-source rejection, original backup equality, corrected live room and already-current handling. Screenshot migration-dialog.png visually inspected: facility name,4×1 target, backup and unsaved-progress explanation, cancel/confirm buttons readable. Dashboard regression136 and batch-save regression23 checks PASS. These are rendered UI signal-handler tests using a controlled legacy fixture; OS pointer input and Windows execution were not tested. Makefile test-ui includes the new flow. + +Remaining: coordinated spatial ordering, actors on removed cells, task retargeting, in-flight transport/rescue/logistics, all30 facilities and Windows acceptance. P08 remains in progress. diff --git a/docs/plans/original-completion-2026-09-29/execution/FACILITY_REACHABILITY.md b/docs/plans/original-completion-2026-09-29/execution/FACILITY_REACHABILITY.md new file mode 100644 index 0000000..688feda --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FACILITY_REACHABILITY.md @@ -0,0 +1,21 @@ +# 施設到達可否の表示処理軽量化 — 2026-09-29 + +## 変更 + +開業ガイドは到達可否しか表示しないが、従来は施設の全候補入口から最安経路を選ぶ詳細レポートを取得していた。 +Runtime に `is_facility_reachable` を追加し、到達可能な候補が一つ見つかれば終了する。候補生成を Guest AI と共用し、入口、アクセス禁止、通行制約は同じとする。状態キャッシュは追加せず、遮断や復元後もその時点の経路で判断する。 +実際の客の移動は従来の最安経路選択を維持。保存形式・ゲーム内経済・施設数に変更なし。 + +## 検証 + +- 新規回帰 `tests/performance/test_facility_reachability.gd`: 33チェックPASS。実際に建設した複数階施設で既存詳細レポートとの一致、存在しない施設、アクセス禁止、入口遮断、保存復元を確認。 +- 既存のインフラ統合180、スナップショット所有権89、清掃アクセス6チェックPASS。 +- 同一プロセス・同一状態・順序を交替する100回の計測(各回はデモ全施設の一巡): 詳細レポート平均6222.41µs/P95 6391µs、到達可否平均4222.04µs/P95 4335µs。平均約32%短縮。 +- 計測機: macOS Apple M4、Godot 4.7.2、headless。小規模デモのこの照会処理だけの測定であり、全体Tick予算やWindows描画性能の合格ではない。 + +証拠: `artifacts/facility-reachability-2026-09-29/` の comparison.json、comparison-summary.json、関連試験ログ、変更前ソース。 +通しデバッグの結果は同ディレクトリの opening-restart に保存する。 + +前回の Windows-cleaning ZIP は変更前のソースを固定した成果物であり、この軽量化を含むとは扱わない。 + +描画あり通しデバッグ: 書込80+再起動読込17=97チェックPASS(62.91秒+48.00秒)。実行中のソース変更なし、再起動前後のゲーム状態JSON一致。3営業日の決算まで完走し、再起動後の3日目決算画面を目視確認。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FINE_CLEANING_EFFECT.md b/docs/plans/original-completion-2026-09-29/execution/FINE_CLEANING_EFFECT.md new file mode 100644 index 0000000..a4553f5 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FINE_CLEANING_EFFECT.md @@ -0,0 +1,13 @@ +# フィーネの清掃速度・作業更新の接続 + +原本6.2の清掃速度3倍をRuntimeへ接続。heroine_fine・UNIQUE・cleanerが一致する作業者は毎分3作業単位、通常は1。8単位の清掃は1分刻みのゲーム時間では3分目に完了する(2分時点6/8)。到着前の移動速度や他業務は変更しない。回復25・一度だけ適用・途中取消の回復なしは維持。清掃状況は3倍速と残り実時間を表示する。 + +実装中の検証で汎用StaffService更新が先に1単位進め、Runtimeの清掃専用判定が同分重複扱いで走らない点を発見。汎用更新に進捗ポリシーを設け、清掃は0としてRuntime側へ委ねた。出勤・施設稼働・取消を確認後、作業量を一度だけ進める。既存の物流の外部駆動契約は変更しない。 + +採用UI・初期能力は未指定で確認待ち。通常カタログにフィーネを追加していない。テストではregister_staffコマンドでUNIQUE/cleanerの本人を登録し、実際のUI配置・移動・汚れ・清掃を検証する。これは通常募集からの加入完了の証拠ではない。暗殺確率UP、成長、他固有効果・人物素材も残件。 + +関連6スイートPASS: Fine効果、通常清掃UI、StaffService、通勤、物流ブリッジ、救護UI。通常8分/固有3分・途中保存同一再生・完了直前の閉鎖/停電・一般雇用/別職種への誤適用防止を含む。実描画と3日営業の別プロセス再起動は実行中。証拠: artifacts/fine-cleaning-effect-2026-09-29/。 + +## 実描画・再起動結果 + +固有效果95チェック、実描画は画像取得を加え96チェックPASS。working.pngで3倍速・残り3分を確認。通常開業から3回決算、別プロセスの保存再開97チェックPASS。source-change.jsonは空、両プロセスのゲーム状態JSONも一致。通常加入への接続は依然未完了で、募集経路・初期値の回答が必要。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_AFTER_GUEST_LOG.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_AFTER_GUEST_LOG.md new file mode 100644 index 0000000..e69c946 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_AFTER_GUEST_LOG.md @@ -0,0 +1,25 @@ +# 現行ソースの全回帰 — 来客判断ログ追加後 + +139ソーススイートを単一のソース状態で実行し、139PASS・失敗0。所要798.08秒。実行対象と実行結果の完全一致、未実行0、重複0、開始・終了のソースハッシュ一致を確認した。 +対象は `tests/run_tests.gd` と `tests/**/test*.gd`。RefCountedのkernelモジュールはrun_testsから実行し、配布PCK専用試験は別工程。 + +来客判断履歴・候補除外理由・経路到達判定の軽量化を含む現行コードの回帰証拠。原本全実装・Windows実機・実プレイ時間・全体性能予算の達成を示すものではない。 + +証拠: `artifacts/full-regression-after-guest-log-2026-09-29/` の expected-tests.json、all-tests.json、全ログ、source-start/end.json、verification.json。 +全件PASS後の描画あり通し検証は同ディレクトリの opening-restart/ へ記録する。 + +前回の137スイート(136合格+期待値修正後1合格)の経緯は FULL_REGRESSION_BEFORE_GUEST_LOG.md に保存した。今回の139スイートは単一走査ですべて合格した。 + +## 独立した調査 + +顧客人数0で生産できないという初期仮説は、顧客向けサービスと厨房生産を混同していた。KitchenProductionServiceの全fixtureを顧客人数0にした追加試験141チェックがPASSし、仮説を棄却した。本番の人数判定は変更していない。ZERO_CUSTOMER_PRODUCTION_BUG.md参照。 + +## 全回帰後の通しデバッグ完了 + +描画あり80+17=97チェックPASS。書込・読込の両プロセス終了コード0。ソース変更なし、write/read-gameplay.json一致。通常開業から3回決算、終了・再起動後の継続を確認。 + +現行差分の追記: この139スイート一括実行後にサービス開始結果の枠番号を修正した。変更後の関連回帰・通しデバッグは SERVICE_START_SLOTS.md を参照。この一括結果を変更後140スイートの合格として扱わない。 + +追加差分: 従業員ドラッグがロード前後をまたがないよう修正した。変更後の回帰証拠は STAFF_DRAG_LOAD.md を参照。 + +追加差分: インフラ投影の重複した経路状態検査を削減。変更後の関連回帰・性能・通し検証は PROJECTOR_SINGLE_LIVE_CHECK.md を参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_ACTOR_SELECTION.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_ACTOR_SELECTION.md new file mode 100644 index 0000000..1aa2c5a --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_ACTOR_SELECTION.md @@ -0,0 +1,9 @@ +# 現行ソースの全回帰 — 自動保存・直接読込の選択復元後 + +2026-09-30。143/143スイートPASS。失敗・未実行・余分な実行・重複・ソースハッシュ差分0。合計1039.40秒。 + +全件PASS後のmacOS実描画通し試験は、新規開業・3回決算・保存・終了・別プロセス再起動・継続で83+20=103項目PASS。書込49.98秒、再読込36.28秒、終了コードともに0。ゲーム状態の比較は一致。再起動後の画像で4日目00:00、帳簿一致、営業利益+346Gを確認。 + +証拠: artifacts/full-regression-after-selection-autosave-2026-09-30/。coverage-verification.json、source-start/end.json、verification.json、opening-restart/。以前の記録はFULL_REGRESSION_BEFORE_SELECTION_AUTOSAVE.md。 + +この結果は全原本実装・Windows実機・長期プレイ・性能予算の合格ではない。現在のWindows配布版は選択復元の変更を含まない。後続変更として従業員・来客の選択復元を実装し、100項目・実描画101項目・関連552項目PASS。この143スイートの証拠には含めず、最新144スイートの全回帰と通しを再実行する。ACTOR_SELECTION_RESTORE.mdを参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_DEFENSE_AUTONOMY.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_DEFENSE_AUTONOMY.md new file mode 100644 index 0000000..a79b78f --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_DEFENSE_AUTONOMY.md @@ -0,0 +1,13 @@ +# 現行ソースの全回帰 — 従業員・来客選択復元後 + +2026-09-30。144/144スイートPASS。失敗・未実行・余分な実行・重複・ソースハッシュ差分0。合計762.12秒。 + +全件PASS後のmacOS実描画通し試験は、新規開業・3回決算・保存・終了・別プロセス再起動・継続で83+20=103項目PASS。書込49.68秒、再読込36.90秒、終了コードともに0。ゲーム状態JSON一致。再起動後の画像で4日目00:00、帳簿一致、営業利益+346Gを確認。 + +追加の従業員・来客選択復元も、別プロセス書込103項目・読込13項目、計116項目PASS。実際の保存データで選択IDと画面の開閉、ゲーム状態JSONと型を含むdigestの一致を確認。読込側画像を目視確認。 + +証拠: artifacts/full-regression-after-actor-selection-2026-09-30/。coverage-verification.json、source-start/end.json、verification.json、opening-restart/。別プロセス選択復元はartifacts/actor-selection-restart-2026-09-30/。以前の143スイート記録はFULL_REGRESSION_BEFORE_ACTOR_SELECTION.mdへ保全。 + +この結果は全原本実装・Windows実機・長期プレイ・性能予算の合格を示さない。Windows配布版の更新は別検証で進める。選択復元の変更と対象試験はACTOR_SELECTION_RESTORE.mdを参照。 + +後続変更: 防衛操作画面を実装し、対象54・実描画55・関連1,250項目PASS。上記144スイートはこの変更前の証拠。自律戦闘の接続を続け、変更後の全回帰・通しを再実行する。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_DEFENSE_HUD.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_DEFENSE_HUD.md new file mode 100644 index 0000000..ebb3332 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_DEFENSE_HUD.md @@ -0,0 +1,15 @@ +# 現行ソースの全回帰 — 防衛準備中取消修正後 + +更新注記: 防衛HUD追加(DEFENSE_HUD.md)はこの記録・配布版の後の変更。追加後の関連試験はPASS。追加後の全回帰・通し・再配布は別途確認する。 + +2026-09-29。140ソーススイートを単一ソース状態で実行し、140PASS・失敗0。スイート実行時間合計783.63秒。期待対象と結果は完全一致し、未実行・余分な実行・重複は各0。開始/終了のソースハッシュ差分0。 + +対象はtests/run_tests.gdとtests/**/test*.gd。RefCountedのkernelモジュールはrun_testsから実行し、配布PCK専用試験は別工程。サービス開始時の枠番号、ロード前後のドラッグ無効化、インフラ投影の重複検査削減、従業員の配属阻害理由表示、防衛準備中取消の修正を含む。 + +全件PASS後に実描画の通しデバッグを実行。通常開業・3回決算・保存・終了・別プロセス再起動・継続が80+17=97チェックPASS。write/read両プロセス終了コード0、実行中ソース変更0、write-gameplay.jsonとread-gameplay.json完全一致。再起動後の決算画像も目視し、3日目利益+346G、帳簿照合一致、日本語表示を確認。 + +証拠はartifacts/full-regression-after-defense-2026-09-29/。expected-tests.json、suites/all-tests.jsonと全ログ、source-start/end.json、source-change.json、coverage-verification.json、verification.json、opening-restart/を参照。 + +前回139スイートの記録はFULL_REGRESSION_AFTER_GUEST_LOG.mdへ保全。今回の全回帰はソース版の検証であり、Windows配布版はこの修正をまだ含まない。Windows実機、未実装の原本機能・資産、長期実プレイ、全体性能予算の合格を示すものではない。NEXT_MECHANICS_AUDIT.mdに仕入れ補正と通常防衛UIの未実装を現行ソースから追記した。 + +配布追補: 上記検証済みソースのWindows開発確認版を作成し、同梱PCK655チェックを検証。CURRENT_WINDOWS_PACKAGE.md参照。Windows実機は未検証。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_EVENT_DEFENSE.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_EVENT_DEFENSE.md new file mode 100644 index 0000000..59c8566 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_EVENT_DEFENSE.md @@ -0,0 +1,13 @@ +# 現行ソースの全回帰 — catalog manifest cache + +2026-09-30。147/147スイートPASS。欠落・余分・重複・失敗・実行中入力差分0。合計708.21秒。新cache専用試験は79項目PASSで、旧146スイートも全件保持。 + +全件PASS後、macOS実描画で新規開業・3回決算・保存・終了・別プロセス再起動・継続を83+20=103項目PASS。書込46.55秒、読込34.92秒、ゲーム状態JSON一致。再起動画像を目視確認し、4日目00:00、帳簿一致、営業利益+346Gを確認。 + +同じ凍結ソースで関連描画5実行・403項目PASS(職員131、自動保存76、施設選択39、人物選択101、防衛画面56)。全回帰の実headless4ログを同じソースの証拠として参照し、484項目を再実行扱いにしない。 + +戦闘途中の別プロセス保存・復元は19+19=38項目PASS。有料受付建設、通常採用、viewportのスクロール・クリックによる登録/開始/次波操作、ゲーム時間による2波撃退を確認。途中・最終の全ゲーム状態JSONと型付きdigestが一致。書込2.87秒、読込2.62秒。脅威作成だけ開示fixture。読込後の撃退画面と自動保存復旧画面を目視確認。 + +証拠: artifacts/full-regression-after-manifest-cache-2026-09-30/、artifacts/manifest-cache-related-2026-09-30/、artifacts/manifest-cache-defense-restart-2026-09-30/。現在source-end SHA256: c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d。前146スイートの証拠は FULL_REGRESSION_BEFORE_MANIFEST_CACHE.mdへ保全。変更と測定は MVP_CATALOG_MANIFEST_CACHE.md を参照。 + +既存試験と今回の通し試験の合格であり、原本全実装、通常脅威発生、性能予算、高負荷/長期プレイ、同じ版のWindows実機は未完了。Windows配布は別証拠に記録。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_EVENT_LABELS.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_EVENT_LABELS.md new file mode 100644 index 0000000..8c6be41 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_EVENT_LABELS.md @@ -0,0 +1,13 @@ +# Event → Defense・全回帰と通し確認 + +2026-09-30。148/148スイートPASS。欠落・余分・重複・失敗・実行中ソース変更0。合計670.50秒。新Event専用試験186項目を含む。 + +全件PASS後、macOS実描画で新規開業・3回決算・保存・終了・別プロセス再起動を83+20=103項目PASS(43.89秒/32.84秒)。ゲーム状態JSON一致、4日目00:00・帳簿一致・営業利益+346Gを目視確認。 + +同じ凍結ソースで関連描画5実行403項目PASS。全回帰の同じソース実ログ5件670項目を参照し、再実行扱いしない。Event queue/時計から生成された襲撃のviewport操作・戦闘途中保存・終了・別プロセス再開は22+20=42項目PASS。全ゲーム状態とtyped digest一致、全domain event/採番/乱数を保全し、Save/Loadの2種類のI/O観測type/payloadのみ明示normalization。 + +通し画像で防衛画面に長い内部IDが露出する問題を発見。これを修正するため、現在の画面受入は完了としない。現在ソースと同じWindows版のexport/PCK検証は未実行。封印済み配布準備は実行せず、表示修正後に新証拠世代で検証する。 + +証拠: artifacts/full-regression-after-event-defense-2026-09-30/、artifacts/event-defense-related-2026-09-30/、artifacts/event-defense-restart-2026-09-30/。source-end SHA256: 319b76d51c268a64e6ea1cddf8e2b0a02b05cfb87730749394ecb985a1fdeac1。 + +原本全実装、通常脅威producer、性能予算、高負荷/長期プレイ、同じ版のWindows実機は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_GUEST_LOG.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_GUEST_LOG.md new file mode 100644 index 0000000..f3a3e7c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_GUEST_LOG.md @@ -0,0 +1,17 @@ +# 現行ソースの全回帰と期待値修正 + +全137ソーススイートを実行し、136合格・1失敗でランナー終了コード1。実行中ソース変更なし、未実行0、重複0をverification.jsonで確認。失敗ログを消去せず残す。 + +失敗はtests/s0/test_definition_registry.gdのANYゾーン一覧にstandard_roomが無いこと。原本準拠の地上限定解除は既に実装・地下建設検証済みだったが、この期待値が古いままだった。一般客室をANY一覧へ追加して312チェックPASS、地下配置の回帰も40チェックPASS。 + +修正前後のハッシュ差は当該テスト1ファイルのみ。製品コード・共通試験コードは不変。保持136合格+修正済み1合格により現行137スイートの合格を網羅した。単一の全件PASS走査ではないことをcorrected-verification.jsonに明記。 + +証拠: artifacts/current-full-regression-2026-09-29/。expected-suites.json、all-tests.json、各ログ、source-start/end.json、verification.json、corrected-source.json、corrected-verification.json。 + +対象はtests/run_tests.gdおよびtests配下のtest*.gd。RefCountedのkernelモジュールはrun_tests経由、配布PCK試験は別工程。全原本実装・Windows実機・製品性能予算・長期プレイの合格ではない。 + +全スイートの結果確定後、同じ製品ソースで実描画の開業・3回決算・別プロセス再起動を開始。実行ハンドル38791、出力opening-restart/。この通し結果は完了後に追記する。 + +## 通し検証完了 + +書込80・読込17、合計97チェックPASS。両プロセス終了コード0。書込63.11秒、読込48.00秒。source-change.jsonは空配列、write/read-gameplay.json一致。通常開業・3回決算・アプリ終了後の再起動継続まで確認した。全原本・Windows・長期プレイの合格ではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md new file mode 100644 index 0000000..23fbf30 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md @@ -0,0 +1,11 @@ +# 現行ソースの全回帰 — 防衛画面・自動進行後 + +2026-09-30。146/146スイートPASS。欠落・余分・重複・失敗・実行中入力差分0。合計743.93秒。 + +全件PASS後のmacOS実描画通し試験は、新規開業・3回決算・保存・終了・別プロセス再起動・継続で83+20=103項目PASS。書込48.18秒、読込35.76秒、終了コード0。ゲーム状態JSON一致。再起動後画像で4日目00:00、帳簿一致、営業利益+346Gを確認。 + +続く戦闘途中の別プロセス保存復元は、実際の有料受付建設・通常採用・スクロールとviewportクリックによる参加/対象登録/開始・2分後の通常UI保存・終了・再起動・残り間隔・次波クリック・時計による撃退まで19+19=38項目PASS。書込3.22秒、読込2.33秒。途中と最終の全ゲーム状態JSONと型付きdigestが一致し、採番・乱数・資金・職員・施設を保持。脅威作成のみ開示fixture。画像4枚のうち読込後の撃退画面を目視確認。前のスクロール位置の画像も保全。 + +証拠: artifacts/full-regression-after-defense-autonomy-2026-09-30/ (coverage-verification.json、source-start/end/change.json、verification.json、suites、opening-restart)。戦闘再開は artifacts/defense-autonomy-restart-2026-09-30/。対象と静的レビューは DEFENSE_CLOCK.md を参照。以前の144スイート結果は FULL_REGRESSION_BEFORE_DEFENSE_AUTONOMY.md へ保全。 + +この結果は実装済みの試験と現在の2通しシナリオの合格。原本全実装、通常脅威発生、性能予算、長期プレイ、同じビルドのWindows実機は未完了。Windows配布更新は別証拠で記録する。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SELECTION_AUTOSAVE.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SELECTION_AUTOSAVE.md new file mode 100644 index 0000000..6c89040 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SELECTION_AUTOSAVE.md @@ -0,0 +1,11 @@ +# 現行ソースの全回帰 — 手動選択復元・受付担当検索改善後 + +2026-09-29。141/141スイートPASS。失敗・未実行・余分な実行・重複・ソースハッシュ差分0。合計678.83秒。 + +全件PASS後の実描画通し試験は、新規開業・3回決算・保存・終了・別プロセス再起動・継続で83+20=103項目PASS。書込46.53秒、再読込34.53秒、終了コードともに0。ゲーム状態の比較は一致。保存読込後の受付の選択枠と詳細表示を画像でも確認。 + +証拠: artifacts/full-regression-after-selection-restore-2026-09-29/。coverage-verification.json、source-start/end.json、verification.json、opening-restart/。以前の記録はFULL_REGRESSION_BEFORE_SELECTION_RESTORE.md。 + +この結果は全原本実装・Windows実機・長期プレイ・性能予算の合格ではない。現在のWindows配布版はこれらの変更を含まない。 + +後続変更: 自動保存・直接読込の選択復元を追加中。この全回帰結果には含まない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SELECTION_RESTORE.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SELECTION_RESTORE.md new file mode 100644 index 0000000..6a781b3 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SELECTION_RESTORE.md @@ -0,0 +1,13 @@ +# 現行ソースの全回帰 — 営業履歴スナップショット軽量化後 + +更新注記: GUIDE_ASSIGNMENT_SCAN.mdの受付担当検索の軽量化とSELECTION_RESTORE.mdの手動選択復元は本記録より後の変更。本記録の全回帰・配布・負荷結果にこの変更を含めない。 + +2026-09-29。140/140スイートPASS、失敗・未実行・余分な実行・重複はすべて0。実行時間合計674.21秒。開始/終了および確認時のソースハッシュ差分0。 + +全件PASS後の実描画通し検証は、新規開業・3回決算・保存・終了・別プロセス再起動・継続で80+17=97項目PASS。書込46.57秒、再読込34.75秒、双方終了コード0。ソース差分0、write-gameplay.jsonとread-gameplay.json一致。再起動後画面で3日目利益+346G、帳簿照合一致、日本語とドット施設・人物を確認。 + +証拠: artifacts/full-regression-after-snapshot-reuse-2026-09-29/ の全ログ、expected-tests.json、coverage-verification.json、source-start/end.json、source-change.json、verification.json、opening-restart/。前回記録はFULL_REGRESSION_BEFORE_SNAPSHOT_REUSE.md。変更内容と個別性能測定はSNAPSHOT_CALL_REUSE.md。 + +これは既存のソース試験と開業シナリオの合格であり、全原本実装・長期プレイ・性能予算・Windows実機の合格ではない。全スイート実行時間は性能基準の判定に使用しない。 + +同一ソースからWindows開発確認版を生成。隔離PCK1,527項目、同PCKのmacOS実描画従業員操作132項目PASS。Windows実機は未確認。CURRENT_WINDOWS_PACKAGE.md参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SNAPSHOT_REUSE.md b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SNAPSHOT_REUSE.md new file mode 100644 index 0000000..2ba5a1e --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/FULL_REGRESSION_BEFORE_SNAPSHOT_REUSE.md @@ -0,0 +1,15 @@ +# 現行ソースの全回帰 — 防衛HUDの配置安定化後 + +更新注記: SNAPSHOT_CALL_REUSE.mdの営業履歴軽量化は、この全回帰・配布記録より後の変更。追加後の全回帰と通し検証は実施中であり、本記録の合格に含めない。 + +2026-09-29。140/140スイートPASS、失敗・未実行・余分な実行・重複はすべて0。実行時間合計783.88秒。開始/終了および確認時のソースハッシュ差分0。 + +初回の防衛HUD追加では、表示行の増減で従業員欄が動き、取消後のドラッグ再配置に失敗した。防衛サマリーを常設して修正。初回失敗ログはartifacts/full-regression-after-defense-hud-2026-09-29/に保存し、今回とは区別。 + +全件PASS後の実描画通し検証は、新規開業・3回決算・保存・終了・別プロセス再起動・継続で80+17=97項目PASS。書込62.34秒、再読込47.24秒、双方終了コード0。ソース差分0、write-gameplay.jsonとread-gameplay.json一致。再起動後画面で3日目利益+346G、帳簿照合一致、日本語とドット施設・人物を確認。 + +証拠: artifacts/full-regression-after-defense-hud-fixed-2026-09-29/ の全ログ、expected-tests.json、coverage-verification.json、source-start/end.json、source-change.json、verification.json、opening-restart/。前回記録はFULL_REGRESSION_BEFORE_DEFENSE_HUD.md。 + +これは既存のソース試験と開業シナリオの合格であり、全原本実装・長期プレイ・性能予算・Windows実機の合格ではない。今回のソースの配布版検証は別途CURRENT_WINDOWS_PACKAGE.mdへ記録する。 + +配布追補: 同一ソースからWindows開発確認版を生成し、隔離PCK677項目とmacOS実描画の従業員操作132項目PASS。CURRENT_WINDOWS_PACKAGE.md参照。Windows実機は未検証。 diff --git a/docs/plans/original-completion-2026-09-29/execution/GUEST_DECISION_LOG.md b/docs/plans/original-completion-2026-09-29/execution/GUEST_DECISION_LOG.md new file mode 100644 index 0000000..aedd316 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/GUEST_DECISION_LOG.md @@ -0,0 +1,31 @@ +# 来客の行先判断記録 — 2026-09-29 + +原本11.2の思考ログ、計画P15/P24への部分実装。現行の自律AIと `decide_guest` コマンドを同じ記録関数へ接続した。 + +## 実装した挙動 + +- 成功した施設選択・退館判断について、判断時の欲求、最大欲求、選択先、欲求優先度、比較評価、移動分数、待ち分数、見込料金を `GUEST_DECISION_RECORDED` イベントへ記録する。 +- 判断失敗は記録しない。実際の選択結果を保存し、表示時に候補選択を再実行しない。 +- 通常のHUD「来客」から、現在の欲求と直近判断時の情報を区別して確認できる。 +- 既存のイベント保存形式を使用し、ロード後も記録が残っている範囲では完全一致する。返す辞書はコピーし、観察画面から内部状態を変えられない。 +- 長い説明をスクロール領域へ収め、1280×720で閉じるボタンを画面内に維持した。 + +## 範囲と制限 + +全イベント共通の直近256件に含まれる記録のみ表示する。永続的な全行動履歴ではない。旧セーブ、判断前、履歴範囲外は「記録なし」と表示し、履歴を捏造しない。実際に利用開始した料金や現在料金ではなく、判断時点の見込料金を表示する。 +全候補の評価比較、除外理由一覧、ゲストの戦利品・種族反応等の残件は別途必要。原本11.2全体およびP15/P24全完了とはしない。 + +## 検証 + +- 自然到着・実際の自律選択による記録、選択先一致、保存復元、現在欲求変更後の過去記録不変、読み取り専用、失敗時記録なし、退館理由を試験。 +- 描画あり新規試験18チェックPASS。最初の描画でダイアログのはみ出しを発見し、スクロール対応後の画像で修正を確認した。 +- 既存来客観察22、GuestService137、Runtime来客循環40チェックPASS。循環試験には24時間の再生・完全スナップショット一致を含む。 +- 証拠: `artifacts/guest-decision-log-2026-09-29/`。最初の描画と修正後の描画を両方保存。 + +前回のWindows配布ZIPにはこの変更は含まれない。 + +追加境界: コマンド経由の候補が `price_gold` / `quoted_price` を使う場合も、GuestServiceと同じ優先順と非負補正で記録する。`price_gold=37` の実判断を追加し、0Gの誤表示を防止。新規追加試験の初回は20チェック中2件失敗(headlessでのウィンドウサイズ判定、料金別名ケース)。ウィンドウサイズを明示し、料金ケースに品質100と生存資金控除無効を指定して試験条件を修正。初期失敗ログを保持した。 + +最終検証: 修正済みの同一テスト本文でheadless20、描画あり21チェックPASS。描画確認後に本文を本番テストパスへ保存した。実装ソースは通し試験中から変更なし。 + +最終ソースの描画あり開業→保存→プロセス再起動→3日決算は80+17=97チェックPASS。実行中のソース変更なし、復元前後のゲーム状態JSON一致。証拠は opening-restart-final/。 diff --git a/docs/plans/original-completion-2026-09-29/execution/GUEST_REJECTION_LOG.md b/docs/plans/original-completion-2026-09-29/execution/GUEST_REJECTION_LOG.md new file mode 100644 index 0000000..ca1dbde --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/GUEST_REJECTION_LOG.md @@ -0,0 +1,23 @@ +# 来客の候補除外理由 — 2026-09-29 + +原本11.2・P15/P24の思考ログを拡張。来客の判断結果を作る際、GuestServiceが実際に返した除外理由を記録し、通常HUDの来客画面に表示する。 + +## 実装 + +- 閉店・稼働停止、経路なし、利用可能資金不足、到着期限、待ち時間、品質、危険度、利用条件等を日本語表示する。 +- 食材・資材不足は、以前はRuntimeの候補生成時に黙って除外されていた。除外自体は維持し、経路計算を行わず `inputs_available=false` の候補をGuestServiceに渡して `MISSING_INPUTS` を記録する。 +- 理由別件数は全件集計し、施設別の例は先頭8件を保存。省略件数を明記する。既存のイベント保存・復元を利用し、現在状態から過去の理由を再計算しない。 +- 古い判断イベントに理由情報がない場合は、理由記録なしと表示する。除外0件と混同しない。 + +## 検証と限界 + +新規の判断記録試験を28チェックに拡張しPASS、描画あり29チェックPASS。閉店9施設・経路なし1・資金不足1・食材不足1の計12候補について、実際のフィルター、正確な件数、8例への制限、画面表示、イベント復元を確認した。画像は境界試験用候補を通常UIで表示したもの。 +既存の来客観察22、GuestService137、Runtime来客循環40、商品管理42チェックPASS。 +初回のGDScript型推論エラーを修正し、失敗ログも保存した。 + +候補の全比較順位や低評価・予約失敗による不選択の詳細はまだ表示していない。職員専用施設やチェックイン済み受付など、最初から検討対象に入らない施設も一覧化しない。履歴は全イベント共通の直近256件であり、永続的な全履歴ではない。 +原本全実装・P15/P24全完了の判定ではなく、Windows配布ZIPには未反映。 + +証拠: `artifacts/guest-rejection-log-2026-09-29/`。 + +描画あり通常営業→保存→終了→別プロセス読込→3日決算は80+17=97チェックPASS。ソース変更なし、再起動前後のゲーム状態JSON一致。opening-restart/に保存。 diff --git a/docs/plans/original-completion-2026-09-29/execution/GUIDE_ASSIGNMENT_SCAN.md b/docs/plans/original-completion-2026-09-29/execution/GUIDE_ASSIGNMENT_SCAN.md new file mode 100644 index 0000000..8daed19 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/GUIDE_ASSIGNMENT_SCAN.md @@ -0,0 +1,13 @@ +# 開業案内の受付担当検索を一回の履歴走査へ変更 + +2026-09-29。InnOpeningGuide._has_reception_assignmentを変更。該当職種の現在配置または繰返し勤務計画が見つかった時点で返す。繰返し計画のない職員IDのみを集め、シフト履歴を一度取得・走査する。従来は職員ごとに履歴全件のソート/列挙を繰り返していた。 + +判定の意味は維持: 管理職は受付要員に数えない。現役受付要員は有効、勤務時間外の繰返し配属は有効。明示的な他施設への再配属があれば旧シフトで上書きしない。取消済み、現在時刻に終了、存在しない職員のシフトは無効。キャッシュを永続化せず、配属変更・読込は毎回の現状態を参照する。保存形式変更なし。 + +旧/候補を決定的な500ケースで比較し全件一致。50人・1,000件の終了済み履歴・繰返し計画なしの模擬読取サービスで、交互100回ずつ計測: 旧平均5,970.79µs→新212.48µs。模擬サービスは配列を複製するがソートしない。この値は対象関数の特定ケースであり、ゲーム全体や本番UIの改善率ではない。 + +恒久境界13、ダッシュボード149、初日営業63、従業員操作131、合計356項目PASS。続くmacOS実描画の開業・3回決算・保存・終了・再起動は80+17=97項目PASS。書込46.09秒、読込34.28秒、双方終了0。保存前後の営業状態一致、検証中ソース差分0。再起動画面で開業案内完了・3日目利益+346G・帳簿照合一致を視認。 + +証拠: artifacts/guide-assignment-2026-09-29/。変更前、候補、差分比較、comparison.json、境界/関連試験ログ、opening-restart、verification.json。対象: scripts/ui/inn_opening_guide.gd、tests/ui/test_opening_assignment.gd。 + +直前の140スイート全回帰およびWindows snapshot-reuse版はこの変更を含まない。変更後の全回帰・配布版・Windows実機は未検証。本番性能予算の達成、原本全実装の完了を示すものではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/LAYER_INTERFACE_SKIN.md b/docs/plans/original-completion-2026-09-29/execution/LAYER_INTERFACE_SKIN.md new file mode 100644 index 0000000..df5dbfb --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/LAYER_INTERFACE_SKIN.md @@ -0,0 +1,15 @@ +# Persistent HUD layer skin + +Partial P23/P25, original B-12.4 and B-13.4. Removed the permanent green HUD override and restored the existing fantasy profile's parchment/brown/gold FRONT palette. BACK uses dark iron blue/grey panels, monitor-green selection and pale readable text. Main HUD frames, navigation/build/action buttons, headings and facility/staff information change with the normal layer button/Tab path. Dynamically constructed main buttons pick up the active layer. Existing warning colours remain semantic. No art files, asset rights or simulation schema changed. The current flat-frame art style remains; this is not acceptance of all final skin assets or BGM. + +Theme changes while the build palette was open left its old expanded height after collapse, causing five viewport-layout failures. Closing the palette now defers BottomBar.reset_size until theme/layout updates resolve. Styles also initialize in the current saved layer and traverse only on later layer changes, avoiding redundant startup work. Final main dashboard139 checks PASS. Native layer-skin11 checks PASS: normal BACK control, panel/text contrast palette, toolbar bounds, unchanged map coordinates, repeated exact FRONT restoration, paused time, initial BACK selection and subsequent FRONT restoration; includes2 captured images. FRONT/BACK screenshots visually inspected at1440x810. + +Remaining: auxiliary dialogs, opening guide and specialist panels still have their own skins; full surface skin integration, material texture polish, saved-game UI reload and Windows acceptance remain. This does not complete P23/P25. Evidence: layer-skin-native.log, layer-skin-dashboard-final.log, layer-skin-front.png, layer-skin-back.png and layer-skin-validation.json. + +## Opening guide follow-up + +The guide panel, body text and header/action button states now register with the shared layer skin. Startup receives the current layer; later toggles flow through the existing main layer path. Folding/unfolding explicitly schedules size recalculation even while paused, without changing guide progress or actions. + +Native16 checks PASS and dashboard139 checks PASS on this increment. Native checks include guide BACK palette, unchanged progress/action, paused collapse/reopen, exact FRONT restoration and existing-BACK startup. Current captures guide-skin-front.png and guide-skin-back.png; BACK image visually inspected. The first capture run reused layer-skin-front/back.png, so those older screenshot paths are superseded by this increment and the old manifest's screenshot hashes are historical, not current-file verification. Use guide-skin-validation.json for current evidence. No new assets were introduced. + +First operating day/save replay completed with59 checks,0 failures,exit0 (guide-skin-opening.log). Specialist panels/dialogs, final asset polish/BGM, Windows and broader original completion remain outstanding. diff --git a/docs/plans/original-completion-2026-09-29/execution/LAYER_PRESENTATION.md b/docs/plans/original-completion-2026-09-29/execution/LAYER_PRESENTATION.md new file mode 100644 index 0000000..e61c5b5 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/LAYER_PRESENTATION.md @@ -0,0 +1,29 @@ +# 表裏レイヤー表示・魔法レンズ切替(2026-09-29) + +原本B-2.2/B-12.2、P23の途中成果。表層では実在する裏層施設の部屋描画を低透明度で重ね、裏層では表層施設の占有輪郭を表示。建設サービスの正規配置から描画し、画面外は除外する。裏層の人物や未完成工事を表示する実装ではない。 + +切替時に画面を読み取るGPUシェーダーで波紋状の屈折を0.55秒表示する。演出は停止中も終了し、人物のアニメーション時間やゲーム時刻を進めない。再切替は同一オーバーレイを再利用し、終了後は非表示。半透明描画ノードは入力を持たず、レンズControlは入力を透過する。選択は従来どおり選択層の占有データで判定する。 + +描画中の施設一覧を1回取得して部屋・非選択層・施設ラベルで共有し、追加描画による一覧の重複コピーを避けた。キャッシュは描画終了時に破棄する。Windows高負荷時のGPU性能は未測定。 + +検証: 最終ソースのmacOS実描画18検査PASS、直前のheadless15検査PASS。表裏で同座標の受付/倉庫、裏専用の待機室、非選択施設へのクリック禁止、座標維持、停止中の演出/人物時間分離、16回切替、全Runtimeスナップショット不変を確認。既存ピクセル画面243検査、ダッシュボード136検査、make checkもPASS(最後の描画用一覧キャッシュ追加前)。最終実描画はキャッシュ追加後のソースで実施。 + +layer-front.png / layer-back.png / layer-ripple.png を確認。半透明の倉庫/待機室、表層の輪郭、波紋の屈折を目視した。既存Kenney素材をそのまま再利用し、新しい外部素材は導入していない。 + +未完了: 建設中/旧設備を含む全異層表現、混雑時やx3連続切替の描画性能、Windows同一ビルド実機、表裏UIスキン/BGMなどP23/P25残件。P23全体や全原本実装の完了ではない。 + +## Opposite-layer active construction (2026-09-29) + +Active construction reservations now appear in the opposite layer with amber hatching and a FRONT/BACK progress badge. They do not render completed room furniture. Geometry comes directly from reserved_cells and progress from ConstructionJob; blocked/planned jobs remain visible, completed/cancelled jobs are excluded. Completed jobs switch to existing facility art/outline. Drawing shares the job list with selected-layer construction labels for one frame, avoiding duplicate list retrieval. + +Final native32 checks PASS in layer-construction-final.log, including two ordinary construction commands, exact reserved footprint, real partial progress, opposite-layer hit isolation, full snapshot preservation, restore, cancel and normal completion. layer-front-construction.png and layer-back-construction.png visually inspected: opposite footprints and labeled progress are visible independently of active-layer hatching. Pixel camera regression243 PASS before the final per-draw job-list cache; final native suite compiles and exercises that cache. + +This removes the active-construction visibility gap. Legacy infrastructure representation, x3/high-load GPU tests, Windows same-build acceptance and remaining HUD/skin requirements remain open. + +## Opposite-layer legacy stairs (2026-09-29) + +LegacyStairService records now join opposite-layer visual data even when the viewing fixture has no ConstructionService. Each stair uses the same compact upper-landing rectangle as active-layer rendering/selection. FRONT shows the backyard stair glyph at low opacity; BACK shows the front stair outline. Active and opposite filters are disjoint. Legacy metadata is cached only during a draw so selected and opposite passes share one snapshot retrieval; query calls outside drawing still get defensive copies. + +Final evidence: layer-legacy-native.log30 checks PASS; layer-legacy-migration.log67 checks PASS; layer-legacy-regression.log27 checks PASS. Actual Runtime8/Path4 migration materializes two original vertical connectors; the BACK view contains exactly those two legacy IDs, and visualization leaves the entire canonical save unchanged. Native UI coverage uses a small canonical LegacyStairService fixture and checks compact geometry, selection isolation, and defensive metadata; both layer screenshots were visually inspected. No migration or path behavior was changed. + +The legacy stair visibility gap is resolved for these fixtures. This is not exhaustive acceptance of every old transport topology; other legacy infrastructure, x3/high-load rendering, Windows same-build and remaining HUD/skin requirements remain unverified. diff --git a/docs/plans/original-completion-2026-09-29/execution/LAZY_PUBLICATION_DIGEST.md b/docs/plans/original-completion-2026-09-29/execution/LAZY_PUBLICATION_DIGEST.md new file mode 100644 index 0000000..d1c8c69 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/LAZY_PUBLICATION_DIGEST.md @@ -0,0 +1,21 @@ +# Separate UI notification from digest computation + +UI subscribers do not need the digest; main uses snapshot settlement data. Added state_updated for these subscribers. Runtime still captures latest_snapshot at the same publication point; latest_digest now computes and caches its exact SHA on demand. When any legacy snapshot_published subscriber exists, its snapshot+digest notification remains eager and exact. Every publication invalidates the previous cached digest. No save format, history or simulation phase was removed. + +World preview and main/staff/business/supply panels prefer state_updated and retain a snapshot_published fallback for older test doubles. The notification carries the already-generated snapshot and omits only the digest. Callback defaults support the missing digest argument. + +Contract11 checks PASS: lazy invalidation/read caching, exact hash, legacy observer notifications, disconnect behavior and full-state equality between observed/unobserved replay. Registered under make test-perf. + +Same small production scene: x1 average33.536ms, x3 P95123.101ms, down from approximately62ms/230ms before. Still fails4ms/12ms budgets. Scene rendering, GPU, full-load and Windows excluded. latest_snapshot creation and infrastructure validation remain significant costs. + +UI regression session39911 ended with failure in the automatic midnight-ledger assertion. After fixing the snapshot payload, contract11 checks PASS; opening regression is running in session54965, log lazy-digest-opening-fixed.log. Do not claim final UI acceptance until the rerun finishes. Evidence: lazy-digest-contract.log and production-lazy-profile.json. This is partial P28 progress. + +UI regression caught the missing snapshot payload through the automatic midnight-ledger assertion. state_updated now carries latest_snapshot; no new snapshot or digest computation is introduced. Initial failed UI log is preserved. + +Final payload-fix rerun: contract11 and first-operating-day48 checks PASS, including automatic midnight-ledger presentation and deterministic next-morning replay. Core test-s0 also passed before the payload fix. The previous full UI invocation itself failed; broader post-payload UI rerun remains outstanding. + +## Full post-fix UI regression + +make test-ui completed with exit0 on the corrected snapshot-payload implementation. All17 direct Godot commands and both separate upkeep save/read processes passed; no ERROR output. Start/end hashes for131 source/test/build files are unchanged. Final opening48 checks includes the previously failing midnight-ledger presentation and next-morning replay. + +Evidence: lazy-payload-full-ui.log, lazy-payload-full-ui-validation.json and archived upkeep logs under artifacts/facility-dimensions-2026-09-29. This supersedes the earlier pending UI status. Performance budgets and Windows acceptance remain unresolved. diff --git a/docs/plans/original-completion-2026-09-29/execution/LEDGER_INTERFACE_SKIN.md b/docs/plans/original-completion-2026-09-29/execution/LEDGER_INTERFACE_SKIN.md new file mode 100644 index 0000000..062a2ec --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/LEDGER_INTERFACE_SKIN.md @@ -0,0 +1,7 @@ +# 帳簿画面の表裏スキン + +P23/P24 の部分実装。帳簿の見出し・本文を共通パレットへ接続し、日付選択にも既存のボタンスタイルを適用。生成直後でシーンツリーへの追加前でも正しい色になるよう setup に初期レイヤーを明示的に渡す。共通レイヤー参照はツリー外の場合に絶対パス検索を行わない。 + +検証対象は通常 UI で建設した閉店受付の所有時間維持費、日跨ぎ精算、帳簿表示、保存、別プロセスでの読込。追加のスキン検証は表裏で金額/決算テキスト/経済 snapshot が変わらないこと、表色への復帰、裏層の画面描画を確認する。原本全会計や全 UI 完了を意味しない。 + +最初の試行でツリー追加前の絶対パス参照エラーを検出し修正。最終結果と SHA-256 は ledger-skin-validation.json。画像 ledger-skin-back-read.png は暗いパネル上で所持金4498G、維持費2G、未払い0G、損益−2Gが判読できることを確認した。ランク画面変更後にも両プロセスの試験を再実施し、headless write23/read14、macOS 描画あり write25/read16 が全件 PASS。 diff --git a/docs/plans/original-completion-2026-09-29/execution/MANAGEMENT_INTERFACE_SKIN.md b/docs/plans/original-completion-2026-09-29/execution/MANAGEMENT_INTERFACE_SKIN.md new file mode 100644 index 0000000..638984f --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/MANAGEMENT_INTERFACE_SKIN.md @@ -0,0 +1,9 @@ +# Supply and business layer skins + +Partial P23/P24/P25. Supply and business body text and result labels now use the shared layer palette. CheckButton text records its original colours and switches normal/pressed/hover/focus/disabled colours without changing the control value. Read-only layer lookup is shared; Runtime commands, drafts, inventory/accounting and save schema are unchanged. Buttons continue through the existing main style callback. + +Regression: supply42 and business65 headless checks PASS. Native runs43/66 PASS, each adding a BACK screenshot; both images visually inspected at1280x720. Existing suites exercise paid purchase/stock/money reconciliation, kitchen policy and logistics, business prices/open status, deferred commands, save/load and replay. Five new cases per suite cover refreshed BACK text, hover/disabled toggle colours, warning colours, FRONT restoration and unchanged policy-toggle value. Shared layer/guide14 checks PASS. + +Artifacts: supply-skin-headless.log, business-skin-headless.log, supply-skin-native.log, business-skin-native.log, supply-skin-back.png, business-skin-back.png, management-skin-layer.log; hashes in management-skin-validation.json. + +Remaining skin surfaces include ledger, rank/other dialogs and popup menus; final art/audio and Windows remain unverified. This does not complete the full original plan or all UI skins. diff --git a/docs/plans/original-completion-2026-09-29/execution/MANA_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/MANA_INTEGRATION.md new file mode 100644 index 0000000..ec59966 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/MANA_INTEGRATION.md @@ -0,0 +1,114 @@ +# P06 マナ枯渇・全館停止の実行準備 + +2026-09-29。状態: 全館停止のRuntime接続と停電境界試験をローカル制作コピーへ反映済み。反映後の関連7本とmake checkがPASS。以下の準備時記録より末尾の進捗を優先する。原本全実装完了ではない。 + +## 実証した差異 + +原本B-9.1はマナ枯渇で全館機能停止を要求する。`artifacts/mana-2026-09-29/probe_zero_reserve.gd` は通常建設コマンドで受付を完成させ、その後に診断用として残量だけを0へ変更して時計を進める。490分時点で残量0、施設power_available=true、マナ網のgeneration=100、allocated=1を観測した。人員不足で受付そのものは未稼働だが、必要な電力停止条件が成立していない。これは診断フィクスチャであり、通常プレイの受入結果とは区別する。 + +証拠: `artifacts/mana-2026-09-29/zero-reserve-probe.json` と `.log`。 + +## 既存処理と接続先 + +- GameStateのmanaは初期100の所持資源。ManaNetworkServiceの初期供給源は出力100で別管理。Runtimeの施設配電は10分周期の網評価でのみ変わり、所持マナ0を参照しない。 +- `_advance_mana` はstate_changesに現れた施設だけpower_availableを更新する。全館停止は差分の有無や施設の消費量0に左右されてはならない。 +- FacilityServiceはpower_available=falseで開始済みサービスを一時停止し、復帰時の再開処理を持つ。破壊・摘発のsuspension_reasonは独立して保持する必要がある。 +- KitchenProductionServiceはNO_POWER判定済み。枯渇がこのフラグへ届くこと、途中バッチと材料の所有権が保たれることを検証する。 +- `_advance_elevators` は各モジュールの網状態を参照する。全館停止とのAND条件が必要。ElevatorService.set_poweredには最寄り有効停止階への退避と、有効停止階がない場合の保持処理が既存。新たなテレポート規則を作らずこの契約を使う。 +- `_operations_arrival_boundary` は現在人口上限だけを捕捉する。停止時の入館抑止は決定的な境界として接続し、客IDや乱数の二重消費を防ぐ。 +- `_advance_inventory_and_logistics` は期限切れと配送を毎分進める。停電で時間・腐敗・費用を止めず、新規配送と搬送中荷物、救護同伴の扱いを分ける。 + +## 原子受入と実装順 + +| ID | 条件 | 現状 | +|---|---|---| +| MNA-01 | 供給・消費・余剰・不足が整数で一致し、負残量を作らない | 残量コンポーネント33項目PASS。施設別需要の集計/Runtime接続は残 | +| MNA-02 | 残量0なら消費0の施設を含め全館停止 | 差異再現済み | +| MNA-03 | 既存サービス・厨房の進捗/課金/材料が停止し再開で重複しない | 接続未検証 | +| MNA-04 | EV乗客・救護同伴者が安全な階へ戻るか保持され、所有権が消えない | 網停電の既存処理あり、全館接続未実装 | +| MNA-05 | 停止中の入館・新規物流を抑止し、在庫期限・時計・経費は進む | 未実装 | +| MNA-06 | 復旧しても破壊・摘発の休止を解除しない | 既存状態分離あり、全館接続未検証 | +| MNA-07 | 枯渇直前/直後/復旧直前の保存復元が一致 | 未実装 | +| MNA-08 | 旧保存の残量を保全し、過去の消費を遡及適用しない | v11移行設計が必要 | +| MNA-09 | HUDで残量・供給/消費・停止理由・復旧条件が分かる | 未実装 | +| MNA-10 | 通常営業で自然に枯渇・復旧し、Windows配布版で再現 | 未検証 | + +数値非依存の残量計算・保存契約を実装した。次はRuntimeへ順次接続する。GameStateと専用サービスに残量を二重保持するなら照合を必須とし、どちらを正とするか曖昧にしない。新しい停止理由・更新順・移行はP03/P04にも記録する。 + +## 未指定値 + +D04の局所確認として、初期100・上限100・10分ごと世界樹供給5・既存maintenance_manaを同周期消費する試験開始案をユーザーへ質問した。未回答。確定値・原本記載値として本番へ適用しない。稼働中の消費条件、全館停止中は供給を蓄積して復旧可能にすること、同一境界で消費と再開を繰り返さないことを計算契約の試験対象にする。 + +## 残量コンポーネントの実装 + +`scripts/mana/mana_reserve_balance.gd` はレートや周期を決めず、呼出側から各区間の供給総量・需要総量を受け取る。供給を需要へ使用した後の余剰を容量まで蓄積し、供給不足と容量超過を別々に返す。保存には容量・残量・時刻・最後の収支を保持し、収支不一致や未知フィールドは拒否する。失敗時に既存状態を変更しない。同一時刻・同一入力は再送扱い、同一時刻で異なる入力は拒否。 + +保存版1のコンポーネント試験 `tests/s2/test_mana_reserve_balance.gd` は33項目PASS。証拠は `artifacts/mana-2026-09-29/reserve-balance.log`。供給を先に容量で切り捨てると同一区間の需要に使えるマナを失うため、需要充足→余剰蓄積の順を採用した。これは計算契約であり、原本明示値として扱わない。 + +まだRuntimeから呼ばれていないため、MNA-02の不具合は未修正。本番供給量・消費間隔は未決定。既存の維持費回帰対象240ファイルは変更していないが、この新規コンポーネント2ファイルはその回帰には含まれず、別の上記試験で検証した。 + +## 全館給電条件の反映コンポーネント + +`scripts/mana/mana_power_projection.gd` を追加。残量>0と網のACTIVE/DEGRADEDをANDで判定し、消費者ノードを持たない無消費施設にも残量0を適用する。施設の営業方針・耐久・依存先・休止理由には書き込まない。供給不足の理由だけを更新し、変化がなければ状態更新を繰り返さない。 + +`tests/s2/test_mana_power_projection.gd` は実FacilityService/ManaNetworkServiceを使って14項目PASS。残量0の全施設停止、消費0の停止、摘発休止の維持、手動閉店の維持、部分給電、復元後の復旧、負数入力拒否を確認した。初回に試験定数名がGodot組み込みProjectionと衝突し、修正後にPASS。ログ `power-projection.log` と `power-projection-recheck.log` を保持。 + +Runtimeへの接続は未完了で、通常プレイの残量0不具合はまだ解消していない。接続時には全ての正消費施設を先にマナ網へ登録すること、電力フラグの正本をこの投影処理に統一することが必要。呼出中の外部コールバック失敗はRuntimeの既存トランザクションに含めて扱う。EV・入館・物流の停止はこの施設フラグだけで完了扱いにしない。 + +## Runtime接続の作業用コピー + +固定ソース回帰を混在させないため、`mana-staging.json` の作業用コピーで実Runtimeへ接続した。毎分の施設給電、無消費施設の停止、EV給電、新規入館の上限0、新規補給生成の抑止、開始直前の残量確認、完成直後の停電反映を追加。物流の搬出/搬入可否へpower_availableを接続し、期限切れ処理と搬送中荷物の所有権は保持する。 + +`test_runtime_mana_depletion.gd` 15項目PASS。通常建設した受付と一般客室を使い、10分周期の外でも残量0で停止すること、全Runtime保存復元、供給回復後の破壊休止維持を確認した。残量の変更は診断入力であり、自然な消費・回復のバランス試験ではない。 + +初回に建設完了から最初の配電評価までの既存契約を変えて営業回帰が失敗した。未評価ノードの既存状態を保持し、残量0だけは必ず優先する修正後、既存営業循環52項目がPASS。営業と物流の関連試験もPASSし、EV回帰は継続中。詳細は作業用コピーの `mana-validation-recheck/results.json`。制作コピーへの反映は未実施。パッチは `artifacts/mana-2026-09-29/staged-runtime/runtime-integration.patch`。 + +次の受入は、実乗車中のEV枯渇、利用中サービスの課金/一時停止、荷物搬送中の停電。数値未回答を理由にこれらを省略しない。新しい有限残量計算コンポーネントの周期・供給・消費への接続と保存契約も未完了。 + + +## 停電境界の追加受入(作業用コピー) + +- 接客循環58項目PASS。利用中の停電で5分間の進捗と財布・材料所有数を保持し、停電保存を復元後、配電評価と接客完了を待って25Gを一度だけ課金した。最初の試験は未評価ノードの復旧を3分後と仮定して失敗。既存の10分配電境界を待つ試験へ修正しPASS。初回失敗ログも保存している。 +- 物流109項目PASS。積載済み8個を停電中30分保持し、納入0、保存復元と所有権照合を確認。復旧後に一度だけ納入して積荷0・納入先8となる。 +- EV37項目PASS。実乗車中に残量を0へ変更し、有効停止階への緊急到着イベントが各乗客に一度だけ発生すること、経路要求の解消、停電保存復元と再給電を確認した。救護同伴者や有効停止階なしの全館停電は別途残る。 + +証拠: `artifacts/mana-2026-09-29/staged-runtime/outage-validation.json` と同階層のログ。残量変更のみ診断入力であり、自然な消費・回復ではない。本番の供給量・消費間隔は未回答で適用していない。P06全体、画面・配布版受入の完了を意味しない。 + + +## 更新順の確認(P04連携) + +SimulationKernelの順序はコマンド→時刻境界→MANA→建設→物流→施設完了→生体/環境→事件→施設待機列→AI→公開保存。施設完了でサービス、続いて厨房を更新する。AIでEV、その後救護を更新する。したがってMANAで給電を確定してから同じ分の物流・接客・生産・EVへ適用される。建設はMANAの後なので、新規完成施設にも残量0を登録時に反映する。入館境界は施設状態の更新待ちにせず残量を直接確認する。 + +停止・復旧は給電条件のみを更新し、破壊/摘発の休止理由を解除しない。毎分判定しても給電と減力理由が変わらない施設は状態更新を発行しない。既存の障害復旧suffixも同じMANA→建設→物流→施設の順序を呼ぶ。残量計算を将来MANAへ接続する際は、供給/消費の確定をこの給電投影より前に置く必要がある。まだ未接続なのでこの記録を有限残量収支の受入完了とはしない。 + + +## ローカル制作コピーへの反映 + +維持費の固定ソース回帰98本が終了。240ファイルのSHAが開始時と一致した。97本は初回PASS、営業管理UIの説明文期待値だけ1件失敗し、「給与と施設維持費」へ更新した試験を本体で再実行して60項目PASS。初回結果は上書きせず `artifacts/upkeep-2026-09-29/frozen-regression/final-gate.json` に再試験と併記。 + +その後、作業用コピーの8対象ファイルについて本体の変更前SHAと候補SHAを照合して反映した(UI期待値修正1、マナ対応7)。`mana-staging.json` のproduction_applied=trueを参照。現在の本体は既存の98本回帰時から変更されているため、あの98本をマナ統合後の全体PASSとは扱わない。反映後の部品/Runtime/接客/積荷/EV/性能試験とmake checkは `artifacts/mana-2026-09-29/production-integration` へ記録する。 + +反映後の7本は全PASS、make checkも終了コード0。部品33/14、Runtime15、接客58、物流109、EV37項目を再確認し、性能回帰もPASS。対象8ファイルのSHAが作業用コピーの検証済み候補と一致することを確認した。未完了条件は前述のとおり。 + + +## 厨房・救護同伴・HUDの追加受入 + +制作コピーで追加検証。厨房は通常建設と購入配送済み8個の食材、雇用・配置した調理職員を使用。調理3分の時点で残量を0にし、30分の停電中にバッチID・予約・進捗が不変、原料8/完成品0、完全保存復元後の復旧で原料4/完成品4となることを確認した。物流試験全体121項目PASS。 + +救護同伴の実乗車中に残量を0にし、職員と患者が同じ有効停止階へ退避。20分停止しても患者は未治療で位置保持、完全保存復元が一致。給電回復後は同じ患者の救護・治療が完了し、所有権の不整合なし。救護EV試験全体30項目PASS。患者の体調とマナ0は診断入力であり自然発症/自然枯渇ではない。 + +HUDは残量0を「マナ 0・停止」と警告色で表示。ツールチップで停止範囲、残量と配電の回復、破壊・摘発の休止は解除されないことを説明。復旧で通常表示・元のテーマ色へ戻る。実描画のダッシュボード131項目PASS、outage-hud.pngを目視確認。サービス直呼びの代用画面ではなく本番シーンだが、OSのポインター操作/Windows受入は未実施。 + +証拠: `artifacts/mana-2026-09-29/kitchen-rescue-ui-validation.json`。有限残量の自動収支/供給・消費表示/旧保存への新収支モデル移行/通常営業での自然枯渇復旧は未完了。有効停止階が全て失われた場合の停電受入も残る。 + + +## 有効停止階なしの停電(Runtime境界) + +`test_runtime_transport.gd` に実乗車中の全停止階封鎖→残量0の受入を追加。安全な降車先がない間は乗車状態と経路所有権を保持し、20分経過しても残り移動時間を進めない。全Runtimeの保存復元が完全一致することを確認する。その後、停止階の封鎖と停電を解除して移動を再開し、乗車要求と経路要求が残らず、復旧後も保存できることを確認する。これは交通障害と残量の診断入力を用いるRuntime試験であり、通常プレイの自然枯渇を証明しない。結果は `artifacts/mana-2026-09-29/no-landing-outage.log` を参照。 + + +## 旧保存の残量0境界 + +維持費導入前のv9形式フィクスチャ(新規建設後、旧形式への変換とupkeep項目の除去)を使用。残量0・古い給電フラグありの状態を読込し、入力セーブを変更せず残量/時刻/停止を保全。読込以前の維持費を課さず、最初のTick前でも新規入館の許容数は0。再開後はMANAがサービス・物流・EVより先に古い給電状態を停止へ反映する。負のマナを入れた破損保存は現在の完全スナップショットを変えず拒否した。Runtimeマナ試験21項目PASS。 + +注意: 古い給電フラグそのものは停止中の読込直後に書き換えず、次のTickで更新する。即時正規化した保存への移行や、新たな有限マナ収支モデルの移行まで検証したわけではない。証拠 `artifacts/mana-2026-09-29/legacy-zero-save-validation.json`。 diff --git a/docs/plans/original-completion-2026-09-29/execution/MVP_CATALOG_MANIFEST_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/MVP_CATALOG_MANIFEST_CACHE.md new file mode 100644 index 0000000..7cc1d58 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/MVP_CATALOG_MANIFEST_CACHE.md @@ -0,0 +1,29 @@ +# Catalog manifest の反復生成を削減 + +2026-09-30。制作ソースへ適用済み。同じソースの全回帰・実描画・再起動・更新Windows配布を検証済み。 + +Runtime が完全 snapshot を作るたび、同じ catalog から顧客定義32個と class/species entry64複製を生成して manifest のID一覧だけを読んでいた。catalog object と rebuild世代が同じ場合は、元builderで作った private manifest を再利用する。返り値は初回・再利用とも全階層を複製し、呼出元の改変を次の状態へ持ち込まない。catalog rebuild、初期化、成功した復元で無効化。復元候補の一時catalogは毎回元builderで検証し、失敗した復元は現行cacheを変更しない。 + +元builderと GuestDefinition 全フィールドの復元比較はbyte同一。保存schema12とmanifest version2を維持し、既存のchapel前manifest version1も従来の限定条件で読める。新しいbalance値は導入していない。 + +普通の独立コピー2組でABBA順に測定。各実行はx1/x3をそれぞれ200回。実際のRuntime一分更新とそのbatchのdeferred dashboard更新を時間に含める。小さな営業シナリオ(7施設・初期4従業員)であり、GPU描画・高負荷条件・Windows native・長期経済の受入には使わない。各variant2回の平均を比較する。 + +| 測定 | 変更前 | 採用候補 | 短縮 | +|---|---:|---:|---:| +| x1 更新平均 | 12.052 ms | 10.789 ms | 10.48% | +| x3 更新P95の2回平均 | 63.445 ms | 52.130 ms | 17.84% | +| x1 snapshot公開phase平均 | 5.383 ms | 4.420 ms | 17.88% | + +元のx1平均4ms・x3P9512msの目標は未達。直接のmanifest再読込は候補で2.20/2.25µs、変更前803.50/997.60µs。cold missと復元用temporary catalogは元の全生成を行う。 + +各4実行のcheckpointとfinalは store_var の保存byte全体が一致し、domain eventsも除外していない。保存byteのSHAを complete-state-byte-verification.json に記録。乱数、採番、イベントdigestも一致。 + +隔離環境の専用確認はreference39+candidate44項目PASS。候補でcatalog216・Runtime content116・snapshot ownership89・SaveOperations127・Runtime save96・防衛自動周期188=832項目PASS。採用後の本番専用回帰は79項目PASSし、キー型・配列型・canonical bytes、nested mutation、rebuild失敗と復帰、catalog交換、旧保存、破損保存の原状保持、late guest登録、完全tick再実行を確認。最初の集計driverは結果表記の判定とresume読取に誤りがあり、成功した元ログを initial-driver-matcher-errors/ に保全してrunnerを修正した。ゲーム側の失敗ではない。 + +証拠は artifacts/manifest-cache-evaluation-2026-09-30/。独立静的監査は artifacts/snapshot-publication-major-audit-2026-09-30/。元の候補manifestのuncompiled/unmeasured記述は作成時点の記録で、採用と実測は新applied-manifest.json/measurement.jsonに記録する。 + +原本全実装、性能目標、高負荷/Windows、実プレイ時間は未完了。 + +## 採用後の統合確認 + +147/147スイートPASS(708.21秒)、開業・決算・別プロセス再起動103項目PASS、関連描画403項目PASS、戦闘途中の別プロセス保存復元38項目PASS。各入力差分0。全回帰の79 cache回帰もPASS。現行Windows版は17 PCK実行2,639項目PASSで、packed cache80判定を含む。実描画とPCKはmacOS実施。最終証拠は verification.json と CURRENT_FULL_REGRESSION.md / CURRENT_WINDOWS_PACKAGE.md。 diff --git a/docs/plans/original-completion-2026-09-29/execution/NEXT_MECHANICS_AUDIT.md b/docs/plans/original-completion-2026-09-29/execution/NEXT_MECHANICS_AUDIT.md new file mode 100644 index 0000000..096fa60 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/NEXT_MECHANICS_AUDIT.md @@ -0,0 +1,31 @@ +# 次の機能接続の現行ソース確認 + +2026-09-29。全体回帰実行中の読み取り監査。実装・数値は変更していない。 + +## P17 ミミの仕入れ・売却補正 + +原本6.2は仕入れ-20%、売却+20%。現在のunique_charactersはアンリとライのみ。SupplyManagementService.preview_purchaseはSupplyCatalogの単価を直接使用し、購入可能最大数と合計金額を計算。purchaseも同じ見積を使い、PLAYER_CASHからOPERATING_EXPENSEへ決済する。SupplyManagementPanelはカタログ単価を直接表示するため、将来補正時は見積の実単価・合計との一致が必要。 + +通常仕入れは原材料1個3G。20%減を単品丸めすると2Gまたは3Gとなり、合計額丸めとは結果が異なる。例として5個購入は合計15G→12G、単価を2Gに丸めれば10Gになる。原本は端数方法、配置先と適用範囲、加入時期を定めていない。原本未指定値の包括的試験設定権限の回答は未取得なので、勝手に確定しない。 + +供給UIのタブは仕入れ・厨房・配送。通常商品の売却入口と売却基準価格は未実装。RuntimeのSERVICE_SALEは顧客サービス利用料金の精算であり、これをそのまま商品の売却と見なさない。仕入れ補正だけでミミ全効果完了としない。 + +次の縦断実装では、適用根拠付き見積→UI単価/合計→決済→数量/帳簿→保存復元・再送を揃える。加入・適用範囲・端数・商品売却の基準値は決定後に本番接続する。 + +参照: baseline-fetched-2026-09-29.txt:118–124、data/characters/mvp_unique_characters.json、scripts/supply/supply_management_service.gd、scripts/supply/supply_catalog.gd、scripts/ui/supply_management_panel.gd:234、autoload/simulation_runtime.gd:11209。 + +## P21 防衛の通常プレイ入口 + +Runtimeには襲撃作成・防衛者/目標登録・開始・取消のコマンドがあり、従業員画面は参加中の配属変更を拒否できる。今回の回帰は実コマンドによる防衛fixtureであり、通常営業からの脅威発生を証明しない。main.gdとscripts/uiには防衛の準備/開始/撤退を操作する画面がない。警告→準備→戦闘→結果の通常UI、借金滞納との接続、砲台と能動スキルの入口を別受入として残す。 + +P17/P21は未完了のまま。回帰PASS数をこれらの完成判定に使わない。 + +## P17 24時間勤務と既存残業の区別 + +防衛HUD追加後の全回帰中に読み取り確認。原本6.5は警告を無視した24時間勤務とストレス激増を要求する。現行StaffService.set_work_assignmentはDAY/NIGHTのみを受け付け、ShiftInstance.create_standardも同じ2種類。StaffRuntimeにはstressとis_overtimeがあり、ShiftInstanceにはworked_minutes/overtime_minutesが保存されるが、これだけで原本対応済みとは判定できない。 + +既存の残業は、シフト終了時に中断不可作業が残る場合、または予約解放が失敗する場合の継続処理。任意の24時間勤務への警告と継続選択ではない。scripts/autoload内のadd_stress呼出しを調べたところ、定義のみで呼出元はなかった。勤務によるストレス増加の接続は別途必要。 + +次の実装では、警告への継続/中止応答、実際の連続勤務区間、休息による区切り、保存復元時の同意と時間、重複コマンドへの耐性を揃える。シフト別の累計勤務分数をそのまま連続勤務時間へ転記しない。通常の業務終了待ちをユニーク人物の過労同意と扱わない。ストレス係数は原本未指定であり、試験値の包括承認は未回答のため未確定。 + +参照: scripts/staff/staff_service.gd:set_work_assignment/_update_shift_states、scripts/staff/shift_instance.gd:create_standard/record_duty_minute、scripts/staff/staff_runtime.gd:add_stress。今回、製品コードや試験ソースは変更していない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/OPENING_RESTART_DEBUG.md b/docs/plans/original-completion-2026-09-29/execution/OPENING_RESTART_DEBUG.md new file mode 100644 index 0000000..43fceb8 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/OPENING_RESTART_DEBUG.md @@ -0,0 +1,25 @@ +# Natural opening and fresh-process continuation debug + +Prepared `tools/run_opening_restart_flow.py` and `tests/ui/fixtures/opening_restart_flow.gd` as a continuation of the normal opening scenario. The original first-day flow uses default funds, construction/hiring/assignment UI handlers and natural arrivals; it does not insert guests or sales. The restart extension uses the actual dashboard Save button and load menu with a unique test-only slot. + +Write phase: complete the existing opening flow through day2 08:00, save using the dashboard, load the same saved state, continue naturally through day4 00:00 (three closed days) and retain the expected gameplay digest and complete three-day financial reports. Read phase: a separate Godot process creates the normal main scene, loads the same slot via the menu, advances to the same third close and compares results. The digest excludes only the domain-event stream because SAVE_LOADED records measured wall time; ID counters, random streams and every other captured gameplay subsystem remain included. Both phases independently require three balanced closes, no Runtime failure latch and the latest close displayed in the ledger. + +The fixture lives under fixtures and is invoked only by its two-process runner. It is intentionally not a standalone test*.gd in the source sweep, which would not provide a shared isolated slot or paired phase. Python compilation and Godot --check-only passed before execution. A rendered run additionally retains screenshots. Execution results must be read from the runner's result.json and logs; preparation alone does not establish a pass. + +This covers the currently available simple inn over three days, not all original facilities, story routes, loans/defense or long-term balance. It is accelerated simulation with real UI handlers, not manual pointer validation or Windows acceptance. Production performance budgets remain independently unmet until measured otherwise. + +## First rendered run and visible defect + +Initial rendered run completed both processes: write77 and read14 checks, total91 PASS, both exit0, and source-change.json empty. All three closes balanced; net profits were -64G, +346G, +346G. The full gameplay snapshot (excluding the timed domain-event stream) and all financial reports matched across process restart. Saved-second-morning and restarted-third-close screenshots were visually inspected. + +Visual review of restarted-third-close.png revealed an additional UI defect: corridor/stair upkeep rows displayed raw facility_... IDs. OperatingLedgerPanel resolved names only through the service-instance registry, while transport/pass-through infrastructure is stored in completed construction. Added a construction-entity fallback that resolves the display name and coordinates without modifying accounting. The restart fixture now checks readable infrastructure names on both sides of the process boundary. Rerun output is isolated under artifacts/opening-restart-debug-2026-09-29/ledger-name-fix; retain the original screenshot as before evidence. + +The127-suite source sweep predates this ledger display correction. The rerun provides targeted UI and three-day/restart coverage for this final change; it does not rewrite the earlier sweep's source scope. + +## Final corrected run + +Rendered rerun completed: write80 and read17 checks, total97 PASS, both exit0. No tracked source changed during this run. Final validation.json records the result and screenshot hashes. Final uninterrupted-third-close.png shows guest corridor/stair names and coordinates instead of internal instance IDs. Full gameplay comparison (same explicit timed-event exclusion), all three financial reports, latest-report UI, natural advancement and ledger balances passed again after the correction. No manual pointer/Windows/full-original completion is implied. + +## Reverified after infrastructure and snapshot optimizations + +After all128 source suites passed on frozen source, rendered writer80/reader17 passed again (97 total). Artifacts: artifacts/snapshot-opening-restart-2026-09-29. Both processes exit0, source-change.json empty; three balanced closes remain -64,+346,+346G. Full continuation comparison and ledger labels pass. Final screenshots reviewed on macOS; Windows/manual-input/complete-original scope remains outstanding. diff --git a/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_KEYPASS_TRIAL.md b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_KEYPASS_TRIAL.md new file mode 100644 index 0000000..be22536 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_KEYPASS_TRIAL.md @@ -0,0 +1,5 @@ +# 営業スナップショットのキー変換共有試験 + +キーの重複検査とエンコードで文字列化結果を共有する変更を比較した。同一営業状態を変更前後それぞれ100回、交互に保存。出力の一致は100回確認できたが、平均2038.87µsから2141.25µs、P95は2143µsから2260µsへ悪化したため採用しない。製品コードは試験前の内容へ復元済み。 + +証拠: `artifacts/operations-keypass-2026-09-29/comparison.log`、`interleaved.json`、`before.txt`。先行2回の参照実装ハーネスの構文エラーは失敗記録として残す。全体フレーム、Windows、性能予算の合格を示す試験ではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_COPY_REDUCTION.md b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_COPY_REDUCTION.md new file mode 100644 index 0000000..6ee45f2 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_COPY_REDUCTION.md @@ -0,0 +1,9 @@ +# 営業状態スナップショットの重複コピー削減 + +P28 の部分改善。create_snapshot の履歴・朝レポート・日次決算・通知・隔離レポートは、最終の _to_snapshot_variant が辞書/配列を再帰的に新規生成する。その前段の duplicate(true) を取り除き、一時的な参照を同期メソッド内だけで使う。公開 getter の防御コピー、型/容量/形状検証、決定的なキー順、保存形式は維持した。参照を外部へ渡すコールバックや await はこの処理中にない。 + +回帰: Operations233、Runtime Operations51、開業UI59項目 PASS。追加3項目は、返却された全辞書/配列を再帰的に変更してもライブ状態が不変であること、履歴が進んでも以前の保存を復元できること、同じ決算コールバックで復元再生した結果が一致することを確認する。最初の追加試験は復元側の決算コールバックが欠けて不一致となり、同一 fixture での比較へ修正した。既存230項目に失敗はなかった。 + +同じ小規模実ゲーム測定で変更前→後: 営業スナップショット平均3.612→3.550ms、全スナップショット8.040→7.830ms、通常速度平均14.503→14.256ms、3倍速P95 54.366→52.113ms。単回比較のため改善幅を保証しない。目標4ms/12msは依然 FAIL。ヘッドレスでGPU/Windows/大規模負荷は対象外。 + +証拠とソース SHA-256: artifacts/facility-dimensions-2026-09-29/copy-reduction-validation.json。最新変更後の全UI一括実行ではなく、保存と開業への関連回帰である。全原本完了ではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_ENCODING.md b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_ENCODING.md new file mode 100644 index 0000000..ec55e42 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_ENCODING.md @@ -0,0 +1,17 @@ +# Operations snapshot validation and encoding + +OperationsCoordinator.create_snapshot validated the assembled output tree and then traversed it again to canonicalize all containers. The new shared visitor can validate alone or validate while constructing the detached canonical output. Internal-state validation and shape checks are retained; only the final output validation/conversion passes are combined. Canonical String keys are sorted by the engine after insertion instead of repeated Variant string comparator callbacks. The project targets Godot4.7, matching the tested runtime. + +Safety contracts remain: depth/node/collection/string budgets, finite numbers, permitted key types, collision rejection before conversion, original insertion-order error precedence, fully detached nested arrays/dictionaries, and exact canonical ordering. Failure returns no partially encoded value. Snapshot version and public saved schema are unchanged. The standalone canonical converter remains for existing restore comparisons. + +Coordinator300 checks PASS (46 added for the checked-encoding path), ownership87, Runtime save/recovery96, midnight29, and Runtime operating integration51:563 targeted checks PASS. Source stayed fixed throughout that selected regression. Interleaved old/new comparison additionally checks100 byte-identical complete Operations snapshots on the same production-derived state, alternating timing order. + +Interleaved microbenchmark: old2384.77us average/P952441us; new1999.03us/P952051us (about16% lower). The old implementation is retained as operations-before.gd.txt; reference_operations.gd adapts only standalone class/self-construction typing so both versions can be loaded together. Initial adapter parse failures are preserved separately and were fixed before the successful interleaved-final.log run. The game did not fail these harness-only attempts. + +One paired full small-scene profile: Operations2466.65→2027.6us; whole snapshot5884.1→5382.55us; x1 update11417.825→10867.475us; x3P9537096→35841us. Same seed424242, macOS AppleM4/Godot4.7.2 headless,40 samples per speed, deferred dashboard flush included, autosave/GPU excluded. The4ms/12ms budgets still FAIL. This is not Windows/full-load/long-term acceptance. + +Evidence: artifacts/operations-encoding-2026-09-29/validation.json, regression.json, source-start/source-change, change.patch, before/final-profile.json, interleaved.json and logs. Previous128-suite complete regression predates this change. A rendered three-day opening and separate-process continuation is running in opening-restart (session64200); do not count it passed until both phases finish. + +## Rendered restart complete + +Both phases finished with exit0: writer80 and reader17,97 checks PASS (targeted plus continuation660). No production/data/test source changed during either phase. Three closes balanced with net profits -64,+346,+346G. Full gameplay state except timed domain events and complete reports match the uninterrupted path. Both third-close screenshots reviewed: Japanese ledger labels, balances and guest/staff scene intact. This is the current optimized source, still not latest Windows executable acceptance or complete original feature coverage. diff --git a/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_PROFILE.md b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_PROFILE.md new file mode 100644 index 0000000..8f68c6b --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/OPERATIONS_SNAPSHOT_PROFILE.md @@ -0,0 +1,37 @@ +# 営業履歴スナップショットの軽量化試行 + +2026-09-29。P28。状態公開フェーズ内のスナップショット生成に注目し、OperationsCoordinatorの辞書キー二重文字列化を調査した。 + +同じデモ状態を復元した変更前・変更後のインスタンスを交互に200回ずつ測定し、全400回の出力辞書一致を確認。 + +- キー文字列を配列へ保存して再利用する案: 旧平均1570.25µs/P95 1660µs、新1652.045µs/P95 1725µs。約5.2%遅いため不採用。 +- Stringキーのstr変換だけを省く案: 旧平均1570.865µs/P95 1613µs、新1621.955µs/P95 1663µs。約3.3%遅いため不採用。 + +両案を取り下げ、検証済み配布版のソースマニフェストと全ファイルの一致を確認。検証や保存の安全性を削って高速化したことにせず、製品コード変更0。 + +現行版を40サンプルずつ再計測: x1平均9734.7µs、x3 P95 34201µs。目標4000/12000µsは未達。スナップショット平均5156.8µs、うち営業集計スナップショット平均2048.2µs。前回の値との差は別実行のばらつきを含むため改善とは主張しない。 + +計測は小規模な使い捨てデモ、macOS headless、オートセーブ無効。同期Tickに遅延ダッシュボード更新を含むが、GPU描画やWindows、高負荷の最終受入ではない。 + +初回比較ドライバーは参照クラスの戻り値型が元クラス名のままでコンパイル失敗し停止。対象プロセスを終了し、参照ドライバーの戻り値注釈だけを調整して再実行。失敗ログはinitial-driver-error.logに保全。 + +次の調査対象は営業履歴全体に対する保存可否検査・正規化・コピーの各費用。検査省略や履歴の無断切捨てではなく、エラー優先順・深さ/ノード/文字数上限・正規順・所有権を維持した最適化が必要。 + +証拠: artifacts/operations-key-profile-2026-09-29/。before.gd.txt、参照ドライバー、却下した2案、paired JSON、current-baseline.json、verification.json。前回140スイート・通し97項目・配布版は変更されていない。 + +## 同一状態の工程別計測 + +200回、全回で元スナップショットとの一致を確認。計測用コピーへの時刻取得挿入による値であり、本番全体の改善率には使用しない。 + +| 工程 | 平均µs | P95 µs | +|---|---:|---:| +| 内部値の保存可否検査 | 694.115 | 714 | +| コレクション形状検査 | 1.340 | 2 | +| 出力項目の組立 | 38.825 | 41 | +| 出力検査・正規化・ソート | 805.315 | 829 | + +内部検査と出力検査は異なる構造・上限を確認するため、そのまま重複とみなして削除できない。入力で不正な値が出力への変換で隠れるケースも保持が必要。 + +辞書キー型の配列内検索をmatchへ置換する第3案は、別の計測用コピーを用い、従来版と交互に200回ずつ比較。旧平均1563.045µs/P95 1605、新1596.075µs/P95 1647。約2.1%遅く、改善を確認できないため不採用。全400回の辞書一致。製品ソースは編集していない。 + +証拠: `artifacts/snapshot-stage-profile-2026-09-29/` の計測ドライバー、stages.json、paired.json、各ログ、verification.json。最新配布版のソースマニフェストに記録された全ファイルのハッシュ一致を再確認。性能予算は引き続き未達。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ORIGINAL_FACILITIES_EXPORT.md b/docs/plans/original-completion-2026-09-29/execution/ORIGINAL_FACILITIES_EXPORT.md new file mode 100644 index 0000000..d9de947 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ORIGINAL_FACILITIES_EXPORT.md @@ -0,0 +1,13 @@ +# 原本施設統合の配布データ検証(2026-09-29) + +P10/P12の途中成果をWindows Desktopリリース形式に出力した。成果物は `build/windows/original-facilities-2026-09-29/TheTree.exe` と同名PCK。EXEはPE32+ x86-64。全原本実装・P29完了ではない。 + +通常登録21施設とパレット、受付・上級客室・倉庫・従業員宿舎の実建設、原本寸法、承認済み0.5%維持費、ディスク保存と全ゲーム状態復元を検証。配布PCKのみを一時ディレクトリから読み、ソース資源へのフォールバックがないことを検査した。テストスクリプトは外部から渡し、施設/UI/Runtimeは同梱資源を使用。 + +初回ソース試験は保存復元1件が不合格。原因は読込が意図的に追加するSAVE_LOADED履歴とID消費を期待値が含まなかったこと。成功イベントがちょうど1件であることと、それ以外の全状態一致を分けて検証した。修正後、headless PCK28件、macOS実描画PCK28件が合格。make check exit0。original-pack-ui.pngを目視し、宿舎・倉庫・建設パレット・日本語ラベルを確認。 + +新しい読込メニューへの変更で、保存なしのドット見学/物流試遊から読込・移行へ入れる抜け道があったため、そのメニューも無効化。通常ゲームの読込は維持した。 + +証拠: artifacts/facility-dimensions-2026-09-29/original-export-validation.json、original-export-source-sha256.json、original-pack-check.log、original-pack-rendered.log、original-windows-export.log、original-export-check.log。 + +未検証: Windows実EXE操作、OSポインタ入力、宿泊・休息の通常一日プレイ、全30施設。現在の試験はUIハンドラ経由であり、物理クリックの証拠ではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ORIGINAL_ROOM_ZONES.md b/docs/plans/original-completion-2026-09-29/execution/ORIGINAL_ROOM_ZONES.md new file mode 100644 index 0000000..6d716be --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ORIGINAL_ROOM_ZONES.md @@ -0,0 +1,9 @@ +# 原本の宿泊・宿舎配置ゾーン + +原本10.1は一般客室2×1表・上級客室4×1表、10.5は従業員寮2×1裏と指定し、地上限定を指定していない。既存定義のABOVE_GROUNDをANYへ変更し、原本にない地上限定制約を解消した。層・寸法・料金・人数・維持費は変更なし。明示的に地下指定の施設や地上境界を必要とする受付は変更していない。 + +P08/P09の部分対応。通常Runtimeでランク2へ進める試験用資金/評判を用意し、未掘削拒否・異層拒否・通常掘削コマンド・3施設の地下建設完了・保存復元の40チェックPASS。地上の上級客室営業、従業員寮、寮の旧寸法移行、建設制約、Runtime建設の計6スイートPASS。Makefileに新規回帰を登録した。 + +macOS実描画では通常パレット選択/マップ入力ハンドラから3施設を地下建設し、保存復元と画像取得を含む41チェックPASS。OSポインタ操作やWindows実機ではない。試験用ランク解放を使用し、新規ゲームからのランク到達・地下での集客/交通/休息全体の受入を示すものではない。 + +証拠: artifacts/original-room-zones-2026-09-29/related.json、native.log、underground.png、source-hashes.json。地下顧客ドミトリーは従業員寮とは別施設で、依然未実装。全原本の完了ではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/PATH_MEMBERSHIP_OPTIMIZATION.md b/docs/plans/original-completion-2026-09-29/execution/PATH_MEMBERSHIP_OPTIMIZATION.md new file mode 100644 index 0000000..bb6ace3 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/PATH_MEMBERSHIP_OPTIMIZATION.md @@ -0,0 +1,9 @@ +# Direct path membership comparison + +Runtime infrastructure validation previously serialized PathService (traffic, connectors, actors and related state) solely to compare passable cells. It now uses matches_passable_cells: count equality, membership and duplicate rejection. It does not cache mutable state or skip the validation. The serialized snapshot validator remains unchanged for save/restore validation. + +Reference comparison8 checks PASS: equal, empty, missing, duplicate, different, reordered cells, no mutation and empty graph. Runtime infrastructure integration162 checks PASS, including fault/rollback, route projection and rank expansion. New regression registered under make test-perf. + +Same production fixture measured x1 average62.711ms before /62.168ms after; construction phase9.416ms before /9.272ms after. Small single-run difference, not a material performance recovery. The4ms/12ms budgets remain unmet. Remaining major cost is snapshot publication and other infrastructure invariant traversal. + +Evidence under artifacts/facility-dimensions-2026-09-29: path-membership.log, membership-infrastructure-regression.log, production-membership-profile.json. No claim of full high-load or Windows performance acceptance. diff --git a/docs/plans/original-completion-2026-09-29/execution/POST_SKIN_PRODUCTION_PERFORMANCE.md b/docs/plans/original-completion-2026-09-29/execution/POST_SKIN_PRODUCTION_PERFORMANCE.md new file mode 100644 index 0000000..fc1177e --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/POST_SKIN_PRODUCTION_PERFORMANCE.md @@ -0,0 +1,9 @@ +# 最新 UI 統合後の実ゲーム性能 + +UI 回帰と同一ソースで既存 profile_production_scene.gd を再実行。測定プロセスは終了コード0、ログにエラーなし。これは計測完了であり性能 PASS ではない。 + +通常速度平均 14.503ms(予算4ms)、3倍速 P95 54.366ms(予算12ms)。引き続き FAIL。通常速度の PUBLISH_SNAPSHOT 平均9.559ms、CONSTRUCTION平均2.526ms。最新スキン統合後も主要な残課題は状態公開・シリアライズとインフラ検証である。今回の測定だけで旧測定との差を回帰や改善と断定しない。 + +既存の同一チェックポイント、シード424242、各速度40サンプルを使用。小規模の通常 Runtime+メイン画面で、同期UI更新を含み、自動保存・GPU描画は含まない。高負荷・Windows受入を代替しない。 + +証拠: post-skin-production-profile.json / .log と post-skin-full-ui-source.json。プロファイル後にもソース SHA-256 一致を確認。次は状態公開時の重複コピーや検証の費用を、セーブ互換性と不正状態検出を維持して削減する。 diff --git a/docs/plans/original-completion-2026-09-29/execution/POST_SKIN_UI_REGRESSION.md b/docs/plans/original-completion-2026-09-29/execution/POST_SKIN_UI_REGRESSION.md new file mode 100644 index 0000000..04a333d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/POST_SKIN_UI_REGRESSION.md @@ -0,0 +1,11 @@ +# 表裏スキン統合後の UI 回帰 + +共通 HUD、開業ガイド、従業員/商品/営業管理、帳簿、ランク画面のスキン変更を含む現在の制作コピーで `make test-ui GODOT=/opt/homebrew/bin/godot` を実行。終了コード0、96.21秒、18本の直接実行スクリプトと維持費の write/read 別プロセスが PASS。試験前後の対象ソース SHA-256 は一致し、ERROR / SCRIPT ERROR は検出なし。 + +直接実行の結果: layer skin16、rank27、rank pointer11、layer presentation27、original build pointer36、dimension migration12、upkeep inspector6、staff rest8、rescue13、guest observer22、room94、pixel tower243、dashboard139、staff management67、supply42、business65、elevator20、first operating day59。維持費は write23/read14。 + +開業シナリオは通常操作の建設、雇用、仕入、営業から翌日の営業までを通し、宿泊売上240G、人件費120G、維持費4G、未払い0G、決算 balanced=true、保存再生を確認する。全原本の通しデバッグや全ゲームの長期プレイではない。 + +証拠は artifacts/facility-dimensions-2026-09-29/post-skin-full-ui-result.json、同接頭辞の .log と -source.json、および post-skin-upkeep-{write.log,read.log,result.json}。維持費の既定出力先は後続実行で更新され得るため、この回帰専用のコピーを保存した。 + +これは headless 検証であり GPU、Windows 実機、全施設・全分岐の受入ではない。P27 の全原本条件は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/PREMIUM_ROOM_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/PREMIUM_ROOM_INTEGRATION.md new file mode 100644 index 0000000..abac25b --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/PREMIUM_ROOM_INTEGRATION.md @@ -0,0 +1,34 @@ +# Premium room normal registration (2026-09-29) + +Status: partial P08/P10 progress, not whole-package acceptance. + +Added premium_room to ORIGINAL_FACILITY_PATHS and normal Runtime registry (20 definitions), main construction palette, shared lodging icon, bedroom furniture and sleeping presentation. Original dimensions 4x1 remain canonical. Explicit customer capacity is 2. Approved initial daily upkeep formula ceil(900*0.005) gives 5G; this is a tuning value, not an original-spec numeric claim. Existing price220G, duration720minutes and mana demand1 remain unchanged. + +Evidence: +- premium-service.log:15 checks PASS. Ordinary Runtime construction commands create reception and premium room without a test registry. Service request at definition price/duration starts, restores halfway, completes with one220G charge, balances ledger and restores at midnight. The payer is a service-wallet fixture; autonomous walking/admission is not tested here. +- premium-dashboard-final.log:136 native rendered checks PASS. Main palette selects the4x1 definition. premium-palette.png visually inspected: visible and selected premium-room button at900G/material10. Shared generic room icon is reused, not a finished unique art claim. +- premium-runtime-regression.log:20 PASS; premium-migration-regression.log:56 PASS; premium-stairs.log:65 PASS; premium-check.log:make check exit0. + +Remaining: actual guest AI access/arrival and lodging after normal placement, full-day normal operation with capacity/outages, other original facilities, staff/transport-safe migration UI, Windows acceptance. No complete P08/P09/P10 claim. + +## Autonomous guest acceptance (2026-09-29) + +Added test_premium_room_guest_flow.gd to make test-mvp. 30 checks PASS in premium-autonomous-final.log. Construction uses ordinary commands for reception, premium room and two guest stairs, with no direct geometry or route injection. Guests enter through the spawn command with controlled sleep needs and funds; target facility and path are not supplied. The real AI selects premium lodging and moves via the first stair landing before automatic queue/service creation. + +Actual stair-save replay reproduces the complete arrival snapshot. Real guest lodging restores mid-service. Empty mana pauses elapsed service and avoids charging; a disposable disk snapshot restores the paused guest/service, then mana recovery completes the service with one220G charge. Midnight ledger and full snapshot restore pass. A second normal-built fixture sends three deciding guests; observed concurrent usage reaches2 but never exceeds2, the third guest is observed QUEUING separately, and crowded state restores exactly. + +These are deterministic Runtime acceptance scenarios using controlled guest inputs, not natural production arrival distribution, physical UI pointer placement or Windows acceptance. Original-spec all-facility completion, other lodging/dining, prices and long-term balance remain pending. No new production behavior was changed in this verification increment. + +## Viewport input and natural opening day (2026-09-29) + +Added tests/ui/test_original_build_pointer.gd to test-ui. Initial5000G, normal production loop and natural arrivals; no guest spawn, target/path injection or direct build/hire/assignment/save/load handler calls. Mouse events traverse viewport GUI hit testing for the palette, horizontal scroll, map placement, hire/assignment controls and save menu. Popup keyboard events use Input.parse_input_event and the target window ID. Time is advanced by the clock API, not real-time playback. This is synthesized engine input, not OS pointer automation or Windows acceptance. + +The scenario builds reception, premium room and two stairs, hires Akane and assigns the receptionist, completes natural lodging for440G by first midnight, verifies the ledger, saves/loads via controls, compares facilities/economy/guest/staff/construction state and replays the next30minutes exactly. Native rendered run36 checks PASS. original-pointer.png visually inspected at Day2 00:30: premium room occupied, separate queue, guide reports first-day completion. + +This exposed a production bug: opening guidance recognized only standard_room for room existence and first lodging sales. It now recognizes FRONT lodging definitions (service type or legacy lodging tag), and recognizes their active construction. Default recommended build remains the inexpensive standard room. Staff dormitories do not count as guest rooms. + +Initial failures are retained in original-pointer-day*.log: infrastructure stairs belong to ConstructionService rather than FacilityService, and injecting keys directly into PopupMenu's viewport bypassed native popup processing. Those were test-harness issues; the guide's hard-coded standard-room checks were the production issue. + +The previous Windows EXE/PCK predates this guide fix; do not treat its prior acceptance as validation of this revision. Remaining: actual OS/Windows input, other original lodging/dining facilities, broader seeds and long-term balance. + +Final verification: original-pointer-day-complete.log36 checks PASS (headless, includes active premium construction guide); original-guide-standard-regression.log48 checks PASS, preserving ordinary standard-room opening through next-morning save/replay; original-guide-check.log make check exit0. Native36 run preceded the final active-construction hint adjustment; that adjustment is covered by the final headless run. diff --git a/docs/plans/original-completion-2026-09-29/execution/PRODUCTION_RUNTIME_PERFORMANCE.md b/docs/plans/original-completion-2026-09-29/execution/PRODUCTION_RUNTIME_PERFORMANCE.md new file mode 100644 index 0000000..ae85882 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/PRODUCTION_RUNTIME_PERFORMANCE.md @@ -0,0 +1,19 @@ +# Production Runtime performance: budget failure + +Measured the normal main scene with a disposable PixelTowerDemo built via Runtime commands and natural arrivals. Automatic saves disabled, headless, paused clock advanced synchronously. Both x1 and x3 start from the same snapshot; 40 samples each, with a process frame between samples. Timing includes synchronous UI subscribers but excludes rendering/GPU. Small scenario, not the 20,000-cell load fixture. + +Final phase-instrumented measurement: x1 average63.616ms (budget4ms); x3 P95=234.396ms (budget12ms). Both fail. Exit0 means profiling completed and produced a report, NOT performance acceptance. The report explicitly sets within_budget=false. + +Largest per-minute phases: PUBLISH_SNAPSHOT average46.905ms in x1 /47.952ms in x3; CONSTRUCTION9.432ms /9.775ms; AI_DECISIONS3.494ms /4.896ms. Publish includes snapshot creation, canonical digest and all synchronous notification subscribers. Snapshot and digest are also separately measured in the report; their micro-costs must not be added again to publish time. + +This contradicts any inference that the earlier standalone domain benchmark proves production performance. The production bottleneck remains unresolved. Next work: preserve save/digest/replay contracts while reducing snapshot publication cost; inspect repeated infrastructure validation on idle construction ticks; remeasure identical checkpoints and validate deterministic behavior. Do not skip correctness checks or lower the acceptance budget to declare success. + +Evidence: production-scene-profile.json and production-scene-phases.log; diagnostic script tests/performance/profile_production_scene.gd. No production behavior changed by this increment. + +## Section profiling and rejected text batching experiment + +Added per-runtime-section canonical digest micro-measurements to profile_production_scene.gd. Largest measured sections: domain_events5.883ms, facility_infrastructure2.764ms, command_queue2.631ms, guests2.230ms, construction1.905ms. Section timings use fresh encoding contexts and must not be summed as an exact whole-digest decomposition. + +Tested batching typed string-array output in64-entry chunks. Reference compatibility83 checks passed including63/64/65/129 entries, UTF-8 and NUL values. The10,000-event-ID microbenchmark improved from about7.25ms to6.64ms, but the production scenario did not improve (x1 average61.810ms before /62.546ms after; event section5.883ms /5.906ms). The batching production change was reverted. Boundary tests and the diagnostic breakdown remain. Reverted encoder83 checks PASS. + +Evidence: production-sections-profile.json, production-text-profile.json, digest-text-final.log, digest-text-reverted.log. Performance remains FAIL. Next investigation should address complete repeated traversal rather than another scalar-level optimization. diff --git a/docs/plans/original-completion-2026-09-29/execution/PROJECTOR_SINGLE_LIVE_CHECK.md b/docs/plans/original-completion-2026-09-29/execution/PROJECTOR_SINGLE_LIVE_CHECK.md new file mode 100644 index 0000000..b841039 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/PROJECTOR_SINGLE_LIVE_CHECK.md @@ -0,0 +1,19 @@ +# インフラ投影の重複検査削減 + +2026-09-29。P28の部分改善。macOS Apple M4 / Godot4.7.2、headless測定。 + +FacilityInfrastructureProjectorの依存検査は、RouteAdapterのis_projection_staleとvalidate_invariantsの両方から同じ経路状態を列挙・比較していた。正確に同じSpatial/Pathへの接続と非隔離状態を先に検証し、後者に含まれるstaleness検査だけを使う。Spatialの独立した不変条件検査、Adapter自身の保存構造検査、個々の施設投影・名前空間衝突検査は維持する。検査頻度やTick、保存形式は変えない。 + +## 検証結果 + +証拠: artifacts/runtime-profile-next-2026-09-29/。 + +- インフラ投影192、経路投影184、Runtimeインフラ180、snapshot所有権89、計645項目PASS。 +- 旧コードをprojector-before.gd.txtに保存。比較用reference_projector.gdはclass_nameだけ除去した旧実装。 +- 同一の実営業デモ状態で旧・新の検査を交互に各200回実行。すべて正常判定、シミュレーション状態不変。 +- 検査平均1494.195→1165.750µs(約22%削減)。P95は1545→1216µs。 +- 通常シーン40サンプルずつ: 建設フェーズ平均x1 1855.925→1640.550µs、x3 1909.742→1544.658µs。 +- 全体x1平均10245.350→10826.525µs、x3 P95 36446→34639µs。x1は悪化しており全体改善とは断定しない。4ms/12msの性能目標は依然FAIL。 +- 描画付きの開業→3決算→別プロセス再起動: 97項目PASS。両プロセス終了0、ソース変更なし、営業状態JSON一致。書込62.56秒・読込47.41秒。 + +部分検査の測定を全ゲーム高速化・2万マス高負荷・Windows性能の証拠にしない。Operationsのスナップショットなど、残る主要負荷の調査が必要。 diff --git a/docs/plans/original-completion-2026-09-29/execution/PROJECTOR_VALIDATION_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/PROJECTOR_VALIDATION_CACHE.md new file mode 100644 index 0000000..194d97c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/PROJECTOR_VALIDATION_CACHE.md @@ -0,0 +1,14 @@ +# Facility infrastructure validation cache + +P28 partial performance improvement; not full acceptance. + +The production diagnostic measured the projector invariant check at7.139ms. It reconstructed and parsed the unchanged complete projection/request history every minute. Validation now retains one private successfully parsed serialized snapshot. Every invocation still creates a fresh snapshot and compares its complete contents; any changed projection, history, revision or quarantine metadata invokes the strict parser again. Current spatial/route/path crosslinks are checked every time, including cache hits. Restore always uses the original strict parser. No saved fields or digest contract changed. The cache holds one snapshot, never a growing list. + +Regression evidence: +- Projector192 checks PASS,exit0, including fresh/unchanged checks, externally mutated returned snapshot, projection fingerprint corruption at unchanged revision, request-history corruption, changed live path, repaired path and later unprojection. +- Runtime infrastructure162 checks PASS,exit0, including existing rollback/restore/construction boundaries. +- The new path-corruption test initially tried restoring traffic before rebuilding the graph; fixed the test to restore the graph first, matching the existing Path restore contract. Production code was not changed to bypass that contract. + +Production headless diagnostic: projector invariant average7.139ms →2.536ms. Current x1 average16.348ms; x3 P9560.411ms. Both remain above4ms/12ms. The fixture is small and uses the normal Runtime/main scene; it excludes GPU/rendering and Windows acceptance. This comparison is a diagnostic single-run result, not statistical certification. Extra memory from one retained serialized snapshot has not yet been assessed at the maximum history size. + +Artifacts: production-validation-costs.json, production-projector-cache.json, projector-validation-cache.log, projector-cache-runtime.log, projector-cache-validation.json. diff --git a/docs/plans/original-completion-2026-09-29/execution/RANK_INTERFACE_SKIN.md b/docs/plans/original-completion-2026-09-29/execution/RANK_INTERFACE_SKIN.md new file mode 100644 index 0000000..5be2484 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/RANK_INTERFACE_SKIN.md @@ -0,0 +1,11 @@ +# 世界樹ランク画面の表裏スキン + +P23/P24 の部分実装。原本 B-12.4 の表モード(羊皮紙・ファンタジー)/裏モード(黒鉄・モニター風)の切替をランク昇格画面に適用した。Window 専用のローカル Theme と既存の共通パレットを使用し、条件文、枠、ボタンの通常・ホバー・押下・フォーカス・無効色を変更する。内部ラベル/ボタンは明示的に更新する。初回から裏層の場合にも適用する。新規外部素材、ゲームルール、セーブ形式の追加はない。 + +検証: test_world_tree_rank.gd headless 27 / macOS 描画あり 28、test_rank_pointer.gd 11、test_layer_skin.gd 16、いずれも failures 0。条件表示・無効ボタン、表裏往復後の全 Runtime snapshot 一致、昇格前の再評価、申請キュー、重複防止、保存・復元、昇格後の建設範囲拡張を含む。裏層起動の色も確認した。rank-skin-back.png を視認確認し、条件文と無効ボタンの判読、枠内配置を確認した。 + +試験中、内部 Control が通常の子走査の対象外となることを検出し、個別更新に修正した。また選択レイヤー自体は保存対象なので、完全 snapshot の不変性は表→裏→表の往復後に比較する。 + +成果物: artifacts/facility-dimensions-2026-09-29/rank-skin-{headless,native,pointer,layer}.log、rank-skin-back.png。ソース/ログの SHA-256 は rank-skin-validation.json。 + +これは操作接続と表示の検証であり、新規ゲームから自然に全ランクへ到達した証拠ではない。その他のダイアログ/ポップアップ、完成素材・音響、Windows 同一ビルド、全原本受入は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/SELECTION_AUTOSAVE.md b/docs/plans/original-completion-2026-09-29/execution/SELECTION_AUTOSAVE.md new file mode 100644 index 0000000..580beef --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SELECTION_AUTOSAVE.md @@ -0,0 +1,16 @@ +# 自動保存・直接読込での選択復元 + +2026-09-29。毎日06:00の自動保存、遅延手動チェックポイント、Runtimeの直接読込をP24の選択復元へ接続。 + +Runtimeは実保存時に任意の画面情報を信号で収集し、interface_selectionだけをコピーする。呼出元が明示した情報を上書きしない。画面が存在しない場合も保存可能。成功したload/recovery/recovery_candidate通知で共通UI復元を行い、手動ボタンからの二重復元を防止する。失敗、保存、移行の通知で選択を変更しない。 + +通常開業から翌々日06:00まで営業し、実自動保存→別の施設選択→自動保存候補の読込を検証。保存予約後に選択を変えた場合は実保存時点の選択を保存。直接厳格読込の成功/失敗も確認。headless75項目PASS、ダッシュボード168、従業員131、画面なしRuntime保存ライフサイクル96項目PASS。旧保存互換試験はUI通知を通さない旧形式のパッケージを作り、任意情報がない保存を検証する。 + +実描画76項目PASS。通し試験は83+20=103項目PASS、書込47.25秒・読込34.58秒、ゲーム状態一致、ソース差分0。画像で自動保存候補読込後の受付選択枠と詳細を確認。証拠: artifacts/selection-autosave-2026-09-29/。変更前、差分、各ログを保存。 + +後続の選択ライフサイクル試験はheadless38項目・実描画39項目PASS。工事、完成施設、建設済み階段、掘削、旧版移行で実体化された既設階段を実パッケージから復元。画面破棄後の保存・再生成後の直接読込・自動保存の古い世代と新しい世代の選択も確認。証拠: artifacts/selection-lifecycle-2026-09-30/。追加試験初回の呼出し誤りはログに保全し、正しい公開APIへ修正後に再実行。今回変更後の全回帰143スイートPASS。全件合格後の実描画・別プロセス再起動103項目PASS、ゲーム状態一致、ソース差分0。CURRENT_FULL_REGRESSION.mdを参照。Windows配布/実機は未完了。この変更だけでP24全体や原本全実装を完了とはしない。 + + +## 次の接続対象(現在ソースの読取監査) + +GuestObservationPanel.selected_guest_idとStaffManagementPanel.selected_staff_idは主画面の施設選択とは別に保持される。現在のinterface_selectionはkind/idだけで、これらを保存しない。したがって施設・工事・掘削・階段の選択復元が通っても、従業員・来客の全詳細画面の選択復元を達成したとは判定しない。次は、保存時に選ばれた実在IDと表示入口を任意情報として保存し、採用候補の情報から復元する。現行の全回帰中は本体へ適用しない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/SELECTION_RESTORE.md b/docs/plans/original-completion-2026-09-29/execution/SELECTION_RESTORE.md new file mode 100644 index 0000000..1478e0e --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SELECTION_RESTORE.md @@ -0,0 +1,21 @@ +# 手動保存した選択対象の復元 + +2026-09-29。P24「保存後も対象選択を回復」の手動セーブ入口を接続。保存ボタンはmanifestにinterface_selection version1(kind/id)を記録する。成功した通常/復旧読込で、その候補のmanifestから選択を復元し、マップ強調表示と管理パネルへ反映。GameStateが保持する保存時の表/裏レイヤーを画面へ同期する。ゲーム状態の保存スキーマ変更なし。 + +選択メタデータは任意。欠落、未知版、型不正、未知種別、存在しないIDは無視し、ゲーム状態の読込を妨げない。失敗した読込は現在の選択を変更しない。施設(建設済みインフラ含む)、工事、掘削、既設階段をそれぞれ対応サービスで存在確認してから反映する。 + +ダッシュボード168項目PASS。保存パッケージにIDが残ること、通常/厳格読込、保存時レイヤー、旧版/不正UI情報、失敗時の選択保持、正式セーブ破損時にバックアップ候補の選択情報を使うことを確認。従業員131項目PASS。 + +通常保存ボタン/読込メニューを使う実描画通しシナリオに選択復元の検証を追加。書込側83・新規プロセス読込側20=103項目PASS。書込46.27秒・読込34.41秒、双方終了0、ソース差分0。3日目までの営業状態・帳簿が一致。保存後読込画面で受付の強調表示と詳細表示を視認。 + +証拠: artifacts/selection-restore-2026-09-29/。変更前ソース、dashboard.log、staff.log、opening-restart(selection.json、画像、両ログ、営業状態)、targeted-verification.json。全回帰141/141 PASS、欠落・重複・ソース差分0。全件PASS後の実描画通し103項目もPASS。artifacts/full-regression-after-selection-restore-2026-09-29/参照。 + +後続の自動保存/直接読込対応はSELECTION_AUTOSAVE.mdを参照。残件: 工事/掘削/既設階段の全個別受入、Windows配布/実機、より広いP24は未完了。今回の実装を全選択・全保存入口の完了とは判定しない。 + +## 自動保存・直接読込への接続調査 + +全回帰中に現行ソースを読み取り確認。Runtime._process_save_checkpointsは毎日06:00に_perform_checkpoint_saveを直接呼び、画面選択はmetadataに含めない。遅延手動チェックポイントも同じ保存関数を使う。したがって手動ボタンのmetadata追加だけでは自動保存へ反映されない。 + +次の接続点は_perform_checkpoint_saveの任意プレゼンテーション情報収集と、既存save_operation_completed通知。load_from_path/load_with_recovery/load_recovery_candidateの結果には実際に採用したmanifestとoperationが残る。画面側は成功した読込操作だけを扱い、save/migration/inspect/失敗を読込と混同しない。_load_manualの現在のUI再接続を共通化し、通知とボタン後処理で二重に復元しないことが必要。 + +受入追加: 06:00自動保存→別候補選択→自動保存候補読込、キュー投入から保存時刻までの選択変更、成功/失敗した直接読込、UIのないheadless保存、画面破棄/再生成、古い選択ID、履歴世代ごとの異なる選択。任意表示情報の異常でゲーム保存を壊さず、保存情報取得でゲーム状態を変えない。この調査後、全回帰完了を確認して接続を実装。SELECTION_AUTOSAVE.mdに後続検証を記録。 diff --git a/docs/plans/original-completion-2026-09-29/execution/SERVICE_START_SLOTS.md b/docs/plans/original-completion-2026-09-29/execution/SERVICE_START_SLOTS.md new file mode 100644 index 0000000..76c8456 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SERVICE_START_SLOTS.md @@ -0,0 +1,19 @@ +# サービス開始結果の枠番号修正 + +2026-09-29。対象: ローカル制作コピー。P09/P27の部分修正。 + +顧客同時利用人数とサービス枠を分離した際、開始結果の `slot` が人数制限を含む `available_slots()` に依存していた。サービス容量100・人数上限3で2人組を最初に開始すると、0ではなく98を返していた。また開始通知のコールバックで次のサービスを開始すると、外側の結果が次の開始の影響を受けていた。 + +挿入前の実行中サービス数を今回の開始インデックスとして確定し、通知後もその値を返す。これは開始時点のインデックスであり、永続的な座席IDではない。利用者人数制限・待機列・料金・保存形式は変更しない。 + +## 検証 + +証拠: `artifacts/service-start-slots-2026-09-29/`。 + +- 修正前: 新規25項目中3件失敗(before.log)。 +- 修正後: 新規25項目PASS(after.log)。容量2/100、2人組と1人組、人数超過拒否、保存復元一致、通知中の再入を確認。 +- 既存関連: FacilityService61・追加施設パラメータ29・Runtime客周期40・清掃通常操作90、計220項目PASS。 +- 初回再現試験の終了時リークは、試験の通知コールバックがサービスを参照する循環を切断して解消。修正後ログに終了時エラーなし。 +- 描画付き開業・決算・再起動試験: 80+17=97項目PASS。書込62.79秒、読込48.07秒、両プロセス終了コード0。3回決算までの営業状態が再起動前後で一致、実行中ソース変更なし。 + +前回の139スイート一括PASSはこの修正前の証拠。今回の変更後に全140スイートを一括実行したとは扱わない。Windows配布版は未更新、実機試験と原本全体の受入は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_CALL_REUSE.md b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_CALL_REUSE.md new file mode 100644 index 0000000..a3b9b33 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_CALL_REUSE.md @@ -0,0 +1,13 @@ +# 営業履歴スナップショットの同一呼び出し内再利用 + +2026-09-29。OperationsCoordinator.create_snapshotの内部検査と出力検査で、構造・深さが一致する4フィールド(demand_history、arrival_reports、alerts、quarantine_report)を再利用する。最初の検査で所有権を分離した正規化コピーとノード数・文字数を記録し、同じ呼び出しの出力組立だけに使う。呼び出しをまたぐキャッシュは作らない。その他の変換項目は再検査する。 + +合計上限に抵触し得る場合は元の再帰検査へ戻し、従来のエラー優先順を維持。履歴の削除、検査上限の緩和、保存形式変更なし。全体スナップショットを公開時に生成する契約も維持。 + +同一デモ状態を交互に200回ずつ計測: 旧平均1561.75µs/P95 1601、新1073.165µs/P95 1110(平均約31.3%短縮)。400回すべて出力一致。既存300チェックと差分比較549チェックを通過し、後者を恒久回帰へ組込み、製品コードで849チェックPASS。比較にはUnicode・キー衝突・非有限数・深さ・集合サイズ・文字数/ノード数の累計超過・正規順・出力と元状態の分離を含む。 + +ゲーム全体の小規模デモで、x1平均9882.775→9371.8µs、x3 P95 34674→32728µs。全スナップショット平均5074.2→4525.65µs。別実行の測定なのでばらつきを含む。macOS headless、オートセーブ無効、同期Tick+遅延UI更新を含む。GPU描画・Windows・最終高負荷条件は未検証。目標4000/12000µsは引き続き未達。 + +証拠: artifacts/snapshot-call-reuse-2026-09-29/。変更前ソース、候補と参照ドライバー、比較・既存回帰ログ、runtime-before/after.json、comparison.json。 + +関連試験: Runtime統合21、Runtime営業51、ダッシュボード149チェックPASS、各終了コード0。全回帰140/140スイートPASS(674.21秒)、欠落・余分・重複・ソース差分0。続く実描画の開業・3回決算・保存・終了・再起動97項目PASS、保存前後の営業状態一致。証拠はartifacts/full-regression-after-snapshot-reuse-2026-09-29/。CURRENT_FULL_REGRESSION.md参照。今回の軽量化を含むWindows開発確認版を生成。隔離PCK1,527項目とmacOS実描画従業員操作132項目PASS。Windows実機未検証。CURRENT_WINDOWS_PACKAGE.md参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_KEY_BUDGET.md b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_KEY_BUDGET.md new file mode 100644 index 0000000..5a7b5fc --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_KEY_BUDGET.md @@ -0,0 +1,11 @@ +# Dictionary key validation cost reduction + +OperationsCoordinator canonical dictionary keys are always strings after key-type validation. Validate that leaf inline instead of calling the recursive general Variant inspector for every key. The existing preflight collision scan, type restrictions, depth-before-node precedence, single-string limit, total string budget and value recursion are unchanged. No limits were raised and no validation was removed. + +Added six dictionary-specific edge assertions (depth/count effects, node budget, aggregate and individual string length, integer canonical key length). The Operations suite passes 254 checks including existing corruption, canonical collision, ownership, byte compatibility and save tests. + +Measured using the same production-scene fixture, seed 424242 and 40 samples per speed, macOS Godot 4.7.2 headless with autosave disabled. Before -> after: Operations snapshot average 3174.95 -> 2419.45 us; full snapshot average 7717.4 -> 6620.5 us; x1 average 13979.775 -> 13487.4 us; x3 P95 54880 -> 51750 us. This is a single paired measurement, not a statistical performance guarantee. Full production budgets (4000 us x1 average, 12000 us x3 P95) still FAIL. Rendering/GPU, full-load fixture and Windows are not measured here. + +Evidence: artifacts/facility-dimensions-2026-09-29/key-budget-before.json, key-budget-after.json, key-budget-tests.log, key-budget-runtime.log, key-budget-opening.log, key-budget-validation.json. Runtime and opening results must be taken from their finished logs/manifest, not inferred from the unit suite. + +Completed follow-up: Runtime Operations integration 51 checks PASS; first operating day 59 checks PASS; both exit code 0. Combined with 254 Operations checks, 364 checks passed on the retained change. diff --git a/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_OWNERSHIP.md b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_OWNERSHIP.md new file mode 100644 index 0000000..97cb786 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_OWNERSHIP.md @@ -0,0 +1,21 @@ +# Transfer detached subsystem snapshots without a second full copy + +Runtime create_snapshot previously assembled the results of every subsystem snapshot/serializer, then passed the complete tree into SimulationSnapshot.capture, which deep-copied it again. Runtime now assembles those freshly owned containers, captures the clock/resource envelope at the same point, and attaches the owned systems tree directly. Generic SimulationSnapshot.capture remains unchanged and still deep-copies arbitrary caller extras. No schema, ordering, hash format or publication frequency changes. + +Reviewed component snapshot builders: world/path/infrastructure build serialized rows and detached cells; operation serialization recursively creates canonical containers; economy/command/event records return detached dictionaries; facility/guest/staff serializers copy nested policies, histories, tasks and definitions; inventory/logistics/kitchen, construction/excavation, authority/defense/relationships/progression and weather use copies or new scalar rows. Runtime manifest/RNG/movement/logistics/event-definition wrappers likewise create new arrays/dictionaries. The Runtime path now relies on that existing component ownership contract rather than masking aliases with another aggregate clone. + +Added a regression registered in make test-perf. It independently mutates all40 Runtime snapshot sections in new-game and populated-demo states, including adding values to empty typed arrays and clearing/replacing nested dictionaries; live state must remain identical. It also covers the clock/resources, retained snapshots while live time advances, exact restore/replay, and generic caller-extra isolation. Before and after both87 checks PASS. Runtime Save/Load/recovery/atomic-rejection96 checks PASS, targeted total183. + +Single paired small production-scene profile: snapshot average6766.5→5457.4us (~19% lower); x1 average12636.875→11957.1us (~5% lower); x3 P9544193→36908us (~16% lower). Same seed424242,40 samples per speed, macOS Godot4.7.2 headless, includes deferred dashboard flush, excludes GPU and autosave. Both4ms/12ms budgets remain FAILED. This is not a statistical/full-load/Windows acceptance claim. + +Because the change spans every saved subsystem, started the complete source regression in artifacts/snapshot-full-regression-2026-09-29 (expected128 unique scripts). Runner session74488 is the handle to resume. Inspect authoritative completion and compare source-start.json at the end; do not infer success from partial all-tests.json. After successful regression, rerun the paired opening/restart scenario. The last Windows ZIP still predates this and the preceding infrastructure validation optimization. + +Evidence: artifacts/snapshot-ownership-2026-09-29/validation.json, before/after ownership logs, save-lifecycle.log, profile-before.json and profile-after.json. Ownership checks exercise current fixtures, not a proof that every possible future subsystem branch can never introduce an alias; retain this test and the general capture contract when adding new components. + +## Complete source regression after both optimizations + +Runner session74488 terminated with exit0. All128 unique discovered source test scripts passed; missing/unexpected scripts and ERROR/SCRIPT ERROR logs were empty. All283 tracked source/data/scene/test files match the starting manifest, including no newly added or removed files. completion.json and source-end.json are retained in artifacts/snapshot-full-regression-2026-09-29. Tooling/docs changes during the run are outside that production-source manifest and do not alter the tested game. This closes the regression gap for the infrastructure-validation and snapshot-ownership optimizations, as well as the prior ledger display correction. It does not certify missing original features or unmet performance budgets. + +A new rendered paired opening/restart run started after that success, output artifacts/snapshot-opening-restart-2026-09-29, runner session84272. It remains pending until both processes and source-change comparison finish; prior runs are not substituted for this result. + +Paired rendered continuation completed after the128-suite pass: writer80 and fresh-process reader17 checks, total97PASS, both exit0, no source changes. All three closes balance (-64,+346,+346G). Gameplay state (excluding only wall-clock domain events) and complete financial reports match across process restart. Both third-close screenshots were visually reviewed. Evidence: artifacts/snapshot-opening-restart-2026-09-29/validation.json. This validates the existing three-day inn flow after both optimizations, not the full unfinished original game. diff --git a/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_TREE_BUDGET.md b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_TREE_BUDGET.md new file mode 100644 index 0000000..d92541b --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/SNAPSHOT_TREE_BUDGET.md @@ -0,0 +1,11 @@ +# 状態検証カウンターの軽量化 + +P28 部分改善。OperationsCoordinator の保存データ走査で用いるノード数/合計文字数を、キー付き Dictionary から共有 Array[int] の2要素へ変更。各ノードでのキー検索と型変換を除去した。検査順序、深さ/件数/単一文字列/総文字数/コレクションの上限、有限数/キー衝突検出、外部へ返す検証レポートは維持する。カウンターは走査ごとに生成し、キャッシュによる検査省略は行わない。 + +キーを一度 String 化してネイティブ sort する案も試したが、営業スナップショット3.550→3.561ms、通常速度14.256→14.388msで改善が見られず、こちらは完全に戻した。旧エンコーダとの正規化出力比較5項目をテストに残した。 + +採用変更の計測: 営業スナップショット平均3.550→3.167ms、通常速度平均14.256→14.031ms、3倍速P95 52.113→50.911ms。同一小規模 fixture の単回比較であり速度向上率の保証ではない。目標4ms/12msは依然 FAIL。GPU、大規模負荷、Windows は未評価。 + +Operations248項目と、通常開業→翌日営業・決算・保存再生59項目が PASS。追加の境界10項目はノード数/総文字数/深さの上限一致と次の値の拒否、Unicode文字数、文字列長、集合サイズ、非有限数を検証。カウンターを上限直前から開始する試験も用い、200万ノードを生成するだけの冗長な試験は行わない。既存の検証/復元失敗ケースも維持。 + +ソース・ログ・計測の SHA-256 は artifacts/facility-dimensions-2026-09-29/tree-budget-validation.json。全原本、全UI、最終性能受入の完了を意味しない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_DEFENSE_LOCK.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_DEFENSE_LOCK.md new file mode 100644 index 0000000..175f6c6 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_DEFENSE_LOCK.md @@ -0,0 +1,15 @@ +# 従業員の配属変更禁止理由と防衛中ドラッグ + +2026-09-29。P24/P27の部分対応。配属変更の可否をRuntime共通判定へ集約し、防衛参加・イベント参加・作業中・勤務不可の理由を従業員詳細、配属ボタンのツールチップ、ドラッグ開始時の拒否に表示する。コマンド側の拒否優先順、理由コード、メッセージは維持。保存形式と経済値の変更なし。 + +実入力のドラッグ中に実防衛コマンドで襲撃・施設目標・防衛者を登録し、配属変更拒否と予定保全を検証。登録状態のファイル保存/読込、再ドラッグ拒否、防衛取消後の再配置まで119チェックPASS。macOS描画ありも119チェックPASS(同一シナリオの再実行)。通常営業からの襲撃発生を検証したものではない。 + +関連回帰: 防衛Runtime135、保存ライフサイクル96、通常清掃UI90、全321チェックPASS。清掃の承認済み初期値は現行施設定義とcleaning_policy.jsonで再確認し変更していない。証拠は artifacts/staff-defense-lock-2026-09-29/。 + +開業・3回決算・別プロセス再起動の実描画通し確認は80+17=97チェックPASS。保存から再開したゲーム状態は中断しなかった状態と一致し、実行中ソース変更は0。証拠はopening-restart/result.json、write-gameplay.json、read-gameplay.json、source-change.json。 + +初期試験で別境界を発見: PREPARINGの襲撃で指定した目標施設をまだregister_defense_assetしていない場合、取消が「Started Raid is missing objective asset」で拒否される。今回のドラッグ試験は正規の目標登録を含むfixtureへ修正して通過。未登録目標の準備中取消の修正・専用回帰は残件として扱う(diagnosis.logに証拠)。 + +既存Windows ZIPは今回の表示改善を含まない。Windows実機、全体140スイートの現行再実行、性能予算、長期プレイ、全原本実装は未完了。 + +追補: 未登録目標の準備中取消はDEFENSE_PREPARATION_CANCEL.mdの修正・専用回帰で対応済み。 diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_DORMITORY_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_DORMITORY_INTEGRATION.md new file mode 100644 index 0000000..e2bc846 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_DORMITORY_INTEGRATION.md @@ -0,0 +1,15 @@ +# Staff dormitory normal integration (2026-09-29) + +Partial P08/P12 implementation. staff_dormitory is registered normally and in the construction palette (21 definitions). Canonical2x1 BACK geometry remains. Daily upkeep uses the approved initial half-percent formula:ceil650*0.005=4G. Customer capacity0 prevents it being a customer-service room; staff deployment min/max remain separate. Rest selection uses the existing physical commute/rest system. Dormitory resting status now names the dormitory. Shared bed icon/furniture is reused, with source assets/licenses unchanged. + +Evidence: dormitory-flow.log18PASS. Normal construction builds warehouse support, dormitory and two service stairs. Off-duty staff cross the staff door and climb stairs, reach the rest destination and recover health/fatigue. Guests cannot route through staff-only access. Snapshot replay matches. Zero mana and closing stop recovery; restored power resumes it. Dormitory customer limit is explicit; this does not define or prove a separate overnight staff lodging capacity/booking system. + +Native UI: dormitory-dashboard.log137PASS; dormitory-palette.png visually inspected with the selected650G/material8 button and BACK placement mode. Existing staff rest14PASS, Runtime integration20PASS, make check exit0. These are Runtime command and rendered UI handler tests, not OS-pointer construction or Windows acceptance. + +Remaining: full ordinary shift/rest-day acceptance, physical UI construction, staff/transport-safe old-size migration flow, other original backyard facilities and Windows build. P12 and the full original goal remain incomplete. + +## Ordinary night shift/rest/next shift + +Added test_dormitory_shift_cycle.gd and registered it beside the existing dormitory test in Makefile.16 checks PASS,exit0. Uses normal Runtime commands to construct warehouse,dormitory and service stairs, register a porter and assign a repeating NIGHT shift. Clock advances through18:00 arrival,05:59 attendance,06:00 off-duty, physical travel up service stairs to the dormitory and next18:00 commute back to the warehouse. A checkpoint at06:00 replays the same60-minute rest commute/state exactly. Staff invariants remain valid. + +This closes the focused ordinary night-shift/rest/next-shift gap for one worker/seed. It is a Runtime command scenario, not a physical UI-input or Windows test. It does not establish all day shifts, crowded dormitory capacity, payroll affordability or long-term balance. Other P12 facilities/equipment remain incomplete. Evidence: dormitory-shift-cycle.log and dormitory-shift-cycle-validation.json. diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_ASSIGNMENT.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_ASSIGNMENT.md new file mode 100644 index 0000000..ca3516c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_ASSIGNMENT.md @@ -0,0 +1,35 @@ +# 従業員のドラッグ配属(P24途中成果) + +雇用済み従業員ボタンから選択層の完成施設へドラッグして配属できる。選択中の勤務帯をドラッグ開始時に保持し、既存set_staff_work_assignmentコマンドへ渡す。従業員・シフトをUIから直接変更しない。既存の施設選択+適用ボタンも維持する。空きマスでは配属解除を行わず、完成施設と表裏の層を確認する案内を表示する。予約中・編集不可の従業員はドラッグ開始を拒否する。 + +最終検証: tests/ui/test_staff_management.gd、実描画53検査PASS、headless53検査PASS。追加6検査は入力イベントによる受付への配属、成功表示、試験状態の復元、空きマス拒否時の全Runtimeスナップショット不変、理由表示、別種類データ拒否。既存47検査はクリック代替、職種不一致、予約、保存復元、勤務帯等を含む。これはWindows実機や全職種ドラッグ経路の受入ではない。 + +入力試験では物理解像度と論理解像度の変換を行ってInput.parse_input_eventへ投入する。途中のテスト失敗は、誤った辞書キー、試験でパネルを閉じたこと、表裏の選択、座標倍率、案内パネルとの重なりが原因。失敗ログを保持している。macOS実描画ではViewport入力に加え、ドロップ時のGodotマウス位置更新のためInput.warp_mouseを使用する。headlessでは倍率変換したInput.parse_input_eventを使う。人によるOS操作の受入とは区別する。 + +残件: ドラッグからの夜勤・職種拒否・人数上限・予約保存復元の個別追加試験、実描画の画像検査、Windows同一ビルド操作、P24全体の他要件。全原本実装の完了ではない。 + +## Additional drag boundary acceptance + +Dragging the receptionist onto the BACK kitchen rejects the role mismatch and preserves the original schedule. Selecting NIGHT and dragging onto the FRONT reception queues exactly one command. An isolated save/load preserves its pending state, and the next minute applies the exact facility and NIGHT template and clears pending feedback. The test save is removed afterward. + +Headless62 checks PASS in staff-drag-boundaries.log; native63 checks PASS in staff-drag-boundaries-native.log (one additional image-save check). staff-drag-night.png was visually inspected: reception night assignment, effective time 18:00 and completed-command feedback are visible. + +Remaining: drag entry coverage for capacity, inaccessible routes, locked staff and other rejection boundaries; Windows same-build input acceptance. Existing service tests do not replace these UI entry tests. + + +## 人数上限・経路なし・ロック中のドラッグ受入 + +現在の本番コードへ追加修正を要する不具合は検出されなかった。テストへ3種の境界を追加し、いずれも従業員一覧からマップへの Viewport マウス入力で検証した。通常登録の受付 staff_max=3 に合わせて3名の勤務予定を設定し、4人目の配属拒否と全従業員状態の不変を確認。到達不能な始点へ試験対象を置き、経路拒否と全従業員状態の不変を確認。イベントのロックを注入し、ドラッグ開始を拒否して勤務もコマンドキューも変化しないことを確認。各ケースの後に元の Runtime snapshot を復元した。 + +これらは境界条件の制御 fixture であり、3名の通常求人からの雇用、実際の通路撤去、物語イベントの自然発生を証明するものではない。投入条件を別とし、操作入口と Runtime の拒否は実コードを使用した。既存の夜勤・申請保存復元・職種拒否・クリック代替の回帰も含め headless77、macOS 描画あり80項目 PASS。描画ありの追加3項目は各拒否表示の画像保存。3画像を視認し、人員上限・通路確認・配属変更不可の説明と無効ボタンを確認した。 + +証拠: staff-drag-rejections-validation.json、staff-drag-rejections.log、staff-drag-rejections-native.log、staff-drag-rejection-{capacity,route,locked}.png(artifacts/facility-dimensions-2026-09-29 配下)。Windows 同一ビルド、防衛ロック固有の入口、ドラッグ中に状態が変わる競合、P24 の他要件は残る。 + + +## ドラッグ開始後の状態変化 + +入力ヘルパーにマウスリリース直前の試験フックを追加。Viewport が実際にドラッグ中であることを確認した後、イベントのロックまたは作業IDを注入する。ドロップが本物の set_staff_work_assignment コマンドを通り、最新状態による EVENT_LOCKED/STAFF_BUSY の拒否結果を返すこと、配属予定が変化せず適用ボタンが無効になること、具体的な理由が表示されて pending UI が残らないことを検証。各ケース後に元の snapshot を復元する。 + +headless87、macOS描画あり87項目 PASS(前回77に10項目追加)。本番実装への修正が必要な不具合は検出されなかった。注入は状態境界の再現であり、物語イベント/実作業の自然発生やWindows実機操作ではない。画像は今回新規取得せず、描画ありの入力・状態検証を実施した。証拠: staff-drag-races-validation.json、staff-drag-races.log、staff-drag-races-native.log。 + +防衛固有のロック、ドラッグ中のロード/施設撤去、Windows同一ビルド操作、P24全体の他要件は残る。 diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_DEMOLITION.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_DEMOLITION.md new file mode 100644 index 0000000..95b470b --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_DEMOLITION.md @@ -0,0 +1,13 @@ +# ドラッグ中の配属先撤去境界 + +2026-09-29。P24/P27。ゲーム本体変更なし、試験追加。 + +実ポインターで従業員をドラッグ開始した後、ドロップ前に通常のdemolish_facilityコマンドで無人の受付を撤去する。ドロップ後も施設が消えていること、従業員状態と勤務予定の不変、再操作の案内表示、保留UIが残らないことを検証した。撤去コマンドの実行は試験ドライバーからであり、同時の手動UI操作を検証したとは表記しない。 + +ソース側の従業員管理106項目PASS。最新Windows配布版と同じPCKを空の一時フォルダからmacOS描画で実行し、隔離確認1件を含む107項目PASS。PCKハッシュは配布BUILD_MANIFESTと一致、本番ソース変更なし。開業通し試験は本番未変更のため再実行していない。 + +証拠: artifacts/staff-drag-demolition-2026-09-29/ のheadless.log、packed-rendered.log、packed-staff.gd、verification.json。 + +Windows実機への操作経路も調査した。現在のCUA対象はmacOS、利用可能ツールにWindowsシェルなし、SSH設定にWindowsホストなし、list_projectsにWindows保存プロジェクトなし。この結果はWindows端末自体が存在しない・オフラインである証明ではない。現在このタスクから実機操作できる経路は未確認であり、Windowsネイティブ合格にはしない。 + +残件: 防衛参加中のドラッグ境界、Windows同一ビルドでの操作、その他P24の原本要件。ロード境界はSTAFF_DRAG_LOAD.mdで検証済み。 diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_LOAD.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_LOAD.md new file mode 100644 index 0000000..31b8b04 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_DRAG_LOAD.md @@ -0,0 +1,23 @@ +# 従業員ドラッグ中の読込をまたぐ操作の取消 + +2026-09-29。P24/P27、原本B-11.2のドラッグ配置と保存復元境界。 + +## 問題と修正 + +従業員を掴んだ後でセーブをロードし、施設へドロップすると、読込前の操作が復元後の世界へ適用された。同じ従業員ID・施設IDがあるため通常の存在確認では防げなかった。 + +ドラッグ開始時にコマンドキューのインスタンスIDを記録し、ドロップ時に現在のキューと比較する。成功したロードや世界の再初期化はキューを置換するため、古い操作を拒否し画面に再操作を案内する。値はUIの一時データのみで保存しない。読込後の新しいドラッグは受理し、無効なスナップショットの読込失敗は現在のドラッグを無効化しない。 + +既に受理済みのコマンドの保存・復元は維持する。ドラッグ終了前の操作と受理済みコマンドを区別した修正。 + +## 証拠 + +`artifacts/staff-drag-load-2026-09-29/`。 + +- before.log: 実ポインター入力中にファイルから読み込み、3件失敗を再現。従業員状態・コマンド履歴・案内表示の不一致。 +- after.log: 従業員画面99項目PASS。読込拒否時、読込後の再操作、既存の昼夜勤務/人数/役割/イベント/作業中/受理済みコマンド復元を含む。 +- rendered.log / rejected.png: macOS描画100項目PASS。取消案内を画面で確認。 +- 保存ライフサイクル96・アンリ加入22の計118項目PASS。 +- 開業→3決算→別プロセス再起動97項目PASS。書込63.81秒・読込47.93秒。実行中ソース変更なし、営業状態JSON一致。 + +Windows実機は未検証。これは全原本受入や全スイート一括PASSの証拠ではない。施設撤去や防衛中のドラッグなど残る境界は別途追跡する。 diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_INTERFACE_SKIN.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_INTERFACE_SKIN.md new file mode 100644 index 0000000..0503340 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_INTERFACE_SKIN.md @@ -0,0 +1,7 @@ +# Staff management layer skin + +Partial P23/P24/P25 UI integration. Staff roster summary, assignment details and form labels now register with the shared FRONT/BACK text palette. Result labels preserve success/error semantics with distinct readable colours on each background, including later refreshes and repeated layer changes. Buttons already use the main style callback. No staff/shift/save logic changed. + +Headless staff management67 checks PASS; native68 checks PASS including capture. Existing scenarios include real UI assignment, draft preservation, queued commands, rejected role, drag assignment, pending save/load and replay. Five new checks cover BACK refresh text, error/success colours, exact FRONT text/error restoration and unchanged authoritative assignment during skin changes. The captured success label was injected to test its visual state, not used as evidence that the selected worker had been assigned. staff-skin-back.png visually inspected: roster, dropdowns, action and result readable at1280x720. Shared layer/guide regression14 checks PASS. + +Evidence: staff-skin-validation.json. Specialist supply/business/ledger panels, popup menus, final art/audio and Windows acceptance remain outstanding; no full-original completion claim. diff --git a/docs/plans/original-completion-2026-09-29/execution/STAFF_TASK_CLOCK.md b/docs/plans/original-completion-2026-09-29/execution/STAFF_TASK_CLOCK.md new file mode 100644 index 0000000..1920022 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/STAFF_TASK_CLOCK.md @@ -0,0 +1,15 @@ +# 従業員作業の重複進行要求を除去 + +原本に明記されたアメリアの回復速度2倍を調査する過程で、RuntimeがStaffServiceの更新後に通常作業へもう一度progress_taskを呼んでいることを確認した。初期には二重進行を疑ったが、再現試験では既存の同一時刻ガードが拒否していた。従って「実際に2倍速になる不具合」ではない。 + +Runtime側の進行を清掃タスクだけに限定した。通常作業はStaffService、物流は外部ドライバー、清掃は出勤・依存条件を確認したRuntimeが担当する。通常作業で不要な失敗結果を毎分生成する経路を除去し、進行責務を明確にする。定量的なフレーム速度改善は未計測。 + +新規試験は8分の通常作業が到着時0、経過1〜7分で未完了、8分目で完了することと、開始時点から保存再開した結果の完全一致を確認。修正前も同じ13チェックPASSであり、挙動維持の証拠。最初の失敗ログは試験従業員の初期状態が勤務外だったためで、AVAILABLEの作業者へ修正して比較した。 + +最終関連試験6スイート: 通常作業13、救護46、物流121、清掃90、フィーネ95、StaffService209、合計574チェックPASS。証拠: artifacts/staff-task-clock-2026-09-29/results.json と各ログ。通し再起動は別途結果を追記する。 + +アメリア回復効果の適用対象は原本未指定。配置先医務室の治療を2倍速にする提案をユーザーへ確認中で、未回答のまま実装を確定しない。寄付効果・通常加入・回復効果の完了状況は分けて追跡する。全原本実装、性能予算、Windows実機の完了ではない。 + +## 再起動の通し検証 + +macOS実描画: 書込80・読込17の計97チェックPASS、双方終了コード0、書込62.00秒・読込47.88秒。試験中ソース変更なし、write/read-gameplay.json一致。医療倍率の実装済み証拠ではなく、通常開業・3回決算・別プロセスロード継続の回帰結果。 diff --git a/docs/plans/original-completion-2026-09-29/execution/UI_INTEGRATED_REGRESSION.md b/docs/plans/original-completion-2026-09-29/execution/UI_INTEGRATED_REGRESSION.md new file mode 100644 index 0000000..a71227f --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/UI_INTEGRATED_REGRESSION.md @@ -0,0 +1,15 @@ +# Integrated UI regression - completed command coverage + +The full make test-ui invocation stopped at the closed-facility upkeep flow. The test still searched for the former Load button; the current UI uses SaveLoadMenu. The test now invokes normal load menu item 0, preserving the original UI-handler testing scope. No upkeep calculation or production load behavior was changed. + +Repaired flow: independent write and read Godot processes both exited 0 and passed error-log inspection. Evidence: artifacts/facility-dimensions-2026-09-29/upkeep-regression/result.json and write.log/read.log. + +The first five UI commands passed before that failure. Remaining commands are executing separately in Makefile order, expanded through make -n test-ui. The runner rejects nonzero exit status, SCRIPT ERROR and ERROR output. Live session 17764; output ui-remaining-regression.log. Do not report whole-suite completion until this process finishes and its complete output is inspected. + +The initial failed invocation remains in ui-integrated-regression.log. Source hashes at start are in ui-regression-source-sha256.json; test_upkeep_closed_ui.gd subsequently changed as described here. This is partial P27 regression evidence, not all-original acceptance or Windows verification. + +## Final result + +All 17 direct Godot commands in expanded make test-ui passed, plus both upkeep subprocesses. The initial make invocation itself exited nonzero; this conclusion combines its first five passing commands, the corrected upkeep rerun, and the successful remaining-command runner (exit0). Expected commands were matched against logs; successful portions contain no SCRIPT ERROR or ERROR. Initial failure evidence is preserved. + +The final first-operating-day test passed48 checks, including first settlement, disk restore and deterministic second-morning replay. Production sources remained unchanged during the suite; only the upkeep UI test was repaired and rerun. Evidence: ui-integrated-validation.json. This completes current test-ui coverage, not full make test, Windows or all-original acceptance. diff --git a/docs/plans/original-completion-2026-09-29/execution/UNIQUE_EMPLOYMENT_PROTECTION.md b/docs/plans/original-completion-2026-09-29/execution/UNIQUE_EMPLOYMENT_PROTECTION.md new file mode 100644 index 0000000..dabf186 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/UNIQUE_EMPLOYMENT_PROTECTION.md @@ -0,0 +1,20 @@ +# ユニーク従業員の原本6.1保護 + +P17 部分実装。従来は EmploymentKind.UNIQUE でも remove_staff/retire_staff が成功し、daily_wage が正なら給与が発生していた。原本「維持費無料・解雇売却不可」に反する。 + +remove_staff と retire_staff の入口で UNIQUE を拒否し、作業の再開放・シフト削除・退職フラグ・未払い移管より前に停止する。理由のラベルを変えて削除を迂回できない。物語上の一時離脱は人物を恒久削除するこの API で代用しない。 + +StaffRuntime.get_effective_daily_wage() を追加し UNIQUE の新規給与を0とする。給与一覧、支払義務、日次精算、管理UI用 Runtime report に適用した。保存済み daily_wage や過去未払いは書き換えず、既存セーブの正確な往復と過去会計を保持する。過去の誤請求を自動返金・消去する移行ではない。 + +検証: StaffService209、従業員UI87、通常開業から翌日決算・保存再生59項目 PASS。追加13項目で削除理由3種、退職失敗時の全snapshot不変、旧非ゼロ給与の復元、既存未払い25のみの精算、二重精算防止、翌日の追加債務0を確認。旧テストが UNIQUE 日給75を期待していた箇所も原本の実効給与0へ修正した。一般従業員の退職・未払い保持の既存試験は継続してPASS。 + +証拠: artifacts/facility-dimensions-2026-09-29/unique-employment-validation.json と同接頭辞ログ。 + +残件: 通常プレイへのユニーク加入導線、雇用時の無料契約、原本全7名、配属と固有効果、成長、物語上の一時離脱、Windows受入。UI試験は既存一般従業員の回帰であり、未実装のユニーク加入を操作できたとは扱わない。 + + +## Runtime 接続の追加検証と登録迂回の防止 + +一般 register_staff は既存一般候補IDを拒否する一方、ユニークのカタログIDを任意の FREE_HIRE として登録できた。Runtime入口の予約IDチェックにユニーク一覧も加え、アンリ/ライの本人IDを正規加入経路以外で偽装登録できないようにした。今後の加入コマンドは検証済みの専用契約を使う。 + +Runtime content/progression116項目 PASS。追加分は予約ユニークIDの登録拒否と従業員/会計不変、汎用 UNIQUE fixture のコマンド登録、管理画面用の日給0表示、退職拒否時の全Runtime snapshot不変、保存復元後の保護を確認する。fixture は加入条件を仮定しないテストIDであり、アンリの通常加入UIが完成した証拠ではない。証拠は unique-runtime-protection-validation.json と .log。ユーザーが別途承認した通常加入/料金方式は ANRI_APPROVED_INTEGRATION.md に保存した。 diff --git a/docs/plans/original-completion-2026-09-29/execution/UPKEEP_IMPLEMENTATION.md b/docs/plans/original-completion-2026-09-29/execution/UPKEEP_IMPLEMENTATION.md new file mode 100644 index 0000000..affaec0 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/UPKEEP_IMPLEMENTATION.md @@ -0,0 +1,71 @@ +# 維持費の実装記録(2026-09-29) + +状態:P05は進行中。通常営業・旧06:00決算移行・インスペクターを接続。日額0.5%の初期バランスをユーザーが選択し、3営業日試験は49項目PASS。原本全実装の完了報告ではない。 + +## 確定仕様と原本 + +原本 `docs/traceability/original/BASELINE.txt` 70–72行(00:00決算と維持費)、188行(支出)。ユーザー回答「保有時間で按分・不足分繰越(推奨)」を `upkeep-policy.json` に記録した。 + +完成時点から撤去時点までの保有時間を対象にする。閉店・休止中も対象。接客施設だけでなく、完成済みの通路等の設備も対象。建設中は対象外。 + +日額 × 保有分数を整数で保持し、1440で割った整数Gを精算する。端数は次の精算に持ち越す。未払いでも費用を認識し、支払可能額との差を債務として保持する。撤去後も既発生の費用・債務・端数を消さない。 + +通常の00:00決算では既存の給与支払いを先に実行し、残金から施設ID順に維持費を支払う。旧版の移行中は維持費を00:00に支払い、元の給与精算は06:00に保持する。これは実装上の優先順であり、原本に明記された細則ではない。 + +## 実装経路 + +| 対象 | 内容 | +|---|---| +| FacilityUpkeepAccrual | 分単位の正確な計上、日跨ぎ、履歴、版付き保存 | +| FacilityUpkeepSettlement | 費用・未払・現金の複式記帳、再送の重複防止、端数・未払い繰越 | +| SimulationRuntime | コマンド・建設完了より前に前分の保有時間を計上。日次決算で精算し、施設別損益へ反映 | +| Runtime保存 v10 | v1–v9は読み込み時刻から計上開始。台帳・精算証憑・計上履歴の照合。不正保存は既存状態を変えず拒否 | +| 決算ロールバック | 精算後に費目判定等が失敗しても、帳簿・現金・給与・維持費の証憑をまとめて復元 | +| OperatingLedgerPanel | 維持費の現金支払と未払いを表示。営業損益の維持費と区別 | + +毎分の未払い照合で精算履歴全体を複製しない読み取りAPIを設けた。性能改善の測定結果としてはまだ扱わない。 + +## 原子受入項目 + +| ID | 条件 | 主な証拠 | +|---|---|---| +| UPK-01 | 建設中はゼロ、完成した瞬間までの時間を遡って請求しない | test_upkeep_construction_boundaries | +| UPK-02 | 完成済み施設・通路の保有分数を計上 | 同上、test_runtime_upkeep | +| UPK-03 | 閉店・休止でも計上し、撤去後は増えない | test_runtime_upkeep、計上サービス試験。休業UI・別プロセス復元は test_upkeep_closed_ui(操作処理を経由) | +| UPK-04 | 00:00に費用と支払を別の台帳行で記録 | test_runtime_upkeep | +| UPK-05 | 未払いでも費用を損益に反映し、未払い合計に算入 | test_runtime_upkeep | +| UPK-06 | 前日の未払いを翌日に支払い、返済を費用として二重計上しない | test_facility_upkeep_settlement、test_runtime_upkeep(翌日00:00返済、二重費用なし、保存復元) | +| UPK-07 | 撤去しても過去の費用と未払いを維持 | test_facility_upkeep_settlement、test_runtime_upkeep | +| UPK-08 | 端数を失わず、分刻みと一括計算が一致 | test_facility_upkeep_accrual | +| UPK-09 | 同一精算の再送で二重請求しない | test_facility_upkeep_settlement、保存再開試験 | +| UPK-10 | 23:59/00:00の保存復元が一致 | test_runtime_upkeep | +| UPK-11 | 旧保存へ過去の費用を遡及請求しない | test_runtime_upkeep、移行試験 | +| UPK-12 | 精算後の異常で関連状態を原子的に復元 | test_runtime_upkeep | +| UPK-13 | 帳簿表示が実際の支払・未払いに一致 | test_runtime_upkeep(Control生成) | +| UPK-14 | 維持費を含む3連続赤字の通常Tick終了 | test_upkeep_loss_streak:15項目PASS(通常建設、3日精算、終了直前/直後の保存) | +| UPK-15 | 旧06:00帳簿を保持して維持費を00:00精算 | test_upkeep_legacy_midnight:23項目PASS。巻き戻し・再試行・中間債務の投資制限も検証 | +| UPK-16 | 施設インスペクターの日額・精算前・未払い | test_upkeep_inspector:実描画7項目PASS、画像確認済み | + +## 検証の扱い + +ログは `artifacts/upkeep-2026-09-29/` に保存する。全体試験の初回失敗ログを残し、修正後は `recheck-*.log` に分ける。初回に旧版番号の期待値で失敗したことを隠さない。 + +単体:計上38、精算51項目。結合:通常Tick/保存/未払い/翌日返済/ロールバック40、通常建設・撤去17項目。既存:Runtime決算51、14日受入46、コンテンツ108、設備162項目のPASSを観測した。全体回帰の初回93本は実装中の変更をまたぐ探索実行であり、同一ソース凍結版の証拠とは扱わない。初回の旧版期待値3件は修正後にPASS、物流の600秒打切りは1800秒上限で再試験し796.36秒・49項目PASS。その後の関連10本も全PASS。承認済み0.5%の3営業日試験も790.60秒・49項目PASS。 + +## 残件・制限 + +1. 旧06:00決算の移行対応を実装済み。00:00の独立した原子的境界で維持費を精算し、06:00では確定済み支払を再実行しない。Coordinatorの確定決算債務とRuntimeの現在債務を区別し、新しい未払いがあれば直ちに投資を止める。関連10本と3連続赤字15項目の回帰はPASS。 +2. ユーザー回答「0.5%案を初期値として試験・調整する」を受け、通常登録19施設へ `ceil(建築費G × 0.005)`、最低1G/日を明示設定した。比較表と採用値は `upkeep-rate-proposals.json`。これは承認済みの初期バランスであり、原本記載値と混同しない。未登録の拡張定義は今回は変更していない。 +3. 建設・休業設定・決算・ディスク保存・別プロセス起動後の読込は、実描画付き29項目PASS。UIの操作処理を呼び出す試験であり、マウスによる通し操作や破壊・摘発での休止を同じシナリオでは検証していない。翌日返済は通常Tick・保存復元でPASS(入金は建設費返金の制御した帳簿フィクスチャであり、自然売上試験とは区別)。インスペクターの実描画と維持費による3連続赤字終了は検証済み。 +4. 同一ソースの全体回帰を frozen-regression/ に開始。実測の性能検証とWindowsビルド確認は別途継続する。 +5. P05の借金・税、他の作業パッケージはこの変更で完了した扱いにしない。 + +## 0.5%初期値の3営業日試験結果 + +通常の開始資金5,000Gから建設・雇用・仕入れ・自然来客・宿泊・物流・途中保存復元を行い、49項目PASS。維持費のない比較試験と食事売上3,000G・宿泊売上1,200G・配送数は一致した。 + +維持費合計50Gを支払い、未払い0G。最終資金は比較試験2,855Gから2,805Gへ50G減少し、支払額と一致。精算証憑・整数按分と端数繰越・費用台帳・3日分の損益を `compare_upkeep_trial.py` で照合し全項目PASS。詳細は `artifacts/upkeep-2026-09-29/rate-0-5-percent-reconciliation.json`。 + +この配置・シードの3営業日では資金不足にならないため、0.5%の初期値を維持する。長期バランス、別の施設構成、Windows実機の受入まで合格したとは扱わない。 + +営業設定の説明へ、休業中も施設維持費が発生することを追加。`tools/run_upkeep_ui_flow.py` で保存側と読込側を別のGodotプロセスとして起動でき、`make test-ui` に組み込んだ。証拠: `closed-ui-process-flow/result.json` と `closed-ui-read.png`。初回の試験失敗(配置ゾーンと保存操作履歴の比較条件)もログを保持している。 diff --git a/docs/plans/original-completion-2026-09-29/execution/UPKEEP_RATE_PROPOSALS.md b/docs/plans/original-completion-2026-09-29/execution/UPKEEP_RATE_PROPOSALS.md new file mode 100644 index 0000000..4623f4d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/UPKEEP_RATE_PROPOSALS.md @@ -0,0 +1,29 @@ +# 維持費日額の比較案(0.5%案を初期値として採用) + +原本には施設別の日額の数値がない。以下は通常登録19施設の既存建築費を基準にした比較用の仮説であり、原本値ではない。2026-09-29にユーザーが0.5%案での初期設定・営業試験・調整を指定したため、その列を通常登録19施設へ反映した。閉店・休止中の計上などの費用規約は確認済み。 + +| 施設 | 建築費G | 日額0.5% | 日額1% | 日額2% | +|---|---:|---:|---:|---:| +| 受付 | 500 | 3 | 5 | 10 | +| 一般客室 | 350 | 2 | 4 | 7 | +| 大衆食堂 | 800 | 4 | 8 | 16 | +| 厨房 | 700 | 4 | 7 | 14 | +| 大浴場 | 1200 | 6 | 12 | 24 | +| 倉庫 | 450 | 3 | 5 | 9 | +| 待機室 | 400 | 2 | 4 | 8 | +| 警備詰所 | 900 | 5 | 9 | 18 | +| 酒場 | 1000 | 5 | 10 | 20 | +| マッサージ室 | 800 | 4 | 8 | 16 | +| 客用昇降機 | 1500 | 8 | 15 | 30 | +| 業務用昇降機 | 2200 | 11 | 22 | 44 | +| 従業員ドア | 100 | 1 | 1 | 2 | +| 客用通路 | 20 | 1 | 1 | 1 | +| 裏通路 | 25 | 1 | 1 | 1 | +| 成人向け個室 | 1500 | 8 | 15 | 30 | +| 医務室 | 640 | 4 | 7 | 13 | +| 客用階段 | 40 | 1 | 1 | 1 | +| 業務用階段 | 50 | 1 | 1 | 1 | + +各案は整数Gへ切上げ、最低1G/日。資材費は換金率が未設定のため算入しない。原本の未登録施設は、施設別の建築費が確定するまでこの表に追加しない。 + +本番採用前に同じ入力・シードの営業を比較し、給与・仕入れ・税・借入が揃った段階で再評価する。現時点ではどの係数でも長期バランスが成立すると断定しない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WEATHER_INTEGRATION.md b/docs/plans/original-completion-2026-09-29/execution/WEATHER_INTEGRATION.md new file mode 100644 index 0000000..b6da327 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WEATHER_INTEGRATION.md @@ -0,0 +1,48 @@ +# P07 天候の接続調査 + +原本 `baseline-fetched-2026-09-29.txt` 74–75行: 雨で客足減/滞在延長、嵐で新規客0、マナの霧で魔法効率/興奮度UP、数日ごとの流行ニュース。214行は上部HUDに天候表示。計画上の要件はP07/P04/P15/P23/P24へ連携する。 + +現制作コピーのscriptsにweather/environmentサービスはなく、Runtime/GameState/mainにも天候状態・接続がない。したがって未実装を既存PASSと混同しない。 + +次の縦断変更は、天候種別・発効時刻・決定的な予約遷移を専用モデルに置き、保存/移行→時刻境界更新→入館判定→HUDへ接続する。嵐の新規入館0は原文明示なので係数の承認待ちは不要。既存客を消去しない。入館境界は `SimulationRuntime._operations_arrival_boundary` の捕捉上限と連携し、同じ時刻の天候反映を入館抽選より先に確定する。失敗時の再送で乱数や天候を二重更新しないことを検証する。 + +雨/霧の倍率・天候遷移確率/期間・流行対象と倍率は原本に数値なし。これらを無言で原本の既定値にしない。旧保存の天候は履歴を捏造せず、移行時の初期状態と発効時刻を記録する。マナ数値回答待ちであることは、嵐の入館抑止や保存契約の作業を止める理由にはしない。 + +現在は調査のみ。コード、天候の実際の出現、画面・受入は未実装。 + + +## 天候モデルの実装 + +`weather_timeline.gd` を追加。呼出側が初期天候と開始分を明示し、晴れ/雨/嵐/マナ霧の発効予定を登録する。未来の時刻に限定し、同一時刻の同じ予約は再送、異なる予約は拒否する。時刻を進めると該当する遷移を時刻順に一度だけ返す。嵐の期間だけ新規入館不可を返す。倍率・発生確率・期間の既定値は導入していない。 + +保存版1に現在時刻・天候・開始時刻・未発効予定を保持。未知版/未知天候/欠損/非整数/過去の予定/順序逆転を拒否し、不正読込で既存状態を変えない。外部に返す予定は複製する。部品試験30項目PASS。初回の試験はJSON数値が浮動小数になるため失敗し、実セーブが使うVariantバイナリ方式へ試験を修正。初回ログを残した。実ファイル保存とRuntime接続は別途必要。 + +証拠 `artifacts/weather-2026-09-29/timeline-validation.json`。まだ本番Runtime・HUDから呼ばれておらず、通常営業で嵐が出現するわけではない。次はRuntime保存への組込みと旧版移行、入館より前の発効、UI表示、実営業受入を接続する。P07全体は未完了。 + + +## Runtime・HUD接続(保存形式11) + +ローカル制作コピーに接続。天候をTIME_BOUNDARIESの`simulation_runtime.0_weather`で決算/入館より先に確定し、嵐の入館捕捉上限を0にする。天候自体は同分の再送で変化を重複発行しない。供給・入館の既存トランザクション失敗時の受入は別途追加する。 + +Runtime保存を11に更新しweatherを必須にした。維持費が導入された版10の判定は独立定数に固定し、版10保存の維持費履歴を旧形式扱いで失わない。版1〜10の天候は保存時刻から晴天で初期化し、過去の天候履歴を推測しない。版11で天候が欠ける/時計が一致しない保存は拒否。旧形式試験は新しい天候データを除去し、移行での天候開始時刻を期待値に明示した。 + +Runtime試験16項目PASS: 390分の嵐で同分の入館0、420分の晴天で枠回復、途中保存からの完全再現、v10移行と不正保存拒否。HUDに晴れ/雨/嵐/マナ霧を表示し、実描画ダッシュボード135項目PASS、storm-hud.pngを確認した。 + +初回パースで初期化ハンドラの挿入位置・インデントを修正。旧物流移行試験は晴天開始時刻を移行時刻へ直す期待値修正後PASS。いずれも初回ログを残している。保存形式に影響する既存10本の回帰を実行中。セッション8854、詳細`weather-validation-progress.json`。回帰完了までは全体PASSとしない。 + +天候を自然に生成する確率/期間はまだ導入していない。予約は試験から投入しており、通常プレイが自然に雨/嵐へ変わる完成状態ではない。雨/霧の効果、流行、環境効果も残る。 + + +## 切替時の障害復旧・実ファイル保存 + +`test_weather_recovery.gd` 14項目PASS。390分の嵐切替と入館処理失敗を重ね、天候は390分の嵐、営業は再試行待ちで保存。実SaveServiceパッケージへ書込/読込し、嵐と420分の晴天予定を保持。同分の再試行で入館0かつ天候スナップショット不変、完全Runtime復元も一致。予定された晴天へ一度だけ復帰し、隔離状態を残さない。試験用保存は専用パスで削除。初回の試験コードはdelete_saveのbool戻り値を辞書扱いしたためパース失敗し、修正後に実行PASS。証拠 `artifacts/weather-2026-09-29/recovery-validation.json`。 + +自然な天候生成について、6時間ごとに晴れ60%/雨25%/嵐5%/マナ霧10%で抽選する初期試験案をユーザーへ確認した。未回答であり本番に適用していない。雨・霧の効果倍率はこの案に含めない。 + + +関連回帰10本が終了し、9本は初回PASS。対象243ファイルのSHAは実行中不変。インフラ試験の旧定数10を11へ更新した再試験は162項目PASS。同じ定数を持つ営業受入試験も更新して実行中(セッション48857、その後make check)。初回失敗ログは保持し、再試験はrechecks.jsonへ別記録する。 + + +## 関連回帰の終了 + +営業受入の短縮時間試験が天候時計を進めずに保存していたため、初回は破産保存/14日再現など4項目が失敗。試験用境界処理へ本番と同順の天候更新を加え、再試験46項目PASS。保存検証の厳密さは緩めていない。関連11本は修正再試験を含めPASS、実描画135/天候障害復旧14もPASS、make check終了コード0。初回失敗・修正後を別ログに保持。証拠 `integration-regression/final-gate.json`。これは天候接続の限定ゲートであり、自然生成やP07全体の完成ではない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_ACTOR_SELECTION.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_ACTOR_SELECTION.md new file mode 100644 index 0000000..a066705 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_ACTOR_SELECTION.md @@ -0,0 +1,19 @@ +# 営業履歴スナップショット軽量化 Windows開発確認版 + +更新注記: GUIDE_ASSIGNMENT_SCAN.mdの受付担当検索の軽量化とSELECTION_RESTORE.mdの手動選択復元は本記録より後の変更。本記録の全回帰・配布・負荷結果にこの変更を含めない。 + +2026-09-29。検証済みの同一呼び出し内スナップショット再利用を含むWindows x86_64開発確認版。Godot 4.7.2、export-debug。Windows実機未確認。 + +配布: build/windows/TheTree-Windows-snapshot-reuse-2026-09-29.zip + +サイズ: 77,248,965 bytes + +SHA256: 03629ba3325aeecb135f199c192e0ffbcb4fae539f1f0dff1c3d76495ff0f842 + +ソース全回帰140スイート、macOS実描画の開業・3回決算・保存・再起動97項目PASS。今回の同梱PCKを空の作業ディレクトリから読み込む隔離検証で、営業/保存境界850、起動/保存復元28、防衛508、従業員操作132、防衛HUD9、計1,527項目PASS。同PCKをmacOSで描画した従業員操作132項目もPASS(同じシナリオの再実行)。 + +営業検証の初回は、配布対象外の旧セーブ試験データを参照できず検証側で中断。旧セーブを外部の検証用データとしてコピーし、ドライバーの参照先のみ変更して全項目を再実行。製品コードは同梱PCKから読み、ソース側のテストがres://で見えないことも検証した。初回失敗ログはoperations-fixture-missing.logに保全。 + +全検証プロセス終了コード0、ビルド前後のソース差分0、ZIP CRC・同梱ファイルSHA一致。LICENSESとBUILD_MANIFEST.jsonを同梱。証拠: artifacts/snapshot-reuse-package-2026-09-29/。前版記録: WINDOWS_PACKAGE_BEFORE_SNAPSHOT_REUSE.md。 + +防衛状態は試験コマンドで生成。通常営業の防衛発生、全原本機能、性能予算、長期プレイ、Windowsネイティブ操作の合格を示すものではない。今回の変更・測定値はSNAPSHOT_CALL_REUSE.md、ソース検証はCURRENT_FULL_REGRESSION.md。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE.md new file mode 100644 index 0000000..7b70b66 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE.md @@ -0,0 +1,25 @@ +# 来客観察・操作修正反映 Windows開発確認版 + +2026-09-29。ローカル制作コピーからWindows x86_64 / Godot4.7.2で生成。 +配布: build/windows/TheTree-Windows-current-2026-09-29.zip +サイズ: 80,892,347 bytes +SHA256: b523fe34b4fbbe88cba913fc68cacfd6a1580912eabd93160c0e570307744ba7 + +前回cleaning版から、来客判断履歴・候補除外理由・スクロールUI、ロードをまたぐ従業員ドラッグ取消、サービス開始slot修正、施設到達判定とインフラ整合性検査の軽量化を反映。 + +## 配布データの検証 + +空の一時ディレクトリを作業ディレクトリにしてPCKをロードし、ソースへのフォールバックを排除。外部ドライバーはゲーム本体ではなく試験だけを提供する。 + +- 開業・ディスク保存復元28項目PASS。 +- 来客判断履歴29、従業員操作100、サービス枠26項目PASS(各元試験に隔離確認を1件追加)。 +- 同一PCKをmacOSで描画し、来客履歴30項目PASS。境界fixtureの除外理由とスクロールUIを目視確認。 +- 初回起動ドライバーは旧画面構造を参照して停止。test_exported_opening.gdを子孫検索に修正し、欠損時もnull参照で停止せず不合格を記録するよう変更。修正後28項目PASS。初回ログは保全。 +- EXEのPE32+形式、ZIP CRC、同梱物SHA256、ビルド中本番ソース不変、前回から素材不変を検証。 +- README、Godot/Kenneyライセンスと出典、BUILD_MANIFESTを同梱。 + +証拠: artifacts/current-package-2026-09-29/。source-start/endの差分は上記の配布対象外テストドライバーのみ。製品PCKの再生成は不要。 + +## 未完了 + +Windowsネイティブ操作、全体性能目標、高負荷/長期プレイ、原本の残施設・人物・物語等は未達。ソースの139スイート一括PASSは直近修正前の証拠であり、この配布版の全140スイート合格とは表記しない。P29最終受入・原本全実装は完了していない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE_AUTONOMY.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE_AUTONOMY.md new file mode 100644 index 0000000..6f7f113 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE_AUTONOMY.md @@ -0,0 +1,21 @@ +# 従業員・来客選択復元 Windows開発確認版 + +2026-09-30。Windows x86_64、Godot 4.7.2、export-debug。施設・建設・掘削・既設階段と従業員・来客の選択復元、手動/自動保存・復旧候補読込を含む現行ソースから作成。Windows実機操作は未確認。 + +配布: build/windows/TheTree-Windows-actor-selection-2026-09-30.zip + +サイズ: 73,155,439 bytes + +SHA256: dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb + +ソース全回帰144スイートPASS。macOS実描画の開業・3回決算・保存・終了・別プロセス再起動・継続103項目PASS。従業員・来客の別プロセス保存復元も書込103+読込13=116項目PASS。各実行中のソース変更0、ゲーム状態比較一致。 + +同梱PCKを空の作業ディレクトリから実行し、12回・計2,094項目PASS(描画再実行を含む)。内訳: 営業850、起動/保存28、防衛508、従業員132+描画132、防衛HUD9、自動保存76+描画77、施設等の選択39+描画40、従業員/来客選択101+描画102。描画はmacOSで実施。復元後の3枚の画像で対象選択と内容を目視確認。 + +制作ソースは全回帰と同じSHA256で固定。検証済み実行用入力の隔離コピーでimport/exportし、UID・importを含む入力差分0、制作側へのimportなし。コピー側のみで開発用ディレクトリを除外し、過去ビルド資料がPCKへ入るのを解消。PE32+ x86_64とPCK署名、ZIP CRC、全同梱ファイルSHA256を検証。LICENSES、SOURCE_MANIFEST.json、BUILD_MANIFEST.jsonを同梱。 + +証拠: artifacts/windows-actor-release-prep-2026-09-30/。build-validation.json、export-validation.json、packed-results.json、source-start/end.json、各ログ・描画画像。以前の配布記録はWINDOWS_PACKAGE_BEFORE_ACTOR_SELECTION.mdへ保全。 + +これは開発確認版。防衛状態は試験コマンドで生成した。通常営業からの防衛発生・自動戦闘、原本全実装、性能予算、長期プレイ、同一ビルドWindows実機操作は未完了。 + +後続変更: 防衛操作画面とその保存復元は本ZIPより後の変更。最新ソースと同一とは扱わない。自律戦闘の接続・全回帰後に配布を更新する。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE_HUD.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE_HUD.md new file mode 100644 index 0000000..eede2a4 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_DEFENSE_HUD.md @@ -0,0 +1,25 @@ +# 防衛・従業員操作修正反映 Windows開発確認版 + +更新注記: 防衛HUD追加(DEFENSE_HUD.md)はこの記録・配布版の後の変更。追加後の関連試験はPASS。追加後の全回帰・通し・再配布は別途確認する。 + +2026-09-29。全140ソーススイート、実描画の開業・3決算・再起動97チェックを通過したローカル制作ソースからWindows x86_64 / Godot4.7.2で生成。 + +配布: build/windows/TheTree-Windows-defense-2026-09-29.zip +サイズ: 80,893,968 bytes +SHA256: 644c5928f81a9f63a0c60ea23092e49ea11a2aef63550810e402cada997bc7d8 + +前版に加え、従業員の配属阻害理由表示、防衛準備中の未登録目標を伴う取消修正を同梱。前版ZIPは保全。 + +## 検証 + +空の一時ディレクトリから同梱PCKを読み込む外部試験ドライバーを使用。制作ソースへフォールバックしないことを検査した。開業・実ファイル保存復元28、防衛495、従業員操作132の計655チェックPASS。防衛・従業員にはそれぞれ隔離確認1件を含む。同PCKのmacOS実描画でも従業員132チェックPASS(同一シナリオの再実行)。未登録目標の防衛準備・取消・従業員解放を検証しており、通常営業からの襲撃発生の合格ではない。 + +EXEのPE32+ x86_64形式、ZIP CRC、同梱ファイルSHA256一致を確認。全回帰時のソースと一致し、書き出し前後のソース変更0。ライセンスと出典、README、BUILD_MANIFESTを同梱。 + +前版の素材マニフェストは.import/.uidを含まなかったため、初回比較が未記録メタデータを変更として扱い停止した。共通ファイルのハッシュ比較と未記録メタデータ一覧に分離して再検証。素材本体の変更0、今回のビルド前後はメタデータも含め変更0。ゲーム実装の変更は不要だった。 + +証拠: artifacts/defense-package-2026-09-29/のexport.log、verify_pack.py、packed-results.json、各ログ、source-start/end.json、package.py、build-validation.json。前版記録はWINDOWS_PACKAGE_BEFORE_DEFENSE.md。 + +## 未完了 + +Windows上の実EXE操作は未検証。macOSでのPCK実行をWindows実機合格とはしない。通常防衛UI、全体性能予算、長期プレイ、原本の残施設・人物・物語・資産は未完了であり、P29最終受入・原本全実装は未達。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_EVENT_DEFENSE.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_EVENT_DEFENSE.md new file mode 100644 index 0000000..6b2bcb1 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_EVENT_DEFENSE.md @@ -0,0 +1,19 @@ +# 状態更新・カタログ再利用 Windows開発確認版 + +2026-09-30。Windows x86_64 PE32+、Godot4.7.2、export-debug。前の防衛自動進行/UI/保存を保持し、変更のないcatalog manifestの反復生成を削減した。保存schema12・原本のフィールド検証を維持。 + +配布: build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip + +サイズ: 73,192,182 bytes + +SHA256: b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37 + +制作ソース147/147スイートPASS、全件PASS後のmacOS実描画開業・3回決算・保存・終了・別プロセス再起動103項目PASS。関連描画5実行403項目PASS。戦闘途中の別プロセス保存復元とviewport操作38項目PASS。ゲーム状態JSONと型付きdigestが一致し、各実行中入力差分0。CURRENT_FULL_REGRESSION.mdを参照。 + +全回帰・描画・再開・入力・外部driver・配布scriptsをSHA封印。同じ実行用入力851ファイルを独立コピーし、制作側へのimportなし。コピーimport6.58秒、export3.96秒。UID/importを含む入力差分0。空cwdから同じPCKだけを使う外部driver17実行・2,639項目PASS(headless2,231、macOS描画408)。manifest cache本来79判定と隔離1の80項目を含む。 + +PCK SHA256: a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee + +PE/PCK、LICENSES5ファイル、SOURCE_MANIFEST/BUILD_MANIFEST、全runの同じPCK SHA、ZIP CRC・全同梱ファイルSHA・重複なし・制作側入力差分0を確認。PCKの読込後の人物・防衛画像を目視確認。証拠は artifacts/windows-manifest-release-prep-2026-09-30/ の build-validation.json/export-validation.json/packed-results.json/sealed_inputs と実ログ・PNG。前の版は WINDOWS_PACKAGE_BEFORE_MANIFEST_CACHE.mdへ保全。 + +これは開発確認版。PCK描画はmacOS Apple M4で実施。小さな営業シナリオで処理時間は改善したが、性能予算は未達。通常営業からの脅威生成、同一build Windows実機、高負荷/長期プレイ、原本全実装は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_MANIFEST_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_MANIFEST_CACHE.md new file mode 100644 index 0000000..751f78f --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_MANIFEST_CACHE.md @@ -0,0 +1,17 @@ +# 防衛画面・自動進行 Windows開発確認版 + +2026-09-30。Windows x86_64 PE32+、Godot 4.7.2、export-debug。通常防衛画面、参加/対象/方針/開始/次波/撤退、選択保存復元、保存済み間隔による自動攻防、再送と採番衝突の修正を含む。 + +配布: build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip + +サイズ: 73,190,478 bytes + +SHA256: 2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc + +制作ソース全回帰146/146スイートPASS。全件PASS後の開業・3回決算・保存・終了・別プロセス再起動のmacOS実描画通し103項目PASS。戦闘途中の別プロセス保存復元とviewport操作も19+19=38項目PASS。途中/最終のゲーム状態JSONと型付きdigestが一致し、各実行中入力差分0。CURRENT_FULL_REGRESSION.mdを参照。 + +全回帰/描画/再開/入力/実行ドライバーをSHAで封印し、同じ実行用入力を隔離コピーでimport/export。制作側へのimportなし、UID/importを含むコピー入力差分0。検証済みPCKを空の作業ディレクトリから外部driverで16回・2,559項目PASS(描画の再実行を含む)。headless 2,151、描画408。営業850、起動/保存28、防衛508、職員132+描画132、HUD9、自動保存76+描画77、施設選択39+描画40、人物選択101+描画102、Runtime防衛163、自動周期189、防衛画面56+描画57。描画はmacOS Apple M4で実施。読込後の防衛・人物・自動保存画像を目視確認。 + +PE/PCK、ライセンス5件、SOURCE_MANIFEST/BUILD_MANIFEST、同じPCKのSHA、ZIP CRC/全同梱ファイルSHA/重複なしを検証。証拠: artifacts/windows-defense-release-prep-2026-09-30/ (build-validation.json、export-validation.json、packed-results.json、sealed入力、各ログ/画像)。以前の配布結果はWINDOWS_PACKAGE_BEFORE_DEFENSE_AUTONOMY.mdへ保全。 + +これは開発確認版。通常営業からの脅威作成は未接続で、試験は開示fixtureを使った。同一ビルドWindows実機、性能予算、長期プレイ、原本全実装は未完了。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_SNAPSHOT_REUSE.md b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_SNAPSHOT_REUSE.md new file mode 100644 index 0000000..7319fec --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WINDOWS_PACKAGE_BEFORE_SNAPSHOT_REUSE.md @@ -0,0 +1,18 @@ +# 防衛状態表示・配置安定化 Windows開発確認版 + +更新注記: SNAPSHOT_CALL_REUSE.mdの営業履歴軽量化は、この全回帰・配布記録より後の変更。追加後の全回帰140スイートと通し検証97項目はPASS(CURRENT_FULL_REGRESSION.md参照)であり、本記録の合格に含めない。 + +2026-09-29。防衛の準備中・戦闘中・次波待ちをタワー画面へ表示し、防衛状態が変わっても従業員欄が上下に動かないよう修正した版。 + +配布: build/windows/TheTree-Windows-defense-hud-2026-09-29.zip +サイズ: 80,896,806 bytes +SHA256: 12bdb12991315e06a55cf070812076facea35a3ed563f9ee93d4f812bae68039 +Windows x86_64 PE32+ / Godot 4.7.2。開発確認用。前版ZIPを保全。 + +ソース全140スイートPASS。実描画の新規開業・3日決算・保存・終了・別プロセス再起動97項目PASS。対象ソースとビルド前後のマニフェスト一致、変更0。 + +PCKのみを読む隔離環境で起動・保存復元28、防衛508、従業員操作132、防衛HUD9=677項目PASS。同PCKのmacOS実描画で従業員操作132項目もPASS(同じシナリオの再検証)。HUD試験は表示・取消・保存復元を確認。防衛状態は試験コマンドで作成しており、通常営業での脅威発生や戦闘操作UIの完了証明ではない。 + +ZIPのCRC検査、同梱ファイルのSHA256照合、EXE形式確認を実施。README・LICENSES・BUILD_MANIFEST.jsonを同梱。証拠: artifacts/defense-hud-package-2026-09-29/ のexport.log、packed-results.json、各試験ログ、source-start/end.json、build-validation.json。 + +Windowsネイティブ実機は未検証。原本の全施設・人物・進行・資産、長期プレイと性能予算は未完了。ソースの全回帰はCURRENT_FULL_REGRESSION.mdを参照。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WORK_PACKAGE_ACTOR_PROGRESS_UPDATE.json b/docs/plans/original-completion-2026-09-29/execution/WORK_PACKAGE_ACTOR_PROGRESS_UPDATE.json new file mode 100644 index 0000000..4543b4c --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WORK_PACKAGE_ACTOR_PROGRESS_UPDATE.json @@ -0,0 +1,42 @@ +{ + "date": "2026-09-30", + "updated_packages": [ + "P24", + "P27", + "P29" + ], + "updated_progress_keys": [ + "actor_selection_restore", + "current_full_regression", + "current_windows_package" + ], + "other_rows_and_non_progress_fields_unchanged": true, + "existing_package_states_unchanged": { + "P24": "planned", + "P27": "in_progress", + "P29": "planned" + }, + "source_suites": 144, + "opening_restart_checks": 103, + "actor_restart_checks": 116, + "windows_package_completion": false, + "windows_native": false, + "all_original_acceptance": false, + "plan_before_sha256": "ce56cbc368bc191833116fc19671cc76dd74b4327f3ec0e96096c2f68bb46cd6", + "plan_after_sha256": "e63076b60948e1b377a8c6552367c8ecf90a3cd97f4f50cc1a24fa6db510aa5e", + "full_source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "latest_update": "Windows development package actual completion reflected after source/proof update", + "windows_development_package_complete": true, + "performance_budget_pass": false, + "zip": "build/windows/TheTree-Windows-actor-selection-2026-09-30.zip", + "zip_bytes": 73155439, + "zip_sha256": "dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb", + "packed_runs": 12, + "packed_total_checks_including_replays": 2094, + "packed_headless_checks": 1743, + "packed_rendered_checks": 351, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "package_update_plan_before_sha256": "4b394e43b602fb5bcef7b56e6315624bc9660c17798f48dd4db02b9c98fb56d9", + "production_source_verified_unchanged": true +} diff --git a/docs/plans/original-completion-2026-09-29/execution/WORLD_SNAPSHOT_CACHE.md b/docs/plans/original-completion-2026-09-29/execution/WORLD_SNAPSHOT_CACHE.md new file mode 100644 index 0000000..30d890f --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WORLD_SNAPSHOT_CACHE.md @@ -0,0 +1,13 @@ +# Worldスナップショットの地形再利用 + +P28の性能改善。更新されていないWorldの占有・通行・掘削・支持・境界の列挙とソートを省き、更新番号ごとの地形スナップショットを1件保持する。返却時に深いコピーを作り、dirty_cellsと各revisionは毎回実状態から組み立てる。reset/restore成功ではキャッシュを無効化し、同じrevisionへの別状態復元でも再利用しない。保存schemaやゲーム挙動は変更なし。キャッシュは1件分の追加メモリを使用する。 + +関連5スイートPASS: 新規キャッシュ境界9チェック、World、全Runtimeのsnapshot ownership、Runtime建設、インフラ統合。失敗復元・外部での返却値変更・dirty消費・掘削・同revision再読込を含む。 + +Apple M4 / macOS / Godot4.7.2 headless。2万マスで旧World実装と現行へ同一状態を入れ、順序を交互にして30回比較、全スナップショット一致。旧平均16861.43µs→新655.30µs(初回構築を含む)、P95は17311→105µs。これは地形が変化しない状態・dirty消費済みの部分測定で、2万人や全ゲーム高負荷・描画性能の測定ではない。 + +小規模本番シーンではWorld作成平均244.65→43.25µs。全更新x1平均11252.525→11051.0µs、x3 P95は36886→36989µsで明確な全体改善とは断定しない。両者とも4ms/12ms予算未達。Operations snapshotなど主要残負荷は継続課題。起動同条件の一対比較であり反復統計やWindows証拠ではない。 + +証拠: artifacts/runtime-performance-2026-09-29/ のbefore.json、after.json、world-comparison.json、related.json、source-hashes.json。実描画の3回決算・別プロセス再起動はopening-restartで実行中。 + +実描画の通し検証完了: 80+17=97チェックPASS、両プロセス終了コード0。3決算・ゲーム状態JSON一致、ソース変更なし。Windows実機・性能予算合格・全原本完了は未達のまま。 diff --git a/docs/plans/original-completion-2026-09-29/execution/WORLD_TREE_RANK_UI.md b/docs/plans/original-completion-2026-09-29/execution/WORLD_TREE_RANK_UI.md new file mode 100644 index 0000000..2a341c7 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/WORLD_TREE_RANK_UI.md @@ -0,0 +1,30 @@ +# 世界樹ランク表示・昇格操作(2026-09-29) + +原本B-11.1の上部ランク表示を追加。P20/P23/P24の途中成果であり、全ランク・物語進行の完了ではない。 + +上部「世界樹ランク」を押すと、現在ランク、次の条件、現在値/必要値、クエスト達成状況を表示する。現在の所持金・稼働施設数はRuntimeの現状から読み取り専用のコピーへ投影し、画面を開くだけでは進行状態を変更しない。既存ProgressionServiceの条件値を表示しており、新しいバランス値の確定や原本承認を意味しない。 + +昇格は既存set_world_tree_rankコマンドへ接続し、実行時にも条件を再検証する。営業復旧待ち・未払い・ゲーム終了では昇格を抑止する。実行待ちと昇格完了は表示を分け、予約中は重複申請を禁止する。保存された予約は読込後も認識し、次の時間更新で反映する。昇格後・保存復元後はHUDと建設範囲が更新される。 + +## 検証 + +- 最終ソースのmacOS実描画: 23検査、0失敗(rank-ui-current.log)。条件不足、営業隔離、確認後の資金減少による再拒否、予約保存復元、昇格、建設範囲、HUD保存復元を確認。 +- 初期条件画面rank-conditions.pngを目視確認。1280×720内に表示され、未達時の昇格ボタンは無効。 +- ダッシュボード136検査と進行Runtime108検査は、最終の予約復元・営業ゲート追加より前の関連回帰。最終ソース全体の再実行結果とは区別する。 +- 最終make checkは終了コード0。これはGodotエディター取込と短時間headless起動の検証であり、全テストスイートではない。 + +昇格条件はテスト内で制御した境界試験。新規から自然な営業だけで最終ランクへ到達した証拠ではない。UIボタン・シグナルによる検証で、OSマウス操作やWindows実機検証ではない。 + +初回試験で申請IDのドットが既存検証規約に違反したため、英小文字・数字・アンダースコアへ修正した。初回失敗ログrank-ui-native.logを保持し、最終23検査で再確認した。 + +## 残件 + +自然な営業による評判・信仰・章条件の到達、全ランクと施設/方針/スキル解放、本編分岐とエンド、Windows同一ビルド操作を継続する。旧Windows出力には今回の変更は含まれない。P23の全HUD・表裏スキン、P24の他インスペクター要件も未完了。 + +証拠とソース識別: artifacts/facility-dimensions-2026-09-29/rank-ui-validation.json。 + +## 入力経路による追加確認 + +ボタンのシグナルを直接発火せず、ViewportへのマウスイベントとWindow宛キー入力で通常操作を検証した。ランク表示クリック、無効な昇格ボタン、Escape、キャンセル、昇格確定、通常保存ボタン、読込メニューのキー操作、復元後の次ランク条件まで通過。macOS実描画12検査PASS、headless11検査PASS(差は画像保存)。rank-pointer.pngを目視し、復元後のランク2表示を確認した。条件値は境界用コマンドで準備しており、自然な営業到達とは区別する。 + +最初の試験ではイベントのglobal_position欠落と埋込Windowへの座標変換不足があり、テスト入力を修正した。ゲーム側変更なし。初回失敗ログも残した。これはOSマウス操作/Windows実機の証拠ではない。試験はMakefileのtest-uiへ登録済み。 diff --git a/docs/plans/original-completion-2026-09-29/execution/ZERO_CUSTOMER_PRODUCTION_BUG.md b/docs/plans/original-completion-2026-09-29/execution/ZERO_CUSTOMER_PRODUCTION_BUG.md new file mode 100644 index 0000000..c60b3f9 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/ZERO_CUSTOMER_PRODUCTION_BUG.md @@ -0,0 +1,10 @@ +# 顧客人数0と厨房生産の確認 — 不具合仮説を棄却 + +最初にFacilityInstance.can_start_service()だけを調べ、customer_capacity=0でfalseとなることを「生産開始不可」と解釈したが、この解釈は誤りだった。 + +FacilityServiceのServiceRequest/ServiceInstanceは顧客を伴うサービスであり、空のguest_idsを明示的に無効とする。厨房の通常生産は独立したKitchenProductionServiceが担い、この判定を呼ばない。 + +既存 `tests/s2/test_kitchen_production.gd` の全fixtureにcustomer_capacity=0を加えた外部試験を実行。通常の食材投入・生産開始/完了、在庫容量、停止/再開、出力失敗、保存復元・不正復元等141チェックがPASSした。 +このため本番の顧客人数判定を変更せず、仮説を棄却する。原本未指定の厨房人数値自体を0として正式決定したわけではない。 + +証拠は `artifacts/full-regression-after-guest-log-2026-09-29/` の kitchen_zero_customers.gd、kitchen-zero-customers.log。誤った初期解釈と顧客サービス側の再現ログも経緯として残すが、不具合確認の証拠とは扱わない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/README.md b/docs/plans/original-completion-2026-09-29/execution/assets/README.md new file mode 100644 index 0000000..75c4642 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/README.md @@ -0,0 +1,33 @@ +# P22 素材の実在・必要枠台帳 + +状態: 初回棚卸し。原本に対する完成認定ではない。原本は `docs/traceability/original/BASELINE.txt`。派生する旧アート仕様書や仮素材の存在を根拠に、原本要件を省略しない。 + +`python3 tools/build_original_asset_inventory.py` で再生成する。対象はローカル制作コピーのassetsと、scripts/scenes/autoload/data/assets内のリテラル参照。動的に組み立てる参照や実画面での到達性は別途確認が必要。 + +## ファイルと必要枠 + +- `media_inventory.json`: 実在290ファイルのSHA・容量・PNG寸法・ソース中の参照・Kenneyライセンスへの対応。mockupsは参考資料として区別。 +- `facility_usage_slots.json`: 原本30施設 × Lv1〜3 × 表裏 × 外観/内装の360利用枠。360枚の新規画像が必要という意味ではない。共有素材やコード描画で満たせる枠は、レベル・状態・レイヤーの画面証拠を確認してから対応付ける。原本の配置層と表示素材の層を混同しない。 +- `character_usage_slots.json`: 指定7名の立ち絵・表情・衣装・破損/汚れ、待機/歩行/作業/気絶/運搬/救護/休息の84枠群。複数差分の必要数は未確定。汎用人物の色違いだけでは固有人物の完成を認定しない。 +- `other_usage_slots.json`: 背景、UI、エフェクト、BGM/環境音/操作音/声、その他人物、今回制作対象外の内容を追跡する17枠群。未制作項目を除外して全素材完成とは扱わない。 +- `inventory_summary.json`: 件数、原本SHA、リテラル参照の欠損を記録。 + +今回の走査では音声ファイル0、画像等へのリテラル参照欠損0。これは音響要件が満たされるという意味ではない。原本は表裏のアレンジ違いBGMとクロスフェード、環境音、操作音、声を要求する。音響の接続コードも今回確認範囲では見つからず、P25で制作・接続が必要。 + +## 既存描画の扱い + +`pixel_tower_art.gd` と `pixel_people.gd` は独自のコード描画。前者の施設ID文字列は候補の抽出にだけ使用し、特定施設の内装・外観・Lv差分が存在する証拠にはしていない。後者の汎用ポーズも人物別完成品としていない。旧高解像度テーマへの参照があっても、現在のマップで使われているとは限らない。 + +Kenney Tiny Dungeon/Tiny Townは同梱License.txtにCC0の明記を確認し、ファイルハッシュと対応を保存。配布元は `assets/kenney/sources.json`。その他テーマ画像の制作元・制作担当・完成承認はこの台帳では未確認とする。権利問題があると断定するものではない。 + +## 差し替えと制作契約 + +1. ゲーム保存は施設ID・人物ID・状態を保持し、画像パスやAtlas座標を埋め込まない。既存VisualThemeProfileの意味IDによる差し替えを維持する。 +2. 既存マップはKenneyの16×16タイルと人物16×18のコード座標を使用。PNGは透明・Nearest・Mipmap無効。画像解像度から当たり判定を変更しない。 +3. 新しい利用枠は施設/人物ID・レベル・レイヤー・状態を識別し、ファイルの有無とは別に「未制作→仮素材→接続済み→画面受入済み」を記録する。 +4. 制作時のファイル命名案は `/-vNN.png`。既存ファイルを一括改名せず、意味IDとの対応を先に追加する。Atlas化は表示側の都合として扱う。 +5. PNG寸法・余白・原点・フレーム順/時間は制作開始前に対象枠ごとに記録。静止画だけでアニメーション合格にしない。実際の歩行/仕事/搬送/停止状態と照合する。 +6. 外部素材はURL・作者・ライセンス本文・取得日・原ファイルSHAを保持。Kenneyを優先する。既存ゲームの画像や音を配布物へ取り込まない。 +7. 各枠に制作担当・受入担当・完成資産・画面証拠を割り当てる。現在は未割当。P22の完了には差分数と担当・品質判断の確定が残る。 + +関連: `docs/ART_ASSET_PRODUCTION_SPECIFICATION.md`、`docs/PIXEL_TOWER_VISUALS.md`。これらは制作手順/現実装の記録であり、基本仕様原本を上書きしない。 diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/character_usage_slots.json b/docs/plans/original-completion-2026-09-29/execution/assets/character_usage_slots.json new file mode 100644 index 0000000..6546381 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/character_usage_slots.json @@ -0,0 +1,1262 @@ +[ + { + "slot_id": "heroine.anri.portrait_base", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.portrait_expression", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.portrait_costume", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.portrait_damage", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.portrait_dirt", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_idle", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_walk", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_work", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_collapsed", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_carry", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_rescue", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.anri.sd_rest", + "character": "アンリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.portrait_base", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.portrait_expression", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.portrait_costume", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.portrait_damage", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.portrait_dirt", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_idle", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_walk", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_work", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_collapsed", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_carry", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_rescue", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.fine.sd_rest", + "character": "フィーネ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.portrait_base", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.portrait_expression", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.portrait_costume", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.portrait_damage", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.portrait_dirt", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_idle", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_walk", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_work", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_collapsed", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_carry", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_rescue", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.tomari.sd_rest", + "character": "トマリ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.portrait_base", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.portrait_expression", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.portrait_costume", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.portrait_damage", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.portrait_dirt", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_idle", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_walk", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_work", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_collapsed", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_carry", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_rescue", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.amelia.sd_rest", + "character": "アメリア", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.portrait_base", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.portrait_expression", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.portrait_costume", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.portrait_damage", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.portrait_dirt", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_idle", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_walk", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_work", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_collapsed", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_carry", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_rescue", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.liz.sd_rest", + "character": "リズ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.portrait_base", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.portrait_expression", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.portrait_costume", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.portrait_damage", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.portrait_dirt", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_idle", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_walk", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_work", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_collapsed", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_carry", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_rescue", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.mimi.sd_rest", + "character": "ミミ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.portrait_base", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_base", + "required_variant_count": 1, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.portrait_expression", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_expression", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.portrait_costume", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_costume", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.portrait_damage", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_damage", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.portrait_dirt", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.1" + ], + "kind": "portrait_dirt", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": null, + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_idle", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_idle", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_walk", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_walk", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_work", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_work", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_collapsed", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_collapsed", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_carry", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_carry", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_rescue", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rescue", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + }, + { + "slot_id": "heroine.satsuki.sd_rest", + "character": "サツキ", + "requirement": [ + "B-6.2", + "B-13.2" + ], + "kind": "sd_rest", + "required_variant_count": null, + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "candidate": "scripts/ui/pixel_people.gd", + "note": "Generic procedural poses do not prove character-specific appearance or role-specific animation." + } +] diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/facility_usage_slots.json b/docs/plans/original-completion-2026-09-29/execution/assets/facility_usage_slots.json new file mode 100644 index 0000000..c8dc9f6 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/facility_usage_slots.json @@ -0,0 +1,6482 @@ +[ + { + "slot_id": "standard_room.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "standard_room.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "一般客室", + "definition_id": "standard_room", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "premium_room.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "上級客室", + "definition_id": "premium_room", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_03.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "VIPスイート", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT高層", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_04.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.1" + ], + "facility_name": "ドミトリー", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cafeteria.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "大衆食堂", + "definition_id": "cafeteria", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_06.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "レストラン", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "tavern.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "酒場", + "definition_id": "tavern", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_08.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.2" + ], + "facility_name": "屋台", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "public_bath.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "大浴場", + "definition_id": "public_bath", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_10.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "カジノ", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_11.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "闘技場", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "chapel.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.3" + ], + "facility_name": "礼拝堂", + "definition_id": "chapel", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "adult_private_room.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "風俗個室", + "definition_id": "adult_private_room", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_14.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "高級ソープ", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_15.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "SM拷問室", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "black_market.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.4" + ], + "facility_name": "闇市場", + "definition_id": "black_market", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "kitchen.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "厨房", + "definition_id": "kitchen", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "warehouse.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "倉庫", + "definition_id": "warehouse", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_waiting_room.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "待機室", + "definition_id": "staff_waiting_room", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "staff_dormitory.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "従業員寮", + "definition_id": "staff_dormitory", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "security_station.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "警備員詰所", + "definition_id": "security_station", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "cleaning_room.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "清掃員室", + "definition_id": "cleaning_room", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "infirmary.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.5" + ], + "facility_name": "医務室", + "definition_id": "infirmary", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_24.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "覗き部屋", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_25.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "地下牢", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_26.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.6" + ], + "facility_name": "マナ炉", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK地下", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "guest_elevator.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "蔓の昇降機", + "definition_id": "guest_elevator", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "service_elevator.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "業務用EV", + "definition_id": "service_elevator", + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "BACK", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": "scripts/ui/pixel_tower_art.gd", + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_29.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "ワープ", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "FRONT", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv1.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "exterior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv1.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 1, + "layer": "front", + "surface": "interior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv1.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "exterior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv1.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 1, + "layer": "back", + "surface": "interior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv2.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "exterior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv2.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 2, + "layer": "front", + "surface": "interior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv2.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "exterior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv2.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 2, + "layer": "back", + "surface": "interior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv3.front.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "exterior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv3.front.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 3, + "layer": "front", + "surface": "interior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv3.back.exterior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "exterior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + }, + { + "slot_id": "original_facility_30.lv3.back.interior", + "requirement": [ + "B-13.3", + "B-10.7" + ], + "facility_name": "魔法砲台", + "definition_id": null, + "level": 3, + "layer": "back", + "surface": "interior", + "original_placement": "外壁", + "creator": "unassigned", + "status": "unverified_required_slot", + "approved_asset": null, + "existing_candidate": null, + "note": "Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count." + } +] diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/inventory_summary.json b/docs/plans/original-completion-2026-09-29/execution/assets/inventory_summary.json new file mode 100644 index 0000000..72aad0e --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/inventory_summary.json @@ -0,0 +1,16 @@ +{ + "facility_mapping": "docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json", + "facility_mapping_sha256": "df0c933220449c8bb3e0fd942ad6f1dee49075abdd6587c4856022e12e3665cf", + "baseline_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "media_files": 290, + "literal_referenced_media_files": 8, + "audio_files": 0, + "facility_count": 30, + "facility_usage_slots": 360, + "named_heroines": 7, + "character_usage_slot_groups": 84, + "other_slot_groups": 17, + "approved_assets": 0, + "missing_literal_references": [], + "scope": "Inventory and candidate mappings only. No asset is promoted to complete by file existence, procedural fallback, or a passing gameplay test. Variant counts and production ownership remain unresolved." +} diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/inventory_validation.json b/docs/plans/original-completion-2026-09-29/execution/assets/inventory_validation.json new file mode 100644 index 0000000..668f987 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/inventory_validation.json @@ -0,0 +1,14 @@ +{ + "scope": "inventory consistency, not visual quality or production completeness", + "checks": { + "unique_media_paths": true, + "unique_facility_slots": true, + "original_facilities": true, + "unique_character_slots": true, + "media_hashes_match": true, + "kenney_cc0_present": true, + "candidates_exist": true, + "no_unreviewed_completion_claim": true + }, + "passed": true +} diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/media_inventory.json b/docs/plans/original-completion-2026-09-29/execution/assets/media_inventory.json new file mode 100644 index 0000000..b45c96d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/media_inventory.json @@ -0,0 +1,4294 @@ +[ + { + "path": "assets/icons/project_icon.svg", + "bytes": 1054, + "sha256": "25cc327f9d3bec159a48a11b85dddcbb614f1b673dfb975dc8ddc130f248a86b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + }, + { + "path": "assets/kenney/tiny_dungeon/Preview.png", + "bytes": 17553, + "sha256": "b6023d3a9504f84bbd128d21680d0899c7a150bce3443d0861590f3f3de1c24b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 918, + "height": 515, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Sample.png", + "bytes": 26519, + "sha256": "2b922fefcaa5728986e0b5c1fb901a51c3e571c39e6bed1a22fa1ffdf504370f", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 918, + "height": 515, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tilemap/tilemap.png", + "bytes": 5533, + "sha256": "5653222ac495d89e942f9b636300759b3f38e85b26e9b888676f2e9ab834095a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 203, + "height": 186, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tilemap/tilemap_packed.png", + "bytes": 5294, + "sha256": "d24e60a41e4ac7a745c0304dfde121143688557f40215f23221c29cfe683825f", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 192, + "height": 176, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0000.png", + "bytes": 99, + "sha256": "ebf91e6638d484dc6bdaec5f30e91589252125146b70c2911f11fac7ebe17090", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0001.png", + "bytes": 126, + "sha256": "cd86d8a06512f6e1bb97110fdefc9dbbd64ea4d50e0c04158860d8420145c346", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0002.png", + "bytes": 150, + "sha256": "1bee42a448291ff9259c6afee8151478238d1c2f49e74f3323577e0110b8fc67", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0003.png", + "bytes": 126, + "sha256": "2c0b45910d964d71112b81f7be17cc9a24169d4942c64ce8b59ea0f3f7490ca8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0004.png", + "bytes": 178, + "sha256": "f3ea334d2be859c68ad1d1ff9c3314905ac12030cd65ce093e4ee8ec3c6b202b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0005.png", + "bytes": 181, + "sha256": "7ed587c0ae06d8c8dfb683ff25b7119c6600f21364a253301a3faf230a1eb0e6", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0006.png", + "bytes": 164, + "sha256": "34fefe03ad9f435a777c81e56e288ead41dc3a0654088d85b51cb36e5579a0d3", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0007.png", + "bytes": 180, + "sha256": "883ba3a3fb022b373db047b9dd19ae67f2611e584190a82d45d2b8d3f21dfad2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0008.png", + "bytes": 209, + "sha256": "23721149763a8fd6071857c19e01d0967c769e80ae89bbe968bc88a4816fc4f5", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0009.png", + "bytes": 174, + "sha256": "c8b7b183b0750dca16581173d7de4862ee01e937b1c414de526da5b75574adff", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0010.png", + "bytes": 159, + "sha256": "ba622a41d8fac8885d65c9cac01f35f3c6a3e7782c7c5eb375fafa78bafa9868", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0011.png", + "bytes": 163, + "sha256": "22b9237af31b12ff6cfaa1ed3b520383e93380c4e1582b7feafd76a9e9344426", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0012.png", + "bytes": 160, + "sha256": "dddf38ef416861eb38c499eaf8fdeb62051b577d5e33699fd42b46a198bec5f9", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0013.png", + "bytes": 110, + "sha256": "4eb6fedc53053420bbe92517109c941bc53cd114094ba9f9b8bc20802548d254", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0014.png", + "bytes": 143, + "sha256": "0d4f0c82ffb4c4158342fc0d7fc95cef22ff509003d711e84ec3b5ba3cee21f6", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0015.png", + "bytes": 110, + "sha256": "fdee6e6ccd725ae07348d4da5740b5837bf516a4e519f55cc6b5c566b5f5aa4d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0016.png", + "bytes": 147, + "sha256": "62e5de639001c66c44f2aa84e3fd1eee56d99eb234c82f2d5b2f471663592bad", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0017.png", + "bytes": 152, + "sha256": "714b92d97d613bbcf4d15c553a0c85fa29a2807a2601ac1395b8f45da6575deb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0018.png", + "bytes": 176, + "sha256": "b331519c825010a8d061b740a1127e3d711c2b447d6db1fad27f371acd1a1870", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0019.png", + "bytes": 182, + "sha256": "77031b04b17129b8142c6ca80dc10d265fb94c39a443f17ecfb2e48d9a7c2a16", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0020.png", + "bytes": 211, + "sha256": "8ed3c08b77ef4e6ead26858f95efcaa302f5f014aaf3b26b49dd2bea991eecce", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0021.png", + "bytes": 182, + "sha256": "9def7eb7a3b00190148a55367c08ecfd4a03b66096e585b0c07b6f21277bb4fd", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0022.png", + "bytes": 173, + "sha256": "24298faf91a61a57d158435e9fa75ca30ab88bbfddae797e2a268b1064787f45", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0023.png", + "bytes": 172, + "sha256": "ff3ac631206dd4882134b8719368519644fd26703ea289a7237924e9d622f208", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0024.png", + "bytes": 175, + "sha256": "3f4d77da84e948836c16d2c81f2812b7805e76147921f0ea9f047705f709a62a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0025.png", + "bytes": 139, + "sha256": "5782701dc1d3d19c26f26cf1b147a044ecbc8535766ffa33ed93eee0e8bec2a1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0026.png", + "bytes": 185, + "sha256": "a18f13529118365c4054f2b6dc7379190c8d99fc70f2f1c374f6e95f532630ce", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0027.png", + "bytes": 139, + "sha256": "c9ddc3f83a9ecb1d340d2925df3f99f34542d8be1514c53e82c71500c5537ced", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0028.png", + "bytes": 149, + "sha256": "7a67b0ec169f8a967906b2272927f4d4dd34ee60e0e066ad1544deaa9cf86d08", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0029.png", + "bytes": 208, + "sha256": "9a448977535c4af375b3db4800112ecde93e0d794b393e0cacf1dd82b4d9f971", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0030.png", + "bytes": 138, + "sha256": "50ba69228b0185e84fe73b4beec8837e275c7be1b8bde1981484feeecdf02199", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0031.png", + "bytes": 171, + "sha256": "3cde5a77140e4d8ed160e37cd14789aa9901c4808e2a6b0c109fd1502ca8deb2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0032.png", + "bytes": 195, + "sha256": "82ee687a8c6b7929ad25f4f9497a2e96b54b00c68cf174c405b77b74b456df33", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0033.png", + "bytes": 202, + "sha256": "928dcac1d9d2df6fa2ac8916edc35f409605e70e387321a7cbd5ba26ffdc3b5e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0034.png", + "bytes": 196, + "sha256": "7e06aac6ac40317d4699b9bad6c5fdcaea4df1aa350878a6c825089db6133f82", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0035.png", + "bytes": 198, + "sha256": "9fa58f97f4b8cd794179d8d2110c6180b69aa2030f449d734d69466b58eed2fb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0036.png", + "bytes": 139, + "sha256": "d0be450733fad7bb93599113861381e3caedb8e0fbf30bd4e120024547689582", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0037.png", + "bytes": 130, + "sha256": "dff77f0cd9bf86fe3f6ee4ebcb4074c2a30ceba18e5966022b0fc74a339e036d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0038.png", + "bytes": 145, + "sha256": "0b8d843daedf04706f865ad877e71d2d9ad1ce1dee01073071d286fd5aafdbdb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0039.png", + "bytes": 140, + "sha256": "7dc16b81fa82f7fdd377d68bac398a8358438599d1036c509de913abcde64683", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0040.png", + "bytes": 128, + "sha256": "7541f8325e46dc4d5f0707819c0b6cbf48dab832727c2bca54a4380991b6cf10", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0041.png", + "bytes": 178, + "sha256": "0fbb63362251a29d741280e59f5d9cf29c80c0fcff0532cc9c8530936d873994", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0042.png", + "bytes": 142, + "sha256": "8dc63d1b4fc05e9ef0dc5ad22788e49e4b37477d4e85de2c2e420d22fe4cd958", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0043.png", + "bytes": 155, + "sha256": "77511b6490f69a9f4991ac807a7a53bb946077883d629ae21f935989709067ea", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0044.png", + "bytes": 170, + "sha256": "e4deaf3ebddc654a8e3d075e41cc78a29fd5e5635b3d6646e6b36da10d63ea20", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0045.png", + "bytes": 191, + "sha256": "4f5d0fd34c70872b66f2418b6cae9c9c9e3aca133bdec0b0dbf860ce82f9f225", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0046.png", + "bytes": 180, + "sha256": "acc19c7eee1b0c89dc9bacda260c7678490967389e41cdd8186c2fe82cee78c5", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0047.png", + "bytes": 178, + "sha256": "ec5c217c482cb5921717c5577049846283f689c993b65ca57a9dff5d245ff635", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0048.png", + "bytes": 99, + "sha256": "63519065228f5f8af6200d979fa54cd7f00650034ceea10a1609203a34b3ff9b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0049.png", + "bytes": 130, + "sha256": "c4257bfa85878e4d293237cd9a50e72b3e883f721e4fe1c48132514bb93668f2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0050.png", + "bytes": 109, + "sha256": "af45eaf5658a481cbf5645ed006ea59072e8bcdbe481b15a55204179d291f5c7", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0051.png", + "bytes": 133, + "sha256": "2c10663f36fbfef2638b018d8ed3c11f1e2e3c62fd63300b13e7aeb39273eb97", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0052.png", + "bytes": 122, + "sha256": "d380d7941b23178f8a90ff41f977bb459890f3a5f4b29f9c159859221d8f9704", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0053.png", + "bytes": 110, + "sha256": "e3d67fdc99ebea256141127ccfeb4103821db7dca11d099639de32e60b05b47e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0054.png", + "bytes": 184, + "sha256": "1a9ae3a209decf1f4b8df62d354edd7dfa1f7d4101ab14819f181a8e51b5ce20", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0055.png", + "bytes": 182, + "sha256": "5a17519655807550ae9a90ae9618772d6bb67e856b9920ec090ccddcc3057e2c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0056.png", + "bytes": 207, + "sha256": "3dce1fb1d0ab8ecc5b168449b9cac11709a221f53d7dc49d31c4c7c96bd5ea2f", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0057.png", + "bytes": 135, + "sha256": "2d553978f4663e6fde5d5eb8dce1db9fd9aff9823c04c4ac9b5434625910c6c2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0058.png", + "bytes": 140, + "sha256": "d010f83dc263ca1b3b4788ef380c7ef5695a21493c7db10e423c9891e2eac730", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0059.png", + "bytes": 134, + "sha256": "78d2be3cc11b4dfcd3bdc6f90a34ff564f8d8216aa3ad7abf11bdd349bba02ee", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0060.png", + "bytes": 140, + "sha256": "c0c9ba7c823d46b2baf1fd4134c36f8e966b840b5f79a95a7585a0b3252a45a8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0061.png", + "bytes": 149, + "sha256": "c090e00663c0b81ad5769020fc450f1f0d32fb9420072f41ffcc939cbc25acb8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0062.png", + "bytes": 152, + "sha256": "cf66bddd9f4b4ecea0ac26406e487cf2639d373a2d5f21f1efd3cc6992272399", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0063.png", + "bytes": 167, + "sha256": "2c7d1a1fdf12a6cd37cfb82f9c38e18cfc8fe28963d1c33ab97379671cdd1aa4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0064.png", + "bytes": 176, + "sha256": "b2ddf6178c613f3725e1e28830514d378649238e695b0d0b74ca5b471f880fa5", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0065.png", + "bytes": 177, + "sha256": "cb304f8bfba830e8213f357e0ad9b255e63aed2574cf5a2b3c351490d28dabf3", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0066.png", + "bytes": 192, + "sha256": "601f7619c0103424fb76a2fe0ef6f086fbc29a404f2bfba2dd16fa6dc141e9d9", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0067.png", + "bytes": 170, + "sha256": "9cbd5a8a7bf7f43eb6a7429698e99e829c57997c5022018a8b60ba9a70e51ccc", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0068.png", + "bytes": 181, + "sha256": "f90351394da9efc3b61293c20378f50d30b3dd531a9fd83b4367927e7bf9c144", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0069.png", + "bytes": 206, + "sha256": "5a09ecf571abb5bb4d2b727b054d7417a0bafc1ac82a4a0ffe7eccc2561ad556", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0070.png", + "bytes": 170, + "sha256": "56ae1a17ea808d02da78e58b5d6dc58a6ea62287b4addb81b5a6967dc2245a5a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0071.png", + "bytes": 201, + "sha256": "33589077ca1518bc453aa63bf2f1810d67422bea07ed5db8f486f8257478d154", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0072.png", + "bytes": 163, + "sha256": "a977172d74aef292a924c867d7d2744ac1c4720f9a9f7120c0dec4ca1b9e9e00", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0073.png", + "bytes": 161, + "sha256": "138b74609bc477ca5bacc3c8c2b6d80bfc0362c81f163e7b6dcb063526c97f89", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0074.png", + "bytes": 201, + "sha256": "cfb675c1dcc7530a41956004471c8ec27cd5ae19a03d8c424074b855395497e8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0075.png", + "bytes": 128, + "sha256": "5fd90e49d9be0c155f1e6a5cb08099fda486ea6fbb266be20b6b97614b96ddd1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0076.png", + "bytes": 165, + "sha256": "349c56fdf06e20fb6a1ee98e2dea1e70833f4f0cf3a96ace0660d32ee0496631", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0077.png", + "bytes": 165, + "sha256": "be810b6e4b8cd8dfa9dd06af7a5964648a6683c5a3e8da64bc3233b48a636be7", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0078.png", + "bytes": 165, + "sha256": "62d47ae44908d577c29ff002a2e73ae1bb07602ff4715a85c9743893ef72546d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0079.png", + "bytes": 174, + "sha256": "c8c819b6054e0c231fae5f9f85dc9a34ed828379f5fdb4456ecd360305ca44c6", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0080.png", + "bytes": 180, + "sha256": "0151695f2890361f16ae4ea585e8f5d80fe0b6c2e82593f5e2ad336fbb7360fe", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0081.png", + "bytes": 156, + "sha256": "92edb7e7a7b658e00d2c983d9526ffa5f868071b72f98a645f48fac2b33eb377", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0082.png", + "bytes": 207, + "sha256": "2efb31e30cd6f1527329fe5d7704e41c65a8376d498bf93372f46155600420c9", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0083.png", + "bytes": 156, + "sha256": "92edb7e7a7b658e00d2c983d9526ffa5f868071b72f98a645f48fac2b33eb377", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0084.png", + "bytes": 232, + "sha256": "b3909f80a29a0f18729f96a90ebe446ecaf26a195da633f2a6c3dd31f660821e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0085.png", + "bytes": 230, + "sha256": "377b35acbce5129e8167df1f5d1d0420805990962a37a30f5784b88b81a40ec2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0086.png", + "bytes": 224, + "sha256": "0606653ad07e177c06c9782c9fff4b1388c7b67865b6ab0e202f76e8e6f6bec8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0087.png", + "bytes": 239, + "sha256": "321ecd683c4e542e09d368709e75821b8b823b7b98b71bebb39d66cead951315", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0088.png", + "bytes": 222, + "sha256": "ad6715e9bcab60faed303260a21adfca2de09a7950b98384065bcc5df05634b2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0089.png", + "bytes": 196, + "sha256": "0b9fa753068061e816246437466d0c9d3100375ff12b77fe9459a801265c83c1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0090.png", + "bytes": 209, + "sha256": "fb4aa142cbbba7d9504ed3ff0d989069e8f1974feb76f25906efd9901d4f9ef4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0091.png", + "bytes": 207, + "sha256": "19a75fbb7df761b7373dac310d5e7dd2aa5a65529f81d4ffc80940c89d0538ff", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0092.png", + "bytes": 244, + "sha256": "357e08bc52b8dc318ce003681196dce6d2de41298b6253bbcc9bd10283785b37", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0093.png", + "bytes": 205, + "sha256": "0ce7aabd23d9784f67ad64d461b132f8bd59932d9cc7dddb98c66edf60a6671a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0094.png", + "bytes": 170, + "sha256": "56ae1a17ea808d02da78e58b5d6dc58a6ea62287b4addb81b5a6967dc2245a5a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0095.png", + "bytes": 203, + "sha256": "f5c2ce5b8e9e36580df27e8287472c662b360e97c4b8bef669ad70eceb9fc7b4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0096.png", + "bytes": 206, + "sha256": "b8a0a9376c1c3398a059a1e3226e8f8d6839c1d73ac53ea37425340d762055fd", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0097.png", + "bytes": 225, + "sha256": "20915fde756b990f5b0c86fda557a93a6c306373d7c4fc07a1991f4ace3271b4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0098.png", + "bytes": 238, + "sha256": "39b7c35f414e086b9c7e0cd2b34693ae370e5382a05b4dbb9964cef20f9769c8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0099.png", + "bytes": 238, + "sha256": "61af4b9f1f67aa761e89e516ac6d60371175875b78b073b7f6a8415517c66181", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0100.png", + "bytes": 240, + "sha256": "e139ab1d56c599af1cea5a955b705ead18a92d41e8e635b550729383cad9de3c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0101.png", + "bytes": 159, + "sha256": "03b7f71be1904c72c71f98d31c5f0f7fd8dceffcd40c19c4232746cbf509f5b1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0102.png", + "bytes": 154, + "sha256": "35af44a6fc76a09e7fe625c0becfeac809360774c6a845ec8024dd8db6f5fc3f", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0103.png", + "bytes": 164, + "sha256": "38a3ab676d73fe4219930bad3d0294e045269fd153587b0e21c60843fc13d281", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0104.png", + "bytes": 166, + "sha256": "3b38808f0c20a49a9923475022041c46e8a04b47d8621e5186fe153b4bbaed48", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0105.png", + "bytes": 181, + "sha256": "e6ce715420b033cc4b0eb6b26255996a919ef721c0081388d3fa6a964047630d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0106.png", + "bytes": 164, + "sha256": "4958c3b09a0d41b3f21741476274855ab2242b3240e5054657dbc0569c881215", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0107.png", + "bytes": 173, + "sha256": "66d47c25a72a5be74cb8a15c0e4930ee4c8859f5ba5d04837d953d45649d0873", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0108.png", + "bytes": 189, + "sha256": "f15c964db469039861f70bdc75cc5b02ba9351eaa5d7421a85d028131eb12f4d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0109.png", + "bytes": 219, + "sha256": "f344d22151345f81fe1b95fdb03cf007747042670bda1540192c14853f35b94a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0110.png", + "bytes": 217, + "sha256": "ca2d26579be3738be5abfb74cea388634e212fb991b84701deeda65da4780f1c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0111.png", + "bytes": 228, + "sha256": "8ecfc9b753e676a3cd1fcf2f8f98cc70c8e814f706f41a7e172508fe15156618", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0112.png", + "bytes": 237, + "sha256": "1e82481db9d95d0cec64ba0ea95b8fadf1e3675936e93731d0ff0e3bc72470b0", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0113.png", + "bytes": 176, + "sha256": "d8b00568696dfc79269a8f47ab8e19ecf6f080140176034cbf8124b4b143d49e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0114.png", + "bytes": 182, + "sha256": "486bea807469ea50e471bd9a8e5d60424386b81b0bbef0c84f3cda285d8cf088", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0115.png", + "bytes": 182, + "sha256": "ad27340925ae7e936788c22cbd0f7d1922ef5bdd21377f916f1be9af6be4312d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0116.png", + "bytes": 182, + "sha256": "610de03ee2d2a7d452159a885707217ccb17c6c18444418d7f21496d76a65f76", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0117.png", + "bytes": 191, + "sha256": "4f75a60e03d46523afba3c52f73c1fcbe06a7566a87eea9fb9b82320718182bf", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0118.png", + "bytes": 200, + "sha256": "43679336db4fca6d7c4b47941f707174cdc4476b75af32e0d9f0bd80677831bd", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0119.png", + "bytes": 184, + "sha256": "c1a5fcc94e0557e04615186966d5ded2a7b590985f03c4a564de6801aeea4acf", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0120.png", + "bytes": 195, + "sha256": "2ca96264cc5506bb52a5f7b5d278d0fa8e66e26c4b8242e40db6b46acb9df95e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0121.png", + "bytes": 189, + "sha256": "28466ec899a6e0720f40e4f622aee5925d2e5c50b2409856f36ef65c7eedc532", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0122.png", + "bytes": 232, + "sha256": "2cb76164d899e3a5eb42d7cd23ffe8defae130f6ce702a35926cbf61d2d1b569", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0123.png", + "bytes": 201, + "sha256": "16d8f777b329a6516f32d790b389fd5072622195152680c298d2caf8d723ef8b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0124.png", + "bytes": 203, + "sha256": "cda39a2ba32af6935e106b5c243ba5ca813a8b2566855b94524485463883196a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0125.png", + "bytes": 165, + "sha256": "d576af0d95a09721d174c3f57cb51b3c736e3627330a9bfee110e676d37c3cab", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0126.png", + "bytes": 171, + "sha256": "59dd6e934921a327935f8aa35a24cd949bc04541c0adaed2d0ac46b29fe409c2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0127.png", + "bytes": 171, + "sha256": "4ae08fffdd99c4e14a941e664de4cf9ecba0064cd94e5caae9d4a82a10fe120c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0128.png", + "bytes": 171, + "sha256": "f9d1c49865855dece38e496cdb68458040facf8256831751f4b0ab695a193be9", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0129.png", + "bytes": 173, + "sha256": "1b0a4efa97420e011f08151149c662dbc7c91be0e3c72caa3e8f5718789908fb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0130.png", + "bytes": 185, + "sha256": "a61e8a3fe7e1e6f192d6b6452852274a02768c20a45ef6afa48ec80908a39185", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_dungeon/Tiles/tile_0131.png", + "bytes": 181, + "sha256": "0fe7c5448902cef52dd8ed8530eda0a986ad0d2cc0c36c2ee1528fe0c6860262", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_dungeon/License.txt", + "license_present": true, + "license_sha256": "9f574a2f1f636a3db8a0665ba90212f34e2b5e61ecb533d77c05237766374111", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Preview.png", + "bytes": 17645, + "sha256": "9a6a773a4e39bc2082b85f0778d8a106f644233b2b3612c5c2a6fd6e4ce2106b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 918, + "height": 515, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Sample.png", + "bytes": 112122, + "sha256": "cb909e46bf51deb2906eeeb14b323aa42e861a738a734f449dc460ad682e4ccc", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 918, + "height": 515, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tilemap/tilemap.png", + "bytes": 5392, + "sha256": "b9e9fb80b95d0b90b52f12d914f000cf16f8b22c1faa815aa94500abfa57bc50", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 203, + "height": 186, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tilemap/tilemap_packed.png", + "bytes": 5042, + "sha256": "3a54d99ecde790d4fdea207a3644cf130fc56fa838f1beb1507c185a95b8e902", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 192, + "height": 176, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0000.png", + "bytes": 99, + "sha256": "d4c692ef80ee97b3395b885ae07020d42a106683ff3650df240f8e972bd5a041", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0001.png", + "bytes": 144, + "sha256": "cf3b3df8669e621af90944fb3155b15f13adf203dda464e6149d7318f3660713", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0002.png", + "bytes": 174, + "sha256": "ea524c6974ad6203f46a3e68a360883d0d53dbbdaa237f6cd81ac6fd5be6ea7c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0003.png", + "bytes": 176, + "sha256": "05ae1d16efd39ec9ec7315d8dba74f3aa85d4a75780f768cbca584dc96e1d2f2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0004.png", + "bytes": 175, + "sha256": "5de3dff614fb984dedcbfb7551d13a984f66d5a0a835df3048b51bc7cfddba57", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0005.png", + "bytes": 197, + "sha256": "5c09569d7e0351146e194d9beb7b573e0869978334cc9e093a57dea8705e81da", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0006.png", + "bytes": 180, + "sha256": "d8046421ac4db5a824d642d26e9fc6532f1b3aa52033f569f7e56e0885b2776d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0007.png", + "bytes": 157, + "sha256": "741e857eba60e676711e0193f3b252fc70be469ddc55c7d5fb74fa343d6aae6b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0008.png", + "bytes": 185, + "sha256": "d9c105e1f635e239dd0a87239c528c57406532ae4c4949ec9555d9fae6fc8bbb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0009.png", + "bytes": 180, + "sha256": "1120f5a9a24c9dc40d0857885b9008860d17d89764a0621f90bfdb352930a72a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0010.png", + "bytes": 157, + "sha256": "5f3aca3ab97afaa731c0a001725d31fd18874c37bb56d386b74b09430bc52d01", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0011.png", + "bytes": 185, + "sha256": "69c6e3e76d76356f16086e922d331cd3baf4b18aed2bf07d242b2c61d1c0719c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0012.png", + "bytes": 161, + "sha256": "e1b87cceac398679e30d7cd4feecf4f40b8104515c255ed2d8e4fd4f868bb35c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0013.png", + "bytes": 153, + "sha256": "dff7a37b7fa1be3648dcb1d01be3df26eed3ea5dfb8d8b7ffdcc2af06715c1ad", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0014.png", + "bytes": 161, + "sha256": "1209e77e92dc55b0c6aab326ccf714a6f48fdb8ca447c77aea779b1c95e4d41e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0015.png", + "bytes": 193, + "sha256": "e1d230db0d9b559d564a5062ea574e8233d5b0c4cd143377756a38cf11f7027c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0016.png", + "bytes": 192, + "sha256": "e491cc2d756b0fe14c19c1484fd79fe086621d2a21fd59f3e7a2d74eecaba561", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0017.png", + "bytes": 177, + "sha256": "6510f793c7dab1b8307dd23e795ce9c680c1ca0b220c9749c37d5705e2aae70e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0018.png", + "bytes": 163, + "sha256": "167c8eeccd110c1c41095a7178524c8c6fa127e47098fcdc16e875bc2c8a8d76", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0019.png", + "bytes": 200, + "sha256": "f7617ce7df2660a9d4aca0aaf317be98bc561306b0e3eef02f7e0c17a0072142", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0020.png", + "bytes": 166, + "sha256": "9bbb26692c6a0b7ba00ad27b1a380f3ecec4d680c597204604bd312a154f7c5d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0021.png", + "bytes": 163, + "sha256": "3e7b7419837373bf32542598d46d1328cce966e9dfed35fd852a37d227f5d6a4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0022.png", + "bytes": 200, + "sha256": "5a6c92e91e67d3b4a9751250f5ffa73308f2d47bab5642677ea6c8c0448b9df1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0023.png", + "bytes": 166, + "sha256": "e2231d052af8e58b280c881962407316a25df85d1baebc0a4c417833c9035d1e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0024.png", + "bytes": 135, + "sha256": "3ffd3954dfb28615d813744ec3b0638d46f0cd9376426329221b70128719282a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0025.png", + "bytes": 99, + "sha256": "63519065228f5f8af6200d979fa54cd7f00650034ceea10a1609203a34b3ff9b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0026.png", + "bytes": 140, + "sha256": "1cc4348d99b6ca90b26e9cda7823a68d549fae0a9859ce3f31bfedff5a405bce", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0027.png", + "bytes": 191, + "sha256": "57a64b241ad12af171fda9d081a6641c12c51d1a9648e8c5ad50f314e3d660e2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0028.png", + "bytes": 191, + "sha256": "a8b6bdb8954e01dbe6abebaec6d3eedf4fcfeb825b7d982ed488c57a573fdaac", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0029.png", + "bytes": 197, + "sha256": "0d55333ffe4c1d3be705978bdf1c176a871c25a6bde232dfba5c359b1a433294", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0030.png", + "bytes": 181, + "sha256": "e38e6cb14ff961f6e154483a62f963cdf9a310d91e6fd34c61e70653e0fe2713", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0031.png", + "bytes": 157, + "sha256": "b0330026a4ee1bb5d0895d2f1310d2caff75512522752ed0a30770783f97bb7a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0032.png", + "bytes": 181, + "sha256": "6f527b7f7415362b0c0fc98e5e0c5b370db119dd171ccdfd444e64e46ad99055", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0033.png", + "bytes": 181, + "sha256": "5d03ae0b19ab782816f2c9faf809c6c6fb263a236e3afafd757f1e2411710875", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0034.png", + "bytes": 157, + "sha256": "23d25bce2e95ac64eb36ca34f0b529f7b6be9d2ba04d2dae48dd33138eb67444", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0035.png", + "bytes": 181, + "sha256": "0c007c5d64b29dc22c3d02fc7ac772efbe3b22cd353abb1b3f34f2d308ec49cd", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0036.png", + "bytes": 154, + "sha256": "87f81fe427117ff6fcee2cf40c1b30688e7bcea78cc2933c7354c9d985ffffac", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0037.png", + "bytes": 134, + "sha256": "ad8ed926be6b53a9a240d18dc332f4140552258d2c625773acbbdfc9b09a825e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0038.png", + "bytes": 149, + "sha256": "6bd82dfc79a21d7eeeacaaa9e6094be415b7a5d2190183ae28bc6ad764623255", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0039.png", + "bytes": 129, + "sha256": "fd9127ce420b7e05ba2bb361a82c9301b95d2c55d3eab899198819e9ba78214a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0040.png", + "bytes": 139, + "sha256": "1ab17e4b24be7eaeb6d8830c0e763b58a66fe2b8f70c15242fbde5138007d334", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0041.png", + "bytes": 126, + "sha256": "3db01ff694f3d9f1e492d45913c3f85ff5f704ac262a1404d368c9b272a3f42d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0042.png", + "bytes": 137, + "sha256": "322a22e1567b667f5f5423767412014ac490968e0f6f6fd22f1721234784f9aa", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0043.png", + "bytes": 166, + "sha256": "1e4364629573e4cf27c3ce616e3003cf1a3fd5ba2928bc46b2aa47070b0a0d41", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0044.png", + "bytes": 164, + "sha256": "4653e4c38fe71ce96287eca370385c00c857b017eecb8458713f1e5f5a9ce262", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0045.png", + "bytes": 178, + "sha256": "4e64ae7ed028b443d8823ccbbb18b1b90b568b8ce34fb24b24bf07e8582853b5", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0046.png", + "bytes": 164, + "sha256": "4e2ce8474d9106596cd5c1108f157fb77fe98abbd5a0762a58bc9676f0e1d1a4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0047.png", + "bytes": 144, + "sha256": "0928b0048900bad8a7d66b55634b099ed810b1dd2fdca7f80739c0ab8374825b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0048.png", + "bytes": 166, + "sha256": "7362f4df34b78d83fa8960f193bc15a3efc392c9d5ae7b4e308647051353969a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0049.png", + "bytes": 161, + "sha256": "674c9bb782acc6d640332167bbcee82e0d0984f147a1a6c9afaac983a863491c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0050.png", + "bytes": 171, + "sha256": "c1060ed198ba2114f08972723e8af88b0ceae34300c69c6c81f2d7f17df1aba7", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0051.png", + "bytes": 167, + "sha256": "8b6383707b4648aef99273bab8be3623866cc2781f9793ca8335c61513381eeb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0052.png", + "bytes": 180, + "sha256": "1ef23eadba9701c44658f19569d6f27322d497ef5c03c4b5dd9dfe793091be53", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0053.png", + "bytes": 178, + "sha256": "3d2d467efe3e81b6a0cd75d7260efc675a1833ebf31dd404f1a16622c63c62de", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0054.png", + "bytes": 181, + "sha256": "0681770aa287aececae7838587b8305579c6fd655f4bc0951a2027a60ae09c8a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0055.png", + "bytes": 188, + "sha256": "4febdf70ccacf1d1c9235bcb1b90c2f6fc994b3024c14b51326c04951b3a40ec", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0056.png", + "bytes": 146, + "sha256": "932bc465b8e63e6aa052cdd3619facd172c757918bd6367791080044e4195987", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0057.png", + "bytes": 213, + "sha256": "b3fc5ffa2c39365a94f0496206a0248f196eacb95f379f25f3235aef6dfb97e4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0058.png", + "bytes": 147, + "sha256": "784067a35d87df94dd0b4f7064437082e3dec99889f7481419548950ee191987", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0059.png", + "bytes": 145, + "sha256": "90f0a1089064abeb9701110ba5c0770ff13b3b1fc90c459e30523ccf4711f507", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0060.png", + "bytes": 177, + "sha256": "bd3479d2718ef0a19a30760028ecb21903e3e2c885f00df22164ad23adb85a6c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0061.png", + "bytes": 166, + "sha256": "decb9f96369b248fb55d27282bcaf9d3b77f7dd214dad9a68f29509b09e1e754", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0062.png", + "bytes": 184, + "sha256": "0dd68a4e4894db1829dd68e66976238245dff035bc57feda7ae0be6aaaf53a43", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0063.png", + "bytes": 216, + "sha256": "516b1ad058739d1b8fc0872624e58c376f673a06be23ec6b9df4f101a6bced7b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0064.png", + "bytes": 177, + "sha256": "79c68a61e262de5f9895e8d82133b01d492acc2ce2037f5fb751a0c8cfbfffe9", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0065.png", + "bytes": 166, + "sha256": "3436ff701d93e7d8c2cdb28e169c8707f544a36c626dd73d8025396cf5988ef0", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0066.png", + "bytes": 184, + "sha256": "8c622e9280a72751ff2131cdbeb4913b83cc723b86580c05b1cb35d64ed7f624", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0067.png", + "bytes": 216, + "sha256": "48919b108e67e1601e12b737e026c1c73e6f95fa1f1231483efe23f31e125cec", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0068.png", + "bytes": 164, + "sha256": "5f8175226f03497c12f39cb66780f5cbe58bcecaa4e5a1298de7a547f0bfbc1e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0069.png", + "bytes": 171, + "sha256": "1a3a354c78a2e4a6461f5374b57f104c3270b50812d2565d488663b6f9c1f5a7", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0070.png", + "bytes": 165, + "sha256": "b207c0fff948c9c2125e6ec44328762004665275c9efad248694f9dc28bebbc8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0071.png", + "bytes": 139, + "sha256": "a5e4e4db70672d69648f7d3924918b8202a2df9e88b9fafbcbcb7f7def8e6700", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0072.png", + "bytes": 137, + "sha256": "ff633e7a44d17849da050def1d96ebd431aa05147ac96938c3c5e1286d66b646", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0073.png", + "bytes": 129, + "sha256": "b20e305ce6c9c9b6e2d0cfec786fa45814d0a635b477d2084e2df0d31273ae8a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0074.png", + "bytes": 155, + "sha256": "1cfedee7d389983b950901a197746a7d574b1fdee51840920dde20334eefff54", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0075.png", + "bytes": 143, + "sha256": "5c455ecf2c89e5bf6884cd02f63b3ecef79ea9669092ebfe3ac08933252ed0ff", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0076.png", + "bytes": 137, + "sha256": "8d36d3a43d2e9a7b75f4d05874b6c4019b0af6aff44fcab5325048c264c15742", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0077.png", + "bytes": 129, + "sha256": "41d237731631ec4179146a19605265169843d5ce923c5f89375041d09ca90a11", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0078.png", + "bytes": 155, + "sha256": "fe06ebf43b8efaec63c05bd9f690f2c36a33bd9eba327fc721d445e4f6745f2e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0079.png", + "bytes": 143, + "sha256": "ea29e62b839d2b6495062c6269f885752ad6932137a5636da599fd216efa9e1e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0080.png", + "bytes": 160, + "sha256": "c0270191499831acc29fb3f32eedc436acbc0c7e471c13ea0412ba76657090dd", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0081.png", + "bytes": 120, + "sha256": "ad179a7fc3d6438202c7f2762ceb13cb6decdd44fcc40e4465bdf763fc6915cc", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0082.png", + "bytes": 162, + "sha256": "f21b75210f83d5d937ab9fa37a24052222e96d9450b57baa9518d21e4431c8fb", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0083.png", + "bytes": 189, + "sha256": "07b9cf1d4c066f91979688db542bb7fbca663608c98e596a0882d9c3838324a8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0084.png", + "bytes": 170, + "sha256": "a652e2b1fbf1cff607f83db9c810a9fa65c794c55bebef5cb07658d960b6b219", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0085.png", + "bytes": 163, + "sha256": "7ca5d76ab45b5aa9be4f19c9953e3bee4b64822642f47a4c9b8bd3f94ab6f737", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0086.png", + "bytes": 161, + "sha256": "1ed488f6faf0d68facc719882d2a1fd287d1979b8d0a21c07371bfeeb3923566", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0087.png", + "bytes": 161, + "sha256": "547c739a99c8fca12ce585db1ee4ab7ac5a0d9eafdda747dad6536325f5ed0fa", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0088.png", + "bytes": 167, + "sha256": "0230599ad30c0611af993c6f7e026eb752e6033b51209af5c83b234725a8b913", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0089.png", + "bytes": 163, + "sha256": "245aefd71d4c720401c6c33181266cc9da920a802cc275ee2da0c39a240bbc2b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0090.png", + "bytes": 161, + "sha256": "cdb2b297444549e861cb091f0fbd2bc4d7c4b9203a1e0add4a6e7c4809d35cc4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0091.png", + "bytes": 162, + "sha256": "d74235f5b7d9d5ef85acdd9d15c0db3d7e0cd52e19029055f483522dc58fd1a9", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0092.png", + "bytes": 158, + "sha256": "9e8df390488fef72d302553abb57028841b030e4ff91681d8fe8b36ef28b8d81", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0093.png", + "bytes": 162, + "sha256": "d432578df64a8e4cc5cefe559129badefd30a9e9ad988eef6e1470bba7eb9fc1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0094.png", + "bytes": 185, + "sha256": "e513be719af08aa8e365bc6a731b950bb76feed4da04ef41836ffbaa4e387b15", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0095.png", + "bytes": 195, + "sha256": "4e67c3f041644af772b07705741d68ae4efee4b7ab642b64797b64c9ef1556e7", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0096.png", + "bytes": 189, + "sha256": "c62b7cd4a8397dc1d7be794246ee5a05aefef46ae320db1cf1aa9d2e752899ed", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0097.png", + "bytes": 139, + "sha256": "93ff83f1b5d2e4485215acefe592bfb02ef735a230ba067d9d4d81f9b6343902", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0098.png", + "bytes": 181, + "sha256": "61406b531c360228979cc37cb7f31af32aea8c099cc1c7a7e5a7df67a86bfe3f", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0099.png", + "bytes": 171, + "sha256": "703b8144c0a570d64d9a586d37ed41ff3b4f414aa9dfb042e1a1e54f546eee33", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0100.png", + "bytes": 132, + "sha256": "535aef9ec659d745835aa54be8ed322642f8397183a52af2e9fb902c21a2deed", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0101.png", + "bytes": 174, + "sha256": "b553572407b1587f0ef3b3de1dd0d600a02e6444a957c05d2402fcba2fb16bfa", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0102.png", + "bytes": 175, + "sha256": "13811299a06a324bbb873d4bbac79e5c74c5f19eaaa2176f509f7b9a206a6157", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0103.png", + "bytes": 196, + "sha256": "af835af3f3f517b324cd363460cef7eeff4f97d6db834627e6c945d2c92d87aa", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0104.png", + "bytes": 242, + "sha256": "3befce5d6373d4315f35eeed22752bb1cf7a2ab5d008ce26dd6b4aebbb278978", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0105.png", + "bytes": 198, + "sha256": "6a8405b5a036ca3ca902a500b74a4bfcf8a71eb5b343e45303a6949b0d67d36d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0106.png", + "bytes": 178, + "sha256": "f915896f745a28497de7c86602b64b0a3bcabb7821103965926ea768b5476b18", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0107.png", + "bytes": 202, + "sha256": "a6783dabf3053b2a00c58fa7a8d7ff27b0ffdd08dcbe695b1ec82d0c2d312154", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0108.png", + "bytes": 122, + "sha256": "462f00726b47e1363d746524d37d9ef53de426b25cb651057c6f19db97cc5acc", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0109.png", + "bytes": 99, + "sha256": "d1ff475a1ad0d4fd5d2c2a8b1fe3b96657d10f9c5b87d204138f1d7a58ac9d04", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0110.png", + "bytes": 121, + "sha256": "0de41607a1269aa7a890461c249528d85b9eeb10745b130e95c8395c8a45b9c1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0111.png", + "bytes": 203, + "sha256": "5af9d32868dfab9ff81d9f23db0d966c899c5942ebf167cb9d09eba052d1ca8c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0112.png", + "bytes": 203, + "sha256": "6b92320e5dadb0062ab8d1fb06dc088a50f5a45660b8feca67a26c9687fe6a0a", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0113.png", + "bytes": 200, + "sha256": "3e78c40c8587a279b3ddd896230d8ab39013f38f854e675db69447c2c1b5c002", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0114.png", + "bytes": 200, + "sha256": "3d89b0b1ebed238aecfa84a251d9eb1bab87a5ae34fac5e91d788176ed523f41", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0115.png", + "bytes": 186, + "sha256": "3a2c43fb289410ebbe9a4301a664fdb3694583fc338743e0f9ecdb523db9bad3", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0116.png", + "bytes": 184, + "sha256": "5b5b45a7f2f756fe0251f84281ae5733b4bcff2b7167d432414c638a722fcc36", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0117.png", + "bytes": 172, + "sha256": "d2be7fbb7b9c2a278dcbe2fc54af0e9f08e0f0c565c5f6f96cf44562deef0665", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0118.png", + "bytes": 183, + "sha256": "81c5aeea97f62be85676741339089472d44e42cb907f08adb63c47b8db739bc1", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0119.png", + "bytes": 183, + "sha256": "30091d93126c683011d0866c54c755d631af69b97c396c3a6b3e285a9da6a2f8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0120.png", + "bytes": 158, + "sha256": "42c3fe979bb36603b5349f87eb2beb31b29459ef72c5b34e421ea0ae67714fd2", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0121.png", + "bytes": 141, + "sha256": "c23db7cbd39871a8b57be75f3d4b528b8e787997f96fdf367b350f1241e43305", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0122.png", + "bytes": 163, + "sha256": "d8a941d06fd2373157dfcdcc3681ad2f930c044d64870ce5ef8500e9472d8c59", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0123.png", + "bytes": 145, + "sha256": "e3fd78cfaf7625dcd088a0d870568f1e186f67999ab4849eee4b988f6b796d1c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0124.png", + "bytes": 146, + "sha256": "35195776721b22c59b4e6ac2b36534d5da18bc686ab98049d6466bcfae2ee96e", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0125.png", + "bytes": 186, + "sha256": "fd107e639cb7bde68f84ebd244bc7171a652a9b5b902661fb38a8dd8e2473f69", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0126.png", + "bytes": 140, + "sha256": "ef91491aabacc8a11c417f5aed4e40817fc0dafd1e772772dca3ea74466ed6b8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0127.png", + "bytes": 184, + "sha256": "22dc3bd37cc7419f14b51f81e7e2b26794a4143b239c35d9d4288d69dc987432", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0128.png", + "bytes": 200, + "sha256": "28289aeb6de1a3a8fe051930365cbf9d0fbde24dbc875f96ee45dfcd2f225b28", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0129.png", + "bytes": 200, + "sha256": "0a273e03a72e29f6343647a511a98b14fa3b26e239f3df02a4e2e4da4b4470a4", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0130.png", + "bytes": 206, + "sha256": "1c6b4444519eddc55abe82de5855effc8e9754dc206186712272e8de8e139b26", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/kenney/tiny_town/Tiles/tile_0131.png", + "bytes": 220, + "sha256": "a9592d792e563721dc5c38d9d922aad3fdc6c603a7d70822ef4194991ef42452", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "Kenney", + "width": 16, + "height": 16, + "creator": "Kenney", + "license": "assets/kenney/tiny_town/License.txt", + "license_present": true, + "license_sha256": "c483c167cc3bdfe17c15f957ff847d409f64d81b410b28783636cf3b2b7814c5", + "license_declares_cc0": true + }, + { + "path": "assets/mockups/the-tree-main-gameplay-visual-mock-v1.png", + "bytes": 2722232, + "sha256": "58877bb1afbf0e72af6531cadab01eca82f9bd8cefb0cdb45f81d6d2dbb83717", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1672, + "height": 941, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-main-gameplay-visual-mock-v2-japanese-fantasy.png", + "bytes": 3049138, + "sha256": "1c079ebb8b2b9610df0e79a2750cc5a0db53f26d137b6df511b12cb6884532d0", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1672, + "height": 941, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png", + "bytes": 3076090, + "sha256": "f802d2b4f275f136fcacc963f7d06228f5d71dbc2399a19f7e04c6bc7531321b", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1672, + "height": 941, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v3-assets-front.png", + "bytes": 1461992, + "sha256": "e54ff252bae66010dc885846acdcdb0117b930e0e3cfc8f5459acc2841eabf0d", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1504, + "height": 1008, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v3-first-pass.png", + "bytes": 434318, + "sha256": "cc7f23ea258d49a8f2bd23735c63196b94ece380eaed934ee6e7f1b6d881cb44", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1504, + "height": 1008, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v4-icons.png", + "bytes": 1317405, + "sha256": "b0826ffce116d96dbe5b901174713f84b55a0e72931f29821e0947b844b9c1b8", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1440, + "height": 810, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v5-map-icons.png", + "bytes": 1321334, + "sha256": "aca56c954b209b0a03342c0fde576cef15bfaa2ce2a1f9feedd51744fcfdb42c", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1440, + "height": 810, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v6-actors-4k.png", + "bytes": 6569282, + "sha256": "9b8a1d9e2e4408b7ee99505d1978a44a489eb8b6945d669d1c52e7e220937e79", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 3840, + "height": 2160, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v6-actors.png", + "bytes": 1322150, + "sha256": "8f9ea1b8bbc779e3f401d4cfdc8dcb0d10b95b7f87f37d3737af9bc85aa9dddd", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1440, + "height": 810, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v7-skins-4k.png", + "bytes": 8849219, + "sha256": "a32d61772e06b944cf7aeb7a2dd0bd1ccfac7cbb3d8e293a6d7075f780f48904", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 3840, + "height": 2160, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/mockups/the-tree-runtime-ui-v7-skins.png", + "bytes": 1778605, + "sha256": "f0dec82c2da661df52124cd3c2f59c3ac94db9cb5d384846d42f1901ada370cf", + "literal_references": [], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1440, + "height": 810, + "purpose": "reference_mockup_not_shipping_acceptance" + }, + { + "path": "assets/themes/japanese_fantasy/actor-markers-v2.png", + "bytes": 2165570, + "sha256": "b742c207613d773e47f21c917975a2ba0eec8a2d9485ff655fef37e3ebf4bde4", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1254, + "height": 1254, + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + }, + { + "path": "assets/themes/japanese_fantasy/facility-icons-v2.png", + "bytes": 1848036, + "sha256": "6fb830fbe3961125fe0f3e640efe63ecba4de5ea6fb758eaf0da2738940fa1b6", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1254, + "height": 1254, + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + }, + { + "path": "assets/themes/japanese_fantasy/system-icons-v2.png", + "bytes": 1786659, + "sha256": "1f1d4f5e20ba4de61e3fb967352b68db435fa36ec5e4bacab2802f132e3b8d40", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1254, + "height": 1254, + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + }, + { + "path": "assets/themes/japanese_fantasy/ui-skin-v1.png", + "bytes": 2492477, + "sha256": "4b4906cc9c6ed3fdb5330bfc11177376e5fec10afa3f2dac9318dbaf71833ff2", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1254, + "height": 1254, + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + }, + { + "path": "assets/themes/japanese_fantasy/world-back-v1.png", + "bytes": 2616183, + "sha256": "2b0dbf07c17e677ed023c56b1e90f013c40362f46c427950033868b042508c7c", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1672, + "height": 941, + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + }, + { + "path": "assets/themes/japanese_fantasy/world-front-v1.png", + "bytes": 2774704, + "sha256": "02b52ea05bdb4bbe78b8c905374052505bb73411eb7f2b0578e9b1a82994cde5", + "literal_references": [ + "assets/themes/japanese_fantasy/visual-theme-v1.tres" + ], + "acceptance": "not_reviewed", + "provenance": "unverified", + "width": 1672, + "height": 941, + "purpose": "existing_theme_or_icon_requires_source_and_quality_review" + } +] diff --git a/docs/plans/original-completion-2026-09-29/execution/assets/other_usage_slots.json b/docs/plans/original-completion-2026-09-29/execution/assets/other_usage_slots.json new file mode 100644 index 0000000..623e033 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/assets/other_usage_slots.json @@ -0,0 +1,192 @@ +[ + { + "slot_id": "background.front", + "requirement": [ + "B-13.3" + ], + "candidate": "scenes/main/world_preview.gd", + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "background.back", + "requirement": [ + "B-13.3" + ], + "candidate": "scenes/main/world_preview.gd", + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "ui.front_skin", + "requirement": [ + "B-13.4" + ], + "candidate": "assets/themes/japanese_fantasy/visual-theme-v1.tres", + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "ui.back_skin", + "requirement": [ + "B-13.4" + ], + "candidate": "assets/themes/japanese_fantasy/visual-theme-v1.tres", + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "ui.emotion_graph", + "requirement": [ + "B-13.4" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "ui.camera_overlay", + "requirement": [ + "B-13.4" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "effect.shield", + "requirement": [ + "B-13.2" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "effect.smoke", + "requirement": [ + "B-13.2" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "effect.mana_light", + "requirement": [ + "B-13.2" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "audio.front_bgm", + "requirement": [ + "B-13.5" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "audio.back_arrangement", + "requirement": [ + "B-13.5" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "audio.front_ambience", + "requirement": [ + "B-13.5" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "audio.back_ambience", + "requirement": [ + "B-13.5" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "audio.ui", + "requirement": [ + "B-13.5" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "audio.character_voice", + "requirement": [ + "B-13.5" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "other_characters", + "requirement": [ + "B-13.1" + ], + "candidate": null, + "status": "unverified_required_slot", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + }, + { + "slot_id": "restricted_content_unproduced", + "requirement": [ + "B-12.3", + "B-13.1", + "B-13.2", + "B-13.5" + ], + "candidate": null, + "status": "not_produced_outside_current_content_scope", + "creator": "unassigned", + "required_variant_count": null, + "approved_asset": null + } +] diff --git a/docs/plans/original-completion-2026-09-29/execution/baseline-fetched-2026-09-29.txt b/docs/plans/original-completion-2026-09-29/execution/baseline-fetched-2026-09-29.txt new file mode 100644 index 0000000..71a896a --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/baseline-fetched-2026-09-29.txt @@ -0,0 +1,258 @@ +# 1. プロジェクト基本定義書 (Project Basic Definitions) +## 1.1 基本情報 (General Information) + * **プロジェクトコード:** Project Yggdrasil + * **タイトル(仮):** The Tree + * **ジャンル:** ファンタジー世界樹経営シミュレーション / タワービルディング / 成人向け (R18) + * **プラットフォーム:** PC (Windows) + * **想定プレイ時間:** + * メインストーリークリア: 20時間 ~ 30時間 + * 完全クリア(全エンド・全ヒロイン攻略・図鑑コンプ): 50時間以上 + * **視点:** 2Dサイドビュー(断面図)形式 +## 1.2 ゲームコンセプト (Game Concept) + * **「聖域」と「魔窟」の二面性経営:** プレイヤーは巨大な「世界樹」を舞台に、地上へ伸びる「表(聖域・秩序)」**と、地下へ潜る**「裏(魔窟・混沌)」という、相反する性質を持つ二つの領域を同時に管理・拡張する。 + * **覗き見る支配者:** プレイヤーは神の視点(管理者)として、客や従業員の自律的な行動を観察し、時にそのプライベートを覗き見(Peeping)し、干渉することで運命を操作する。 + * **欲望のタワービルディング:** フロアを積み重ねることで世界樹自体が成長する。高さを求めるほど「権威(表)」が増し、深さを求めるほど「富(裏)」が増す構造。 +## 1.3 コアゲームループ (Core Game Loop) + 1. **建設 (Build):** マップに施設(客室、食堂、風俗店など)と動線(通路、エレベーター)を配置。表層と裏層を切り替え効率的な構造を設計する。 + 2. **集客 (Attract):** 多様な客が来店。客のAIは「睡眠」「食欲」「性欲」「娯楽」の欲求に従い自動行動する。 + 3. **サービスと搾取 (Service & Exploit):** + * **表:** 健全なサービス(宿泊・食事・治療)で「信頼(信仰値)」と「クリーンな資金」を得る。 + * **裏:** アングラなサービス(賭博・性風俗・闇取引)で「依存度」と「莫大な裏金」を得る。 + 4. **管理と介入 (Manage & Intervene):** 従業員のシフト管理、バックヤード動線確保。VIP客の覗き見、脅迫、人身売買。 + 5. **拡張と防衛 (Expand & Defend):** 世界樹ランクを上げ、より高度な施設を解禁。外敵を防衛力で撃退する。 +## 1.4 コアメカニクス定義 (Core Mechanics Definition) + * **デュアルレイヤー・システム:** マップ情報を2層で管理しUIで瞬時に切り替える。「表層 (Front Layer)」は客が目にする演出された空間。「裏層 (Back Layer)」は従業員のみが知る機能的空間・覗き部屋。 + * **自律AIと動線パズル:** 全てのキャラクターは自律AI。エレベーター配置が不適切だと「渋滞」発生。施設ごとのシフト配置管理が必要。 + * **信仰と依存:** 「信仰 (Faith)」は表の経営で得られストーリー進行に関わる。「依存 (Addiction)」は裏で客に植え付け、全財産を吐き出させるステータス。 +## 1.5 世界観・ナラティブ定義 (World & Narrative Definition) + * **舞台:** 旧魔王城跡地に生えた「世界樹」。 + * **主人公:** かつて勇者として使い捨てられた男。宿屋の主として再起し真の王への即位を目指す。 + * **ヒロイン:** 「純愛」「NTR」「快楽堕ち」の3つの運命を持つ7名のメインキャラクター。 +## 1.6 成人向け要素の定義 (Adult Content Definition) + * **区分:** R18(成人向け) + * **表現の方向性:** 純愛(合意の上での性愛)、NTR(ライバルに奪われる背徳感)、堕落(快楽・金による尊厳破壊)。 + * **経営的搾取:** 覗き(マジックミラー越しの監視)、脅迫、人身売買。 +# 2. マップ構造とデュアルレイヤーシステム詳細仕様書 +## 2.1 マップ基本構造 (Map Basic Structure) + * **視点:** 2Dサイドビュー(断面図)。1ブロック単位のグリッドで管理。 + * **領域区分:** 地上エリア(幹・枝 / Y > 0)、地下エリア(根 / Y < 0)、地面(境界線 / Y = 0)。 + * **建築制限:** 「地面」または「他の施設・幹」に隣接必須。進行に伴い建築可能領域が拡張。 +## 2.2 デュアルレイヤーシステム (Dual Layer System) +マップ上の全てのグリッド座標に、Z軸方向の深さとして2つのスロットが存在する。 + 1. **表層 (Front Layer / Z-1):** 客ユニット、接客中の従業員。客からはこの層しか見えない。 + 2. **裏層 (Back Layer / Z-2):** 待機・移動中の従業員、警備員、運搬物資、覗き見中のプレイヤー。客からは認識できない。 + * **表示切替:** 表層モード時は裏層が半透明、裏層モード時は表層がワイヤーフレームになる。 +## 2.3 表層:客エリア詳細 (Front Layer Specifications) + * **施設:** 客室、商業、客用通路・エレベーター。 + * **環境ステータス:** 景観度 (Beauty)、清潔度 (Cleanliness)、騒音度 (Noise) が計算され客の満足度に影響する。 +## 2.4 裏層:バックヤード詳細 (Back Layer Specifications) + * **施設:** 待機室、従業員寮、倉庫、裏通路、業務用エレベーター、リネンシュート、**覗き部屋**、隠し扉。 + * **接続ルール:** 表層の施設奥に設置された「従業員用ドア」を通じて 表層 ⇔ 裏層 を移動する。客は通過不可。 +## 2.5 覗き・脅迫ギミック (Peeping Mechanics) + * **建設条件:** 表層のターゲット施設(VIPルーム、風俗個室、脱衣所)の裏層に建設。覗き部屋を建設したグリッドは裏通路として使えなくなるジレンマあり。 + * **機能:** マジックミラー視点での鑑賞(通常マップとは違うアングル)。記録係を配置し「脅迫ネタ」を収集。 +## 2.6 エリアゾーニング詳細 (Zoning) + * **地上(聖域):** 採光・マナ濃度ボーナス。一般市民・貴族・聖職者向け。 + * **地下(魔窟):** 遮音・隠蔽ボーナス。犯罪者・魔物・依存症患者向け。 +# 3. 時間進行とゲームサイクル詳細仕様書 +## 3.1 基本時間ロジック (Basic Time Logic) + * **スケール:** 現実時間 10秒 = ゲーム内 1時間。 + * **操作:** 再生速度変更(Pause/x1/x2/x3)。Pause中も建設・指示可能(アクティブポーズ)。 +## 3.2 24時間サイクルとフェーズ (24-Hour Cycle) + * **朝 (06:00-10:00):** 「下りラッシュ」。チェックアウト、朝食。昼番出勤・清掃時間。 + * **昼 (10:00-16:00):** 新規来店、施設回遊。経営が安定し建設に適する。 + * **夕 (16:00-20:00):** 「上りラッシュ」。帰還した冒険者が大浴場・食堂へ殺到。18:00に夜番へシフト交代(バックヤード大混雑)。 + * **夜 (20:00-06:00):** 健全客は睡眠、依存客は地下へ。性欲解消・NTRイベント発生のピーク。 +## 3.3 シフト制システム詳細 (Shift System) + * **勤務枠:** 各施設に 昼番 (06:00-18:00) と 夜番 (18:00-06:00) のスロットを設定。 + * **体力と気絶:** 勤務で体力減少、休憩で回復。0になると「気絶」し救護搬送が必要。客に見られると評判ダウン。 + * **通勤ラッシュ:** 交代時刻の裏層の動線設計が悪いと、従業員が渋滞し業務が滞る。 +## 3.4 日次決算 (Daily Settlement) + * **時刻:** 毎日 00:00 に決算レポート表示。 + * **処理:** 売上(表裏の収益)から経費(人件費・維持費・借金返済)を引く。赤字決算が3回続くとゲームオーバー。 +## 3.5 環境・気象変動 (Environment & Weather) + * **天候:** 雨(客足減・滞在延長)、嵐(新規客ゼロ)、マナの霧(魔法効率UP・興奮度UP)。 + * **流行:** 数日おきのニュースで特定の客層・施設の売上がブーストされる。 +# 4. 客(冒険者)ユニット行動AIと交通システム詳細仕様書 +## 4.1 ユニット内部パラメータ (Internal Parameters) + * **資源:** 所持金 (Budget)、戦利品 (Loot)。 + * **四大欲求 (0-100):** 睡眠欲 (客室で解消)、性欲 (地下で解消)、食欲 (食堂で解消)、娯楽欲 (カジノ等で解消)。 + * **状態:** 忍耐力 (渋滞等で減少し0で激怒退店)、清潔度 (汚れを歩くと減少・伝播)、依存度 (地下利用で上昇)。 +## 4.2 AI行動決定ロジック (AI Decision Logic) + * 現在の「最も高い欲求」を基に目的地を決定。 + * **施設選択の重み付け:** 「距離」「行列の短さ」「価格・クラス(所持金に見合うか)」で算出。 +## 4.3 交通・移動システム (Traffic & Movement) + * **経路探索:** A*アルゴリズム。エレベーター待ち時間もコストに含む。 + * **垂直移動:** 蔓の昇降機(客用・定員あり)、業務用エレベーター(裏層・大容量)、階段(速度遅い)、マナワープ(瞬間移動・コスト大)。 + * **渋滞デバフ:** 通路にユニットが重なると移動速度50%低下。 +## 4.4 状態変化・特殊ステータス + * **依存症:** Lv3(廃人)になると退店を拒否し装備品を売却して居座る。 + * **破産処理:** 所持金0で依存度が高い場合、男性は「暴動」、女性は借金契約を結ばされ「奴隷ユニット」として回収される。 +## 4.5 クラス別AI特性 + * **貴族:** 金払いは良いが忍耐力が極端に低い。 + * **聖職者:** アングラ要素を目撃すると即帰宅&風紀ダウン。 + * **エルフ:** 自然物を好む。 + * **魔族/亜人:** 地下耐性が高く満足度が下がらない。 +# 5. 従業員ユニット・モブキャラクター詳細仕様書 +## 5.1 ユニット分類と基本定義 + * **一般従業員:** ギルド雇用。日給発生。ストレスが溜まりやすく解雇自由。 + * **奴隷:** 市場購入や借金客拘束で獲得。食費のみ。ストレス耐性が高く「人身売買」で売却可能。 +## 5.2 パラメータ詳細 + * **基礎:** 体力 (Stamina / 0で気絶)、ストレス (Stress)、忠誠度 (Loyalty)。 + * **業務適正:** 運営技術 (料理・清掃)、奉仕技術 (接客・性処理)、戦闘力 (警備)。 + * **特性 (Traits):** 生成時にランダム付与 (例: 力持ち、名器、サボり魔、潔癖症)。 +## 5.3 職種と役割 (Jobs & Roles) + * **サービス業(表層):** ウェイター、受付係、バニー。 + * **バックヤード業(裏層):** シェフ、清掃員、運搬員、救護班。 + * **アングラ・防衛業:** 娼婦、警備員、調教係。 +## 5.4 労務管理システム (Labor Management) + * **シフト制:** 昼番・夜番配置。非番時は待機室等で体力回復。 + * **気絶回収:** 体力0で倒れた者は、救護班が裏ルートを通って回収しなければならない。 +## 5.5 獲得と処分 + * **獲得:** 求人募集、奴隷市場、破産客の捕獲。 + * **処分:** 解雇(退職金)、人身売買(ステータスにより価格変動。他の従業員の忠誠度低下リスクあり)。 +# 6. 従業員ユニット・ユニークキャラクター詳細仕様書 +## 6.1 基本定義 (Basic Definition) + * **特性:** 代替不可能、雇用コスト・維持費無料、成長上限が高い、解雇・売却不可。固有スキルを所持。 +## 6.2 キャラクターリストと役割 + * **アンリ (女将):** [看板娘の微笑] 配置フロアの全施設売上+20%。 + * **フィーネ (メイド):** [神速の奉仕] 清掃速度3倍。暗殺確率UP。 + * **トマリ (騎士):** [鉄壁の守護] フロアトラブル0%。防衛スコア加算。 + * **アメリア (シスター):** [聖なる寄付] 寄付金3倍。回復速度2倍。 + * **リズ (狩人):** [エルフの眼] レア素材発見率UP。高級食材消費減。 + * **ミミ (商人):** [商魂たくましい] 仕入れ-20%、売却+20%。 + * **サツキ (雑用):** [高貴なオーラ] VIP客満足度上昇1.5倍。チップ増。 + * **ライバルキャラクター:** ライ、リーゼンバルト、ソウ等(雇用可能だがNTRリスク発生)。 +## 6.3 ステータスとパラメータ + * **基礎/業務能力:** 体力、ストレス、運営、奉仕、戦闘、アングラ。 + * **レベルと成長:** 業務でEXP蓄積、Lv99まで成長可能。 +## 6.4 スキルシステム + * **パッシブスキル:** 配置するだけで自動発動。 + * **アクティブスキル:** 防衛戦等で任意発動。 + * **ルート変化:** 純愛/堕落などの進行度で習得するスキルが変化する。 +## 6.5 運用とライフサイクル + * **シフト:** 警告を無視すれば24時間勤務も可能(ストレス激増)。衣装変更で適正補正変化。 + * **離脱:** 原則ロストしないが、防衛戦敗北での一時拉致や、特定バッドエンド確定時にのみ敵や背景オブジェクト化する。 +# 7. 恋愛・侵食システム詳細仕様書 【完全版】 +## 7.1 概要とコアコンセプト +3つの感情パラメータと防御値「愛の盾」、直接対決「決闘」によってヒロインの運命(純愛・NTR・堕落)をシミュレートする。 +## 7.2 感情パラメータと「愛の盾」 + * **好感度 (Affection):** 主人公への愛。「愛の盾」の最大耐久値。 + * **淫乱度 (Lust):** 快楽への依存。盾の防御力(ダメージ軽減率)を下げる。 + * **NTR度 (Netorare):** ライバル依存。盾破壊後に上昇する。 + * **『愛の盾』:** NTR攻撃を無効化するバリア。耐久値0でブレイクし、NTR侵食が始まる。 +## 7.3 NTR侵食プロセス + 1. **盾への攻撃:** ライバルと同配置時、盾耐久値が減少。 + 2. **ブレイク:** 耐久値0で「心の隙間」発生。 + 3. **NTR進行:** ライバル接触で直接NTR度が上昇。 + 4. **陥落確定:** NTR度が閾値越えで盾が消滅。純愛ルート完全消失。 +## 7.4 決闘システム (Duel System) +NTR阻止のための直接的解決手段。 + * **種類:** 武力決闘、商談決闘、夜の決闘(性技対決)。 + * **リターン:** ライバル追放・隷属化、「愛の盾」全回復。 + * **リスク:** 敗北時、ヒロインを即座に強奪されNTRルート確定。 +## 7.5 ルート攻略とプレイヤーアクション + * **純愛:** デート・会話で盾を回復。ライバルを物理的に隔離、または決闘で排除。 + * **NTR:** 意図的に放置・アングラ労働で盾を弱体化させ、覗き部屋から寝取られる過程を鑑賞する。 + * **堕落:** 媚薬や地下業務で淫乱度を上げ、盾の耐久値上限を溶かして家畜化させる。 +## 7.6 Hステータスと履歴 + * **履歴:** 処女喪失相手、経験数、現在のパートナーを記録。 + * **妊娠:** 排卵日あり。父親判定、妊婦期の外見・ステータス変化。 + * **刻印:** 指輪(純愛)、キスマーク(NTR)、淫紋・首輪・焼印(堕落・家畜)が立ち絵に反映される。 +# 8. 施設システム詳細仕様書 +## 8.1 施設の基本定義 + * **建設ルール:** 地面・完成済み施設に隣接。表層は客用通路、裏層は裏通路にアクセス必須。 + * **レベル:** LvMAXまで拡張可能。詳細設定(マイクロマネジメント)が解禁される。 +## 8.2 表層施設:客用エリア + * **宿泊:** 一般客室、上級客室、VIPスイート(覗き対応・要高景観)。 + * **飲食:** 大衆食堂、レストラン、酒場。裏層の「厨房」とセット運用必須。 + * **衛生・娯楽:** 大浴場(覗き対応)、カジノ、闘技場、礼拝堂。 + * **アングラ:** 風俗個室(覗き対応)、高級ソープ、SM拷問室、闇市場。 +## 8.3 裏層施設:バックヤード + * **従業員管理:** 待機室、従業員寮(夜這い可)、警備員詰所。 + * **物流:** 厨房(表層飲食店の裏)、倉庫、清掃員室、医務室。 + * **特殊:** 覗き部屋(VIP/浴場/風俗の裏限定・裏通路を寸断)、地下牢、マナ炉。 +## 8.4 動線・インフラ設備 + * **垂直移動:** 蔓の昇降機(表・客用)、業務用EV(裏・高速)、階段、マナワープ。 + * **通路:** 客用通路、裏通路、従業員ドア(表と裏を接続)。 +## 8.5 施設マイクロマネジメント +LvUPで価格設定(ぼったくり設定含む)、食材ランク、風呂の混浴設定、風俗のハードプレイ設定、カジノのイカサマ率設定などが解禁される。 +# 9. 経済システムと関連する施設詳細仕様書 +## 9.1 通貨とリソース + * **資金 (Gold):** 表の金と裏の金。 + * **マナ (Mana):** 施設維持エネルギー。枯渇すると全館機能停止。 + * **資材 (Materials):** 建設・修理素材。 +## 9.2 収益と支出 + * **収益 (表):** 宿泊料、飲食売上。 + * **収益 (裏):** 性風俗、カジノ、脅迫金、人身売買。 + * **支出:** 人件費、維持費、仕入れ原価、闇銀行への借金返済、世界樹ランク税(上納金)。 +## 9.3 経済関連施設 + * **受付/精算所:** ロビーに設置。処理が遅いと機会損失。 + * **金庫室:** 所持金上限解放。盗賊の標的になる。 + * **購買部:** 消費自動発注、戦利品買取。 + * **闇銀行:** 即時融資。返済滞納で店舗破壊部隊が襲来。 +## 9.4 客の経済圏と破産システム + * **予算AI:** 「生存経費」より「娯楽経費(依存)」を優先するようになる。 + * **破産:** ツケ払いで信用度を超過すると破産。女性客は「代物弁済」イベントで即座に奴隷化・投獄される。 +# 10. 建築可能施設 詳細仕様書 +## 10.1 宿泊施設 + * 一般客室(2x1,表)、上級客室(4x1,表)、VIPスイート(6x2,表高層,覗き対応)、ドミトリー(4x1,表地下)。 +## 10.2 飲食施設 + * 大衆食堂(4x2,表)、レストラン(6x2,表)、酒場(4x2,表,騒音発生)、屋台(2x1,表)。 +## 10.3 衛生・娯楽施設 + * 大浴場(6x2,表,覗き対応)、カジノ(6x3,表地下)、闘技場(8x4,表地下)、礼拝堂(4x3,表)。 +## 10.4 アングラ・性風俗施設 + * 風俗個室(2x1,表地下,覗き対応)、高級ソープ(4x2,表地下)、SM拷問室(4x2,表地下)、闇市場(4x2,表地下)。 +## 10.5 バックヤード施設 + * 厨房(4x2,裏)、倉庫(4x2,裏)、待機室(4x2,裏)、従業員寮(2x1,裏)、警備員詰所(2x2,裏)、清掃員室(2x1,裏)、医務室(4x2,裏)。 +## 10.6 特殊・ギミック施設 + * 覗き部屋(2x1,裏限定)、地下牢(4x2,裏地下)、マナ炉(6x3,裏地下,奴隷消費)。 +## 10.7 インフラ・防衛 + * 蔓の昇降機(2x2,表)、業務用EV(3x2,裏)、ワープ(2x2,表)、魔法砲台(外壁)。 +# 11. UX・ゲームプレイ画面詳細仕様書 +## 11.1 メイン画面レイアウト + * **上部:** 資金・マナ・資材リソース、時間・天候、世界樹ランク。 + * **下部:** [FRONT / BACK] レイヤー切替巨大トグルスイッチ、建設・管理メニュー。 + * **マップエリア:** マウススクロール、ズームインアウト対応の2D断面図。表/裏の視覚的強調・半透明化制御。 +## 11.2 インスペクター・詳細ウィンドウ + * **施設:** ポリシー設定、シフト配置(ドラッグ&ドロップ)。 + * **ユニット:** 客の欲求バー・AI思考ログ、従業員の感情3軸グラフ・愛の盾耐久値。 +## 11.3 覗き・背徳インターフェース + * 裏層の「カメラアイコン」からマジックミラー視界へ移行。 + * アングル変更、スクリーンショット(脅迫証拠収集)、介入操作が可能。 +## 11.4 建設・通知UX + * **建設:** グリッドスナップ、接続ライン警告表示。 + * **通知:** ユニット頭上のフキダシ(Zzz、怒り、ハート)、イベントログ。 + * **カットイン:** 「NTR侵食開始」などの重要警告、決闘発生、防衛戦開始演出。 +# 12. デザイン基本方針仕様書 +## 12.1 アートコンセプト + * **テーマ:** 『二面性 (Duality)』。表(自然・秩序・光)と裏(人工・混沌・闇)のコントラスト。 + * **グラフィック:** 高精細ドット絵(マップ・SDキャラ)と、高品質アニメ塗り立ち絵(イベント・会話)。 +## 12.2 レイヤー切替演出 + * ガシャンという音ではなく、「魔法のレンズを通した」ように波紋が広がり世界が反転するシームレス演出。 +## 12.3 キャラクター表現 + * **ドット絵:** 呼吸、胸の揺れ、職種別歩行、生々しいHアニメーションループ。 + * **立ち絵:** 破損、汚れ、刻印差分。純愛(ソフトフォーカス)とNTR/堕落(強い影・レイプ目)の明確な描き分け。 +## 12.4 UI / サウンドデザイン + * **UIスキン:** 表モード(羊皮紙・ファンタジー)と裏モード(黒鉄・モニター風)で動的に変化。 + * **BGM:** 表裏で同じ曲のアレンジ違い(裏は重低音ベース強調)をクロスフェード再生。 + * **SE:** Hサウンドの水音へのこだわり、環境音(表の小鳥 vs 裏の蒸気管)による空間演出。 +# 13. 必要デザインアセットリスト +## 1. 2Dイラストレーション + * メイン・サブキャラ立ち絵(表情・衣装・刻印差分含む)。 + * HシーンイベントCG(ルート別・差分あり)、モブ汎用Hスチル。 +## 2. ドット絵・アニメーション + * ユニットSDモーション(待機・歩行・作業・気絶・各種Hアニメループ)。 + * エフェクト(愛の盾、煙、マナの光)。 +## 3. マップ・施設タイルセット + * 施設Lv1〜3の外観・内装(表裏両方)。背景遠景。 +## 4. UI/UX素材 + * 表裏スキン、感情グラフUI、覗きモード専用オーバーレイ(ビデオカメラ風)。 +## 5. サウンドアセット + * 動的変化対応BGM、環境音、UI操作音、H用SE(生音系)、キャラクターボイス。 +# 14. ⭐️要修正・開発スケジュール・ロードマップ (大枠) +**対象期間:** 2026年3月 ~ 2027年12月 (マスターアップ) + * **【フェーズ1】プロトタイプ (2026/03-08):** 表裏レイヤー切替、AI移動ロジック、基本素材作成。MVPの完成。 + * **【フェーズ2】アルファ版量産 (2026/09-2027/02):** パラメータ管理実装、立ち絵・施設タイルの量産、シナリオ・通常BGM組み込み。 + * **【フェーズ3】ベータ版・R18実装 (2027/03-08):** Hシーン実装、覗きモード組み込み、ボイス収録・実装、ゲームバランスの徹底調整。 + * **【フェーズ4】ポリッシュ・マスターアップ (2027/09-12):** 進行不能バグ等のデバッグ、UIレスポンス向上、マスタリング、最終完成。 diff --git a/docs/plans/original-completion-2026-09-29/execution/baseline-verification.json b/docs/plans/original-completion-2026-09-29/execution/baseline-verification.json new file mode 100644 index 0000000..d1c6f67 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/baseline-verification.json @@ -0,0 +1,23 @@ +{ + "verified_at_utc": "2026-09-29T00:32:34.734690+00:00", + "source": "Google Drive readable text fetch", + "file_id": "1w3hpz3kacxo1jRDwDhDT0TzNRJtAo-LW", + "mime_type": "text/plain", + "modified_time": "2026-09-22T02:14:53.827Z", + "saved_baseline_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "fetched_text_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "readable_text_exact_match": true, + "raw_download_verified": false, + "changed_requirement_ids": [], + "source_files": 228, + "runtime_snapshot_version": 9, + "production_workspace": "/Users/korizatou114/Projects/とりにく工房ゲーム制作部", + "previous_goal_turn_classification": "progress: audit and plan artifacts were created; this is the first implementation continuation", + "packages": { + "P00": "completed: readable original text unchanged, starting code snapshot fixed", + "P01": "in_progress", + "P02": "pending fresh regression", + "P03": "in_progress: Runtime v10 upkeep migration and crosslinks implemented; regression ongoing", + "P05": "in_progress: user confirmed ownership proration; Runtime, ledger, Save and UI integrated; acceptance and migration refinement ongoing" + } +} diff --git a/docs/plans/original-completion-2026-09-29/execution/compare_upkeep_trial.py b/docs/plans/original-completion-2026-09-29/execution/compare_upkeep_trial.py new file mode 100644 index 0000000..920eb6a --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/compare_upkeep_trial.py @@ -0,0 +1,56 @@ +"""Reconcile the approved 0.5% three-day trial with its zero-rate reference. +Run from the project root after both test reports exist. Evidence only; does not +set game balancing values or claim long-term/Windows acceptance. +""" +import json +from pathlib import Path + +root = Path('artifacts/upkeep-2026-09-29') +reference = json.loads((root / 'zero-rate-reference-report.json').read_text()) +trial = json.loads((root / 'rate-0-5-percent/normal-play-report.json').read_text()) +checks = [] +def check(label, condition): + checks.append({'condition': label, 'passed': bool(condition)}) + +check('both gameplay trials passed', reference['failures'] == trial['failures'] == 0) +check('three closes in each trial', len(reference['closes']) == len(trial['closes']) == 3) +for key in ('meal_sales', 'lodging_sales', 'raw_deliveries', 'meal_deliveries'): + check(f'{key} unchanged', reference[key] == trial[key]) +receipts = trial['upkeep']['receipts'] +expense = sum(row['new_expense'] for receipt in receipts for row in receipt['rows']) +paid = sum(row['paid'] for receipt in receipts for row in receipt['rows']) +check('positive upkeep charged', expense > 0) +check('maintenance ledger matches receipts', expense == trial['maintenance_expense']) +check('maintenance ledger matches daily P&L', expense == sum(c['category_totals']['maintenance'] for c in trial['closes'])) +check('expense minus payments matches arrears', expense - paid == trial['upkeep']['arrears_total']) +check('trial cash change exactly equals upkeep payments', reference['cash'] - trial['cash'] == paid) +check('no unpaid upkeep', trial['upkeep']['arrears_total'] == 0) +fractions, debt = {}, {} +for receipt in receipts: + rows = receipt['rows'] + end = receipt['period']['period_end'] + for row in rows: + fid = row['facility_id'] + units = receipt['period']['facility_units'].get(fid, 0) + fractions.get(fid, 0) + check(f'{end}/{fid} exact proration and fractional carry', divmod(units, 1440) == (row['new_expense'], row['fraction_units'])) + check(f'{end}/{fid} debt carries and payment reconciles', debt.get(fid, 0) + row['new_expense'] - row['paid'] == row['arrears']) + fractions[fid], debt[fid] = row['fraction_units'], row['arrears'] +for old, new in zip(reference['closes'], trial['closes']): + day = new['day'] + check(f'day {day} balanced', new['balanced'] and new['reconciliation_difference'] == 0) + check(f'day {day} no total arrears', new['arrears_total'] == 0) + check(f'day {day} P&L difference equals upkeep', old['net_profit'] - new['net_profit'] == new['category_totals']['maintenance']) + check(f'day {day} other expense/revenue categories unchanged', all(new['category_totals'][k] == v for k,v in old['category_totals'].items() if k != 'maintenance')) +result = { + 'approved_rate_percent': 0.5, + 'passed': all(c['passed'] for c in checks), + 'checks': checks, + 'cash_reference': reference['cash'], 'cash_trial': trial['cash'], + 'maintenance_expense': expense, 'maintenance_paid': paid, + 'upkeep_arrears': trial['upkeep']['arrears_total'], + 'days': [{k:c[k] for k in ('day','category_totals','net_profit','arrears_total','balanced')} for c in trial['closes']], + 'scope': 'Three simulated days, one normal inn layout and fixed seed; not long-term balancing or Windows acceptance.' +} +(root / 'rate-0-5-percent-reconciliation.json').write_text(json.dumps(result, ensure_ascii=False, indent=2)+'\n') +print(json.dumps({k:v for k,v in result.items() if k not in ('checks','days')},ensure_ascii=False)) +raise SystemExit(0 if result['passed'] else 1) diff --git a/docs/plans/original-completion-2026-09-29/execution/facility-dimension-preflight.json b/docs/plans/original-completion-2026-09-29/execution/facility-dimension-preflight.json new file mode 100644 index 0000000..15dc40d --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/facility-dimension-preflight.json @@ -0,0 +1,71 @@ +{ + "status": "audited_not_modified", + "authority": "baseline-fetched-2026-09-29.txt chapter10", + "facilities": [ + { + "id": "premium_room", + "path": "data/facilities/expansion/premium_room.tres", + "current_footprint": [ + 3, + 2 + ], + "original_footprint": [ + 4, + 1 + ], + "source_sha256": "bf035db5deea3818ab12f029fdf53f65afa2f5c99fec4a236ce7dfb7076f5bb7", + "normal_registry": false, + "migration_risks": [ + "saved occupied or reserved cells", + "support of facilities on removed cells", + "overlap on added cells", + "front/back route connectivity", + "construction jobs still in progress" + ] + }, + { + "id": "staff_dormitory", + "path": "data/facilities/expansion/staff_dormitory.tres", + "current_footprint": [ + 4, + 2 + ], + "original_footprint": [ + 2, + 1 + ], + "source_sha256": "84c7a92216300a20f627c7e822219cf188912d9ba3417fa910261d18bdaf69cc", + "normal_registry": false, + "migration_risks": [ + "saved occupied or reserved cells", + "support of facilities on removed cells", + "overlap on added cells", + "front/back route connectivity", + "construction jobs still in progress" + ] + }, + { + "id": "black_market", + "path": "data/facilities/expansion/black_market.tres", + "current_footprint": [ + 3, + 2 + ], + "original_footprint": [ + 4, + 2 + ], + "source_sha256": "39eb0a91776f84f31755c8555dfee9320eca1d4491951c297f9188b8fa1c4422", + "normal_registry": false, + "migration_risks": [ + "saved occupied or reserved cells", + "support of facilities on removed cells", + "overlap on added cells", + "front/back route connectivity", + "construction jobs still in progress" + ] + } + ], + "registry_evidence": "FacilityCatalog.EXPANSION_FACILITY_PATHS; Runtime registry loads FACILITY_PATHS + infrastructure + rescue only", + "next": "Implement explicit preflight and atomic migration before applying new definitions to saved facilities. Do not infer legacy dimensions from current definitions." +} diff --git a/docs/plans/original-completion-2026-09-29/execution/mana-staging.json b/docs/plans/original-completion-2026-09-29/execution/mana-staging.json new file mode 100644 index 0000000..b8961ba --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/mana-staging.json @@ -0,0 +1,44 @@ +{ + "stage": "/var/folders/41/j8fq6vqj2993cmyprl6p76pw0000gn/T/thetree-mana-integration-126vatdl", + "production": "/Users/korizatou114/Projects/とりにく工房ゲーム制作部", + "runtime_before_sha256": "d30cc751636ead7f5a9132276df94332770707018322e403f94bd05721464c2b", + "purpose": "Isolated runtime mana integration; apply to production only after frozen baseline completes and targeted tests pass.", + "recorded_at": "2026-09-29T02:10:16.901923+00:00", + "targeted_validation_session_id": 24561, + "runtime_depletion_checks": 15, + "runtime_depletion_passed": true, + "stage_files_sha256": { + "autoload/simulation_runtime.gd": "a0fe3df66bdc598eb27b1bbe659c1dcc7e72f0706b41daba7b09ea478ea8f0ae", + "scripts/facility/facility_instance.gd": "525c58507035cf3cd3498a2a63faa1514d75ad505f429a544570ceb9b2557122", + "scripts/mana/mana_power_projection.gd": "2026467960e857349bcb18fb97b7753189be5f2de2be91a17e73e90146eba69e", + "tests/mvp/test_runtime_mana_depletion.gd": "23e0e7b40d4d80042f09295c0a18bba26f8719517cad170d503c79575f1c85bf", + "tests/s2/test_runtime_operations_cycle.gd": "b7c07beb887b268213a3ba484d1a4f65c542a2dc06c64c78b4f806eaeeed997b", + "tests/supply/test_supply_logistics_bridge.gd": "353a4453a12f7abcfa2de26204d3730e54b9fd64144d846e4a271cba5c216886", + "tests/transport/test_runtime_transport.gd": "feacbab361cfaa7fa7996c010198eabd4783d4c341597784f7b6ded8669576b9", + "tests/ui/test_business_management.gd": "50aa8294d3b92d02a2f6b83bfc4d820624060b276a9aef5162ed25e58bbcfa8a" + }, + "production_applied": true, + "patch": "artifacts/mana-2026-09-29/staged-runtime/runtime-integration.patch", + "next": "Continue P06: kitchen and rescue-companion outage, visible outage UI and old-save boundaries. Await numeric policy for finite reserve integration. No full original-completion claim.", + "production_before_sha256": { + "autoload/simulation_runtime.gd": "d30cc751636ead7f5a9132276df94332770707018322e403f94bd05721464c2b", + "scripts/facility/facility_instance.gd": "e4783d99493d49a7a7d6d03aa726dc45803c0c3c87adec52eb8814b377fc474d", + "scripts/mana/mana_power_projection.gd": "357167cfc511a9bd3585da385a73092d87170811171d707ef43da6d3ff96452d", + "tests/mvp/test_runtime_mana_depletion.gd": null, + "tests/s2/test_runtime_operations_cycle.gd": "43bb6155dbcd8e21c01a57a62d13e2ab59edd1f3b23ebd14485f1273b15a065e", + "tests/supply/test_supply_logistics_bridge.gd": "dc745130fe155e27868a281e3f6a1b68afafaa5ba0b36723ef1b6bc49f388611", + "tests/transport/test_runtime_transport.gd": "00e5ecf548b5a0cf2d833b0f62123d111a7d85d9c94646c597034543e5d17b7c", + "tests/ui/test_business_management.gd": "182d6869b37a2643cdc4e4cb5c7eca95ed9fb36853a2069d630379ba5643d7f7" + }, + "targeted_validation_complete": true, + "outage_validation_complete": true, + "baseline_ui_expectation_repair": { + "checks": 60, + "passed_in_stage": true, + "test": "tests/ui/test_business_management.gd", + "reason": "Upkeep explanation now includes wages and facility upkeep; old test required obsolete wages-only substring." + }, + "production_applied_at": "2026-09-29T02:25:34.819320+00:00", + "production_validation_passed": true, + "production_validation_scripts": 7 +} diff --git a/docs/plans/original-completion-2026-09-29/execution/mana-validation-progress.json b/docs/plans/original-completion-2026-09-29/execution/mana-validation-progress.json new file mode 100644 index 0000000..0d4e593 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/mana-validation-progress.json @@ -0,0 +1,47 @@ +{ + "recorded_at": "2026-09-29T02:27:50.233792+00:00", + "status": "production_outage_kitchen_rescue_and_hud_passed", + "checks": 33, + "exit_code": 0, + "log": "artifacts/mana-2026-09-29/reserve-balance.log", + "source_sha256": { + "scripts/mana/mana_reserve_balance.gd": "cd462c279d37cc297466b64d9cf3fd71870df9508c1ff5f7189e22ddbdbef4b3", + "tests/s2/test_mana_reserve_balance.gd": "07fe5f10e665cac3c8aa1b12e0c71878ffccc6d4b409250b317135345e3c8a05", + "scripts/mana/mana_power_projection.gd": "2026467960e857349bcb18fb97b7753189be5f2de2be91a17e73e90146eba69e", + "tests/s2/test_mana_power_projection.gd": "e3020534b084b57a8b773967931fd2be34a1de878b4940c8805b99592bbbc46f", + "autoload/simulation_runtime.gd": "a0fe3df66bdc598eb27b1bbe659c1dcc7e72f0706b41daba7b09ea478ea8f0ae", + "scripts/facility/facility_instance.gd": "525c58507035cf3cd3498a2a63faa1514d75ad505f429a544570ceb9b2557122", + "tests/mvp/test_runtime_mana_depletion.gd": "23e0e7b40d4d80042f09295c0a18bba26f8719517cad170d503c79575f1c85bf", + "tests/s2/test_runtime_operations_cycle.gd": "b7c07beb887b268213a3ba484d1a4f65c542a2dc06c64c78b4f806eaeeed997b", + "tests/supply/test_supply_logistics_bridge.gd": "353a4453a12f7abcfa2de26204d3730e54b9fd64144d846e4a271cba5c216886", + "tests/transport/test_runtime_transport.gd": "feacbab361cfaa7fa7996c010198eabd4783d4c341597784f7b6ded8669576b9", + "tests/ui/test_business_management.gd": "50aa8294d3b92d02a2f6b83bfc4d820624060b276a9aef5162ed25e58bbcfa8a" + }, + "numeric_policy": "awaiting_user_reply", + "next": "P06 natural supply/consumption policy remains pending. P07 weather has no existing runtime or HUD implementation; implement explicit weather/save contract and storm admission before numeric balance.", + "power_projection": { + "checks": 14, + "exit_code": 0, + "log": "artifacts/mana-2026-09-29/power-projection-recheck.log", + "scope": "production runtime integrated; component and runtime outage tests passed" + }, + "staged_runtime": { + "checks": 15, + "passed": true, + "outage_acceptance": { + "service_cycle": 58, + "loaded_cargo": 109, + "boarded_elevator": 37 + }, + "evidence": "artifacts/mana-2026-09-29/staged-runtime/outage-validation.json" + }, + "production_validation_session_id": null, + "production_applied": true, + "production_validation_scripts": 7, + "production_validation_passed": true, + "make_check_exit_code": 0, + "latest_evidence": "artifacts/mana-2026-09-29/kitchen-rescue-ui-validation.json", + "previous_goal_turn_classification": "progress: no-safe-landing Runtime outage recovery verified", + "no_landing_evidence": "artifacts/mana-2026-09-29/no-landing-validation.json", + "legacy_zero_evidence": "artifacts/mana-2026-09-29/legacy-zero-save-validation.json" +} diff --git a/docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json b/docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json new file mode 100644 index 0000000..2fed8d9 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json @@ -0,0 +1,212 @@ +[ + { + "section": "10.1", + "name": "一般客室", + "definition_id": "standard_room", + "original_size": "2x1", + "original_layer_zone": "FRONT" + }, + { + "section": "10.1", + "name": "上級客室", + "definition_id": "premium_room", + "original_size": "4x1", + "original_layer_zone": "FRONT" + }, + { + "section": "10.1", + "name": "VIPスイート", + "definition_id": null, + "original_size": "6x2", + "original_layer_zone": "FRONT高層" + }, + { + "section": "10.1", + "name": "ドミトリー", + "definition_id": null, + "original_size": "4x1", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.2", + "name": "大衆食堂", + "definition_id": "cafeteria", + "original_size": "4x2", + "original_layer_zone": "FRONT" + }, + { + "section": "10.2", + "name": "レストラン", + "definition_id": null, + "original_size": "6x2", + "original_layer_zone": "FRONT" + }, + { + "section": "10.2", + "name": "酒場", + "definition_id": "tavern", + "original_size": "4x2", + "original_layer_zone": "FRONT" + }, + { + "section": "10.2", + "name": "屋台", + "definition_id": null, + "original_size": "2x1", + "original_layer_zone": "FRONT" + }, + { + "section": "10.3", + "name": "大浴場", + "definition_id": "public_bath", + "original_size": "6x2", + "original_layer_zone": "FRONT" + }, + { + "section": "10.3", + "name": "カジノ", + "definition_id": null, + "original_size": "6x3", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.3", + "name": "闘技場", + "definition_id": null, + "original_size": "8x4", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.3", + "name": "礼拝堂", + "definition_id": "chapel", + "original_size": "4x3", + "original_layer_zone": "FRONT" + }, + { + "section": "10.4", + "name": "風俗個室", + "definition_id": "adult_private_room", + "original_size": "2x1", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.4", + "name": "高級ソープ", + "definition_id": null, + "original_size": "4x2", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.4", + "name": "SM拷問室", + "definition_id": null, + "original_size": "4x2", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.4", + "name": "闇市場", + "definition_id": "black_market", + "original_size": "4x2", + "original_layer_zone": "FRONT地下" + }, + { + "section": "10.5", + "name": "厨房", + "definition_id": "kitchen", + "original_size": "4x2", + "original_layer_zone": "BACK" + }, + { + "section": "10.5", + "name": "倉庫", + "definition_id": "warehouse", + "original_size": "4x2", + "original_layer_zone": "BACK" + }, + { + "section": "10.5", + "name": "待機室", + "definition_id": "staff_waiting_room", + "original_size": "4x2", + "original_layer_zone": "BACK" + }, + { + "section": "10.5", + "name": "従業員寮", + "definition_id": "staff_dormitory", + "original_size": "2x1", + "original_layer_zone": "BACK" + }, + { + "section": "10.5", + "name": "警備員詰所", + "definition_id": "security_station", + "original_size": "2x2", + "original_layer_zone": "BACK" + }, + { + "section": "10.5", + "name": "清掃員室", + "definition_id": "cleaning_room", + "original_size": "2x1", + "original_layer_zone": "BACK" + }, + { + "section": "10.5", + "name": "医務室", + "definition_id": "infirmary", + "original_size": "4x2", + "original_layer_zone": "BACK" + }, + { + "section": "10.6", + "name": "覗き部屋", + "definition_id": null, + "original_size": "2x1", + "original_layer_zone": "BACK" + }, + { + "section": "10.6", + "name": "地下牢", + "definition_id": null, + "original_size": "4x2", + "original_layer_zone": "BACK地下" + }, + { + "section": "10.6", + "name": "マナ炉", + "definition_id": null, + "original_size": "6x3", + "original_layer_zone": "BACK地下" + }, + { + "section": "10.7", + "name": "蔓の昇降機", + "definition_id": "guest_elevator", + "original_size": "2x2", + "original_layer_zone": "FRONT" + }, + { + "section": "10.7", + "name": "業務用EV", + "definition_id": "service_elevator", + "original_size": "3x2", + "original_layer_zone": "BACK" + }, + { + "section": "10.7", + "name": "ワープ", + "definition_id": null, + "original_size": "2x2", + "original_layer_zone": "FRONT" + }, + { + "section": "10.7", + "name": "魔法砲台", + "definition_id": null, + "original_size": "未指定", + "original_layer_zone": "外壁" + } +] diff --git a/docs/plans/original-completion-2026-09-29/execution/starting-git-status.txt b/docs/plans/original-completion-2026-09-29/execution/starting-git-status.txt new file mode 100644 index 0000000..41704cb --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/starting-git-status.txt @@ -0,0 +1,104 @@ + M Makefile + M README.md + M assets/themes/japanese_fantasy/visual-theme-v1.tres + M autoload/simulation_runtime.gd + M docs/ART_ASSET_PRODUCTION_SPECIFICATION.md + M docs/DEVELOPMENT_BACKLOG.md + M docs/IMPLEMENTATION_STATUS.md + M docs/PIXEL_TOWER_VISUALS.md + M docs/PLAYTEST_GUIDE.md + M docs/UI_OPERATION_SPECIFICATION.md + M scenes/main/main.gd + M scenes/main/world_preview.gd + M scripts/core/definition_registry.gd + M scripts/core/performance_budget.gd + M scripts/core/simulation_kernel.gd + M scripts/core/simulation_snapshot.gd + M scripts/data/facility_catalog.gd + M scripts/data/facility_definition.gd + M scripts/facility/facility_instance.gd + M scripts/facility/facility_pricing.gd + M scripts/facility/facility_service.gd + M scripts/guest/guest_service.gd + M scripts/operations/operations_coordinator.gd + M scripts/staff/staff_service.gd + M scripts/staff/staff_task.gd + M scripts/supply/kitchen_production_service.gd + M scripts/supply/supply_logistics_bridge.gd + M scripts/supply/supply_management_service.gd + M scripts/transport/elevator_service.gd + M scripts/ui/business_management_panel.gd + M scripts/ui/inn_opening_guide.gd + M scripts/ui/operating_ledger_panel.gd + M scripts/ui/pixel_tower_art.gd + M scripts/ui/staff_management_panel.gd + M scripts/ui/supply_management_panel.gd + M scripts/world/path_service.gd + M tests/export/test_exported_opening.gd + M tests/mvp/test_operations_coordinator.gd + M tests/mvp/test_runtime_operations_acceptance.gd + M tests/mvp/test_runtime_operations_integration.gd + M tests/performance/perf_mvp_001_harness.gd + M tests/run_tests.gd + M tests/s0/test_runtime_integration.gd + M tests/s1/test_path_congestion.gd + M tests/s2/test_facility_service.gd + M tests/s2/test_kitchen_production.gd + M tests/s3/test_guest_service.gd + M tests/s3/test_runtime_path_traffic.gd + M tests/s6/test_performance_budget.gd + M tests/s6/test_runtime_save_lifecycle.gd + M tests/transport/test_elevator_service.gd + M tests/transport/test_runtime_stairs.gd + M tests/ui/test_business_management.gd + M tests/ui/test_first_operating_day.gd + M tests/ui/test_main_dashboard.gd + M tests/ui/test_pixel_tower.gd + M tests/ui/test_staff_management.gd + M tests/world/test_path_transit_density.gd +?? "TheTree-\343\203\211\343\203\203\343\203\210\347\211\210\343\202\222\350\265\267\345\213\225.command" +?? data/facilities/rescue/ +?? docs/DEBUG_COMPLETION_2026-09-27.md +?? docs/PIXEL_ANIMATION_2026-09-27.md +?? docs/SYSTEM_PERFORMANCE_2026-09-27.md +?? docs/THE_TOWER_2_COMPLETION_2026-09-29.md +?? docs/THE_TOWER_2_REFERENCE_2026-09-28.md +?? docs/THE_TOWER_2_WINDOWS_REVIEW_2026-09-28.md +?? docs/plans/ +?? docs/traceability/ +?? scripts/ui/guest_observation_panel.gd +?? scripts/ui/guest_observation_panel.gd.uid +?? scripts/ui/pixel_people.gd +?? scripts/ui/pixel_people.gd.uid +?? scripts/ui/room_presentation.gd +?? scripts/ui/room_presentation.gd.uid +?? tests/mvp/fixtures/ +?? tests/mvp/test_original_midnight_settlement.gd +?? tests/mvp/test_original_midnight_settlement.gd.uid +?? tests/performance/fixtures/linear_path_reference.gd +?? tests/performance/fixtures/linear_path_reference.gd.uid +?? tests/performance/fixtures/uncached_preview_reference.gd +?? tests/performance/fixtures/uncached_preview_reference.gd.uid +?? tests/performance/test_system_optimization.gd +?? tests/performance/test_system_optimization.gd.uid +?? tests/s2/test_original_facility_parameters.gd +?? tests/s2/test_original_facility_parameters.gd.uid +?? tests/staff/test_rescue_elevator.gd +?? tests/staff/test_rescue_elevator.gd.uid +?? tests/staff/test_shift_rush.gd +?? tests/staff/test_shift_rush.gd.uid +?? tests/staff/test_staff_rescue.gd +?? tests/staff/test_staff_rescue.gd.uid +?? tests/staff/test_staff_rest.gd +?? tests/staff/test_staff_rest.gd.uid +?? tests/ui/test_guest_observation.gd +?? tests/ui/test_guest_observation.gd.uid +?? tests/ui/test_original_facility_controls.gd +?? tests/ui/test_original_facility_controls.gd.uid +?? tests/ui/test_room_presentation.gd +?? tests/ui/test_room_presentation.gd.uid +?? tests/ui/test_staff_rescue.gd +?? tests/ui/test_staff_rescue.gd.uid +?? tests/ui/test_staff_rest.gd +?? tests/ui/test_staff_rest.gd.uid +?? tools/run_all_tests.py diff --git a/docs/plans/original-completion-2026-09-29/execution/starting-source-manifest.json b/docs/plans/original-completion-2026-09-29/execution/starting-source-manifest.json new file mode 100644 index 0000000..9c0d924 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/starting-source-manifest.json @@ -0,0 +1,230 @@ +{ + "autoload/event_bus.gd": "6950280a5639dd5a3071ffefd034cb443a694739d5bf8c4e2dbcd52aadbab4b2", + "autoload/game_clock.gd": "39400634a57ccbf8c584b54a15a259048a95da90b1528330151e45667d65f7f7", + "autoload/game_state.gd": "cf0e884910220e54c56b5875983e53de439af9d893be1e1b65c2121d2a30722f", + "autoload/simulation_runtime.gd": "0c571936c6222f8891da9a6e6798543ec50a1f1b59eb1c8dfc709bc83fe6a351", + "data/characters/mvp_guest_classes.json": "5d55d4a0a10902da747000357ba36be9644bf027f253ea4b200a75f1ee4cc88b", + "data/characters/mvp_species.json": "6c7caf52305d76e07a0dee2b225ee92674c45d24cbca7f62bf4029bd3db00915", + "data/characters/mvp_staff_candidates.json": "437fb2d6731ac7d6539d88d3e2c006643a0ae379b22ce67a33e17f1c2e7fb3d9", + "data/characters/mvp_unique_characters.json": "51fbfb2720062a442a6027b994e9cf679a807edc20b2dd2f82635f8597286f46", + "data/facilities/adult_private_room.tres": "8f4725a1de93d36fef2cfbbea5672260e61e3d4e73951eb1d685f83a61b5635a", + "data/facilities/cafeteria.tres": "e2945fd3a7533eef4151277b95ec59c1798438721216a3df5145fdbbf1105fe1", + "data/facilities/expansion/black_market.tres": "39eb0a91776f84f31755c8555dfee9320eca1d4491951c297f9188b8fa1c4422", + "data/facilities/expansion/clinic.tres": "339cf22c68d7ab3cb1514b9e8d2ea63dc0772fe93918d70767c11cf7b6ee0e16", + "data/facilities/expansion/cold_storage.tres": "1aac824147b52b41e124d2ab2ce63c3bac58de2ec7fc447a5806a1cf9fa9ad99", + "data/facilities/expansion/general_store.tres": "76872e0375648b43f05fdc5581ccabe4aa6a166674c3b523980dd1cf0b6e6dc1", + "data/facilities/expansion/guild_counter.tres": "93e8736da4aab8a17b1c8e7831c9a7b8de7f32d0ec251aa9aa5c8cf859c8fd7b", + "data/facilities/expansion/laundry.tres": "2518ea2e5da12cf4f2997502bf50f00dd8145caca153fb3486b44b99881fdab4", + "data/facilities/expansion/maintenance_workshop.tres": "8bd10332a9f45a252febdc626f22f90507317fb4cff5b18b2526aacd1f1a176b", + "data/facilities/expansion/mana_control_room.tres": "55e4c52a049ff61ddf3d081ccb990a8572b64bc7cf37d3a0d52ca5a08cc32f25", + "data/facilities/expansion/premium_room.tres": "bf035db5deea3818ab12f029fdf53f65afa2f5c99fec4a236ce7dfb7076f5bb7", + "data/facilities/expansion/private_bath.tres": "241b807e7fa3a1b9dc4bda0d2495e637155cdaea89a4234e37a121f00098499c", + "data/facilities/expansion/secret_tavern.tres": "f0a1372931078fa0f93dd5b9cec6df6b137975479aa706633fdf6a9be4cf1f62", + "data/facilities/expansion/staff_dormitory.tres": "84c7a92216300a20f627c7e822219cf188912d9ba3417fa910261d18bdaf69cc", + "data/facilities/expansion/tea_lounge.tres": "77d1ace36c5e84f80c0e6d563ae973c7ff1cd12b56956afd4895cdcdd5771a67", + "data/facilities/expansion/underground_guest_room.tres": "cdfd0f8cd21812a9972485926dff27daaa6718af16aac1651894728b087bef9e", + "data/facilities/expansion/vip_private_salon.tres": "1c94a7a70393262fba82112c3f4a0cb67ad22728664f5ca408f450049d3657a7", + "data/facilities/expansion/waste_processing.tres": "9954855c7a07e425376a220c589989beb6ec95da3c5331316ef3615a60b45f1d", + "data/facilities/guest_corridor.tres": "2abf83d274ed4667ba07b8a384affcdfe2b387ad9513a444a7f78bcf237dab0c", + "data/facilities/guest_elevator.tres": "bf779e6ac3e342a01bc023673e90a2c0b12f22de456923a08bcc3294adb765c2", + "data/facilities/infrastructure/guest_stairs.tres": "37114d29b7486d1b5a3d84ab830c85d477973b2d39cfde84949aba342f4793b8", + "data/facilities/infrastructure/service_stairs.tres": "45c608dc4767a80ff322bd0d3721c953a314f445490142f715270a7a789391cc", + "data/facilities/kitchen.tres": "96dd06b83eebe6fdc735a491c52e44137adb1a17c7aac36ed1cec9aa96a10b67", + "data/facilities/massage_room.tres": "c6987f428c0ab73ccbbc8a992b5cf78c27bed0e87d886ad2e723018e804473a9", + "data/facilities/public_bath.tres": "0a2f7086c2c2eecd142e720183d279f33ed06e12fb5c1e782cca1ce3386f03f4", + "data/facilities/reception.tres": "d898f63d619c126abf80ee1e415d0bc587ccee30cd8ad4bd94f74fbae4cf4e76", + "data/facilities/rescue/infirmary.tres": "ae70ba4dbf6bece85e8d520a97a72e49a2b8069b4bed7e98c1f90b1b7ab9a46f", + "data/facilities/security_station.tres": "0c05f0e10239293f0a83e69f9ed7a98e38415920d9e610cebbfe327c913aa2a2", + "data/facilities/service_corridor.tres": "0e375c9f94f8d1597cbffe126c7f0d8fb4a52dd161fe4acd4ce60178b18cb2ee", + "data/facilities/service_elevator.tres": "17a22e70d7a997d8d61f973a3cbaccf51731e83d121fd9e4edf38d8f53ebb27a", + "data/facilities/staff_door.tres": "dd1df9b56b023a0b9624be2524a4f3ffe36252467bd189c07e441de64fe63d26", + "data/facilities/staff_waiting_room.tres": "c065b4ddb8cb9e8b45e114b6e4b12907ef85852b7c47271d280efed5871c9f58", + "data/facilities/standard_room.tres": "be26c02d3ac6d2423755f3648893adda8d81eaafa3d33c7babc8f8a4f2f93ebb", + "data/facilities/tavern.tres": "c38da70a8e66c6aa2cae650d17738022d06589322ad0e4ab561f5bbbd737378d", + "data/facilities/warehouse.tres": "0c797d1cb5fc574d0efce06e13c398f243889cf428f94c3d526d3b7f25fb6828", + "scenes/main/main.gd": "952a45ca4cdbaca8a667a3f8a8e032027963740fe8f840aa92c3db7b44c8ff4e", + "scenes/main/main.tscn": "dde43688b86251ce617131c362b1054ce8bd59b509c1abe19e831c0481a07319", + "scenes/main/world_preview.gd": "0ebb7c6a698d066199051ef51624d3f42d36660f31ffa853d57bd94b53f00b44", + "scripts/authority/authority_service.gd": "2979cca4c6a82932fed9babf209a608b8a20bda550c170f83a166db68ce02ab9", + "scripts/authority/evidence.gd": "85f1bd83a4ec994c8bb9bce296d8b63fcdb6bb26c1b4c2f47e16864450438649", + "scripts/authority/heat_state.gd": "7e507461684c159bed4c17acc939d6a98537d06d73de2dae235a788048f290b2", + "scripts/authority/incident.gd": "dbb58592746d0055b438fc8259f7c40d09f77076737c47051b732395888df124", + "scripts/authority/inspection.gd": "ff523082ec6d918dbc559e04c3c11fd6d30d4f88c5a54ffa57eb3c758e244812", + "scripts/construction/construction_job.gd": "6c59af45346c16572f8c4cb111af86c586cec8081302bf67b6ef411a56ac5196", + "scripts/construction/construction_service.gd": "c737eb7aa927097da677ac23ad649ef871af1dea54bcb8103348b88c5303ea25", + "scripts/construction/excavation_service.gd": "337e9e5d07a9eeaf362253fec12cbe5fb2ea8e74e4887de82868748577d4af33", + "scripts/construction/placement_report.gd": "9f179c9a94a176a126027cf1d53942c0ac73b008ad119ab2480ba93f972b145c", + "scripts/core/command.gd": "9751ff4b40bebdae56b6b3879dd7cbffdc2a36b050a12bda89e1de47eb54145a", + "scripts/core/command_queue.gd": "b3625e340adec1d278bdc078d647959b582bafa86515da1017d469be99732e90", + "scripts/core/command_result.gd": "71b45fb50d69951797fe8d39ad5f707b79108eac30de09fa5e8d64ddb2436ae8", + "scripts/core/definition_registry.gd": "5636d31579d77bb57fd1eb4e6ab5d8cf8fc34c4b824c7cb7e0228caa9133f594", + "scripts/core/deterministic_rng.gd": "9c12c1b3b212e140b64137fa34d7e8b2494897cad341b3bbcf9f8da215053204", + "scripts/core/domain_event.gd": "cc9a903c366d9f889134561694b0c7fd52da260e97236a7b60830d7a68947bd4", + "scripts/core/domain_event_stream.gd": "47e27ecbd622bf5e05da8262d73e3b8c5f2204338c74a3259f3d184627819c26", + "scripts/core/performance_budget.gd": "79d18e8f0dcc3932801cd02ea8a8ba037af02ef57665387cba8dbe3b3e9af917", + "scripts/core/save_migration_registry.gd": "22ac2cce07a02d1a2a9ac2109e7a0ecf9a0fbe675df4dc9d5249aa9ab3583f98", + "scripts/core/save_service.gd": "7d0ea0fe95d9c38c5d2dffa9499cfa9215331602847a66ad36ac3c12b08ac99f", + "scripts/core/simulation_digest.gd": "a4da38b4e4573dbfa7c133fe9cf20a61f38dd21ca7faa18b2b43f87661aba676", + "scripts/core/simulation_kernel.gd": "341c0c878c4894adbac628d0db6362ff69fc3347bdd9e61e50936e15dc2d1edb", + "scripts/core/simulation_snapshot.gd": "fada16febc0942a659992f3946d41bb8195ac5fb67cf630d123afd482d9d6da5", + "scripts/core/stable_id_generator.gd": "78e0dd691ba18d1f159d9d7abba5894d2e10d36885aa6dc5be02a27f9edd5013", + "scripts/core/state_transaction.gd": "22e4dbd4efceb20ac9b71429a76494ca75bfdd4115212408d2207794c5d3ef04", + "scripts/data/facility_catalog.gd": "4646632848b52b60f7af1071ef6bc5cb17bb959798d73d2faf4c51e9af43d417", + "scripts/data/facility_definition.gd": "6f6d2f87d253b97c65d1533f70d9f3c68fbddd8eb6ff0135f6b0629873bbcc62", + "scripts/data/mvp_content_catalog.gd": "ca65ed9ee7affa7beec2fcded07ddda65684d034e14372189c51dc994904ce82", + "scripts/defense/defense_service.gd": "6a6b8e7c8166eb426d234770813ecbb0a9a33b2a335c6ea563562b34486764bf", + "scripts/economy/economy_service.gd": "ecbd42338e5f17ec75501373a4f13d8839c723fc674cc7f74241aa4d9b637abd", + "scripts/economy/ledger_backed_resource_state.gd": "199442c566185dc0abcc4eea5b4cf59c07330731bb5d5038b605dd801ae0209b", + "scripts/economy/ledger_entry.gd": "625d709b8d3239ef271dfbd42f189fd3a3fb99bd22d4f39438050c8e3addfe26", + "scripts/events/event_action.gd": "ebbb233caf961d7333271bc498738b65bc505d709de48b0469597018218d3fba", + "scripts/events/event_choice.gd": "6077cba5d59e500e4df391d58801aa8c10669b17ac36c06e830329ca8e977dd6", + "scripts/events/event_condition.gd": "31d493d68db9b49d8eed0d5adf7a6c4d90f12df0ac3353b1ceafa6259196efd5", + "scripts/events/event_definition.gd": "4af68f2e5ec185a750d59aaa24c7cb63fe612cd6a785e7fd39af11f0b5ce74a2", + "scripts/events/event_instance.gd": "e8ce02a03dcb111d808d3cb3068a752fc5bbd61ba0cb15026f1371615619b974", + "scripts/events/event_service.gd": "efa0a7992736aa9c99b33f25e66a3ba40f8ebe0aa5ab609cc6e5b25d968b5083", + "scripts/facility/facility_instance.gd": "e4783d99493d49a7a7d6d03aa726dc45803c0c3c87adec52eb8814b377fc474d", + "scripts/facility/facility_pricing.gd": "e895e29469dedfb7ba82a7bc1f1931f2711b9bf3f7e2c436133527aa9f7a60e4", + "scripts/facility/facility_service.gd": "f8d3b7a204120b7b113169a8c1dbfa4cb78077e6514d7955a71f4f2cd551d9a9", + "scripts/facility/service_instance.gd": "629a142f80cbc31b97b6d08483d94e029e448969bb6d39fdc8dd39931654dd35", + "scripts/facility/service_request.gd": "767a8c054188c8dde1e446f49d43d9ddc727a215bc9b31d5420650bd44c90024", + "scripts/guest/guest_definition.gd": "082aa3f33b9cb17daf10a2cf8a1dece7b79dd80c2ae21e1a424d1b2ceb52b918", + "scripts/guest/guest_runtime.gd": "5bb687b2da6f634c66680ffbefb4b4a57ed02fb3dff10f72ce7e7858177d1241", + "scripts/guest/guest_service.gd": "499b7c4facfdb1d98a4220f6e806683fcaabfc4e0f036d4b886ec17b2885d9a9", + "scripts/inventory/inventory_reservation.gd": "2d913d26e5af2ea12842264441d87ee5fce8fe84ef6664297ae2fa971a4ca10b", + "scripts/inventory/inventory_service.gd": "7ee83b335864385119ca757037c3cdb912957aa4478a6312a0898a734e41f31a", + "scripts/inventory/logistics_dispatcher.gd": "2313f5ba3154143d5f0b6e1eeb1fbdda37cc81a7a1589515f56dfb53a797f720", + "scripts/inventory/logistics_request.gd": "6dc5565083b8adb2c620fe805d1ba76be01ae09faf3070a9363485f4fc5e171b", + "scripts/mana/mana_network_service.gd": "96f195cf2a28b0966d12cd7c6fab86d13eae97c8342d07d70f52e598fbf49af9", + "scripts/operations/operations_coordinator.gd": "cc3b9d27beca796364b619463b5630a3a47e6ea86180f4cd60d68b3f032349bc", + "scripts/progression/progression_service.gd": "4f4e00fbeab4a4cec49b7912d8e75381b8354791e0a9efdb8414d984e88ca46f", + "scripts/relationship/placement_risk_preview_service.gd": "d8e974487b75fbad49ecbbe1da8e36aac23dc4e1a5cf10281a1c567c97dd59f6", + "scripts/relationship/relationship_service.gd": "76d49e4fdb50e9a185f7ba5065d257682a11aece1c76272208ba8555ffd534c2", + "scripts/relationship/relationship_state.gd": "14551398f31ecd899e43a3d07b165d4884e54e83983e433c6de57e3e6a18d083", + "scripts/staff/shift_instance.gd": "cec2bed5bfb138d4cc9c141bb7cdd9c19d70c034aad304398627ef1f4977ab48", + "scripts/staff/staff_commute_service.gd": "3a1b816a3a778fc362e950817275fb8464d8562b61527f141787e4b2d2a3eba0", + "scripts/staff/staff_runtime.gd": "80b4e277f1db415f2121e1079ed41e3eebd3f7363d79307a1fa9a0e46aa144eb", + "scripts/staff/staff_service.gd": "47f66f32141a8b104f0b898139f6760201f7f63a198d852e2769fd76331d1b69", + "scripts/staff/staff_task.gd": "5eeb8862a83e31e36d75e19720597fccf1aa4b25f30193d17ee95aaa94302b8a", + "scripts/staff/task_dispatcher.gd": "fdf2b6464e4b1089c58cbce972ff6217a0f7c69929b1be425536152d4b8290c7", + "scripts/supply/kitchen_production_service.gd": "f3bc6bdc1a0ba278e9411108172ceceb7eb06520b4e33f93f35684da1f103c62", + "scripts/supply/supply_catalog.gd": "93a3f674a953fb0627a38ad0342df31dc5fa77ba8a225780f2250e30ec22dd15", + "scripts/supply/supply_logistics_bridge.gd": "b9e394af788595d2744b71f1907c8e7c3d762d38ff49992b1ee74e63b9639e80", + "scripts/supply/supply_management_service.gd": "466c12b25c435afe3789c4079107c0cf66d79ec3484af847814829dbdb0944bf", + "scripts/transport/elevator_service.gd": "cea09dd9204551c26b31b30cd3aa847cc77e3b75fdac31662b9694a95fb3580c", + "scripts/ui/business_management_panel.gd": "404dbe00cbd73abe7ee9e7f4ede99a335d3675270735a63eb714ae4e74d0fc92", + "scripts/ui/guest_observation_panel.gd": "492d16457d53e113c05ae8d327ae513c782952afb03d97a85dbf3bcd720dd35f", + "scripts/ui/inn_opening_guide.gd": "c923bf932a7fcde4548887cd86162d34c9eb8079e433014ed49ad6ff8ec23b75", + "scripts/ui/operating_ledger_panel.gd": "efc282cbe8711c6e3d1a7a6eab0dd9ccf63b2a26af20205fa0a5b45a2c7eb6ec", + "scripts/ui/pixel_people.gd": "8a46ba96705576bda98d3ba6b08323ddc1a3bd1e9fb4321b9b959b84fd07c626", + "scripts/ui/pixel_tower_art.gd": "9d1082a9cc177a4f3232f8c340a24b485328d4b23f57ab01340bf0db9e331f7d", + "scripts/ui/pixel_tower_demo.gd": "d66a245818d77d879b3046a4daea372d8b36d33c7fcce8c7ad3c170206e847ae", + "scripts/ui/room_presentation.gd": "4d7c6f9acbd5f27845acc748b623a8f039e99951045c1e421a1932ecfd4921f3", + "scripts/ui/staff_management_panel.gd": "01443ef0b348a6aecfd453f2c25045753891ea12d89138cd40b37d5eaf8f3cf6", + "scripts/ui/supply_management_panel.gd": "18036066d69fe0d6ef7fb578e4db899c2ab958176cfabe6918b9160e70273d58", + "scripts/ui/visual_theme_profile.gd": "2a251a94d4a18c2ddd9e307cb810d1f327c12f2f15cd3bed91e65aa499258ceb", + "scripts/world/facility_infrastructure_projector.gd": "e329019aa54f315df6a8e3c899770ab8701578b59583e88a027b815780bc1400", + "scripts/world/legacy_stair_service.gd": "e6899ee68894a59af061056ceb8673a772cdcc80a63dab5b4d54309429c33c2c", + "scripts/world/path_service.gd": "c09f5220cff1664fdd47ffd55f6750dcb0393691cbb0d13ea6238d7d32ca99c0", + "scripts/world/route_subslot_path_adapter.gd": "d8b98918c038855e0a4c4cf039853e4781718ccb0ca3b7516ed354d9b898ebf5", + "scripts/world/spatial_subslot_service.gd": "6238843f3f53e8fa21e325a8152e9a77aca97aebe495a05fb19d37ee62d549ae", + "scripts/world/world_service.gd": "666d96378d053fbef0969ddaa37cab30a60f32e2253043e00f36c402ef63a2a8", + "tests/business/test_business_closure.gd": "7b13a0dc13d1276cce7515973af47ad9653620e04221c43679e059e674b1d4fb", + "tests/business/test_business_operations.gd": "5b1975cb3e921febe5c23ae26b3c8445d92ab68de2479d23f86f565a934f04f5", + "tests/export/test_exported_opening.gd": "d980545e0c3d1477eb746da7a131556493cdba6f4e267f4491fe3973aed76fbf", + "tests/fixtures/facilities/invalid_service_profile.tres": "8bd02f6ca505a9bd65360a1ce99f961252bc201c5381e0033519ef4952f8f2e6", + "tests/mvp/test_mvp_content_catalog.gd": "2444ca2803881eac4b5e32724337cebfb8c5d86fb1dfcdba79e3c2a86ff2dde9", + "tests/mvp/test_operations_coordinator.gd": "7d1ce485242e7fd9232875eaf4c01fcb6ea579f1148f32a72873b87f410bc989", + "tests/mvp/test_original_midnight_settlement.gd": "916d0b4184842b1c8cc23c1a95db12558a8ba665b251aa6a383a42995eceaa30", + "tests/mvp/test_progression_service.gd": "f97895fceacc42fbbe14f3e77972ef980abf8f63ca9a5c8c8d4003900305b0aa", + "tests/mvp/test_runtime_content_progression.gd": "ec94db8462b30daf9fc4a1dd8bd3d7568937e2908f5d95afa073b8183902b6fc", + "tests/mvp/test_runtime_operations_acceptance.gd": "2ae592cf5ac555cde8546bdfccd9e96dfd0f2f8b93bd4ee09f9c12974e75552a", + "tests/mvp/test_runtime_operations_integration.gd": "4fc5d868924af7cf46db3fa6e0d8fc6b15ebd6ab96035758b4df790ad08d005c", + "tests/performance/fixtures/linear_path_reference.gd": "406b664557e99a8fc9903b9431f708e8e6372c3c56bf1665dd1908485c9bd528", + "tests/performance/fixtures/simulation_digest_v1_reference.gd": "64b5c099f8b4ff6e855c80513f150cfdb0e9c6d0f816fd02ec2660d569550928", + "tests/performance/fixtures/uncached_preview_reference.gd": "5ba415527f1ecc5cb6d6248ba75014ae91df86d01e7f9c35b8834f378c0c69d1", + "tests/performance/perf_mvp_001_harness.gd": "63519cf3723d084fa9c30425e49eb210a54a23fd1987df910621f8d12df9e413", + "tests/performance/test_digest_performance.gd": "20d1849f0f1b973ac3942d923ea20206cff16beacc0da36ba4583fb5670560b7", + "tests/performance/test_perf_mvp_001.gd": "1a4bc20ea4810924e8e64bc5fec1b44fc327f1676200e978a77577d628675e5b", + "tests/performance/test_system_optimization.gd": "374ff4b3ba78b7a724a3620d683db3835fd2a545f1de11606fd4969a3c6ede95", + "tests/run_tests.gd": "4c8ef388e2349ff975a2af7260da0aa02231a6036ea71b4000f2bb0e6f4083d5", + "tests/s0/test_command_transaction.gd": "1f1559646a38940db027ee83b2622f317e9b9c6c6f7d4b86f41f866eabb7e75d", + "tests/s0/test_definition_registry.gd": "21734d25641eaf906e0261a0ded634874aaf8ef987c4aea704c147d64487e33e", + "tests/s0/test_determinism.gd": "a397d2b33a2c8abfae76350d8ee65b967bbc1625186a865dffd776b421cc7520", + "tests/s0/test_domain_events.gd": "e16700219d5f3482b967de3216d4ace9853e47a69c91dcfd57d003b1d34c1da2", + "tests/s0/test_kernel_and_save.gd": "6d13f83672f8273ec6ddc47c88de1ce3916f4126af022be8bc562f5dd1d302a7", + "tests/s0/test_runtime_integration.gd": "9c15f4de6129fd4a225d6629c1ada67141685d3ff90456cbbe7b73c45a269b7b", + "tests/s0/test_stable_id_generator.gd": "2fac546b4788650b69832b2336d9d46f7b8b343b660d687256d98a64ef4b823c", + "tests/s1/test_construction_service.gd": "ce63074b84cb75d710eb3650701a9cb2acf920ff607e41be645904fc2395e2c3", + "tests/s1/test_excavation_service.gd": "64dacd14dbe0bc94368879daa130e4861ff43cc63fac6bb02e4e64091ee9a750", + "tests/s1/test_facility_infrastructure_projector.gd": "cb74f878cd709e80f53c74fa646daa138577f9d8ca18668e5178126c6e899b39", + "tests/s1/test_path_congestion.gd": "709bb680f04862fd774644b0b425150977f54346ca76f6223663c414e00f33bd", + "tests/s1/test_path_service.gd": "0f48bb44c27e32f08477b9aed61a0db5c8808219490928f734c3570a07efb251", + "tests/s1/test_route_subslot_path_adapter.gd": "573fcfb1e2f8e8781703e29d37600730dc54c77fda11a4630401a8b9e9259445", + "tests/s1/test_runtime_infrastructure_integration.gd": "b4d34870ff6573973cfe7069e02105630f24b74ae13862dc91cf454ad5ddbe7f", + "tests/s1/test_runtime_world_construction.gd": "65924a9b69cd27b5923c5464bd44ab839b26fe39a51b3580e691805d5a9e0e5b", + "tests/s1/test_spatial_subslot_service.gd": "06f4f69438a280ef942bbefdb9caaef1ba59e7dfd0eab71469ca29d090a8ad58", + "tests/s1/test_world_service.gd": "a5e165886f161b8bc71722ac7facdd17704bc6274c88dfe7fd2229f7f2b3f2b7", + "tests/s2/test_economy_service.gd": "cfe6196c0da9e39d95ae0e39f0c5404e321f4fa8be3602b58d4bf6cd3fef3969", + "tests/s2/test_facility_pricing.gd": "e658a7421aaef08c1c6f9a1bce42cb819ae39ee8ec0da72547b42400688c1a98", + "tests/s2/test_facility_service.gd": "e5a0ceef45d2fcae110f777ddb3c678374b7c9110cd40f743f88c578f0ed2d34", + "tests/s2/test_inventory_logistics.gd": "ef8b87e721f57210da02b8cd0822f58e1a4c1cdaa19fe56301a03a87891e73d6", + "tests/s2/test_kitchen_production.gd": "e2aeb1fdbc8d1dfe9400646693a0e97919b3b86cb74a4f000ebeba01bfbee626", + "tests/s2/test_ledger_backed_resources.gd": "26c2c9b883ee1448345d5555ac3e97293c3bf273b42668c11a6bdc23e1184dd3", + "tests/s2/test_mana_network_service.gd": "b0a21ea663667979c109cf6fd2efff717c284398071948c897b9e99090ce12fc", + "tests/s2/test_original_facility_parameters.gd": "3d5cc5f13f986f8cea9500a7f8d48d8f5803b8d0bee5eb0e7c44b3218a2e3d7b", + "tests/s2/test_runtime_operations_cycle.gd": "43bb6155dbcd8e21c01a57a62d13e2ab59edd1f3b23ebd14485f1273b15a065e", + "tests/s3/test_guest_service.gd": "0d10b68512685d2ed7d5c0c858952009333e0e47f348ace4b4ccdd0b21d23647", + "tests/s3/test_runtime_actor_cycle.gd": "cbad1505c735f3dab4596f7ad823d6501faf0cea4b9b91cc2b6ff0422296a2de", + "tests/s3/test_runtime_actor_hardening.gd": "1cdb1706646e70d99ac77530416ca5681582fc6e76ced455023bf5b674efffa5", + "tests/s3/test_runtime_path_traffic.gd": "6336a2291754bc2a2f3d83a6de96959bbcdc68f749fa362c0096fb4a26b2f41c", + "tests/s3/test_runtime_staff_assignment.gd": "f6004ab1601e2910268dd5d02923c32eb697fb587ad74da1f06ca4de6c21713c", + "tests/s3/test_staff_payroll_operations.gd": "05c817b7e891ac1649072ad33102561e26280b9bdd34f6f3831c7da055044f8c", + "tests/s3/test_staff_service.gd": "1f41ec1dc91f3bd3739ad2672b2921070fdde3a2878bc55ec6bd62101f0ac926", + "tests/s4/test_placement_risk_preview_service.gd": "165eddf9a5c51d8d9eeb85ea89e21b1ab6067b7ee057ee0fe5b189e97a71c1ad", + "tests/s4/test_relationship_event_service.gd": "b65afa74a67985059cc9e8e19684405324cfcdde277a21d7eba6062c968b24c5", + "tests/s4/test_runtime_relationship_events.gd": "52c9df100bd85fc49b62b80d214f84750c907330255a909a83a8dbd8195aac39", + "tests/s5/test_authority_service.gd": "983ac275dc4f40f7c8058831b7239f1f4b4e2b3f12da0bbb6ea5e8ad69afe2c5", + "tests/s5/test_defense_service.gd": "c6ab9eff8bd54a26a4b0f7ba1e95817ce03f8a8d83c15c3ca3fff450effd4db1", + "tests/s5/test_runtime_authority_cycle.gd": "33952b6253c77410c7997ddbf37e039f79bcc0ae283da76c3580ec166f928273", + "tests/s5/test_runtime_defense_cycle.gd": "ad69856ca5d0ec0fc15e4dea65d04cd1069880613d7fa901b0a787aafbc5ddbf", + "tests/s6/test_performance_budget.gd": "1ae5490637d73b519bf9ed0e00bda0e034d5ea0e589493bcf8886e4d1ff63689", + "tests/s6/test_runtime_save_lifecycle.gd": "de94d588d6ce204aa8d0221c2989b8dd900cf4e36ca51f83b464518a382a887f", + "tests/s6/test_save_operations.gd": "199df0b7fcc3d212cd32c79e0108dd9e5b83f9895f07553b325571c7674ed96c", + "tests/staff/test_rescue_elevator.gd": "bbbab6c56b740b5b9d7ccf092152a037d33c597c9af5dcf2125b54ef3282abe3", + "tests/staff/test_shift_rush.gd": "af3d47d848c1220741992612e57736df4af05969d424b79829d9977c774e8149", + "tests/staff/test_staff_commute.gd": "0837c92a49f8f83c03ca820eaeb23f7b1dfbc1072f8eeffcb557bb6b57b20c49", + "tests/staff/test_staff_rescue.gd": "8d8c02444c35b2c41c6db42ac0195dd614835541235cd92641ca9b220f59c44c", + "tests/staff/test_staff_rest.gd": "fe9b9d2a8a9051850980a393b8c06b8be41de8a82d5efc998c69df3a06cf54ec", + "tests/supply/test_normal_supply_chain.gd": "b481f2ae38c98eac78bd310902a85ba47e5d1d442078944a968ab3ea15f6d142", + "tests/supply/test_supply_logistics_bridge.gd": "dc745130fe155e27868a281e3f6a1b68afafaa5ba0b36723ef1b6bc49f388611", + "tests/supply/test_supply_management_service.gd": "de70213e9a07cb461614114e55d3b5371249a36250222a6eb48654103faea3ce", + "tests/supply/test_supply_save_migration.gd": "f781d2657fa7f004fa6efa9bfe3e829170c27ed269391e844f6a527935586831", + "tests/transport/test_elevator_service.gd": "e70de48a8e6f7928690d0495110451c26d9dec180c2dd7033c793155f099c2f6", + "tests/transport/test_runtime_stairs.gd": "f2f2d87dcf20d08fdfa721985fe60eecbaabbd0b2d1f5a90971f859f4354c3b9", + "tests/transport/test_runtime_transport.gd": "8b1d0fba1d27fbae2c703e030144bbff0ca008dca5292b7ea07f80e9a73bf96a", + "tests/transport/test_runtime_transport_comparison.gd": "ba577397fa8db5a6cf92cfd398dd7a5ece49e6b59856f14772b67b51ddc0bde9", + "tests/transport/test_transport_boundaries.gd": "1a5a647c45ffab674a8f58b8bc72692c5a30090c9b99a1e6f67194bbe3586b34", + "tests/transport/test_transport_comparison.gd": "7e80e9ef80500278c56f0d1ada5c911f43e3c0a9bc370045fdbebd33c2f02fd5", + "tests/transport/test_transport_density_migration.gd": "b30bf5c58b567ca711be9d9f130b37e114c22c7c69d5b7b581ad8dda2a690b50", + "tests/transport/test_transport_save.gd": "c09d4c8abcd2af39eea173c1f8a8789ca6a3f262953f65372469af29eef6a3d5", + "tests/ui/capture_main_dashboard.gd": "dcaeb97ef2f8a5910d2e8b5eb8aa7f6bf5a5a4d9128e0afab3e14eb16100117c", + "tests/ui/capture_pixel_tower.gd": "3a2eabc841a3208a14b06d2a619c89c0294c72538e0f009fec1b18d1448b998d", + "tests/ui/test_business_management.gd": "182d6869b37a2643cdc4e4cb5c7eca95ed9fb36853a2069d630379ba5643d7f7", + "tests/ui/test_elevator_management.gd": "1d7ed05d39f34d91f2ef485f3930de26e7136224f07ff9fec1b768726a365d68", + "tests/ui/test_first_operating_day.gd": "7a8d8e101e8e93cfe50c680b8c49f4295b2455321facdf9b959db9de244536ce", + "tests/ui/test_guest_observation.gd": "d163141420a04856730d3819efca9dce0aad7c2126866a82664120bc68ca272f", + "tests/ui/test_main_dashboard.gd": "34be7dbfd3878c8090aaaf0e069dd0f07bf9ead2eb7928e9cb37c085bfa4c0ad", + "tests/ui/test_original_facility_controls.gd": "7aed4ad0564e3690f64bc3ab3149e6b36df6ccd8bea0530d1ecd8dd1f87fb576", + "tests/ui/test_pixel_tower.gd": "93bdbb195f2ceec07dbe052e0d0a3017d84b56f1e8ec46b87be8d98105ec0f88", + "tests/ui/test_room_presentation.gd": "f7cc6e96c490b0f9b258e8ca087beaae4f540fe4c02c6a04a6460c8503a22be5", + "tests/ui/test_staff_management.gd": "f22b87155ede7d2f33f15331d79ec2af699e163f04444d270346f911f355ac53", + "tests/ui/test_staff_rescue.gd": "2ab4a58ef7cce5d1b2e03250dbe92ed6957f38c71e538cdc4f425fa198193bc8", + "tests/ui/test_staff_rest.gd": "accfc9c0ee2eed5192085d01d19bbdce65f6171551b22e5a0b564f68c7080eaa", + "tests/ui/test_stair_transport_details.gd": "d8b1539cf81bcf1105ad66fea5236e779e03fdf1eb59168e910412ec1da797b2", + "tests/ui/test_supply_management.gd": "c24f98fc9a9329c7f2983b3048009fba2169c45ea413137ca2fbebee802b9c35", + "tests/world/test_elevator_topology.gd": "4710a6bba103d82a4d2febea1a2c30d029c0f1ccd2a3d9a6c1712b4fb372033b", + "tests/world/test_legacy_stairs.gd": "258d759537aec8b7b90cab107985c9c6a16f10e85ae7ae92a9c54cc94417d48e", + "tests/world/test_path_transit_density.gd": "8de5fc8bdf8631ff41fcd4862cea40daa3316040f381f182dc6fe09dba9f3cb9", + "tests/world/test_path_vertical_policy.gd": "140cdf552309f89cd93bf1a4d38827a671a66461203f0858f1c2651a8e6e8473", + "tests/world/test_stair_infrastructure.gd": "f6d1a29b43875b7b1fc945611c2ed15fbdcb8bb7220addf283b30fedf2ccba28" +} diff --git a/docs/plans/original-completion-2026-09-29/execution/upkeep-policy.json b/docs/plans/original-completion-2026-09-29/execution/upkeep-policy.json new file mode 100644 index 0000000..80f9c77 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/upkeep-policy.json @@ -0,0 +1,24 @@ +{ + "status": "confirmed_by_user", + "date": "2026-09-29", + "source": "async question reply", + "answer": "保有時間で按分・不足分繰越(推奨)", + "ownership": "completion inclusive to demolition exclusive; closed and suspended included", + "settlement": "00:00", + "insufficient_cash": "recognize full expense and carry unpaid balance forward", + "rounding": "integer gold-minute units; fractional gold retained across periods", + "legacy": "start accrual at restored minute; no retroactive charges", + "implementation_choices": { + "payment_priority": "existing payroll first, then upkeep in stable facility ID order", + "production_rates": "User selected initial rate: ceil(construction gold * 0.005), minimum 1 G/day for 19 registered facilities; trial and adjustment authorized" + }, + "outstanding": [ + "full regression completion" + ], + "rate_decision": { + "source": "user async reply", + "answer": "0.5%案を初期値として試験・調整する", + "date": "2026-09-29" + }, + "legacy_calendar": "preserve historical 06:00 payroll/financial close; independently settle maintenance at calendar midnight with atomic retry" +} diff --git a/docs/plans/original-completion-2026-09-29/execution/upkeep-rate-proposals.json b/docs/plans/original-completion-2026-09-29/execution/upkeep-rate-proposals.json new file mode 100644 index 0000000..8854b70 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/upkeep-rate-proposals.json @@ -0,0 +1,160 @@ +{ + "status": "user_selected_0_5_percent_initial_balance", + "source": "existing registered construction gold; proposed coefficients not original specification", + "rounding": "ceil with minimum 1 G per day", + "facilities": [ + { + "file": "data/facilities/reception.tres", + "name": "受付", + "construction_gold": 500, + "daily_0_5_percent": 3, + "daily_1_percent": 5, + "daily_2_percent": 10 + }, + { + "file": "data/facilities/standard_room.tres", + "name": "一般客室", + "construction_gold": 350, + "daily_0_5_percent": 2, + "daily_1_percent": 4, + "daily_2_percent": 7 + }, + { + "file": "data/facilities/cafeteria.tres", + "name": "大衆食堂", + "construction_gold": 800, + "daily_0_5_percent": 4, + "daily_1_percent": 8, + "daily_2_percent": 16 + }, + { + "file": "data/facilities/kitchen.tres", + "name": "厨房", + "construction_gold": 700, + "daily_0_5_percent": 4, + "daily_1_percent": 7, + "daily_2_percent": 14 + }, + { + "file": "data/facilities/public_bath.tres", + "name": "大浴場", + "construction_gold": 1200, + "daily_0_5_percent": 6, + "daily_1_percent": 12, + "daily_2_percent": 24 + }, + { + "file": "data/facilities/warehouse.tres", + "name": "倉庫", + "construction_gold": 450, + "daily_0_5_percent": 3, + "daily_1_percent": 5, + "daily_2_percent": 9 + }, + { + "file": "data/facilities/staff_waiting_room.tres", + "name": "待機室", + "construction_gold": 400, + "daily_0_5_percent": 2, + "daily_1_percent": 4, + "daily_2_percent": 8 + }, + { + "file": "data/facilities/security_station.tres", + "name": "警備詰所", + "construction_gold": 900, + "daily_0_5_percent": 5, + "daily_1_percent": 9, + "daily_2_percent": 18 + }, + { + "file": "data/facilities/tavern.tres", + "name": "酒場", + "construction_gold": 1000, + "daily_0_5_percent": 5, + "daily_1_percent": 10, + "daily_2_percent": 20 + }, + { + "file": "data/facilities/massage_room.tres", + "name": "マッサージ室", + "construction_gold": 800, + "daily_0_5_percent": 4, + "daily_1_percent": 8, + "daily_2_percent": 16 + }, + { + "file": "data/facilities/guest_elevator.tres", + "name": "客用昇降機", + "construction_gold": 1500, + "daily_0_5_percent": 8, + "daily_1_percent": 15, + "daily_2_percent": 30 + }, + { + "file": "data/facilities/service_elevator.tres", + "name": "業務用昇降機", + "construction_gold": 2200, + "daily_0_5_percent": 11, + "daily_1_percent": 22, + "daily_2_percent": 44 + }, + { + "file": "data/facilities/staff_door.tres", + "name": "従業員ドア", + "construction_gold": 100, + "daily_0_5_percent": 1, + "daily_1_percent": 1, + "daily_2_percent": 2 + }, + { + "file": "data/facilities/guest_corridor.tres", + "name": "客用通路", + "construction_gold": 20, + "daily_0_5_percent": 1, + "daily_1_percent": 1, + "daily_2_percent": 1 + }, + { + "file": "data/facilities/service_corridor.tres", + "name": "裏通路", + "construction_gold": 25, + "daily_0_5_percent": 1, + "daily_1_percent": 1, + "daily_2_percent": 1 + }, + { + "file": "data/facilities/adult_private_room.tres", + "name": "成人向け個室", + "construction_gold": 1500, + "daily_0_5_percent": 8, + "daily_1_percent": 15, + "daily_2_percent": 30 + }, + { + "file": "data/facilities/rescue/infirmary.tres", + "name": "医務室", + "construction_gold": 640, + "daily_0_5_percent": 4, + "daily_1_percent": 7, + "daily_2_percent": 13 + }, + { + "file": "data/facilities/infrastructure/guest_stairs.tres", + "name": "客用階段", + "construction_gold": 40, + "daily_0_5_percent": 1, + "daily_1_percent": 1, + "daily_2_percent": 1 + }, + { + "file": "data/facilities/infrastructure/service_stairs.tres", + "name": "業務用階段", + "construction_gold": 50, + "daily_0_5_percent": 1, + "daily_1_percent": 1, + "daily_2_percent": 1 + } + ], + "selected_percent": 0.5 +} diff --git a/docs/plans/original-completion-2026-09-29/execution/upkeep-rate-validation.json b/docs/plans/original-completion-2026-09-29/execution/upkeep-rate-validation.json new file mode 100644 index 0000000..3e84381 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/upkeep-rate-validation.json @@ -0,0 +1,120 @@ +{ + "decision": "user-approved 0.5% initial balance", + "checked_at": "2026-09-29T01:23:23.240847+00:00", + "facilities": [ + { + "file": "data/facilities/reception.tres", + "expected": 3, + "actual": 3, + "passed": true + }, + { + "file": "data/facilities/standard_room.tres", + "expected": 2, + "actual": 2, + "passed": true + }, + { + "file": "data/facilities/cafeteria.tres", + "expected": 4, + "actual": 4, + "passed": true + }, + { + "file": "data/facilities/kitchen.tres", + "expected": 4, + "actual": 4, + "passed": true + }, + { + "file": "data/facilities/public_bath.tres", + "expected": 6, + "actual": 6, + "passed": true + }, + { + "file": "data/facilities/warehouse.tres", + "expected": 3, + "actual": 3, + "passed": true + }, + { + "file": "data/facilities/staff_waiting_room.tres", + "expected": 2, + "actual": 2, + "passed": true + }, + { + "file": "data/facilities/security_station.tres", + "expected": 5, + "actual": 5, + "passed": true + }, + { + "file": "data/facilities/tavern.tres", + "expected": 5, + "actual": 5, + "passed": true + }, + { + "file": "data/facilities/massage_room.tres", + "expected": 4, + "actual": 4, + "passed": true + }, + { + "file": "data/facilities/guest_elevator.tres", + "expected": 8, + "actual": 8, + "passed": true + }, + { + "file": "data/facilities/service_elevator.tres", + "expected": 11, + "actual": 11, + "passed": true + }, + { + "file": "data/facilities/staff_door.tres", + "expected": 1, + "actual": 1, + "passed": true + }, + { + "file": "data/facilities/guest_corridor.tres", + "expected": 1, + "actual": 1, + "passed": true + }, + { + "file": "data/facilities/service_corridor.tres", + "expected": 1, + "actual": 1, + "passed": true + }, + { + "file": "data/facilities/adult_private_room.tres", + "expected": 8, + "actual": 8, + "passed": true + }, + { + "file": "data/facilities/rescue/infirmary.tres", + "expected": 4, + "actual": 4, + "passed": true + }, + { + "file": "data/facilities/infrastructure/guest_stairs.tres", + "expected": 1, + "actual": 1, + "passed": true + }, + { + "file": "data/facilities/infrastructure/service_stairs.tres", + "expected": 1, + "actual": 1, + "passed": true + } + ] +} diff --git a/docs/plans/original-completion-2026-09-29/execution/upkeep-source-hashes.json b/docs/plans/original-completion-2026-09-29/execution/upkeep-source-hashes.json new file mode 100644 index 0000000..1060ba4 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/upkeep-source-hashes.json @@ -0,0 +1,32 @@ +{ + "autoload/simulation_runtime.gd": "d30cc751636ead7f5a9132276df94332770707018322e403f94bd05721464c2b", + "scripts/economy/facility_upkeep_accrual.gd": "229655648c67c5a8b2bfa2c5faeb0534984939d4cf6743b7721f5cf271ac108c", + "scripts/economy/facility_upkeep_settlement.gd": "26ccebf26b19089557aff1a70c53bc7686d878c75c5d3ded1b0e6df33ece50af", + "scripts/operations/operations_coordinator.gd": "6baf78d5087ae6c9a2dd31257654eaa16bd5095f1fc39b5a27d0abc70f213d0b", + "scripts/ui/operating_ledger_panel.gd": "75086e39fd76c66dc5e46e48e5d2e0e2c1253cb6a68f3924f20804e7502cd54a", + "scenes/main/main.gd": "15641b783a06a8c4e3b7539e5e63207b1997176fcdd18992cf698fd53d99f5a9", + "data/facilities/reception.tres": "bed3de120a0e98c539771d1bb189111d095712f8ae25dfdf7cb2983efc0dee9f", + "data/facilities/standard_room.tres": "20f12c6298a073631f9fbed369cc0a04ce60487d660580b8fe8def45cf0c4ac6", + "data/facilities/cafeteria.tres": "86d540fe2d78b9dcdbe5a0af6e4a001f4cec54934636ed01deb25a943c79128a", + "data/facilities/kitchen.tres": "38197aa8a3e90c03a82e9ccdc88bb444af4eeb0349c41a39e0bdd7b188e7d5d3", + "data/facilities/public_bath.tres": "8ec259f48ed75c8ab2e5154a1caccf19f6be04331adbe774b877f0a2d8b2f778", + "data/facilities/warehouse.tres": "d0fcf0528da66da7b354ab5efaafe2483a7a76e05b324db255700ef73cdfc791", + "data/facilities/staff_waiting_room.tres": "5c7a2f7f7c508102c4ee89401ad6348191b57d1ff65538acb9d450e464a3a46f", + "data/facilities/security_station.tres": "69f3f518df7714d00c8e954e9c7405aa44b683dd274181cc3da832cc956815b3", + "data/facilities/tavern.tres": "eb494ae6ab0d1b9e88ae0a38944609702e43c81a370a29cfb030af9ae46f523c", + "data/facilities/massage_room.tres": "75ecf845fb3cfa8d3150f6b0dc90e901953469af512611384b70cc4878d21e85", + "data/facilities/guest_elevator.tres": "ad4fb3c439ac4ed90fe52f07e29ed7b4ff654c54d1c400c7c8e226da77127af7", + "data/facilities/service_elevator.tres": "35d552d5b96065413feccb50847e3cd7173e673896ce65afba238f90abc25132", + "data/facilities/staff_door.tres": "a993058bfd809ccdbbb9a61b9850259d7bf6e60003e57ccfa3972f6c29170752", + "data/facilities/guest_corridor.tres": "d77ccf267e20a26ed70527d29e8d54e6fcab1df213f02d5a4e28ead4f705276b", + "data/facilities/service_corridor.tres": "5d79fbde201d657740efcba924f595bd7075e069b344d708e424dbebbdc2f254", + "data/facilities/adult_private_room.tres": "d8501586416b5c57a8b3b3b2afd1c0143428d8a4fa1df7112e995bd36649df6d", + "data/facilities/rescue/infirmary.tres": "c3e861533971e369068ae9e42f71b660fa4ab3893c7c1f798fc367893befd943", + "data/facilities/infrastructure/guest_stairs.tres": "757c687b1ebe2a3f237fb28092cd5f01591f15b2bcd1b4e639042a24635a92c6", + "data/facilities/infrastructure/service_stairs.tres": "637217d867bcc99669d27c54b6d2c391c82a4a17356ab9a80c897ccaee38622c", + "tests/mvp/test_runtime_upkeep.gd": "eae48823d58d78c419d29728e33a5559fbe5131b49e33d04ca4e6fa3a721c29b", + "scripts/ui/business_management_panel.gd": "3585fc1aa4f597606b30a6b8b04cd378fbac02561fdbf890d5527564fed5e4a9", + "tests/ui/test_upkeep_closed_ui.gd": "ef60b9c80898a3fd014f6638c8e6a4850ee32350e04d83e476f3f262de0221fb", + "tools/run_upkeep_ui_flow.py": "32d7d9e0075b70749696db01c2c27a588a35cf0ed89d8ed942cbeaece17863c9", + "Makefile": "01c39efefc0fad710a9c6a3ffa27b741f3660c23eb1939d45b9140bd07e39922" +} diff --git a/docs/plans/original-completion-2026-09-29/execution/upkeep-validation-progress.json b/docs/plans/original-completion-2026-09-29/execution/upkeep-validation-progress.json new file mode 100644 index 0000000..fb4d5fb --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/upkeep-validation-progress.json @@ -0,0 +1,108 @@ +{ + "recorded_at": "2026-09-29T01:59:32.325356+00:00", + "status": "in_progress", + "runner_session_id": null, + "completed_initial_scripts": 93, + "initial_failures": [ + "old version fixture expectations: repaired and rechecked", + "normal supply timeout600: previous baseline791; retry1800 in progress" + ], + "rechecks": [ + { + "test": "tests/mvp/test_runtime_content_progression.gd", + "log": "recheck-content.log", + "exit_code_observed": 0, + "passed": true + }, + { + "test": "tests/mvp/test_runtime_operations_acceptance.gd", + "log": "recheck-acceptance.log", + "exit_code_observed": 0, + "passed": true + }, + { + "test": "tests/s1/test_runtime_infrastructure_integration.gd", + "log": "recheck-infrastructure.log", + "exit_code_observed": 0, + "passed": true + } + ], + "focused_checks": { + "test_facility_upkeep_accrual": 38, + "test_facility_upkeep_settlement": 51, + "test_runtime_upkeep": 40, + "test_upkeep_construction_boundaries": 17, + "test_upkeep_loss_streak": 15, + "test_upkeep_legacy_midnight": 23, + "test_upkeep_inspector_rendered": 7 + }, + "focused_total": 191, + "make_check": "repayment-check.log passed, exit code 0", + "all_pass_claim": false, + "next": "Upkeep baseline complete with one UI expectation repair (60 checks PASS). Mana integration now changes Runtime; separate targeted checks follow. Windows, long-term balancing and P05 debt/tax remain.", + "previous_goal_turn_classification": "progress: three-day 0.5% trial reconciled and Runtime next-day repayment verified", + "reference_supply_retry_session_id": null, + "approved_rate_trial_session_id": null, + "current_related_regression_session_id": null, + "legacy_midnight_test_session_id": null, + "initial_suite_completed": true, + "initial_suite_scripts": 93, + "initial_failures_resolved": true, + "initial_suite_scope": "exploratory regression across active edits; not a frozen-source release gate", + "reference_supply_result": { + "exit_code": 0, + "seconds": 796.36, + "timeout_seconds": 1800 + }, + "current_related_regression_passed": true, + "current_loss_streak_session_id": null, + "approved_rate_percent": 0.5, + "approved_rate_trial_started": true, + "current_loss_streak_result": { + "exit_code": 0, + "checks": 15, + "log": "current-upkeep-loss-streak.log" + }, + "runtime_repayment_session_id": null, + "runtime_repayment_result": { + "exit_code": 0, + "checks": 40, + "log": "current-runtime-repayment.log", + "scope": "Runtime clock and ledger refund fixture; not natural sales" + }, + "approved_rate_trial_result": { + "exit_code": 0, + "failed": false, + "seconds": 790.6, + "timeout_seconds": 1800, + "initial_rate_percent": 0.5 + }, + "approved_rate_trial_reconciliation": { + "approved_rate_percent": 0.5, + "passed": true, + "cash_reference": 2855, + "cash_trial": 2805, + "maintenance_expense": 50, + "maintenance_paid": 50, + "upkeep_arrears": 0, + "scope": "Three simulated days, one normal inn layout and fixed seed; not long-term balancing or Windows acceptance.", + "checks": 84 + }, + "closed_ui_fresh_process": { + "passed": true, + "write_checks": 19, + "read_checks": 10, + "rendered": true, + "evidence": "closed-ui-process-flow/result.json", + "scope": "UI handlers plus native rendering; not manual pointer input; suspension not in this flow" + }, + "frozen_regression_session_id": null, + "frozen_regression_source_files": 240, + "mana_next_increment": "MANA_INTEGRATION.md: zero-reserve gap reproduced with actual construction; numeric trial values awaiting user response. No production mana changes during frozen regression.", + "parallel_independent_progress": "P22 initial inventory: 290 media files, 360 facility usage slots, 84 named-character slot groups; consistency 8 checks passed; no completed asset claim. execution/assets/README.md", + "frozen_regression_complete": true, + "frozen_regression_scripts": 98, + "frozen_regression_initial_failures": 1, + "frozen_regression_repairs_passed": true, + "frozen_regression_verified_source_files": 240 +} diff --git a/docs/plans/original-completion-2026-09-29/execution/weather-validation-progress.json b/docs/plans/original-completion-2026-09-29/execution/weather-validation-progress.json new file mode 100644 index 0000000..8a5d519 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/execution/weather-validation-progress.json @@ -0,0 +1,50 @@ +{ + "recorded_at": "2026-09-29T02:46:08.008002+00:00", + "status": "weather_runtime_schema_gate_passed", + "runtime_checks": 16, + "rendered_dashboard_checks": 135, + "image_inspected": true, + "image": "artifacts/weather-2026-09-29/storm-hud.png", + "regression_session_id": null, + "regression_evidence": "artifacts/weather-2026-09-29/integration-regression/results.json", + "source_sha256": { + "autoload/simulation_runtime.gd": "a5650c32659e839208a65b7153dd8664ab8c75ebb6cc1b1b9ec370139bae922e", + "scenes/main/main.gd": "55bc962d0ee84c10ee4451e2c94fe2203549b62e2c4dde9a2c5c36d2afb5cd11", + "scripts/environment/weather_timeline.gd": "7a5c82061171b803e288c6726991d03a254b78a21face870ca349c493ae393b0", + "tests/environment/test_runtime_weather.gd": "79534cfe8dbcf66cf22e7ad690866c3f5b9b93679869be68698901664ac8f7bc", + "tests/ui/test_main_dashboard.gd": "3986c60227003a1fda4f637e105049b06d52772d081417c60859cf0fb7c720e8", + "tests/fixtures/pre_upkeep_runtime.gd": "89b3077e96f327bbd57f60da977905c6d49a1b78ae4c4619847eb1b138549aed" + }, + "runtime_schema": 11, + "previous_upkeep_schema": 10, + "natural_weather_generation": false, + "next": "Continue P08 safe dimension migration preflight while weather generation and mana numeric policies await user input. Weather natural generation and rain/fog effects remain incomplete.", + "known_regression_failure": { + "test": "tests/s1/test_runtime_infrastructure_integration.gd", + "cause": "test-local RUNTIME_SNAPSHOT_VERSION constant remains 10", + "repair_after_frozen_run": [ + "tests/s1/test_runtime_infrastructure_integration.gd: constant to11 and current description", + "tests/mvp/test_runtime_operations_acceptance.gd: constant to11; include acceptance rerun" + ], + "additional_cause": "Accelerated Operations test advanced clock/upkeep but not new weather boundary; four restore-related assertions failed. Added weather update before upkeep in the fixture." + }, + "recovery_disk_validation": { + "checks": 14, + "passed": true, + "evidence": "artifacts/weather-2026-09-29/recovery-validation.json" + }, + "generation_policy": "awaiting_user_reply: every6h clear60 rain25 storm5 fog10", + "previous_goal_turn_classification": "progress: Runtime11 and weather HUD integrated, related regression started", + "initial_regression_complete": true, + "initial_regression_scripts": 10, + "initial_regression_failed": 1, + "initial_regression_verified_files": 243, + "recheck_session_id": null, + "infrastructure_recheck": { + "checks": 162, + "passed": true + }, + "acceptance_clock_recheck_session_id": null, + "make_check_exit_code": 0, + "related_gate": "artifacts/weather-2026-09-29/integration-regression/final-gate.json" +} diff --git a/docs/plans/original-completion-2026-09-29/plan_validation.json b/docs/plans/original-completion-2026-09-29/plan_validation.json new file mode 100644 index 0000000..340a6d5 --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/plan_validation.json @@ -0,0 +1,49 @@ +{ + "package_count": 30, + "requirements": 75, + "unmapped_requirements": 0, + "supplement_count": 10, + "missing_dependencies": 0, + "dependency_cycles": 0, + "topological_order": [ + "P00", + "P01", + "P02", + "P03", + "P04", + "P05", + "P06", + "P07", + "P08", + "P09", + "P10", + "P11", + "P12", + "P13", + "P14", + "P15", + "P16", + "P17", + "P18", + "P19", + "P20", + "P21", + "P22", + "P23", + "P24", + "P25", + "P26", + "P27", + "P28", + "P29" + ], + "planning_estimate_person_days": [ + 180, + 390 + ], + "estimates_exclude": "素材・脚本・収録の制作量、外部待ち、実プレイ時間、予備費。暫定見積であり納期保証ではない。", + "source_audit_sha256": "a08c20235633a82f85528b070c1e074fc1c13102103fc35d42c36b1b0678a98c", + "self_review_items": 15, + "plan_revision": 2, + "validation_status": "PASS (plan structure only; not implementation acceptance)" +} diff --git a/docs/plans/original-completion-2026-09-29/requirement_coverage.json b/docs/plans/original-completion-2026-09-29/requirement_coverage.json new file mode 100644 index 0000000..f26bddd --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/requirement_coverage.json @@ -0,0 +1,777 @@ +[ + { + "requirement_id": "B-1.1", + "requirement": "基本情報・Windows・2D・20〜30時間/50時間以上", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P00", + "P01", + "P20", + "P26", + "P27", + "P28", + "P29" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-1.2", + "requirement": "地上/地下の二面性経営・観察介入・権威/富", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P01", + "P20" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-1.3", + "requirement": "建設→集客→収益→管理→拡張防衛", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P01", + "P04", + "P20", + "P21", + "P27" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-1.4", + "requirement": "二層・自律AI・渋滞・信仰と依存", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P01", + "P04", + "P20" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-1.5", + "requirement": "世界観・主人公・7ヒロイン3運命", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P01", + "P17", + "P20", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-1.6", + "requirement": "成人向けルートと経営的搾取", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P01", + "P13", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-2.1", + "requirement": "グリッド・Y正負・支持・領域拡張", + "current_status": "実装あり・全受入未検証", + "work_packages": [ + "P03", + "P08" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-2.2", + "requirement": "同座標Front/Back・半透明/ワイヤー表示", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P23" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-2.3", + "requirement": "客エリアと景観/清潔/騒音の満足度効果", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P07" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-2.4", + "requirement": "バックヤード・職員ドア・客通行禁止", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P12", + "P14" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-2.5", + "requirement": "覗き部屋の配置制約・通路寸断・証拠収集", + "current_status": "該当機能未実装", + "work_packages": [ + "P13" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-2.6", + "requirement": "地上/地下の採光・マナ・遮音・隠蔽ボーナス", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P06", + "P07" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.1a", + "requirement": "10秒=1時間、Pause/x1/x2/x3", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02", + "P28" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.1b", + "requirement": "アクティブポーズ中も建設・指示", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.2", + "requirement": "4時間帯と朝下り/夕上り/夜行動", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P04", + "P07", + "P15" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.3", + "requirement": "昼夜シフト・体力0気絶・救護・通勤混雑", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.4a", + "requirement": "毎日00:00に決算レポート", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02", + "P05" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.4b", + "requirement": "3回連続赤字でゲームオーバー", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02", + "P05" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-3.5", + "requirement": "雨・嵐・マナの霧・流行ニュース", + "current_status": "該当機能未実装", + "work_packages": [ + "P07" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.1", + "requirement": "所持金/戦利品・四欲求・忍耐/清潔/依存", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P03", + "P15" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.2", + "requirement": "最大欲求優先と距離/行列/価格選択", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02", + "P15" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.3a", + "requirement": "A*とエレベーター待ち時間コスト", + "current_status": "実装あり・全受入未検証", + "work_packages": [ + "P14", + "P28" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.3b", + "requirement": "4種垂直移動のゲーム内利用", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P14" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.3c", + "requirement": "重なり時速度50%低下", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02", + "P14", + "P28" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.4", + "requirement": "依存Lv3退店拒否・装備売却・性別別破産", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P15" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-4.5", + "requirement": "貴族/聖職者/エルフ/魔族のAI特性", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P07", + "P15" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-5.1", + "requirement": "一般雇用と奴隷の費用/耐性/売却区別", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P16" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-5.2", + "requirement": "体力/ストレス/忠誠/技能/ランダム特性", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P03", + "P16" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-5.3", + "requirement": "接客/裏方/救護/防衛等の職種", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P12", + "P16" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-5.4", + "requirement": "シフト休息と気絶回収", + "current_status": "検証済み(記載範囲)", + "work_packages": [ + "P02", + "P12", + "P16" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-5.5", + "requirement": "求人/市場/破産捕獲・退職金/売買価格", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P16" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-6.1", + "requirement": "ユニーク無料・高成長・解雇売却不可", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P17" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-6.2", + "requirement": "7ヒロインの指定固有効果とライバル", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P17" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-6.3", + "requirement": "基礎技能と業務EXP・Lv99", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P03", + "P17" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-6.4", + "requirement": "配置パッシブ・任意発動・ルート別スキル", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P17", + "P20", + "P21" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-6.5", + "requirement": "24時間勤務警告・衣装補正・限定離脱", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P17", + "P20", + "P21" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-7.1", + "requirement": "三感情・盾・決闘の3ルート運命", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P18", + "P19" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-7.2", + "requirement": "好感/淫乱/NTR・好感度=盾最大・淫乱軽減", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P03", + "P18" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-7.3", + "requirement": "同配置接触→盾破壊→NTR上昇→不可逆陥落", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P18" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-7.4", + "requirement": "3種決闘・勝利追放/隷属/盾回復・敗北確定", + "current_status": "該当機能未実装", + "work_packages": [ + "P19" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-7.5", + "requirement": "会話/デート/隔離・覗き・薬による各ルート", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P19", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-7.6", + "requirement": "履歴・妊娠/父親・刻印立ち絵", + "current_status": "該当機能未実装", + "work_packages": [ + "P19", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-8.1", + "requirement": "隣接/通路必須とLv拡張", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P03", + "P08", + "P09", + "P20" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-8.2", + "requirement": "表層全施設・飲食と厨房セット", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P09", + "P10", + "P11", + "P13" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-8.3", + "requirement": "裏層全施設と特殊設備", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P09", + "P12", + "P13" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-8.4", + "requirement": "EV/階段/ワープ/通路/職員ドア", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P14" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-8.5", + "requirement": "Lv解放の価格/食材/混浴/ハード/イカサマ設定", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P09", + "P20" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-9.1", + "requirement": "表金/裏金・マナ全館停止・資材", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P03", + "P05", + "P06" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-9.2", + "requirement": "全収益/人件費/維持/仕入/借金/ランク税", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P04", + "P05", + "P09" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-9.3", + "requirement": "受付/金庫/購買/闇銀行", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P05", + "P21" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-9.4", + "requirement": "依存客の娯楽優先・信用超過の破産拘束", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P15" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.1", + "requirement": "宿泊施設の寸法/層/ゾーン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P08", + "P10" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.2", + "requirement": "飲食施設の寸法/層/ゾーン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P08", + "P10" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.3", + "requirement": "衛生/娯楽施設の寸法/層/ゾーン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P08", + "P11" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.4", + "requirement": "アングラ施設の寸法/層/ゾーン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P08", + "P13" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.5", + "requirement": "バックヤード施設の寸法/層/ゾーン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P08", + "P12" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.6", + "requirement": "特殊施設の寸法/層/ゾーン", + "current_status": "該当機能未実装", + "work_packages": [ + "P06", + "P08", + "P13" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-10.7", + "requirement": "インフラ/防衛施設の寸法/層/ゾーン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P06", + "P08", + "P14", + "P21" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-11.1", + "requirement": "資源/時間/天候/Rank・下部切替・スクロールズーム", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P07", + "P23", + "P24", + "P28" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-11.2", + "requirement": "Policy/ドラッグ配置・欲求/思考/感情グラフ/盾", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P24" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-11.3", + "requirement": "カメラ入口・覗きアングル/撮影/介入", + "current_status": "該当機能未実装", + "work_packages": [ + "P19", + "P24" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-11.4", + "requirement": "グリッド/接続警告・フキダシ/ログ・カットイン", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P21", + "P23", + "P24" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-12.1", + "requirement": "二面性・高精細ドットとアニメ立ち絵", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P22", + "P23", + "P25", + "P26", + "P28" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-12.2", + "requirement": "魔法レンズ風の波紋切替", + "current_status": "該当機能未実装", + "work_packages": [ + "P23" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-12.3", + "requirement": "SD各モーション・立ち絵破損/汚れ/刻印差分", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P22", + "P25", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-12.4", + "requirement": "表裏UIスキン・BGMクロスフェード・環境音/SE", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P22", + "P23", + "P25" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-13.1", + "requirement": "立ち絵/CG/差分", + "current_status": "該当機能未実装", + "work_packages": [ + "P22", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-13.2", + "requirement": "SDモーション/エフェクト", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P22", + "P25" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-13.3", + "requirement": "Lv1〜3表裏タイル/背景", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P22", + "P25" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-13.4", + "requirement": "表裏スキン/感情/覗き素材", + "current_status": "一部実装・差異あり", + "work_packages": [ + "P22", + "P24", + "P25" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-13.5", + "requirement": "BGM/環境/操作/成人向けSE/声", + "current_status": "該当機能未実装", + "work_packages": [ + "P22", + "P25", + "P26" + ], + "verification_gate": "P27" + }, + { + "requirement_id": "B-14.1", + "requirement": "2026/03〜08 プロトタイプ", + "current_status": "工程要件・完了未認定", + "work_packages": [ + "P00", + "P01", + "P29" + ], + "verification_gate": "P29" + }, + { + "requirement_id": "B-14.2", + "requirement": "2026/09〜2027/02 アルファ", + "current_status": "工程要件・完了未認定", + "work_packages": [ + "P00", + "P01", + "P29" + ], + "verification_gate": "P29" + }, + { + "requirement_id": "B-14.3", + "requirement": "2027/03〜08 ベータ/成人向け", + "current_status": "工程要件・完了未認定", + "work_packages": [ + "P00", + "P01", + "P29" + ], + "verification_gate": "P29" + }, + { + "requirement_id": "B-14.4", + "requirement": "2027/09〜12 ポリッシュ", + "current_status": "工程要件・完了未認定", + "work_packages": [ + "P00", + "P01", + "P27", + "P28", + "P29" + ], + "verification_gate": "P29" + } +] diff --git a/docs/plans/original-completion-2026-09-29/work_packages.json b/docs/plans/original-completion-2026-09-29/work_packages.json new file mode 100644 index 0000000..49c93bc --- /dev/null +++ b/docs/plans/original-completion-2026-09-29/work_packages.json @@ -0,0 +1,3523 @@ +[ + { + "id": "P00", + "phase": 0, + "title": "原本・作業対象の固定", + "depends_on": [], + "size": "S", + "requirement_ids": [ + "B-1.1", + "B-14.1", + "B-14.2", + "B-14.3", + "B-14.4" + ], + "work": "指定Drive原本を再照合し差分を保存。ローカル制作コピーの全ソース・現在のセーブ形式・既存成果物を識別する。今回計画の原本は保存版であり最新取得済みとは扱わない。", + "acceptance": "原本SHAと変更履歴、制作対象、現在の実装証拠が再現可能。原本更新があれば影響要件を再評価。", + "decisions": [], + "state": "completed", + "estimate_person_days": [ + 1, + 3 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": {} + }, + { + "id": "P01", + "phase": 0, + "title": "原子要件と受入台帳", + "depends_on": [ + "P00" + ], + "size": "M", + "requirement_ids": [ + "B-1.1", + "B-1.2", + "B-1.3", + "B-1.4", + "B-1.5", + "B-1.6", + "B-14.1", + "B-14.2", + "B-14.3", + "B-14.4" + ], + "work": "75複合要件を単独判定できる条件へ分割。原本の各文、追加10項目、UI入口、Runtime、保存、試験、資産、完了責任を対応付ける。", + "acceptance": "全原本文に対応先があり、未設定・未実装・未検証が別状態。派生仕様の独自ルールを確定仕様に昇格させない。", + "decisions": [ + "D01", + "D02", + "D03", + "D04", + "D05", + "D06", + "D07", + "D08", + "D09", + "D10" + ], + "state": "in_progress", + "estimate_person_days": [ + 3, + 7 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "current_facility_audit": { + "status": "Live audit: original30 = normal16 + definition-only1 + unmapped13; 17 mapped dimensions/layers match; customer limits unset13. Not full acceptance.", + "evidence": "execution/CURRENT_FACILITY_AUDIT.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P02", + "phase": 0, + "title": "既存機能の回帰基準", + "depends_on": [ + "P00" + ], + "size": "M", + "requirement_ids": [ + "B-3.1a", + "B-3.1b", + "B-3.3", + "B-3.4a", + "B-3.4b", + "B-4.2", + "B-4.3c", + "B-5.4" + ], + "work": "時計、停止中建設、最高欲求、混雑、決算、赤字終了、救護/EV同伴/休息の既存証拠を新規開発の基準にする。必要な代表セーブを固定。 Windows基準環境と配布EXEのスモーク確認を準備し、利用不能なら未検証として記録する。", + "acceptance": "日付・入力・乱数・セーブを固定して再実行できる。既存PASS8群を新仕様導入で失わない。", + "decisions": [], + "state": "in_progress", + "estimate_person_days": [ + 3, + 7 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": {} + }, + { + "id": "P03", + "phase": 1, + "title": "保存形式と移行契約", + "depends_on": [ + "P01", + "P02" + ], + "size": "L", + "requirement_ids": [ + "B-2.1", + "B-4.1", + "B-5.2", + "B-6.3", + "B-7.2", + "B-8.1", + "B-9.1" + ], + "work": "施設、会計、客、職員、関係値、天候の新項目を版管理。原本と異なる関係値は一対一変換を仮定せず旧値を保全。移行は検証成功時のみ確定する。", + "acceptance": "旧版/欠損/未知版/破損/途中書込の試験。失敗時に元セーブを変更しない。新しい状態は途中保存から同一結果で再開。", + "decisions": [ + "D06", + "D07" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "cleaning_access_followup": { + "evidence": "execution/CLEANING_ACCESS_FOLLOWUP.md", + "status": "Alternate accessible destinations and wait/reconnect behavior implemented; cleaning-room UI corrected. Six targeted suites PASS; broad regression59503 remains active and spans this source change." + }, + "cleaning_animation_and_ordering": { + "status": "mop driven by actual active on-site work; explicit lexical cleanliness save/dispatch order fixes cross-process mismatch; related tests and rendered restart97 PASS", + "evidence": "execution/CLEANING_ANIMATION.md", + "all_original_acceptance": false + }, + "world_snapshot_cache": { + "status": "single cached geometry with detached reads and restore invalidation; 5 related suites PASS; 20000-cell same-state world serialization improved, full simulation budgets still fail", + "evidence": "execution/WORLD_SNAPSHOT_CACHE.md", + "all_original_acceptance": false + }, + "event_defense_integration": { + "status": "integrated_current_source_full_and_restart_verified", + "evidence": "artifacts/event-defense-integration-2026-09-30/", + "runtime_schema": 13, + "event_checks": 186, + "natural_producer_complete": false, + "all_original_acceptance": false, + "full_evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + } + } + }, + { + "id": "P04", + "phase": 1, + "title": "更新順序・責務の整理", + "depends_on": [ + "P03" + ], + "size": "M", + "requirement_ids": [ + "B-1.3", + "B-1.4", + "B-3.2", + "B-9.2" + ], + "work": "一分更新、00:00決算、時間帯・天候更新、施設稼働判定、客選択の適用順を仕様化。新機能は分離サービスで実装しRuntimeから接続。全体の一括書換えはしない。", + "acceptance": "同一Tickでの閉店/停電/退出/課金順が決定的。コマンド再送で二重課金・二重生成しない。", + "decisions": [ + "D05" + ], + "state": "in_progress", + "estimate_person_days": [ + 3, + 7 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "fine_cleaning_effect": { + "status": "triple work rate and single-owner cleaning progression implemented; six related suites PASS; normal recruitment pending user values", + "evidence": "execution/FINE_CLEANING_EFFECT.md", + "all_original_acceptance": false + }, + "staff_task_clock": { + "status": "Removed redundant rejected per-tick progress calls; normal task time unchanged; 6 suites / 574 checks PASS", + "evidence": "execution/STAFF_TASK_CLOCK.md", + "all_original_acceptance": false + }, + "event_defense_integration": { + "status": "integrated_current_source_full_and_restart_verified", + "evidence": "artifacts/event-defense-integration-2026-09-30/", + "runtime_schema": 13, + "event_checks": 186, + "natural_producer_complete": false, + "all_original_acceptance": false, + "full_evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + } + } + }, + { + "id": "P05", + "phase": 2, + "title": "全収支・維持費・税・借入", + "depends_on": [ + "P04" + ], + "size": "L", + "requirement_ids": [ + "B-3.4a", + "B-3.4b", + "B-9.1", + "B-9.2", + "B-9.3" + ], + "work": "表/裏収入区分、維持費、返済元本/利息、税、未払いを帳簿に接続。金庫上限・購買・銀行のサービス契約を先に作る。決算損益と現金収支を区別。", + "acceptance": "3営業日の費目別総額が台帳・施設別・資金と一致。借入元本を売上にしない。支払い失敗/再送/復元/3赤字を検証。", + "decisions": [ + "D04", + "D05" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": {} + }, + { + "id": "P06", + "phase": 2, + "title": "マナ枯渇と全館停止", + "depends_on": [ + "P04" + ], + "size": "L", + "requirement_ids": [ + "B-2.6", + "B-9.1", + "B-10.6", + "B-10.7" + ], + "work": "供給/蓄積/消費と世界樹初期源の扱いを整理。全館停止時のサービス、EV、物流、救護、入館と安全な復帰を接続。休止状態は停電復旧で解除しない。", + "acceptance": "残量境界、停止中利用者、EV乗車中、搬送中、同日復旧を試験。停止中の売上や生産が無条件に継続しない。", + "decisions": [ + "D04", + "D05" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "evidence": "execution/MANA_INTEGRATION.md", + "status": "Production outage logic, kitchen and paired rescue recovery, HUD outage display verified; make check passed", + "remaining": [ + "finite reserve policy and Runtime generation/consumption", + "new reserve model save migration and legacy-zero-save boundary", + "supply/consumption HUD values after policy integration", + "natural gameplay and Windows acceptance" + ], + "no_landing_evidence": "artifacts/mana-2026-09-29/no-landing-validation.json" + } + }, + { + "id": "P07", + "phase": 2, + "title": "天候・流行・環境", + "depends_on": [ + "P04" + ], + "size": "L", + "requirement_ids": [ + "B-2.3", + "B-2.6", + "B-3.2", + "B-3.5", + "B-4.5", + "B-11.1" + ], + "work": "雨/嵐/マナ霧、流行ニュース、ゾーンの採光/遮音/隠蔽、景観/清潔/騒音を段階導入。係数と乱数系列を保存しHUDと予測へ表示。", + "acceptance": "嵐で新規入館0、雨で来客減/滞在延長。天候変化の瞬間と保存再開が一致。環境変更に応じて満足度と対象売上が変わる。", + "decisions": [ + "D04", + "D05" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "evidence": "execution/WEATHER_INTEGRATION.md", + "status": "Runtime11 migration, storm boundary, disk save/retry and native HUD verified; related11 script gate passed after fixture corrections.", + "remaining": [ + "natural weather generation policy", + "rain and fog effects", + "trend news and environmental effects", + "normal gameplay and Windows acceptance" + ], + "cleaning_approved_foundation": { + "evidence": "execution/CLEANING_APPROVED_FOUNDATION.md", + "status": "User approved trial room/policy values; standalone cleanliness model and strict policy validator:47 checks PASS. Runtime/scheduler/UI/save migration remain unconnected; not normal-play completion." + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + } + } + }, + { + "id": "P08", + "phase": 3, + "title": "施設寸法・既存配置の整合", + "depends_on": [ + "P03", + "P01" + ], + "size": "M", + "requirement_ids": [ + "B-2.1", + "B-8.1", + "B-10.1", + "B-10.2", + "B-10.3", + "B-10.4", + "B-10.5", + "B-10.6", + "B-10.7" + ], + "work": "上級客室4×1、宿舎2×1、闇市場4×2へ原本準拠。30施設を正規IDで管理し類似施設を代用扱いしない。旧占有・支持・接続の移行を検証。", + "acceptance": "新規建設の寸法/層/地上地下が全30件で一致。旧施設の拡縮で重複・経路断を起こさず、解消不能な移行は元データを保つ。", + "decisions": [ + "D06" + ], + "state": "in_progress", + "estimate_person_days": [ + 3, + 7 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "evidence": "execution/FACILITY_DIMENSION_MIGRATION.md", + "status": "Migration review/load UI added; native UI13,dashboard136,batch23 checks PASS; P08 remains in progress", + "remaining": [ + "coordinated layout ordering and broader mixed-job acceptance", + "physical actors on removed cells and coordinated task retargeting", + "in-flight elevators and working rescue/logistics acceptance", + "all30 normal building/save acceptance", + "OS pointer and Windows migration UI acceptance" + ], + "current_facility_audit": { + "status": "Live audit: original30 = normal16 + definition-only1 + unmapped13; 17 mapped dimensions/layers match; customer limits unset13. Not full acceptance.", + "evidence": "execution/CURRENT_FACILITY_AUDIT.md", + "all_original_acceptance": false + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "original_room_zones": { + "status": "removed unsupported above-ground-only restriction from standard/premium/staff dormitory; 6 suites and rendered41 PASS", + "evidence": "execution/ORIGINAL_ROOM_ZONES.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P09", + "phase": 3, + "title": "施設共通契約と追加10項目", + "depends_on": [ + "P05", + "P06", + "P08" + ], + "size": "L", + "requirement_ids": [ + "B-8.1", + "B-8.2", + "B-8.3", + "B-8.5", + "B-9.2" + ], + "work": "人数専用上限、staff_min/max、3稼働状態、関連施設、サービス属性、価格、施設レベルを統合。全35既存定義を点検し原本30施設の値を台帳化。", + "acceptance": "本番UI→コマンド→Runtime→人数/料金→台帳→保存まで接続。不一致属性でも利用可能。待機者と利用者を混同せず、人数超過/停止中課金/勝手な休止復帰を防ぐ。", + "decisions": [ + "D01", + "D02", + "D03", + "D04", + "D05" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [ + "F-01", + "F-02", + "F-03", + "F-04", + "F-05", + "F-06", + "F-07", + "F-08", + "F-09", + "F-10" + ], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "current_facility_audit": { + "status": "Live audit: original30 = normal16 + definition-only1 + unmapped13; 17 mapped dimensions/layers match; customer limits unset13. Not full acceptance.", + "evidence": "execution/CURRENT_FACILITY_AUDIT.md", + "all_original_acceptance": false + }, + "business_customer_counts": { + "status": "implemented; business72 headless/native and chapel16 passed; original controls in evidence", + "evidence": "execution/BUSINESS_CUSTOMER_COUNTS.md", + "remaining": [ + "customer limit values for unspecified facilities", + "other original facility parameters and end-to-end acceptance" + ] + }, + "cleaning_approved_foundation": { + "evidence": "execution/CLEANING_APPROVED_FOUNDATION.md", + "status": "User approved trial room/policy values; standalone cleanliness model and strict policy validator:47 checks PASS. Runtime/scheduler/UI/save migration remain unconnected; not normal-play completion." + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "original_room_zones": { + "status": "removed unsupported above-ground-only restriction from standard/premium/staff dormitory; 6 suites and rendered41 PASS", + "evidence": "execution/ORIGINAL_ROOM_ZONES.md", + "all_original_acceptance": false + }, + "service_start_slots": { + "status": "Start index separated from customer headcount and fixed before notification callbacks. New25 and related220 checks PASS; rendered two-process opening/restart97 PASS, gameplay equal and source unchanged. Not full140-suite or original acceptance.", + "evidence": "execution/SERVICE_START_SLOTS.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P10", + "phase": 3, + "title": "宿泊・飲食施設の完成", + "depends_on": [ + "P09" + ], + "size": "L", + "requirement_ids": [ + "B-8.2", + "B-10.1", + "B-10.2" + ], + "work": "一般/上級/VIP/ドミトリー、食堂/レストラン/酒場/屋台を建設・厨房・在庫・従業員・客AI・UIへ接続。高層/景観要件と厨房依存を含む。", + "acceptance": "各施設を通常パレットから建設して一営業日利用し、必要条件喪失と再開、売上、人数上限、保存を検証。", + "decisions": [ + "D04" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "Windows部分実機確認。不可なら未検証を残す", + "初期/中盤/終盤に対応するプレイ試行で収支・操作・到達性を確認" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "evidence": "execution/PREMIUM_ROOM_INTEGRATION.md", + "status": "Premium natural opening day via synthesized GUI input:440G lodging, midnight close, UI save/load, next30minute exact replay; opening guide now recognizes all FRONT lodging. Native36 and standard opening48 checks PASS; final headless36 checks including construction guide PASS; make check exit0.", + "remaining": [ + "OS pointer and Windows same-build acceptance", + "other original lodging and dining facilities", + "broader arrival seeds, full-day outages and long-term balance" + ], + "distribution_evidence": "execution/ORIGINAL_FACILITIES_EXPORT.md", + "original_room_zones": { + "status": "removed unsupported above-ground-only restriction from standard/premium/staff dormitory; 6 suites and rendered41 PASS", + "evidence": "execution/ORIGINAL_ROOM_ZONES.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P11", + "phase": 3, + "title": "衛生・娯楽・礼拝施設", + "depends_on": [ + "P09", + "P07" + ], + "size": "L", + "requirement_ids": [ + "B-8.2", + "B-10.3" + ], + "work": "浴場/カジノ/闘技場/礼拝堂の役割、利用、収支、職種、信仰への接続を実装。機能ごとの通常UIを用意。", + "acceptance": "4施設が定義だけでなく通常営業できる。礼拝の信仰をランク条件へ接続し、停止・退店・精算を再現。", + "decisions": [ + "D04", + "D05" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "chapel_service_boundary": { + "status": "Normal catalog, cleric hire, assignment, autonomous worship, payment/Faith and UI connected; broader acceptance pending", + "evidence": "execution/CHAPEL_APPROVED_INTEGRATION.md", + "remaining": [ + "long-term economic balance", + "final chapel art", + "Windows" + ] + }, + "chapel_normal_gameplay": { + "status": "Native17 checks passed; pre-final UI suite and catalog/content regression passed", + "evidence": "execution/CHAPEL_APPROVED_INTEGRATION.md" + }, + "chapel_capacity_and_two_day": { + "status": "Capacity8/queue, real cleric unassignment, no reward on interruption/failure, exact save/replay, two-day normal arrival and maintenance settlement verified", + "checks": 46, + "evidence": "execution/CHAPEL_APPROVED_INTEGRATION.md" + }, + "catalog_lookup": { + "status": "Fixed chapel lookup omission; normal catalog query coverage310 checks PASS", + "evidence": "execution/CHAPEL_FULL_REGRESSION.md" + }, + "amelia_donations": { + "status": "Placement-based triple chapel donation connected to quotes, actual payment, ledger and UI; native39 and related136 checks PASS; normal recruitment and healing remain", + "evidence": "execution/AMELIA_DONATIONS.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P12", + "phase": 3, + "title": "バックヤード施設完成", + "depends_on": [ + "P09" + ], + "size": "L", + "requirement_ids": [ + "B-2.4", + "B-5.3", + "B-5.4", + "B-8.3", + "B-10.5" + ], + "work": "宿舎・清掃員室を追加し厨房/倉庫/待機/警備/医務を統一契約へ移行。リネンシュート・隠し扉の動線も個別に追跡する。", + "acceptance": "7施設と追加設備の職員行動、客の侵入禁止、在庫/清掃/休息/救護を通常プレイで検証。重複救護と孤立物資が残らない。", + "decisions": [ + "D04", + "D05" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "evidence": "execution/STAFF_DORMITORY_INTEGRATION.md", + "status": "Dormitory normal registration/palette plus physical staff access/rest/outage/restore;18 focused checks PASS", + "remaining": [ + "ordinary full shift/rest-day and physical UI construction acceptance", + "staff/transport-safe legacy migration", + "remaining backyard facilities and special equipment", + "Windows native acceptance" + ], + "distribution_evidence": "execution/ORIGINAL_FACILITIES_EXPORT.md", + "shift_cycle_status": "16 checks PASS: ordinary repeating NIGHT shift, natural06:00 end, dormitory rest via physical stairs, exact60-minute checkpoint replay, next18:00 return. One seed/worker; UI/Windows and other shift scenarios remain.", + "cleaning_approved_foundation": { + "evidence": "execution/CLEANING_APPROVED_FOUNDATION.md", + "status": "User approved trial room/policy values; standalone cleanliness model and strict policy validator:47 checks PASS. Runtime/scheduler/UI/save migration remain unconnected; not normal-play completion." + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "cleaning_access_followup": { + "evidence": "execution/CLEANING_ACCESS_FOLLOWUP.md", + "status": "Alternate accessible destinations and wait/reconnect behavior implemented; cleaning-room UI corrected. Six targeted suites PASS; broad regression59503 remains active and spans this source change." + }, + "original_room_zones": { + "status": "removed unsupported above-ground-only restriction from standard/premium/staff dormitory; 6 suites and rendered41 PASS", + "evidence": "execution/ORIGINAL_ROOM_ZONES.md", + "all_original_acceptance": false + }, + "fine_cleaning_effect": { + "status": "triple work rate and single-owner cleaning progression implemented; six related suites PASS; normal recruitment pending user values", + "evidence": "execution/FINE_CLEANING_EFFECT.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P13", + "phase": 3, + "title": "特殊・地下施設の非描写システム接続", + "depends_on": [ + "P09", + "P12" + ], + "size": "L", + "requirement_ids": [ + "B-1.6", + "B-2.5", + "B-8.2", + "B-8.3", + "B-10.4", + "B-10.6" + ], + "work": "原本にある地下/特殊施設の配置、アクセス制御、役割、会計、イベント状態を接続。覗き部屋は対象施設背面と裏通路寸断、地下牢/マナ炉は明示状態遷移として追跡。表現資産は別工程。", + "acceptance": "特殊条件が建設時と運用時の両方で効く。無効配置や失われた対象への操作を拒否。イベント状態を保存でき、実装範囲と未制作内容を分けて報告。", + "decisions": [ + "D04", + "D05", + "D08" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": {} + }, + { + "id": "P14", + "phase": 3, + "title": "垂直交通・ワープ完成", + "depends_on": [ + "P06", + "P09" + ], + "size": "L", + "requirement_ids": [ + "B-2.4", + "B-4.3a", + "B-4.3b", + "B-4.3c", + "B-8.4", + "B-10.7" + ], + "work": "客/業務EV、階段にマナワープと費用を追加。待ち時間の経路費用、容量、職員アクセス、停止時退避を統一。魔法砲台はP21で接続。", + "acceptance": "全交通を通常建設し経路選択・実移動を検証。待ち時間増で別経路を選択。ワープ中/EV中保存、マナ不足、救護同伴時の人数を検証。", + "decisions": [ + "D04", + "D05" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測", + "Windows部分実機確認。不可なら未検証を残す" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "cleaning_access_followup": { + "evidence": "execution/CLEANING_ACCESS_FOLLOWUP.md", + "status": "Alternate accessible destinations and wait/reconnect behavior implemented; cleaning-room UI corrected. Six targeted suites PASS; broad regression59503 remains active and spans this source change." + } + } + }, + { + "id": "P15", + "phase": 4, + "title": "客の全パラメータ・時間帯AI", + "depends_on": [ + "P07", + "P10", + "P11", + "P14", + "P13" + ], + "size": "L", + "requirement_ids": [ + "B-3.2", + "B-4.1", + "B-4.2", + "B-4.4", + "B-4.5", + "B-9.4" + ], + "work": "戦利品、清潔度と汚染伝播、クラス/種族反応、朝夕の移動目的、依存と信用限度の非描写状態処理を追加。最大欲求選択を維持。", + "acceptance": "一般/貴族/聖職者/エルフ/魔族等で期待行動を再現。朝下り/夕上りの目的地分布、清潔伝播、装備処分・破産状態を保存再開まで検証。", + "decisions": [ + "D04", + "D05", + "D08" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "guest_decision_history": { + "status": "Actual completed guest choices recorded in bounded saved event history and exposed through normal HUD. New headless20/rendered21 PASS; existing observer22, GuestService137, runtime cycle40 PASS; rendered operating restart97 PASS. Not full thought-history or original acceptance.", + "evidence": "execution/GUEST_DECISION_LOG.md", + "all_original_acceptance": false + }, + "guest_candidate_rejections": { + "status": "Actual rejection reason counts and up to8 facility examples retained in saved decision event; includes missing supplies without unnecessary path queries. Headless28/rendered29, related241, rendered restart97 checks PASS. Not whole-original acceptance.", + "evidence": "execution/GUEST_REJECTION_LOG.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P16", + "phase": 4, + "title": "雇用・職種・労務完成", + "depends_on": [ + "P12", + "P15", + "P13" + ], + "size": "L", + "requirement_ids": [ + "B-5.1", + "B-5.2", + "B-5.3", + "B-5.4", + "B-5.5" + ], + "work": "雇用区分別費用、全職種業務、生成時特性、獲得/退職/処分の非描写状態処理を整理。乱数特性は生成時に固定しセーブへ保存。", + "acceptance": "求人から配置・業務・休息・精算・退職まで通常UIで通る。費用/忠誠/特性効果の変化が可視化され、ロードで特性が再抽選されない。", + "decisions": [ + "D04", + "D05", + "D08" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "cleaning_approved_foundation": { + "evidence": "execution/CLEANING_APPROVED_FOUNDATION.md", + "status": "User approved trial room/policy values; standalone cleanliness model and strict policy validator:47 checks PASS. Runtime/scheduler/UI/save migration remain unconnected; not normal-play completion." + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "staff_task_clock": { + "status": "Removed redundant rejected per-tick progress calls; normal task time unchanged; 6 suites / 574 checks PASS", + "evidence": "execution/STAFF_TASK_CLOCK.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P17", + "phase": 4, + "title": "ユニーク7名・固有効果・成長", + "depends_on": [ + "P16", + "P05", + "P11" + ], + "size": "L", + "requirement_ids": [ + "B-1.5", + "B-6.1", + "B-6.2", + "B-6.3", + "B-6.4", + "B-6.5" + ], + "work": "7ヒロインと指定ライバル、無料/削除禁止、業務EXP/Lv99、固有パッシブを実装。アンリの配置階売上+20%など原本の明示数値を優先。衣装/超過勤務/離脱を状態として接続。", + "acceptance": "7名全員で固有効果の適用前後、配置変更/同時配置、99上限、削除禁止を試験。24時間警告から継続/中止ができ、保存復元で効果を重複適用しない。", + "decisions": [ + "D04", + "D07", + "D08" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "初期/中盤/終盤に対応するプレイ試行で収支・操作・到達性を確認" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "anri_original_effect": { + "status": "Definition correction followed by implemented recruitment, placement and floor pricing; see subsequent evidence", + "evidence": "execution/ANRI_ORIGINAL_EFFECT.md", + "remaining": [ + "all seven characters", + "broader gameplay acceptance and Windows" + ] + }, + "unique_employment_protection": { + "status": "Implemented service non-removal and zero new wages; full unique gameplay incomplete", + "evidence": "execution/UNIQUE_EMPLOYMENT_PROTECTION.md", + "checks": [ + 209, + 87, + 59 + ] + }, + "unique_runtime_protection": { + "checks": 116, + "evidence": "execution/UNIQUE_EMPLOYMENT_PROTECTION.md" + }, + "anri_user_decisions": { + "recruitment": "Player selects free recruitment in staff UI", + "revenue": "Increase guest price by 20 percent; wallet debit equals facility revenue", + "evidence": "execution/ANRI_APPROVED_INTEGRATION.md", + "implemented": true, + "implementation_status": "Free UI recruitment and approved floor pricing implemented; broader acceptance pending" + }, + "anri_recruitment": { + "status": "Implemented staff-screen free recruitment and save/load", + "evidence": "execution/ANRI_RECRUITMENT.md", + "remaining": [ + "all seven characters and broader P17", + "Windows" + ] + }, + "anri_floor_pricing": { + "status": "Implemented placement, pricing, UI and targeted payment/save tests", + "evidence": "execution/ANRI_FLOOR_PRICING.md", + "remaining": [ + "random arrival through departure acceptance", + "additional shift/placement boundaries", + "other six heroines", + "full P17 and Windows" + ] + }, + "anri_autonomous_guest_flow": { + "status": "Controlled guest spawn followed by autonomous selection, stairs, lodging and equal payment/revenue verified; movement save replay exact", + "checks": { + "runtime": 36, + "recruitment_ui_headless": 17 + }, + "evidence": "execution/ANRI_FLOOR_PRICING.md" + }, + "anri_placement_boundaries": { + "status": "Cross-floor DAY to NIGHT transfer, invalid zero-capacity target, demolition guard and repeated save restore verified", + "runtime_checks": 48, + "evidence": "execution/ANRI_FLOOR_PRICING.md" + }, + "anri_integrated_ui_regression": { + "status": "make test-ui passed, source hashes stable; full original acceptance remains incomplete", + "evidence": "execution/ANRI_INTEGRATED_UI_REGRESSION.md", + "direct_script_checks": 992 + }, + "anri_opening_guide_role": { + "status": "Fixed guide counting manager as receptionist; regression reproduced before fix and targeted UI passes", + "evidence": "execution/ANRI_OPENING_GUIDE_ROLE.md" + }, + "fine_cleaning_effect": { + "status": "triple work rate and single-owner cleaning progression implemented; six related suites PASS; normal recruitment pending user values", + "evidence": "execution/FINE_CLEANING_EFFECT.md", + "all_original_acceptance": false + }, + "amelia_donations": { + "status": "Placement-based triple chapel donation connected to quotes, actual payment, ledger and UI; native39 and related136 checks PASS; normal recruitment and healing remain", + "evidence": "execution/AMELIA_DONATIONS.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P18", + "phase": 4, + "title": "関係パラメータ・盾・確定遷移", + "depends_on": [ + "P03", + "P17" + ], + "size": "L", + "requirement_ids": [ + "B-7.1", + "B-7.2", + "B-7.3" + ], + "work": "原本の感情値と盾の非描写シミュレーションを専用モデルに分離。好感度と盾最大、軽減、接触、閾値、ルート固定を接続。既存の汎用関係値を黙って同義変換しない。", + "acceptance": "境界値・盾破壊前後・同配置/別配置・一度確定したルートの再読込を試験。操作と表示の値が一致し、旧セーブ移行の意味を説明できる。", + "decisions": [ + "D04", + "D07", + "D08" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": {} + }, + { + "id": "P19", + "phase": 4, + "title": "イベント・決闘・行動・履歴基盤", + "depends_on": [ + "P18", + "P05" + ], + "size": "L", + "requirement_ids": [ + "B-7.1", + "B-7.4", + "B-7.5", + "B-7.6", + "B-11.3" + ], + "work": "原本の決闘種別、プレイヤー行動、結果、履歴、人物外見状態を非描写のデータ/遷移/画面として実装。専用カメラは証拠IDとイベント基盤を接続。内容制作はP26に分離。", + "acceptance": "各分岐が到達可能で勝敗/費用/盾/履歴を一度だけ反映。中断・対象離脱・取消・ロード後も矛盾なし。専用画面は中立テスト内容で操作を検証。", + "decisions": [ + "D04", + "D07", + "D08" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": {} + }, + { + "id": "P20", + "phase": 5, + "title": "ランク・信仰・物語進行", + "depends_on": [ + "P05", + "P11", + "P15", + "P19" + ], + "size": "L", + "requirement_ids": [ + "B-1.1", + "B-1.2", + "B-1.3", + "B-1.4", + "B-1.5", + "B-6.4", + "B-6.5", + "B-8.1", + "B-8.5" + ], + "work": "信仰/評判/裏影響、ランク条件、施設/方針/スキル解放、章とエンドを接続。条件評価APIだけでは進行完了としない。", + "acceptance": "デバッグで条件値を直接書き込まず新規ゲームから最終ランクと本編終点へ到達。条件未達の理由が画面で分かり、分岐復元が一致。", + "decisions": [ + "D04", + "D08" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "初期/中盤/終盤に対応するプレイ試行で収支・操作・到達性を確認" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "status": "Partial: rank HUD, conditions and promotion command UI implemented; broader package remains open", + "rank_ui_evidence": "execution/WORLD_TREE_RANK_UI.md", + "rank_ui_validation": { + "native_checks": 23, + "failures": 0, + "make_check_exit_code": 0, + "natural_campaign_completion": false, + "windows_same_build_verified": false, + "viewport_input": { + "native_checks": 12, + "headless_checks": 11, + "failures": 0, + "evidence": "artifacts/facility-dimensions-2026-09-29/rank-pointer-validation.json" + } + } + } + }, + { + "id": "P21", + "phase": 5, + "title": "防衛・監査・銀行・砲台", + "depends_on": [ + "P05", + "P13", + "P14", + "P17" + ], + "size": "L", + "requirement_ids": [ + "B-1.3", + "B-6.4", + "B-6.5", + "B-9.3", + "B-10.7", + "B-11.4" + ], + "work": "原本の脅威発生から警告、準備、戦闘、能動スキル、魔法砲台、結果、施設損傷・休止へ接続。返済滞納と襲撃を接続。", + "acceptance": "API直呼びでなく通常営業から発生。勝利/敗北/退避/破壊・摘発/復元を検証。敗北で意図しない恒久ロストや勝手な休止解除がない。", + "decisions": [ + "D03", + "D04", + "D05", + "D08" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "defense_preparation_cancel": { + "state": "verified", + "evidence": "execution/DEFENSE_PREPARATION_CANCEL.md", + "targeted_checks": 856, + "rendered_checks": 131, + "full_regression": "140 PASS; no source changes" + }, + "defense_hud": { + "status": "targeted_and_rendered_verified", + "evidence": "execution/DEFENSE_HUD.md", + "normal_trigger_and_combat_ui": "pending" + }, + "defense_management": { + "state": "implemented_source_full_and_rendered_restart_verified", + "ordinary_ui": true, + "threat_creation_is_fixture": true, + "preparation_cancellation_exposed": false, + "selection_save_restore": true, + "evidence": "artifacts/defense-autonomy-2026-09-30", + "related_headless_checks": 1703, + "related_rendered_checks": 403, + "all_original_acceptance": false, + "autonomy_proof_from_same_source_full": true + }, + "defense_clock": { + "state": "implemented_source_full_and_rendered_restart_verified", + "existing_round_interval_used": true, + "replay_same_tick_guard_fixed": true, + "generated_id_collision_fixed": true, + "first_autonomy_checks": 188, + "existing_runtime_cycle_checks": 162, + "natural_threat_generation": false, + "evidence": "artifacts/defense-autonomy-2026-09-30", + "independent_review": "artifacts/defense-autonomy-independent-review-2026-09-30/FOLLOWUP_REVIEW.md", + "all_original_acceptance": false + }, + "defense_autonomy_checkpoint": { + "state": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "full_suites": 146, + "full_failures": 0, + "source_changes": 0, + "opening_rendered_checks": 103, + "defense_restart_rendered_checks": 38, + "full_evidence": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "packed_runs": 16, + "packed_checks": 2559, + "zip": "build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip", + "zip_sha256": "2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc" + }, + "current_full_regression": { + "status": "source_full_rendered_readable_restart_and_windows_packed_verified", + "full_suites": 148, + "full_failures": 0, + "full_seconds": 657.68, + "source_changes": [], + "opening_restart_checks": 103, + "related_rendered_checks": 453, + "related_headless_checks_referenced_from_same_full": 720, + "defense_restart_checks": 66, + "human_readable_defense_labels": true, + "full_source_manifest_sha256": "0395e515c1cb090408c11c7235b36f8b2015bf350fe412b2d894b286b249ef6c", + "windows_package": "built_and_verified", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "windows_zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "windows_zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "packed_headless_checks": 2468, + "packed_rendered_checks": 458, + "evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "previous_full_regression_before_manifest_cache": { + "status": "source_and_rendered_verified", + "evidence": "execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md", + "suites": 146, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 743.93, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30/verification.json", + "artifact": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "source_changes": 0, + "source_manifest_sha256": "afd27dc86d0125d5547cf58beda9461dec34fd38c0fc94e85a96e7f82d2896a5", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "manifest_cache_checkpoint": { + "state": "retained_source_full_rendered_restart_packed_verified", + "full_suites": 147, + "full_failures": 0, + "source_changes": [], + "live_manifest_checks": 79, + "complete_checkpoint_and_final_store_var_bytes_equal": true, + "abbreviated_comparison_order": "ABBA", + "frames_per_speed_per_run": 200, + "x1_average_reduction_percent": 10.479991918650523, + "x3_p95_reduction_percent": 17.835132792182208, + "publication_average_reduction_percent": 17.883028279234804, + "opening_restart_checks": 103, + "related_rendered_checks": 403, + "related_headless_from_same_source_full_checks": 484, + "defense_restart_checks": 38, + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_checks_including_replays": 2639, + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "zip_sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "full_source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "natural_threat_generation": false, + "windows_native": false, + "performance_budget_pass": false, + "full_load_acceptance": false, + "all_original_acceptance": false + }, + "event_defense_integration": { + "status": "integrated_current_source_full_and_restart_verified", + "evidence": "artifacts/event-defense-integration-2026-09-30/", + "runtime_schema": 13, + "event_checks": 186, + "natural_producer_complete": false, + "all_original_acceptance": false, + "full_evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "previous_full_regression_before_event_defense": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 147, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 708.21, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "related_rendered_checks": 403, + "artifact": "artifacts/full-regression-after-manifest-cache-2026-09-30", + "defense_restart_evidence": "artifacts/manifest-cache-defense-restart-2026-09-30/verification.json", + "source_changes": 0, + "source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "defense_human_labels": { + "status": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "evidence": "artifacts/event-defense-labels-integration-2026-09-30/", + "isolated_checks": 1350, + "all_original_acceptance": false, + "full_suites": 148, + "restart_checks": 66, + "rendered_checks": 453, + "packed_checks": 2926, + "windows_native": false + }, + "previous_full_regression_before_event_labels": { + "status": "running", + "evidence": "artifacts/full-regression-after-event-defense-2026-09-30/", + "expected_suites": 148, + "all_original_acceptance": false + } + } + }, + { + "id": "P22", + "phase": 1, + "title": "素材台帳・量産仕様の先行確定", + "depends_on": [ + "P01" + ], + "size": "M", + "requirement_ids": [ + "B-12.1", + "B-12.3", + "B-12.4", + "B-13.1", + "B-13.2", + "B-13.3", + "B-13.4", + "B-13.5" + ], + "work": "全施設Lv1〜3×表裏、7人物、状態差分、背景、UI、音響の必要枠を列挙。既存Kenney素材と新規制作を区別しサイズ/命名/ライセンス/差替契約を定義。", + "acceptance": "各資産が利用要件・画面・状態・制作者・仮素材/完成品に紐付く。必要数未確定のまま完成時期を断定しない。", + "decisions": [ + "D08", + "D09" + ], + "state": "in_progress", + "estimate_person_days": [ + 3, + 7 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "P22/P26で制作担当を確定" + }, + "execution_evidence": "execution/assets/README.md", + "remaining": "差分数・制作担当・品質受入と利用画面の確定。既存ファイルの存在を完成認定しない。", + "execution_progress": { + "current_facility_audit": { + "evidence": "execution/CURRENT_FACILITY_AUDIT.md", + "counts": { + "normal_registered_and_palette": 15, + "definition_only": 1, + "missing_definition": 14 + }, + "status": "partial; live catalog and declaration audit only", + "remaining": [ + "14 missing definitions", + "black_market normal integration", + "13 unspecified customer limits", + "all16 mapped service attributes unset", + "zone restrictions and special placement acceptance", + "all30 gameplay/save/art/Windows acceptance" + ] + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + } + } + }, + { + "id": "P23", + "phase": 3, + "title": "タワー画面・レイヤー演出", + "depends_on": [ + "P08", + "P22" + ], + "size": "L", + "requirement_ids": [ + "B-2.2", + "B-11.1", + "B-11.4", + "B-12.1", + "B-12.2", + "B-12.4" + ], + "work": "非選択層の半透明/ワイヤー、波紋切替、上下移動/ズーム/視認性、原本に沿ったHUD配置と表裏スキンを実装。座標変換と選択判定を共通化。", + "acceptance": "切替前後に選択・クリック座標が一致。パン/ズーム中の建設、異層施設の視認性、連続切替、速度x3で破綻しない。", + "decisions": [ + "D09", + "D10" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測", + "Windows部分実機確認。不可なら未検証を残す" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "status": "Completed rooms, active jobs and legacy stairs visible across layers; legacy native30/runtime67/layer27 checks PASS; partial P23", + "evidence": "execution/LAYER_PRESENTATION.md", + "remaining": [ + "remaining legacy transport topology acceptance beyond stairs", + "high-load x3 rapid-switch GPU/input acceptance", + "Windows same-build acceptance", + "remaining original HUD/layer skin requirements" + ], + "rank_ui_evidence": "execution/WORLD_TREE_RANK_UI.md", + "rank_ui_validation": { + "native_checks": 23, + "failures": 0, + "make_check_exit_code": 0, + "natural_campaign_completion": false, + "windows_same_build_verified": false, + "viewport_input": { + "native_checks": 12, + "headless_checks": 11, + "failures": 0, + "evidence": "artifacts/facility-dimensions-2026-09-29/rank-pointer-validation.json" + } + }, + "hud_skin_evidence": "execution/LAYER_INTERFACE_SKIN.md", + "guide_skin_status": "Guide joins FRONT/BACK HUD skin; paused collapse/reopen fixed. Native16, dashboard139 and opening/save replay59 PASS.", + "staff_skin_evidence": "execution/STAFF_INTERFACE_SKIN.md", + "management_skin_evidence": "execution/MANAGEMENT_INTERFACE_SKIN.md", + "ledger_skin_evidence": "execution/LEDGER_INTERFACE_SKIN.md", + "rank_skin_evidence": "execution/RANK_INTERFACE_SKIN.md" + } + }, + { + "id": "P24", + "phase": 4, + "title": "インスペクター・通知・操作完成", + "depends_on": [ + "P09", + "P15", + "P16", + "P18", + "P23", + "P19" + ], + "size": "L", + "requirement_ids": [ + "B-11.1", + "B-11.2", + "B-11.3", + "B-11.4", + "B-13.4" + ], + "work": "シフトのドラッグ配置、欲求/思考ログ、感情/盾、料金内訳、稼働阻害理由、接続線、フキダシ、カットインを接続。クリックによる代替操作も用意。", + "acceptance": "無効ドロップで状態を壊さず理由を表示。保存後も対象選択を回復。ログの説明と実際の選択が一致。モーダル/停止/再開の入力を検証。", + "decisions": [ + "D09" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "Windows部分実機確認。不可なら未検証を残す" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "status": "Partial: rank HUD, conditions and promotion command UI implemented; broader package remains open", + "rank_ui_evidence": "execution/WORLD_TREE_RANK_UI.md", + "rank_ui_validation": { + "native_checks": 23, + "failures": 0, + "make_check_exit_code": 0, + "natural_campaign_completion": false, + "windows_same_build_verified": false, + "viewport_input": { + "native_checks": 12, + "headless_checks": 11, + "failures": 0, + "evidence": "artifacts/facility-dimensions-2026-09-29/rank-pointer-validation.json" + } + }, + "staff_drag_evidence": "execution/STAFF_DRAG_ASSIGNMENT.md", + "staff_drag_validation": { + "native_checks": 63, + "headless_checks": 62, + "evidence": "artifacts/facility-dimensions-2026-09-29/staff-drag-boundaries-validation.json", + "remaining": [ + "other drag rejection boundaries", + "Windows same-build input acceptance" + ] + }, + "rank_skin_evidence": "execution/RANK_INTERFACE_SKIN.md", + "staff_drag_rejection_validation": { + "headless_checks": 77, + "native_checks": 80, + "evidence": "artifacts/facility-dimensions-2026-09-29/staff-drag-rejections-validation.json", + "remaining": [ + "Windows same-build input", + "defense-specific locks", + "state changes during drag", + "broader P24 scope" + ] + }, + "staff_drag_race_validation": { + "headless_checks": 87, + "native_checks": 87, + "evidence": "artifacts/facility-dimensions-2026-09-29/staff-drag-races-validation.json", + "remaining": [ + "defense-specific lock", + "load/demolition during drag", + "Windows same-build input", + "broader P24 scope" + ] + }, + "infrastructure_ledger_labels": { + "status": "Fixed raw IDs for completed corridor/stair upkeep; names and coordinates verified across restart", + "evidence": "execution/OPENING_RESTART_DEBUG.md" + }, + "business_customer_counts": { + "status": "implemented; business72 headless/native and chapel16 passed; original controls in evidence", + "evidence": "execution/BUSINESS_CUSTOMER_COUNTS.md", + "remaining": [ + "customer limit values for unspecified facilities", + "other original facility parameters and end-to-end acceptance" + ] + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "cleaning_access_followup": { + "evidence": "execution/CLEANING_ACCESS_FOLLOWUP.md", + "status": "Alternate accessible destinations and wait/reconnect behavior implemented; cleaning-room UI corrected. Six targeted suites PASS; broad regression59503 remains active and spans this source change." + }, + "fine_cleaning_effect": { + "status": "triple work rate and single-owner cleaning progression implemented; six related suites PASS; normal recruitment pending user values", + "evidence": "execution/FINE_CLEANING_EFFECT.md", + "all_original_acceptance": false + }, + "amelia_donations": { + "status": "Placement-based triple chapel donation connected to quotes, actual payment, ledger and UI; native39 and related136 checks PASS; normal recruitment and healing remain", + "evidence": "execution/AMELIA_DONATIONS.md", + "all_original_acceptance": false + }, + "facility_reachability": { + "status": "UI reachability early exit; 33 new + 275 related checks PASS; rendered opening/restart97 PASS. Target query average 6222.41 to 4222.04 us (100 paired samples). Not whole-game performance acceptance.", + "evidence": "execution/FACILITY_REACHABILITY.md", + "all_original_acceptance": false + }, + "guest_decision_history": { + "status": "Actual completed guest choices recorded in bounded saved event history and exposed through normal HUD. New headless20/rendered21 PASS; existing observer22, GuestService137, runtime cycle40 PASS; rendered operating restart97 PASS. Not full thought-history or original acceptance.", + "evidence": "execution/GUEST_DECISION_LOG.md", + "all_original_acceptance": false + }, + "guest_candidate_rejections": { + "status": "Actual rejection reason counts and up to8 facility examples retained in saved decision event; includes missing supplies without unnecessary path queries. Headless28/rendered29, related241, rendered restart97 checks PASS. Not whole-original acceptance.", + "evidence": "execution/GUEST_REJECTION_LOG.md", + "all_original_acceptance": false + }, + "staff_drag_load": { + "status": "Reject unfinished drag across successful load; failed restore and fresh drag remain functional. Headless99/rendered100, related118, rendered opening/restart97 PASS; gameplay equal and source unchanged. Partial original acceptance.", + "evidence": "execution/STAFF_DRAG_LOAD.md", + "all_original_acceptance": false + }, + "staff_drag_demolition": { + "status": "Live pointer drag with normal demolition command before drop preserves staff schedules, keeps facility removed and reports rejection. Source106 and isolated same-PCK macOS rendered107 PASS. Production unchanged; Windows native access unavailable in current tools.", + "evidence": "execution/STAFF_DRAG_DEMOLITION.md", + "all_original_acceptance": false + }, + "staff_defense_lock": { + "state": "partial_verified", + "evidence": "execution/STAFF_DEFENSE_LOCK.md", + "checks": 119, + "related_checks": 321, + "rendered_checks": 119, + "opening_restart": "97 checks PASS; equal gameplay; source changes 0", + "remaining": [ + "current Windows package/native verification", + "broader original requirements" + ] + }, + "defense_preparation_cancel": { + "state": "verified", + "evidence": "execution/DEFENSE_PREPARATION_CANCEL.md", + "targeted_checks": 856, + "rendered_checks": 131, + "full_regression": "140 PASS; no source changes" + }, + "defense_hud": { + "status": "targeted_and_rendered_verified", + "evidence": "execution/DEFENSE_HUD.md", + "normal_trigger_and_combat_ui": "pending" + }, + "guide_assignment_scan": { + "status": "targeted_and_rendered_verified", + "evidence": "execution/GUIDE_ASSIGNMENT_SCAN.md", + "targeted_checks": 356, + "rendered_restart_checks": 97, + "whole_game_performance_pass": false, + "windows_package_includes_change": false + }, + "selection_restore": { + "status": "manual_save_full_and_rendered_verified", + "evidence": "execution/SELECTION_RESTORE.md", + "dashboard_checks": 168, + "staff_checks": 131, + "rendered_restart_checks": 103, + "all_save_entry_points_verified": false, + "full_suites": 141 + }, + "selection_autosave": { + "status": "source_and_rendered_verified", + "evidence": "execution/SELECTION_AUTOSAVE.md", + "checks": { + "autosave_headless": 75, + "autosave_rendered": 76, + "lifecycle_headless": 38, + "lifecycle_rendered": 39, + "related": 395, + "restart": 103 + }, + "windows_native": false, + "suites": 143, + "opening_restart_checks": 103 + }, + "actor_selection_restore": { + "status": "source_full_and_rendered_restart_verified", + "headless_checks": 100, + "rendered_checks": 101, + "related_checks": 552, + "production_main_sha256": "15708bfb4d534b4f741df5b087fa56ebf36bb9ca004bb80e0bd9c43d07e6c9f1", + "actor_test_sha256": "ecc08464c20990f0a4bd5d1e5d8d6fceb6f3636c959790842a08539fc54ad911", + "full_regression": "144 PASS; no missing, extra, duplicate or changed source inputs", + "windows_export": "Windows EXE/PCK exported; isolated PCK on macOS and ZIP integrity verified; Windows native pending", + "windows_native": false, + "all_original_acceptance": false, + "evidence": "execution/ACTOR_SELECTION_RESTORE.md", + "full_suites": 144, + "full_opening_restart_checks": 103, + "actor_restart_checks": 116, + "opening_restart_checks": 103, + "full_regression_artifact": "artifacts/full-regression-after-actor-selection-2026-09-30", + "full_source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "actor_restart": { + "status": "verified", + "write_checks": 103, + "read_checks": 13, + "checks": 116, + "gameplay_json_equal": true, + "typed_digest_equal": true, + "source_changes": 0, + "evidence": "artifacts/actor-selection-restart-2026-09-30/verification.json", + "driver_sha256": { + "verify.py": "52d4e7e7ee1561867aab3db98704ae27d8ca3237d80dea0d39acda64ca7fed5a", + "flow.gd": "4de71e591925c022a1febeb7ac4a99a3557afa79f8c46bd5cac447934030f1c3" + } + }, + "independent_review": { + "status": "no_actionable_product_findings", + "evidence": "artifacts/actor-selection-independent-review-2026-09-30/review.json", + "sha256": "9de10628a3831e236bb951f4d674e3327767fbce7af8bc3c1c7a2f4468f6ddac" + }, + "windows_export_session": 88628, + "windows_build_validation": "verified; 12 isolated PCK runs / 2094 checks including rendered repetitions", + "windows_build_process_complete": true, + "windows_package": { + "zip": "build/windows/TheTree-Windows-actor-selection-2026-09-30.zip", + "sha256": "dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb", + "actor_headless_checks": 101, + "actor_rendered_macos_checks": 102, + "evidence": "artifacts/windows-actor-release-prep-2026-09-30/build-validation.json", + "windows_native": false, + "all_original_acceptance": false + } + }, + "current_full_regression": { + "status": "source_full_rendered_readable_restart_and_windows_packed_verified", + "full_suites": 148, + "full_failures": 0, + "full_seconds": 657.68, + "source_changes": [], + "opening_restart_checks": 103, + "related_rendered_checks": 453, + "related_headless_checks_referenced_from_same_full": 720, + "defense_restart_checks": 66, + "human_readable_defense_labels": true, + "full_source_manifest_sha256": "0395e515c1cb090408c11c7235b36f8b2015bf350fe412b2d894b286b249ef6c", + "windows_package": "built_and_verified", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "windows_zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "windows_zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "packed_headless_checks": 2468, + "packed_rendered_checks": 458, + "evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "defense_autonomy_checkpoint": { + "state": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "full_suites": 146, + "full_failures": 0, + "source_changes": 0, + "opening_rendered_checks": 103, + "defense_restart_rendered_checks": 38, + "full_evidence": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "packed_runs": 16, + "packed_checks": 2559, + "zip": "build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip", + "zip_sha256": "2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc" + }, + "previous_full_regression_before_defense_autonomy": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 144, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 762.12, + "opening_restart_checks": 103, + "actor_restart_checks": 116, + "actor_restart_evidence": "artifacts/actor-selection-restart-2026-09-30/verification.json", + "windows_native": false, + "artifact": "artifacts/full-regression-after-actor-selection-2026-09-30", + "source_changes": 0, + "source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "later_source_change": null, + "all_original_acceptance": false + }, + "previous_full_regression_before_manifest_cache": { + "status": "source_and_rendered_verified", + "evidence": "execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md", + "suites": 146, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 743.93, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30/verification.json", + "artifact": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "source_changes": 0, + "source_manifest_sha256": "afd27dc86d0125d5547cf58beda9461dec34fd38c0fc94e85a96e7f82d2896a5", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "manifest_cache_checkpoint": { + "state": "retained_source_full_rendered_restart_packed_verified", + "full_suites": 147, + "full_failures": 0, + "source_changes": [], + "live_manifest_checks": 79, + "complete_checkpoint_and_final_store_var_bytes_equal": true, + "abbreviated_comparison_order": "ABBA", + "frames_per_speed_per_run": 200, + "x1_average_reduction_percent": 10.479991918650523, + "x3_p95_reduction_percent": 17.835132792182208, + "publication_average_reduction_percent": 17.883028279234804, + "opening_restart_checks": 103, + "related_rendered_checks": 403, + "related_headless_from_same_source_full_checks": 484, + "defense_restart_checks": 38, + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_checks_including_replays": 2639, + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "zip_sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "full_source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "natural_threat_generation": false, + "windows_native": false, + "performance_budget_pass": false, + "full_load_acceptance": false, + "all_original_acceptance": false + }, + "previous_full_regression_before_event_defense": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 147, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 708.21, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "related_rendered_checks": 403, + "artifact": "artifacts/full-regression-after-manifest-cache-2026-09-30", + "defense_restart_evidence": "artifacts/manifest-cache-defense-restart-2026-09-30/verification.json", + "source_changes": 0, + "source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "defense_human_labels": { + "status": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "evidence": "artifacts/event-defense-labels-integration-2026-09-30/", + "isolated_checks": 1350, + "all_original_acceptance": false, + "full_suites": 148, + "restart_checks": 66, + "rendered_checks": 453, + "packed_checks": 2926, + "windows_native": false + }, + "previous_full_regression_before_event_labels": { + "status": "running", + "evidence": "artifacts/full-regression-after-event-defense-2026-09-30/", + "expected_suites": 148, + "all_original_acceptance": false + } + } + }, + { + "id": "P25", + "phase": 5, + "title": "ドット人物・施設差分・音響接続", + "depends_on": [ + "P22", + "P23", + "P12", + "P17" + ], + "size": "L", + "requirement_ids": [ + "B-12.1", + "B-12.3", + "B-12.4", + "B-13.2", + "B-13.3", + "B-13.4", + "B-13.5" + ], + "work": "状態駆動の歩行/作業/搬送/休息、施設Lv差分、背景、表裏BGMクロスフェード、環境/操作音を接続。実シミュレーションの状態を演出源にする。", + "acceptance": "停止中は人物移動が進まない。搬送/休息は実位置に一致。音量/連続切替/ロードで二重再生しない。完成資産の欠損とライセンス記録漏れ0。", + "decisions": [ + "D08", + "D09" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存", + "既存代表負荷の性能回帰を計測" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "P22/P26で制作担当を確定" + }, + "execution_progress": { + "chapel_room_presentation": { + "status": "Initial code-native chapel furnishings and8 visitor anchors implemented; level variants/audio/Windows remain", + "evidence": "execution/CHAPEL_ROOM_PRESENTATION.md" + }, + "cleaning_animation": { + "status": "PASS: actual work animation, related suites, and fixed two-process restart97", + "evidence": "execution/CLEANING_ANIMATION.md", + "all_original_acceptance": false + }, + "cleaning_animation_and_ordering": { + "status": "mop driven by actual active on-site work; explicit lexical cleanliness save/dispatch order fixes cross-process mismatch; related tests and rendered restart97 PASS", + "evidence": "execution/CLEANING_ANIMATION.md", + "all_original_acceptance": false + } + } + }, + { + "id": "P26", + "phase": 5, + "title": "シナリオ・立ち絵・内容資産の制作受入", + "depends_on": [ + "P19", + "P20", + "P22" + ], + "size": "L", + "requirement_ids": [ + "B-1.1", + "B-1.5", + "B-1.6", + "B-7.5", + "B-7.6", + "B-12.1", + "B-12.3", + "B-13.1", + "B-13.5" + ], + "work": "全章・人物別ルート・終点・台詞・立ち絵・音声を台帳で制作/接続/受入。成人向け内容は表現仕様と制作可否を別途確認し、今回は非露骨な構造・資産管理・分岐検証のみ計画対象とする。", + "acceptance": "仮素材や未制作の場面を完了扱いしない。全必要分岐と資産の欠損を一覧化。制作できない内容は原本との差分として残し、全実装完了を宣言しない。", + "decisions": [ + "D08", + "D09" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "P22/P26で制作担当を確定" + }, + "execution_progress": {} + }, + { + "id": "P27", + "phase": 6, + "title": "統合・境界・障害復旧試験", + "depends_on": [ + "P10", + "P11", + "P12", + "P13", + "P14", + "P15", + "P16", + "P17", + "P18", + "P19", + "P20", + "P21", + "P24", + "P25", + "P26" + ], + "size": "L", + "requirement_ids": [ + "B-1.1", + "B-1.3", + "B-14.4" + ], + "work": "新規営業/停電/嵐/救護/決算/破産/襲撃/ルート進行の横断シナリオを実施。代表地点で保存・終了・起動を挟む。失敗を再現ケースに固定。", + "acceptance": "全原子要件に合否証拠。進行不能・セーブ破損・帳簿不整合・原本との未承認差異0。未設定や未制作をスキップPASSしない。", + "decisions": [ + "D01", + "D02", + "D03", + "D04", + "D05", + "D06", + "D07", + "D08", + "D09", + "D10" + ], + "state": "in_progress", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "ui_regression": { + "status": "current test-ui command coverage passed after upkeep test repair", + "evidence": "execution/UI_INTEGRATED_REGRESSION.md", + "whole_original_acceptance": false + }, + "lazy_digest_ui_regression": { + "status": "PASS full make test-ui after payload correction", + "evidence": "execution/LAZY_PUBLICATION_DIGEST.md", + "all_original_acceptance": false + }, + "post_skin_ui_regression": { + "status": "PASS current make test-ui; 18 direct scripts plus 2 upkeep processes", + "evidence": "execution/POST_SKIN_UI_REGRESSION.md", + "all_original_acceptance": false + }, + "chapel_full_regression": { + "status": "127 unique source suites PASS, runner exit0, discovery complete; final catalog correction separately310 PASS", + "evidence": "execution/CHAPEL_FULL_REGRESSION.md", + "single_frozen_source_sweep": false, + "all_original_acceptance": false + }, + "opening_restart_debug": { + "status": "Rendered two-process natural opening/three-close debug97 checks PASS after infrastructure ledger label fix; source unchanged during final run", + "evidence": "execution/OPENING_RESTART_DEBUG.md", + "all_original_acceptance": false + }, + "snapshot_full_regression": { + "status": "passed128; runner exit0; frozen source verified", + "evidence": "artifacts/snapshot-full-regression-2026-09-29/completion.json", + "remaining": [ + "all original feature acceptance remains incomplete" + ] + }, + "optimized_opening_restart": { + "status": "passed97 after full128; native macOS screenshots reviewed", + "evidence": "artifacts/snapshot-opening-restart-2026-09-29/validation.json", + "remaining": [ + "all original cross-system scenarios", + "Windows exact build acceptance", + "performance budget and long-term acceptance" + ] + }, + "operations_snapshot_encoding": { + "status": "targeted563 and rendered restart97 passed; interleaved byte equivalence100", + "evidence": "execution/OPERATIONS_SNAPSHOT_ENCODING.md", + "same_state_average_us_before": 2384.77, + "same_state_average_us_after": 1999.03, + "remaining": [ + "production performance budgets", + "full-load and Windows", + "remaining original content and all acceptance gates" + ] + }, + "cleaning_runtime_integration": { + "evidence": "execution/CLEANING_RUNTIME_INTEGRATION.md", + "status": "Normal room construction/hiring/assignment, actual travel and8-minute work,25 recovery, Runtime schema12, cancellation and business UI connected.81 gameplay checks PASS; native82 including capture. Full source regression running; alternate-access and additional boundary coverage remain." + }, + "cleaning_access_followup": { + "evidence": "execution/CLEANING_ACCESS_FOLLOWUP.md", + "status": "Alternate accessible destinations and wait/reconnect behavior implemented; cleaning-room UI corrected. Six targeted suites PASS; broad regression59503 remains active and spans this source change." + }, + "cleaning_regression_recheck": { + "status": "PASS: 132 suites covered; 105 retained after final shared edit plus 27 rechecks; no missing or failed suites; source manifests unchanged", + "evidence": "artifacts/cleaning-regression-final-2026-09-29/verification.json", + "opening_restart": "PASS: rendered macOS two-process flow, 80+17 checks; three closes balanced; gameplay and reports match; source unchanged", + "all_original_acceptance": false, + "opening_restart_evidence": "artifacts/cleaning-opening-restart-2026-09-29/result.json" + }, + "cleaning_animation": { + "status": "PASS: actual work animation, related suites, and fixed two-process restart97", + "evidence": "execution/CLEANING_ANIMATION.md", + "all_original_acceptance": false + }, + "cleaning_animation_and_ordering": { + "status": "mop driven by actual active on-site work; explicit lexical cleanliness save/dispatch order fixes cross-process mismatch; related tests and rendered restart97 PASS", + "evidence": "execution/CLEANING_ANIMATION.md", + "all_original_acceptance": false + }, + "fine_cleaning_effect": { + "status": "triple work rate and single-owner cleaning progression implemented; six related suites PASS; normal recruitment pending user values", + "evidence": "execution/FINE_CLEANING_EFFECT.md", + "all_original_acceptance": false + }, + "amelia_donations": { + "status": "Placement-based triple chapel donation connected to quotes, actual payment, ledger and UI; native39 and related136 checks PASS; normal recruitment and healing remain", + "evidence": "execution/AMELIA_DONATIONS.md", + "all_original_acceptance": false + }, + "staff_task_clock": { + "status": "Removed redundant rejected per-tick progress calls; normal task time unchanged; 6 suites / 574 checks PASS", + "evidence": "execution/STAFF_TASK_CLOCK.md", + "all_original_acceptance": false + }, + "current_full_regression": { + "status": "source_full_rendered_readable_restart_and_windows_packed_verified", + "full_suites": 148, + "full_failures": 0, + "full_seconds": 657.68, + "source_changes": [], + "opening_restart_checks": 103, + "related_rendered_checks": 453, + "related_headless_checks_referenced_from_same_full": 720, + "defense_restart_checks": 66, + "human_readable_defense_labels": true, + "full_source_manifest_sha256": "0395e515c1cb090408c11c7235b36f8b2015bf350fe412b2d894b286b249ef6c", + "windows_package": "built_and_verified", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "windows_zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "windows_zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "packed_headless_checks": 2468, + "packed_rendered_checks": 458, + "evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "facility_reachability": { + "status": "UI reachability early exit; 33 new + 275 related checks PASS; rendered opening/restart97 PASS. Target query average 6222.41 to 4222.04 us (100 paired samples). Not whole-game performance acceptance.", + "evidence": "execution/FACILITY_REACHABILITY.md", + "all_original_acceptance": false + }, + "guest_decision_history": { + "status": "Actual completed guest choices recorded in bounded saved event history and exposed through normal HUD. New headless20/rendered21 PASS; existing observer22, GuestService137, runtime cycle40 PASS; rendered operating restart97 PASS. Not full thought-history or original acceptance.", + "evidence": "execution/GUEST_DECISION_LOG.md", + "all_original_acceptance": false + }, + "guest_candidate_rejections": { + "status": "Actual rejection reason counts and up to8 facility examples retained in saved decision event; includes missing supplies without unnecessary path queries. Headless28/rendered29, related241, rendered restart97 checks PASS. Not whole-original acceptance.", + "evidence": "execution/GUEST_REJECTION_LOG.md", + "all_original_acceptance": false + }, + "service_start_slots": { + "status": "Start index separated from customer headcount and fixed before notification callbacks. New25 and related220 checks PASS; rendered two-process opening/restart97 PASS, gameplay equal and source unchanged. Not full140-suite or original acceptance.", + "evidence": "execution/SERVICE_START_SLOTS.md", + "all_original_acceptance": false + }, + "staff_drag_load": { + "status": "Reject unfinished drag across successful load; failed restore and fresh drag remain functional. Headless99/rendered100, related118, rendered opening/restart97 PASS; gameplay equal and source unchanged. Partial original acceptance.", + "evidence": "execution/STAFF_DRAG_LOAD.md", + "all_original_acceptance": false + }, + "projector_single_live_check": { + "status": "Removed duplicate live projection traversal; source and adapter validation retained. Related645 and rendered opening/restart97 checks PASS. Paired200 samples: average1494.195 to1165.750 us. Full production budgets still FAIL; no whole-game speedup claim.", + "evidence": "execution/PROJECTOR_SINGLE_LIVE_CHECK.md", + "all_original_acceptance": false + }, + "current_windows_package": { + "status": "source_full_rendered_restart_and_packed_verified", + "evidence": "artifacts/windows-event-defense-labels-release-prep-2026-09-30/", + "zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "windows_native": false, + "all_original_acceptance": false + }, + "staff_drag_demolition": { + "status": "Live pointer drag with normal demolition command before drop preserves staff schedules, keeps facility removed and reports rejection. Source106 and isolated same-PCK macOS rendered107 PASS. Production unchanged; Windows native access unavailable in current tools.", + "evidence": "execution/STAFF_DRAG_DEMOLITION.md", + "all_original_acceptance": false + }, + "staff_defense_lock": { + "state": "partial_verified", + "evidence": "execution/STAFF_DEFENSE_LOCK.md", + "checks": 119, + "related_checks": 321, + "rendered_checks": 119, + "opening_restart": "97 checks PASS; equal gameplay; source changes 0", + "remaining": [ + "current Windows package/native verification", + "broader original requirements" + ] + }, + "defense_preparation_cancel": { + "state": "verified", + "evidence": "execution/DEFENSE_PREPARATION_CANCEL.md", + "targeted_checks": 856, + "rendered_checks": 131, + "full_regression": "140 PASS; no source changes" + }, + "selection_restore": { + "status": "manual_save_full_and_rendered_verified", + "evidence": "execution/SELECTION_RESTORE.md", + "dashboard_checks": 168, + "staff_checks": 131, + "rendered_restart_checks": 103, + "all_save_entry_points_verified": false, + "full_suites": 141 + }, + "selection_autosave": { + "status": "source_and_rendered_verified", + "evidence": "execution/SELECTION_AUTOSAVE.md", + "checks": { + "autosave_headless": 75, + "autosave_rendered": 76, + "lifecycle_headless": 38, + "lifecycle_rendered": 39, + "related": 395, + "restart": 103 + }, + "windows_native": false, + "suites": 143, + "opening_restart_checks": 103 + }, + "actor_selection_restore": { + "status": "source_full_and_rendered_restart_verified", + "headless_checks": 100, + "rendered_checks": 101, + "related_checks": 552, + "production_main_sha256": "15708bfb4d534b4f741df5b087fa56ebf36bb9ca004bb80e0bd9c43d07e6c9f1", + "actor_test_sha256": "ecc08464c20990f0a4bd5d1e5d8d6fceb6f3636c959790842a08539fc54ad911", + "full_regression": "144 PASS; no missing, extra, duplicate or changed source inputs", + "windows_export": "Windows EXE/PCK exported; isolated PCK on macOS and ZIP integrity verified; Windows native pending", + "windows_native": false, + "all_original_acceptance": false, + "evidence": "execution/ACTOR_SELECTION_RESTORE.md", + "full_suites": 144, + "full_opening_restart_checks": 103, + "actor_restart_checks": 116, + "opening_restart_checks": 103, + "full_regression_artifact": "artifacts/full-regression-after-actor-selection-2026-09-30", + "full_source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "actor_restart": { + "status": "verified", + "write_checks": 103, + "read_checks": 13, + "checks": 116, + "gameplay_json_equal": true, + "typed_digest_equal": true, + "source_changes": 0, + "evidence": "artifacts/actor-selection-restart-2026-09-30/verification.json", + "driver_sha256": { + "verify.py": "52d4e7e7ee1561867aab3db98704ae27d8ca3237d80dea0d39acda64ca7fed5a", + "flow.gd": "4de71e591925c022a1febeb7ac4a99a3557afa79f8c46bd5cac447934030f1c3" + } + }, + "independent_review": { + "status": "no_actionable_product_findings", + "evidence": "artifacts/actor-selection-independent-review-2026-09-30/review.json", + "sha256": "9de10628a3831e236bb951f4d674e3327767fbce7af8bc3c1c7a2f4468f6ddac" + }, + "windows_export_session": 88628, + "windows_build_validation": "verified; 12 isolated PCK runs / 2094 checks including rendered repetitions", + "windows_build_process_complete": true, + "windows_package": { + "zip": "build/windows/TheTree-Windows-actor-selection-2026-09-30.zip", + "sha256": "dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb", + "actor_headless_checks": 101, + "actor_rendered_macos_checks": 102, + "evidence": "artifacts/windows-actor-release-prep-2026-09-30/build-validation.json", + "windows_native": false, + "all_original_acceptance": false + } + }, + "defense_autonomy_checkpoint": { + "state": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "full_suites": 146, + "full_failures": 0, + "source_changes": 0, + "opening_rendered_checks": 103, + "defense_restart_rendered_checks": 38, + "full_evidence": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "packed_runs": 16, + "packed_checks": 2559, + "zip": "build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip", + "zip_sha256": "2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc" + }, + "previous_full_regression_before_defense_autonomy": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 144, + "opening_restart_checks": 103, + "windows_native": false, + "artifact": "artifacts/full-regression-after-actor-selection-2026-09-30", + "source_changes": 0, + "later_source_change": null, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 762.12, + "actor_restart_checks": 116, + "actor_restart_evidence": "artifacts/actor-selection-restart-2026-09-30/verification.json", + "source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "all_original_acceptance": false + }, + "previous_windows_package_before_defense_autonomy": { + "status": "packed_verified_windows_native_pending", + "evidence": "execution/CURRENT_WINDOWS_PACKAGE.md", + "zip": "build/windows/TheTree-Windows-actor-selection-2026-09-30.zip", + "bytes": 73155439, + "sha256": "dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb", + "build_id": "actor-selection-2026-09-30", + "export_mode": "debug", + "architecture": "Windows x86_64 PE32+", + "engine": "4.7.2.stable.official.ed1daf0bf", + "packed_runs": 12, + "packed_headless_checks": 1743, + "packed_rendered_checks": 351, + "packed_rendered_staff_checks": 132, + "packed_total_checks_including_replays": 2094, + "packed_checks": { + "operations": 850, + "opening": 28, + "defense": 508, + "staff": 132, + "staff-rendered": 132, + "hud": 9, + "autosave": 76, + "autosave-rendered": 77, + "selection": 39, + "selection-rendered": 40, + "actor": 101, + "actor-rendered": 102 + }, + "packed_rendering_platform": "macOS Apple M4 OpenGL compatibility", + "source_suites": 144, + "source_opening_restart_checks": 103, + "source_actor_restart_checks": 116, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "source_changes": 0, + "clone_import_input_changes": 0, + "copied_runtime_inputs": 849, + "exe_sha256": "c52c04ae1283256102b446df50499420d29ab11758041b8abfc043b61dd270a1", + "pack_sha256": "697577f0d33d400d887d8f398d2afb02cb19c452aaad92e5a486d07b9718303d", + "source_manifest_sha256": "afdc8638ae17ce37545ef12bf337bccf4a6e8a4b5a09f4b8bdee55d3f0543bc4", + "validation_artifact": "artifacts/windows-actor-release-prep-2026-09-30/build-validation.json", + "later_source_change": null, + "windows_native": false, + "performance_budget_pass": false, + "all_original_acceptance": false, + "previous_development_package": { + "zip": "build/windows/TheTree-Windows-snapshot-reuse-2026-09-29.zip", + "sha256": "03629ba3325aeecb135f199c192e0ffbcb4fae539f1f0dff1c3d76495ff0f842", + "packed_headless_checks": 1527, + "packed_rendered_staff_checks": 132 + } + }, + "previous_full_regression_before_manifest_cache": { + "status": "source_and_rendered_verified", + "evidence": "execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md", + "suites": 146, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 743.93, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30/verification.json", + "artifact": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "source_changes": 0, + "source_manifest_sha256": "afd27dc86d0125d5547cf58beda9461dec34fd38c0fc94e85a96e7f82d2896a5", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "previous_windows_package_before_manifest_cache": { + "status": "packed_verified_windows_native_pending", + "evidence": "execution/WINDOWS_PACKAGE_BEFORE_MANIFEST_CACHE.md", + "zip": "build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip", + "bytes": 73190478, + "sha256": "2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc", + "build_id": "defense-autonomy-2026-09-30", + "export_mode": "debug", + "architecture": "Windows x86_64 PE32+", + "engine": "4.7.2.stable.official.ed1daf0bf", + "packed_runs": 16, + "packed_headless_checks": 2151, + "packed_rendered_checks": 408, + "packed_total_checks_including_replays": 2559, + "packed_checks": { + "operations": 850, + "opening": 28, + "defense": 508, + "staff": 132, + "staff-rendered": 132, + "hud": 9, + "autosave": 76, + "autosave-rendered": 77, + "selection": 39, + "selection-rendered": 40, + "actor": 101, + "actor-rendered": 102, + "defense-cycle": 163, + "defense-autonomy": 189, + "defense-management": 56, + "defense-management-rendered": 57 + }, + "packed_rendering_platform": "macOS Apple M4 OpenGL compatibility", + "source_suites": 146, + "source_opening_restart_checks": 103, + "source_defense_restart_checks": 38, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "source_changes": 0, + "clone_import_input_changes": 0, + "copied_runtime_inputs": 851, + "exe_sha256": "c52c04ae1283256102b446df50499420d29ab11758041b8abfc043b61dd270a1", + "pack_sha256": "8d7ba120e35593fffcadf08174b32c91b78e8892a454393535975500f2309081", + "source_manifest_sha256": "49e05221691a06e2781a720b734f38f7996be1237cf3738e2f0c7f737f794fe9", + "validation_artifact": "artifacts/windows-defense-release-prep-2026-09-30/build-validation.json", + "later_source_change": null, + "windows_native": false, + "performance_budget_pass": false, + "all_original_acceptance": false + }, + "manifest_cache_checkpoint": { + "state": "retained_source_full_rendered_restart_packed_verified", + "full_suites": 147, + "full_failures": 0, + "source_changes": [], + "live_manifest_checks": 79, + "complete_checkpoint_and_final_store_var_bytes_equal": true, + "abbreviated_comparison_order": "ABBA", + "frames_per_speed_per_run": 200, + "x1_average_reduction_percent": 10.479991918650523, + "x3_p95_reduction_percent": 17.835132792182208, + "publication_average_reduction_percent": 17.883028279234804, + "opening_restart_checks": 103, + "related_rendered_checks": 403, + "related_headless_from_same_source_full_checks": 484, + "defense_restart_checks": 38, + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_checks_including_replays": 2639, + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "zip_sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "full_source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "natural_threat_generation": false, + "windows_native": false, + "performance_budget_pass": false, + "full_load_acceptance": false, + "all_original_acceptance": false + }, + "event_defense_integration": { + "status": "integrated_current_source_full_and_restart_verified", + "evidence": "artifacts/event-defense-integration-2026-09-30/", + "runtime_schema": 13, + "event_checks": 186, + "natural_producer_complete": false, + "all_original_acceptance": false, + "full_evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "previous_full_regression_before_event_defense": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 147, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 708.21, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "related_rendered_checks": 403, + "artifact": "artifacts/full-regression-after-manifest-cache-2026-09-30", + "defense_restart_evidence": "artifacts/manifest-cache-defense-restart-2026-09-30/verification.json", + "source_changes": 0, + "source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "previous_windows_package_before_event_defense": { + "status": "packed_verified_windows_native_pending", + "evidence": "execution/CURRENT_WINDOWS_PACKAGE.md", + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "bytes": 73192182, + "sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "build_id": "manifest-cache-2026-09-30", + "export_mode": "debug", + "architecture": "Windows x86_64 PE32+", + "engine": "4.7.2.stable.official.ed1daf0bf", + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_total_checks_including_replays": 2639, + "packed_checks": { + "operations": 850, + "opening": 28, + "defense": 508, + "staff": 132, + "staff-rendered": 132, + "hud": 9, + "autosave": 76, + "autosave-rendered": 77, + "selection": 39, + "selection-rendered": 40, + "actor": 101, + "actor-rendered": 102, + "defense-cycle": 163, + "defense-autonomy": 189, + "defense-management": 56, + "defense-management-rendered": 57, + "manifest-cache": 80 + }, + "packed_rendering_platform": "macOS Apple M4 OpenGL compatibility", + "source_suites": 147, + "source_opening_restart_checks": 103, + "source_defense_restart_checks": 38, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "source_changes": 0, + "clone_import_input_changes": 0, + "copied_runtime_inputs": 851, + "exe_sha256": "c52c04ae1283256102b446df50499420d29ab11758041b8abfc043b61dd270a1", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "source_manifest_sha256": "a9476841dcb6dba6697380b9f8005617986c04d23b6ef29b287bd351f437102f", + "validation_artifact": "artifacts/windows-manifest-release-prep-2026-09-30/build-validation.json", + "later_source_change": null, + "windows_native": false, + "performance_budget_pass": false, + "all_original_acceptance": false + }, + "defense_human_labels": { + "status": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "evidence": "artifacts/event-defense-labels-integration-2026-09-30/", + "isolated_checks": 1350, + "all_original_acceptance": false, + "full_suites": 148, + "restart_checks": 66, + "rendered_checks": 453, + "packed_checks": 2926, + "windows_native": false + }, + "previous_full_regression_before_event_labels": { + "status": "running", + "evidence": "artifacts/full-regression-after-event-defense-2026-09-30/", + "expected_suites": 148, + "all_original_acceptance": false + } + } + }, + { + "id": "P28", + "phase": 6, + "title": "性能・操作感・長期バランス", + "depends_on": [ + "P27" + ], + "size": "L", + "requirement_ids": [ + "B-1.1", + "B-3.1a", + "B-4.3a", + "B-4.3c", + "B-11.1", + "B-12.1", + "B-14.4" + ], + "work": "既存高負荷条件でCPU/経路/描画/保存を測定。通常プレイのラッシュ/満室/負荷ピークと長期経済を評価。20〜30時間本編・50時間以上全攻略は実プレイ記録で確認。", + "acceptance": "既存性能予算と新しいWindows描画目標を満たす。到達不可能な収支/信仰/ランク条件なし。高速自動再生だけでプレイ時間・操作感を合格にしない。", + "decisions": [ + "D04", + "D08", + "D10" + ], + "state": "planned", + "estimate_person_days": [ + 7, + 15 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "current_domain_performance": { + "status": "smoke and full public-API composition PASS", + "evidence": "execution/CURRENT_PERFORMANCE.md", + "remaining": [ + "production Runtime performance", + "rendered scene and GPU load", + "Windows target acceptance", + "long campaign balance" + ] + }, + "production_runtime_performance": { + "status": "improved but FAIL: x1 average14.359ms, x3 P95 53.828ms (small production fixture)", + "evidence": "execution/PRODUCTION_RUNTIME_PERFORMANCE.md", + "next": [ + "snapshot publication cost", + "idle infrastructure validation", + "same-checkpoint regression and remeasurement" + ], + "scalar_cache_evidence": "execution/DIGEST_SCALAR_CACHE.md", + "membership_evidence": "execution/PATH_MEMBERSHIP_OPTIMIZATION.md", + "latest_investigation": "Operations snapshot validation/conversion fusion rejected; exact cell-set fix passed168 and full UI regression. Snapshot publication remains primary performance hotspot.", + "lazy_digest_evidence": "execution/LAZY_PUBLICATION_DIGEST.md", + "ui_regression": "Full make test-ui exit0 after compact HUD, projector/adapter validation caches and exact cell-set fix;17 direct scripts plus2 upkeep processes, unchanged source hashes, no error markers.", + "event_snapshot_evidence": "execution/EVENT_HISTORY_SNAPSHOT_CACHE.md", + "compact_hud_evidence": "execution/COMPACT_OPERATIONS_HUD.md", + "projector_cache_evidence": "execution/PROJECTOR_VALIDATION_CACHE.md", + "adapter_cache_evidence": "execution/ADAPTER_VALIDATION_CACHE.md", + "cell_set_evidence": "execution/EXACT_CELL_SET_VALIDATION.md", + "post_skin_measurement": { + "status": "FAIL", + "x1_average_ms": 14.503425, + "x3_p95_ms": 54.366, + "evidence": "execution/POST_SKIN_PRODUCTION_PERFORMANCE.md" + }, + "snapshot_copy_reduction": { + "evidence": "execution/OPERATIONS_SNAPSHOT_COPY_REDUCTION.md", + "functional_status": "PASS 233+51+59", + "performance_status": "FAIL x1 14.256ms / x3 P95 52.113ms" + }, + "tree_budget_optimization": { + "evidence": "execution/SNAPSHOT_TREE_BUDGET.md", + "functional_status": "PASS 248+59", + "performance_status": "FAIL x1 14.031ms / x3 P95 50.911ms" + }, + "latest_measurement": "Catalog manifest cache retained after 200-frame ABBA comparison; x1 reference mean12.052ms -> candidate10.789ms; x3 P95 mean63.445ms ->52.1295ms. Small macOS production fixture only; budgets still FAIL. execution/MVP_CATALOG_MANIFEST_CACHE.md." + }, + "snapshot_key_budget": { + "status": "Validation leaf optimization measured; production budgets still FAIL", + "evidence": "execution/SNAPSHOT_KEY_BUDGET.md", + "before_after_us": { + "operations_snapshot_average": [ + 3174.95, + 2419.45 + ], + "x1_average": [ + 13979.775, + 13487.4 + ], + "x3_p95": [ + 54880, + 51750 + ] + } + }, + "dashboard_refresh_coalescing": { + "status": "Implemented one deferred main dashboard refresh per tick batch; UI and deterministic replay pass; performance budgets still FAIL", + "evidence": "execution/DASHBOARD_REFRESH_COALESCING.md" + }, + "composed_infrastructure_validation": { + "status": "Retained duplicate validation elimination; targeted435 checks PASS; x1avg12.849ms/x3P9546.529ms still exceed budgets", + "evidence": "execution/COMPOSED_INFRASTRUCTURE_VALIDATION.md", + "acceptance_passed": false + }, + "snapshot_ownership": { + "status": "Removed redundant aggregate deep copy; ownership87/save recovery96 PASS; x1avg11.957ms/x3P9536.908ms still above budgets", + "evidence": "execution/SNAPSHOT_OWNERSHIP.md", + "acceptance_passed": false + }, + "operations_snapshot_encoding": { + "status": "targeted563 and rendered restart97 passed; interleaved byte equivalence100", + "evidence": "execution/OPERATIONS_SNAPSHOT_ENCODING.md", + "same_state_average_us_before": 2384.77, + "same_state_average_us_after": 1999.03, + "remaining": [ + "production performance budgets", + "full-load and Windows", + "remaining original content and all acceptance gates" + ] + }, + "world_snapshot_cache": { + "status": "single cached geometry with detached reads and restore invalidation; 5 related suites PASS; 20000-cell same-state world serialization improved, full simulation budgets still fail", + "evidence": "execution/WORLD_SNAPSHOT_CACHE.md", + "all_original_acceptance": false + }, + "staff_task_clock": { + "status": "Removed redundant rejected per-tick progress calls; normal task time unchanged; 6 suites / 574 checks PASS", + "evidence": "execution/STAFF_TASK_CLOCK.md", + "all_original_acceptance": false + }, + "facility_reachability": { + "status": "UI reachability early exit; 33 new + 275 related checks PASS; rendered opening/restart97 PASS. Target query average 6222.41 to 4222.04 us (100 paired samples). Not whole-game performance acceptance.", + "evidence": "execution/FACILITY_REACHABILITY.md", + "all_original_acceptance": false + }, + "projector_single_live_check": { + "status": "Removed duplicate live projection traversal; source and adapter validation retained. Related645 and rendered opening/restart97 checks PASS. Paired200 samples: average1494.195 to1165.750 us. Full production budgets still FAIL; no whole-game speedup claim.", + "evidence": "execution/PROJECTOR_SINGLE_LIVE_CHECK.md", + "all_original_acceptance": false + }, + "operations_snapshot_profile": { + "state": "measured_candidates_rejected", + "evidence": "execution/OPERATIONS_SNAPSHOT_PROFILE.md", + "production_changes": 0, + "x1_average_us": 9734.7, + "x3_p95_us": 34201, + "budget_pass": false + }, + "snapshot_stage_profile": { + "status": "measured_candidate_rejected", + "evidence": "artifacts/snapshot-stage-profile-2026-09-29/verification.json", + "production_changes": 0, + "performance_budget_pass": false + }, + "snapshot_call_reuse": { + "status": "source_rendered_and_packed_verified", + "evidence": "execution/SNAPSHOT_CALL_REUSE.md", + "performance_budget_pass": false, + "suites": 140, + "opening_restart_checks": 97, + "windows_package_includes_change": true + }, + "current_standalone_full_load": { + "status": "24_checks_pass", + "evidence": "execution/CURRENT_FULL_LOAD.md", + "x3_p95_us": 7419, + "whole_runtime_performance_pass": false, + "windows_native": false + }, + "guide_assignment_scan": { + "status": "targeted_and_rendered_verified", + "evidence": "execution/GUIDE_ASSIGNMENT_SCAN.md", + "targeted_checks": 356, + "rendered_restart_checks": 97, + "whole_game_performance_pass": false, + "windows_package_includes_change": false + }, + "mvp_catalog_manifest_cache": { + "status": "retained_source_full_rendered_restart_packed_verified", + "evidence": "execution/MVP_CATALOG_MANIFEST_CACHE.md", + "artifact": "artifacts/manifest-cache-evaluation-2026-09-30", + "isolated_reference_checks": 39, + "isolated_candidate_checks": 44, + "live_checks": 79, + "related_isolated_checks": 832, + "abbreviated_comparison_order": "ABBA", + "frames_per_speed_per_run": 200, + "whole_x1_average_reduction_percent": 10.479991918650523, + "whole_x3_p95_reduction_percent": 17.835132792182208, + "publication_average_reduction_percent": 17.883028279234804, + "complete_checkpoint_and_final_bytes_equal": true, + "performance_budget_pass": false, + "full_load_acceptance": false, + "windows_native": false, + "all_original_acceptance": false, + "full_suites": 147, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "related_rendered_checks": 403, + "packed_runs": 17, + "packed_checks": 2639, + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "zip_sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37" + }, + "current_full_regression": { + "status": "source_full_rendered_readable_restart_and_windows_packed_verified", + "full_suites": 148, + "full_failures": 0, + "full_seconds": 657.68, + "source_changes": [], + "opening_restart_checks": 103, + "related_rendered_checks": 453, + "related_headless_checks_referenced_from_same_full": 720, + "defense_restart_checks": 66, + "human_readable_defense_labels": true, + "full_source_manifest_sha256": "0395e515c1cb090408c11c7235b36f8b2015bf350fe412b2d894b286b249ef6c", + "windows_package": "built_and_verified", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "windows_zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "windows_zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "packed_headless_checks": 2468, + "packed_rendered_checks": 458, + "evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "manifest_cache_checkpoint": { + "state": "retained_source_full_rendered_restart_packed_verified", + "full_suites": 147, + "full_failures": 0, + "source_changes": [], + "live_manifest_checks": 79, + "complete_checkpoint_and_final_store_var_bytes_equal": true, + "abbreviated_comparison_order": "ABBA", + "frames_per_speed_per_run": 200, + "x1_average_reduction_percent": 10.479991918650523, + "x3_p95_reduction_percent": 17.835132792182208, + "publication_average_reduction_percent": 17.883028279234804, + "opening_restart_checks": 103, + "related_rendered_checks": 403, + "related_headless_from_same_source_full_checks": 484, + "defense_restart_checks": 38, + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_checks_including_replays": 2639, + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "zip_sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "full_source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "natural_threat_generation": false, + "windows_native": false, + "performance_budget_pass": false, + "full_load_acceptance": false, + "all_original_acceptance": false + }, + "previous_full_regression_before_event_defense": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 147, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 708.21, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "related_rendered_checks": 403, + "artifact": "artifacts/full-regression-after-manifest-cache-2026-09-30", + "defense_restart_evidence": "artifacts/manifest-cache-defense-restart-2026-09-30/verification.json", + "source_changes": 0, + "source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "previous_full_regression_before_event_labels": { + "status": "running", + "evidence": "artifacts/full-regression-after-event-defense-2026-09-30/", + "expected_suites": 148, + "all_original_acceptance": false + } + } + }, + { + "id": "P29", + "phase": 6, + "title": "Windows実機・配布版・最終監査", + "depends_on": [ + "P28" + ], + "size": "M", + "requirement_ids": [ + "B-1.1", + "B-14.1", + "B-14.2", + "B-14.3", + "B-14.4" + ], + "work": "実EXEで新規→建設→営業→保存→終了→再起動→継続を操作。入力/表示倍率/音/日本語パス/回復を確認し、ビルドとソースのハッシュを固定。75要件と追加仕様を最終再監査。", + "acceptance": "同一リリース候補のソース試験・PCK試験・Windowsネイティブ試験・プレイ記録・資産台帳が揃う。原本差異0で初めて全実装完了。工程の過去納期達成は別判定。", + "decisions": [ + "D10" + ], + "state": "in_progress", + "estimate_person_days": [ + 3, + 7 + ], + "supplement_ids": [], + "cross_cutting_done": [ + "原本条件とUI操作が一致", + "Runtimeの実利用・会計・行動へ接続", + "途中保存・再起動で再現", + "異常/境界/中断を検証", + "関連回帰PASS", + "要件別ログとソースハッシュを保存" + ], + "owner_roles": { + "implementation": "実装担当(担当者未割当)", + "verification": "実装担当が証拠作成、受入担当が確認(担当者未割当)", + "content": "必要時に素材台帳と連携" + }, + "execution_progress": { + "status": "Partial development Windows EXE/PCK exported; isolated pack28 and macOS rendered pack28 checks PASS; not final release acceptance", + "evidence": "execution/ORIGINAL_FACILITIES_EXPORT.md", + "remaining": [ + "all original requirements and earlier package completion", + "Windows native same-build operating acceptance", + "long-term play/performance and final traceability audit" + ], + "chapel_anri_development_package": { + "status": "Windows EXE/PCK built; isolated PCK opening28, Anri22, chapel16 and macOS rendered chapel17 PASS; ZIP CRC and hashes verified", + "evidence": "execution/CHAPEL_ANRI_WINDOWS_PACKAGE.md", + "windows_native_verified": false, + "all_original_acceptance": false + }, + "current_facility_audit": { + "status": "Live audit: original30 = normal16 + definition-only1 + unmapped13; 17 mapped dimensions/layers match; customer limits unset13. Not full acceptance.", + "evidence": "execution/CURRENT_FACILITY_AUDIT.md", + "all_original_acceptance": false + }, + "cleaning_development_package": { + "status": "Windows EXE/PCK built; isolated PCK opening28, cleaning91 and macOS rendered cleaning92 PASS; ZIP CRC and SHA256 verified", + "evidence": "execution/CLEANING_WINDOWS_PACKAGE.md", + "windows_native_verified": false, + "all_original_acceptance": false + }, + "current_windows_package": { + "status": "source_full_rendered_restart_and_packed_verified", + "evidence": "artifacts/windows-event-defense-labels-release-prep-2026-09-30/", + "zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "windows_native": false, + "all_original_acceptance": false + }, + "staff_drag_demolition": { + "status": "Live pointer drag with normal demolition command before drop preserves staff schedules, keeps facility removed and reports rejection. Source106 and isolated same-PCK macOS rendered107 PASS. Production unchanged; Windows native access unavailable in current tools.", + "evidence": "execution/STAFF_DRAG_DEMOLITION.md", + "all_original_acceptance": false + }, + "staff_defense_lock": { + "state": "partial_verified", + "evidence": "execution/STAFF_DEFENSE_LOCK.md", + "checks": 119, + "related_checks": 321, + "rendered_checks": 119, + "opening_restart": "97 checks PASS; equal gameplay; source changes 0", + "remaining": [ + "current Windows package/native verification", + "broader original requirements" + ] + }, + "defense_preparation_cancel": { + "state": "verified", + "evidence": "execution/DEFENSE_PREPARATION_CANCEL.md", + "targeted_checks": 856, + "rendered_checks": 131, + "full_regression": "140 PASS; no source changes" + }, + "current_full_regression": { + "status": "source_full_rendered_readable_restart_and_windows_packed_verified", + "full_suites": 148, + "full_failures": 0, + "full_seconds": 657.68, + "source_changes": [], + "opening_restart_checks": 103, + "related_rendered_checks": 453, + "related_headless_checks_referenced_from_same_full": 720, + "defense_restart_checks": 66, + "human_readable_defense_labels": true, + "full_source_manifest_sha256": "0395e515c1cb090408c11c7235b36f8b2015bf350fe412b2d894b286b249ef6c", + "windows_package": "built_and_verified", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "windows_zip": "build/windows/TheTree-Windows-event-defense-labels-2026-09-30.zip", + "windows_zip_sha256": "2e22d793d113fa3f2eba4839d6812ec69f9762bdfe86c8804b943f79f4b1bad4", + "packed_runs": 18, + "packed_checks": 2926, + "packed_headless_checks": 2468, + "packed_rendered_checks": 458, + "evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "actor_selection_restore": { + "status": "source_full_and_rendered_restart_verified", + "headless_checks": 100, + "rendered_checks": 101, + "related_checks": 552, + "production_main_sha256": "15708bfb4d534b4f741df5b087fa56ebf36bb9ca004bb80e0bd9c43d07e6c9f1", + "actor_test_sha256": "ecc08464c20990f0a4bd5d1e5d8d6fceb6f3636c959790842a08539fc54ad911", + "full_regression": "144 PASS; no missing, extra, duplicate or changed source inputs", + "windows_export": "Windows EXE/PCK exported; isolated PCK on macOS and ZIP integrity verified; Windows native pending", + "windows_native": false, + "all_original_acceptance": false, + "full_suites": 144, + "full_opening_restart_checks": 103, + "actor_restart_checks": 116, + "opening_restart_checks": 103, + "full_regression_artifact": "artifacts/full-regression-after-actor-selection-2026-09-30", + "full_source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "actor_restart": { + "status": "verified", + "write_checks": 103, + "read_checks": 13, + "checks": 116, + "gameplay_json_equal": true, + "typed_digest_equal": true, + "source_changes": 0, + "evidence": "artifacts/actor-selection-restart-2026-09-30/verification.json", + "driver_sha256": { + "verify.py": "52d4e7e7ee1561867aab3db98704ae27d8ca3237d80dea0d39acda64ca7fed5a", + "flow.gd": "4de71e591925c022a1febeb7ac4a99a3557afa79f8c46bd5cac447934030f1c3" + } + }, + "independent_review": { + "status": "no_actionable_product_findings", + "evidence": "artifacts/actor-selection-independent-review-2026-09-30/review.json", + "sha256": "9de10628a3831e236bb951f4d674e3327767fbce7af8bc3c1c7a2f4468f6ddac" + }, + "windows_export_session": 88628, + "windows_build_validation": "verified; 12 isolated PCK runs / 2094 checks including rendered repetitions", + "evidence": "execution/ACTOR_SELECTION_RESTORE.md", + "windows_build_process_complete": true, + "windows_package": { + "zip": "build/windows/TheTree-Windows-actor-selection-2026-09-30.zip", + "sha256": "dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb", + "actor_headless_checks": 101, + "actor_rendered_macos_checks": 102, + "evidence": "artifacts/windows-actor-release-prep-2026-09-30/build-validation.json", + "windows_native": false, + "all_original_acceptance": false + } + }, + "defense_autonomy_checkpoint": { + "state": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "full_suites": 146, + "full_failures": 0, + "source_changes": 0, + "opening_rendered_checks": 103, + "defense_restart_rendered_checks": 38, + "full_evidence": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30", + "windows_native": false, + "natural_threat_generation": false, + "all_original_acceptance": false, + "packed_runs": 16, + "packed_checks": 2559, + "zip": "build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip", + "zip_sha256": "2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc" + }, + "previous_full_regression_before_defense_autonomy": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 144, + "opening_restart_checks": 103, + "windows_native": false, + "artifact": "artifacts/full-regression-after-actor-selection-2026-09-30", + "source_changes": 0, + "later_source_change": null, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 762.12, + "actor_restart_checks": 116, + "actor_restart_evidence": "artifacts/actor-selection-restart-2026-09-30/verification.json", + "source_manifest_sha256": "a16bd4acb9576357b93eb9c79f82b0707fa89a37d1dd18cefc20a14ad4393412", + "all_original_acceptance": false + }, + "previous_windows_package_before_defense_autonomy": { + "status": "packed_verified_windows_native_pending", + "evidence": "execution/CURRENT_WINDOWS_PACKAGE.md", + "zip": "build/windows/TheTree-Windows-actor-selection-2026-09-30.zip", + "bytes": 73155439, + "sha256": "dbf82a93777d1dc46c9ed0b6c32b84b03177756aed6c080bcbbde0e24ff24ffb", + "build_id": "actor-selection-2026-09-30", + "export_mode": "debug", + "architecture": "Windows x86_64 PE32+", + "engine": "4.7.2.stable.official.ed1daf0bf", + "packed_runs": 12, + "packed_headless_checks": 1743, + "packed_rendered_checks": 351, + "packed_rendered_staff_checks": 132, + "packed_total_checks_including_replays": 2094, + "packed_checks": { + "operations": 850, + "opening": 28, + "defense": 508, + "staff": 132, + "staff-rendered": 132, + "hud": 9, + "autosave": 76, + "autosave-rendered": 77, + "selection": 39, + "selection-rendered": 40, + "actor": 101, + "actor-rendered": 102 + }, + "packed_rendering_platform": "macOS Apple M4 OpenGL compatibility", + "source_suites": 144, + "source_opening_restart_checks": 103, + "source_actor_restart_checks": 116, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "source_changes": 0, + "clone_import_input_changes": 0, + "copied_runtime_inputs": 849, + "exe_sha256": "c52c04ae1283256102b446df50499420d29ab11758041b8abfc043b61dd270a1", + "pack_sha256": "697577f0d33d400d887d8f398d2afb02cb19c452aaad92e5a486d07b9718303d", + "source_manifest_sha256": "afdc8638ae17ce37545ef12bf337bccf4a6e8a4b5a09f4b8bdee55d3f0543bc4", + "validation_artifact": "artifacts/windows-actor-release-prep-2026-09-30/build-validation.json", + "later_source_change": null, + "windows_native": false, + "performance_budget_pass": false, + "all_original_acceptance": false, + "previous_development_package": { + "zip": "build/windows/TheTree-Windows-snapshot-reuse-2026-09-29.zip", + "sha256": "03629ba3325aeecb135f199c192e0ffbcb4fae539f1f0dff1c3d76495ff0f842", + "packed_headless_checks": 1527, + "packed_rendered_staff_checks": 132 + } + }, + "previous_full_regression_before_manifest_cache": { + "status": "source_and_rendered_verified", + "evidence": "execution/FULL_REGRESSION_BEFORE_MANIFEST_CACHE.md", + "suites": 146, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 743.93, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "defense_restart_evidence": "artifacts/defense-autonomy-restart-2026-09-30/verification.json", + "artifact": "artifacts/full-regression-after-defense-autonomy-2026-09-30", + "source_changes": 0, + "source_manifest_sha256": "afd27dc86d0125d5547cf58beda9461dec34fd38c0fc94e85a96e7f82d2896a5", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "previous_windows_package_before_manifest_cache": { + "status": "packed_verified_windows_native_pending", + "evidence": "execution/WINDOWS_PACKAGE_BEFORE_MANIFEST_CACHE.md", + "zip": "build/windows/TheTree-Windows-defense-autonomy-2026-09-30.zip", + "bytes": 73190478, + "sha256": "2427d259fd31f846f215eaca8ea3cccdec899dc0e7d49e8ab8a1884eb2cbeefc", + "build_id": "defense-autonomy-2026-09-30", + "export_mode": "debug", + "architecture": "Windows x86_64 PE32+", + "engine": "4.7.2.stable.official.ed1daf0bf", + "packed_runs": 16, + "packed_headless_checks": 2151, + "packed_rendered_checks": 408, + "packed_total_checks_including_replays": 2559, + "packed_checks": { + "operations": 850, + "opening": 28, + "defense": 508, + "staff": 132, + "staff-rendered": 132, + "hud": 9, + "autosave": 76, + "autosave-rendered": 77, + "selection": 39, + "selection-rendered": 40, + "actor": 101, + "actor-rendered": 102, + "defense-cycle": 163, + "defense-autonomy": 189, + "defense-management": 56, + "defense-management-rendered": 57 + }, + "packed_rendering_platform": "macOS Apple M4 OpenGL compatibility", + "source_suites": 146, + "source_opening_restart_checks": 103, + "source_defense_restart_checks": 38, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "source_changes": 0, + "clone_import_input_changes": 0, + "copied_runtime_inputs": 851, + "exe_sha256": "c52c04ae1283256102b446df50499420d29ab11758041b8abfc043b61dd270a1", + "pack_sha256": "8d7ba120e35593fffcadf08174b32c91b78e8892a454393535975500f2309081", + "source_manifest_sha256": "49e05221691a06e2781a720b734f38f7996be1237cf3738e2f0c7f737f794fe9", + "validation_artifact": "artifacts/windows-defense-release-prep-2026-09-30/build-validation.json", + "later_source_change": null, + "windows_native": false, + "performance_budget_pass": false, + "all_original_acceptance": false + }, + "manifest_cache_checkpoint": { + "state": "retained_source_full_rendered_restart_packed_verified", + "full_suites": 147, + "full_failures": 0, + "source_changes": [], + "live_manifest_checks": 79, + "complete_checkpoint_and_final_store_var_bytes_equal": true, + "abbreviated_comparison_order": "ABBA", + "frames_per_speed_per_run": 200, + "x1_average_reduction_percent": 10.479991918650523, + "x3_p95_reduction_percent": 17.835132792182208, + "publication_average_reduction_percent": 17.883028279234804, + "opening_restart_checks": 103, + "related_rendered_checks": 403, + "related_headless_from_same_source_full_checks": 484, + "defense_restart_checks": 38, + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_checks_including_replays": 2639, + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "zip_sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "full_source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "natural_threat_generation": false, + "windows_native": false, + "performance_budget_pass": false, + "full_load_acceptance": false, + "all_original_acceptance": false + }, + "event_defense_integration": { + "status": "integrated_current_source_full_and_restart_verified", + "evidence": "artifacts/event-defense-integration-2026-09-30/", + "runtime_schema": 13, + "event_checks": 186, + "natural_producer_complete": false, + "all_original_acceptance": false, + "full_evidence": "artifacts/full-regression-after-event-defense-labels-2026-09-30/" + }, + "previous_full_regression_before_event_defense": { + "status": "source_and_rendered_verified", + "evidence": "execution/CURRENT_FULL_REGRESSION.md", + "suites": 147, + "failures": 0, + "missing": 0, + "extra": 0, + "duplicates": 0, + "suite_seconds": 708.21, + "opening_restart_checks": 103, + "defense_restart_checks": 38, + "related_rendered_checks": 403, + "artifact": "artifacts/full-regression-after-manifest-cache-2026-09-30", + "defense_restart_evidence": "artifacts/manifest-cache-defense-restart-2026-09-30/verification.json", + "source_changes": 0, + "source_manifest_sha256": "c09bc629442d319a3e4b928859213d23735943cfb32d91855f50380d5724449d", + "later_source_change": null, + "windows_native": false, + "all_original_acceptance": false + }, + "previous_windows_package_before_event_defense": { + "status": "packed_verified_windows_native_pending", + "evidence": "execution/CURRENT_WINDOWS_PACKAGE.md", + "zip": "build/windows/TheTree-Windows-manifest-cache-2026-09-30.zip", + "bytes": 73192182, + "sha256": "b90d6ae5f7e5f23e1d77084bd28226317e1c52a613fdb079bf036babe9dded37", + "build_id": "manifest-cache-2026-09-30", + "export_mode": "debug", + "architecture": "Windows x86_64 PE32+", + "engine": "4.7.2.stable.official.ed1daf0bf", + "packed_runs": 17, + "packed_headless_checks": 2231, + "packed_rendered_checks": 408, + "packed_total_checks_including_replays": 2639, + "packed_checks": { + "operations": 850, + "opening": 28, + "defense": 508, + "staff": 132, + "staff-rendered": 132, + "hud": 9, + "autosave": 76, + "autosave-rendered": 77, + "selection": 39, + "selection-rendered": 40, + "actor": 101, + "actor-rendered": 102, + "defense-cycle": 163, + "defense-autonomy": 189, + "defense-management": 56, + "defense-management-rendered": 57, + "manifest-cache": 80 + }, + "packed_rendering_platform": "macOS Apple M4 OpenGL compatibility", + "source_suites": 147, + "source_opening_restart_checks": 103, + "source_defense_restart_checks": 38, + "zip_crc_pass": true, + "zip_file_hashes_pass": true, + "source_changes": 0, + "clone_import_input_changes": 0, + "copied_runtime_inputs": 851, + "exe_sha256": "c52c04ae1283256102b446df50499420d29ab11758041b8abfc043b61dd270a1", + "pack_sha256": "a393d90ab602f9cbe3c5092535b03f910b2217b2e3ba0e45a02c955e423ab8ee", + "source_manifest_sha256": "a9476841dcb6dba6697380b9f8005617986c04d23b6ef29b287bd351f437102f", + "validation_artifact": "artifacts/windows-manifest-release-prep-2026-09-30/build-validation.json", + "later_source_change": null, + "windows_native": false, + "performance_budget_pass": false, + "all_original_acceptance": false + }, + "defense_human_labels": { + "status": "source_full_rendered_restart_and_packed_verified_windows_native_pending", + "evidence": "artifacts/event-defense-labels-integration-2026-09-30/", + "isolated_checks": 1350, + "all_original_acceptance": false, + "full_suites": 148, + "restart_checks": 66, + "rendered_checks": 453, + "packed_checks": 2926, + "windows_native": false + }, + "previous_full_regression_before_event_labels": { + "status": "running", + "evidence": "artifacts/full-regression-after-event-defense-2026-09-30/", + "expected_suites": 148, + "all_original_acceptance": false + } + } + } +] diff --git a/docs/traceability/original/BASELINE.txt b/docs/traceability/original/BASELINE.txt new file mode 100644 index 0000000..71a896a --- /dev/null +++ b/docs/traceability/original/BASELINE.txt @@ -0,0 +1,258 @@ +# 1. プロジェクト基本定義書 (Project Basic Definitions) +## 1.1 基本情報 (General Information) + * **プロジェクトコード:** Project Yggdrasil + * **タイトル(仮):** The Tree + * **ジャンル:** ファンタジー世界樹経営シミュレーション / タワービルディング / 成人向け (R18) + * **プラットフォーム:** PC (Windows) + * **想定プレイ時間:** + * メインストーリークリア: 20時間 ~ 30時間 + * 完全クリア(全エンド・全ヒロイン攻略・図鑑コンプ): 50時間以上 + * **視点:** 2Dサイドビュー(断面図)形式 +## 1.2 ゲームコンセプト (Game Concept) + * **「聖域」と「魔窟」の二面性経営:** プレイヤーは巨大な「世界樹」を舞台に、地上へ伸びる「表(聖域・秩序)」**と、地下へ潜る**「裏(魔窟・混沌)」という、相反する性質を持つ二つの領域を同時に管理・拡張する。 + * **覗き見る支配者:** プレイヤーは神の視点(管理者)として、客や従業員の自律的な行動を観察し、時にそのプライベートを覗き見(Peeping)し、干渉することで運命を操作する。 + * **欲望のタワービルディング:** フロアを積み重ねることで世界樹自体が成長する。高さを求めるほど「権威(表)」が増し、深さを求めるほど「富(裏)」が増す構造。 +## 1.3 コアゲームループ (Core Game Loop) + 1. **建設 (Build):** マップに施設(客室、食堂、風俗店など)と動線(通路、エレベーター)を配置。表層と裏層を切り替え効率的な構造を設計する。 + 2. **集客 (Attract):** 多様な客が来店。客のAIは「睡眠」「食欲」「性欲」「娯楽」の欲求に従い自動行動する。 + 3. **サービスと搾取 (Service & Exploit):** + * **表:** 健全なサービス(宿泊・食事・治療)で「信頼(信仰値)」と「クリーンな資金」を得る。 + * **裏:** アングラなサービス(賭博・性風俗・闇取引)で「依存度」と「莫大な裏金」を得る。 + 4. **管理と介入 (Manage & Intervene):** 従業員のシフト管理、バックヤード動線確保。VIP客の覗き見、脅迫、人身売買。 + 5. **拡張と防衛 (Expand & Defend):** 世界樹ランクを上げ、より高度な施設を解禁。外敵を防衛力で撃退する。 +## 1.4 コアメカニクス定義 (Core Mechanics Definition) + * **デュアルレイヤー・システム:** マップ情報を2層で管理しUIで瞬時に切り替える。「表層 (Front Layer)」は客が目にする演出された空間。「裏層 (Back Layer)」は従業員のみが知る機能的空間・覗き部屋。 + * **自律AIと動線パズル:** 全てのキャラクターは自律AI。エレベーター配置が不適切だと「渋滞」発生。施設ごとのシフト配置管理が必要。 + * **信仰と依存:** 「信仰 (Faith)」は表の経営で得られストーリー進行に関わる。「依存 (Addiction)」は裏で客に植え付け、全財産を吐き出させるステータス。 +## 1.5 世界観・ナラティブ定義 (World & Narrative Definition) + * **舞台:** 旧魔王城跡地に生えた「世界樹」。 + * **主人公:** かつて勇者として使い捨てられた男。宿屋の主として再起し真の王への即位を目指す。 + * **ヒロイン:** 「純愛」「NTR」「快楽堕ち」の3つの運命を持つ7名のメインキャラクター。 +## 1.6 成人向け要素の定義 (Adult Content Definition) + * **区分:** R18(成人向け) + * **表現の方向性:** 純愛(合意の上での性愛)、NTR(ライバルに奪われる背徳感)、堕落(快楽・金による尊厳破壊)。 + * **経営的搾取:** 覗き(マジックミラー越しの監視)、脅迫、人身売買。 +# 2. マップ構造とデュアルレイヤーシステム詳細仕様書 +## 2.1 マップ基本構造 (Map Basic Structure) + * **視点:** 2Dサイドビュー(断面図)。1ブロック単位のグリッドで管理。 + * **領域区分:** 地上エリア(幹・枝 / Y > 0)、地下エリア(根 / Y < 0)、地面(境界線 / Y = 0)。 + * **建築制限:** 「地面」または「他の施設・幹」に隣接必須。進行に伴い建築可能領域が拡張。 +## 2.2 デュアルレイヤーシステム (Dual Layer System) +マップ上の全てのグリッド座標に、Z軸方向の深さとして2つのスロットが存在する。 + 1. **表層 (Front Layer / Z-1):** 客ユニット、接客中の従業員。客からはこの層しか見えない。 + 2. **裏層 (Back Layer / Z-2):** 待機・移動中の従業員、警備員、運搬物資、覗き見中のプレイヤー。客からは認識できない。 + * **表示切替:** 表層モード時は裏層が半透明、裏層モード時は表層がワイヤーフレームになる。 +## 2.3 表層:客エリア詳細 (Front Layer Specifications) + * **施設:** 客室、商業、客用通路・エレベーター。 + * **環境ステータス:** 景観度 (Beauty)、清潔度 (Cleanliness)、騒音度 (Noise) が計算され客の満足度に影響する。 +## 2.4 裏層:バックヤード詳細 (Back Layer Specifications) + * **施設:** 待機室、従業員寮、倉庫、裏通路、業務用エレベーター、リネンシュート、**覗き部屋**、隠し扉。 + * **接続ルール:** 表層の施設奥に設置された「従業員用ドア」を通じて 表層 ⇔ 裏層 を移動する。客は通過不可。 +## 2.5 覗き・脅迫ギミック (Peeping Mechanics) + * **建設条件:** 表層のターゲット施設(VIPルーム、風俗個室、脱衣所)の裏層に建設。覗き部屋を建設したグリッドは裏通路として使えなくなるジレンマあり。 + * **機能:** マジックミラー視点での鑑賞(通常マップとは違うアングル)。記録係を配置し「脅迫ネタ」を収集。 +## 2.6 エリアゾーニング詳細 (Zoning) + * **地上(聖域):** 採光・マナ濃度ボーナス。一般市民・貴族・聖職者向け。 + * **地下(魔窟):** 遮音・隠蔽ボーナス。犯罪者・魔物・依存症患者向け。 +# 3. 時間進行とゲームサイクル詳細仕様書 +## 3.1 基本時間ロジック (Basic Time Logic) + * **スケール:** 現実時間 10秒 = ゲーム内 1時間。 + * **操作:** 再生速度変更(Pause/x1/x2/x3)。Pause中も建設・指示可能(アクティブポーズ)。 +## 3.2 24時間サイクルとフェーズ (24-Hour Cycle) + * **朝 (06:00-10:00):** 「下りラッシュ」。チェックアウト、朝食。昼番出勤・清掃時間。 + * **昼 (10:00-16:00):** 新規来店、施設回遊。経営が安定し建設に適する。 + * **夕 (16:00-20:00):** 「上りラッシュ」。帰還した冒険者が大浴場・食堂へ殺到。18:00に夜番へシフト交代(バックヤード大混雑)。 + * **夜 (20:00-06:00):** 健全客は睡眠、依存客は地下へ。性欲解消・NTRイベント発生のピーク。 +## 3.3 シフト制システム詳細 (Shift System) + * **勤務枠:** 各施設に 昼番 (06:00-18:00) と 夜番 (18:00-06:00) のスロットを設定。 + * **体力と気絶:** 勤務で体力減少、休憩で回復。0になると「気絶」し救護搬送が必要。客に見られると評判ダウン。 + * **通勤ラッシュ:** 交代時刻の裏層の動線設計が悪いと、従業員が渋滞し業務が滞る。 +## 3.4 日次決算 (Daily Settlement) + * **時刻:** 毎日 00:00 に決算レポート表示。 + * **処理:** 売上(表裏の収益)から経費(人件費・維持費・借金返済)を引く。赤字決算が3回続くとゲームオーバー。 +## 3.5 環境・気象変動 (Environment & Weather) + * **天候:** 雨(客足減・滞在延長)、嵐(新規客ゼロ)、マナの霧(魔法効率UP・興奮度UP)。 + * **流行:** 数日おきのニュースで特定の客層・施設の売上がブーストされる。 +# 4. 客(冒険者)ユニット行動AIと交通システム詳細仕様書 +## 4.1 ユニット内部パラメータ (Internal Parameters) + * **資源:** 所持金 (Budget)、戦利品 (Loot)。 + * **四大欲求 (0-100):** 睡眠欲 (客室で解消)、性欲 (地下で解消)、食欲 (食堂で解消)、娯楽欲 (カジノ等で解消)。 + * **状態:** 忍耐力 (渋滞等で減少し0で激怒退店)、清潔度 (汚れを歩くと減少・伝播)、依存度 (地下利用で上昇)。 +## 4.2 AI行動決定ロジック (AI Decision Logic) + * 現在の「最も高い欲求」を基に目的地を決定。 + * **施設選択の重み付け:** 「距離」「行列の短さ」「価格・クラス(所持金に見合うか)」で算出。 +## 4.3 交通・移動システム (Traffic & Movement) + * **経路探索:** A*アルゴリズム。エレベーター待ち時間もコストに含む。 + * **垂直移動:** 蔓の昇降機(客用・定員あり)、業務用エレベーター(裏層・大容量)、階段(速度遅い)、マナワープ(瞬間移動・コスト大)。 + * **渋滞デバフ:** 通路にユニットが重なると移動速度50%低下。 +## 4.4 状態変化・特殊ステータス + * **依存症:** Lv3(廃人)になると退店を拒否し装備品を売却して居座る。 + * **破産処理:** 所持金0で依存度が高い場合、男性は「暴動」、女性は借金契約を結ばされ「奴隷ユニット」として回収される。 +## 4.5 クラス別AI特性 + * **貴族:** 金払いは良いが忍耐力が極端に低い。 + * **聖職者:** アングラ要素を目撃すると即帰宅&風紀ダウン。 + * **エルフ:** 自然物を好む。 + * **魔族/亜人:** 地下耐性が高く満足度が下がらない。 +# 5. 従業員ユニット・モブキャラクター詳細仕様書 +## 5.1 ユニット分類と基本定義 + * **一般従業員:** ギルド雇用。日給発生。ストレスが溜まりやすく解雇自由。 + * **奴隷:** 市場購入や借金客拘束で獲得。食費のみ。ストレス耐性が高く「人身売買」で売却可能。 +## 5.2 パラメータ詳細 + * **基礎:** 体力 (Stamina / 0で気絶)、ストレス (Stress)、忠誠度 (Loyalty)。 + * **業務適正:** 運営技術 (料理・清掃)、奉仕技術 (接客・性処理)、戦闘力 (警備)。 + * **特性 (Traits):** 生成時にランダム付与 (例: 力持ち、名器、サボり魔、潔癖症)。 +## 5.3 職種と役割 (Jobs & Roles) + * **サービス業(表層):** ウェイター、受付係、バニー。 + * **バックヤード業(裏層):** シェフ、清掃員、運搬員、救護班。 + * **アングラ・防衛業:** 娼婦、警備員、調教係。 +## 5.4 労務管理システム (Labor Management) + * **シフト制:** 昼番・夜番配置。非番時は待機室等で体力回復。 + * **気絶回収:** 体力0で倒れた者は、救護班が裏ルートを通って回収しなければならない。 +## 5.5 獲得と処分 + * **獲得:** 求人募集、奴隷市場、破産客の捕獲。 + * **処分:** 解雇(退職金)、人身売買(ステータスにより価格変動。他の従業員の忠誠度低下リスクあり)。 +# 6. 従業員ユニット・ユニークキャラクター詳細仕様書 +## 6.1 基本定義 (Basic Definition) + * **特性:** 代替不可能、雇用コスト・維持費無料、成長上限が高い、解雇・売却不可。固有スキルを所持。 +## 6.2 キャラクターリストと役割 + * **アンリ (女将):** [看板娘の微笑] 配置フロアの全施設売上+20%。 + * **フィーネ (メイド):** [神速の奉仕] 清掃速度3倍。暗殺確率UP。 + * **トマリ (騎士):** [鉄壁の守護] フロアトラブル0%。防衛スコア加算。 + * **アメリア (シスター):** [聖なる寄付] 寄付金3倍。回復速度2倍。 + * **リズ (狩人):** [エルフの眼] レア素材発見率UP。高級食材消費減。 + * **ミミ (商人):** [商魂たくましい] 仕入れ-20%、売却+20%。 + * **サツキ (雑用):** [高貴なオーラ] VIP客満足度上昇1.5倍。チップ増。 + * **ライバルキャラクター:** ライ、リーゼンバルト、ソウ等(雇用可能だがNTRリスク発生)。 +## 6.3 ステータスとパラメータ + * **基礎/業務能力:** 体力、ストレス、運営、奉仕、戦闘、アングラ。 + * **レベルと成長:** 業務でEXP蓄積、Lv99まで成長可能。 +## 6.4 スキルシステム + * **パッシブスキル:** 配置するだけで自動発動。 + * **アクティブスキル:** 防衛戦等で任意発動。 + * **ルート変化:** 純愛/堕落などの進行度で習得するスキルが変化する。 +## 6.5 運用とライフサイクル + * **シフト:** 警告を無視すれば24時間勤務も可能(ストレス激増)。衣装変更で適正補正変化。 + * **離脱:** 原則ロストしないが、防衛戦敗北での一時拉致や、特定バッドエンド確定時にのみ敵や背景オブジェクト化する。 +# 7. 恋愛・侵食システム詳細仕様書 【完全版】 +## 7.1 概要とコアコンセプト +3つの感情パラメータと防御値「愛の盾」、直接対決「決闘」によってヒロインの運命(純愛・NTR・堕落)をシミュレートする。 +## 7.2 感情パラメータと「愛の盾」 + * **好感度 (Affection):** 主人公への愛。「愛の盾」の最大耐久値。 + * **淫乱度 (Lust):** 快楽への依存。盾の防御力(ダメージ軽減率)を下げる。 + * **NTR度 (Netorare):** ライバル依存。盾破壊後に上昇する。 + * **『愛の盾』:** NTR攻撃を無効化するバリア。耐久値0でブレイクし、NTR侵食が始まる。 +## 7.3 NTR侵食プロセス + 1. **盾への攻撃:** ライバルと同配置時、盾耐久値が減少。 + 2. **ブレイク:** 耐久値0で「心の隙間」発生。 + 3. **NTR進行:** ライバル接触で直接NTR度が上昇。 + 4. **陥落確定:** NTR度が閾値越えで盾が消滅。純愛ルート完全消失。 +## 7.4 決闘システム (Duel System) +NTR阻止のための直接的解決手段。 + * **種類:** 武力決闘、商談決闘、夜の決闘(性技対決)。 + * **リターン:** ライバル追放・隷属化、「愛の盾」全回復。 + * **リスク:** 敗北時、ヒロインを即座に強奪されNTRルート確定。 +## 7.5 ルート攻略とプレイヤーアクション + * **純愛:** デート・会話で盾を回復。ライバルを物理的に隔離、または決闘で排除。 + * **NTR:** 意図的に放置・アングラ労働で盾を弱体化させ、覗き部屋から寝取られる過程を鑑賞する。 + * **堕落:** 媚薬や地下業務で淫乱度を上げ、盾の耐久値上限を溶かして家畜化させる。 +## 7.6 Hステータスと履歴 + * **履歴:** 処女喪失相手、経験数、現在のパートナーを記録。 + * **妊娠:** 排卵日あり。父親判定、妊婦期の外見・ステータス変化。 + * **刻印:** 指輪(純愛)、キスマーク(NTR)、淫紋・首輪・焼印(堕落・家畜)が立ち絵に反映される。 +# 8. 施設システム詳細仕様書 +## 8.1 施設の基本定義 + * **建設ルール:** 地面・完成済み施設に隣接。表層は客用通路、裏層は裏通路にアクセス必須。 + * **レベル:** LvMAXまで拡張可能。詳細設定(マイクロマネジメント)が解禁される。 +## 8.2 表層施設:客用エリア + * **宿泊:** 一般客室、上級客室、VIPスイート(覗き対応・要高景観)。 + * **飲食:** 大衆食堂、レストラン、酒場。裏層の「厨房」とセット運用必須。 + * **衛生・娯楽:** 大浴場(覗き対応)、カジノ、闘技場、礼拝堂。 + * **アングラ:** 風俗個室(覗き対応)、高級ソープ、SM拷問室、闇市場。 +## 8.3 裏層施設:バックヤード + * **従業員管理:** 待機室、従業員寮(夜這い可)、警備員詰所。 + * **物流:** 厨房(表層飲食店の裏)、倉庫、清掃員室、医務室。 + * **特殊:** 覗き部屋(VIP/浴場/風俗の裏限定・裏通路を寸断)、地下牢、マナ炉。 +## 8.4 動線・インフラ設備 + * **垂直移動:** 蔓の昇降機(表・客用)、業務用EV(裏・高速)、階段、マナワープ。 + * **通路:** 客用通路、裏通路、従業員ドア(表と裏を接続)。 +## 8.5 施設マイクロマネジメント +LvUPで価格設定(ぼったくり設定含む)、食材ランク、風呂の混浴設定、風俗のハードプレイ設定、カジノのイカサマ率設定などが解禁される。 +# 9. 経済システムと関連する施設詳細仕様書 +## 9.1 通貨とリソース + * **資金 (Gold):** 表の金と裏の金。 + * **マナ (Mana):** 施設維持エネルギー。枯渇すると全館機能停止。 + * **資材 (Materials):** 建設・修理素材。 +## 9.2 収益と支出 + * **収益 (表):** 宿泊料、飲食売上。 + * **収益 (裏):** 性風俗、カジノ、脅迫金、人身売買。 + * **支出:** 人件費、維持費、仕入れ原価、闇銀行への借金返済、世界樹ランク税(上納金)。 +## 9.3 経済関連施設 + * **受付/精算所:** ロビーに設置。処理が遅いと機会損失。 + * **金庫室:** 所持金上限解放。盗賊の標的になる。 + * **購買部:** 消費自動発注、戦利品買取。 + * **闇銀行:** 即時融資。返済滞納で店舗破壊部隊が襲来。 +## 9.4 客の経済圏と破産システム + * **予算AI:** 「生存経費」より「娯楽経費(依存)」を優先するようになる。 + * **破産:** ツケ払いで信用度を超過すると破産。女性客は「代物弁済」イベントで即座に奴隷化・投獄される。 +# 10. 建築可能施設 詳細仕様書 +## 10.1 宿泊施設 + * 一般客室(2x1,表)、上級客室(4x1,表)、VIPスイート(6x2,表高層,覗き対応)、ドミトリー(4x1,表地下)。 +## 10.2 飲食施設 + * 大衆食堂(4x2,表)、レストラン(6x2,表)、酒場(4x2,表,騒音発生)、屋台(2x1,表)。 +## 10.3 衛生・娯楽施設 + * 大浴場(6x2,表,覗き対応)、カジノ(6x3,表地下)、闘技場(8x4,表地下)、礼拝堂(4x3,表)。 +## 10.4 アングラ・性風俗施設 + * 風俗個室(2x1,表地下,覗き対応)、高級ソープ(4x2,表地下)、SM拷問室(4x2,表地下)、闇市場(4x2,表地下)。 +## 10.5 バックヤード施設 + * 厨房(4x2,裏)、倉庫(4x2,裏)、待機室(4x2,裏)、従業員寮(2x1,裏)、警備員詰所(2x2,裏)、清掃員室(2x1,裏)、医務室(4x2,裏)。 +## 10.6 特殊・ギミック施設 + * 覗き部屋(2x1,裏限定)、地下牢(4x2,裏地下)、マナ炉(6x3,裏地下,奴隷消費)。 +## 10.7 インフラ・防衛 + * 蔓の昇降機(2x2,表)、業務用EV(3x2,裏)、ワープ(2x2,表)、魔法砲台(外壁)。 +# 11. UX・ゲームプレイ画面詳細仕様書 +## 11.1 メイン画面レイアウト + * **上部:** 資金・マナ・資材リソース、時間・天候、世界樹ランク。 + * **下部:** [FRONT / BACK] レイヤー切替巨大トグルスイッチ、建設・管理メニュー。 + * **マップエリア:** マウススクロール、ズームインアウト対応の2D断面図。表/裏の視覚的強調・半透明化制御。 +## 11.2 インスペクター・詳細ウィンドウ + * **施設:** ポリシー設定、シフト配置(ドラッグ&ドロップ)。 + * **ユニット:** 客の欲求バー・AI思考ログ、従業員の感情3軸グラフ・愛の盾耐久値。 +## 11.3 覗き・背徳インターフェース + * 裏層の「カメラアイコン」からマジックミラー視界へ移行。 + * アングル変更、スクリーンショット(脅迫証拠収集)、介入操作が可能。 +## 11.4 建設・通知UX + * **建設:** グリッドスナップ、接続ライン警告表示。 + * **通知:** ユニット頭上のフキダシ(Zzz、怒り、ハート)、イベントログ。 + * **カットイン:** 「NTR侵食開始」などの重要警告、決闘発生、防衛戦開始演出。 +# 12. デザイン基本方針仕様書 +## 12.1 アートコンセプト + * **テーマ:** 『二面性 (Duality)』。表(自然・秩序・光)と裏(人工・混沌・闇)のコントラスト。 + * **グラフィック:** 高精細ドット絵(マップ・SDキャラ)と、高品質アニメ塗り立ち絵(イベント・会話)。 +## 12.2 レイヤー切替演出 + * ガシャンという音ではなく、「魔法のレンズを通した」ように波紋が広がり世界が反転するシームレス演出。 +## 12.3 キャラクター表現 + * **ドット絵:** 呼吸、胸の揺れ、職種別歩行、生々しいHアニメーションループ。 + * **立ち絵:** 破損、汚れ、刻印差分。純愛(ソフトフォーカス)とNTR/堕落(強い影・レイプ目)の明確な描き分け。 +## 12.4 UI / サウンドデザイン + * **UIスキン:** 表モード(羊皮紙・ファンタジー)と裏モード(黒鉄・モニター風)で動的に変化。 + * **BGM:** 表裏で同じ曲のアレンジ違い(裏は重低音ベース強調)をクロスフェード再生。 + * **SE:** Hサウンドの水音へのこだわり、環境音(表の小鳥 vs 裏の蒸気管)による空間演出。 +# 13. 必要デザインアセットリスト +## 1. 2Dイラストレーション + * メイン・サブキャラ立ち絵(表情・衣装・刻印差分含む)。 + * HシーンイベントCG(ルート別・差分あり)、モブ汎用Hスチル。 +## 2. ドット絵・アニメーション + * ユニットSDモーション(待機・歩行・作業・気絶・各種Hアニメループ)。 + * エフェクト(愛の盾、煙、マナの光)。 +## 3. マップ・施設タイルセット + * 施設Lv1〜3の外観・内装(表裏両方)。背景遠景。 +## 4. UI/UX素材 + * 表裏スキン、感情グラフUI、覗きモード専用オーバーレイ(ビデオカメラ風)。 +## 5. サウンドアセット + * 動的変化対応BGM、環境音、UI操作音、H用SE(生音系)、キャラクターボイス。 +# 14. ⭐️要修正・開発スケジュール・ロードマップ (大枠) +**対象期間:** 2026年3月 ~ 2027年12月 (マスターアップ) + * **【フェーズ1】プロトタイプ (2026/03-08):** 表裏レイヤー切替、AI移動ロジック、基本素材作成。MVPの完成。 + * **【フェーズ2】アルファ版量産 (2026/09-2027/02):** パラメータ管理実装、立ち絵・施設タイルの量産、シナリオ・通常BGM組み込み。 + * **【フェーズ3】ベータ版・R18実装 (2027/03-08):** Hシーン実装、覗きモード組み込み、ボイス収録・実装、ゲームバランスの徹底調整。 + * **【フェーズ4】ポリッシュ・マスターアップ (2027/09-12):** 進行不能バグ等のデバッグ、UIレスポンス向上、マスタリング、最終完成。 diff --git a/docs/traceability/original/GUEST_AI_VERIFICATION_2026-09-29.md b/docs/traceability/original/GUEST_AI_VERIFICATION_2026-09-29.md new file mode 100644 index 0000000..e72ec40 --- /dev/null +++ b/docs/traceability/original/GUEST_AI_VERIFICATION_2026-09-29.md @@ -0,0 +1,51 @@ +# 最大欲求優先AIと来客観察UIの実装 + +2026-09-29。原本 `BASELINE.txt` 81〜83行(B-4.2)を現行のGuestServiceとRuntimeへ反映した。原本そのものは変更していない。 + +## 挙動の変更 + +従来は欲求解消・好み・品質・価格等の総合点を先に比較しており、空腹90・睡眠70でも高品質な客室を選び得た。現在は、施設が解消する四大欲求(食欲・睡眠欲・娯楽欲・性欲)の現在値を優先し、同じ欲求優先度の候補間で既存の距離・行列・価格・品質・好み等の点数を比較する。候補側が指定する緊急度で実欲求を上書きしない。 + +到達不能・休業・所持金不足・最低品質・クラス制約等の既存フィルターは維持する。同点時は既存の安定した施設ID順に決め、候補の投入順に左右されない。主欲求を解消する施設が利用不能、効用が退館閾値未満、予約失敗の場合は次の利用可能な候補へ進む。これは既存の代替施設・退館処理を維持する実装判断で、原本にない閾値や料金を新設していない。 + +新たな意思決定時に適用する。移動・待機・利用途中の目的地を強制的に変更しない。サービス属性一致の料金加算とは別の選択規則であり、未設定の加算額・不足基準を確定していない。衛生・救護の既存状態と緊急回復経路も保持する。 + +## ゲーム内の確認方法 + +上部の「来客」ボタンを押すと、実在する客を選び、四欲求のバー、所持金、忍耐、現在の行動、目的地を確認できる。現在最も高い欲求を文字でも表示する。同値の欲求は併記し、全て0なら「なし」。退館者の選択は残さず、読込後は復元されたGuestServiceから表示を作り直す。 + +表示は現在値と選択方針の説明。過去の意思決定を記録した思考履歴ではない。B-11.2の四欲求観察部分を進めたが、同要件群全体を完了とはしない。 + +パネルの更新は表示中のみ0.5秒間隔。経路探索や候補採点を表示のために再実行せず、保存スキーマも追加しない。描画確認ではSimulationDigest不変を検証した。 + +## 検証 + +[最終集計](../../../artifacts/guest-ai-2026-09-29/verification-summary.json): 関連 **627項目PASS・失敗0**。今回は関連試験の再実行であり、前回の全83本を新たに全件実行した結果ではない。 + +| 対象 | 項目数 | 主な確認 | +|---|---:|---| +| GuestService | 137 | 低い欲求の高品質施設より最大欲求を優先、距離・列・価格、到達・所持金、同値、閾値と予約失敗の代替、保存再生 | +| 来客UI | 22 | 実HUDから開く、全行動状態、欲求更新、空一覧、退館、復元、観察による状態不変 | +| Runtime客・職員統合 | 40 | 自律行動とサービス、24時間再生の完全一致 | +| Runtime異常系 | 74 | 行動・経路・保存境界 | +| ダッシュボード | 127 | HUD、操作、複数解像度のレイアウト | +| 営業 | 48 | 料金・受付・待機等 | +| 保存ライフサイクル | 96 | ディスク保存、復元、破損と回復 | +| 通常開業 | 53 | 初期予算から実建設・雇用・宿泊・00:00決算・翌朝、実描画 | +| macOS配布PCK | 15 | ソース非依存の18施設・建設・来客パネル | +| Windows配布PCK | 15 | 同上 | + +`make check`、`git diff --check`も成功。Macの配布実行ファイルを起動して正常終了。Windows向け書き出しとPCK検証はMac上で行い、今回のWindows実機操作・性能測定は未実施。 + +[来客パネルの実画面](../../../artifacts/guest-ai-2026-09-29/observer.png) は、テスト用の実GuestRuntimeの欲求を設定した表示確認。自然来客の営業受入は上記53項目の別シナリオで確認した。 + +## 配布版 + +- Mac: `build/macos/TheTree-GuestAI-2026-09-29.app` +- `TheTree-ドット版を起動.command` を上記へ更新(ローカルとDriveの既存起動リンク)。 +- Windows: `build/TheTree-Windows-GuestAI-2026-09-29.zip`。展開したEXEとPCKを同じフォルダーに置き、EXEを起動する。 +- [ソース・配布物のハッシュ](../../../artifacts/guest-ai-2026-09-29/manifest.json) + +## 残件 + +天候・流行、救護搬送のローカル統合、全施設・経済・進行・クラス特性、思考履歴などは残る。[原本対応表](IMPLEMENTATION_STATUS.md) と `requirements.json` を参照。本変更で原本全体の完成とはしない。 diff --git a/docs/traceability/original/IMPLEMENTATION_STATUS.md b/docs/traceability/original/IMPLEMENTATION_STATUS.md new file mode 100644 index 0000000..e193014 --- /dev/null +++ b/docs/traceability/original/IMPLEMENTATION_STATUS.md @@ -0,0 +1,89 @@ +# 原本基準の現行実装状況 + +2026-09-29、ローカル制作コピーの監査。詳細・証拠・差異は [75要件トレーサビリティ](audit-2026-09-29/TRACEABILITY.md)。原本保存版との照合であり、今回Drive再取得は行っていない。 + +- 一部実装・差異あり: 52件 +- 実装あり・全受入未検証: 2件 +- 該当機能未実装: 9件 +- 検証済み(記載範囲): 8件 +- 工程要件・完了未認定: 4件 + +90スイートPASSは原本全実装を意味しない。工程4件を含む複合要件のため件数から完成率を算出しない。 + +| 要件 | 判定 | 根拠・差異 | +|---|---|---| +| B-1.1 基本情報・Windows・2D・20〜30時間/50時間以上 | 一部実装・差異あり | Windows配布物と2D断面図を実装。 20〜30時間の本編、50時間以上の全攻略の受入証拠がない。今回Windowsネイティブ確認は実施していない。 | +| B-1.2 地上/地下の二面性経営・観察介入・権威/富 | 一部実装・差異あり | 地上地下・二層の建設と観察、ランク・経済の基盤あり。 高さと権威、深さと富、介入を一貫して成立させる完成ゲームの受入が不足。 | +| B-1.3 建設→集客→収益→管理→拡張防衛 | 一部実装・差異あり | 建設・集客・宿泊飲食収入・雇用・物流の通常ループあり。防衛APIとRuntimeテストあり。 全裏収益、全施設、通常プレイでの防衛発生・高ランク到達まで未完了。APIテストを全ループ完了に数えない。 | +| B-1.4 二層・自律AI・渋滞・信仰と依存 | 一部実装・差異あり | 二層、客AI、シフト、渋滞、依存値と信仰値の基盤あり。 信仰による本編進行、依存客の全特殊行動まで接続されていない。 | +| B-1.5 世界観・主人公・7ヒロイン3運命 | 一部実装・差異あり | アンリとライの2名のデータあり。 原本の7ヒロインと各3ルート、主人公の物語・全エンドが揃っていない。 | +| B-1.6 成人向けルートと経営的搾取 | 一部実装・差異あり | 成人区分・対象施設・コンテンツ制御の基盤あり。 原本記載の全ルート・イベント・経営要素としては未完成。データやタグの存在を内容実装と扱わない。 | +| B-2.1 グリッド・Y正負・支持・領域拡張 | 実装あり・全受入未検証 | Y正負・境界、グリッド占有、支持判定、ランク別領域拡張APIあり。 個別テストはあるが通常プレイで高ランクまで進む原本一式の受入は未確認。 | +| B-2.2 同座標Front/Back・半透明/ワイヤー表示 | 一部実装・差異あり | 同一XYのFRONT/BACKスロットと切替を実装。 現画面は選択層のみの施設描画。原本の非選択層の半透明・ワイヤーフレーム表示を満たしていない。 | +| B-2.3 客エリアと景観/清潔/騒音の満足度効果 | 一部実装・差異あり | 清掃・施設品質・満足度の基盤、景観/清潔/騒音の定義フィールドあり。 beauty_effect/noise_per_service/cleanliness_decay_per_serviceの本体参照は定義のみ。3環境値の計算と客満足度への連動が不足。 | +| B-2.4 バックヤード・職員ドア・客通行禁止 | 一部実装・差異あり | 職員専用ドア、BACK経路、客アクセス制限、業務用EVあり。 原本にあるリネンシュート、隠し扉、覗き部屋を含む全バックヤード施設は未完了。 | +| B-2.5 覗き部屋の配置制約・通路寸断・証拠収集 | 該当機能未実装 | 証拠管理APIは別機能としてあるが、覗き部屋の施設定義はない。 対象室背面限定建設、裏通路寸断、記録係と専用視界からの証拠収集が未実装。 | +| B-2.6 地上/地下の採光・マナ・遮音・隠蔽ボーナス | 一部実装・差異あり | 地上/地下ゾーンの配置制約、関連する環境定義フィールドあり。 採光・マナ濃度・遮音・隠蔽のゾーン別ボーナス計算と客層効果が未接続。 | +| B-3.1a 10秒=1時間、Pause/x1/x2/x3 | 検証済み(記載範囲) | 10秒=1時間、Pause/x1/x2/x3。旧x4セーブの移行も実装。 今回時計テストを再実行。時間倍率の要件を検証済み。 | +| B-3.1b アクティブポーズ中も建設・指示 | 検証済み(記載範囲) | 停止中の当分コマンドを即処理し、停止したまま建設予約・指示を受理。 今回停止中コマンドテストと開業UIテストを再実行。工事進行は時間再開後。 | +| B-3.2 4時間帯と朝下り/夕上り/夜行動 | 一部実装・差異あり | 06/10/16/20時の4フェーズ、来客時間帯、18時シフト交代あり。 朝の下り・夕の上りラッシュと原本指定の夜行動全体を再現するシナリオ受入が不足。 | +| B-3.3 昼夜シフト・体力0気絶・救護・通勤混雑 | 検証済み(記載範囲) | 勤務体力減少、気絶、目撃評判低下、BACK/EV同伴救護、待機室休息、16人交代混雑を検証。 現行施設での検証。回復速度・治療時間等の数値は暫定値であり原本確定値ではない。 | +| B-3.4a 毎日00:00に決算レポート | 検証済み(記載範囲) | 00:00決算・給与・帳簿自動表示・保存復元を実装。 時刻とレポートの要件を検証済み。未接続費目はB-9.2へ分離。 | +| B-3.4b 3回連続赤字でゲームオーバー | 検証済み(記載範囲) | 3回連続赤字で終了。黒字/ゼロで連続数リセット。終了状態も保存。 判定と終了制御を検証済み。全収支種別の充足とは別。 | +| B-3.5 雨・嵐・マナの霧・流行ニュース | 該当機能未実装 | 4時間帯はあるが天候・流行の実行系はない。 雨の客足/滞在、嵐の新規客ゼロ、マナ霧効果、数日ごとのニュースと売上効果が未実装。 | +| B-4.1 所持金/戦利品・四欲求・忍耐/清潔/依存 | 一部実装・差異あり | 財布・四欲求・忍耐・依存・信用/債務値あり。 戦利品の所持/換金、客自身の清潔度と歩行汚染伝播が不足。hygiene欲求を清潔度伝播の実装と数えない。 | +| B-4.2 最大欲求優先と距離/行列/価格選択 | 検証済み(記載範囲) | 利用可能候補の最大欲求を先に比較し、同順位を距離・待ち・価格等で選択。 現行サービス候補で検証済み。未登録施設の網羅ではない。 | +| B-4.3a A*とエレベーター待ち時間コスト | 実装あり・全受入未検証 | A*相当の探索とヒューリスティック、EV行列から見積る待ちコストを経路費用に加算。 今回経路・混雑テストPASS。実カゴ運行時間との見積誤差を含む原本専用の総合受入は未確認。 | +| B-4.3b 4種垂直移動のゲーム内利用 | 一部実装・差異あり | 客用EV・業務用EV・遅い階段は建設から実移動まで接続。 マナワープ施設・費用・瞬間移動は未実装。 | +| B-4.3c 重なり時速度50%低下 | 検証済み(記載範囲) | 重なった通路の移動速度0.5と移動クレジットを実装。 今回混雑テストを再実行。経路コストだけでなく実移動速度も既存Runtime/交代試験対象。 | +| B-4.4 依存Lv3退店拒否・装備売却・性別別破産 | 一部実装・差異あり | 依存値と支出優先の基盤あり。 依存Lv3退店拒否、装備売却、原本の性別別破産分岐は未接続。 | +| B-4.5 貴族/聖職者/エルフ/魔族のAI特性 | 一部実装・差異あり | 8客クラス・4種族、予算/忍耐/嗜好等の定義あり。 聖職者の目撃即退店/風紀、エルフの自然好み、魔族等の地下満足度耐性を原本通り通す受入がない。 | +| B-5.1 一般雇用と奴隷の費用/耐性/売却区別 | 一部実装・差異あり | FREE_HIRE/ADULT_CONTRACT/UNIQUEの区分と給与処理あり。 原本の食費のみ・ストレス耐性差・売却ルール一式とは一致を確認できない。 | +| B-5.2 体力/ストレス/忠誠/技能/ランダム特性 | 一部実装・差異あり | health/stress/loyalty/技能を保存し、勤務と救護でhealthを使用。 候補20名は固定データ。生成時のランダム特性付与と全職業技能効果が不足。 | +| B-5.3 接客/裏方/救護/防衛等の職種 | 一部実装・差異あり | 受付・料理・清掃・運搬・医療・警備等の職種と一部実作業を実装。 原本の全職種と専用施設・専用業務を網羅していない。 | +| B-5.4 シフト休息と気絶回収 | 検証済み(記載範囲) | 非番は稼働中待機室まで実移動して回復。体力0は回復せずBACK/EV搬送・治療へ。 現行施設で途中保存再開と画面表示を検証済み。宿舎の通常建設は未接続。 | +| B-5.5 求人/市場/破産捕獲・退職金/売買価格 | 一部実装・差異あり | 求人候補の採用・費用・割当、職員削除/退職APIあり。 市場購入、破産客獲得、退職金、能力による売価と他職員忠誠変化の通常フローが不足。 | +| B-6.1 ユニーク無料・高成長・解雇売却不可 | 一部実装・差異あり | UNIQUE区分、unique/unsellableの定義タグあり。 高成長・無料維持の一式と操作禁止の受入がない。remove_staffにはUNIQUE区分による削除拒否がない。 | +| B-6.2 7ヒロインの指定固有効果とライバル | 一部実装・差異あり | ユニークデータはアンリとライのみ。 残り6ヒロイン等が不足。アンリは忍耐減少倍率9000bpの定義であり原本の配置階売上+20%ではない。効果の本番適用も未接続。 | +| B-6.3 基礎技能と業務EXP・Lv99 | 一部実装・差異あり | 基礎パラメータと技能値はある。 業務EXP→レベル成長→Lv99上限の実行系が不足。MAX_SKILL_VALUE=999はLv99ではない。 | +| B-6.4 配置パッシブ・任意発動・ルート別スキル | 一部実装・差異あり | 固有效果のデータ枠、防衛スキルAPIがある。 7名の配置パッシブとプレイヤー任意発動、ルート別習得の通常導線が不足。 | +| B-6.5 24時間勤務警告・衣装補正・限定離脱 | 一部実装・差異あり | シフト例外・超過勤務・不在状態などの基盤あり。 ユニークの24時間勤務警告/強制継続、衣装補正、原本指定の限定離脱条件まで未完了。 | +| B-7.1 三感情・盾・決闘の3ルート運命 | 一部実装・差異あり | 関係値・盾・ルート固定・閾値イベントの汎用基盤あり。 原本の3感情と決闘による3ルート全体は未実装。 | +| B-7.2 好感/淫乱/NTR・好感度=盾最大・淫乱軽減 | 一部実装・差異あり | affectionとshield_current/maxはある。 現在の5指標はtrust/respect/affection/fear/dependence。原本のlust/NTR値がなく、好感度=盾最大とlustによる防御低下に不一致。 | +| B-7.3 同配置接触→盾破壊→NTR上昇→不可逆陥落 | 一部実装・差異あり | 盾攻撃API、配置リスク予測、ルート確認固定APIあり。 通常Tickはイベント期限/候補処理。実配置による自動接触→盾破壊→NTR増加→不可逆陥落の一連の接続がない。 | +| B-7.4 3種決闘・勝利追放/隷属/盾回復・敗北確定 | 該当機能未実装 | 防衛戦はあるが原本の決闘とは別機能。 3種類の決闘と勝敗による指定結果が未実装。 | +| B-7.5 会話/デート/隔離・覗き・薬による各ルート | 一部実装・差異あり | イベント選択・関係変化・盾回復の部品あり。 原本の各プレイヤーアクションと全ルートイベントを通常UIから進める接続が不足。 | +| B-7.6 履歴・妊娠/父親・刻印立ち絵 | 該当機能未実装 | 汎用フラグはあるが原本専用の履歴・外見システムはない。 指定履歴、妊娠周期・父親判定、立ち絵差分との接続が未実装。 | +| B-8.1 隣接/通路必須とLv拡張 | 一部実装・差異あり | 支持/隣接、アクセス点・通路判定、max_level/upgrade_costs定義あり。 施設LvUPの通常操作・支払い・能力変化・全解放設定が未完了。 | +| B-8.2 表層全施設・飲食と厨房セット | 一部実装・差異あり | 一般客室・食堂・酒場・浴場等と厨房の生産配送を実装。 レストラン/VIP/カジノ/闘技場/礼拝堂等が通常登録されていない。飲食全件の厨房セット条件は未充足。 | +| B-8.3 裏層全施設と特殊設備 | 一部実装・差異あり | 厨房・倉庫・待機室・警備詰所・医務室は通常登録あり。 宿舎は拡張定義のみ。清掃員室・覗き部屋・地下牢・マナ炉が不足。 | +| B-8.4 EV/階段/ワープ/通路/職員ドア | 一部実装・差異あり | 2種EV、階段、2層通路、職員ドアあり。 ワープ未実装。通路・職員専用制限は経路試験対象。 | +| B-8.5 Lv解放の価格/食材/混浴/ハード/イカサマ設定 | 一部実装・差異あり | 価格ポリシー、営業時間/営業状態のUIあり。 レベル解放と食材ランク/混浴/施設別特殊設定が原本一式として未完了。 | +| B-9.1 表金/裏金・マナ全館停止・資材 | 一部実装・差異あり | Gold/材料/マナ、帳簿、電力不足による施設状態あり。 表金/裏金の独立運用と「マナ枯渇で全館停止」は未充足。Runtimeは出力100の初期マナ源を持つ。 | +| B-9.2 全収益/人件費/維持/仕入/借金/ランク税 | 一部実装・差異あり | 宿泊飲食売上、給与、仕入れ、建設、罰金等を記帳。 maintenance_goldは定義/検証のみ。日次maintenance/utilities/taxes_and_interestに全施設維持費・借金返済・ランク税を計上する実処理がない。 | +| B-9.3 受付/金庫/購買/闇銀行 | 一部実装・差異あり | 受付・精算待ち、購買/自動発注管理の部品あり。 金庫室の上限/盗賊、戦利品買取、闇銀行融資・滞納破壊を施設と通常UIへ接続していない。 | +| B-9.4 依存客の娯楽優先・信用超過の破産拘束 | 一部実装・差異あり | 高依存時の生存予算留保0、信用枠/債務を実装。 信用超過→原本の破産イベント/拘束/配置まで未接続。 | +| B-10.1 宿泊施設の寸法/層/ゾーン | 一部実装・差異あり | 一般客室2×1 FRONTは一致。 上級客室は定義3×2で原本4×1と不一致かつ通常未登録。VIP6×2、ドミトリー4×1なし。 | +| B-10.2 飲食施設の寸法/層/ゾーン | 一部実装・差異あり | 大衆食堂4×2、酒場4×2 FRONTは寸法一致。 レストラン6×2・屋台2×1なし。騒音定義の実効果不足。 | +| B-10.3 衛生/娯楽施設の寸法/層/ゾーン | 一部実装・差異あり | 大浴場6×2 FRONTは寸法一致。 カジノ6×3、闘技場8×4、礼拝堂4×3なし。浴場の覗き機能なし。 | +| B-10.4 アングラ施設の寸法/層/ゾーン | 一部実装・差異あり | 個室2×1 FRONT地下は寸法/層一致。 高級ソープ・SM室なし。闇市場は定義3×2で原本4×2と不一致かつ通常未登録。 | +| B-10.5 バックヤード施設の寸法/層/ゾーン | 一部実装・差異あり | 厨房4×2・倉庫4×2・待機室4×2・警備2×2・医務4×2 BACKは寸法一致。 従業員寮は定義4×2で原本2×1と不一致かつ通常未登録。清掃員室2×1なし。 | +| B-10.6 特殊施設の寸法/層/ゾーン | 該当機能未実装 | 原本3施設に対応する通常登録・定義なし。 覗き部屋2×1 BACK、地下牢4×2 BACK地下、マナ炉6×3 BACK地下が未実装。 | +| B-10.7 インフラ/防衛施設の寸法/層/ゾーン | 一部実装・差異あり | 客用EV2×2 FRONT・業務用EV3×2 BACKは一致。 ワープ2×2 FRONT・外壁魔法砲台がない。防衛APIの存在は砲台施設の証拠ではない。 | +| B-11.1 資源/時間/天候/Rank・下部切替・スクロールズーム | 一部実装・差異あり | 資源/時間/ランク、レイヤー切替、パン/ズームあり。 天候表示、指定下部巨大切替、非選択層の半透明表示まで原本一致ではない。 | +| B-11.2 Policy/ドラッグ配置・欲求/思考/感情グラフ/盾 | 一部実装・差異あり | 価格ポリシー、職員割当UI、客四欲求/状態/目的地表示あり。 ドラッグ配置、AI思考履歴、3感情グラフ、盾詳細の通常UIが不足。 | +| B-11.3 カメラ入口・覗きアングル/撮影/介入 | 該当機能未実装 | 現在のメインUIに原本の覗き専用導線はない。 カメラ入口、角度、撮影、証拠収集、介入の一式が未実装。 | +| B-11.4 グリッド/接続警告・フキダシ/ログ・カットイン | 一部実装・差異あり | グリッド建設プレビュー・警告/状態表示・ログと人物表示あり。 原本指定の全フキダシ、重要イベント/決闘/防衛カットインと接続線警告の全受入は不足。 | +| B-12.1 二面性・高精細ドットとアニメ立ち絵 | 一部実装・差異あり | Kenneyタイルと独自ドット人物で断面図・室内作業を表示。 原本の高品質アニメ立ち絵と二面性表現の完成受入はない。 | +| B-12.2 魔法レンズ風の波紋切替 | 該当機能未実装 | レイヤー選択は即時切替。 魔法レンズ風の波紋反転演出が未実装。 | +| B-12.3 SD各モーション・立ち絵破損/汚れ/刻印差分 | 一部実装・差異あり | 歩行・待機・作業・気絶/搬送/治療・休息のドット表示あり。 原本の全職種専用モーション、立ち絵破損/汚れ/刻印差分は未完成。 | +| B-12.4 表裏UIスキン・BGMクロスフェード・環境音/SE | 一部実装・差異あり | 昼夜/レイヤーの色調変化あり。 羊皮紙/黒鉄の完成スキン、表裏BGMクロスフェード、環境音/SEの実再生なし。 | +| B-13.1 立ち絵/CG/差分 | 該当機能未実装 | キャラクターデータはあるが指定イラスト一式はない。 メイン/サブ立ち絵、衣装/表情/ルート差分・イベントCGの制作と接続が不足。 | +| B-13.2 SDモーション/エフェクト | 一部実装・差異あり | ドット人物と一部状態モーションあり。 原本が求める全モーションと盾/煙/マナ等の全演出資産は未完成。 | +| B-13.3 Lv1〜3表裏タイル/背景 | 一部実装・差異あり | 現行施設内装・背景・タイル素材あり。 全施設Lv1〜3の表裏外観/内装差分とLvUP表示接続が不足。 | +| B-13.4 表裏スキン/感情/覗き素材 | 一部実装・差異あり | 現行管理UI・ボタン・欲求バー等あり。 表裏スキン完成版、3感情グラフ、専用カメラオーバーレイなし。 | +| B-13.5 BGM/環境/操作/成人向けSE/声 | 該当機能未実装 | assets内にogg/wav/mp3がなく、本体コードにAudioStreamPlayer参照がない。 動的BGM、環境/操作SE、ボイス等の同梱・再生が未実装。 | +| B-14.1 2026/03〜08 プロトタイプ | 工程要件・完了未認定 | MVP相当の開業ループと試験は存在する。 工程要件。2026/03〜08の納期達成や当時の受入完了を現在コードからは認定できない。 | +| B-14.2 2026/09〜2027/02 アルファ | 工程要件・完了未認定 | パラメータ基盤あり。現日はアルファ予定期間内。 量産素材・シナリオ・通常BGMの工程完了は未達。機能完了数に混ぜない。 | +| B-14.3 2027/03〜08 ベータ/成人向け | 工程要件・完了未認定 | 原本に将来工程として記載。 2027/03〜08の完成条件であり、現在完了扱いにしない。 | +| B-14.4 2027/09〜12 ポリッシュ | 工程要件・完了未認定 | デバッグ・性能確認の実施記録はある。 2027/09〜12のマスターアップ工程全体の承認証拠はない。個別PASSで代替しない。 | diff --git a/docs/traceability/original/LABOR_COMPLETION_2026-09-29.md b/docs/traceability/original/LABOR_COMPLETION_2026-09-29.md new file mode 100644 index 0000000..46ba3b0 --- /dev/null +++ b/docs/traceability/original/LABOR_COMPLETION_2026-09-29.md @@ -0,0 +1,58 @@ +# 同伴救護・待機室休息・交代ラッシュ(2026-09-29) + +ローカル制作版 `/Users/korizatou114/Projects/とりにく工房ゲーム制作部` に実装。原本68行・108〜110行を対象とする。原本そのものは変更していない。前回の [救護記録](STAFF_RESCUE_VERIFICATION_2026-09-29.md) に残していた同伴エレベーター乗車を解消し、追加監査で見つかった非番の休息先への移動も修正した。 + +## 同伴搬送 + +- 救護者と患者の二人分を一括予約し、二人とも同じ便に乗車・降車する。残り1席なら二人とも次便を待つ。 +- 二人目の予約が満杯等で失敗した場合、一人目の予約と採番を戻す。再送は重複予約にしない。 +- 裏経路ではBACKの業務用EVのみ使用できる。表層の客用EVで裏経路を代用しない。階段も引き続き使える。 +- 停電・手動停止では二人を同じ実在する最寄り階へ降ろす。安全な階がない場合は車内で保持する。 +- 医務室停止・救護者気絶が乗車中に起きても、患者だけを乗車階へ戻したり途中で回復させたりしない。実際の降車後に再手配する。 +- 予約中の取消は二人同時。乗車中の個別取消は拒否する。 +- 相互の同伴IDを保存し、保存データの片側欠損・不一致を拒否する。追加フィールドのない既存の単独乗車セーブも読める。 +- 増設工事中の昇降機には新しい同伴予約を入れない。 + +通常の雇用候補テッサを医務室へ配属し、業務用EVだけでつながった上階の患者の回収・治療を描画付きで確認した。患者の負傷だけをテスト用に注入している。実際の車内には2人が表示される。 + +## 非番の休息 + +従来は非番なら表通路でも回復していた。現在は、稼働している待機室等への到達経路を選び、実際に歩いて移動し、到着後に体力を回復する。休室・経路断では回復しない。体力0の気絶は休息では復帰せず、救護を必要とする。 + +移動は既存の通勤経路・移動進捗・昇降機を使用する。隠れたタイマーや別の座標を保存せず、現在位置・施設状態から再開する。従業員パネルの「休憩先へ移動中」「待機室で休憩中」「休憩先なし」は実状態に連動する。待機室内のドット従業員と休憩人数も実際に到着した人物から描画する。 + +休憩室の保管量等の汎用capacityを、未指定の従業員休憩人数上限に流用していない。元からの回復速度は調整値のままであり、原本の確定数値とは扱わない。 + +## シフト交代 + +通常建設した4施設、昼番8人・夜番8人の計16人で18時の交代を確認。昼番は勤務終了し、夜番は物理的に職場へ到着してから勤務を始める。同じ通路に集中すると移動速度は50%になり、移動途中のセーブから同じ位置・勤務履歴・イベントを再現する。 + +## 個別検証 + +- ElevatorService: 123項目PASS(既存の単独客・スタッフ乗車も含む)。 +- 救護基本・裏経路: 46項目PASS。 +- Runtime同伴救護: 23項目PASS(ディスク保存、緊急降車、医務室停止、救護者気絶)。 +- 通常雇用・配属からの同伴救護UI: 描画あり16項目PASS。 +- 待機室休息: 14項目PASS。 +- 待機室UI: 描画あり9項目PASS。 +- 16人交代ラッシュ: 42項目PASS。 +- 開業→00:00決算→翌朝→ディスク保存・再開: 描画あり53項目PASS。 +- 実購入・調理・配送を含む3営業日: 49項目PASS。食堂売上3,000G、原料配送30便・料理配送80便、最終所持金2,855G。各日の帳簿照合・未払なし・保存復元を確認。 + +全ソーステスト **90スイートPASS・失敗0**。Mac/Windowsの配布PCK単体でも各5本(開業、同伴Runtime、同伴UI、休息Runtime、休息UI)を確認。`make check`、`git diff --check`、Mac実行ファイル起動、Windows ZIPのCRCを確認した。 + +全体回帰の最終集計は `artifacts/remaining-2026-09-29/verification-summary.json` に記録する。これは現在の実装に対するテストであり、原本75要件すべての完成を意味しない。対応表のB-3.3とB-5.4は、今回の現行施設での検証範囲を明記して更新した。 + +## 配布と操作 + +1. BACKに待機室を建設し、職員扉・裏通路・必要な階段または業務用EVでつなぐ。非番の従業員は自動で休息へ向かう。 +2. BACKに医務室を建設し、医療職テッサを通常雇用して配属する。気絶した従業員を自動救護する。 +3. 従業員パネルで体力・休憩・救護状態を確認できる。 + +Mac版: `build/macos/TheTree-Labor-2026-09-29.app`。 +Windows版: `build/TheTree-Windows-Labor-2026-09-29.zip`。 +Windowsは配布PCK単体の自動検証を行う。今回のセッションからWindowsネイティブ実機を操作できる接続は確認できていないため、実機確認済みとはしない。 + +## 原本全体に残る範囲 + +天候・流行、原本全施設の機能接続、全固有キャラクターの非露骨な能力・進行、上位ランク、経済・音声等は引き続き対応表の未完了項目に残る。ベッド人数上限・加算料金等の利用者が未設定とした数値も、決め打ちで確定させていない。今回の労務・搬送の完了と原本全体の完了を区別する。 diff --git a/docs/traceability/original/MIDNIGHT_SETTLEMENT.md b/docs/traceability/original/MIDNIGHT_SETTLEMENT.md new file mode 100644 index 0000000..593beb7 --- /dev/null +++ b/docs/traceability/original/MIDNIGHT_SETTLEMENT.md @@ -0,0 +1,25 @@ +# 00:00決算と連続赤字判定(2026-09-27) + +対象原本: `BASELINE.txt` 3.4、B-3.4a / B-3.4b。 + +## 実装 + +- 新規ゲームは毎日00:00に決算。初日は06:00開始から最初の00:00までの実際の取引を集計する。 +- 日次給与は決算で一度だけ発生。費用はその営業日の損益へ配賦し、現金支払の時刻は実際の決算時刻を記帳する。 +- 分類済みの売上・費用による損益が負なら連続赤字を加算。0または正なら0へ戻す。所持金・未払い残高とは別に判定し、3回で終了する。 +- 終了した決算は保存し、その時刻以後の時間進行を止める。給与・台帳・損益・終了状態の整合をロード時にも検証する。終了後の新規コマンドは採番・記帳前に拒否し、ゲーム状態を変更しない。 +- 00:00に帳簿パネルを自動表示する。現金収支、営業損益、未払い、連続赤字回数、確定時刻を表示する。 +- 朝06:00の勤務・行動点・朝の保存処理は独立して維持する。 + +## 旧セーブ + +Operations v1〜v3はv4へ移行する。確定済みの06:00決算・給与・旧未払いカウンタは改変しない。 +開いている旧営業日を次の06:00に一度締め、それ以降は00:00に締める。 +切替時の営業日は18時間となる。前の決算終了時刻を次の集計開始時刻にするため、取引の重複・欠落はない。 +旧確定記録の未払い連続回数を新しい赤字連続回数に混ぜず、移行後の最初の決算から損益で判定する。 +既に旧版で終了したセーブは終了状態を保持する。画面では旧版の履歴と識別する。 + +## 範囲と残件 + +今回の判定は実装済みの会計項目を対象とする。原本全施設の維持費、借金返済など未接続の経済機能は別の残件であり、この変更で実装済みとはしない。 +検証結果は実行完了後に `MIDNIGHT_VERIFICATION.md` に記録する。 diff --git a/docs/traceability/original/MIDNIGHT_VERIFICATION.md b/docs/traceability/original/MIDNIGHT_VERIFICATION.md new file mode 100644 index 0000000..08dbac8 --- /dev/null +++ b/docs/traceability/original/MIDNIGHT_VERIFICATION.md @@ -0,0 +1,38 @@ +# 00:00決算の検証結果(2026-09-27) + +原本3.4の決算時刻・連続赤字による終了を実装した。原本全体の実装は未完了。 +仕様と保存データ移行は [実装詳細](MIDNIGHT_SETTLEMENT.md)、残件は [実装対応表](IMPLEMENTATION_STATUS.md) に記録する。 + +| 検証 | 結果 | +|---|---| +| 全ソーステストと差分の最終再検証 | 82本・6,951チェック、失敗0、未実行0 | +| 決算境界・損益判定・旧セーブ移行 | 専用29チェック合格。23:59保存、00:00決算、未払いがない赤字、黒字時の未払い、0/黒字リセット、旧履歴保持、移行日、改変拒否を確認 | +| Runtimeの給与・台帳・保存互換 | 51チェック合格。旧06:00で実際に支払った給与の履歴を保存し、切替後の18時間集計も復元可能 | +| 終了・14日分の決算再現性 | 46チェック合格。終了後の新規操作は採番・現金・履歴を変更せず拒否。中間復元と連続実行が同一 | +| 通常資金で3日間の営業 | 49チェック合格。自然来客、雇用、調理、配送、給与、配送中のディスク保存復元。残金2,855G・食事売上3,000G・食事配送80件 | +| 全テスト合格後の画面付き通し確認 | 53チェック合格。新規開業→建設→雇用→自然来客→00:00帳簿表示→保存復元→翌朝営業→同一状態再現 | +| 帳簿選択 | 新決算時は最新日を自動選択。その後の過去日選択は通常更新で維持 | +| エディター/起動検査 | make check、差分の空白検査に合格 | +| macOS配布データ | 元ソース外から13チェック合格、ネイティブMac実行終了コード0 | +| Windows配布データ | Mac上のGodotでPCKの13チェック合格。Windows実機での実行は未検証 | + +通常営業の決算結果: + +| 営業日 | 確定分 | 損益 | 未払い | 連続赤字 | +|---|---:|---:|---:|---:| +| 1 | 1440(翌日00:00) | -555G | 0G | 1回 | +| 2 | 2880(翌日00:00) | +715G | 0G | 0回 | +| 3 | 4320(翌日00:00) | +715G | 0G | 0回 | + +ログ・集計・画面画像は `artifacts/midnight-2026-09-27/`。 +`all-tests.json` は初回全件実行、`effective-tests.json` は追加した検証を含む最終集計。 +全件実行中に追加した変更は関連テストを再実行して最終集計に反映した。帳簿選択のUI2ファイルは以後のUIテストと画面付き通し検証で確認した。画面確認で開業ガイドの旧決算表現1箇所を修正し、最終版の画面付き通し検証も再実行した。 +原本文書は変更していない。最終配布版作成後の126ソースファイルのハッシュは検証完了時にも一致。 + +配布版: + +- `build/macos/TheTree-Midnight-2026-09-27.app` +- `build/windows-midnight-2026-09-27/TheTree.exe` と `TheTree.pck` +- ローカル版と旧Driveフォルダの `TheTree-ドット版を起動.command` は今回のMac版を参照。 + +維持費・借金返済の全接続、天候、全施設、救護搬送等は別の残件。このテスト合格を原本全要件の完了とは扱わない。 diff --git a/docs/traceability/original/STAFF_RESCUE_VERIFICATION_2026-09-29.md b/docs/traceability/original/STAFF_RESCUE_VERIFICATION_2026-09-29.md new file mode 100644 index 0000000..943e734 --- /dev/null +++ b/docs/traceability/original/STAFF_RESCUE_VERIFICATION_2026-09-29.md @@ -0,0 +1,50 @@ +# 従業員救護の実装・検証(2026-09-29) + +作業対象は `/Users/korizatou114/Projects/とりにく工房ゲーム制作部`。原本 `BASELINE.txt` は変更していない。原本68行の体力0・救護・目撃評判、110行の裏経路による回収、207行の4×2 BACK医務室を対象とした。原本全体の実装完了ではない。 + +## 実装した動作 + +- 勤務による体力低下、体力0の気絶と作業中断。シフト終了や休息だけで気絶から復帰しない。 +- 稼働中の医務室と裏経路がある場合、医療職に救護を割り当てる。通常の雇用候補テッサで利用できる。物流職だけでは救護を担当しない。 +- 医療職が患者まで歩き、患者と同じ位置に到達してから搬送を開始する。裏通路・職員扉・実際に接続された階段を使用し、表通路の近道や接続のない上下移動は使用しない。 +- 搬送中は患者と救護者の位置を同期。医務室への到着後に治療し、終了時に体力を回復する。 +- 医務室停止や救護者の気絶で搬送を中断する。患者を最後の位置に残し、条件成立後に再手配する。重複救護を作らない。 +- 表層で顧客が気絶を目撃した場合、評判を一度だけ下げる。 +- 搬送中のディスク保存・読込、保存ダイジェスト一致、復元後70分の状態・イベント一致を検証。既存タスクの追加フィールド欠落は許容し、不正型は拒否する。 +- 建設パレットに医務室を追加(通常登録施設19件)。顧客向け施設候補から医務室を除外する。 +- 従業員パネルに体力、救護待ち、迎え、搬送、治療の状態を表示。ドット画面に搬送中の患者と治療中のベッド表示を追加した。独自の新規画像は追加していない。 + +## 操作方法 + +裏層に医務室を建て、職員扉・裏通路・必要な階段で経路をつなぐ。従業員候補から医療職のテッサを雇用し、医務室へ配属する。体力0の従業員が発生すると救護を自動手配する。選択した従業員の詳細で進行状態を確認できる。 + +## 検証 + +- 救護Runtime: **43項目 PASS**。気絶、経路、医務室停止、担当者気絶、目撃、旧タスク互換、ディスクSave/Load、決定的再現を含む。 +- 描画ありの救護操作: **13項目 PASS**。建設パレットから有償建設、通常雇用・配属、搬送、治療完了を確認。患者の負傷だけをテスト用に注入した。 +- 関連14スイート: **1,149項目 PASS**。従業員、通勤、配属、UI、Save/Load、階段、経路、来客AI、開業フロー。 +- 配布Mac/Windows PCK: **各15項目 PASS**。ソースツリー外で配布データから建設・開業・HUDを確認。 +- Windows配布PCK単体の救護Runtime/操作: **43 + 13項目 PASS**。ソースツリー外から検証。 +- 描画ありの開業→決算→翌朝→保存再開: **53項目 PASS**。 +- 上記合計 **1,344項目 PASS**。`make check`、`git diff --check`、Mac実行ファイル起動、Windows ZIP整合性を確認。 +- Windowsネイティブ実機の操作確認は今回実施していない。 + +ログ、スクリーンショット、検証集計、ソースと配布物SHA-256は `artifacts/rescue-2026-09-29/`。`initial-startup-before-fix.log` は初期開発時の型エラーの記録で、修正後の起動確認は `check.log` / `native-macos.log`。最終結果は `verification-summary.json` を参照。 + +## 原本が数値を定めていない調整値 + +勤務30分につき体力-1、通常休息30分につき+1、治療30分で体力100・疲労0、目撃による評判-1は暫定値。医務室640G・資材8・建設80分も調整値であり、原本の確定値とは扱わない。体力減少は通常の営業シミュレーションで有効にする。 + +## この記録の時点での残件 + +同日続報: [労務・同伴救護の検証](LABOR_COMPLETION_2026-09-29.md)で同伴乗車と待機室休息を追加した。以下は初回検証時の履歴。 + +- エレベーターへの患者・救護者の同伴乗車。現状は搬送用経路からエレベーターを除外する。階段がない階へは搬送できない。 +- 医務室のベッド人数上限、治療バランス、固有キャラクターによる治療補正。ベッド絵を同時人数の確定仕様とは扱わない。 +- B-3.3/B-5.4全体、および原本の他要件の全件完了判定は保留。施設の破壊・摘発による休止解除条件も追加していない。 + +## 配布物 + +- Mac: `build/macos/TheTree-Rescue-2026-09-29.app` +- Windows: `build/TheTree-Windows-Rescue-2026-09-29.zip`(EXEとPCKを同梱) +- `TheTree-ドット版を起動.command` は検証したMac版を起動する。 diff --git a/docs/traceability/original/VERIFICATION.md b/docs/traceability/original/VERIFICATION.md new file mode 100644 index 0000000..7102658 --- /dev/null +++ b/docs/traceability/original/VERIFICATION.md @@ -0,0 +1,35 @@ +# 原本準拠修正の検証(2026-09-27) + +この文書は先行する時間倍率・施設パラメータ修正の記録。最新の決算修正・配布版は [00:00決算の検証記録](MIDNIGHT_VERIFICATION.md) を参照。 + +原本全体の実装は未完了。対応済みの範囲・既知の差異は [実装対応表](IMPLEMENTATION_STATUS.md) に記載する。 + +## 結果 + +| 検証 | 結果 | +|---|---| +| ソースのテストエントリポイント | 81本 / 6,905チェック、修正後の失敗0 | +| 20,000マス・200顧客・50従業員・150施設の高負荷 | 24チェック PASS | +| 実描画で開業→営業→保存復元→翌朝→同一再現 | 46チェック PASS | +| 実描画で関連施設操作・休止・巻戻し・旧速度セーブ | 15チェック PASS | +| 通常仕入れ・調理・配送の3日間 | 49チェック PASS、食事売上3,000G、残金2,855G | +| macOS書出しアプリの格納データによる開業 | 13チェック PASS | +| Windows書出しPCKによる開業 | 13チェック PASS | +| macOSネイティブアプリ単独起動 | /tmpから実行、終了コード0 | +| エディタのロード・通常起動 | make check PASS | +| 原本照合 | Google Drive再取得本文とローカル原本の全バイト一致 | + +最初の全件走査は80本で、旧速度(25%/33%)の期待値を保持した2本が失敗した。原本の50%へ修正し、実移動・乗車時密度の両方を再実行して合格した。走査開始後に追加したUIテスト1本と、変更箇所の再実行を合わせた最終結果が81本。初回の失敗ログを削除せず `all-tests.json` に残し、修正後の結果を `effective-tests.json` と `verification-summary.json` に記録した。 + +関連施設については、実際に建設した客室をRuntimeコマンドで関連付け、ディスクへ保存し、設定変更後に読み戻して施設スナップショットが完全一致することも確認(営業管理UIテスト60チェックに含む)。休止中の厨房の調理と物流停止も検証済み。 + +性能試験はシミュレーション単体の測定であり、描画FPSの保証ではない。Windowsのexe/PCKは生成済みだがWindows実機上の起動は未検証。料金加算額・不足判定・人数上限など未設定の値を確定していない。 + +## 成果物 + +- macOS: `build/macos/TheTree-OriginalAlignment-2026-09-27.app` +- Windows: `build/windows-original-2026-09-27/TheTree.exe` と同ディレクトリの `TheTree.pck` +- ログ・画像・集計: `artifacts/original-2026-09-27/` +- ビルド時のソースハッシュ: `artifacts/original-2026-09-27/source-manifest.json`(検証終了時も一致) + +これらのテストは現在実装した機能の検証であり、原本75要件群の全件合格ではない。 diff --git a/docs/traceability/original/audit-2026-09-29/TRACEABILITY.md b/docs/traceability/original/audit-2026-09-29/TRACEABILITY.md new file mode 100644 index 0000000..56d4545 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/TRACEABILITY.md @@ -0,0 +1,1007 @@ +# 原本と現行実装のトレーサビリティ監査(2026-09-29) + +対象: `/Users/korizatou114/Projects/とりにく工房ゲーム制作部`。ゲームコードの修正は行わず、原本75要件群と追加施設仕様を照合した。 + +原本は指定Driveファイルの保存済み `基本仕様書.txt`(BASELINE.txt)。2026-09-22取得、保存された2026-09-27照合記録あり。今回はDriveを再取得していない。SHA-256: `75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a`。派生仕様書を原本の代用にはしていない。 + +## 判定の意味 + +「検証済み」は各項目に書いた現行施設・機能範囲の証拠があるもの。「一部実装」は部品の存在に加えて明確な差異または接続不足があるもの。「該当機能未実装」は関連する汎用部品を完成機能と数えなかったもの。「実装あり・全受入未検証」はコード・部品試験があるが要件全体の受入証拠が不足するもの。工程4件は機能要件と区別する。 + +| 判定 | 要件群数 | +|---|---:| +| 一部実装・差異あり | 52 | +| 実装あり・全受入未検証 | 2 | +| 該当機能未実装 | 9 | +| 検証済み(記載範囲) | 8 | +| 工程要件・完了未認定 | 4 | + +合計75。複合要件で粒度が異なるため、この件数から完成率を算出しない。要件群に関連試験を列挙していても、その試験が要件全体を保証する意味ではない。 + +## 主な差異 + +- 通常登録19施設に対し、原本10章の30施設に対応する通常登録は12。3施設は定義のみ、15施設は対応定義なし。19には原本10章以外の受付・通路・ドア・階段等が含まれる。 +- 上級客室は3×2(原本4×1)、従業員寮は4×2(原本2×1)、闇市場は3×2(原本4×2)。この3件は通常の登録・建設パレットにも未接続。 +- ユニークはアンリ/ライの2名。原本7ヒロインの6名不足。アンリの定義効果は忍耐減少倍率であり、原本の売上+20%と不一致。 +- 関係値の5指標は原本の好感度/淫乱度/NTR度と異なる。決闘、履歴/妊娠、全ルート進行は未完成。 +- 天候/流行、施設維持費の支出、借金/税、全館マナ枯渇停止、ワープ等に実行系の不足がある。 +- ドット断面図は動作するが、非選択層透過、波紋、完成立ち絵、音響一式は未完了。 + +## 検証証拠と限界 + +- 既存90スイート・失敗0の記録と90ログの存在を確認。既存マニフェスト24件は監査文書更新前に全件ハッシュ一致。マニフェストが全ソースを収録しているわけではなく、90件を本日再実行したとは扱わない。 +- 今回の再実行結果は [artifacts/traceability-audit-2026-09-29/results.json](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/artifacts/traceability-audit-2026-09-29/results.json)。make check と時計/停止操作/経路/施設追加仕様/関係部品/開業UIの8スイートを再実行し、全件PASS(400検証項目)。 +- 既存の救護・休息・交代混雑のソース/試験は既存マニフェストとの一致で確認。Windowsネイティブ再確認、長時間の本編全攻略、未実装機能の受入は行っていない。 +- 原本未指定の救護数値や施設追加仕様の未設定値を原本確定値と扱わない。 + +原本258行のうち243行を要件に直接対応付け、14行は章見出し、1行は工程全体の対象期間として対応付けた。対応漏れの本文行は0。詳細は `source_coverage.json`。 + +## 75要件の対応 + +### B-1.1 基本情報・Windows・2D・20〜30時間/50時間以上 + +**判定:** 一部実装・差異あり。原本行 2–10。 + +**現状:** Windows配布物と2D断面図を実装。 + +**差異・受入条件:** 20〜30時間の本編、50時間以上の全攻略の受入証拠がない。今回Windowsネイティブ確認は実施していない。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-1.2 地上/地下の二面性経営・観察介入・権威/富 + +**判定:** 一部実装・差異あり。原本行 11–14。 + +**現状:** 地上地下・二層の建設と観察、ランク・経済の基盤あり。 + +**差異・受入条件:** 高さと権威、深さと富、介入を一貫して成立させる完成ゲームの受入が不足。 + +**コード:** [scripts/world/world_service.gd:103](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/world_service.gd:103) / [scripts/construction/construction_service.gd:56](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/construction/construction_service.gd:56) / [scripts/progression/progression_service.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/progression/progression_service.gd:143) / [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scripts/economy/economy_service.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/economy/economy_service.gd:28) / [scripts/mana/mana_network_service.gd:46](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/mana/mana_network_service.gd:46) / [autoload/simulation_runtime.gd:4736](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4736) + +**関連部品試験:** [tests/s1/test_world_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_world_service.gd) / [tests/s1/test_construction_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_construction_service.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/s2/test_economy_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s2/test_economy_service.gd) / [tests/s2/test_mana_network_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s2/test_mana_network_service.gd) + +### B-1.3 建設→集客→収益→管理→拡張防衛 + +**判定:** 一部実装・差異あり。原本行 15–22。 + +**現状:** 建設・集客・宿泊飲食収入・雇用・物流の通常ループあり。防衛APIとRuntimeテストあり。 + +**差異・受入条件:** 全裏収益、全施設、通常プレイでの防衛発生・高ランク到達まで未完了。APIテストを全ループ完了に数えない。 + +**コード:** [scripts/supply/kitchen_production_service.gd:19](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/kitchen_production_service.gd:19) / [scripts/supply/supply_logistics_bridge.gd:17](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_logistics_bridge.gd:17) / [scripts/supply/supply_management_service.gd:7](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_management_service.gd:7) / [autoload/simulation_runtime.gd:2278](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:2278) / [scripts/economy/economy_service.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/economy/economy_service.gd:28) / [scripts/operations/operations_coordinator.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/operations/operations_coordinator.gd:143) / [scripts/defense/defense_service.gd:79](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/defense/defense_service.gd:79) / [autoload/simulation_runtime.gd:6475](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:6475) + +**関連部品試験:** [tests/supply/test_normal_supply_chain.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/supply/test_normal_supply_chain.gd) / [tests/mvp/test_original_midnight_settlement.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_original_midnight_settlement.gd) / [tests/mvp/test_runtime_operations_acceptance.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_operations_acceptance.gd) / [tests/s5/test_defense_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_defense_service.gd) / [tests/s5/test_runtime_defense_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_runtime_defense_cycle.gd) + +### B-1.4 二層・自律AI・渋滞・信仰と依存 + +**判定:** 一部実装・差異あり。原本行 23–26。 + +**現状:** 二層、客AI、シフト、渋滞、依存値と信仰値の基盤あり。 + +**差異・受入条件:** 信仰による本編進行、依存客の全特殊行動まで接続されていない。 + +**コード:** [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scenes/main/world_preview.gd:653](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:653) / [scripts/world/path_service.gd:952](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:952) / [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) / [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) + +### B-1.5 世界観・主人公・7ヒロイン3運命 + +**判定:** 一部実装・差異あり。原本行 27–30。 + +**現状:** アンリとライの2名のデータあり。 + +**差異・受入条件:** 原本の7ヒロインと各3ルート、主人公の物語・全エンドが揃っていない。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-1.6 成人向けルートと経営的搾取 + +**判定:** 一部実装・差異あり。原本行 31–34。 + +**現状:** 成人区分・対象施設・コンテンツ制御の基盤あり。 + +**差異・受入条件:** 原本記載の全ルート・イベント・経営要素としては未完成。データやタグの存在を内容実装と扱わない。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-2.1 グリッド・Y正負・支持・領域拡張 + +**判定:** 実装あり・全受入未検証。原本行 36–39。 + +**現状:** Y正負・境界、グリッド占有、支持判定、ランク別領域拡張APIあり。 + +**差異・受入条件:** 個別テストはあるが通常プレイで高ランクまで進む原本一式の受入は未確認。 + +**コード:** [scripts/world/world_service.gd:103](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/world_service.gd:103) / [scripts/construction/construction_service.gd:56](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/construction/construction_service.gd:56) / [scripts/progression/progression_service.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/progression/progression_service.gd:143) + +**関連部品試験:** [tests/s1/test_world_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_world_service.gd) / [tests/s1/test_construction_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_construction_service.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-2.2 同座標Front/Back・半透明/ワイヤー表示 + +**判定:** 一部実装・差異あり。原本行 40–44。 + +**現状:** 同一XYのFRONT/BACKスロットと切替を実装。 + +**差異・受入条件:** 現画面は選択層のみの施設描画。原本の非選択層の半透明・ワイヤーフレーム表示を満たしていない。 + +**コード:** [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scenes/main/world_preview.gd:653](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:653) / [scripts/world/path_service.gd:952](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:952) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) + +### B-2.3 客エリアと景観/清潔/騒音の満足度効果 + +**判定:** 一部実装・差異あり。原本行 45–47。 + +**現状:** 清掃・施設品質・満足度の基盤、景観/清潔/騒音の定義フィールドあり。 + +**差異・受入条件:** beauty_effect/noise_per_service/cleanliness_decay_per_serviceの本体参照は定義のみ。3環境値の計算と客満足度への連動が不足。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-2.4 バックヤード・職員ドア・客通行禁止 + +**判定:** 一部実装・差異あり。原本行 48–50。 + +**現状:** 職員専用ドア、BACK経路、客アクセス制限、業務用EVあり。 + +**差異・受入条件:** 原本にあるリネンシュート、隠し扉、覗き部屋を含む全バックヤード施設は未完了。 + +**コード:** [scripts/world/path_service.gd:938](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:938) / [scripts/world/path_service.gd:1672](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:1672) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/s1/test_path_congestion.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_congestion.gd) / [tests/transport/test_runtime_transport.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/transport/test_runtime_transport.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-2.5 覗き部屋の配置制約・通路寸断・証拠収集 + +**判定:** 該当機能未実装。原本行 51–53。 + +**現状:** 証拠管理APIは別機能としてあるが、覗き部屋の施設定義はない。 + +**差異・受入条件:** 対象室背面限定建設、裏通路寸断、記録係と専用視界からの証拠収集が未実装。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-2.6 地上/地下の採光・マナ・遮音・隠蔽ボーナス + +**判定:** 一部実装・差異あり。原本行 54–56。 + +**現状:** 地上/地下ゾーンの配置制約、関連する環境定義フィールドあり。 + +**差異・受入条件:** 採光・マナ濃度・遮音・隠蔽のゾーン別ボーナス計算と客層効果が未接続。 + +**コード:** [scripts/world/world_service.gd:103](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/world_service.gd:103) / [scripts/construction/construction_service.gd:56](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/construction/construction_service.gd:56) / [scripts/progression/progression_service.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/progression/progression_service.gd:143) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s1/test_world_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_world_service.gd) / [tests/s1/test_construction_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_construction_service.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-3.1a 10秒=1時間、Pause/x1/x2/x3 + +**判定:** 検証済み(記載範囲)。原本行 58–60。 + +**現状:** 10秒=1時間、Pause/x1/x2/x3。旧x4セーブの移行も実装。 + +**差異・受入条件:** 今回時計テストを再実行。時間倍率の要件を検証済み。 + +**コード:** [scripts/core/simulation_kernel.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/core/simulation_kernel.gd:28) / [autoload/game_clock.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/game_clock.gd:29) / [autoload/simulation_runtime.gd:446](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:446) + +**関連部品試験:** [tests/run_tests.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/run_tests.gd) / [tests/s0/test_runtime_integration.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_runtime_integration.gd) / [tests/ui/test_first_operating_day.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_first_operating_day.gd) + +### B-3.1b アクティブポーズ中も建設・指示 + +**判定:** 検証済み(記載範囲)。原本行 58–60。 + +**現状:** 停止中の当分コマンドを即処理し、停止したまま建設予約・指示を受理。 + +**差異・受入条件:** 今回停止中コマンドテストと開業UIテストを再実行。工事進行は時間再開後。 + +**コード:** [scripts/core/simulation_kernel.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/core/simulation_kernel.gd:28) / [autoload/game_clock.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/game_clock.gd:29) / [autoload/simulation_runtime.gd:446](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:446) + +**関連部品試験:** [tests/run_tests.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/run_tests.gd) / [tests/s0/test_runtime_integration.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_runtime_integration.gd) / [tests/ui/test_first_operating_day.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_first_operating_day.gd) + +### B-3.2 4時間帯と朝下り/夕上り/夜行動 + +**判定:** 一部実装・差異あり。原本行 61–65。 + +**現状:** 06/10/16/20時の4フェーズ、来客時間帯、18時シフト交代あり。 + +**差異・受入条件:** 朝の下り・夕の上りラッシュと原本指定の夜行動全体を再現するシナリオ受入が不足。 + +**コード:** [scripts/core/simulation_kernel.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/core/simulation_kernel.gd:28) / [autoload/game_clock.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/game_clock.gd:29) / [autoload/simulation_runtime.gd:446](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:446) / [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/run_tests.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/run_tests.gd) / [tests/s0/test_runtime_integration.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_runtime_integration.gd) / [tests/ui/test_first_operating_day.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_first_operating_day.gd) / [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-3.3 昼夜シフト・体力0気絶・救護・通勤混雑 + +**判定:** 検証済み(記載範囲)。原本行 66–69。 + +**現状:** 勤務体力減少、気絶、目撃評判低下、BACK/EV同伴救護、待機室休息、16人交代混雑を検証。 + +**差異・受入条件:** 現行施設での検証。回復速度・治療時間等の数値は暫定値であり原本確定値ではない。 + +**コード:** [autoload/simulation_runtime.gd:11906](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11906) / [autoload/simulation_runtime.gd:11985](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11985) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) / [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) + +**関連部品試験:** [tests/staff/test_staff_rescue.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rescue.gd) / [tests/staff/test_rescue_elevator.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_rescue_elevator.gd) / [tests/staff/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rest.gd) / [tests/staff/test_shift_rush.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_shift_rush.gd) / [tests/ui/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_rest.gd) / [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) + +### B-3.4a 毎日00:00に決算レポート + +**判定:** 検証済み(記載範囲)。原本行 70–72。 + +**現状:** 00:00決算・給与・帳簿自動表示・保存復元を実装。 + +**差異・受入条件:** 時刻とレポートの要件を検証済み。未接続費目はB-9.2へ分離。 + +**コード:** [autoload/simulation_runtime.gd:2278](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:2278) / [scripts/economy/economy_service.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/economy/economy_service.gd:28) / [scripts/operations/operations_coordinator.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/operations/operations_coordinator.gd:143) + +**関連部品試験:** [tests/mvp/test_original_midnight_settlement.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_original_midnight_settlement.gd) / [tests/mvp/test_runtime_operations_acceptance.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_operations_acceptance.gd) + +### B-3.4b 3回連続赤字でゲームオーバー + +**判定:** 検証済み(記載範囲)。原本行 70–72。 + +**現状:** 3回連続赤字で終了。黒字/ゼロで連続数リセット。終了状態も保存。 + +**差異・受入条件:** 判定と終了制御を検証済み。全収支種別の充足とは別。 + +**コード:** [autoload/simulation_runtime.gd:2278](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:2278) / [scripts/economy/economy_service.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/economy/economy_service.gd:28) / [scripts/operations/operations_coordinator.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/operations/operations_coordinator.gd:143) + +**関連部品試験:** [tests/mvp/test_original_midnight_settlement.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_original_midnight_settlement.gd) / [tests/mvp/test_runtime_operations_acceptance.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_operations_acceptance.gd) + +### B-3.5 雨・嵐・マナの霧・流行ニュース + +**判定:** 該当機能未実装。原本行 73–75。 + +**現状:** 4時間帯はあるが天候・流行の実行系はない。 + +**差異・受入条件:** 雨の客足/滞在、嵐の新規客ゼロ、マナ霧効果、数日ごとのニュースと売上効果が未実装。 + +**コード:** [scripts/core/simulation_kernel.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/core/simulation_kernel.gd:28) / [autoload/game_clock.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/game_clock.gd:29) / [autoload/simulation_runtime.gd:446](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:446) + +**関連部品試験:** [tests/run_tests.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/run_tests.gd) / [tests/s0/test_runtime_integration.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_runtime_integration.gd) / [tests/ui/test_first_operating_day.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_first_operating_day.gd) + +### B-4.1 所持金/戦利品・四欲求・忍耐/清潔/依存 + +**判定:** 一部実装・差異あり。原本行 77–80。 + +**現状:** 財布・四欲求・忍耐・依存・信用/債務値あり。 + +**差異・受入条件:** 戦利品の所持/換金、客自身の清潔度と歩行汚染伝播が不足。hygiene欲求を清潔度伝播の実装と数えない。 + +**コード:** [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-4.2 最大欲求優先と距離/行列/価格選択 + +**判定:** 検証済み(記載範囲)。原本行 81–83。 + +**現状:** 利用可能候補の最大欲求を先に比較し、同順位を距離・待ち・価格等で選択。 + +**差異・受入条件:** 現行サービス候補で検証済み。未登録施設の網羅ではない。 + +**コード:** [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-4.3a A*とエレベーター待ち時間コスト + +**判定:** 実装あり・全受入未検証。原本行 84–87。 + +**現状:** A*相当の探索とヒューリスティック、EV行列から見積る待ちコストを経路費用に加算。 + +**差異・受入条件:** 今回経路・混雑テストPASS。実カゴ運行時間との見積誤差を含む原本専用の総合受入は未確認。 + +**コード:** [scripts/world/path_service.gd:938](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:938) / [scripts/world/path_service.gd:1672](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:1672) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/s1/test_path_congestion.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_congestion.gd) / [tests/transport/test_runtime_transport.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/transport/test_runtime_transport.gd) + +### B-4.3b 4種垂直移動のゲーム内利用 + +**判定:** 一部実装・差異あり。原本行 84–87。 + +**現状:** 客用EV・業務用EV・遅い階段は建設から実移動まで接続。 + +**差異・受入条件:** マナワープ施設・費用・瞬間移動は未実装。 + +**コード:** [scripts/world/path_service.gd:938](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:938) / [scripts/world/path_service.gd:1672](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:1672) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/s1/test_path_congestion.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_congestion.gd) / [tests/transport/test_runtime_transport.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/transport/test_runtime_transport.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-4.3c 重なり時速度50%低下 + +**判定:** 検証済み(記載範囲)。原本行 84–87。 + +**現状:** 重なった通路の移動速度0.5と移動クレジットを実装。 + +**差異・受入条件:** 今回混雑テストを再実行。経路コストだけでなく実移動速度も既存Runtime/交代試験対象。 + +**コード:** [scripts/world/path_service.gd:938](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:938) / [scripts/world/path_service.gd:1672](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:1672) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/s1/test_path_congestion.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_congestion.gd) / [tests/transport/test_runtime_transport.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/transport/test_runtime_transport.gd) + +### B-4.4 依存Lv3退店拒否・装備売却・性別別破産 + +**判定:** 一部実装・差異あり。原本行 88–90。 + +**現状:** 依存値と支出優先の基盤あり。 + +**差異・受入条件:** 依存Lv3退店拒否、装備売却、原本の性別別破産分岐は未接続。 + +**コード:** [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-4.5 貴族/聖職者/エルフ/魔族のAI特性 + +**判定:** 一部実装・差異あり。原本行 91–95。 + +**現状:** 8客クラス・4種族、予算/忍耐/嗜好等の定義あり。 + +**差異・受入条件:** 聖職者の目撃即退店/風紀、エルフの自然好み、魔族等の地下満足度耐性を原本通り通す受入がない。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) / [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-5.1 一般雇用と奴隷の費用/耐性/売却区別 + +**判定:** 一部実装・差異あり。原本行 97–99。 + +**現状:** FREE_HIRE/ADULT_CONTRACT/UNIQUEの区分と給与処理あり。 + +**差異・受入条件:** 原本の食費のみ・ストレス耐性差・売却ルール一式とは一致を確認できない。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) + +### B-5.2 体力/ストレス/忠誠/技能/ランダム特性 + +**判定:** 一部実装・差異あり。原本行 100–103。 + +**現状:** health/stress/loyalty/技能を保存し、勤務と救護でhealthを使用。 + +**差異・受入条件:** 候補20名は固定データ。生成時のランダム特性付与と全職業技能効果が不足。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) / [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) / [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-5.3 接客/裏方/救護/防衛等の職種 + +**判定:** 一部実装・差異あり。原本行 104–107。 + +**現状:** 受付・料理・清掃・運搬・医療・警備等の職種と一部実作業を実装。 + +**差異・受入条件:** 原本の全職種と専用施設・専用業務を網羅していない。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) / [autoload/simulation_runtime.gd:11906](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11906) / [autoload/simulation_runtime.gd:11985](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11985) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) / [scripts/supply/kitchen_production_service.gd:19](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/kitchen_production_service.gd:19) / [scripts/supply/supply_logistics_bridge.gd:17](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_logistics_bridge.gd:17) / [scripts/supply/supply_management_service.gd:7](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_management_service.gd:7) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) / [tests/staff/test_staff_rescue.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rescue.gd) / [tests/staff/test_rescue_elevator.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_rescue_elevator.gd) / [tests/staff/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rest.gd) / [tests/staff/test_shift_rush.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_shift_rush.gd) / [tests/ui/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_rest.gd) / [tests/supply/test_normal_supply_chain.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/supply/test_normal_supply_chain.gd) + +### B-5.4 シフト休息と気絶回収 + +**判定:** 検証済み(記載範囲)。原本行 108–110。 + +**現状:** 非番は稼働中待機室まで実移動して回復。体力0は回復せずBACK/EV搬送・治療へ。 + +**差異・受入条件:** 現行施設で途中保存再開と画面表示を検証済み。宿舎の通常建設は未接続。 + +**コード:** [autoload/simulation_runtime.gd:11906](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11906) / [autoload/simulation_runtime.gd:11985](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11985) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/staff/test_staff_rescue.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rescue.gd) / [tests/staff/test_rescue_elevator.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_rescue_elevator.gd) / [tests/staff/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rest.gd) / [tests/staff/test_shift_rush.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_shift_rush.gd) / [tests/ui/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_rest.gd) + +### B-5.5 求人/市場/破産捕獲・退職金/売買価格 + +**判定:** 一部実装・差異あり。原本行 111–113。 + +**現状:** 求人候補の採用・費用・割当、職員削除/退職APIあり。 + +**差異・受入条件:** 市場購入、破産客獲得、退職金、能力による売価と他職員忠誠変化の通常フローが不足。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) / [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) / [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-6.1 ユニーク無料・高成長・解雇売却不可 + +**判定:** 一部実装・差異あり。原本行 115–116。 + +**現状:** UNIQUE区分、unique/unsellableの定義タグあり。 + +**差異・受入条件:** 高成長・無料維持の一式と操作禁止の受入がない。remove_staffにはUNIQUE区分による削除拒否がない。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) / [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) / [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-6.2 7ヒロインの指定固有効果とライバル + +**判定:** 一部実装・差異あり。原本行 117–125。 + +**現状:** ユニークデータはアンリとライのみ。 + +**差異・受入条件:** 残り6ヒロイン等が不足。アンリは忍耐減少倍率9000bpの定義であり原本の配置階売上+20%ではない。効果の本番適用も未接続。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-6.3 基礎技能と業務EXP・Lv99 + +**判定:** 一部実装・差異あり。原本行 126–128。 + +**現状:** 基礎パラメータと技能値はある。 + +**差異・受入条件:** 業務EXP→レベル成長→Lv99上限の実行系が不足。MAX_SKILL_VALUE=999はLv99ではない。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) + +### B-6.4 配置パッシブ・任意発動・ルート別スキル + +**判定:** 一部実装・差異あり。原本行 129–132。 + +**現状:** 固有效果のデータ枠、防衛スキルAPIがある。 + +**差異・受入条件:** 7名の配置パッシブとプレイヤー任意発動、ルート別習得の通常導線が不足。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) / [scripts/defense/defense_service.gd:79](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/defense/defense_service.gd:79) / [autoload/simulation_runtime.gd:6475](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:6475) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/s5/test_defense_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_defense_service.gd) / [tests/s5/test_runtime_defense_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_runtime_defense_cycle.gd) + +### B-6.5 24時間勤務警告・衣装補正・限定離脱 + +**判定:** 一部実装・差異あり。原本行 133–135。 + +**現状:** シフト例外・超過勤務・不在状態などの基盤あり。 + +**差異・受入条件:** ユニークの24時間勤務警告/強制継続、衣装補正、原本指定の限定離脱条件まで未完了。 + +**コード:** [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) / [scripts/defense/defense_service.gd:79](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/defense/defense_service.gd:79) / [autoload/simulation_runtime.gd:6475](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:6475) + +**関連部品試験:** [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) / [tests/s5/test_defense_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_defense_service.gd) / [tests/s5/test_runtime_defense_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_runtime_defense_cycle.gd) + +### B-7.1 三感情・盾・決闘の3ルート運命 + +**判定:** 一部実装・差異あり。原本行 137–138。 + +**現状:** 関係値・盾・ルート固定・閾値イベントの汎用基盤あり。 + +**差異・受入条件:** 原本の3感情と決闘による3ルート全体は未実装。 + +**コード:** [scripts/relationship/relationship_state.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_state.gd:15) / [scripts/relationship/relationship_service.gd:223](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_service.gd:223) / [autoload/simulation_runtime.gd:7824](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7824) + +**関連部品試験:** [tests/s4/test_relationship_event_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_relationship_event_service.gd) / [tests/s4/test_runtime_relationship_events.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_runtime_relationship_events.gd) + +### B-7.2 好感/淫乱/NTR・好感度=盾最大・淫乱軽減 + +**判定:** 一部実装・差異あり。原本行 139–143。 + +**現状:** affectionとshield_current/maxはある。 + +**差異・受入条件:** 現在の5指標はtrust/respect/affection/fear/dependence。原本のlust/NTR値がなく、好感度=盾最大とlustによる防御低下に不一致。 + +**コード:** [scripts/relationship/relationship_state.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_state.gd:15) / [scripts/relationship/relationship_service.gd:223](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_service.gd:223) / [autoload/simulation_runtime.gd:7824](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7824) + +**関連部品試験:** [tests/s4/test_relationship_event_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_relationship_event_service.gd) / [tests/s4/test_runtime_relationship_events.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_runtime_relationship_events.gd) + +### B-7.3 同配置接触→盾破壊→NTR上昇→不可逆陥落 + +**判定:** 一部実装・差異あり。原本行 144–148。 + +**現状:** 盾攻撃API、配置リスク予測、ルート確認固定APIあり。 + +**差異・受入条件:** 通常Tickはイベント期限/候補処理。実配置による自動接触→盾破壊→NTR増加→不可逆陥落の一連の接続がない。 + +**コード:** [scripts/relationship/relationship_state.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_state.gd:15) / [scripts/relationship/relationship_service.gd:223](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_service.gd:223) / [autoload/simulation_runtime.gd:7824](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7824) / [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) + +**関連部品試験:** [tests/s4/test_relationship_event_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_relationship_event_service.gd) / [tests/s4/test_runtime_relationship_events.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_runtime_relationship_events.gd) / [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) + +### B-7.4 3種決闘・勝利追放/隷属/盾回復・敗北確定 + +**判定:** 該当機能未実装。原本行 149–153。 + +**現状:** 防衛戦はあるが原本の決闘とは別機能。 + +**差異・受入条件:** 3種類の決闘と勝敗による指定結果が未実装。 + +**コード:** [scripts/relationship/relationship_state.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_state.gd:15) / [scripts/relationship/relationship_service.gd:223](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_service.gd:223) / [autoload/simulation_runtime.gd:7824](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7824) / [scripts/defense/defense_service.gd:79](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/defense/defense_service.gd:79) / [autoload/simulation_runtime.gd:6475](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:6475) + +**関連部品試験:** [tests/s4/test_relationship_event_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_relationship_event_service.gd) / [tests/s4/test_runtime_relationship_events.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_runtime_relationship_events.gd) / [tests/s5/test_defense_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_defense_service.gd) / [tests/s5/test_runtime_defense_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s5/test_runtime_defense_cycle.gd) + +### B-7.5 会話/デート/隔離・覗き・薬による各ルート + +**判定:** 一部実装・差異あり。原本行 154–157。 + +**現状:** イベント選択・関係変化・盾回復の部品あり。 + +**差異・受入条件:** 原本の各プレイヤーアクションと全ルートイベントを通常UIから進める接続が不足。 + +**コード:** [scripts/relationship/relationship_state.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_state.gd:15) / [scripts/relationship/relationship_service.gd:223](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_service.gd:223) / [autoload/simulation_runtime.gd:7824](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7824) + +**関連部品試験:** [tests/s4/test_relationship_event_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_relationship_event_service.gd) / [tests/s4/test_runtime_relationship_events.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_runtime_relationship_events.gd) + +### B-7.6 履歴・妊娠/父親・刻印立ち絵 + +**判定:** 該当機能未実装。原本行 158–161。 + +**現状:** 汎用フラグはあるが原本専用の履歴・外見システムはない。 + +**差異・受入条件:** 指定履歴、妊娠周期・父親判定、立ち絵差分との接続が未実装。 + +**コード:** [scripts/relationship/relationship_state.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_state.gd:15) / [scripts/relationship/relationship_service.gd:223](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/relationship/relationship_service.gd:223) / [autoload/simulation_runtime.gd:7824](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7824) + +**関連部品試験:** [tests/s4/test_relationship_event_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_relationship_event_service.gd) / [tests/s4/test_runtime_relationship_events.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s4/test_runtime_relationship_events.gd) + +### B-8.1 隣接/通路必須とLv拡張 + +**判定:** 一部実装・差異あり。原本行 163–165。 + +**現状:** 支持/隣接、アクセス点・通路判定、max_level/upgrade_costs定義あり。 + +**差異・受入条件:** 施設LvUPの通常操作・支払い・能力変化・全解放設定が未完了。 + +**コード:** [scripts/world/world_service.gd:103](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/world_service.gd:103) / [scripts/construction/construction_service.gd:56](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/construction/construction_service.gd:56) / [scripts/progression/progression_service.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/progression/progression_service.gd:143) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s1/test_world_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_world_service.gd) / [tests/s1/test_construction_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_construction_service.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-8.2 表層全施設・飲食と厨房セット + +**判定:** 一部実装・差異あり。原本行 166–170。 + +**現状:** 一般客室・食堂・酒場・浴場等と厨房の生産配送を実装。 + +**差異・受入条件:** レストラン/VIP/カジノ/闘技場/礼拝堂等が通常登録されていない。飲食全件の厨房セット条件は未充足。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [scripts/supply/kitchen_production_service.gd:19](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/kitchen_production_service.gd:19) / [scripts/supply/supply_logistics_bridge.gd:17](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_logistics_bridge.gd:17) / [scripts/supply/supply_management_service.gd:7](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_management_service.gd:7) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/supply/test_normal_supply_chain.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/supply/test_normal_supply_chain.gd) + +### B-8.3 裏層全施設と特殊設備 + +**判定:** 一部実装・差異あり。原本行 171–174。 + +**現状:** 厨房・倉庫・待機室・警備詰所・医務室は通常登録あり。 + +**差異・受入条件:** 宿舎は拡張定義のみ。清掃員室・覗き部屋・地下牢・マナ炉が不足。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [autoload/simulation_runtime.gd:11906](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11906) / [autoload/simulation_runtime.gd:11985](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11985) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/staff/test_staff_rescue.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rescue.gd) / [tests/staff/test_rescue_elevator.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_rescue_elevator.gd) / [tests/staff/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rest.gd) / [tests/staff/test_shift_rush.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_shift_rush.gd) / [tests/ui/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_rest.gd) + +### B-8.4 EV/階段/ワープ/通路/職員ドア + +**判定:** 一部実装・差異あり。原本行 175–177。 + +**現状:** 2種EV、階段、2層通路、職員ドアあり。 + +**差異・受入条件:** ワープ未実装。通路・職員専用制限は経路試験対象。 + +**コード:** [scripts/world/path_service.gd:938](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:938) / [scripts/world/path_service.gd:1672](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:1672) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/s1/test_path_congestion.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_congestion.gd) / [tests/transport/test_runtime_transport.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/transport/test_runtime_transport.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-8.5 Lv解放の価格/食材/混浴/ハード/イカサマ設定 + +**判定:** 一部実装・差異あり。原本行 178–179。 + +**現状:** 価格ポリシー、営業時間/営業状態のUIあり。 + +**差異・受入条件:** レベル解放と食材ランク/混浴/施設別特殊設定が原本一式として未完了。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-9.1 表金/裏金・マナ全館停止・資材 + +**判定:** 一部実装・差異あり。原本行 181–184。 + +**現状:** Gold/材料/マナ、帳簿、電力不足による施設状態あり。 + +**差異・受入条件:** 表金/裏金の独立運用と「マナ枯渇で全館停止」は未充足。Runtimeは出力100の初期マナ源を持つ。 + +**コード:** [scripts/economy/economy_service.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/economy/economy_service.gd:28) / [scripts/mana/mana_network_service.gd:46](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/mana/mana_network_service.gd:46) / [autoload/simulation_runtime.gd:4736](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4736) + +**関連部品試験:** [tests/s2/test_economy_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s2/test_economy_service.gd) / [tests/s2/test_mana_network_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s2/test_mana_network_service.gd) + +### B-9.2 全収益/人件費/維持/仕入/借金/ランク税 + +**判定:** 一部実装・差異あり。原本行 185–188。 + +**現状:** 宿泊飲食売上、給与、仕入れ、建設、罰金等を記帳。 + +**差異・受入条件:** maintenance_goldは定義/検証のみ。日次maintenance/utilities/taxes_and_interestに全施設維持費・借金返済・ランク税を計上する実処理がない。 + +**コード:** [autoload/simulation_runtime.gd:2278](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:2278) / [scripts/economy/economy_service.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/economy/economy_service.gd:28) / [scripts/operations/operations_coordinator.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/operations/operations_coordinator.gd:143) / [scripts/supply/kitchen_production_service.gd:19](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/kitchen_production_service.gd:19) / [scripts/supply/supply_logistics_bridge.gd:17](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_logistics_bridge.gd:17) / [scripts/supply/supply_management_service.gd:7](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_management_service.gd:7) + +**関連部品試験:** [tests/mvp/test_original_midnight_settlement.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_original_midnight_settlement.gd) / [tests/mvp/test_runtime_operations_acceptance.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_operations_acceptance.gd) / [tests/supply/test_normal_supply_chain.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/supply/test_normal_supply_chain.gd) + +### B-9.3 受付/金庫/購買/闇銀行 + +**判定:** 一部実装・差異あり。原本行 189–193。 + +**現状:** 受付・精算待ち、購買/自動発注管理の部品あり。 + +**差異・受入条件:** 金庫室の上限/盗賊、戦利品買取、闇銀行融資・滞納破壊を施設と通常UIへ接続していない。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [scripts/supply/kitchen_production_service.gd:19](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/kitchen_production_service.gd:19) / [scripts/supply/supply_logistics_bridge.gd:17](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_logistics_bridge.gd:17) / [scripts/supply/supply_management_service.gd:7](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/supply/supply_management_service.gd:7) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/supply/test_normal_supply_chain.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/supply/test_normal_supply_chain.gd) + +### B-9.4 依存客の娯楽優先・信用超過の破産拘束 + +**判定:** 一部実装・差異あり。原本行 194–196。 + +**現状:** 高依存時の生存予算留保0、信用枠/債務を実装。 + +**差異・受入条件:** 信用超過→原本の破産イベント/拘束/配置まで未接続。 + +**コード:** [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) + +**関連部品試験:** [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-10.1 宿泊施設の寸法/層/ゾーン + +**判定:** 一部実装・差異あり。原本行 198–199。 + +**現状:** 一般客室2×1 FRONTは一致。 + +**差異・受入条件:** 上級客室は定義3×2で原本4×1と不一致かつ通常未登録。VIP6×2、ドミトリー4×1なし。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-10.2 飲食施設の寸法/層/ゾーン + +**判定:** 一部実装・差異あり。原本行 200–201。 + +**現状:** 大衆食堂4×2、酒場4×2 FRONTは寸法一致。 + +**差異・受入条件:** レストラン6×2・屋台2×1なし。騒音定義の実効果不足。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-10.3 衛生/娯楽施設の寸法/層/ゾーン + +**判定:** 一部実装・差異あり。原本行 202–203。 + +**現状:** 大浴場6×2 FRONTは寸法一致。 + +**差異・受入条件:** カジノ6×3、闘技場8×4、礼拝堂4×3なし。浴場の覗き機能なし。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-10.4 アングラ施設の寸法/層/ゾーン + +**判定:** 一部実装・差異あり。原本行 204–205。 + +**現状:** 個室2×1 FRONT地下は寸法/層一致。 + +**差異・受入条件:** 高級ソープ・SM室なし。闇市場は定義3×2で原本4×2と不一致かつ通常未登録。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-10.5 バックヤード施設の寸法/層/ゾーン + +**判定:** 一部実装・差異あり。原本行 206–207。 + +**現状:** 厨房4×2・倉庫4×2・待機室4×2・警備2×2・医務4×2 BACKは寸法一致。 + +**差異・受入条件:** 従業員寮は定義4×2で原本2×1と不一致かつ通常未登録。清掃員室2×1なし。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [autoload/simulation_runtime.gd:11906](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11906) / [autoload/simulation_runtime.gd:11985](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11985) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/staff/test_staff_rescue.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rescue.gd) / [tests/staff/test_rescue_elevator.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_rescue_elevator.gd) / [tests/staff/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rest.gd) / [tests/staff/test_shift_rush.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_shift_rush.gd) / [tests/ui/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_rest.gd) + +### B-10.6 特殊施設の寸法/層/ゾーン + +**判定:** 該当機能未実装。原本行 208–209。 + +**現状:** 原本3施設に対応する通常登録・定義なし。 + +**差異・受入条件:** 覗き部屋2×1 BACK、地下牢4×2 BACK地下、マナ炉6×3 BACK地下が未実装。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-10.7 インフラ/防衛施設の寸法/層/ゾーン + +**判定:** 一部実装・差異あり。原本行 210–211。 + +**現状:** 客用EV2×2 FRONT・業務用EV3×2 BACKは一致。 + +**差異・受入条件:** ワープ2×2 FRONT・外壁魔法砲台がない。防衛APIの存在は砲台施設の証拠ではない。 + +**コード:** [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) / [scripts/world/path_service.gd:938](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:938) / [scripts/world/path_service.gd:1672](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:1672) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) / [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/s1/test_path_congestion.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_congestion.gd) / [tests/transport/test_runtime_transport.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/transport/test_runtime_transport.gd) + +### B-11.1 資源/時間/天候/Rank・下部切替・スクロールズーム + +**判定:** 一部実装・差異あり。原本行 213–216。 + +**現状:** 資源/時間/ランク、レイヤー切替、パン/ズームあり。 + +**差異・受入条件:** 天候表示、指定下部巨大切替、非選択層の半透明表示まで原本一致ではない。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scenes/main/world_preview.gd:653](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:653) / [scripts/world/path_service.gd:952](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:952) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) + +### B-11.2 Policy/ドラッグ配置・欲求/思考/感情グラフ/盾 + +**判定:** 一部実装・差異あり。原本行 217–219。 + +**現状:** 価格ポリシー、職員割当UI、客四欲求/状態/目的地表示あり。 + +**差異・受入条件:** ドラッグ配置、AI思考履歴、3感情グラフ、盾詳細の通常UIが不足。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scripts/staff/staff_runtime.gd:29](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_runtime.gd:29) / [scripts/staff/staff_service.gd:453](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/staff/staff_service.gd:453) / [scripts/ui/staff_management_panel.gd:42](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/staff_management_panel.gd:42) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/s3/test_staff_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_service.gd) / [tests/s3/test_staff_payroll_operations.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_staff_payroll_operations.gd) / [tests/ui/test_staff_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_management.gd) + +### B-11.3 カメラ入口・覗きアングル/撮影/介入 + +**判定:** 該当機能未実装。原本行 220–222。 + +**現状:** 現在のメインUIに原本の覗き専用導線はない。 + +**差異・受入条件:** カメラ入口、角度、撮影、証拠収集、介入の一式が未実装。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-11.4 グリッド/接続警告・フキダシ/ログ・カットイン + +**判定:** 一部実装・差異あり。原本行 223–226。 + +**現状:** グリッド建設プレビュー・警告/状態表示・ログと人物表示あり。 + +**差異・受入条件:** 原本指定の全フキダシ、重要イベント/決闘/防衛カットインと接続線警告の全受入は不足。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-12.1 二面性・高精細ドットとアニメ立ち絵 + +**判定:** 一部実装・差異あり。原本行 228–230。 + +**現状:** Kenneyタイルと独自ドット人物で断面図・室内作業を表示。 + +**差異・受入条件:** 原本の高品質アニメ立ち絵と二面性表現の完成受入はない。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-12.2 魔法レンズ風の波紋切替 + +**判定:** 該当機能未実装。原本行 231–232。 + +**現状:** レイヤー選択は即時切替。 + +**差異・受入条件:** 魔法レンズ風の波紋反転演出が未実装。 + +**コード:** [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scenes/main/world_preview.gd:653](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:653) / [scripts/world/path_service.gd:952](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/path_service.gd:952) + +**関連部品試験:** [tests/s1/test_path_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_path_service.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) + +### B-12.3 SD各モーション・立ち絵破損/汚れ/刻印差分 + +**判定:** 一部実装・差異あり。原本行 233–235。 + +**現状:** 歩行・待機・作業・気絶/搬送/治療・休息のドット表示あり。 + +**差異・受入条件:** 原本の全職種専用モーション、立ち絵破損/汚れ/刻印差分は未完成。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [autoload/simulation_runtime.gd:11906](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11906) / [autoload/simulation_runtime.gd:11985](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:11985) / [scripts/transport/elevator_service.gd:178](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/transport/elevator_service.gd:178) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) / [tests/staff/test_staff_rescue.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rescue.gd) / [tests/staff/test_rescue_elevator.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_rescue_elevator.gd) / [tests/staff/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_staff_rest.gd) / [tests/staff/test_shift_rush.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/staff/test_shift_rush.gd) / [tests/ui/test_staff_rest.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_staff_rest.gd) + +### B-12.4 表裏UIスキン・BGMクロスフェード・環境音/SE + +**判定:** 一部実装・差異あり。原本行 236–239。 + +**現状:** 昼夜/レイヤーの色調変化あり。 + +**差異・受入条件:** 羊皮紙/黒鉄の完成スキン、表裏BGMクロスフェード、環境音/SEの実再生なし。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) / [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-13.1 立ち絵/CG/差分 + +**判定:** 該当機能未実装。原本行 241–243。 + +**現状:** キャラクターデータはあるが指定イラスト一式はない。 + +**差異・受入条件:** メイン/サブ立ち絵、衣装/表情/ルート差分・イベントCGの制作と接続が不足。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-13.2 SDモーション/エフェクト + +**判定:** 一部実装・差異あり。原本行 244–246。 + +**現状:** ドット人物と一部状態モーションあり。 + +**差異・受入条件:** 原本が求める全モーションと盾/煙/マナ等の全演出資産は未完成。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-13.3 Lv1〜3表裏タイル/背景 + +**判定:** 一部実装・差異あり。原本行 247–248。 + +**現状:** 現行施設内装・背景・タイル素材あり。 + +**差異・受入条件:** 全施設Lv1〜3の表裏外観/内装差分とLvUP表示接続が不足。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scripts/data/facility_catalog.gd:28](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_catalog.gd:28) / [scripts/data/facility_definition.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:32) / [autoload/simulation_runtime.gd:386](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:386) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) / [tests/s0/test_definition_registry.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s0/test_definition_registry.gd) / [tests/s1/test_runtime_world_construction.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_runtime_world_construction.gd) + +### B-13.4 表裏スキン/感情/覗き素材 + +**判定:** 一部実装・差異あり。原本行 249–250。 + +**現状:** 現行管理UI・ボタン・欲求バー等あり。 + +**差異・受入条件:** 表裏スキン完成版、3感情グラフ、専用カメラオーバーレイなし。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) / [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) / [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) + +### B-13.5 BGM/環境/操作/成人向けSE/声 + +**判定:** 該当機能未実装。原本行 251–252。 + +**現状:** assets内にogg/wav/mp3がなく、本体コードにAudioStreamPlayer参照がない。 + +**差異・受入条件:** 動的BGM、環境/操作SE、ボイス等の同梱・再生が未実装。 + +**コード:** [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-14.1 2026/03〜08 プロトタイプ + +**判定:** 工程要件・完了未認定。原本行 255–255。 + +**現状:** MVP相当の開業ループと試験は存在する。 + +**差異・受入条件:** 工程要件。2026/03〜08の納期達成や当時の受入完了を現在コードからは認定できない。 + +**コード:** [scripts/world/world_service.gd:103](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/world/world_service.gd:103) / [scripts/construction/construction_service.gd:56](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/construction/construction_service.gd:56) / [scripts/progression/progression_service.gd:143](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/progression/progression_service.gd:143) / [scripts/guest/guest_runtime.gd:34](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_runtime.gd:34) / [scripts/guest/guest_service.gd:1367](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/guest/guest_service.gd:1367) / [autoload/simulation_runtime.gd:7723](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:7723) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/s1/test_world_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_world_service.gd) / [tests/s1/test_construction_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s1/test_construction_service.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/s3/test_guest_service.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_guest_service.gd) / [tests/s3/test_runtime_actor_cycle.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/s3/test_runtime_actor_cycle.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-14.2 2026/09〜2027/02 アルファ + +**判定:** 工程要件・完了未認定。原本行 256–256。 + +**現状:** パラメータ基盤あり。現日はアルファ予定期間内。 + +**差異・受入条件:** 量産素材・シナリオ・通常BGMの工程完了は未達。機能完了数に混ぜない。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-14.3 2027/03〜08 ベータ/成人向け + +**判定:** 工程要件・完了未認定。原本行 257–257。 + +**現状:** 原本に将来工程として記載。 + +**差異・受入条件:** 2027/03〜08の完成条件であり、現在完了扱いにしない。 + +**コード:** [scripts/data/mvp_content_catalog.gd:33](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/mvp_content_catalog.gd:33) / [data/characters/mvp_unique_characters.json:16](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/characters/mvp_unique_characters.json:16) / [autoload/simulation_runtime.gd:4895](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/autoload/simulation_runtime.gd:4895) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/mvp/test_mvp_content_catalog.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_mvp_content_catalog.gd) / [tests/mvp/test_runtime_content_progression.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/mvp/test_runtime_content_progression.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +### B-14.4 2027/09〜12 ポリッシュ + +**判定:** 工程要件・完了未認定。原本行 258–258。 + +**現状:** デバッグ・性能確認の実施記録はある。 + +**差異・受入条件:** 2027/09〜12のマスターアップ工程全体の承認証拠はない。個別PASSで代替しない。 + +**コード:** [scenes/main/main.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/main.gd:13) / [scripts/ui/guest_observation_panel.gd:15](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/guest_observation_panel.gd:15) / [scripts/ui/business_management_panel.gd:171](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/business_management_panel.gd:171) / [scripts/ui/pixel_people.gd:10](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_people.gd:10) / [scripts/ui/pixel_tower_art.gd:13](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/ui/pixel_tower_art.gd:13) / [scenes/main/world_preview.gd:468](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scenes/main/world_preview.gd:468) + +**関連部品試験:** [tests/ui/test_main_dashboard.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_main_dashboard.gd) / [tests/ui/test_business_management.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_business_management.gd) / [tests/ui/test_guest_observation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_guest_observation.gd) / [tests/ui/test_pixel_tower.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_pixel_tower.gd) / [tests/ui/test_room_presentation.gd](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/tests/ui/test_room_presentation.gd) + +## 原本10章の30施設 + +寸法一致は役割・特殊機能の完成を意味しない。地下客室をドミトリー、地下VIPサロンを高層VIPスイート、魔力制御室をマナ炉と読み替えていない。 + +| 原本施設 | 原本寸法・層 | 定義 | 現行寸法 | 通常登録 / パレット | +|---|---|---|---|---| +| 一般客室 | 2x1 FRONT | [data/facilities/standard_room.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/standard_room.tres) | 2x1 | あり / あり | +| 上級客室 | 4x1 FRONT | [data/facilities/expansion/premium_room.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/expansion/premium_room.tres) | 3x2 | なし / なし | +| VIPスイート | 6x2 FRONT高層 | なし | — | なし / なし | +| ドミトリー | 4x1 FRONT地下 | なし | — | なし / なし | +| 大衆食堂 | 4x2 FRONT | [data/facilities/cafeteria.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/cafeteria.tres) | 4x2 | あり / あり | +| レストラン | 6x2 FRONT | なし | — | なし / なし | +| 酒場 | 4x2 FRONT | [data/facilities/tavern.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/tavern.tres) | 4x2 | あり / あり | +| 屋台 | 2x1 FRONT | なし | — | なし / なし | +| 大浴場 | 6x2 FRONT | [data/facilities/public_bath.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/public_bath.tres) | 6x2 | あり / あり | +| カジノ | 6x3 FRONT地下 | なし | — | なし / なし | +| 闘技場 | 8x4 FRONT地下 | なし | — | なし / なし | +| 礼拝堂 | 4x3 FRONT | なし | — | なし / なし | +| 風俗個室 | 2x1 FRONT地下 | [data/facilities/adult_private_room.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/adult_private_room.tres) | 2x1 | あり / あり | +| 高級ソープ | 4x2 FRONT地下 | なし | — | なし / なし | +| SM拷問室 | 4x2 FRONT地下 | なし | — | なし / なし | +| 闇市場 | 4x2 FRONT地下 | [data/facilities/expansion/black_market.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/expansion/black_market.tres) | 3x2 | なし / なし | +| 厨房 | 4x2 BACK | [data/facilities/kitchen.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/kitchen.tres) | 4x2 | あり / あり | +| 倉庫 | 4x2 BACK | [data/facilities/warehouse.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/warehouse.tres) | 4x2 | あり / あり | +| 待機室 | 4x2 BACK | [data/facilities/staff_waiting_room.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/staff_waiting_room.tres) | 4x2 | あり / あり | +| 従業員寮 | 2x1 BACK | [data/facilities/expansion/staff_dormitory.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/expansion/staff_dormitory.tres) | 4x2 | なし / なし | +| 警備員詰所 | 2x2 BACK | [data/facilities/security_station.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/security_station.tres) | 2x2 | あり / あり | +| 清掃員室 | 2x1 BACK | なし | — | なし / なし | +| 医務室 | 4x2 BACK | [data/facilities/rescue/infirmary.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/rescue/infirmary.tres) | 4x2 | あり / あり | +| 覗き部屋 | 2x1 BACK | なし | — | なし / なし | +| 地下牢 | 4x2 BACK地下 | なし | — | なし / なし | +| マナ炉 | 6x3 BACK地下 | なし | — | なし / なし | +| 蔓の昇降機 | 2x2 FRONT | [data/facilities/guest_elevator.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/guest_elevator.tres) | 2x2 | あり / あり | +| 業務用EV | 3x2 BACK | [data/facilities/service_elevator.tres](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/data/facilities/service_elevator.tres) | 3x2 | あり / あり | +| ワープ | 2x2 FRONT | なし | — | なし / なし | +| 魔法砲台 | 未指定 外壁 | なし | — | なし / なし | + +## 追加指定:施設主要10パラメータ + +静的55項目と可変項目の全フィールド一覧に置き換えるものではない。以下は会話で追加指定された主要項目の追跡。 + +| No. | 項目 | 実装と判定 | +|---:|---|---| +| 1 | 配置コスト | construction_costと建設支払いあり。通常建設で検証。 | +| 2 | 運用コスト | maintenance_gold/manaの定義と電力基盤あり。maintenance_goldの継続支出は未接続。 | +| 3 | 利用料金 | 通常料金/価格ポリシーは会計へ接続。一致加算は計算APIのみで請求へ未接続。 | +| 4 | サイズ | footprintと占有/支持判定あり。原本と不一致の3施設は上表参照。 | +| 5 | 役割 | 職種/サービス/利用者・物流の基盤あり。原本の全施設・全役割は未完了。 | +| 6 | 稼働状態 | INACTIVE/OPERATING/SUSPENDED、破壊/摘発後休止、関連施設復旧だけでは休止解除しない。保存/復元試験あり。復帰条件は未設定のまま。 | +| 7 | 顧客同時利用最大人数 | customer_capacity、利用中の人数判定、待機人数との分離あり。全35定義に明示値がなく既定-1(未指定)。本番施設ごとの人数上限設定は未完了。generic capacityから推定していない。 | +| 8 | 配置可能従業員最大数 | staff_maxとstaff_minを別に保持しRuntime割当上限に使用。人数仕様の全施設値レビューは別途必要。 | +| 9 | 関連施設/稼働フラグ | 単一関連先・実稼働判定・循環拒否・UI指定・保存/復元を実装。単なる配置有無では判定しない。複数指定/AND/ORは未設定のまま。 | +| 10 | 提供サービス属性 | appetite/relaxation/sexual/gamblingの型と一致加算APIあり。不一致を利用不可にはしない。全35定義の属性は未設定。quote_serviceは本体内で呼ばれていない。 | + +加算額/率、不足判定基準、施設の複数属性、複数一致の重複加算、休止復帰条件はユーザーが未設定と指定した事項。実装者が勝手に補完すべき確定仕様ではない。一方で、値決定後に通常プレイの請求へ接続する作業は残っている。 + +追加仕様のコード: [scripts/facility/facility_instance.gd:8](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/facility/facility_instance.gd:8) / [scripts/facility/facility_service.gd:351](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/facility/facility_service.gd:351) / [scripts/facility/facility_pricing.gd:32](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/facility/facility_pricing.gd:32)。人数/サービス属性定義は [scripts/data/facility_definition.gd:61](/Users/korizatou114/Projects/とりにく工房ゲーム制作部/scripts/data/facility_definition.gd:61)。 + +## 優先して解消する差異 + +1. 経済の実接続:施設維持費、借金/税、マナ枯渇、天候と集客への影響。 +2. 原本施設の寸法と登録を揃え、役割・アクセス・物流・保存・UI・受入をセットで通す。 +3. 追加仕様の未設定値を決定した後、顧客人数上限とサービス属性加算を本番データ・請求へ接続する。 +4. 原本と異なるキャラクター効果・関係パラメータを仕様差異として扱い、通常プレイへの導線と内容を整える。 +5. 非選択層表示、波紋、立ち絵/音響、全体シナリオの受入。 + +機械可読成果物: `requirements.json`、`facilities.json`、`evidence.json`、`source_manifest.json`。監査前の75件表は `previous_requirements.json` に保存。 diff --git a/docs/traceability/original/audit-2026-09-29/evidence.json b/docs/traceability/original/audit-2026-09-29/evidence.json new file mode 100644 index 0000000..d665aa9 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/evidence.json @@ -0,0 +1,96 @@ +{ + "date": "2026-09-29", + "workspace": "/Users/korizatou114/Projects/とりにく工房ゲーム制作部", + "baseline_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "baseline_drive_id": "1w3hpz3kacxo1jRDwDhDT0TzNRJtAo-LW", + "baseline_snapshot_date": "2026-09-22", + "prior_live_verification_date": "2026-09-27", + "drive_refetched_this_audit": false, + "requirement_count": 75, + "counts": { + "一部実装・差異あり": 52, + "実装あり・全受入未検証": 2, + "該当機能未実装": 9, + "検証済み(記載範囲)": 8, + "工程要件・完了未認定": 4 + }, + "previous_manifest_checked_entries": 24, + "previous_manifest_mismatches_before_doc_update": [], + "existing_source_suites": 90, + "existing_failed_suites": 0, + "existing_logs_missing": [], + "normal_registered_definitions": 19, + "palette_entries": 19, + "all_facility_definitions": 35, + "original_ch10_types": 30, + "original_ch10_normal_registered": 12, + "original_ch10_definition_only": 3, + "original_ch10_absent": 15, + "facility_definitions_explicit_customer_capacity": 0, + "facility_definitions_explicit_service_attribute": 0, + "new_test_results": "artifacts/traceability-audit-2026-09-29/results.json", + "windows_native_run_this_audit": false, + "new_checks": [ + { + "check": "check", + "exit_code": 0, + "seconds": 10.93, + "log": "artifacts/traceability-audit-2026-09-29/check.log" + }, + { + "check": "tests/run_tests.gd", + "exit_code": 0, + "seconds": 1.08, + "log": "artifacts/traceability-audit-2026-09-29/tests__run_tests.gd.log" + }, + { + "check": "tests/s0/test_runtime_integration.gd", + "exit_code": 0, + "seconds": 20.92, + "log": "artifacts/traceability-audit-2026-09-29/tests__s0__test_runtime_integration.gd.log" + }, + { + "check": "tests/s1/test_path_service.gd", + "exit_code": 0, + "seconds": 1.04, + "log": "artifacts/traceability-audit-2026-09-29/tests__s1__test_path_service.gd.log" + }, + { + "check": "tests/s1/test_path_congestion.gd", + "exit_code": 0, + "seconds": 0.95, + "log": "artifacts/traceability-audit-2026-09-29/tests__s1__test_path_congestion.gd.log" + }, + { + "check": "tests/s2/test_original_facility_parameters.gd", + "exit_code": 0, + "seconds": 0.94, + "log": "artifacts/traceability-audit-2026-09-29/tests__s2__test_original_facility_parameters.gd.log" + }, + { + "check": "tests/ui/test_original_facility_controls.gd", + "exit_code": 0, + "seconds": 1.36, + "log": "artifacts/traceability-audit-2026-09-29/tests__ui__test_original_facility_controls.gd.log" + }, + { + "check": "tests/s4/test_relationship_event_service.gd", + "exit_code": 0, + "seconds": 0.96, + "log": "artifacts/traceability-audit-2026-09-29/tests__s4__test_relationship_event_service.gd.log" + }, + { + "check": "tests/ui/test_first_operating_day.gd", + "exit_code": 0, + "seconds": 118.4, + "log": "artifacts/traceability-audit-2026-09-29/tests__ui__test_first_operating_day.gd.log" + } + ], + "new_check_count": 9, + "new_failed_checks": 0, + "source_manifest_entries": 231, + "audit_integrity": "75 unique IDs; code anchors and test paths valid; source hashes match", + "source_line_count": 258, + "unmapped_substantive_lines": 0, + "new_assertion_count": 400 +} diff --git a/docs/traceability/original/audit-2026-09-29/facilities.json b/docs/traceability/original/audit-2026-09-29/facilities.json new file mode 100644 index 0000000..5ef1c03 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/facilities.json @@ -0,0 +1,437 @@ +[ + { + "section": "10.1", + "name": "一般客室", + "definition_id": "standard_room", + "original_size": "2x1", + "original_layer_zone": "FRONT", + "definition": { + "path": "data/facilities/standard_room.tres", + "footprint": "2x1", + "layer": 0, + "zone": 1, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.1", + "name": "上級客室", + "definition_id": "premium_room", + "original_size": "4x1", + "original_layer_zone": "FRONT", + "definition": { + "path": "data/facilities/expansion/premium_room.tres", + "footprint": "3x2", + "layer": 0, + "zone": 1, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.1", + "name": "VIPスイート", + "definition_id": null, + "original_size": "6x2", + "original_layer_zone": "FRONT高層", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.1", + "name": "ドミトリー", + "definition_id": null, + "original_size": "4x1", + "original_layer_zone": "FRONT地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.2", + "name": "大衆食堂", + "definition_id": "cafeteria", + "original_size": "4x2", + "original_layer_zone": "FRONT", + "definition": { + "path": "data/facilities/cafeteria.tres", + "footprint": "4x2", + "layer": 0, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.2", + "name": "レストラン", + "definition_id": null, + "original_size": "6x2", + "original_layer_zone": "FRONT", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.2", + "name": "酒場", + "definition_id": "tavern", + "original_size": "4x2", + "original_layer_zone": "FRONT", + "definition": { + "path": "data/facilities/tavern.tres", + "footprint": "4x2", + "layer": 0, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.2", + "name": "屋台", + "definition_id": null, + "original_size": "2x1", + "original_layer_zone": "FRONT", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.3", + "name": "大浴場", + "definition_id": "public_bath", + "original_size": "6x2", + "original_layer_zone": "FRONT", + "definition": { + "path": "data/facilities/public_bath.tres", + "footprint": "6x2", + "layer": 0, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.3", + "name": "カジノ", + "definition_id": null, + "original_size": "6x3", + "original_layer_zone": "FRONT地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.3", + "name": "闘技場", + "definition_id": null, + "original_size": "8x4", + "original_layer_zone": "FRONT地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.3", + "name": "礼拝堂", + "definition_id": null, + "original_size": "4x3", + "original_layer_zone": "FRONT", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.4", + "name": "風俗個室", + "definition_id": "adult_private_room", + "original_size": "2x1", + "original_layer_zone": "FRONT地下", + "definition": { + "path": "data/facilities/adult_private_room.tres", + "footprint": "2x1", + "layer": 0, + "zone": 3, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.4", + "name": "高級ソープ", + "definition_id": null, + "original_size": "4x2", + "original_layer_zone": "FRONT地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.4", + "name": "SM拷問室", + "definition_id": null, + "original_size": "4x2", + "original_layer_zone": "FRONT地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.4", + "name": "闇市場", + "definition_id": "black_market", + "original_size": "4x2", + "original_layer_zone": "FRONT地下", + "definition": { + "path": "data/facilities/expansion/black_market.tres", + "footprint": "3x2", + "layer": 0, + "zone": 3, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.5", + "name": "厨房", + "definition_id": "kitchen", + "original_size": "4x2", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/kitchen.tres", + "footprint": "4x2", + "layer": 1, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.5", + "name": "倉庫", + "definition_id": "warehouse", + "original_size": "4x2", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/warehouse.tres", + "footprint": "4x2", + "layer": 1, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.5", + "name": "待機室", + "definition_id": "staff_waiting_room", + "original_size": "4x2", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/staff_waiting_room.tres", + "footprint": "4x2", + "layer": 1, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.5", + "name": "従業員寮", + "definition_id": "staff_dormitory", + "original_size": "2x1", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/expansion/staff_dormitory.tres", + "footprint": "4x2", + "layer": 1, + "zone": 1, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.5", + "name": "警備員詰所", + "definition_id": "security_station", + "original_size": "2x2", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/security_station.tres", + "footprint": "2x2", + "layer": 1, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.5", + "name": "清掃員室", + "definition_id": null, + "original_size": "2x1", + "original_layer_zone": "BACK", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.5", + "name": "医務室", + "definition_id": "infirmary", + "original_size": "4x2", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/rescue/infirmary.tres", + "footprint": "4x2", + "layer": 1, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.6", + "name": "覗き部屋", + "definition_id": null, + "original_size": "2x1", + "original_layer_zone": "BACK", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.6", + "name": "地下牢", + "definition_id": null, + "original_size": "4x2", + "original_layer_zone": "BACK地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.6", + "name": "マナ炉", + "definition_id": null, + "original_size": "6x3", + "original_layer_zone": "BACK地下", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.7", + "name": "蔓の昇降機", + "definition_id": "guest_elevator", + "original_size": "2x2", + "original_layer_zone": "FRONT", + "definition": { + "path": "data/facilities/guest_elevator.tres", + "footprint": "2x2", + "layer": 0, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.7", + "name": "業務用EV", + "definition_id": "service_elevator", + "original_size": "3x2", + "original_layer_zone": "BACK", + "definition": { + "path": "data/facilities/service_elevator.tres", + "footprint": "3x2", + "layer": 1, + "zone": 0, + "customer_capacity_explicit": false, + "service_attribute_explicit": false + }, + "runtime_registered": true, + "palette_listed": true, + "size_matches": true + }, + { + "section": "10.7", + "name": "ワープ", + "definition_id": null, + "original_size": "2x2", + "original_layer_zone": "FRONT", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + }, + { + "section": "10.7", + "name": "魔法砲台", + "definition_id": null, + "original_size": "未指定", + "original_layer_zone": "外壁", + "definition": null, + "runtime_registered": false, + "palette_listed": false, + "size_matches": false + } +] diff --git a/docs/traceability/original/audit-2026-09-29/previous_requirements.json b/docs/traceability/original/audit-2026-09-29/previous_requirements.json new file mode 100644 index 0000000..b039c61 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/previous_requirements.json @@ -0,0 +1,934 @@ +[ + { + "id": "B-1.1", + "section": "1.1", + "requirement": "基本情報・Windows・2D・20〜30時間/50時間以上", + "source_lines": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-1.2", + "section": "1.2", + "requirement": "地上/地下の二面性経営・観察介入・権威/富", + "source_lines": [ + 11, + 12, + 13, + 14 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-1.3", + "section": "1.3", + "requirement": "建設→集客→収益→管理→拡張防衛", + "source_lines": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-1.4", + "section": "1.4", + "requirement": "二層・自律AI・渋滞・信仰と依存", + "source_lines": [ + 23, + 24, + 25, + 26 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-1.5", + "section": "1.5", + "requirement": "世界観・主人公・7ヒロイン3運命", + "source_lines": [ + 27, + 28, + 29, + 30 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-1.6", + "section": "1.6", + "requirement": "成人向けルートと経営的搾取", + "source_lines": [ + 31, + 32, + 33, + 34 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-2.1", + "section": "2.1", + "requirement": "グリッド・Y正負・支持・領域拡張", + "source_lines": [ + 36, + 37, + 38, + 39 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-2.2", + "section": "2.2", + "requirement": "同座標Front/Back・半透明/ワイヤー表示", + "source_lines": [ + 40, + 41, + 42, + 43, + 44 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-2.3", + "section": "2.3", + "requirement": "客エリアと景観/清潔/騒音の満足度効果", + "source_lines": [ + 45, + 46, + 47 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-2.4", + "section": "2.4", + "requirement": "バックヤード・職員ドア・客通行禁止", + "source_lines": [ + 48, + 49, + 50 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-2.5", + "section": "2.5", + "requirement": "覗き部屋の配置制約・通路寸断・証拠収集", + "source_lines": [ + 51, + 52, + 53 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-2.6", + "section": "2.6", + "requirement": "地上/地下の採光・マナ・遮音・隠蔽ボーナス", + "source_lines": [ + 54, + 55, + 56 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-3.1a", + "section": "3.1a", + "requirement": "10秒=1時間、Pause/x1/x2/x3", + "source_lines": [ + 58, + 59, + 60 + ], + "current_status": "実装・検証済み", + "current_note": "10秒/時間・1/2/3倍速・旧4倍速セーブを3倍速へ移行。tests/run_tests.gd, tests/s6/test_performance_budget.gd" + }, + { + "id": "B-3.1b", + "section": "3.1b", + "requirement": "アクティブポーズ中も建設・指示", + "source_lines": [ + 58, + 59, + 60 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-3.2", + "section": "3.2", + "requirement": "4時間帯と朝下り/夕上り/夜行動", + "source_lines": [ + 61, + 62, + 63, + 64, + 65 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-3.3", + "section": "3.3", + "requirement": "昼夜シフト・体力0気絶・救護・通勤混雑", + "source_lines": [ + 66, + 67, + 68, + 69 + ], + "current_status": "実装・検証済み(現行施設)", + "current_note": "昼夜シフト境界、勤務体力低下/待機室休息、気絶と目撃評判、裏経路/業務用EV同伴救護を検証。16人の18:00交代で混雑50%速度、実到着後勤務、途中保存の完全再現を確認。LABOR_COMPLETION_2026-09-29.md参照。" + }, + { + "id": "B-3.4a", + "section": "3.4a", + "requirement": "毎日00:00に決算レポート", + "source_lines": [ + 70, + 71, + 72 + ], + "current_status": "実装・検証済み", + "current_note": "00:00決算・給与計上・帳簿自動表示・Save/Load照合。旧セーブの確定済み06:00履歴を保持し、開いている日を一度締めてから00:00へ移行。MIDNIGHT_VERIFICATION.md参照。" + }, + { + "id": "B-3.4b", + "section": "3.4b", + "requirement": "3回連続赤字でゲームオーバー", + "source_lines": [ + 70, + 71, + 72 + ], + "current_status": "実装・検証済み", + "current_note": "損益が負の決算3回連続で終了。0/黒字でリセット。未払いと別に判定。終了後の時間・新規コマンドを停止し、保存復元でも保持。全施設維持費・借金の経済接続は別の残件。" + }, + { + "id": "B-3.5", + "section": "3.5", + "requirement": "雨・嵐・マナの霧・流行ニュース", + "source_lines": [ + 73, + 74, + 75 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-4.1", + "section": "4.1", + "requirement": "所持金/戦利品・四欲求・忍耐/清潔/依存", + "source_lines": [ + 77, + 78, + 79, + 80 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-4.2", + "section": "4.2", + "requirement": "最大欲求優先と距離/行列/価格選択", + "source_lines": [ + 81, + 82, + 83 + ], + "current_status": "実装・検証済み(現行施設)", + "current_note": "2026-09-29: 実欲求を解消する候補の最大欲求値を先に比較し、同優先度では既存の距離・行列・価格・品質等の効用を比較。到達・価格・クラス等の既存フィルターを維持。候補不能時は次の利用可能候補、同値は安定IDで決定。GuestService 137項目とRuntime再生を検証。GUEST_AI_VERIFICATION_2026-09-29.md参照。" + }, + { + "id": "B-4.3a", + "section": "4.3a", + "requirement": "A*とエレベーター待ち時間コスト", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-4.3b", + "section": "4.3b", + "requirement": "4種垂直移動のゲーム内利用", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-4.3c", + "section": "4.3c", + "requirement": "重なり時速度50%低下", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "実装・検証済み", + "current_note": "同一マスの複数人重なりで速度0.5。tests/s1/test_path_congestion.gd" + }, + { + "id": "B-4.4", + "section": "4.4", + "requirement": "依存Lv3退店拒否・装備売却・性別別破産", + "source_lines": [ + 88, + 89, + 90 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-4.5", + "section": "4.5", + "requirement": "貴族/聖職者/エルフ/魔族のAI特性", + "source_lines": [ + 91, + 92, + 93, + 94, + 95 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-5.1", + "section": "5.1", + "requirement": "一般雇用と奴隷の費用/耐性/売却区別", + "source_lines": [ + 97, + 98, + 99 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-5.2", + "section": "5.2", + "requirement": "体力/ストレス/忠誠/技能/ランダム特性", + "source_lines": [ + 100, + 101, + 102, + 103 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-5.3", + "section": "5.3", + "requirement": "接客/裏方/救護/防衛等の職種", + "source_lines": [ + 104, + 105, + 106, + 107 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。 医療職と4×2 BACK医務室を救護に接続。施設・職種の全件完了ではない。STAFF_RESCUE_VERIFICATION_2026-09-29.md参照。" + }, + { + "id": "B-5.4", + "section": "5.4", + "requirement": "シフト休息と気絶回収", + "source_lines": [ + 108, + 109, + 110 + ], + "current_status": "実装・検証済み(現行施設)", + "current_note": "非番の待機室への実移動、到着後の体力回復、閉室/経路断で回復停止、気絶時は回復せず救護。裏経路・業務用EV同伴搬送・治療・保存再開を検証。LABOR_COMPLETION_2026-09-29.md参照。" + }, + { + "id": "B-5.5", + "section": "5.5", + "requirement": "求人/市場/破産捕獲・退職金/売買価格", + "source_lines": [ + 111, + 112, + 113 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-6.1", + "section": "6.1", + "requirement": "ユニーク無料・高成長・解雇売却不可", + "source_lines": [ + 115, + 116 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-6.2", + "section": "6.2", + "requirement": "7ヒロインの指定固有効果とライバル", + "source_lines": [ + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-6.3", + "section": "6.3", + "requirement": "基礎技能と業務EXP・Lv99", + "source_lines": [ + 126, + 127, + 128 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-6.4", + "section": "6.4", + "requirement": "配置パッシブ・任意発動・ルート別スキル", + "source_lines": [ + 129, + 130, + 131, + 132 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-6.5", + "section": "6.5", + "requirement": "24時間勤務警告・衣装補正・限定離脱", + "source_lines": [ + 133, + 134, + 135 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-7.1", + "section": "7.1", + "requirement": "三感情・盾・決闘の3ルート運命", + "source_lines": [ + 137, + 138 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-7.2", + "section": "7.2", + "requirement": "好感/淫乱/NTR・好感度=盾最大・淫乱軽減", + "source_lines": [ + 139, + 140, + 141, + 142, + 143 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-7.3", + "section": "7.3", + "requirement": "同配置接触→盾破壊→NTR上昇→不可逆陥落", + "source_lines": [ + 144, + 145, + 146, + 147, + 148 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-7.4", + "section": "7.4", + "requirement": "3種決闘・勝利追放/隷属/盾回復・敗北確定", + "source_lines": [ + 149, + 150, + 151, + 152, + 153 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-7.5", + "section": "7.5", + "requirement": "会話/デート/隔離・覗き・薬による各ルート", + "source_lines": [ + 154, + 155, + 156, + 157 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-7.6", + "section": "7.6", + "requirement": "履歴・妊娠/父親・刻印立ち絵", + "source_lines": [ + 158, + 159, + 160, + 161 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-8.1", + "section": "8.1", + "requirement": "隣接/通路必須とLv拡張", + "source_lines": [ + 163, + 164, + 165 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-8.2", + "section": "8.2", + "requirement": "表層全施設・飲食と厨房セット", + "source_lines": [ + 166, + 167, + 168, + 169, + 170 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-8.3", + "section": "8.3", + "requirement": "裏層全施設と特殊設備", + "source_lines": [ + 171, + 172, + 173, + 174 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。 医療職と4×2 BACK医務室を救護に接続。施設・職種の全件完了ではない。STAFF_RESCUE_VERIFICATION_2026-09-29.md参照。" + }, + { + "id": "B-8.4", + "section": "8.4", + "requirement": "EV/階段/ワープ/通路/職員ドア", + "source_lines": [ + 175, + 176, + 177 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-8.5", + "section": "8.5", + "requirement": "Lv解放の価格/食材/混浴/ハード/イカサマ設定", + "source_lines": [ + 178, + 179 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-9.1", + "section": "9.1", + "requirement": "表金/裏金・マナ全館停止・資材", + "source_lines": [ + 181, + 182, + 183, + 184 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-9.2", + "section": "9.2", + "requirement": "全収益/人件費/維持/仕入/借金/ランク税", + "source_lines": [ + 185, + 186, + 187, + 188 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-9.3", + "section": "9.3", + "requirement": "受付/金庫/購買/闇銀行", + "source_lines": [ + 189, + 190, + 191, + 192, + 193 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-9.4", + "section": "9.4", + "requirement": "依存客の娯楽優先・信用超過の破産拘束", + "source_lines": [ + 194, + 195, + 196 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-10.1", + "section": "10.1", + "requirement": "宿泊施設の寸法/層/ゾーン", + "source_lines": [ + 198, + 199 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-10.2", + "section": "10.2", + "requirement": "飲食施設の寸法/層/ゾーン", + "source_lines": [ + 200, + 201 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-10.3", + "section": "10.3", + "requirement": "衛生/娯楽施設の寸法/層/ゾーン", + "source_lines": [ + 202, + 203 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-10.4", + "section": "10.4", + "requirement": "アングラ施設の寸法/層/ゾーン", + "source_lines": [ + 204, + 205 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-10.5", + "section": "10.5", + "requirement": "バックヤード施設の寸法/層/ゾーン", + "source_lines": [ + 206, + 207 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。 医療職と4×2 BACK医務室を救護に接続。施設・職種の全件完了ではない。STAFF_RESCUE_VERIFICATION_2026-09-29.md参照。" + }, + { + "id": "B-10.6", + "section": "10.6", + "requirement": "特殊施設の寸法/層/ゾーン", + "source_lines": [ + 208, + 209 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-10.7", + "section": "10.7", + "requirement": "インフラ/防衛施設の寸法/層/ゾーン", + "source_lines": [ + 210, + 211 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-11.1", + "section": "11.1", + "requirement": "資源/時間/天候/Rank・下部切替・スクロールズーム", + "source_lines": [ + 213, + 214, + 215, + 216 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-11.2", + "section": "11.2", + "requirement": "Policy/ドラッグ配置・欲求/思考/感情グラフ/盾", + "source_lines": [ + 217, + 218, + 219 + ], + "current_status": "一部実装・欲求観察UIを追加", + "current_note": "2026-09-29: HUDの来客ボタンから四欲求・所持金・忍耐・状態・現在の目的地を確認できる。読込後と退館後の再表示を22項目で検証。意思決定履歴、ドラッグ配置、感情グラフ等を含む要件群全体は未完了。" + }, + { + "id": "B-11.3", + "section": "11.3", + "requirement": "カメラ入口・覗きアングル/撮影/介入", + "source_lines": [ + 220, + 221, + 222 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-11.4", + "section": "11.4", + "requirement": "グリッド/接続警告・フキダシ/ログ・カットイン", + "source_lines": [ + 223, + 224, + 225, + 226 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-12.1", + "section": "12.1", + "requirement": "二面性・高精細ドットとアニメ立ち絵", + "source_lines": [ + 228, + 229, + 230 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-12.2", + "section": "12.2", + "requirement": "魔法レンズ風の波紋切替", + "source_lines": [ + 231, + 232 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-12.3", + "section": "12.3", + "requirement": "SD各モーション・立ち絵破損/汚れ/刻印差分", + "source_lines": [ + 233, + 234, + 235 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-12.4", + "section": "12.4", + "requirement": "表裏UIスキン・BGMクロスフェード・環境音/SE", + "source_lines": [ + 236, + 237, + 238, + 239 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-13.1", + "section": "13.1", + "requirement": "立ち絵/CG/差分", + "source_lines": [ + 241, + 242, + 243 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-13.2", + "section": "13.2", + "requirement": "SDモーション/エフェクト", + "source_lines": [ + 244, + 245, + 246 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-13.3", + "section": "13.3", + "requirement": "Lv1〜3表裏タイル/背景", + "source_lines": [ + 247, + 248 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-13.4", + "section": "13.4", + "requirement": "表裏スキン/感情/覗き素材", + "source_lines": [ + 249, + 250 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-13.5", + "section": "13.5", + "requirement": "BGM/環境/操作/成人向けSE/声", + "source_lines": [ + 251, + 252 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-14.1", + "section": "14.1", + "requirement": "2026/03〜08 プロトタイプ", + "source_lines": [ + 255 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-14.2", + "section": "14.2", + "requirement": "2026/09〜2027/02 アルファ", + "source_lines": [ + 256 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-14.3", + "section": "14.3", + "requirement": "2027/03〜08 ベータ/成人向け", + "source_lines": [ + 257 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + }, + { + "id": "B-14.4", + "section": "14.4", + "requirement": "2027/09〜12 ポリッシュ", + "source_lines": [ + 258 + ], + "current_status": "未完了・現行版の全受入未確認", + "current_note": "過去監査の実装証拠をそのまま現行版の完了判定に流用しない。" + } +] diff --git a/docs/traceability/original/audit-2026-09-29/requirements.json b/docs/traceability/original/audit-2026-09-29/requirements.json new file mode 100644 index 0000000..227dbf6 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/requirements.json @@ -0,0 +1,3703 @@ +[ + { + "id": "B-1.1", + "section": "1.1", + "requirement": "基本情報・Windows・2D・20〜30時間/50時間以上", + "source_lines": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "current_status": "一部実装・差異あり", + "current_note": "Windows配布物と2D断面図を実装。 20〜30時間の本編、50時間以上の全攻略の受入証拠がない。今回Windowsネイティブ確認は実施していない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "Windows配布物と2D断面図を実装。", + "gap_or_acceptance": "20〜30時間の本編、50時間以上の全攻略の受入証拠がない。今回Windowsネイティブ確認は実施していない。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-1.2", + "section": "1.2", + "requirement": "地上/地下の二面性経営・観察介入・権威/富", + "source_lines": [ + 11, + 12, + 13, + 14 + ], + "current_status": "一部実装・差異あり", + "current_note": "地上地下・二層の建設と観察、ランク・経済の基盤あり。 高さと権威、深さと富、介入を一貫して成立させる完成ゲームの受入が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "地上地下・二層の建設と観察、ランク・経済の基盤あり。", + "gap_or_acceptance": "高さと権威、深さと富、介入を一貫して成立させる完成ゲームの受入が不足。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/mana/mana_network_service.gd", + "line": 46, + "symbol": "func register_source" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4736, + "symbol": "func _initialize_mana_network" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s2/test_economy_service.gd", + "tests/s2/test_mana_network_service.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-1.3", + "section": "1.3", + "requirement": "建設→集客→収益→管理→拡張防衛", + "source_lines": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "current_status": "一部実装・差異あり", + "current_note": "建設・集客・宿泊飲食収入・雇用・物流の通常ループあり。防衛APIとRuntimeテストあり。 全裏収益、全施設、通常プレイでの防衛発生・高ランク到達まで未完了。APIテストを全ループ完了に数えない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "建設・集客・宿泊飲食収入・雇用・物流の通常ループあり。防衛APIとRuntimeテストあり。", + "gap_or_acceptance": "全裏収益、全施設、通常プレイでの防衛発生・高ランク到達まで未完了。APIテストを全ループ完了に数えない。", + "source_evidence": [ + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/supply/test_normal_supply_chain.gd", + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-1.4", + "section": "1.4", + "requirement": "二層・自律AI・渋滞・信仰と依存", + "source_lines": [ + 23, + 24, + 25, + 26 + ], + "current_status": "一部実装・差異あり", + "current_note": "二層、客AI、シフト、渋滞、依存値と信仰値の基盤あり。 信仰による本編進行、依存客の全特殊行動まで接続されていない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "二層、客AI、シフト、渋滞、依存値と信仰値の基盤あり。", + "gap_or_acceptance": "信仰による本編進行、依存客の全特殊行動まで接続されていない。", + "source_evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd", + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-1.5", + "section": "1.5", + "requirement": "世界観・主人公・7ヒロイン3運命", + "source_lines": [ + 27, + 28, + 29, + 30 + ], + "current_status": "一部実装・差異あり", + "current_note": "アンリとライの2名のデータあり。 原本の7ヒロインと各3ルート、主人公の物語・全エンドが揃っていない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "アンリとライの2名のデータあり。", + "gap_or_acceptance": "原本の7ヒロインと各3ルート、主人公の物語・全エンドが揃っていない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-1.6", + "section": "1.6", + "requirement": "成人向けルートと経営的搾取", + "source_lines": [ + 31, + 32, + 33, + 34 + ], + "current_status": "一部実装・差異あり", + "current_note": "成人区分・対象施設・コンテンツ制御の基盤あり。 原本記載の全ルート・イベント・経営要素としては未完成。データやタグの存在を内容実装と扱わない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "成人区分・対象施設・コンテンツ制御の基盤あり。", + "gap_or_acceptance": "原本記載の全ルート・イベント・経営要素としては未完成。データやタグの存在を内容実装と扱わない。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-2.1", + "section": "2.1", + "requirement": "グリッド・Y正負・支持・領域拡張", + "source_lines": [ + 36, + 37, + 38, + 39 + ], + "current_status": "実装あり・全受入未検証", + "current_note": "Y正負・境界、グリッド占有、支持判定、ランク別領域拡張APIあり。 個別テストはあるが通常プレイで高ランクまで進む原本一式の受入は未確認。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "U", + "implemented": "Y正負・境界、グリッド占有、支持判定、ランク別領域拡張APIあり。", + "gap_or_acceptance": "個別テストはあるが通常プレイで高ランクまで進む原本一式の受入は未確認。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-2.2", + "section": "2.2", + "requirement": "同座標Front/Back・半透明/ワイヤー表示", + "source_lines": [ + 40, + 41, + 42, + 43, + 44 + ], + "current_status": "一部実装・差異あり", + "current_note": "同一XYのFRONT/BACKスロットと切替を実装。 現画面は選択層のみの施設描画。原本の非選択層の半透明・ワイヤーフレーム表示を満たしていない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "同一XYのFRONT/BACKスロットと切替を実装。", + "gap_or_acceptance": "現画面は選択層のみの施設描画。原本の非選択層の半透明・ワイヤーフレーム表示を満たしていない。", + "source_evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-2.3", + "section": "2.3", + "requirement": "客エリアと景観/清潔/騒音の満足度効果", + "source_lines": [ + 45, + 46, + 47 + ], + "current_status": "一部実装・差異あり", + "current_note": "清掃・施設品質・満足度の基盤、景観/清潔/騒音の定義フィールドあり。 beauty_effect/noise_per_service/cleanliness_decay_per_serviceの本体参照は定義のみ。3環境値の計算と客満足度への連動が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "清掃・施設品質・満足度の基盤、景観/清潔/騒音の定義フィールドあり。", + "gap_or_acceptance": "beauty_effect/noise_per_service/cleanliness_decay_per_serviceの本体参照は定義のみ。3環境値の計算と客満足度への連動が不足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-2.4", + "section": "2.4", + "requirement": "バックヤード・職員ドア・客通行禁止", + "source_lines": [ + 48, + 49, + 50 + ], + "current_status": "一部実装・差異あり", + "current_note": "職員専用ドア、BACK経路、客アクセス制限、業務用EVあり。 原本にあるリネンシュート、隠し扉、覗き部屋を含む全バックヤード施設は未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "職員専用ドア、BACK経路、客アクセス制限、業務用EVあり。", + "gap_or_acceptance": "原本にあるリネンシュート、隠し扉、覗き部屋を含む全バックヤード施設は未完了。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-2.5", + "section": "2.5", + "requirement": "覗き部屋の配置制約・通路寸断・証拠収集", + "source_lines": [ + 51, + 52, + 53 + ], + "current_status": "該当機能未実装", + "current_note": "証拠管理APIは別機能としてあるが、覗き部屋の施設定義はない。 対象室背面限定建設、裏通路寸断、記録係と専用視界からの証拠収集が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "証拠管理APIは別機能としてあるが、覗き部屋の施設定義はない。", + "gap_or_acceptance": "対象室背面限定建設、裏通路寸断、記録係と専用視界からの証拠収集が未実装。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-2.6", + "section": "2.6", + "requirement": "地上/地下の採光・マナ・遮音・隠蔽ボーナス", + "source_lines": [ + 54, + 55, + 56 + ], + "current_status": "一部実装・差異あり", + "current_note": "地上/地下ゾーンの配置制約、関連する環境定義フィールドあり。 採光・マナ濃度・遮音・隠蔽のゾーン別ボーナス計算と客層効果が未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "地上/地下ゾーンの配置制約、関連する環境定義フィールドあり。", + "gap_or_acceptance": "採光・マナ濃度・遮音・隠蔽のゾーン別ボーナス計算と客層効果が未接続。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.1a", + "section": "3.1a", + "requirement": "10秒=1時間、Pause/x1/x2/x3", + "source_lines": [ + 58, + 59, + 60 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "10秒=1時間、Pause/x1/x2/x3。旧x4セーブの移行も実装。 今回時計テストを再実行。時間倍率の要件を検証済み。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "10秒=1時間、Pause/x1/x2/x3。旧x4セーブの移行も実装。", + "gap_or_acceptance": "今回時計テストを再実行。時間倍率の要件を検証済み。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.1b", + "section": "3.1b", + "requirement": "アクティブポーズ中も建設・指示", + "source_lines": [ + 58, + 59, + 60 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "停止中の当分コマンドを即処理し、停止したまま建設予約・指示を受理。 今回停止中コマンドテストと開業UIテストを再実行。工事進行は時間再開後。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "V", + "implemented": "停止中の当分コマンドを即処理し、停止したまま建設予約・指示を受理。", + "gap_or_acceptance": "今回停止中コマンドテストと開業UIテストを再実行。工事進行は時間再開後。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.2", + "section": "3.2", + "requirement": "4時間帯と朝下り/夕上り/夜行動", + "source_lines": [ + 61, + 62, + 63, + 64, + 65 + ], + "current_status": "一部実装・差異あり", + "current_note": "06/10/16/20時の4フェーズ、来客時間帯、18時シフト交代あり。 朝の下り・夕の上りラッシュと原本指定の夜行動全体を再現するシナリオ受入が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "06/10/16/20時の4フェーズ、来客時間帯、18時シフト交代あり。", + "gap_or_acceptance": "朝の下り・夕の上りラッシュと原本指定の夜行動全体を再現するシナリオ受入が不足。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.3", + "section": "3.3", + "requirement": "昼夜シフト・体力0気絶・救護・通勤混雑", + "source_lines": [ + 66, + 67, + 68, + 69 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "勤務体力減少、気絶、目撃評判低下、BACK/EV同伴救護、待機室休息、16人交代混雑を検証。 現行施設での検証。回復速度・治療時間等の数値は暫定値であり原本確定値ではない。", + "previous_status": "実装・検証済み(現行施設)", + "status_code": "V", + "implemented": "勤務体力減少、気絶、目撃評判低下、BACK/EV同伴救護、待機室休息、16人交代混雑を検証。", + "gap_or_acceptance": "現行施設での検証。回復速度・治療時間等の数値は暫定値であり原本確定値ではない。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd", + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.4a", + "section": "3.4a", + "requirement": "毎日00:00に決算レポート", + "source_lines": [ + 70, + 71, + 72 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "00:00決算・給与・帳簿自動表示・保存復元を実装。 時刻とレポートの要件を検証済み。未接続費目はB-9.2へ分離。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "00:00決算・給与・帳簿自動表示・保存復元を実装。", + "gap_or_acceptance": "時刻とレポートの要件を検証済み。未接続費目はB-9.2へ分離。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.4b", + "section": "3.4b", + "requirement": "3回連続赤字でゲームオーバー", + "source_lines": [ + 70, + 71, + 72 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "3回連続赤字で終了。黒字/ゼロで連続数リセット。終了状態も保存。 判定と終了制御を検証済み。全収支種別の充足とは別。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "3回連続赤字で終了。黒字/ゼロで連続数リセット。終了状態も保存。", + "gap_or_acceptance": "判定と終了制御を検証済み。全収支種別の充足とは別。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-3.5", + "section": "3.5", + "requirement": "雨・嵐・マナの霧・流行ニュース", + "source_lines": [ + 73, + 74, + 75 + ], + "current_status": "該当機能未実装", + "current_note": "4時間帯はあるが天候・流行の実行系はない。 雨の客足/滞在、嵐の新規客ゼロ、マナ霧効果、数日ごとのニュースと売上効果が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "4時間帯はあるが天候・流行の実行系はない。", + "gap_or_acceptance": "雨の客足/滞在、嵐の新規客ゼロ、マナ霧効果、数日ごとのニュースと売上効果が未実装。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.1", + "section": "4.1", + "requirement": "所持金/戦利品・四欲求・忍耐/清潔/依存", + "source_lines": [ + 77, + 78, + 79, + 80 + ], + "current_status": "一部実装・差異あり", + "current_note": "財布・四欲求・忍耐・依存・信用/債務値あり。 戦利品の所持/換金、客自身の清潔度と歩行汚染伝播が不足。hygiene欲求を清潔度伝播の実装と数えない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "財布・四欲求・忍耐・依存・信用/債務値あり。", + "gap_or_acceptance": "戦利品の所持/換金、客自身の清潔度と歩行汚染伝播が不足。hygiene欲求を清潔度伝播の実装と数えない。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.2", + "section": "4.2", + "requirement": "最大欲求優先と距離/行列/価格選択", + "source_lines": [ + 81, + 82, + 83 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "利用可能候補の最大欲求を先に比較し、同順位を距離・待ち・価格等で選択。 現行サービス候補で検証済み。未登録施設の網羅ではない。", + "previous_status": "実装・検証済み(現行施設)", + "status_code": "V", + "implemented": "利用可能候補の最大欲求を先に比較し、同順位を距離・待ち・価格等で選択。", + "gap_or_acceptance": "現行サービス候補で検証済み。未登録施設の網羅ではない。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.3a", + "section": "4.3a", + "requirement": "A*とエレベーター待ち時間コスト", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "実装あり・全受入未検証", + "current_note": "A*相当の探索とヒューリスティック、EV行列から見積る待ちコストを経路費用に加算。 今回経路・混雑テストPASS。実カゴ運行時間との見積誤差を含む原本専用の総合受入は未確認。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "U", + "implemented": "A*相当の探索とヒューリスティック、EV行列から見積る待ちコストを経路費用に加算。", + "gap_or_acceptance": "今回経路・混雑テストPASS。実カゴ運行時間との見積誤差を含む原本専用の総合受入は未確認。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.3b", + "section": "4.3b", + "requirement": "4種垂直移動のゲーム内利用", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "一部実装・差異あり", + "current_note": "客用EV・業務用EV・遅い階段は建設から実移動まで接続。 マナワープ施設・費用・瞬間移動は未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "客用EV・業務用EV・遅い階段は建設から実移動まで接続。", + "gap_or_acceptance": "マナワープ施設・費用・瞬間移動は未実装。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.3c", + "section": "4.3c", + "requirement": "重なり時速度50%低下", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "重なった通路の移動速度0.5と移動クレジットを実装。 今回混雑テストを再実行。経路コストだけでなく実移動速度も既存Runtime/交代試験対象。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "重なった通路の移動速度0.5と移動クレジットを実装。", + "gap_or_acceptance": "今回混雑テストを再実行。経路コストだけでなく実移動速度も既存Runtime/交代試験対象。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.4", + "section": "4.4", + "requirement": "依存Lv3退店拒否・装備売却・性別別破産", + "source_lines": [ + 88, + 89, + 90 + ], + "current_status": "一部実装・差異あり", + "current_note": "依存値と支出優先の基盤あり。 依存Lv3退店拒否、装備売却、原本の性別別破産分岐は未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "依存値と支出優先の基盤あり。", + "gap_or_acceptance": "依存Lv3退店拒否、装備売却、原本の性別別破産分岐は未接続。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-4.5", + "section": "4.5", + "requirement": "貴族/聖職者/エルフ/魔族のAI特性", + "source_lines": [ + 91, + 92, + 93, + 94, + 95 + ], + "current_status": "一部実装・差異あり", + "current_note": "8客クラス・4種族、予算/忍耐/嗜好等の定義あり。 聖職者の目撃即退店/風紀、エルフの自然好み、魔族等の地下満足度耐性を原本通り通す受入がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "8客クラス・4種族、予算/忍耐/嗜好等の定義あり。", + "gap_or_acceptance": "聖職者の目撃即退店/風紀、エルフの自然好み、魔族等の地下満足度耐性を原本通り通す受入がない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-5.1", + "section": "5.1", + "requirement": "一般雇用と奴隷の費用/耐性/売却区別", + "source_lines": [ + 97, + 98, + 99 + ], + "current_status": "一部実装・差異あり", + "current_note": "FREE_HIRE/ADULT_CONTRACT/UNIQUEの区分と給与処理あり。 原本の食費のみ・ストレス耐性差・売却ルール一式とは一致を確認できない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "FREE_HIRE/ADULT_CONTRACT/UNIQUEの区分と給与処理あり。", + "gap_or_acceptance": "原本の食費のみ・ストレス耐性差・売却ルール一式とは一致を確認できない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-5.2", + "section": "5.2", + "requirement": "体力/ストレス/忠誠/技能/ランダム特性", + "source_lines": [ + 100, + 101, + 102, + 103 + ], + "current_status": "一部実装・差異あり", + "current_note": "health/stress/loyalty/技能を保存し、勤務と救護でhealthを使用。 候補20名は固定データ。生成時のランダム特性付与と全職業技能効果が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "health/stress/loyalty/技能を保存し、勤務と救護でhealthを使用。", + "gap_or_acceptance": "候補20名は固定データ。生成時のランダム特性付与と全職業技能効果が不足。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-5.3", + "section": "5.3", + "requirement": "接客/裏方/救護/防衛等の職種", + "source_lines": [ + 104, + 105, + 106, + 107 + ], + "current_status": "一部実装・差異あり", + "current_note": "受付・料理・清掃・運搬・医療・警備等の職種と一部実作業を実装。 原本の全職種と専用施設・専用業務を網羅していない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "受付・料理・清掃・運搬・医療・警備等の職種と一部実作業を実装。", + "gap_or_acceptance": "原本の全職種と専用施設・専用業務を網羅していない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-5.4", + "section": "5.4", + "requirement": "シフト休息と気絶回収", + "source_lines": [ + 108, + 109, + 110 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "非番は稼働中待機室まで実移動して回復。体力0は回復せずBACK/EV搬送・治療へ。 現行施設で途中保存再開と画面表示を検証済み。宿舎の通常建設は未接続。", + "previous_status": "実装・検証済み(現行施設)", + "status_code": "V", + "implemented": "非番は稼働中待機室まで実移動して回復。体力0は回復せずBACK/EV搬送・治療へ。", + "gap_or_acceptance": "現行施設で途中保存再開と画面表示を検証済み。宿舎の通常建設は未接続。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-5.5", + "section": "5.5", + "requirement": "求人/市場/破産捕獲・退職金/売買価格", + "source_lines": [ + 111, + 112, + 113 + ], + "current_status": "一部実装・差異あり", + "current_note": "求人候補の採用・費用・割当、職員削除/退職APIあり。 市場購入、破産客獲得、退職金、能力による売価と他職員忠誠変化の通常フローが不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "求人候補の採用・費用・割当、職員削除/退職APIあり。", + "gap_or_acceptance": "市場購入、破産客獲得、退職金、能力による売価と他職員忠誠変化の通常フローが不足。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-6.1", + "section": "6.1", + "requirement": "ユニーク無料・高成長・解雇売却不可", + "source_lines": [ + 115, + 116 + ], + "current_status": "一部実装・差異あり", + "current_note": "UNIQUE区分、unique/unsellableの定義タグあり。 高成長・無料維持の一式と操作禁止の受入がない。remove_staffにはUNIQUE区分による削除拒否がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "UNIQUE区分、unique/unsellableの定義タグあり。", + "gap_or_acceptance": "高成長・無料維持の一式と操作禁止の受入がない。remove_staffにはUNIQUE区分による削除拒否がない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-6.2", + "section": "6.2", + "requirement": "7ヒロインの指定固有効果とライバル", + "source_lines": [ + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125 + ], + "current_status": "一部実装・差異あり", + "current_note": "ユニークデータはアンリとライのみ。 残り6ヒロイン等が不足。アンリは忍耐減少倍率9000bpの定義であり原本の配置階売上+20%ではない。効果の本番適用も未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "ユニークデータはアンリとライのみ。", + "gap_or_acceptance": "残り6ヒロイン等が不足。アンリは忍耐減少倍率9000bpの定義であり原本の配置階売上+20%ではない。効果の本番適用も未接続。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-6.3", + "section": "6.3", + "requirement": "基礎技能と業務EXP・Lv99", + "source_lines": [ + 126, + 127, + 128 + ], + "current_status": "一部実装・差異あり", + "current_note": "基礎パラメータと技能値はある。 業務EXP→レベル成長→Lv99上限の実行系が不足。MAX_SKILL_VALUE=999はLv99ではない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "基礎パラメータと技能値はある。", + "gap_or_acceptance": "業務EXP→レベル成長→Lv99上限の実行系が不足。MAX_SKILL_VALUE=999はLv99ではない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-6.4", + "section": "6.4", + "requirement": "配置パッシブ・任意発動・ルート別スキル", + "source_lines": [ + 129, + 130, + 131, + 132 + ], + "current_status": "一部実装・差異あり", + "current_note": "固有效果のデータ枠、防衛スキルAPIがある。 7名の配置パッシブとプレイヤー任意発動、ルート別習得の通常導線が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "固有效果のデータ枠、防衛スキルAPIがある。", + "gap_or_acceptance": "7名の配置パッシブとプレイヤー任意発動、ルート別習得の通常導線が不足。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-6.5", + "section": "6.5", + "requirement": "24時間勤務警告・衣装補正・限定離脱", + "source_lines": [ + 133, + 134, + 135 + ], + "current_status": "一部実装・差異あり", + "current_note": "シフト例外・超過勤務・不在状態などの基盤あり。 ユニークの24時間勤務警告/強制継続、衣装補正、原本指定の限定離脱条件まで未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "シフト例外・超過勤務・不在状態などの基盤あり。", + "gap_or_acceptance": "ユニークの24時間勤務警告/強制継続、衣装補正、原本指定の限定離脱条件まで未完了。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-7.1", + "section": "7.1", + "requirement": "三感情・盾・決闘の3ルート運命", + "source_lines": [ + 137, + 138 + ], + "current_status": "一部実装・差異あり", + "current_note": "関係値・盾・ルート固定・閾値イベントの汎用基盤あり。 原本の3感情と決闘による3ルート全体は未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "関係値・盾・ルート固定・閾値イベントの汎用基盤あり。", + "gap_or_acceptance": "原本の3感情と決闘による3ルート全体は未実装。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-7.2", + "section": "7.2", + "requirement": "好感/淫乱/NTR・好感度=盾最大・淫乱軽減", + "source_lines": [ + 139, + 140, + 141, + 142, + 143 + ], + "current_status": "一部実装・差異あり", + "current_note": "affectionとshield_current/maxはある。 現在の5指標はtrust/respect/affection/fear/dependence。原本のlust/NTR値がなく、好感度=盾最大とlustによる防御低下に不一致。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "affectionとshield_current/maxはある。", + "gap_or_acceptance": "現在の5指標はtrust/respect/affection/fear/dependence。原本のlust/NTR値がなく、好感度=盾最大とlustによる防御低下に不一致。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-7.3", + "section": "7.3", + "requirement": "同配置接触→盾破壊→NTR上昇→不可逆陥落", + "source_lines": [ + 144, + 145, + 146, + 147, + 148 + ], + "current_status": "一部実装・差異あり", + "current_note": "盾攻撃API、配置リスク予測、ルート確認固定APIあり。 通常Tickはイベント期限/候補処理。実配置による自動接触→盾破壊→NTR増加→不可逆陥落の一連の接続がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "盾攻撃API、配置リスク予測、ルート確認固定APIあり。", + "gap_or_acceptance": "通常Tickはイベント期限/候補処理。実配置による自動接触→盾破壊→NTR増加→不可逆陥落の一連の接続がない。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-7.4", + "section": "7.4", + "requirement": "3種決闘・勝利追放/隷属/盾回復・敗北確定", + "source_lines": [ + 149, + 150, + 151, + 152, + 153 + ], + "current_status": "該当機能未実装", + "current_note": "防衛戦はあるが原本の決闘とは別機能。 3種類の決闘と勝敗による指定結果が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "防衛戦はあるが原本の決闘とは別機能。", + "gap_or_acceptance": "3種類の決闘と勝敗による指定結果が未実装。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-7.5", + "section": "7.5", + "requirement": "会話/デート/隔離・覗き・薬による各ルート", + "source_lines": [ + 154, + 155, + 156, + 157 + ], + "current_status": "一部実装・差異あり", + "current_note": "イベント選択・関係変化・盾回復の部品あり。 原本の各プレイヤーアクションと全ルートイベントを通常UIから進める接続が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "イベント選択・関係変化・盾回復の部品あり。", + "gap_or_acceptance": "原本の各プレイヤーアクションと全ルートイベントを通常UIから進める接続が不足。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-7.6", + "section": "7.6", + "requirement": "履歴・妊娠/父親・刻印立ち絵", + "source_lines": [ + 158, + 159, + 160, + 161 + ], + "current_status": "該当機能未実装", + "current_note": "汎用フラグはあるが原本専用の履歴・外見システムはない。 指定履歴、妊娠周期・父親判定、立ち絵差分との接続が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "汎用フラグはあるが原本専用の履歴・外見システムはない。", + "gap_or_acceptance": "指定履歴、妊娠周期・父親判定、立ち絵差分との接続が未実装。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-8.1", + "section": "8.1", + "requirement": "隣接/通路必須とLv拡張", + "source_lines": [ + 163, + 164, + 165 + ], + "current_status": "一部実装・差異あり", + "current_note": "支持/隣接、アクセス点・通路判定、max_level/upgrade_costs定義あり。 施設LvUPの通常操作・支払い・能力変化・全解放設定が未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "支持/隣接、アクセス点・通路判定、max_level/upgrade_costs定義あり。", + "gap_or_acceptance": "施設LvUPの通常操作・支払い・能力変化・全解放設定が未完了。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-8.2", + "section": "8.2", + "requirement": "表層全施設・飲食と厨房セット", + "source_lines": [ + 166, + 167, + 168, + 169, + 170 + ], + "current_status": "一部実装・差異あり", + "current_note": "一般客室・食堂・酒場・浴場等と厨房の生産配送を実装。 レストラン/VIP/カジノ/闘技場/礼拝堂等が通常登録されていない。飲食全件の厨房セット条件は未充足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "一般客室・食堂・酒場・浴場等と厨房の生産配送を実装。", + "gap_or_acceptance": "レストラン/VIP/カジノ/闘技場/礼拝堂等が通常登録されていない。飲食全件の厨房セット条件は未充足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-8.3", + "section": "8.3", + "requirement": "裏層全施設と特殊設備", + "source_lines": [ + 171, + 172, + 173, + 174 + ], + "current_status": "一部実装・差異あり", + "current_note": "厨房・倉庫・待機室・警備詰所・医務室は通常登録あり。 宿舎は拡張定義のみ。清掃員室・覗き部屋・地下牢・マナ炉が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "厨房・倉庫・待機室・警備詰所・医務室は通常登録あり。", + "gap_or_acceptance": "宿舎は拡張定義のみ。清掃員室・覗き部屋・地下牢・マナ炉が不足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-8.4", + "section": "8.4", + "requirement": "EV/階段/ワープ/通路/職員ドア", + "source_lines": [ + 175, + 176, + 177 + ], + "current_status": "一部実装・差異あり", + "current_note": "2種EV、階段、2層通路、職員ドアあり。 ワープ未実装。通路・職員専用制限は経路試験対象。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "2種EV、階段、2層通路、職員ドアあり。", + "gap_or_acceptance": "ワープ未実装。通路・職員専用制限は経路試験対象。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-8.5", + "section": "8.5", + "requirement": "Lv解放の価格/食材/混浴/ハード/イカサマ設定", + "source_lines": [ + 178, + 179 + ], + "current_status": "一部実装・差異あり", + "current_note": "価格ポリシー、営業時間/営業状態のUIあり。 レベル解放と食材ランク/混浴/施設別特殊設定が原本一式として未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "価格ポリシー、営業時間/営業状態のUIあり。", + "gap_or_acceptance": "レベル解放と食材ランク/混浴/施設別特殊設定が原本一式として未完了。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-9.1", + "section": "9.1", + "requirement": "表金/裏金・マナ全館停止・資材", + "source_lines": [ + 181, + 182, + 183, + 184 + ], + "current_status": "一部実装・差異あり", + "current_note": "Gold/材料/マナ、帳簿、電力不足による施設状態あり。 表金/裏金の独立運用と「マナ枯渇で全館停止」は未充足。Runtimeは出力100の初期マナ源を持つ。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "Gold/材料/マナ、帳簿、電力不足による施設状態あり。", + "gap_or_acceptance": "表金/裏金の独立運用と「マナ枯渇で全館停止」は未充足。Runtimeは出力100の初期マナ源を持つ。", + "source_evidence": [ + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/mana/mana_network_service.gd", + "line": 46, + "symbol": "func register_source" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4736, + "symbol": "func _initialize_mana_network" + } + ], + "related_component_tests": [ + "tests/s2/test_economy_service.gd", + "tests/s2/test_mana_network_service.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-9.2", + "section": "9.2", + "requirement": "全収益/人件費/維持/仕入/借金/ランク税", + "source_lines": [ + 185, + 186, + 187, + 188 + ], + "current_status": "一部実装・差異あり", + "current_note": "宿泊飲食売上、給与、仕入れ、建設、罰金等を記帳。 maintenance_goldは定義/検証のみ。日次maintenance/utilities/taxes_and_interestに全施設維持費・借金返済・ランク税を計上する実処理がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "宿泊飲食売上、給与、仕入れ、建設、罰金等を記帳。", + "gap_or_acceptance": "maintenance_goldは定義/検証のみ。日次maintenance/utilities/taxes_and_interestに全施設維持費・借金返済・ランク税を計上する実処理がない。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-9.3", + "section": "9.3", + "requirement": "受付/金庫/購買/闇銀行", + "source_lines": [ + 189, + 190, + 191, + 192, + 193 + ], + "current_status": "一部実装・差異あり", + "current_note": "受付・精算待ち、購買/自動発注管理の部品あり。 金庫室の上限/盗賊、戦利品買取、闇銀行融資・滞納破壊を施設と通常UIへ接続していない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "受付・精算待ち、購買/自動発注管理の部品あり。", + "gap_or_acceptance": "金庫室の上限/盗賊、戦利品買取、闇銀行融資・滞納破壊を施設と通常UIへ接続していない。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-9.4", + "section": "9.4", + "requirement": "依存客の娯楽優先・信用超過の破産拘束", + "source_lines": [ + 194, + 195, + 196 + ], + "current_status": "一部実装・差異あり", + "current_note": "高依存時の生存予算留保0、信用枠/債務を実装。 信用超過→原本の破産イベント/拘束/配置まで未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "高依存時の生存予算留保0、信用枠/債務を実装。", + "gap_or_acceptance": "信用超過→原本の破産イベント/拘束/配置まで未接続。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.1", + "section": "10.1", + "requirement": "宿泊施設の寸法/層/ゾーン", + "source_lines": [ + 198, + 199 + ], + "current_status": "一部実装・差異あり", + "current_note": "一般客室2×1 FRONTは一致。 上級客室は定義3×2で原本4×1と不一致かつ通常未登録。VIP6×2、ドミトリー4×1なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "一般客室2×1 FRONTは一致。", + "gap_or_acceptance": "上級客室は定義3×2で原本4×1と不一致かつ通常未登録。VIP6×2、ドミトリー4×1なし。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.2", + "section": "10.2", + "requirement": "飲食施設の寸法/層/ゾーン", + "source_lines": [ + 200, + 201 + ], + "current_status": "一部実装・差異あり", + "current_note": "大衆食堂4×2、酒場4×2 FRONTは寸法一致。 レストラン6×2・屋台2×1なし。騒音定義の実効果不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "大衆食堂4×2、酒場4×2 FRONTは寸法一致。", + "gap_or_acceptance": "レストラン6×2・屋台2×1なし。騒音定義の実効果不足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.3", + "section": "10.3", + "requirement": "衛生/娯楽施設の寸法/層/ゾーン", + "source_lines": [ + 202, + 203 + ], + "current_status": "一部実装・差異あり", + "current_note": "大浴場6×2 FRONTは寸法一致。 カジノ6×3、闘技場8×4、礼拝堂4×3なし。浴場の覗き機能なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "大浴場6×2 FRONTは寸法一致。", + "gap_or_acceptance": "カジノ6×3、闘技場8×4、礼拝堂4×3なし。浴場の覗き機能なし。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.4", + "section": "10.4", + "requirement": "アングラ施設の寸法/層/ゾーン", + "source_lines": [ + 204, + 205 + ], + "current_status": "一部実装・差異あり", + "current_note": "個室2×1 FRONT地下は寸法/層一致。 高級ソープ・SM室なし。闇市場は定義3×2で原本4×2と不一致かつ通常未登録。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "個室2×1 FRONT地下は寸法/層一致。", + "gap_or_acceptance": "高級ソープ・SM室なし。闇市場は定義3×2で原本4×2と不一致かつ通常未登録。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.5", + "section": "10.5", + "requirement": "バックヤード施設の寸法/層/ゾーン", + "source_lines": [ + 206, + 207 + ], + "current_status": "一部実装・差異あり", + "current_note": "厨房4×2・倉庫4×2・待機室4×2・警備2×2・医務4×2 BACKは寸法一致。 従業員寮は定義4×2で原本2×1と不一致かつ通常未登録。清掃員室2×1なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "厨房4×2・倉庫4×2・待機室4×2・警備2×2・医務4×2 BACKは寸法一致。", + "gap_or_acceptance": "従業員寮は定義4×2で原本2×1と不一致かつ通常未登録。清掃員室2×1なし。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.6", + "section": "10.6", + "requirement": "特殊施設の寸法/層/ゾーン", + "source_lines": [ + 208, + 209 + ], + "current_status": "該当機能未実装", + "current_note": "原本3施設に対応する通常登録・定義なし。 覗き部屋2×1 BACK、地下牢4×2 BACK地下、マナ炉6×3 BACK地下が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "原本3施設に対応する通常登録・定義なし。", + "gap_or_acceptance": "覗き部屋2×1 BACK、地下牢4×2 BACK地下、マナ炉6×3 BACK地下が未実装。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-10.7", + "section": "10.7", + "requirement": "インフラ/防衛施設の寸法/層/ゾーン", + "source_lines": [ + 210, + 211 + ], + "current_status": "一部実装・差異あり", + "current_note": "客用EV2×2 FRONT・業務用EV3×2 BACKは一致。 ワープ2×2 FRONT・外壁魔法砲台がない。防衛APIの存在は砲台施設の証拠ではない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "客用EV2×2 FRONT・業務用EV3×2 BACKは一致。", + "gap_or_acceptance": "ワープ2×2 FRONT・外壁魔法砲台がない。防衛APIの存在は砲台施設の証拠ではない。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-11.1", + "section": "11.1", + "requirement": "資源/時間/天候/Rank・下部切替・スクロールズーム", + "source_lines": [ + 213, + 214, + 215, + 216 + ], + "current_status": "一部実装・差異あり", + "current_note": "資源/時間/ランク、レイヤー切替、パン/ズームあり。 天候表示、指定下部巨大切替、非選択層の半透明表示まで原本一致ではない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "資源/時間/ランク、レイヤー切替、パン/ズームあり。", + "gap_or_acceptance": "天候表示、指定下部巨大切替、非選択層の半透明表示まで原本一致ではない。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-11.2", + "section": "11.2", + "requirement": "Policy/ドラッグ配置・欲求/思考/感情グラフ/盾", + "source_lines": [ + 217, + 218, + 219 + ], + "current_status": "一部実装・差異あり", + "current_note": "価格ポリシー、職員割当UI、客四欲求/状態/目的地表示あり。 ドラッグ配置、AI思考履歴、3感情グラフ、盾詳細の通常UIが不足。", + "previous_status": "一部実装・欲求観察UIを追加", + "status_code": "P", + "implemented": "価格ポリシー、職員割当UI、客四欲求/状態/目的地表示あり。", + "gap_or_acceptance": "ドラッグ配置、AI思考履歴、3感情グラフ、盾詳細の通常UIが不足。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-11.3", + "section": "11.3", + "requirement": "カメラ入口・覗きアングル/撮影/介入", + "source_lines": [ + 220, + 221, + 222 + ], + "current_status": "該当機能未実装", + "current_note": "現在のメインUIに原本の覗き専用導線はない。 カメラ入口、角度、撮影、証拠収集、介入の一式が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "現在のメインUIに原本の覗き専用導線はない。", + "gap_or_acceptance": "カメラ入口、角度、撮影、証拠収集、介入の一式が未実装。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-11.4", + "section": "11.4", + "requirement": "グリッド/接続警告・フキダシ/ログ・カットイン", + "source_lines": [ + 223, + 224, + 225, + 226 + ], + "current_status": "一部実装・差異あり", + "current_note": "グリッド建設プレビュー・警告/状態表示・ログと人物表示あり。 原本指定の全フキダシ、重要イベント/決闘/防衛カットインと接続線警告の全受入は不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "グリッド建設プレビュー・警告/状態表示・ログと人物表示あり。", + "gap_or_acceptance": "原本指定の全フキダシ、重要イベント/決闘/防衛カットインと接続線警告の全受入は不足。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-12.1", + "section": "12.1", + "requirement": "二面性・高精細ドットとアニメ立ち絵", + "source_lines": [ + 228, + 229, + 230 + ], + "current_status": "一部実装・差異あり", + "current_note": "Kenneyタイルと独自ドット人物で断面図・室内作業を表示。 原本の高品質アニメ立ち絵と二面性表現の完成受入はない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "Kenneyタイルと独自ドット人物で断面図・室内作業を表示。", + "gap_or_acceptance": "原本の高品質アニメ立ち絵と二面性表現の完成受入はない。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-12.2", + "section": "12.2", + "requirement": "魔法レンズ風の波紋切替", + "source_lines": [ + 231, + 232 + ], + "current_status": "該当機能未実装", + "current_note": "レイヤー選択は即時切替。 魔法レンズ風の波紋反転演出が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "レイヤー選択は即時切替。", + "gap_or_acceptance": "魔法レンズ風の波紋反転演出が未実装。", + "source_evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-12.3", + "section": "12.3", + "requirement": "SD各モーション・立ち絵破損/汚れ/刻印差分", + "source_lines": [ + 233, + 234, + 235 + ], + "current_status": "一部実装・差異あり", + "current_note": "歩行・待機・作業・気絶/搬送/治療・休息のドット表示あり。 原本の全職種専用モーション、立ち絵破損/汚れ/刻印差分は未完成。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "歩行・待機・作業・気絶/搬送/治療・休息のドット表示あり。", + "gap_or_acceptance": "原本の全職種専用モーション、立ち絵破損/汚れ/刻印差分は未完成。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-12.4", + "section": "12.4", + "requirement": "表裏UIスキン・BGMクロスフェード・環境音/SE", + "source_lines": [ + 236, + 237, + 238, + 239 + ], + "current_status": "一部実装・差異あり", + "current_note": "昼夜/レイヤーの色調変化あり。 羊皮紙/黒鉄の完成スキン、表裏BGMクロスフェード、環境音/SEの実再生なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "昼夜/レイヤーの色調変化あり。", + "gap_or_acceptance": "羊皮紙/黒鉄の完成スキン、表裏BGMクロスフェード、環境音/SEの実再生なし。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-13.1", + "section": "13.1", + "requirement": "立ち絵/CG/差分", + "source_lines": [ + 241, + 242, + 243 + ], + "current_status": "該当機能未実装", + "current_note": "キャラクターデータはあるが指定イラスト一式はない。 メイン/サブ立ち絵、衣装/表情/ルート差分・イベントCGの制作と接続が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "キャラクターデータはあるが指定イラスト一式はない。", + "gap_or_acceptance": "メイン/サブ立ち絵、衣装/表情/ルート差分・イベントCGの制作と接続が不足。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-13.2", + "section": "13.2", + "requirement": "SDモーション/エフェクト", + "source_lines": [ + 244, + 245, + 246 + ], + "current_status": "一部実装・差異あり", + "current_note": "ドット人物と一部状態モーションあり。 原本が求める全モーションと盾/煙/マナ等の全演出資産は未完成。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "ドット人物と一部状態モーションあり。", + "gap_or_acceptance": "原本が求める全モーションと盾/煙/マナ等の全演出資産は未完成。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-13.3", + "section": "13.3", + "requirement": "Lv1〜3表裏タイル/背景", + "source_lines": [ + 247, + 248 + ], + "current_status": "一部実装・差異あり", + "current_note": "現行施設内装・背景・タイル素材あり。 全施設Lv1〜3の表裏外観/内装差分とLvUP表示接続が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "現行施設内装・背景・タイル素材あり。", + "gap_or_acceptance": "全施設Lv1〜3の表裏外観/内装差分とLvUP表示接続が不足。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-13.4", + "section": "13.4", + "requirement": "表裏スキン/感情/覗き素材", + "source_lines": [ + 249, + 250 + ], + "current_status": "一部実装・差異あり", + "current_note": "現行管理UI・ボタン・欲求バー等あり。 表裏スキン完成版、3感情グラフ、専用カメラオーバーレイなし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "現行管理UI・ボタン・欲求バー等あり。", + "gap_or_acceptance": "表裏スキン完成版、3感情グラフ、専用カメラオーバーレイなし。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-13.5", + "section": "13.5", + "requirement": "BGM/環境/操作/成人向けSE/声", + "source_lines": [ + 251, + 252 + ], + "current_status": "該当機能未実装", + "current_note": "assets内にogg/wav/mp3がなく、本体コードにAudioStreamPlayer参照がない。 動的BGM、環境/操作SE、ボイス等の同梱・再生が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "assets内にogg/wav/mp3がなく、本体コードにAudioStreamPlayer参照がない。", + "gap_or_acceptance": "動的BGM、環境/操作SE、ボイス等の同梱・再生が未実装。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-14.1", + "section": "14.1", + "requirement": "2026/03〜08 プロトタイプ", + "source_lines": [ + 255 + ], + "current_status": "工程要件・完了未認定", + "current_note": "MVP相当の開業ループと試験は存在する。 工程要件。2026/03〜08の納期達成や当時の受入完了を現在コードからは認定できない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "MVP相当の開業ループと試験は存在する。", + "gap_or_acceptance": "工程要件。2026/03〜08の納期達成や当時の受入完了を現在コードからは認定できない。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-14.2", + "section": "14.2", + "requirement": "2026/09〜2027/02 アルファ", + "source_lines": [ + 256 + ], + "current_status": "工程要件・完了未認定", + "current_note": "パラメータ基盤あり。現日はアルファ予定期間内。 量産素材・シナリオ・通常BGMの工程完了は未達。機能完了数に混ぜない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "パラメータ基盤あり。現日はアルファ予定期間内。", + "gap_or_acceptance": "量産素材・シナリオ・通常BGMの工程完了は未達。機能完了数に混ぜない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-14.3", + "section": "14.3", + "requirement": "2027/03〜08 ベータ/成人向け", + "source_lines": [ + 257 + ], + "current_status": "工程要件・完了未認定", + "current_note": "原本に将来工程として記載。 2027/03〜08の完成条件であり、現在完了扱いにしない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "原本に将来工程として記載。", + "gap_or_acceptance": "2027/03〜08の完成条件であり、現在完了扱いにしない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + }, + { + "id": "B-14.4", + "section": "14.4", + "requirement": "2027/09〜12 ポリッシュ", + "source_lines": [ + 258 + ], + "current_status": "工程要件・完了未認定", + "current_note": "デバッグ・性能確認の実施記録はある。 2027/09〜12のマスターアップ工程全体の承認証拠はない。個別PASSで代替しない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "デバッグ・性能確認の実施記録はある。", + "gap_or_acceptance": "2027/09〜12のマスターアップ工程全体の承認証拠はない。個別PASSで代替しない。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29" + } +] diff --git a/docs/traceability/original/audit-2026-09-29/source_coverage.json b/docs/traceability/original/audit-2026-09-29/source_coverage.json new file mode 100644 index 0000000..88ff204 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/source_coverage.json @@ -0,0 +1,1797 @@ +[ + { + "line": 1, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 2, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 3, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 4, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 5, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 6, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 7, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 8, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 9, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 10, + "kind": "requirement", + "requirement_ids": [ + "B-1.1" + ] + }, + { + "line": 11, + "kind": "requirement", + "requirement_ids": [ + "B-1.2" + ] + }, + { + "line": 12, + "kind": "requirement", + "requirement_ids": [ + "B-1.2" + ] + }, + { + "line": 13, + "kind": "requirement", + "requirement_ids": [ + "B-1.2" + ] + }, + { + "line": 14, + "kind": "requirement", + "requirement_ids": [ + "B-1.2" + ] + }, + { + "line": 15, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 16, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 17, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 18, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 19, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 20, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 21, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 22, + "kind": "requirement", + "requirement_ids": [ + "B-1.3" + ] + }, + { + "line": 23, + "kind": "requirement", + "requirement_ids": [ + "B-1.4" + ] + }, + { + "line": 24, + "kind": "requirement", + "requirement_ids": [ + "B-1.4" + ] + }, + { + "line": 25, + "kind": "requirement", + "requirement_ids": [ + "B-1.4" + ] + }, + { + "line": 26, + "kind": "requirement", + "requirement_ids": [ + "B-1.4" + ] + }, + { + "line": 27, + "kind": "requirement", + "requirement_ids": [ + "B-1.5" + ] + }, + { + "line": 28, + "kind": "requirement", + "requirement_ids": [ + "B-1.5" + ] + }, + { + "line": 29, + "kind": "requirement", + "requirement_ids": [ + "B-1.5" + ] + }, + { + "line": 30, + "kind": "requirement", + "requirement_ids": [ + "B-1.5" + ] + }, + { + "line": 31, + "kind": "requirement", + "requirement_ids": [ + "B-1.6" + ] + }, + { + "line": 32, + "kind": "requirement", + "requirement_ids": [ + "B-1.6" + ] + }, + { + "line": 33, + "kind": "requirement", + "requirement_ids": [ + "B-1.6" + ] + }, + { + "line": 34, + "kind": "requirement", + "requirement_ids": [ + "B-1.6" + ] + }, + { + "line": 35, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 36, + "kind": "requirement", + "requirement_ids": [ + "B-2.1" + ] + }, + { + "line": 37, + "kind": "requirement", + "requirement_ids": [ + "B-2.1" + ] + }, + { + "line": 38, + "kind": "requirement", + "requirement_ids": [ + "B-2.1" + ] + }, + { + "line": 39, + "kind": "requirement", + "requirement_ids": [ + "B-2.1" + ] + }, + { + "line": 40, + "kind": "requirement", + "requirement_ids": [ + "B-2.2" + ] + }, + { + "line": 41, + "kind": "requirement", + "requirement_ids": [ + "B-2.2" + ] + }, + { + "line": 42, + "kind": "requirement", + "requirement_ids": [ + "B-2.2" + ] + }, + { + "line": 43, + "kind": "requirement", + "requirement_ids": [ + "B-2.2" + ] + }, + { + "line": 44, + "kind": "requirement", + "requirement_ids": [ + "B-2.2" + ] + }, + { + "line": 45, + "kind": "requirement", + "requirement_ids": [ + "B-2.3" + ] + }, + { + "line": 46, + "kind": "requirement", + "requirement_ids": [ + "B-2.3" + ] + }, + { + "line": 47, + "kind": "requirement", + "requirement_ids": [ + "B-2.3" + ] + }, + { + "line": 48, + "kind": "requirement", + "requirement_ids": [ + "B-2.4" + ] + }, + { + "line": 49, + "kind": "requirement", + "requirement_ids": [ + "B-2.4" + ] + }, + { + "line": 50, + "kind": "requirement", + "requirement_ids": [ + "B-2.4" + ] + }, + { + "line": 51, + "kind": "requirement", + "requirement_ids": [ + "B-2.5" + ] + }, + { + "line": 52, + "kind": "requirement", + "requirement_ids": [ + "B-2.5" + ] + }, + { + "line": 53, + "kind": "requirement", + "requirement_ids": [ + "B-2.5" + ] + }, + { + "line": 54, + "kind": "requirement", + "requirement_ids": [ + "B-2.6" + ] + }, + { + "line": 55, + "kind": "requirement", + "requirement_ids": [ + "B-2.6" + ] + }, + { + "line": 56, + "kind": "requirement", + "requirement_ids": [ + "B-2.6" + ] + }, + { + "line": 57, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 58, + "kind": "requirement", + "requirement_ids": [ + "B-3.1a", + "B-3.1b" + ] + }, + { + "line": 59, + "kind": "requirement", + "requirement_ids": [ + "B-3.1a", + "B-3.1b" + ] + }, + { + "line": 60, + "kind": "requirement", + "requirement_ids": [ + "B-3.1a", + "B-3.1b" + ] + }, + { + "line": 61, + "kind": "requirement", + "requirement_ids": [ + "B-3.2" + ] + }, + { + "line": 62, + "kind": "requirement", + "requirement_ids": [ + "B-3.2" + ] + }, + { + "line": 63, + "kind": "requirement", + "requirement_ids": [ + "B-3.2" + ] + }, + { + "line": 64, + "kind": "requirement", + "requirement_ids": [ + "B-3.2" + ] + }, + { + "line": 65, + "kind": "requirement", + "requirement_ids": [ + "B-3.2" + ] + }, + { + "line": 66, + "kind": "requirement", + "requirement_ids": [ + "B-3.3" + ] + }, + { + "line": 67, + "kind": "requirement", + "requirement_ids": [ + "B-3.3" + ] + }, + { + "line": 68, + "kind": "requirement", + "requirement_ids": [ + "B-3.3" + ] + }, + { + "line": 69, + "kind": "requirement", + "requirement_ids": [ + "B-3.3" + ] + }, + { + "line": 70, + "kind": "requirement", + "requirement_ids": [ + "B-3.4a", + "B-3.4b" + ] + }, + { + "line": 71, + "kind": "requirement", + "requirement_ids": [ + "B-3.4a", + "B-3.4b" + ] + }, + { + "line": 72, + "kind": "requirement", + "requirement_ids": [ + "B-3.4a", + "B-3.4b" + ] + }, + { + "line": 73, + "kind": "requirement", + "requirement_ids": [ + "B-3.5" + ] + }, + { + "line": 74, + "kind": "requirement", + "requirement_ids": [ + "B-3.5" + ] + }, + { + "line": 75, + "kind": "requirement", + "requirement_ids": [ + "B-3.5" + ] + }, + { + "line": 76, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 77, + "kind": "requirement", + "requirement_ids": [ + "B-4.1" + ] + }, + { + "line": 78, + "kind": "requirement", + "requirement_ids": [ + "B-4.1" + ] + }, + { + "line": 79, + "kind": "requirement", + "requirement_ids": [ + "B-4.1" + ] + }, + { + "line": 80, + "kind": "requirement", + "requirement_ids": [ + "B-4.1" + ] + }, + { + "line": 81, + "kind": "requirement", + "requirement_ids": [ + "B-4.2" + ] + }, + { + "line": 82, + "kind": "requirement", + "requirement_ids": [ + "B-4.2" + ] + }, + { + "line": 83, + "kind": "requirement", + "requirement_ids": [ + "B-4.2" + ] + }, + { + "line": 84, + "kind": "requirement", + "requirement_ids": [ + "B-4.3a", + "B-4.3b", + "B-4.3c" + ] + }, + { + "line": 85, + "kind": "requirement", + "requirement_ids": [ + "B-4.3a", + "B-4.3b", + "B-4.3c" + ] + }, + { + "line": 86, + "kind": "requirement", + "requirement_ids": [ + "B-4.3a", + "B-4.3b", + "B-4.3c" + ] + }, + { + "line": 87, + "kind": "requirement", + "requirement_ids": [ + "B-4.3a", + "B-4.3b", + "B-4.3c" + ] + }, + { + "line": 88, + "kind": "requirement", + "requirement_ids": [ + "B-4.4" + ] + }, + { + "line": 89, + "kind": "requirement", + "requirement_ids": [ + "B-4.4" + ] + }, + { + "line": 90, + "kind": "requirement", + "requirement_ids": [ + "B-4.4" + ] + }, + { + "line": 91, + "kind": "requirement", + "requirement_ids": [ + "B-4.5" + ] + }, + { + "line": 92, + "kind": "requirement", + "requirement_ids": [ + "B-4.5" + ] + }, + { + "line": 93, + "kind": "requirement", + "requirement_ids": [ + "B-4.5" + ] + }, + { + "line": 94, + "kind": "requirement", + "requirement_ids": [ + "B-4.5" + ] + }, + { + "line": 95, + "kind": "requirement", + "requirement_ids": [ + "B-4.5" + ] + }, + { + "line": 96, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 97, + "kind": "requirement", + "requirement_ids": [ + "B-5.1" + ] + }, + { + "line": 98, + "kind": "requirement", + "requirement_ids": [ + "B-5.1" + ] + }, + { + "line": 99, + "kind": "requirement", + "requirement_ids": [ + "B-5.1" + ] + }, + { + "line": 100, + "kind": "requirement", + "requirement_ids": [ + "B-5.2" + ] + }, + { + "line": 101, + "kind": "requirement", + "requirement_ids": [ + "B-5.2" + ] + }, + { + "line": 102, + "kind": "requirement", + "requirement_ids": [ + "B-5.2" + ] + }, + { + "line": 103, + "kind": "requirement", + "requirement_ids": [ + "B-5.2" + ] + }, + { + "line": 104, + "kind": "requirement", + "requirement_ids": [ + "B-5.3" + ] + }, + { + "line": 105, + "kind": "requirement", + "requirement_ids": [ + "B-5.3" + ] + }, + { + "line": 106, + "kind": "requirement", + "requirement_ids": [ + "B-5.3" + ] + }, + { + "line": 107, + "kind": "requirement", + "requirement_ids": [ + "B-5.3" + ] + }, + { + "line": 108, + "kind": "requirement", + "requirement_ids": [ + "B-5.4" + ] + }, + { + "line": 109, + "kind": "requirement", + "requirement_ids": [ + "B-5.4" + ] + }, + { + "line": 110, + "kind": "requirement", + "requirement_ids": [ + "B-5.4" + ] + }, + { + "line": 111, + "kind": "requirement", + "requirement_ids": [ + "B-5.5" + ] + }, + { + "line": 112, + "kind": "requirement", + "requirement_ids": [ + "B-5.5" + ] + }, + { + "line": 113, + "kind": "requirement", + "requirement_ids": [ + "B-5.5" + ] + }, + { + "line": 114, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 115, + "kind": "requirement", + "requirement_ids": [ + "B-6.1" + ] + }, + { + "line": 116, + "kind": "requirement", + "requirement_ids": [ + "B-6.1" + ] + }, + { + "line": 117, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 118, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 119, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 120, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 121, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 122, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 123, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 124, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 125, + "kind": "requirement", + "requirement_ids": [ + "B-6.2" + ] + }, + { + "line": 126, + "kind": "requirement", + "requirement_ids": [ + "B-6.3" + ] + }, + { + "line": 127, + "kind": "requirement", + "requirement_ids": [ + "B-6.3" + ] + }, + { + "line": 128, + "kind": "requirement", + "requirement_ids": [ + "B-6.3" + ] + }, + { + "line": 129, + "kind": "requirement", + "requirement_ids": [ + "B-6.4" + ] + }, + { + "line": 130, + "kind": "requirement", + "requirement_ids": [ + "B-6.4" + ] + }, + { + "line": 131, + "kind": "requirement", + "requirement_ids": [ + "B-6.4" + ] + }, + { + "line": 132, + "kind": "requirement", + "requirement_ids": [ + "B-6.4" + ] + }, + { + "line": 133, + "kind": "requirement", + "requirement_ids": [ + "B-6.5" + ] + }, + { + "line": 134, + "kind": "requirement", + "requirement_ids": [ + "B-6.5" + ] + }, + { + "line": 135, + "kind": "requirement", + "requirement_ids": [ + "B-6.5" + ] + }, + { + "line": 136, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 137, + "kind": "requirement", + "requirement_ids": [ + "B-7.1" + ] + }, + { + "line": 138, + "kind": "requirement", + "requirement_ids": [ + "B-7.1" + ] + }, + { + "line": 139, + "kind": "requirement", + "requirement_ids": [ + "B-7.2" + ] + }, + { + "line": 140, + "kind": "requirement", + "requirement_ids": [ + "B-7.2" + ] + }, + { + "line": 141, + "kind": "requirement", + "requirement_ids": [ + "B-7.2" + ] + }, + { + "line": 142, + "kind": "requirement", + "requirement_ids": [ + "B-7.2" + ] + }, + { + "line": 143, + "kind": "requirement", + "requirement_ids": [ + "B-7.2" + ] + }, + { + "line": 144, + "kind": "requirement", + "requirement_ids": [ + "B-7.3" + ] + }, + { + "line": 145, + "kind": "requirement", + "requirement_ids": [ + "B-7.3" + ] + }, + { + "line": 146, + "kind": "requirement", + "requirement_ids": [ + "B-7.3" + ] + }, + { + "line": 147, + "kind": "requirement", + "requirement_ids": [ + "B-7.3" + ] + }, + { + "line": 148, + "kind": "requirement", + "requirement_ids": [ + "B-7.3" + ] + }, + { + "line": 149, + "kind": "requirement", + "requirement_ids": [ + "B-7.4" + ] + }, + { + "line": 150, + "kind": "requirement", + "requirement_ids": [ + "B-7.4" + ] + }, + { + "line": 151, + "kind": "requirement", + "requirement_ids": [ + "B-7.4" + ] + }, + { + "line": 152, + "kind": "requirement", + "requirement_ids": [ + "B-7.4" + ] + }, + { + "line": 153, + "kind": "requirement", + "requirement_ids": [ + "B-7.4" + ] + }, + { + "line": 154, + "kind": "requirement", + "requirement_ids": [ + "B-7.5" + ] + }, + { + "line": 155, + "kind": "requirement", + "requirement_ids": [ + "B-7.5" + ] + }, + { + "line": 156, + "kind": "requirement", + "requirement_ids": [ + "B-7.5" + ] + }, + { + "line": 157, + "kind": "requirement", + "requirement_ids": [ + "B-7.5" + ] + }, + { + "line": 158, + "kind": "requirement", + "requirement_ids": [ + "B-7.6" + ] + }, + { + "line": 159, + "kind": "requirement", + "requirement_ids": [ + "B-7.6" + ] + }, + { + "line": 160, + "kind": "requirement", + "requirement_ids": [ + "B-7.6" + ] + }, + { + "line": 161, + "kind": "requirement", + "requirement_ids": [ + "B-7.6" + ] + }, + { + "line": 162, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 163, + "kind": "requirement", + "requirement_ids": [ + "B-8.1" + ] + }, + { + "line": 164, + "kind": "requirement", + "requirement_ids": [ + "B-8.1" + ] + }, + { + "line": 165, + "kind": "requirement", + "requirement_ids": [ + "B-8.1" + ] + }, + { + "line": 166, + "kind": "requirement", + "requirement_ids": [ + "B-8.2" + ] + }, + { + "line": 167, + "kind": "requirement", + "requirement_ids": [ + "B-8.2" + ] + }, + { + "line": 168, + "kind": "requirement", + "requirement_ids": [ + "B-8.2" + ] + }, + { + "line": 169, + "kind": "requirement", + "requirement_ids": [ + "B-8.2" + ] + }, + { + "line": 170, + "kind": "requirement", + "requirement_ids": [ + "B-8.2" + ] + }, + { + "line": 171, + "kind": "requirement", + "requirement_ids": [ + "B-8.3" + ] + }, + { + "line": 172, + "kind": "requirement", + "requirement_ids": [ + "B-8.3" + ] + }, + { + "line": 173, + "kind": "requirement", + "requirement_ids": [ + "B-8.3" + ] + }, + { + "line": 174, + "kind": "requirement", + "requirement_ids": [ + "B-8.3" + ] + }, + { + "line": 175, + "kind": "requirement", + "requirement_ids": [ + "B-8.4" + ] + }, + { + "line": 176, + "kind": "requirement", + "requirement_ids": [ + "B-8.4" + ] + }, + { + "line": 177, + "kind": "requirement", + "requirement_ids": [ + "B-8.4" + ] + }, + { + "line": 178, + "kind": "requirement", + "requirement_ids": [ + "B-8.5" + ] + }, + { + "line": 179, + "kind": "requirement", + "requirement_ids": [ + "B-8.5" + ] + }, + { + "line": 180, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 181, + "kind": "requirement", + "requirement_ids": [ + "B-9.1" + ] + }, + { + "line": 182, + "kind": "requirement", + "requirement_ids": [ + "B-9.1" + ] + }, + { + "line": 183, + "kind": "requirement", + "requirement_ids": [ + "B-9.1" + ] + }, + { + "line": 184, + "kind": "requirement", + "requirement_ids": [ + "B-9.1" + ] + }, + { + "line": 185, + "kind": "requirement", + "requirement_ids": [ + "B-9.2" + ] + }, + { + "line": 186, + "kind": "requirement", + "requirement_ids": [ + "B-9.2" + ] + }, + { + "line": 187, + "kind": "requirement", + "requirement_ids": [ + "B-9.2" + ] + }, + { + "line": 188, + "kind": "requirement", + "requirement_ids": [ + "B-9.2" + ] + }, + { + "line": 189, + "kind": "requirement", + "requirement_ids": [ + "B-9.3" + ] + }, + { + "line": 190, + "kind": "requirement", + "requirement_ids": [ + "B-9.3" + ] + }, + { + "line": 191, + "kind": "requirement", + "requirement_ids": [ + "B-9.3" + ] + }, + { + "line": 192, + "kind": "requirement", + "requirement_ids": [ + "B-9.3" + ] + }, + { + "line": 193, + "kind": "requirement", + "requirement_ids": [ + "B-9.3" + ] + }, + { + "line": 194, + "kind": "requirement", + "requirement_ids": [ + "B-9.4" + ] + }, + { + "line": 195, + "kind": "requirement", + "requirement_ids": [ + "B-9.4" + ] + }, + { + "line": 196, + "kind": "requirement", + "requirement_ids": [ + "B-9.4" + ] + }, + { + "line": 197, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 198, + "kind": "requirement", + "requirement_ids": [ + "B-10.1" + ] + }, + { + "line": 199, + "kind": "requirement", + "requirement_ids": [ + "B-10.1" + ] + }, + { + "line": 200, + "kind": "requirement", + "requirement_ids": [ + "B-10.2" + ] + }, + { + "line": 201, + "kind": "requirement", + "requirement_ids": [ + "B-10.2" + ] + }, + { + "line": 202, + "kind": "requirement", + "requirement_ids": [ + "B-10.3" + ] + }, + { + "line": 203, + "kind": "requirement", + "requirement_ids": [ + "B-10.3" + ] + }, + { + "line": 204, + "kind": "requirement", + "requirement_ids": [ + "B-10.4" + ] + }, + { + "line": 205, + "kind": "requirement", + "requirement_ids": [ + "B-10.4" + ] + }, + { + "line": 206, + "kind": "requirement", + "requirement_ids": [ + "B-10.5" + ] + }, + { + "line": 207, + "kind": "requirement", + "requirement_ids": [ + "B-10.5" + ] + }, + { + "line": 208, + "kind": "requirement", + "requirement_ids": [ + "B-10.6" + ] + }, + { + "line": 209, + "kind": "requirement", + "requirement_ids": [ + "B-10.6" + ] + }, + { + "line": 210, + "kind": "requirement", + "requirement_ids": [ + "B-10.7" + ] + }, + { + "line": 211, + "kind": "requirement", + "requirement_ids": [ + "B-10.7" + ] + }, + { + "line": 212, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 213, + "kind": "requirement", + "requirement_ids": [ + "B-11.1" + ] + }, + { + "line": 214, + "kind": "requirement", + "requirement_ids": [ + "B-11.1" + ] + }, + { + "line": 215, + "kind": "requirement", + "requirement_ids": [ + "B-11.1" + ] + }, + { + "line": 216, + "kind": "requirement", + "requirement_ids": [ + "B-11.1" + ] + }, + { + "line": 217, + "kind": "requirement", + "requirement_ids": [ + "B-11.2" + ] + }, + { + "line": 218, + "kind": "requirement", + "requirement_ids": [ + "B-11.2" + ] + }, + { + "line": 219, + "kind": "requirement", + "requirement_ids": [ + "B-11.2" + ] + }, + { + "line": 220, + "kind": "requirement", + "requirement_ids": [ + "B-11.3" + ] + }, + { + "line": 221, + "kind": "requirement", + "requirement_ids": [ + "B-11.3" + ] + }, + { + "line": 222, + "kind": "requirement", + "requirement_ids": [ + "B-11.3" + ] + }, + { + "line": 223, + "kind": "requirement", + "requirement_ids": [ + "B-11.4" + ] + }, + { + "line": 224, + "kind": "requirement", + "requirement_ids": [ + "B-11.4" + ] + }, + { + "line": 225, + "kind": "requirement", + "requirement_ids": [ + "B-11.4" + ] + }, + { + "line": 226, + "kind": "requirement", + "requirement_ids": [ + "B-11.4" + ] + }, + { + "line": 227, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 228, + "kind": "requirement", + "requirement_ids": [ + "B-12.1" + ] + }, + { + "line": 229, + "kind": "requirement", + "requirement_ids": [ + "B-12.1" + ] + }, + { + "line": 230, + "kind": "requirement", + "requirement_ids": [ + "B-12.1" + ] + }, + { + "line": 231, + "kind": "requirement", + "requirement_ids": [ + "B-12.2" + ] + }, + { + "line": 232, + "kind": "requirement", + "requirement_ids": [ + "B-12.2" + ] + }, + { + "line": 233, + "kind": "requirement", + "requirement_ids": [ + "B-12.3" + ] + }, + { + "line": 234, + "kind": "requirement", + "requirement_ids": [ + "B-12.3" + ] + }, + { + "line": 235, + "kind": "requirement", + "requirement_ids": [ + "B-12.3" + ] + }, + { + "line": 236, + "kind": "requirement", + "requirement_ids": [ + "B-12.4" + ] + }, + { + "line": 237, + "kind": "requirement", + "requirement_ids": [ + "B-12.4" + ] + }, + { + "line": 238, + "kind": "requirement", + "requirement_ids": [ + "B-12.4" + ] + }, + { + "line": 239, + "kind": "requirement", + "requirement_ids": [ + "B-12.4" + ] + }, + { + "line": 240, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 241, + "kind": "requirement", + "requirement_ids": [ + "B-13.1" + ] + }, + { + "line": 242, + "kind": "requirement", + "requirement_ids": [ + "B-13.1" + ] + }, + { + "line": 243, + "kind": "requirement", + "requirement_ids": [ + "B-13.1" + ] + }, + { + "line": 244, + "kind": "requirement", + "requirement_ids": [ + "B-13.2" + ] + }, + { + "line": 245, + "kind": "requirement", + "requirement_ids": [ + "B-13.2" + ] + }, + { + "line": 246, + "kind": "requirement", + "requirement_ids": [ + "B-13.2" + ] + }, + { + "line": 247, + "kind": "requirement", + "requirement_ids": [ + "B-13.3" + ] + }, + { + "line": 248, + "kind": "requirement", + "requirement_ids": [ + "B-13.3" + ] + }, + { + "line": 249, + "kind": "requirement", + "requirement_ids": [ + "B-13.4" + ] + }, + { + "line": 250, + "kind": "requirement", + "requirement_ids": [ + "B-13.4" + ] + }, + { + "line": 251, + "kind": "requirement", + "requirement_ids": [ + "B-13.5" + ] + }, + { + "line": 252, + "kind": "requirement", + "requirement_ids": [ + "B-13.5" + ] + }, + { + "line": 253, + "kind": "chapter_heading", + "requirement_ids": [] + }, + { + "line": 254, + "kind": "schedule_context", + "requirement_ids": [ + "B-14.1", + "B-14.2", + "B-14.3", + "B-14.4" + ] + }, + { + "line": 255, + "kind": "requirement", + "requirement_ids": [ + "B-14.1" + ] + }, + { + "line": 256, + "kind": "requirement", + "requirement_ids": [ + "B-14.2" + ] + }, + { + "line": 257, + "kind": "requirement", + "requirement_ids": [ + "B-14.3" + ] + }, + { + "line": 258, + "kind": "requirement", + "requirement_ids": [ + "B-14.4" + ] + } +] diff --git a/docs/traceability/original/audit-2026-09-29/source_manifest.json b/docs/traceability/original/audit-2026-09-29/source_manifest.json new file mode 100644 index 0000000..35edd64 --- /dev/null +++ b/docs/traceability/original/audit-2026-09-29/source_manifest.json @@ -0,0 +1,233 @@ +{ + "Makefile": "0947b523867dc7f1357292004897e17e48ce8fc97f208c85ae53a6dd230aa2fa", + "autoload/event_bus.gd": "6950280a5639dd5a3071ffefd034cb443a694739d5bf8c4e2dbcd52aadbab4b2", + "autoload/game_clock.gd": "39400634a57ccbf8c584b54a15a259048a95da90b1528330151e45667d65f7f7", + "autoload/game_state.gd": "cf0e884910220e54c56b5875983e53de439af9d893be1e1b65c2121d2a30722f", + "autoload/simulation_runtime.gd": "0c571936c6222f8891da9a6e6798543ec50a1f1b59eb1c8dfc709bc83fe6a351", + "data/characters/mvp_guest_classes.json": "5d55d4a0a10902da747000357ba36be9644bf027f253ea4b200a75f1ee4cc88b", + "data/characters/mvp_species.json": "6c7caf52305d76e07a0dee2b225ee92674c45d24cbca7f62bf4029bd3db00915", + "data/characters/mvp_staff_candidates.json": "437fb2d6731ac7d6539d88d3e2c006643a0ae379b22ce67a33e17f1c2e7fb3d9", + "data/characters/mvp_unique_characters.json": "51fbfb2720062a442a6027b994e9cf679a807edc20b2dd2f82635f8597286f46", + "data/facilities/adult_private_room.tres": "8f4725a1de93d36fef2cfbbea5672260e61e3d4e73951eb1d685f83a61b5635a", + "data/facilities/cafeteria.tres": "e2945fd3a7533eef4151277b95ec59c1798438721216a3df5145fdbbf1105fe1", + "data/facilities/expansion/black_market.tres": "39eb0a91776f84f31755c8555dfee9320eca1d4491951c297f9188b8fa1c4422", + "data/facilities/expansion/clinic.tres": "339cf22c68d7ab3cb1514b9e8d2ea63dc0772fe93918d70767c11cf7b6ee0e16", + "data/facilities/expansion/cold_storage.tres": "1aac824147b52b41e124d2ab2ce63c3bac58de2ec7fc447a5806a1cf9fa9ad99", + "data/facilities/expansion/general_store.tres": "76872e0375648b43f05fdc5581ccabe4aa6a166674c3b523980dd1cf0b6e6dc1", + "data/facilities/expansion/guild_counter.tres": "93e8736da4aab8a17b1c8e7831c9a7b8de7f32d0ec251aa9aa5c8cf859c8fd7b", + "data/facilities/expansion/laundry.tres": "2518ea2e5da12cf4f2997502bf50f00dd8145caca153fb3486b44b99881fdab4", + "data/facilities/expansion/maintenance_workshop.tres": "8bd10332a9f45a252febdc626f22f90507317fb4cff5b18b2526aacd1f1a176b", + "data/facilities/expansion/mana_control_room.tres": "55e4c52a049ff61ddf3d081ccb990a8572b64bc7cf37d3a0d52ca5a08cc32f25", + "data/facilities/expansion/premium_room.tres": "bf035db5deea3818ab12f029fdf53f65afa2f5c99fec4a236ce7dfb7076f5bb7", + "data/facilities/expansion/private_bath.tres": "241b807e7fa3a1b9dc4bda0d2495e637155cdaea89a4234e37a121f00098499c", + "data/facilities/expansion/secret_tavern.tres": "f0a1372931078fa0f93dd5b9cec6df6b137975479aa706633fdf6a9be4cf1f62", + "data/facilities/expansion/staff_dormitory.tres": "84c7a92216300a20f627c7e822219cf188912d9ba3417fa910261d18bdaf69cc", + "data/facilities/expansion/tea_lounge.tres": "77d1ace36c5e84f80c0e6d563ae973c7ff1cd12b56956afd4895cdcdd5771a67", + "data/facilities/expansion/underground_guest_room.tres": "cdfd0f8cd21812a9972485926dff27daaa6718af16aac1651894728b087bef9e", + "data/facilities/expansion/vip_private_salon.tres": "1c94a7a70393262fba82112c3f4a0cb67ad22728664f5ca408f450049d3657a7", + "data/facilities/expansion/waste_processing.tres": "9954855c7a07e425376a220c589989beb6ec95da3c5331316ef3615a60b45f1d", + "data/facilities/guest_corridor.tres": "2abf83d274ed4667ba07b8a384affcdfe2b387ad9513a444a7f78bcf237dab0c", + "data/facilities/guest_elevator.tres": "bf779e6ac3e342a01bc023673e90a2c0b12f22de456923a08bcc3294adb765c2", + "data/facilities/infrastructure/guest_stairs.tres": "37114d29b7486d1b5a3d84ab830c85d477973b2d39cfde84949aba342f4793b8", + "data/facilities/infrastructure/service_stairs.tres": "45c608dc4767a80ff322bd0d3721c953a314f445490142f715270a7a789391cc", + "data/facilities/kitchen.tres": "96dd06b83eebe6fdc735a491c52e44137adb1a17c7aac36ed1cec9aa96a10b67", + "data/facilities/massage_room.tres": "c6987f428c0ab73ccbbc8a992b5cf78c27bed0e87d886ad2e723018e804473a9", + "data/facilities/public_bath.tres": "0a2f7086c2c2eecd142e720183d279f33ed06e12fb5c1e782cca1ce3386f03f4", + "data/facilities/reception.tres": "d898f63d619c126abf80ee1e415d0bc587ccee30cd8ad4bd94f74fbae4cf4e76", + "data/facilities/rescue/infirmary.tres": "ae70ba4dbf6bece85e8d520a97a72e49a2b8069b4bed7e98c1f90b1b7ab9a46f", + "data/facilities/security_station.tres": "0c05f0e10239293f0a83e69f9ed7a98e38415920d9e610cebbfe327c913aa2a2", + "data/facilities/service_corridor.tres": "0e375c9f94f8d1597cbffe126c7f0d8fb4a52dd161fe4acd4ce60178b18cb2ee", + "data/facilities/service_elevator.tres": "17a22e70d7a997d8d61f973a3cbaccf51731e83d121fd9e4edf38d8f53ebb27a", + "data/facilities/staff_door.tres": "dd1df9b56b023a0b9624be2524a4f3ffe36252467bd189c07e441de64fe63d26", + "data/facilities/staff_waiting_room.tres": "c065b4ddb8cb9e8b45e114b6e4b12907ef85852b7c47271d280efed5871c9f58", + "data/facilities/standard_room.tres": "be26c02d3ac6d2423755f3648893adda8d81eaafa3d33c7babc8f8a4f2f93ebb", + "data/facilities/tavern.tres": "c38da70a8e66c6aa2cae650d17738022d06589322ad0e4ab561f5bbbd737378d", + "data/facilities/warehouse.tres": "0c797d1cb5fc574d0efce06e13c398f243889cf428f94c3d526d3b7f25fb6828", + "docs/traceability/original/BASELINE.txt": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "project.godot": "5be9bb6100daf31afcbaeddc784ec6e82f1f9283ab0b8a0ee3c7ae448cac867e", + "scenes/main/main.gd": "952a45ca4cdbaca8a667a3f8a8e032027963740fe8f840aa92c3db7b44c8ff4e", + "scenes/main/main.tscn": "dde43688b86251ce617131c362b1054ce8bd59b509c1abe19e831c0481a07319", + "scenes/main/world_preview.gd": "0ebb7c6a698d066199051ef51624d3f42d36660f31ffa853d57bd94b53f00b44", + "scripts/authority/authority_service.gd": "2979cca4c6a82932fed9babf209a608b8a20bda550c170f83a166db68ce02ab9", + "scripts/authority/evidence.gd": "85f1bd83a4ec994c8bb9bce296d8b63fcdb6bb26c1b4c2f47e16864450438649", + "scripts/authority/heat_state.gd": "7e507461684c159bed4c17acc939d6a98537d06d73de2dae235a788048f290b2", + "scripts/authority/incident.gd": "dbb58592746d0055b438fc8259f7c40d09f77076737c47051b732395888df124", + "scripts/authority/inspection.gd": "ff523082ec6d918dbc559e04c3c11fd6d30d4f88c5a54ffa57eb3c758e244812", + "scripts/construction/construction_job.gd": "6c59af45346c16572f8c4cb111af86c586cec8081302bf67b6ef411a56ac5196", + "scripts/construction/construction_service.gd": "c737eb7aa927097da677ac23ad649ef871af1dea54bcb8103348b88c5303ea25", + "scripts/construction/excavation_service.gd": "337e9e5d07a9eeaf362253fec12cbe5fb2ea8e74e4887de82868748577d4af33", + "scripts/construction/placement_report.gd": "9f179c9a94a176a126027cf1d53942c0ac73b008ad119ab2480ba93f972b145c", + "scripts/core/command.gd": "9751ff4b40bebdae56b6b3879dd7cbffdc2a36b050a12bda89e1de47eb54145a", + "scripts/core/command_queue.gd": "b3625e340adec1d278bdc078d647959b582bafa86515da1017d469be99732e90", + "scripts/core/command_result.gd": "71b45fb50d69951797fe8d39ad5f707b79108eac30de09fa5e8d64ddb2436ae8", + "scripts/core/definition_registry.gd": "5636d31579d77bb57fd1eb4e6ab5d8cf8fc34c4b824c7cb7e0228caa9133f594", + "scripts/core/deterministic_rng.gd": "9c12c1b3b212e140b64137fa34d7e8b2494897cad341b3bbcf9f8da215053204", + "scripts/core/domain_event.gd": "cc9a903c366d9f889134561694b0c7fd52da260e97236a7b60830d7a68947bd4", + "scripts/core/domain_event_stream.gd": "47e27ecbd622bf5e05da8262d73e3b8c5f2204338c74a3259f3d184627819c26", + "scripts/core/performance_budget.gd": "79d18e8f0dcc3932801cd02ea8a8ba037af02ef57665387cba8dbe3b3e9af917", + "scripts/core/save_migration_registry.gd": "22ac2cce07a02d1a2a9ac2109e7a0ecf9a0fbe675df4dc9d5249aa9ab3583f98", + "scripts/core/save_service.gd": "7d0ea0fe95d9c38c5d2dffa9499cfa9215331602847a66ad36ac3c12b08ac99f", + "scripts/core/simulation_digest.gd": "a4da38b4e4573dbfa7c133fe9cf20a61f38dd21ca7faa18b2b43f87661aba676", + "scripts/core/simulation_kernel.gd": "341c0c878c4894adbac628d0db6362ff69fc3347bdd9e61e50936e15dc2d1edb", + "scripts/core/simulation_snapshot.gd": "fada16febc0942a659992f3946d41bb8195ac5fb67cf630d123afd482d9d6da5", + "scripts/core/stable_id_generator.gd": "78e0dd691ba18d1f159d9d7abba5894d2e10d36885aa6dc5be02a27f9edd5013", + "scripts/core/state_transaction.gd": "22e4dbd4efceb20ac9b71429a76494ca75bfdd4115212408d2207794c5d3ef04", + "scripts/data/facility_catalog.gd": "4646632848b52b60f7af1071ef6bc5cb17bb959798d73d2faf4c51e9af43d417", + "scripts/data/facility_definition.gd": "6f6d2f87d253b97c65d1533f70d9f3c68fbddd8eb6ff0135f6b0629873bbcc62", + "scripts/data/mvp_content_catalog.gd": "ca65ed9ee7affa7beec2fcded07ddda65684d034e14372189c51dc994904ce82", + "scripts/defense/defense_service.gd": "6a6b8e7c8166eb426d234770813ecbb0a9a33b2a335c6ea563562b34486764bf", + "scripts/economy/economy_service.gd": "ecbd42338e5f17ec75501373a4f13d8839c723fc674cc7f74241aa4d9b637abd", + "scripts/economy/ledger_backed_resource_state.gd": "199442c566185dc0abcc4eea5b4cf59c07330731bb5d5038b605dd801ae0209b", + "scripts/economy/ledger_entry.gd": "625d709b8d3239ef271dfbd42f189fd3a3fb99bd22d4f39438050c8e3addfe26", + "scripts/events/event_action.gd": "ebbb233caf961d7333271bc498738b65bc505d709de48b0469597018218d3fba", + "scripts/events/event_choice.gd": "6077cba5d59e500e4df391d58801aa8c10669b17ac36c06e830329ca8e977dd6", + "scripts/events/event_condition.gd": "31d493d68db9b49d8eed0d5adf7a6c4d90f12df0ac3353b1ceafa6259196efd5", + "scripts/events/event_definition.gd": "4af68f2e5ec185a750d59aaa24c7cb63fe612cd6a785e7fd39af11f0b5ce74a2", + "scripts/events/event_instance.gd": "e8ce02a03dcb111d808d3cb3068a752fc5bbd61ba0cb15026f1371615619b974", + "scripts/events/event_service.gd": "efa0a7992736aa9c99b33f25e66a3ba40f8ebe0aa5ab609cc6e5b25d968b5083", + "scripts/facility/facility_instance.gd": "e4783d99493d49a7a7d6d03aa726dc45803c0c3c87adec52eb8814b377fc474d", + "scripts/facility/facility_pricing.gd": "e895e29469dedfb7ba82a7bc1f1931f2711b9bf3f7e2c436133527aa9f7a60e4", + "scripts/facility/facility_service.gd": "f8d3b7a204120b7b113169a8c1dbfa4cb78077e6514d7955a71f4f2cd551d9a9", + "scripts/facility/service_instance.gd": "629a142f80cbc31b97b6d08483d94e029e448969bb6d39fdc8dd39931654dd35", + "scripts/facility/service_request.gd": "767a8c054188c8dde1e446f49d43d9ddc727a215bc9b31d5420650bd44c90024", + "scripts/guest/guest_definition.gd": "082aa3f33b9cb17daf10a2cf8a1dece7b79dd80c2ae21e1a424d1b2ceb52b918", + "scripts/guest/guest_runtime.gd": "5bb687b2da6f634c66680ffbefb4b4a57ed02fb3dff10f72ce7e7858177d1241", + "scripts/guest/guest_service.gd": "499b7c4facfdb1d98a4220f6e806683fcaabfc4e0f036d4b886ec17b2885d9a9", + "scripts/inventory/inventory_reservation.gd": "2d913d26e5af2ea12842264441d87ee5fce8fe84ef6664297ae2fa971a4ca10b", + "scripts/inventory/inventory_service.gd": "7ee83b335864385119ca757037c3cdb912957aa4478a6312a0898a734e41f31a", + "scripts/inventory/logistics_dispatcher.gd": "2313f5ba3154143d5f0b6e1eeb1fbdda37cc81a7a1589515f56dfb53a797f720", + "scripts/inventory/logistics_request.gd": "6dc5565083b8adb2c620fe805d1ba76be01ae09faf3070a9363485f4fc5e171b", + "scripts/mana/mana_network_service.gd": "96f195cf2a28b0966d12cd7c6fab86d13eae97c8342d07d70f52e598fbf49af9", + "scripts/operations/operations_coordinator.gd": "cc3b9d27beca796364b619463b5630a3a47e6ea86180f4cd60d68b3f032349bc", + "scripts/progression/progression_service.gd": "4f4e00fbeab4a4cec49b7912d8e75381b8354791e0a9efdb8414d984e88ca46f", + "scripts/relationship/placement_risk_preview_service.gd": "d8e974487b75fbad49ecbbe1da8e36aac23dc4e1a5cf10281a1c567c97dd59f6", + "scripts/relationship/relationship_service.gd": "76d49e4fdb50e9a185f7ba5065d257682a11aece1c76272208ba8555ffd534c2", + "scripts/relationship/relationship_state.gd": "14551398f31ecd899e43a3d07b165d4884e54e83983e433c6de57e3e6a18d083", + "scripts/staff/shift_instance.gd": "cec2bed5bfb138d4cc9c141bb7cdd9c19d70c034aad304398627ef1f4977ab48", + "scripts/staff/staff_commute_service.gd": "3a1b816a3a778fc362e950817275fb8464d8562b61527f141787e4b2d2a3eba0", + "scripts/staff/staff_runtime.gd": "80b4e277f1db415f2121e1079ed41e3eebd3f7363d79307a1fa9a0e46aa144eb", + "scripts/staff/staff_service.gd": "47f66f32141a8b104f0b898139f6760201f7f63a198d852e2769fd76331d1b69", + "scripts/staff/staff_task.gd": "5eeb8862a83e31e36d75e19720597fccf1aa4b25f30193d17ee95aaa94302b8a", + "scripts/staff/task_dispatcher.gd": "fdf2b6464e4b1089c58cbce972ff6217a0f7c69929b1be425536152d4b8290c7", + "scripts/supply/kitchen_production_service.gd": "f3bc6bdc1a0ba278e9411108172ceceb7eb06520b4e33f93f35684da1f103c62", + "scripts/supply/supply_catalog.gd": "93a3f674a953fb0627a38ad0342df31dc5fa77ba8a225780f2250e30ec22dd15", + "scripts/supply/supply_logistics_bridge.gd": "b9e394af788595d2744b71f1907c8e7c3d762d38ff49992b1ee74e63b9639e80", + "scripts/supply/supply_management_service.gd": "466c12b25c435afe3789c4079107c0cf66d79ec3484af847814829dbdb0944bf", + "scripts/transport/elevator_service.gd": "cea09dd9204551c26b31b30cd3aa847cc77e3b75fdac31662b9694a95fb3580c", + "scripts/ui/business_management_panel.gd": "404dbe00cbd73abe7ee9e7f4ede99a335d3675270735a63eb714ae4e74d0fc92", + "scripts/ui/guest_observation_panel.gd": "492d16457d53e113c05ae8d327ae513c782952afb03d97a85dbf3bcd720dd35f", + "scripts/ui/inn_opening_guide.gd": "c923bf932a7fcde4548887cd86162d34c9eb8079e433014ed49ad6ff8ec23b75", + "scripts/ui/operating_ledger_panel.gd": "efc282cbe8711c6e3d1a7a6eab0dd9ccf63b2a26af20205fa0a5b45a2c7eb6ec", + "scripts/ui/pixel_people.gd": "8a46ba96705576bda98d3ba6b08323ddc1a3bd1e9fb4321b9b959b84fd07c626", + "scripts/ui/pixel_tower_art.gd": "9d1082a9cc177a4f3232f8c340a24b485328d4b23f57ab01340bf0db9e331f7d", + "scripts/ui/pixel_tower_demo.gd": "d66a245818d77d879b3046a4daea372d8b36d33c7fcce8c7ad3c170206e847ae", + "scripts/ui/room_presentation.gd": "4d7c6f9acbd5f27845acc748b623a8f039e99951045c1e421a1932ecfd4921f3", + "scripts/ui/staff_management_panel.gd": "01443ef0b348a6aecfd453f2c25045753891ea12d89138cd40b37d5eaf8f3cf6", + "scripts/ui/supply_management_panel.gd": "18036066d69fe0d6ef7fb578e4db899c2ab958176cfabe6918b9160e70273d58", + "scripts/ui/visual_theme_profile.gd": "2a251a94d4a18c2ddd9e307cb810d1f327c12f2f15cd3bed91e65aa499258ceb", + "scripts/world/facility_infrastructure_projector.gd": "e329019aa54f315df6a8e3c899770ab8701578b59583e88a027b815780bc1400", + "scripts/world/legacy_stair_service.gd": "e6899ee68894a59af061056ceb8673a772cdcc80a63dab5b4d54309429c33c2c", + "scripts/world/path_service.gd": "c09f5220cff1664fdd47ffd55f6750dcb0393691cbb0d13ea6238d7d32ca99c0", + "scripts/world/route_subslot_path_adapter.gd": "d8b98918c038855e0a4c4cf039853e4781718ccb0ca3b7516ed354d9b898ebf5", + "scripts/world/spatial_subslot_service.gd": "6238843f3f53e8fa21e325a8152e9a77aca97aebe495a05fb19d37ee62d549ae", + "scripts/world/world_service.gd": "666d96378d053fbef0969ddaa37cab30a60f32e2253043e00f36c402ef63a2a8", + "tests/business/test_business_closure.gd": "7b13a0dc13d1276cce7515973af47ad9653620e04221c43679e059e674b1d4fb", + "tests/business/test_business_operations.gd": "5b1975cb3e921febe5c23ae26b3c8445d92ab68de2479d23f86f565a934f04f5", + "tests/export/test_exported_opening.gd": "d980545e0c3d1477eb746da7a131556493cdba6f4e267f4491fe3973aed76fbf", + "tests/fixtures/facilities/invalid_service_profile.tres": "8bd02f6ca505a9bd65360a1ce99f961252bc201c5381e0033519ef4952f8f2e6", + "tests/mvp/test_mvp_content_catalog.gd": "2444ca2803881eac4b5e32724337cebfb8c5d86fb1dfcdba79e3c2a86ff2dde9", + "tests/mvp/test_operations_coordinator.gd": "7d1ce485242e7fd9232875eaf4c01fcb6ea579f1148f32a72873b87f410bc989", + "tests/mvp/test_original_midnight_settlement.gd": "916d0b4184842b1c8cc23c1a95db12558a8ba665b251aa6a383a42995eceaa30", + "tests/mvp/test_progression_service.gd": "f97895fceacc42fbbe14f3e77972ef980abf8f63ca9a5c8c8d4003900305b0aa", + "tests/mvp/test_runtime_content_progression.gd": "ec94db8462b30daf9fc4a1dd8bd3d7568937e2908f5d95afa073b8183902b6fc", + "tests/mvp/test_runtime_operations_acceptance.gd": "2ae592cf5ac555cde8546bdfccd9e96dfd0f2f8b93bd4ee09f9c12974e75552a", + "tests/mvp/test_runtime_operations_integration.gd": "4fc5d868924af7cf46db3fa6e0d8fc6b15ebd6ab96035758b4df790ad08d005c", + "tests/performance/fixtures/linear_path_reference.gd": "406b664557e99a8fc9903b9431f708e8e6372c3c56bf1665dd1908485c9bd528", + "tests/performance/fixtures/simulation_digest_v1_reference.gd": "64b5c099f8b4ff6e855c80513f150cfdb0e9c6d0f816fd02ec2660d569550928", + "tests/performance/fixtures/uncached_preview_reference.gd": "5ba415527f1ecc5cb6d6248ba75014ae91df86d01e7f9c35b8834f378c0c69d1", + "tests/performance/perf_mvp_001_harness.gd": "63519cf3723d084fa9c30425e49eb210a54a23fd1987df910621f8d12df9e413", + "tests/performance/test_digest_performance.gd": "20d1849f0f1b973ac3942d923ea20206cff16beacc0da36ba4583fb5670560b7", + "tests/performance/test_perf_mvp_001.gd": "1a4bc20ea4810924e8e64bc5fec1b44fc327f1676200e978a77577d628675e5b", + "tests/performance/test_system_optimization.gd": "374ff4b3ba78b7a724a3620d683db3835fd2a545f1de11606fd4969a3c6ede95", + "tests/run_tests.gd": "4c8ef388e2349ff975a2af7260da0aa02231a6036ea71b4000f2bb0e6f4083d5", + "tests/s0/test_command_transaction.gd": "1f1559646a38940db027ee83b2622f317e9b9c6c6f7d4b86f41f866eabb7e75d", + "tests/s0/test_definition_registry.gd": "21734d25641eaf906e0261a0ded634874aaf8ef987c4aea704c147d64487e33e", + "tests/s0/test_determinism.gd": "a397d2b33a2c8abfae76350d8ee65b967bbc1625186a865dffd776b421cc7520", + "tests/s0/test_domain_events.gd": "e16700219d5f3482b967de3216d4ace9853e47a69c91dcfd57d003b1d34c1da2", + "tests/s0/test_kernel_and_save.gd": "6d13f83672f8273ec6ddc47c88de1ce3916f4126af022be8bc562f5dd1d302a7", + "tests/s0/test_runtime_integration.gd": "9c15f4de6129fd4a225d6629c1ada67141685d3ff90456cbbe7b73c45a269b7b", + "tests/s0/test_stable_id_generator.gd": "2fac546b4788650b69832b2336d9d46f7b8b343b660d687256d98a64ef4b823c", + "tests/s1/test_construction_service.gd": "ce63074b84cb75d710eb3650701a9cb2acf920ff607e41be645904fc2395e2c3", + "tests/s1/test_excavation_service.gd": "64dacd14dbe0bc94368879daa130e4861ff43cc63fac6bb02e4e64091ee9a750", + "tests/s1/test_facility_infrastructure_projector.gd": "cb74f878cd709e80f53c74fa646daa138577f9d8ca18668e5178126c6e899b39", + "tests/s1/test_path_congestion.gd": "709bb680f04862fd774644b0b425150977f54346ca76f6223663c414e00f33bd", + "tests/s1/test_path_service.gd": "0f48bb44c27e32f08477b9aed61a0db5c8808219490928f734c3570a07efb251", + "tests/s1/test_route_subslot_path_adapter.gd": "573fcfb1e2f8e8781703e29d37600730dc54c77fda11a4630401a8b9e9259445", + "tests/s1/test_runtime_infrastructure_integration.gd": "b4d34870ff6573973cfe7069e02105630f24b74ae13862dc91cf454ad5ddbe7f", + "tests/s1/test_runtime_world_construction.gd": "65924a9b69cd27b5923c5464bd44ab839b26fe39a51b3580e691805d5a9e0e5b", + "tests/s1/test_spatial_subslot_service.gd": "06f4f69438a280ef942bbefdb9caaef1ba59e7dfd0eab71469ca29d090a8ad58", + "tests/s1/test_world_service.gd": "a5e165886f161b8bc71722ac7facdd17704bc6274c88dfe7fd2229f7f2b3f2b7", + "tests/s2/test_economy_service.gd": "cfe6196c0da9e39d95ae0e39f0c5404e321f4fa8be3602b58d4bf6cd3fef3969", + "tests/s2/test_facility_pricing.gd": "e658a7421aaef08c1c6f9a1bce42cb819ae39ee8ec0da72547b42400688c1a98", + "tests/s2/test_facility_service.gd": "e5a0ceef45d2fcae110f777ddb3c678374b7c9110cd40f743f88c578f0ed2d34", + "tests/s2/test_inventory_logistics.gd": "ef8b87e721f57210da02b8cd0822f58e1a4c1cdaa19fe56301a03a87891e73d6", + "tests/s2/test_kitchen_production.gd": "e2aeb1fdbc8d1dfe9400646693a0e97919b3b86cb74a4f000ebeba01bfbee626", + "tests/s2/test_ledger_backed_resources.gd": "26c2c9b883ee1448345d5555ac3e97293c3bf273b42668c11a6bdc23e1184dd3", + "tests/s2/test_mana_network_service.gd": "b0a21ea663667979c109cf6fd2efff717c284398071948c897b9e99090ce12fc", + "tests/s2/test_original_facility_parameters.gd": "3d5cc5f13f986f8cea9500a7f8d48d8f5803b8d0bee5eb0e7c44b3218a2e3d7b", + "tests/s2/test_runtime_operations_cycle.gd": "43bb6155dbcd8e21c01a57a62d13e2ab59edd1f3b23ebd14485f1273b15a065e", + "tests/s3/test_guest_service.gd": "0d10b68512685d2ed7d5c0c858952009333e0e47f348ace4b4ccdd0b21d23647", + "tests/s3/test_runtime_actor_cycle.gd": "cbad1505c735f3dab4596f7ad823d6501faf0cea4b9b91cc2b6ff0422296a2de", + "tests/s3/test_runtime_actor_hardening.gd": "1cdb1706646e70d99ac77530416ca5681582fc6e76ced455023bf5b674efffa5", + "tests/s3/test_runtime_path_traffic.gd": "6336a2291754bc2a2f3d83a6de96959bbcdc68f749fa362c0096fb4a26b2f41c", + "tests/s3/test_runtime_staff_assignment.gd": "f6004ab1601e2910268dd5d02923c32eb697fb587ad74da1f06ca4de6c21713c", + "tests/s3/test_staff_payroll_operations.gd": "05c817b7e891ac1649072ad33102561e26280b9bdd34f6f3831c7da055044f8c", + "tests/s3/test_staff_service.gd": "1f41ec1dc91f3bd3739ad2672b2921070fdde3a2878bc55ec6bd62101f0ac926", + "tests/s4/test_placement_risk_preview_service.gd": "165eddf9a5c51d8d9eeb85ea89e21b1ab6067b7ee057ee0fe5b189e97a71c1ad", + "tests/s4/test_relationship_event_service.gd": "b65afa74a67985059cc9e8e19684405324cfcdde277a21d7eba6062c968b24c5", + "tests/s4/test_runtime_relationship_events.gd": "52c9df100bd85fc49b62b80d214f84750c907330255a909a83a8dbd8195aac39", + "tests/s5/test_authority_service.gd": "983ac275dc4f40f7c8058831b7239f1f4b4e2b3f12da0bbb6ea5e8ad69afe2c5", + "tests/s5/test_defense_service.gd": "c6ab9eff8bd54a26a4b0f7ba1e95817ce03f8a8d83c15c3ca3fff450effd4db1", + "tests/s5/test_runtime_authority_cycle.gd": "33952b6253c77410c7997ddbf37e039f79bcc0ae283da76c3580ec166f928273", + "tests/s5/test_runtime_defense_cycle.gd": "ad69856ca5d0ec0fc15e4dea65d04cd1069880613d7fa901b0a787aafbc5ddbf", + "tests/s6/test_performance_budget.gd": "1ae5490637d73b519bf9ed0e00bda0e034d5ea0e589493bcf8886e4d1ff63689", + "tests/s6/test_runtime_save_lifecycle.gd": "de94d588d6ce204aa8d0221c2989b8dd900cf4e36ca51f83b464518a382a887f", + "tests/s6/test_save_operations.gd": "199df0b7fcc3d212cd32c79e0108dd9e5b83f9895f07553b325571c7674ed96c", + "tests/staff/test_rescue_elevator.gd": "bbbab6c56b740b5b9d7ccf092152a037d33c597c9af5dcf2125b54ef3282abe3", + "tests/staff/test_shift_rush.gd": "af3d47d848c1220741992612e57736df4af05969d424b79829d9977c774e8149", + "tests/staff/test_staff_commute.gd": "0837c92a49f8f83c03ca820eaeb23f7b1dfbc1072f8eeffcb557bb6b57b20c49", + "tests/staff/test_staff_rescue.gd": "8d8c02444c35b2c41c6db42ac0195dd614835541235cd92641ca9b220f59c44c", + "tests/staff/test_staff_rest.gd": "fe9b9d2a8a9051850980a393b8c06b8be41de8a82d5efc998c69df3a06cf54ec", + "tests/supply/test_normal_supply_chain.gd": "b481f2ae38c98eac78bd310902a85ba47e5d1d442078944a968ab3ea15f6d142", + "tests/supply/test_supply_logistics_bridge.gd": "dc745130fe155e27868a281e3f6a1b68afafaa5ba0b36723ef1b6bc49f388611", + "tests/supply/test_supply_management_service.gd": "de70213e9a07cb461614114e55d3b5371249a36250222a6eb48654103faea3ce", + "tests/supply/test_supply_save_migration.gd": "f781d2657fa7f004fa6efa9bfe3e829170c27ed269391e844f6a527935586831", + "tests/transport/test_elevator_service.gd": "e70de48a8e6f7928690d0495110451c26d9dec180c2dd7033c793155f099c2f6", + "tests/transport/test_runtime_stairs.gd": "f2f2d87dcf20d08fdfa721985fe60eecbaabbd0b2d1f5a90971f859f4354c3b9", + "tests/transport/test_runtime_transport.gd": "8b1d0fba1d27fbae2c703e030144bbff0ca008dca5292b7ea07f80e9a73bf96a", + "tests/transport/test_runtime_transport_comparison.gd": "ba577397fa8db5a6cf92cfd398dd7a5ece49e6b59856f14772b67b51ddc0bde9", + "tests/transport/test_transport_boundaries.gd": "1a5a647c45ffab674a8f58b8bc72692c5a30090c9b99a1e6f67194bbe3586b34", + "tests/transport/test_transport_comparison.gd": "7e80e9ef80500278c56f0d1ada5c911f43e3c0a9bc370045fdbebd33c2f02fd5", + "tests/transport/test_transport_density_migration.gd": "b30bf5c58b567ca711be9d9f130b37e114c22c7c69d5b7b581ad8dda2a690b50", + "tests/transport/test_transport_save.gd": "c09d4c8abcd2af39eea173c1f8a8789ca6a3f262953f65372469af29eef6a3d5", + "tests/ui/capture_main_dashboard.gd": "dcaeb97ef2f8a5910d2e8b5eb8aa7f6bf5a5a4d9128e0afab3e14eb16100117c", + "tests/ui/capture_pixel_tower.gd": "3a2eabc841a3208a14b06d2a619c89c0294c72538e0f009fec1b18d1448b998d", + "tests/ui/test_business_management.gd": "182d6869b37a2643cdc4e4cb5c7eca95ed9fb36853a2069d630379ba5643d7f7", + "tests/ui/test_elevator_management.gd": "1d7ed05d39f34d91f2ef485f3930de26e7136224f07ff9fec1b768726a365d68", + "tests/ui/test_first_operating_day.gd": "7a8d8e101e8e93cfe50c680b8c49f4295b2455321facdf9b959db9de244536ce", + "tests/ui/test_guest_observation.gd": "d163141420a04856730d3819efca9dce0aad7c2126866a82664120bc68ca272f", + "tests/ui/test_main_dashboard.gd": "34be7dbfd3878c8090aaaf0e069dd0f07bf9ead2eb7928e9cb37c085bfa4c0ad", + "tests/ui/test_original_facility_controls.gd": "7aed4ad0564e3690f64bc3ab3149e6b36df6ccd8bea0530d1ecd8dd1f87fb576", + "tests/ui/test_pixel_tower.gd": "93bdbb195f2ceec07dbe052e0d0a3017d84b56f1e8ec46b87be8d98105ec0f88", + "tests/ui/test_room_presentation.gd": "f7cc6e96c490b0f9b258e8ca087beaae4f540fe4c02c6a04a6460c8503a22be5", + "tests/ui/test_staff_management.gd": "f22b87155ede7d2f33f15331d79ec2af699e163f04444d270346f911f355ac53", + "tests/ui/test_staff_rescue.gd": "2ab4a58ef7cce5d1b2e03250dbe92ed6957f38c71e538cdc4f425fa198193bc8", + "tests/ui/test_staff_rest.gd": "accfc9c0ee2eed5192085d01d19bbdce65f6171551b22e5a0b564f68c7080eaa", + "tests/ui/test_stair_transport_details.gd": "d8b1539cf81bcf1105ad66fea5236e779e03fdf1eb59168e910412ec1da797b2", + "tests/ui/test_supply_management.gd": "c24f98fc9a9329c7f2983b3048009fba2169c45ea413137ca2fbebee802b9c35", + "tests/world/test_elevator_topology.gd": "4710a6bba103d82a4d2febea1a2c30d029c0f1ccd2a3d9a6c1712b4fb372033b", + "tests/world/test_legacy_stairs.gd": "258d759537aec8b7b90cab107985c9c6a16f10e85ae7ae92a9c54cc94417d48e", + "tests/world/test_path_transit_density.gd": "8de5fc8bdf8631ff41fcd4862cea40daa3316040f381f182dc6fe09dba9f3cb9", + "tests/world/test_path_vertical_policy.gd": "140cdf552309f89cd93bf1a4d38827a671a66461203f0858f1c2651a8e6e8473", + "tests/world/test_stair_infrastructure.gd": "f6d1a29b43875b7b1fc945611c2ed15fbdcb8bb7220addf283b30fedf2ccba28" +} diff --git a/docs/traceability/original/baseline_metadata.json b/docs/traceability/original/baseline_metadata.json new file mode 100644 index 0000000..0f53ebe --- /dev/null +++ b/docs/traceability/original/baseline_metadata.json @@ -0,0 +1,11 @@ +{ + "file_id": "1w3hpz3kacxo1jRDwDhDT0TzNRJtAo-LW", + "file_name": "基本仕様書.txt", + "url": "https://drive.google.com/file/d/1w3hpz3kacxo1jRDwDhDT0TzNRJtAo-LW/view", + "drive_modified_utc": "2026-09-22T02:14:53.827Z", + "drive_size_bytes": 23494, + "captured_date_jst": "2026-09-22", + "snapshot_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "snapshot_note": "Drive connector readable text snapshot; not claimed to be byte-identical raw download", + "implementation_commit": null +} diff --git a/docs/traceability/original/historical_requirements_2026-09-22.json b/docs/traceability/original/historical_requirements_2026-09-22.json new file mode 100644 index 0000000..d7678cf --- /dev/null +++ b/docs/traceability/original/historical_requirements_2026-09-22.json @@ -0,0 +1,2005 @@ +[ + { + "id": "B-1.1", + "section": "1.1", + "requirement": "基本情報・Windows・2D・20〜30時間/50時間以上", + "status": "一部実装", + "evidence": [ + { + "path": "project.godot", + "line": 1 + }, + { + "path": "export_presets.cfg", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [ + "tests/ui/test_main_dashboard.gd" + ], + "finding": "Windows export設定と2D画面あり。クリア時間、全エンド、図鑑完成の証拠なし。Windows配布ZIPは本ソースとの同一性未確認。", + "acceptance": "Windows実機起動と全ルートのプレイ時間計測", + "source_lines": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ] + }, + { + "id": "B-1.2", + "section": "1.2", + "requirement": "地上/地下の二面性経営・観察介入・権威/富", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/mvp/test_runtime_content_progression.gd" + ], + "finding": "ZoneとLayer、Rankはある。覗き・物語・高さと権威/深さと富の全効果は未接続。", + "acceptance": "両領域の経営差と介入を通しプレイで検証", + "source_lines": [ + 11, + 12, + 13, + 14 + ] + }, + { + "id": "B-1.3", + "section": "1.3", + "requirement": "建設→集客→収益→管理→拡張防衛", + "status": "一部実装", + "evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/mvp/test_runtime_operations_acceptance.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "finding": "運営縦断と防衛基盤あり。脅迫・売買・全施設を含む原本のループは未完成。", + "acceptance": "原本に沿った建設から防衛までの受入シナリオ", + "source_lines": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ] + }, + { + "id": "B-1.4", + "section": "1.4", + "requirement": "二層・自律AI・渋滞・信仰と依存", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103 + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + }, + { + "path": "scripts/world/path_service.gd", + "line": 463 + } + ], + "related_tests": [ + "tests/s3/test_runtime_path_traffic.gd" + ], + "finding": "基礎システムあり。昇降機統合、依存の最終処理、Faithに連動する本編は不足。", + "acceptance": "二層/渋滞/信仰/依存の統合試験", + "source_lines": [ + 23, + 24, + 25, + 26 + ] + }, + { + "id": "B-1.5", + "section": "1.5", + "requirement": "世界観・主人公・7ヒロイン3運命", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "data/characters/mvp_unique_characters.json", + "line": 1 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [], + "finding": "収録ユニークはアンリとライのみ。7ヒロインの本編・エンド実装を確認できない。", + "acceptance": "7名全員の加入・3ルートと主人公本編を確認", + "source_lines": [ + 27, + 28, + 29, + 30 + ] + }, + { + "id": "B-1.6", + "section": "1.6", + "requirement": "成人向けルートと経営的搾取", + "status": "一部実装", + "evidence": [ + { + "path": "data/facilities/adult_private_room.tres", + "line": 1 + }, + { + "path": "scripts/events/event_service.gd", + "line": 1 + } + ], + "related_tests": [], + "finding": "成人向け営業と表現フィルタの基盤あり。原本指定のルート内容・覗き・脅迫・売買は未完成。", + "acceptance": "対象機能と分岐のシナリオ検証", + "source_lines": [ + 31, + 32, + 33, + 34 + ] + }, + { + "id": "B-2.1", + "section": "2.1", + "requirement": "グリッド・Y正負・支持・領域拡張", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103 + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56 + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd" + ], + "finding": "座標、支持、Rank拡張あり。支持Component等の追加制約は原本の単純隣接と同等か要確認。", + "acceptance": "Y=0/±1、孤立/隣接、Rank前後の建設境界を検証", + "source_lines": [ + 36, + 37, + 38, + 39 + ] + }, + { + "id": "B-2.2", + "section": "2.2", + "requirement": "同座標Front/Back・半透明/ワイヤー表示", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [ + "tests/s1/test_world_service.gd", + "tests/ui/test_main_dashboard.gd" + ], + "finding": "二層データと切替は存在。描画は非選択層を除外し、指定の透過/ワイヤー比較はない。", + "acceptance": "同座標の2施設と非選択層表示を確認", + "source_lines": [ + 40, + 41, + 42, + 43, + 44 + ] + }, + { + "id": "B-2.3", + "section": "2.3", + "requirement": "客エリアと景観/清潔/騒音の満足度効果", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_definition.gd", + "line": 1 + }, + { + "path": "scripts/facility/facility_service.gd", + "line": 46 + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + } + ], + "related_tests": [ + "tests/s2/test_facility_service.gd" + ], + "finding": "環境フィールドと施設状態あり。3環境値を空間計算して客満足へ結ぶ完全な処理は未確認。", + "acceptance": "環境値だけを変えた対照試験で満足度差を確認", + "source_lines": [ + 45, + 46, + 47 + ] + }, + { + "id": "B-2.4", + "section": "2.4", + "requirement": "バックヤード・職員ドア・客通行禁止", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/facility_infrastructure_projector.gd", + "line": 41 + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s1/test_runtime_infrastructure_integration.gd" + ], + "finding": "通路と職員専用ドアは投影済み。リネンシュート・隠し扉など全設備はない。", + "acceptance": "客の跨層拒否、職員通行、全設備の動作を確認", + "source_lines": [ + 48, + 49, + 50 + ] + }, + { + "id": "B-2.5", + "section": "2.5", + "requirement": "覗き部屋の配置制約・通路寸断・証拠収集", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [], + "finding": "対応施設、専用画面、記録係による収集ループなし。AuthorityのEvidenceは代替証拠にしない。", + "acceptance": "対象施設の裏限定建設と経路寸断、専用表示、証拠収集", + "source_lines": [ + 51, + 52, + 53 + ] + }, + { + "id": "B-2.6", + "section": "2.6", + "requirement": "地上/地下の採光・マナ・遮音・隠蔽ボーナス", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103 + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 1 + }, + { + "path": "data/characters/mvp_species.json", + "line": 1 + } + ], + "related_tests": [], + "finding": "Zone/嗜好/隠蔽のデータはある。原本の4ボーナスの統合効果は未確認。", + "acceptance": "同条件でZoneだけを変える比較試験", + "source_lines": [ + 54, + 55, + 56 + ] + }, + { + "id": "B-3.1a", + "section": "3.1a", + "requirement": "10秒=1時間、Pause/x1/x2/x3", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28 + }, + { + "path": "scenes/main/main.gd", + "line": 1232 + } + ], + "related_tests": [ + "tests/run_tests.gd" + ], + "finding": "実装は30秒=1時間、速度1/2/4。原本へ合わせる修正が必要。", + "acceptance": "10秒の入力で60分進行、x3受理・x4不受理を検証", + "source_lines": [ + 58, + 59, + 60 + ] + }, + { + "id": "B-3.1b", + "section": "3.1b", + "requirement": "アクティブポーズ中も建設・指示", + "status": "一部実装", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_main_dashboard.gd" + ], + "finding": "停止とCommandの基盤あり。全指示が時刻を進めず受理される原本受入を別途必要とする。", + "acceptance": "停止中建設/配置指示、時刻不変、再開後一度だけ反映", + "source_lines": [ + 58, + 59, + 60 + ] + }, + { + "id": "B-3.2", + "section": "3.2", + "requirement": "4時間帯と朝下り/夕上り/夜行動", + "status": "一部実装", + "evidence": [ + { + "path": "autoload/game_clock.gd", + "line": 1 + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 25 + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + } + ], + "related_tests": [ + "tests/mvp/test_operations_coordinator.gd" + ], + "finding": "06/10/16/20時の区分は一致。各ラッシュと夜間関係イベントまでの統合は未完成。", + "acceptance": "時間帯境界と行動/混雑分布の受入", + "source_lines": [ + 61, + 62, + 63, + 64, + 65 + ] + }, + { + "id": "B-3.3", + "section": "3.3", + "requirement": "昼夜シフト・体力0気絶・救護・通勤混雑", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + }, + { + "path": "scripts/staff/shift_instance.gd", + "line": 1 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_runtime_actor_cycle.gd" + ], + "finding": "勤務・休憩・移動とfatigueがある。Stamina=0→救護搬送→目撃評判低下の一連処理なし。", + "acceptance": "06/18時交代と体力0救護の実経路試験", + "source_lines": [ + 66, + 67, + 68, + 69 + ] + }, + { + "id": "B-3.4a", + "section": "3.4a", + "requirement": "毎日00:00に決算レポート", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 25 + }, + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28 + } + ], + "related_tests": [ + "tests/mvp/test_runtime_operations_acceptance.gd" + ], + "finding": "Clockの00時signalはあるが運営締めは06時。signalの存在を日次決算実装とみなさない。", + "acceptance": "23:59→00:00で一度締め、06時に再締めしない", + "source_lines": [ + 70, + 71, + 72 + ] + }, + { + "id": "B-3.4b", + "section": "3.4b", + "requirement": "3回連続赤字でゲームオーバー", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 25 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s3/test_staff_payroll_operations.gd" + ], + "finding": "判定は損益赤字でなくarrears_total>0の連続回数。現金余裕のある赤字と未払発生を区別する必要。", + "acceptance": "十分な現金+3日損失でもゲームオーバーになることを検証", + "source_lines": [ + 70, + 71, + 72 + ] + }, + { + "id": "B-3.5", + "section": "3.5", + "requirement": "雨・嵐・マナの霧・流行ニュース", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 25 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "scenes/main/main.gd", + "line": 1232 + } + ], + "related_tests": [], + "finding": "需要更新はあるが天候状態、天候効果、流行配信の対応処理なし。", + "acceptance": "雨の滞在延長、嵐の新規客0、霧効果、流行売上比較", + "source_lines": [ + 73, + 74, + 75 + ] + }, + { + "id": "B-4.1", + "section": "4.1", + "requirement": "所持金/戦利品・四欲求・忍耐/清潔/依存", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 1 + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + } + ], + "related_tests": [ + "tests/s3/test_guest_service.gd" + ], + "finding": "財布/信用/依存/忍耐あり。欲求は6種へ拡張。汚れ伝播と戦利品の客固有循環は未完成。", + "acceptance": "原本4欲求と戦利品/清潔伝播を個別検証", + "source_lines": [ + 77, + 78, + 79, + 80 + ] + }, + { + "id": "B-4.2", + "section": "4.2", + "requirement": "最大欲求優先と距離/行列/価格選択", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + } + ], + "related_tests": [ + "tests/s3/test_guest_service.gd" + ], + "finding": "距離・行列・価格を含むUtility方式。最高欲求を最優先する二段階選択は保証されず、品質/嗜好等も加算。", + "acceptance": "最高欲求と高品質の別施設が競合するケースで原本優先順位を検証", + "source_lines": [ + 81, + 82, + 83 + ] + }, + { + "id": "B-4.3a", + "section": "4.3a", + "requirement": "A*とエレベーター待ち時間コスト", + "status": "実装確認", + "evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 463 + } + ], + "related_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd" + ], + "finding": "A*、待機コスト、容量付きQueueをService層で確認。建設統合は別行。", + "acceptance": "Service試験に加え施設建設から同経路を使用する受入", + "source_lines": [ + 84, + 85, + 86, + 87 + ] + }, + { + "id": "B-4.3b", + "section": "4.3b", + "requirement": "4種垂直移動のゲーム内利用", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 463 + }, + { + "path": "scripts/world/facility_infrastructure_projector.gd", + "line": 41 + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s1/test_facility_infrastructure_projector.gd" + ], + "finding": "EV基盤/定義は存在するがProjectorはUNSUPPORTED_VERTICAL_TOPOLOGYを返す。通常上下隣接辺も生成。階段/ワープの正式施設なし。", + "acceptance": "EV建設完了→乗降→容量待ち、通常上下歩行禁止、階段/ワープ差分を検証", + "source_lines": [ + 84, + 85, + 86, + 87 + ] + }, + { + "id": "B-4.3c", + "section": "4.3c", + "requirement": "重なり時速度50%低下", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 463 + } + ], + "related_tests": [ + "tests/s1/test_path_congestion.gd" + ], + "finding": "capacity/densityの可変倍率、最低0.25。重なりで一律0.5ではない。", + "acceptance": "2ユニット重なり等の境界で0.5になることを検証", + "source_lines": [ + 84, + 85, + 86, + 87 + ] + }, + { + "id": "B-4.4", + "section": "4.4", + "requirement": "依存Lv3退店拒否・装備売却・性別別破産", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [], + "finding": "依存値と予備費解放はあるが、原本指定の退店拒否・装備換金・暴動/拘束処理なし。", + "acceptance": "依存Lv3、財布0、装備あり/なしで終端処理を検証", + "source_lines": [ + 88, + 89, + 90 + ] + }, + { + "id": "B-4.5", + "section": "4.5", + "requirement": "貴族/聖職者/エルフ/魔族のAI特性", + "status": "一部実装", + "evidence": [ + { + "path": "data/characters/mvp_guest_classes.json", + "line": 1 + }, + { + "path": "data/characters/mvp_species.json", + "line": 1 + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + } + ], + "related_tests": [ + "tests/mvp/test_mvp_content_catalog.gd" + ], + "finding": "貴族の高予算/低忍耐、種族嗜好あり。聖職者の目撃即退店/風紀低下等は未確認。", + "acceptance": "種族/職業ごとに原本の特殊行動を発生させる", + "source_lines": [ + 91, + 92, + 93, + 94, + 95 + ] + }, + { + "id": "B-5.1", + "section": "5.1", + "requirement": "一般雇用と奴隷の費用/耐性/売却区別", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 1 + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + } + ], + "related_tests": [ + "tests/s3/test_staff_service.gd" + ], + "finding": "EmploymentKindはFREE_HIRE/ADULT_CONTRACT/UNIQUE。原本の奴隷区分と食費のみ/売買システムに一致しない。", + "acceptance": "原本区分ごとの費用・耐性・処分仕様の実装", + "source_lines": [ + 97, + 98, + 99 + ] + }, + { + "id": "B-5.2", + "section": "5.2", + "requirement": "体力/ストレス/忠誠/技能/ランダム特性", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 1 + }, + { + "path": "data/characters/mvp_staff_candidates.json", + "line": 1 + } + ], + "related_tests": [ + "tests/s3/test_staff_service.gd" + ], + "finding": "stress/loyalty/4技能、health/fatigueあり。固定候補20名でランダム特性生成はない。", + "acceptance": "原本パラメータ対応、生成seedと特性効果の試験", + "source_lines": [ + 100, + 101, + 102, + 103 + ] + }, + { + "id": "B-5.3", + "section": "5.3", + "requirement": "接客/裏方/救護/防衛等の職種", + "status": "一部実装", + "evidence": [ + { + "path": "data/characters/mvp_staff_candidates.json", + "line": 1 + }, + { + "path": "scripts/staff/task_dispatcher.gd", + "line": 1 + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + } + ], + "related_tests": [ + "tests/s3/test_staff_service.gd" + ], + "finding": "役割タグとTask配賦はある。救護搬送/調教等の全職種業務は未確認。", + "acceptance": "全職種を配置し固有業務の完了を確認", + "source_lines": [ + 104, + 105, + 106, + 107 + ] + }, + { + "id": "B-5.4", + "section": "5.4", + "requirement": "シフト休息と気絶回収", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s3/test_runtime_actor_cycle.gd" + ], + "finding": "シフトと非番fatigue回復あり。気絶者の救護班による裏ルート搬送不足。", + "acceptance": "夜跨ぎシフトと救護経路切断ケースを検証", + "source_lines": [ + 108, + 109, + 110 + ] + }, + { + "id": "B-5.5", + "section": "5.5", + "requirement": "求人/市場/破産捕獲・退職金/売買価格", + "status": "一部実装", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + }, + { + "path": "data/characters/mvp_staff_candidates.json", + "line": 1 + } + ], + "related_tests": [ + "tests/s3/test_staff_payroll_operations.gd" + ], + "finding": "候補雇用・退職/未払基盤あり。市場・捕獲・原本退職金・売却と忠誠リスクは不足。", + "acceptance": "各獲得/処分と台帳/忠誠の変化を確認", + "source_lines": [ + 111, + 112, + 113 + ] + }, + { + "id": "B-6.1", + "section": "6.1", + "requirement": "ユニーク無料・高成長・解雇売却不可", + "status": "一部実装", + "evidence": [ + { + "path": "data/characters/mvp_unique_characters.json", + "line": 1 + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/mvp/test_mvp_content_catalog.gd" + ], + "finding": "unique/unsellableとUNIQUE区分あり。全人物運用・成長上限・禁止操作のUI受入は不足。", + "acceptance": "7名の無料運用と解雇/売却拒否、成長試験", + "source_lines": [ + 115, + 116 + ] + }, + { + "id": "B-6.2", + "section": "6.2", + "requirement": "7ヒロインの指定固有効果とライバル", + "status": "設計差異", + "evidence": [ + { + "path": "data/characters/mvp_unique_characters.json", + "line": 1 + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/mvp/test_mvp_content_catalog.gd" + ], + "finding": "アンリは売上+20%でなく忍耐減少倍率9000bp。フィーネ/トマリ/アメリア/リズ/ミミ/サツキ、リーゼンバルト/ソウの定義なし。", + "acceptance": "原本の全人物と各倍率/固有効果を対照試験", + "source_lines": [ + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125 + ] + }, + { + "id": "B-6.3", + "section": "6.3", + "requirement": "基礎技能と業務EXP・Lv99", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 1 + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + } + ], + "related_tests": [], + "finding": "技能あり。EXP/Lv99成長ループなし。", + "acceptance": "業務EXP加算とLv99上限、保存復元", + "source_lines": [ + 126, + 127, + 128 + ] + }, + { + "id": "B-6.4", + "section": "6.4", + "requirement": "配置パッシブ・任意発動・ルート別スキル", + "status": "一部実装", + "evidence": [ + { + "path": "data/characters/mvp_unique_characters.json", + "line": 1 + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 1 + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223 + } + ], + "related_tests": [], + "finding": "効果メタデータ/防衛/ルートロック基盤あり。原本の固有スキルとルート別習得は未完成。", + "acceptance": "配置/発動/ルート変更の各スキル効果を検証", + "source_lines": [ + 129, + 130, + 131, + 132 + ] + }, + { + "id": "B-6.5", + "section": "6.5", + "requirement": "24時間勤務警告・衣装補正・限定離脱", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/staff/staff_service.gd", + "line": 1100 + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/s5/test_runtime_defense_cycle.gd" + ], + "finding": "残業/捕虜状態等の基盤あり。衣装、専用警告、ヒロイン別バッドエンド変化はない。", + "acceptance": "24時間勤務と限定離脱/復帰、衣装補正を確認", + "source_lines": [ + 133, + 134, + 135 + ] + }, + { + "id": "B-7.1", + "section": "7.1", + "requirement": "三感情・盾・決闘の3ルート運命", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223 + }, + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15 + } + ], + "related_tests": [ + "tests/s4/test_relationship_event_service.gd" + ], + "finding": "盾とルートロックのService基盤。原本モデル/決闘/本編は未完成。", + "acceptance": "3ルート各1本の端から端までの試験", + "source_lines": [ + 137, + 138 + ] + }, + { + "id": "B-7.2", + "section": "7.2", + "requirement": "好感/淫乱/NTR・好感度=盾最大・淫乱軽減", + "status": "設計差異", + "evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15 + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223 + } + ], + "related_tests": [ + "tests/s4/test_relationship_event_service.gd" + ], + "finding": "metricsはtrust/respect/affection/fear/dependence。shield_maxは独立設定、Lust/NTRモデルではない。", + "acceptance": "原本3軸、好感=最大盾、淫乱による防御低下を検証", + "source_lines": [ + 139, + 140, + 141, + 142, + 143 + ] + }, + { + "id": "B-7.3", + "section": "7.3", + "requirement": "同配置接触→盾破壊→NTR上昇→不可逆陥落", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223 + }, + { + "path": "scripts/relationship/placement_risk_preview_service.gd", + "line": 1 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s4/test_placement_risk_preview_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "finding": "攻撃Command/閾値Event/Previewはある。実配置接触の自動発生と原本NTR遷移は未統合。", + "acceptance": "同施設/同Shiftのみ接触し、盾破壊前後と不可逆境界を検証", + "source_lines": [ + 144, + 145, + 146, + 147, + 148 + ] + }, + { + "id": "B-7.4", + "section": "7.4", + "requirement": "3種決闘・勝利追放/隷属/盾回復・敗北確定", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223 + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 1 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [], + "finding": "防衛戦や一般Eventは決闘そのものではない。3種の決闘進行/結果処理なし。", + "acceptance": "3種類×勝敗の6ケースと不可逆な結果保存", + "source_lines": [ + 149, + 150, + 151, + 152, + 153 + ] + }, + { + "id": "B-7.5", + "section": "7.5", + "requirement": "会話/デート/隔離・覗き・薬による各ルート", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223 + }, + { + "path": "scripts/events/event_service.gd", + "line": 1 + }, + { + "path": "scenes/main/main.gd", + "line": 1232 + } + ], + "related_tests": [ + "tests/s4/test_runtime_relationship_events.gd" + ], + "finding": "盾回復API/選択肢はある。指定プレイヤー行動と原本ルート全体は未接続。", + "acceptance": "各行動の入口/消費/数値/ルート終端を確認", + "source_lines": [ + 154, + 155, + 156, + 157 + ] + }, + { + "id": "B-7.6", + "section": "7.6", + "requirement": "履歴・妊娠/父親・刻印立ち絵", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "該当状態・更新ループ・立ち絵差分の接続なし。", + "acceptance": "履歴と周期/父親判定、外見更新、保存復元", + "source_lines": [ + 158, + 159, + 160, + 161 + ] + }, + { + "id": "B-8.1", + "section": "8.1", + "requirement": "隣接/通路必須とLv拡張", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/construction/construction_service.gd", + "line": 56 + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 1 + }, + { + "path": "scripts/facility/facility_service.gd", + "line": 46 + } + ], + "related_tests": [ + "tests/s1/test_construction_service.gd" + ], + "finding": "配置/接続検証とmax_level/upgrade_costsあり。通常UIからの施設LvUP全体は未確認。", + "acceptance": "Front/Back接続別とLvUP操作→コスト→解放", + "source_lines": [ + 163, + 164, + 165 + ] + }, + { + "id": "B-8.2", + "section": "8.2", + "requirement": "表層全施設・飲食と厨房セット", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + }, + { + "path": "scripts/facility/facility_service.gd", + "line": 46 + }, + { + "path": "scripts/inventory/inventory_service.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/s2/test_runtime_operations_cycle.gd" + ], + "finding": "一般客室/食堂/浴場等あり。全施設は施設照合表参照。厨房在庫連携と真裏必須配置は別要件。", + "acceptance": "全施設の営業、厨房未接続時拒否/停止、VIP景観条件", + "source_lines": [ + 166, + 167, + 168, + 169, + 170 + ] + }, + { + "id": "B-8.3", + "section": "8.3", + "requirement": "裏層全施設と特殊設備", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s1/test_runtime_infrastructure_integration.gd" + ], + "finding": "待機室/倉庫/厨房/警備詰所あり。寮は拡張定義のみ。覗き/牢/炉等なし。", + "acceptance": "全設備の建設と専用効果", + "source_lines": [ + 171, + 172, + 173, + 174 + ] + }, + { + "id": "B-8.4", + "section": "8.4", + "requirement": "EV/階段/ワープ/通路/職員ドア", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/world/facility_infrastructure_projector.gd", + "line": 41 + }, + { + "path": "scripts/world/path_service.gd", + "line": 463 + } + ], + "related_tests": [ + "tests/s1/test_runtime_infrastructure_integration.gd" + ], + "finding": "通路/職員ドアは統合。垂直設備はB-4.3bの欠落。", + "acceptance": "客/職員権限と全垂直設備のゲーム内通行", + "source_lines": [ + 175, + 176, + 177 + ] + }, + { + "id": "B-8.5", + "section": "8.5", + "requirement": "Lv解放の価格/食材/混浴/ハード/イカサマ設定", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_definition.gd", + "line": 1 + }, + { + "path": "scripts/facility/facility_service.gd", + "line": 46 + }, + { + "path": "scenes/main/main.gd", + "line": 1232 + } + ], + "related_tests": [ + "tests/s2/test_facility_service.gd" + ], + "finding": "Policy schema/Snapshot基盤はある。列挙設定のLv解放・編集UI・各効果は未完成。", + "acceptance": "Lv前後の操作解放と全Policyの収支/行動差", + "source_lines": [ + 178, + 179 + ] + }, + { + "id": "B-9.1", + "section": "9.1", + "requirement": "表金/裏金・マナ全館停止・資材", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/economy/economy_service.gd", + "line": 1 + }, + { + "path": "scripts/mana/mana_network_service.gd", + "line": 1 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s2/test_ledger_backed_resources.gd", + "tests/s2/test_mana_network_service.gd" + ], + "finding": "台帳/Gold/Materials/Mana供給あり。RuntimeはPLAYER_CASH単一口座と売上カテゴリで、表裏資金分離は未確認。優先供給/個別Brownoutは全館停止と別に照合が必要。", + "acceptance": "表裏収支分離とMana=0で全施設停止を受入", + "source_lines": [ + 181, + 182, + 183, + 184 + ] + }, + { + "id": "B-9.2", + "section": "9.2", + "requirement": "全収益/人件費/維持/仕入/借金/ランク税", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/economy/economy_service.gd", + "line": 1 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s2/test_runtime_operations_cycle.gd", + "tests/s3/test_staff_payroll_operations.gd" + ], + "finding": "通常売上/賃金等は接続。taxes_and_interest=0の経路があり、脅迫/売買/銀行/上納金は不足。", + "acceptance": "列挙項目の全取引を日次レポートへ計上", + "source_lines": [ + 185, + 186, + 187, + 188 + ] + }, + { + "id": "B-9.3", + "section": "9.3", + "requirement": "受付/金庫/購買/闇銀行", + "status": "一部実装", + "evidence": [ + { + "path": "data/facilities/reception.tres", + "line": 1 + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/s2/test_facility_service.gd" + ], + "finding": "受付あり。購買相当の売店は拡張定義。所持上限金庫、戦利品買取、融資/滞納襲来は未完成。", + "acceptance": "各施設の固有金融効果と破壊襲来を検証", + "source_lines": [ + 189, + 190, + 191, + 192, + 193 + ] + }, + { + "id": "B-9.4", + "section": "9.4", + "requirement": "依存客の娯楽優先・信用超過の破産拘束", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/guest/guest_service.gd", + "line": 461 + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 1 + } + ], + "related_tests": [ + "tests/s3/test_guest_service.gd" + ], + "finding": "依存75以上で生存予備費0、信用判定あり。信用超過→原本の代物弁済イベント/投獄なし。", + "acceptance": "依存境界の予算と信用超過から拘束までを検証", + "source_lines": [ + 194, + 195, + 196 + ] + }, + { + "id": "B-10.1", + "section": "10.1", + "requirement": "宿泊施設の寸法/層/ゾーン", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 198, + 199 + ] + }, + { + "id": "B-10.2", + "section": "10.2", + "requirement": "飲食施設の寸法/層/ゾーン", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 200, + 201 + ] + }, + { + "id": "B-10.3", + "section": "10.3", + "requirement": "衛生/娯楽施設の寸法/層/ゾーン", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 202, + 203 + ] + }, + { + "id": "B-10.4", + "section": "10.4", + "requirement": "アングラ施設の寸法/層/ゾーン", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 204, + 205 + ] + }, + { + "id": "B-10.5", + "section": "10.5", + "requirement": "バックヤード施設の寸法/層/ゾーン", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 206, + 207 + ] + }, + { + "id": "B-10.6", + "section": "10.6", + "requirement": "特殊施設の寸法/層/ゾーン", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 208, + 209 + ] + }, + { + "id": "B-10.7", + "section": "10.7", + "requirement": "インフラ/防衛施設の寸法/層/ゾーン", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 9 + } + ], + "related_tests": [ + "tests/s0/test_definition_registry.gd" + ], + "finding": "施設別表で個別照合。定義一致は営業/建設統合の完成を意味しない。", + "acceptance": "個別寸法・層・ゾーン・特殊効果の受入", + "source_lines": [ + 210, + 211 + ] + }, + { + "id": "B-11.1", + "section": "11.1", + "requirement": "資源/時間/天候/Rank・下部切替・スクロールズーム", + "status": "一部実装", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [ + "tests/ui/test_main_dashboard.gd" + ], + "finding": "資源/時刻/下部Front Back/建設UIあり。天候・上部Rankの常設表示とマップのパン/ズーム操作なし。", + "acceptance": "天候/Rank表示とホイールズーム/パン/層比較を実画面確認", + "source_lines": [ + 213, + 214, + 215, + 216 + ] + }, + { + "id": "B-11.2", + "section": "11.2", + "requirement": "Policy/ドラッグ配置・欲求/思考/感情グラフ/盾", + "status": "一部実装", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [ + "tests/ui/test_main_dashboard.gd" + ], + "finding": "施設状態と料金/人員の表示はある。編集/ドラッグ配置、ユニット詳細、3軸グラフはない。", + "acceptance": "指定インスペクター操作とリアルタイム数値表示", + "source_lines": [ + 217, + 218, + 219 + ] + }, + { + "id": "B-11.3", + "section": "11.3", + "requirement": "カメラ入口・覗きアングル/撮影/介入", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "対応入力、画面、撮影と証拠連携なし。", + "acceptance": "カメラ入口から撮影/介入までの画面受入", + "source_lines": [ + 220, + 221, + 222 + ] + }, + { + "id": "B-11.4", + "section": "11.4", + "requirement": "グリッド/接続警告・フキダシ/ログ・カットイン", + "status": "一部実装", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + } + ], + "related_tests": [ + "tests/ui/test_main_dashboard.gd" + ], + "finding": "配置Previewと失敗理由、待機Badge、Event選択UIあり。指定フキダシ/接続線/重大カットインは不足。", + "acceptance": "指定3通知と決闘/侵食/防衛演出を確認", + "source_lines": [ + 223, + 224, + 225, + 226 + ] + }, + { + "id": "B-12.1", + "section": "12.1", + "requirement": "二面性・高精細ドットとアニメ立ち絵", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "背景/人物/施設アイコンあり。高精細アニメーション/本編立ち絵の製品品質は未達・目視未評価。", + "acceptance": "正式素材の全画面レビュー", + "source_lines": [ + 228, + 229, + 230 + ] + }, + { + "id": "B-12.2", + "section": "12.2", + "requirement": "魔法レンズ風の波紋切替", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "layer_mode切替と再描画。波紋Shader/遷移なし。", + "acceptance": "表裏連続切替の波紋と操作応答を確認", + "source_lines": [ + 231, + 232 + ] + }, + { + "id": "B-12.3", + "section": "12.3", + "requirement": "SD各モーション・立ち絵破損/汚れ/刻印差分", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 230 + }, + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + } + ], + "related_tests": [], + "finding": "静的Marker Atlasはあるが指定アニメ/立ち絵差分なし。", + "acceptance": "全モーションとルート差分の表示チェック", + "source_lines": [ + 233, + 234, + 235 + ] + }, + { + "id": "B-12.4", + "section": "12.4", + "requirement": "表裏UIスキン・BGMクロスフェード・環境音/SE", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/main.gd", + "line": 1232 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "UIスキンと背景の層差はある。UI全体の黒鉄切替、音声資産/AudioStream接続は未確認。", + "acceptance": "表裏切替のUI全体と音響クロスフェードを実機確認", + "source_lines": [ + 236, + 237, + 238, + 239 + ] + }, + { + "id": "B-13.1", + "section": "13.1", + "requirement": "立ち絵/CG/差分", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "対象assetsに正式立ち絵/イベントCG一式なし。", + "acceptance": "資産一覧とゲーム内使用箇所を突合し実機レビュー", + "source_lines": [ + 241, + 242, + 243 + ] + }, + { + "id": "B-13.2", + "section": "13.2", + "requirement": "SDモーション/エフェクト", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "静的actor markerをモーション完成として扱わない。", + "acceptance": "資産一覧とゲーム内使用箇所を突合し実機レビュー", + "source_lines": [ + 244, + 245, + 246 + ] + }, + { + "id": "B-13.3", + "section": "13.3", + "requirement": "Lv1〜3表裏タイル/背景", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "背景と施設Atlasあり。全施設Lv別内外装なし。", + "acceptance": "資産一覧とゲーム内使用箇所を突合し実機レビュー", + "source_lines": [ + 247, + 248 + ] + }, + { + "id": "B-13.4", + "section": "13.4", + "requirement": "表裏スキン/感情/覗き素材", + "status": "一部実装", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "UI Skin Atlasあり。感情グラフ/専用覗きOverlay不足。", + "acceptance": "資産一覧とゲーム内使用箇所を突合し実機レビュー", + "source_lines": [ + 249, + 250 + ] + }, + { + "id": "B-13.5", + "section": "13.5", + "requirement": "BGM/環境/操作/成人向けSE/声", + "status": "未実装(対象内)", + "evidence": [ + { + "path": "scripts/ui/visual_theme_profile.gd", + "line": 1 + }, + { + "path": "scenes/main/world_preview.gd", + "line": 230 + } + ], + "related_tests": [], + "finding": "assets内に音声拡張子、コードに音声再生接続を確認できない。", + "acceptance": "資産一覧とゲーム内使用箇所を突合し実機レビュー", + "source_lines": [ + 251, + 252 + ] + }, + { + "id": "B-14.1", + "section": "14.1", + "requirement": "2026/03〜08 プロトタイプ", + "status": "計画・未判定", + "evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "docs/IMPLEMENTATION_STATUS.md", + "line": 1 + } + ], + "related_tests": [], + "finding": "原本自身が「要修正」とする計画。現在の実装事実だけで予定達成/納期を判定しない。", + "acceptance": "本表の残課題からスコープ・工数・完了条件を再計画", + "source_lines": [ + 255 + ] + }, + { + "id": "B-14.2", + "section": "14.2", + "requirement": "2026/09〜2027/02 アルファ", + "status": "計画・未判定", + "evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "docs/IMPLEMENTATION_STATUS.md", + "line": 1 + } + ], + "related_tests": [], + "finding": "原本自身が「要修正」とする計画。現在の実装事実だけで予定達成/納期を判定しない。", + "acceptance": "本表の残課題からスコープ・工数・完了条件を再計画", + "source_lines": [ + 256 + ] + }, + { + "id": "B-14.3", + "section": "14.3", + "requirement": "2027/03〜08 ベータ/成人向け", + "status": "計画・未判定", + "evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "docs/IMPLEMENTATION_STATUS.md", + "line": 1 + } + ], + "related_tests": [], + "finding": "原本自身が「要修正」とする計画。現在の実装事実だけで予定達成/納期を判定しない。", + "acceptance": "本表の残課題からスコープ・工数・完了条件を再計画", + "source_lines": [ + 257 + ] + }, + { + "id": "B-14.4", + "section": "14.4", + "requirement": "2027/09〜12 ポリッシュ", + "status": "計画・未判定", + "evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 7593 + }, + { + "path": "docs/IMPLEMENTATION_STATUS.md", + "line": 1 + } + ], + "related_tests": [], + "finding": "原本自身が「要修正」とする計画。現在の実装事実だけで予定達成/納期を判定しない。", + "acceptance": "本表の残課題からスコープ・工数・完了条件を再計画", + "source_lines": [ + 258 + ] + } +] diff --git a/docs/traceability/original/live_verification.json b/docs/traceability/original/live_verification.json new file mode 100644 index 0000000..71ee2b1 --- /dev/null +++ b/docs/traceability/original/live_verification.json @@ -0,0 +1,8 @@ +{ + "file_id": "1w3hpz3kacxo1jRDwDhDT0TzNRJtAo-LW", + "verified_date_jst": "2026-09-27", + "modified_time": "2026-09-22T02:14:53.827Z", + "live_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "baseline_sha256": "75e4bdbdeb379c9269553f0c776bdb9cc96e01f2d6aa5ca650d132c63a8aeb1a", + "byte_identical": true +} diff --git a/docs/traceability/original/requirements.json b/docs/traceability/original/requirements.json new file mode 100644 index 0000000..2b157ec --- /dev/null +++ b/docs/traceability/original/requirements.json @@ -0,0 +1,3778 @@ +[ + { + "id": "B-1.1", + "section": "1.1", + "requirement": "基本情報・Windows・2D・20〜30時間/50時間以上", + "source_lines": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "current_status": "一部実装・差異あり", + "current_note": "Windows配布物と2D断面図を実装。 20〜30時間の本編、50時間以上の全攻略の受入証拠がない。今回Windowsネイティブ確認は実施していない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "Windows配布物と2D断面図を実装。", + "gap_or_acceptance": "20〜30時間の本編、50時間以上の全攻略の受入証拠がない。今回Windowsネイティブ確認は実施していない。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-1.2", + "section": "1.2", + "requirement": "地上/地下の二面性経営・観察介入・権威/富", + "source_lines": [ + 11, + 12, + 13, + 14 + ], + "current_status": "一部実装・差異あり", + "current_note": "地上地下・二層の建設と観察、ランク・経済の基盤あり。 高さと権威、深さと富、介入を一貫して成立させる完成ゲームの受入が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "地上地下・二層の建設と観察、ランク・経済の基盤あり。", + "gap_or_acceptance": "高さと権威、深さと富、介入を一貫して成立させる完成ゲームの受入が不足。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/mana/mana_network_service.gd", + "line": 46, + "symbol": "func register_source" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4736, + "symbol": "func _initialize_mana_network" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s2/test_economy_service.gd", + "tests/s2/test_mana_network_service.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-1.3", + "section": "1.3", + "requirement": "建設→集客→収益→管理→拡張防衛", + "source_lines": [ + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22 + ], + "current_status": "一部実装・差異あり", + "current_note": "建設・集客・宿泊飲食収入・雇用・物流の通常ループあり。防衛APIとRuntimeテストあり。 全裏収益、全施設、通常プレイでの防衛発生・高ランク到達まで未完了。APIテストを全ループ完了に数えない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "建設・集客・宿泊飲食収入・雇用・物流の通常ループあり。防衛APIとRuntimeテストあり。", + "gap_or_acceptance": "全裏収益、全施設、通常プレイでの防衛発生・高ランク到達まで未完了。APIテストを全ループ完了に数えない。", + "source_evidence": [ + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/supply/test_normal_supply_chain.gd", + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-1.4", + "section": "1.4", + "requirement": "二層・自律AI・渋滞・信仰と依存", + "source_lines": [ + 23, + 24, + 25, + 26 + ], + "current_status": "一部実装・差異あり", + "current_note": "二層、客AI、シフト、渋滞、依存値と信仰値の基盤あり。 信仰による本編進行、依存客の全特殊行動まで接続されていない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "二層、客AI、シフト、渋滞、依存値と信仰値の基盤あり。", + "gap_or_acceptance": "信仰による本編進行、依存客の全特殊行動まで接続されていない。", + "source_evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd", + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-1.5", + "section": "1.5", + "requirement": "世界観・主人公・7ヒロイン3運命", + "source_lines": [ + 27, + 28, + 29, + 30 + ], + "current_status": "一部実装・差異あり", + "current_note": "アンリとライの2名のデータあり。 原本の7ヒロインと各3ルート、主人公の物語・全エンドが揃っていない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "アンリとライの2名のデータあり。", + "gap_or_acceptance": "原本の7ヒロインと各3ルート、主人公の物語・全エンドが揃っていない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-1.6", + "section": "1.6", + "requirement": "成人向けルートと経営的搾取", + "source_lines": [ + 31, + 32, + 33, + 34 + ], + "current_status": "一部実装・差異あり", + "current_note": "成人区分・対象施設・コンテンツ制御の基盤あり。 原本記載の全ルート・イベント・経営要素としては未完成。データやタグの存在を内容実装と扱わない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "成人区分・対象施設・コンテンツ制御の基盤あり。", + "gap_or_acceptance": "原本記載の全ルート・イベント・経営要素としては未完成。データやタグの存在を内容実装と扱わない。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-2.1", + "section": "2.1", + "requirement": "グリッド・Y正負・支持・領域拡張", + "source_lines": [ + 36, + 37, + 38, + 39 + ], + "current_status": "実装あり・全受入未検証", + "current_note": "Y正負・境界、グリッド占有、支持判定、ランク別領域拡張APIあり。 個別テストはあるが通常プレイで高ランクまで進む原本一式の受入は未確認。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "U", + "implemented": "Y正負・境界、グリッド占有、支持判定、ランク別領域拡張APIあり。", + "gap_or_acceptance": "個別テストはあるが通常プレイで高ランクまで進む原本一式の受入は未確認。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-2.2", + "section": "2.2", + "requirement": "同座標Front/Back・半透明/ワイヤー表示", + "source_lines": [ + 40, + 41, + 42, + 43, + 44 + ], + "current_status": "一部実装・差異あり", + "current_note": "同一XYのFRONT/BACKスロットと切替を実装。 現画面は選択層のみの施設描画。原本の非選択層の半透明・ワイヤーフレーム表示を満たしていない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "同一XYのFRONT/BACKスロットと切替を実装。", + "gap_or_acceptance": "現画面は選択層のみの施設描画。原本の非選択層の半透明・ワイヤーフレーム表示を満たしていない。", + "source_evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-2.3", + "section": "2.3", + "requirement": "客エリアと景観/清潔/騒音の満足度効果", + "source_lines": [ + 45, + 46, + 47 + ], + "current_status": "一部実装・差異あり", + "current_note": "清掃・施設品質・満足度の基盤、景観/清潔/騒音の定義フィールドあり。 beauty_effect/noise_per_service/cleanliness_decay_per_serviceの本体参照は定義のみ。3環境値の計算と客満足度への連動が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "清掃・施設品質・満足度の基盤、景観/清潔/騒音の定義フィールドあり。", + "gap_or_acceptance": "beauty_effect/noise_per_service/cleanliness_decay_per_serviceの本体参照は定義のみ。3環境値の計算と客満足度への連動が不足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-2.4", + "section": "2.4", + "requirement": "バックヤード・職員ドア・客通行禁止", + "source_lines": [ + 48, + 49, + 50 + ], + "current_status": "一部実装・差異あり", + "current_note": "職員専用ドア、BACK経路、客アクセス制限、業務用EVあり。 原本にあるリネンシュート、隠し扉、覗き部屋を含む全バックヤード施設は未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "職員専用ドア、BACK経路、客アクセス制限、業務用EVあり。", + "gap_or_acceptance": "原本にあるリネンシュート、隠し扉、覗き部屋を含む全バックヤード施設は未完了。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-2.5", + "section": "2.5", + "requirement": "覗き部屋の配置制約・通路寸断・証拠収集", + "source_lines": [ + 51, + 52, + 53 + ], + "current_status": "該当機能未実装", + "current_note": "証拠管理APIは別機能としてあるが、覗き部屋の施設定義はない。 対象室背面限定建設、裏通路寸断、記録係と専用視界からの証拠収集が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "証拠管理APIは別機能としてあるが、覗き部屋の施設定義はない。", + "gap_or_acceptance": "対象室背面限定建設、裏通路寸断、記録係と専用視界からの証拠収集が未実装。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-2.6", + "section": "2.6", + "requirement": "地上/地下の採光・マナ・遮音・隠蔽ボーナス", + "source_lines": [ + 54, + 55, + 56 + ], + "current_status": "一部実装・差異あり", + "current_note": "地上/地下ゾーンの配置制約、関連する環境定義フィールドあり。 採光・マナ濃度・遮音・隠蔽のゾーン別ボーナス計算と客層効果が未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "地上/地下ゾーンの配置制約、関連する環境定義フィールドあり。", + "gap_or_acceptance": "採光・マナ濃度・遮音・隠蔽のゾーン別ボーナス計算と客層効果が未接続。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.1a", + "section": "3.1a", + "requirement": "10秒=1時間、Pause/x1/x2/x3", + "source_lines": [ + 58, + 59, + 60 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "10秒=1時間、Pause/x1/x2/x3。旧x4セーブの移行も実装。 今回時計テストを再実行。時間倍率の要件を検証済み。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "10秒=1時間、Pause/x1/x2/x3。旧x4セーブの移行も実装。", + "gap_or_acceptance": "今回時計テストを再実行。時間倍率の要件を検証済み。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.1b", + "section": "3.1b", + "requirement": "アクティブポーズ中も建設・指示", + "source_lines": [ + 58, + 59, + 60 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "停止中の当分コマンドを即処理し、停止したまま建設予約・指示を受理。 今回停止中コマンドテストと開業UIテストを再実行。工事進行は時間再開後。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "V", + "implemented": "停止中の当分コマンドを即処理し、停止したまま建設予約・指示を受理。", + "gap_or_acceptance": "今回停止中コマンドテストと開業UIテストを再実行。工事進行は時間再開後。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.2", + "section": "3.2", + "requirement": "4時間帯と朝下り/夕上り/夜行動", + "source_lines": [ + 61, + 62, + 63, + 64, + 65 + ], + "current_status": "一部実装・差異あり", + "current_note": "06/10/16/20時の4フェーズ、来客時間帯、18時シフト交代あり。 朝の下り・夕の上りラッシュと原本指定の夜行動全体を再現するシナリオ受入が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "06/10/16/20時の4フェーズ、来客時間帯、18時シフト交代あり。", + "gap_or_acceptance": "朝の下り・夕の上りラッシュと原本指定の夜行動全体を再現するシナリオ受入が不足。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.3", + "section": "3.3", + "requirement": "昼夜シフト・体力0気絶・救護・通勤混雑", + "source_lines": [ + 66, + 67, + 68, + 69 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "勤務体力減少、気絶、目撃評判低下、BACK/EV同伴救護、待機室休息、16人交代混雑を検証。 現行施設での検証。回復速度・治療時間等の数値は暫定値であり原本確定値ではない。", + "previous_status": "実装・検証済み(現行施設)", + "status_code": "V", + "implemented": "勤務体力減少、気絶、目撃評判低下、BACK/EV同伴救護、待機室休息、16人交代混雑を検証。", + "gap_or_acceptance": "現行施設での検証。回復速度・治療時間等の数値は暫定値であり原本確定値ではない。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd", + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.4a", + "section": "3.4a", + "requirement": "毎日00:00に決算レポート", + "source_lines": [ + 70, + 71, + 72 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "00:00決算・給与・帳簿自動表示・保存復元を実装。 時刻とレポートの要件を検証済み。未接続費目はB-9.2へ分離。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "00:00決算・給与・帳簿自動表示・保存復元を実装。", + "gap_or_acceptance": "時刻とレポートの要件を検証済み。未接続費目はB-9.2へ分離。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.4b", + "section": "3.4b", + "requirement": "3回連続赤字でゲームオーバー", + "source_lines": [ + 70, + 71, + 72 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "3回連続赤字で終了。黒字/ゼロで連続数リセット。終了状態も保存。 判定と終了制御を検証済み。全収支種別の充足とは別。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "3回連続赤字で終了。黒字/ゼロで連続数リセット。終了状態も保存。", + "gap_or_acceptance": "判定と終了制御を検証済み。全収支種別の充足とは別。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-3.5", + "section": "3.5", + "requirement": "雨・嵐・マナの霧・流行ニュース", + "source_lines": [ + 73, + 74, + 75 + ], + "current_status": "該当機能未実装", + "current_note": "4時間帯はあるが天候・流行の実行系はない。 雨の客足/滞在、嵐の新規客ゼロ、マナ霧効果、数日ごとのニュースと売上効果が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "4時間帯はあるが天候・流行の実行系はない。", + "gap_or_acceptance": "雨の客足/滞在、嵐の新規客ゼロ、マナ霧効果、数日ごとのニュースと売上効果が未実装。", + "source_evidence": [ + { + "path": "scripts/core/simulation_kernel.gd", + "line": 28, + "symbol": "const REAL_SECONDS_PER_GAME_HOUR" + }, + { + "path": "autoload/game_clock.gd", + "line": 29, + "symbol": "func get_phase" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 446, + "symbol": "if _clock.paused and scheduled_tick" + } + ], + "related_component_tests": [ + "tests/run_tests.gd", + "tests/s0/test_runtime_integration.gd", + "tests/ui/test_first_operating_day.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.1", + "section": "4.1", + "requirement": "所持金/戦利品・四欲求・忍耐/清潔/依存", + "source_lines": [ + 77, + 78, + 79, + 80 + ], + "current_status": "一部実装・差異あり", + "current_note": "財布・四欲求・忍耐・依存・信用/債務値あり。 戦利品の所持/換金、客自身の清潔度と歩行汚染伝播が不足。hygiene欲求を清潔度伝播の実装と数えない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "財布・四欲求・忍耐・依存・信用/債務値あり。", + "gap_or_acceptance": "戦利品の所持/換金、客自身の清潔度と歩行汚染伝播が不足。hygiene欲求を清潔度伝播の実装と数えない。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.2", + "section": "4.2", + "requirement": "最大欲求優先と距離/行列/価格選択", + "source_lines": [ + 81, + 82, + 83 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "利用可能候補の最大欲求を先に比較し、同順位を距離・待ち・価格等で選択。 現行サービス候補で検証済み。未登録施設の網羅ではない。", + "previous_status": "実装・検証済み(現行施設)", + "status_code": "V", + "implemented": "利用可能候補の最大欲求を先に比較し、同順位を距離・待ち・価格等で選択。", + "gap_or_acceptance": "現行サービス候補で検証済み。未登録施設の網羅ではない。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.3a", + "section": "4.3a", + "requirement": "A*とエレベーター待ち時間コスト", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "実装あり・全受入未検証", + "current_note": "A*相当の探索とヒューリスティック、EV行列から見積る待ちコストを経路費用に加算。 今回経路・混雑テストPASS。実カゴ運行時間との見積誤差を含む原本専用の総合受入は未確認。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "U", + "implemented": "A*相当の探索とヒューリスティック、EV行列から見積る待ちコストを経路費用に加算。", + "gap_or_acceptance": "今回経路・混雑テストPASS。実カゴ運行時間との見積誤差を含む原本専用の総合受入は未確認。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.3b", + "section": "4.3b", + "requirement": "4種垂直移動のゲーム内利用", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "一部実装・差異あり", + "current_note": "客用EV・業務用EV・遅い階段は建設から実移動まで接続。 マナワープ施設・費用・瞬間移動は未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "客用EV・業務用EV・遅い階段は建設から実移動まで接続。", + "gap_or_acceptance": "マナワープ施設・費用・瞬間移動は未実装。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.3c", + "section": "4.3c", + "requirement": "重なり時速度50%低下", + "source_lines": [ + 84, + 85, + 86, + 87 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "重なった通路の移動速度0.5と移動クレジットを実装。 今回混雑テストを再実行。経路コストだけでなく実移動速度も既存Runtime/交代試験対象。", + "previous_status": "実装・検証済み", + "status_code": "V", + "implemented": "重なった通路の移動速度0.5と移動クレジットを実装。", + "gap_or_acceptance": "今回混雑テストを再実行。経路コストだけでなく実移動速度も既存Runtime/交代試験対象。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.4", + "section": "4.4", + "requirement": "依存Lv3退店拒否・装備売却・性別別破産", + "source_lines": [ + 88, + 89, + 90 + ], + "current_status": "一部実装・差異あり", + "current_note": "依存値と支出優先の基盤あり。 依存Lv3退店拒否、装備売却、原本の性別別破産分岐は未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "依存値と支出優先の基盤あり。", + "gap_or_acceptance": "依存Lv3退店拒否、装備売却、原本の性別別破産分岐は未接続。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-4.5", + "section": "4.5", + "requirement": "貴族/聖職者/エルフ/魔族のAI特性", + "source_lines": [ + 91, + 92, + 93, + 94, + 95 + ], + "current_status": "一部実装・差異あり", + "current_note": "8客クラス・4種族、予算/忍耐/嗜好等の定義あり。 聖職者の目撃即退店/風紀、エルフの自然好み、魔族等の地下満足度耐性を原本通り通す受入がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "8客クラス・4種族、予算/忍耐/嗜好等の定義あり。", + "gap_or_acceptance": "聖職者の目撃即退店/風紀、エルフの自然好み、魔族等の地下満足度耐性を原本通り通す受入がない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-5.1", + "section": "5.1", + "requirement": "一般雇用と奴隷の費用/耐性/売却区別", + "source_lines": [ + 97, + 98, + 99 + ], + "current_status": "一部実装・差異あり", + "current_note": "FREE_HIRE/ADULT_CONTRACT/UNIQUEの区分と給与処理あり。 原本の食費のみ・ストレス耐性差・売却ルール一式とは一致を確認できない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "FREE_HIRE/ADULT_CONTRACT/UNIQUEの区分と給与処理あり。", + "gap_or_acceptance": "原本の食費のみ・ストレス耐性差・売却ルール一式とは一致を確認できない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-5.2", + "section": "5.2", + "requirement": "体力/ストレス/忠誠/技能/ランダム特性", + "source_lines": [ + 100, + 101, + 102, + 103 + ], + "current_status": "一部実装・差異あり", + "current_note": "health/stress/loyalty/技能を保存し、勤務と救護でhealthを使用。 候補20名は固定データ。生成時のランダム特性付与と全職業技能効果が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "health/stress/loyalty/技能を保存し、勤務と救護でhealthを使用。", + "gap_or_acceptance": "候補20名は固定データ。生成時のランダム特性付与と全職業技能効果が不足。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-5.3", + "section": "5.3", + "requirement": "接客/裏方/救護/防衛等の職種", + "source_lines": [ + 104, + 105, + 106, + 107 + ], + "current_status": "一部実装・差異あり", + "current_note": "受付・料理・清掃・運搬・医療・警備等の職種と一部実作業を実装。 原本の全職種と専用施設・専用業務を網羅していない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "受付・料理・清掃・運搬・医療・警備等の職種と一部実作業を実装。", + "gap_or_acceptance": "原本の全職種と専用施設・専用業務を網羅していない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-5.4", + "section": "5.4", + "requirement": "シフト休息と気絶回収", + "source_lines": [ + 108, + 109, + 110 + ], + "current_status": "検証済み(記載範囲)", + "current_note": "非番は稼働中待機室まで実移動して回復。体力0は回復せずBACK/EV搬送・治療へ。 現行施設で途中保存再開と画面表示を検証済み。宿舎の通常建設は未接続。", + "previous_status": "実装・検証済み(現行施設)", + "status_code": "V", + "implemented": "非番は稼働中待機室まで実移動して回復。体力0は回復せずBACK/EV搬送・治療へ。", + "gap_or_acceptance": "現行施設で途中保存再開と画面表示を検証済み。宿舎の通常建設は未接続。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-5.5", + "section": "5.5", + "requirement": "求人/市場/破産捕獲・退職金/売買価格", + "source_lines": [ + 111, + 112, + 113 + ], + "current_status": "一部実装・差異あり", + "current_note": "求人候補の採用・費用・割当、職員削除/退職APIあり。 市場購入、破産客獲得、退職金、能力による売価と他職員忠誠変化の通常フローが不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "求人候補の採用・費用・割当、職員削除/退職APIあり。", + "gap_or_acceptance": "市場購入、破産客獲得、退職金、能力による売価と他職員忠誠変化の通常フローが不足。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-6.1", + "section": "6.1", + "requirement": "ユニーク無料・高成長・解雇売却不可", + "source_lines": [ + 115, + 116 + ], + "current_status": "一部実装・差異あり", + "current_note": "UNIQUE区分、unique/unsellableの定義タグあり。 高成長・無料維持の一式と操作禁止の受入がない。remove_staffにはUNIQUE区分による削除拒否がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "UNIQUE区分、unique/unsellableの定義タグあり。", + "gap_or_acceptance": "高成長・無料維持の一式と操作禁止の受入がない。remove_staffにはUNIQUE区分による削除拒否がない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-6.2", + "section": "6.2", + "requirement": "7ヒロインの指定固有効果とライバル", + "source_lines": [ + 117, + 118, + 119, + 120, + 121, + 122, + 123, + 124, + 125 + ], + "current_status": "一部実装・差異あり", + "current_note": "ユニークデータはアンリとライのみ。 残り6ヒロイン等が不足。アンリは忍耐減少倍率9000bpの定義であり原本の配置階売上+20%ではない。効果の本番適用も未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "ユニークデータはアンリとライのみ。", + "gap_or_acceptance": "残り6ヒロイン等が不足。アンリは忍耐減少倍率9000bpの定義であり原本の配置階売上+20%ではない。効果の本番適用も未接続。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-6.3", + "section": "6.3", + "requirement": "基礎技能と業務EXP・Lv99", + "source_lines": [ + 126, + 127, + 128 + ], + "current_status": "一部実装・差異あり", + "current_note": "基礎パラメータと技能値はある。 業務EXP→レベル成長→Lv99上限の実行系が不足。MAX_SKILL_VALUE=999はLv99ではない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "基礎パラメータと技能値はある。", + "gap_or_acceptance": "業務EXP→レベル成長→Lv99上限の実行系が不足。MAX_SKILL_VALUE=999はLv99ではない。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-6.4", + "section": "6.4", + "requirement": "配置パッシブ・任意発動・ルート別スキル", + "source_lines": [ + 129, + 130, + 131, + 132 + ], + "current_status": "一部実装・差異あり", + "current_note": "固有效果のデータ枠、防衛スキルAPIがある。 7名の配置パッシブとプレイヤー任意発動、ルート別習得の通常導線が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "固有效果のデータ枠、防衛スキルAPIがある。", + "gap_or_acceptance": "7名の配置パッシブとプレイヤー任意発動、ルート別習得の通常導線が不足。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-6.5", + "section": "6.5", + "requirement": "24時間勤務警告・衣装補正・限定離脱", + "source_lines": [ + 133, + 134, + 135 + ], + "current_status": "一部実装・差異あり", + "current_note": "シフト例外・超過勤務・不在状態などの基盤あり。 ユニークの24時間勤務警告/強制継続、衣装補正、原本指定の限定離脱条件まで未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "シフト例外・超過勤務・不在状態などの基盤あり。", + "gap_or_acceptance": "ユニークの24時間勤務警告/強制継続、衣装補正、原本指定の限定離脱条件まで未完了。", + "source_evidence": [ + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-7.1", + "section": "7.1", + "requirement": "三感情・盾・決闘の3ルート運命", + "source_lines": [ + 137, + 138 + ], + "current_status": "一部実装・差異あり", + "current_note": "関係値・盾・ルート固定・閾値イベントの汎用基盤あり。 原本の3感情と決闘による3ルート全体は未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "関係値・盾・ルート固定・閾値イベントの汎用基盤あり。", + "gap_or_acceptance": "原本の3感情と決闘による3ルート全体は未実装。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-7.2", + "section": "7.2", + "requirement": "好感/淫乱/NTR・好感度=盾最大・淫乱軽減", + "source_lines": [ + 139, + 140, + 141, + 142, + 143 + ], + "current_status": "一部実装・差異あり", + "current_note": "affectionとshield_current/maxはある。 現在の5指標はtrust/respect/affection/fear/dependence。原本のlust/NTR値がなく、好感度=盾最大とlustによる防御低下に不一致。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "affectionとshield_current/maxはある。", + "gap_or_acceptance": "現在の5指標はtrust/respect/affection/fear/dependence。原本のlust/NTR値がなく、好感度=盾最大とlustによる防御低下に不一致。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-7.3", + "section": "7.3", + "requirement": "同配置接触→盾破壊→NTR上昇→不可逆陥落", + "source_lines": [ + 144, + 145, + 146, + 147, + 148 + ], + "current_status": "一部実装・差異あり", + "current_note": "盾攻撃API、配置リスク予測、ルート確認固定APIあり。 通常Tickはイベント期限/候補処理。実配置による自動接触→盾破壊→NTR増加→不可逆陥落の一連の接続がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "盾攻撃API、配置リスク予測、ルート確認固定APIあり。", + "gap_or_acceptance": "通常Tickはイベント期限/候補処理。実配置による自動接触→盾破壊→NTR増加→不可逆陥落の一連の接続がない。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + }, + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd", + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-7.4", + "section": "7.4", + "requirement": "3種決闘・勝利追放/隷属/盾回復・敗北確定", + "source_lines": [ + 149, + 150, + 151, + 152, + 153 + ], + "current_status": "該当機能未実装", + "current_note": "防衛戦はあるが原本の決闘とは別機能。 3種類の決闘と勝敗による指定結果が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "防衛戦はあるが原本の決闘とは別機能。", + "gap_or_acceptance": "3種類の決闘と勝敗による指定結果が未実装。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + }, + { + "path": "scripts/defense/defense_service.gd", + "line": 79, + "symbol": "func" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 6475, + "symbol": "func _execute_create_defense_raid" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd", + "tests/s5/test_defense_service.gd", + "tests/s5/test_runtime_defense_cycle.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-7.5", + "section": "7.5", + "requirement": "会話/デート/隔離・覗き・薬による各ルート", + "source_lines": [ + 154, + 155, + 156, + 157 + ], + "current_status": "一部実装・差異あり", + "current_note": "イベント選択・関係変化・盾回復の部品あり。 原本の各プレイヤーアクションと全ルートイベントを通常UIから進める接続が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "イベント選択・関係変化・盾回復の部品あり。", + "gap_or_acceptance": "原本の各プレイヤーアクションと全ルートイベントを通常UIから進める接続が不足。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-7.6", + "section": "7.6", + "requirement": "履歴・妊娠/父親・刻印立ち絵", + "source_lines": [ + 158, + 159, + 160, + 161 + ], + "current_status": "該当機能未実装", + "current_note": "汎用フラグはあるが原本専用の履歴・外見システムはない。 指定履歴、妊娠周期・父親判定、立ち絵差分との接続が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "汎用フラグはあるが原本専用の履歴・外見システムはない。", + "gap_or_acceptance": "指定履歴、妊娠周期・父親判定、立ち絵差分との接続が未実装。", + "source_evidence": [ + { + "path": "scripts/relationship/relationship_state.gd", + "line": 15, + "symbol": "const METRIC_IDS" + }, + { + "path": "scripts/relationship/relationship_service.gd", + "line": 223, + "symbol": "func apply_shield_attack" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7824, + "symbol": "func _advance_relationship_events" + } + ], + "related_component_tests": [ + "tests/s4/test_relationship_event_service.gd", + "tests/s4/test_runtime_relationship_events.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-8.1", + "section": "8.1", + "requirement": "隣接/通路必須とLv拡張", + "source_lines": [ + 163, + 164, + 165 + ], + "current_status": "一部実装・差異あり", + "current_note": "支持/隣接、アクセス点・通路判定、max_level/upgrade_costs定義あり。 施設LvUPの通常操作・支払い・能力変化・全解放設定が未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "支持/隣接、アクセス点・通路判定、max_level/upgrade_costs定義あり。", + "gap_or_acceptance": "施設LvUPの通常操作・支払い・能力変化・全解放設定が未完了。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-8.2", + "section": "8.2", + "requirement": "表層全施設・飲食と厨房セット", + "source_lines": [ + 166, + 167, + 168, + 169, + 170 + ], + "current_status": "一部実装・差異あり", + "current_note": "一般客室・食堂・酒場・浴場等と厨房の生産配送を実装。 レストラン/VIP/カジノ/闘技場/礼拝堂等が通常登録されていない。飲食全件の厨房セット条件は未充足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "一般客室・食堂・酒場・浴場等と厨房の生産配送を実装。", + "gap_or_acceptance": "レストラン/VIP/カジノ/闘技場/礼拝堂等が通常登録されていない。飲食全件の厨房セット条件は未充足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-8.3", + "section": "8.3", + "requirement": "裏層全施設と特殊設備", + "source_lines": [ + 171, + 172, + 173, + 174 + ], + "current_status": "一部実装・差異あり", + "current_note": "厨房・倉庫・待機室・警備詰所・医務室は通常登録あり。 宿舎は拡張定義のみ。清掃員室・覗き部屋・地下牢・マナ炉が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "厨房・倉庫・待機室・警備詰所・医務室は通常登録あり。", + "gap_or_acceptance": "宿舎は拡張定義のみ。清掃員室・覗き部屋・地下牢・マナ炉が不足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-8.4", + "section": "8.4", + "requirement": "EV/階段/ワープ/通路/職員ドア", + "source_lines": [ + 175, + 176, + 177 + ], + "current_status": "一部実装・差異あり", + "current_note": "2種EV、階段、2層通路、職員ドアあり。 ワープ未実装。通路・職員専用制限は経路試験対象。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "2種EV、階段、2層通路、職員ドアあり。", + "gap_or_acceptance": "ワープ未実装。通路・職員専用制限は経路試験対象。", + "source_evidence": [ + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-8.5", + "section": "8.5", + "requirement": "Lv解放の価格/食材/混浴/ハード/イカサマ設定", + "source_lines": [ + 178, + 179 + ], + "current_status": "一部実装・差異あり", + "current_note": "価格ポリシー、営業時間/営業状態のUIあり。 レベル解放と食材ランク/混浴/施設別特殊設定が原本一式として未完了。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "価格ポリシー、営業時間/営業状態のUIあり。", + "gap_or_acceptance": "レベル解放と食材ランク/混浴/施設別特殊設定が原本一式として未完了。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-9.1", + "section": "9.1", + "requirement": "表金/裏金・マナ全館停止・資材", + "source_lines": [ + 181, + 182, + 183, + 184 + ], + "current_status": "一部実装・差異あり", + "current_note": "Gold/材料/マナ、帳簿、電力不足による施設状態あり。 表金/裏金の独立運用と「マナ枯渇で全館停止」は未充足。Runtimeは出力100の初期マナ源を持つ。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "Gold/材料/マナ、帳簿、電力不足による施設状態あり。", + "gap_or_acceptance": "表金/裏金の独立運用と「マナ枯渇で全館停止」は未充足。Runtimeは出力100の初期マナ源を持つ。", + "source_evidence": [ + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/mana/mana_network_service.gd", + "line": 46, + "symbol": "func register_source" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4736, + "symbol": "func _initialize_mana_network" + } + ], + "related_component_tests": [ + "tests/s2/test_economy_service.gd", + "tests/s2/test_mana_network_service.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-9.2", + "section": "9.2", + "requirement": "全収益/人件費/維持/仕入/借金/ランク税", + "source_lines": [ + 185, + 186, + 187, + 188 + ], + "current_status": "一部実装・差異あり", + "current_note": "宿泊飲食売上、給与、仕入れ、建設、罰金等を記帳。 maintenance_goldは定義/検証のみ。日次maintenance/utilities/taxes_and_interestに全施設維持費・借金返済・ランク税を計上する実処理がない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "宿泊飲食売上、給与、仕入れ、建設、罰金等を記帳。", + "gap_or_acceptance": "maintenance_goldは定義/検証のみ。日次maintenance/utilities/taxes_and_interestに全施設維持費・借金返済・ランク税を計上する実処理がない。", + "source_evidence": [ + { + "path": "autoload/simulation_runtime.gd", + "line": 2278, + "symbol": "func _build_operations_daily_financials" + }, + { + "path": "scripts/economy/economy_service.gd", + "line": 28, + "symbol": "func" + }, + { + "path": "scripts/operations/operations_coordinator.gd", + "line": 143, + "symbol": "func" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/mvp/test_original_midnight_settlement.gd", + "tests/mvp/test_runtime_operations_acceptance.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-9.3", + "section": "9.3", + "requirement": "受付/金庫/購買/闇銀行", + "source_lines": [ + 189, + 190, + 191, + 192, + 193 + ], + "current_status": "一部実装・差異あり", + "current_note": "受付・精算待ち、購買/自動発注管理の部品あり。 金庫室の上限/盗賊、戦利品買取、闇銀行融資・滞納破壊を施設と通常UIへ接続していない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "受付・精算待ち、購買/自動発注管理の部品あり。", + "gap_or_acceptance": "金庫室の上限/盗賊、戦利品買取、闇銀行融資・滞納破壊を施設と通常UIへ接続していない。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/supply/kitchen_production_service.gd", + "line": 19, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_logistics_bridge.gd", + "line": 17, + "symbol": "func" + }, + { + "path": "scripts/supply/supply_management_service.gd", + "line": 7, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/supply/test_normal_supply_chain.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-9.4", + "section": "9.4", + "requirement": "依存客の娯楽優先・信用超過の破産拘束", + "source_lines": [ + 194, + 195, + 196 + ], + "current_status": "一部実装・差異あり", + "current_note": "高依存時の生存予算留保0、信用枠/債務を実装。 信用超過→原本の破産イベント/拘束/配置まで未接続。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "高依存時の生存予算留保0、信用枠/債務を実装。", + "gap_or_acceptance": "信用超過→原本の破産イベント/拘束/配置まで未接続。", + "source_evidence": [ + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + } + ], + "related_component_tests": [ + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.1", + "section": "10.1", + "requirement": "宿泊施設の寸法/層/ゾーン", + "source_lines": [ + 198, + 199 + ], + "current_status": "一部実装・差異あり", + "current_note": "一般客室2×1 FRONTは一致。 上級客室は定義3×2で原本4×1と不一致かつ通常未登録。VIP6×2、ドミトリー4×1なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "一般客室2×1 FRONTは一致。", + "gap_or_acceptance": "上級客室は定義3×2で原本4×1と不一致かつ通常未登録。VIP6×2、ドミトリー4×1なし。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.2", + "section": "10.2", + "requirement": "飲食施設の寸法/層/ゾーン", + "source_lines": [ + 200, + 201 + ], + "current_status": "一部実装・差異あり", + "current_note": "大衆食堂4×2、酒場4×2 FRONTは寸法一致。 レストラン6×2・屋台2×1なし。騒音定義の実効果不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "大衆食堂4×2、酒場4×2 FRONTは寸法一致。", + "gap_or_acceptance": "レストラン6×2・屋台2×1なし。騒音定義の実効果不足。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.3", + "section": "10.3", + "requirement": "衛生/娯楽施設の寸法/層/ゾーン", + "source_lines": [ + 202, + 203 + ], + "current_status": "一部実装・差異あり", + "current_note": "大浴場6×2 FRONTは寸法一致。 カジノ6×3、闘技場8×4、礼拝堂4×3なし。浴場の覗き機能なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "大浴場6×2 FRONTは寸法一致。", + "gap_or_acceptance": "カジノ6×3、闘技場8×4、礼拝堂4×3なし。浴場の覗き機能なし。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.4", + "section": "10.4", + "requirement": "アングラ施設の寸法/層/ゾーン", + "source_lines": [ + 204, + 205 + ], + "current_status": "一部実装・差異あり", + "current_note": "個室2×1 FRONT地下は寸法/層一致。 高級ソープ・SM室なし。闇市場は定義3×2で原本4×2と不一致かつ通常未登録。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "個室2×1 FRONT地下は寸法/層一致。", + "gap_or_acceptance": "高級ソープ・SM室なし。闇市場は定義3×2で原本4×2と不一致かつ通常未登録。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.5", + "section": "10.5", + "requirement": "バックヤード施設の寸法/層/ゾーン", + "source_lines": [ + 206, + 207 + ], + "current_status": "一部実装・差異あり", + "current_note": "厨房4×2・倉庫4×2・待機室4×2・警備2×2・医務4×2 BACKは寸法一致。 従業員寮は定義4×2で原本2×1と不一致かつ通常未登録。清掃員室2×1なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "厨房4×2・倉庫4×2・待機室4×2・警備2×2・医務4×2 BACKは寸法一致。", + "gap_or_acceptance": "従業員寮は定義4×2で原本2×1と不一致かつ通常未登録。清掃員室2×1なし。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.6", + "section": "10.6", + "requirement": "特殊施設の寸法/層/ゾーン", + "source_lines": [ + 208, + 209 + ], + "current_status": "該当機能未実装", + "current_note": "原本3施設に対応する通常登録・定義なし。 覗き部屋2×1 BACK、地下牢4×2 BACK地下、マナ炉6×3 BACK地下が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "原本3施設に対応する通常登録・定義なし。", + "gap_or_acceptance": "覗き部屋2×1 BACK、地下牢4×2 BACK地下、マナ炉6×3 BACK地下が未実装。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-10.7", + "section": "10.7", + "requirement": "インフラ/防衛施設の寸法/層/ゾーン", + "source_lines": [ + 210, + 211 + ], + "current_status": "一部実装・差異あり", + "current_note": "客用EV2×2 FRONT・業務用EV3×2 BACKは一致。 ワープ2×2 FRONT・外壁魔法砲台がない。防衛APIの存在は砲台施設の証拠ではない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "客用EV2×2 FRONT・業務用EV3×2 BACKは一致。", + "gap_or_acceptance": "ワープ2×2 FRONT・外壁魔法砲台がない。防衛APIの存在は砲台施設の証拠ではない。", + "source_evidence": [ + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + }, + { + "path": "scripts/world/path_service.gd", + "line": 938, + "symbol": "func estimate_elevator_wait_cost" + }, + { + "path": "scripts/world/path_service.gd", + "line": 1672, + "symbol": "func _heuristic" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd", + "tests/s1/test_path_service.gd", + "tests/s1/test_path_congestion.gd", + "tests/transport/test_runtime_transport.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-11.1", + "section": "11.1", + "requirement": "資源/時間/天候/Rank・下部切替・スクロールズーム", + "source_lines": [ + 213, + 214, + 215, + 216 + ], + "current_status": "一部実装・差異あり", + "current_note": "資源/時間/ランク、レイヤー切替、パン/ズームあり。 天候表示、指定下部巨大切替、非選択層の半透明表示まで原本一致ではない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "資源/時間/ランク、レイヤー切替、パン/ズームあり。", + "gap_or_acceptance": "天候表示、指定下部巨大切替、非選択層の半透明表示まで原本一致ではない。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-11.2", + "section": "11.2", + "requirement": "Policy/ドラッグ配置・欲求/思考/感情グラフ/盾", + "source_lines": [ + 217, + 218, + 219 + ], + "current_status": "一部実装・差異あり", + "current_note": "価格ポリシー、職員割当UI、客四欲求/状態/目的地表示あり。 ドラッグ配置、AI思考履歴、3感情グラフ、盾詳細の通常UIが不足。", + "previous_status": "一部実装・欲求観察UIを追加", + "status_code": "P", + "implemented": "価格ポリシー、職員割当UI、客四欲求/状態/目的地表示あり。", + "gap_or_acceptance": "ドラッグ配置、AI思考履歴、3感情グラフ、盾詳細の通常UIが不足。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/staff/staff_runtime.gd", + "line": 29, + "symbol": "enum EmploymentKind" + }, + { + "path": "scripts/staff/staff_service.gd", + "line": 453, + "symbol": "func set_work_assignment" + }, + { + "path": "scripts/ui/staff_management_panel.gd", + "line": 42, + "symbol": "func" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/s3/test_staff_service.gd", + "tests/s3/test_staff_payroll_operations.gd", + "tests/ui/test_staff_management.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-11.3", + "section": "11.3", + "requirement": "カメラ入口・覗きアングル/撮影/介入", + "source_lines": [ + 220, + 221, + 222 + ], + "current_status": "該当機能未実装", + "current_note": "現在のメインUIに原本の覗き専用導線はない。 カメラ入口、角度、撮影、証拠収集、介入の一式が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "現在のメインUIに原本の覗き専用導線はない。", + "gap_or_acceptance": "カメラ入口、角度、撮影、証拠収集、介入の一式が未実装。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-11.4", + "section": "11.4", + "requirement": "グリッド/接続警告・フキダシ/ログ・カットイン", + "source_lines": [ + 223, + 224, + 225, + 226 + ], + "current_status": "一部実装・差異あり", + "current_note": "グリッド建設プレビュー・警告/状態表示・ログと人物表示あり。 原本指定の全フキダシ、重要イベント/決闘/防衛カットインと接続線警告の全受入は不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "グリッド建設プレビュー・警告/状態表示・ログと人物表示あり。", + "gap_or_acceptance": "原本指定の全フキダシ、重要イベント/決闘/防衛カットインと接続線警告の全受入は不足。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-12.1", + "section": "12.1", + "requirement": "二面性・高精細ドットとアニメ立ち絵", + "source_lines": [ + 228, + 229, + 230 + ], + "current_status": "一部実装・差異あり", + "current_note": "Kenneyタイルと独自ドット人物で断面図・室内作業を表示。 原本の高品質アニメ立ち絵と二面性表現の完成受入はない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "Kenneyタイルと独自ドット人物で断面図・室内作業を表示。", + "gap_or_acceptance": "原本の高品質アニメ立ち絵と二面性表現の完成受入はない。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-12.2", + "section": "12.2", + "requirement": "魔法レンズ風の波紋切替", + "source_lines": [ + 231, + 232 + ], + "current_status": "該当機能未実装", + "current_note": "レイヤー選択は即時切替。 魔法レンズ風の波紋反転演出が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "レイヤー選択は即時切替。", + "gap_or_acceptance": "魔法レンズ風の波紋反転演出が未実装。", + "source_evidence": [ + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 653, + "symbol": "func _prepare_room_presentations" + }, + { + "path": "scripts/world/path_service.gd", + "line": 952, + "symbol": "func find_path" + } + ], + "related_component_tests": [ + "tests/s1/test_path_service.gd", + "tests/ui/test_pixel_tower.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-12.3", + "section": "12.3", + "requirement": "SD各モーション・立ち絵破損/汚れ/刻印差分", + "source_lines": [ + 233, + 234, + 235 + ], + "current_status": "一部実装・差異あり", + "current_note": "歩行・待機・作業・気絶/搬送/治療・休息のドット表示あり。 原本の全職種専用モーション、立ち絵破損/汚れ/刻印差分は未完成。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "歩行・待機・作業・気絶/搬送/治療・休息のドット表示あり。", + "gap_or_acceptance": "原本の全職種専用モーション、立ち絵破損/汚れ/刻印差分は未完成。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11906, + "symbol": "func _advance_staff_rescue" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 11985, + "symbol": "func get_staff_rest_report" + }, + { + "path": "scripts/transport/elevator_service.gd", + "line": 178, + "symbol": "func request_pair" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/staff/test_staff_rescue.gd", + "tests/staff/test_rescue_elevator.gd", + "tests/staff/test_staff_rest.gd", + "tests/staff/test_shift_rush.gd", + "tests/ui/test_staff_rest.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-12.4", + "section": "12.4", + "requirement": "表裏UIスキン・BGMクロスフェード・環境音/SE", + "source_lines": [ + 236, + 237, + 238, + 239 + ], + "current_status": "一部実装・差異あり", + "current_note": "昼夜/レイヤーの色調変化あり。 羊皮紙/黒鉄の完成スキン、表裏BGMクロスフェード、環境音/SEの実再生なし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "昼夜/レイヤーの色調変化あり。", + "gap_or_acceptance": "羊皮紙/黒鉄の完成スキン、表裏BGMクロスフェード、環境音/SEの実再生なし。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-13.1", + "section": "13.1", + "requirement": "立ち絵/CG/差分", + "source_lines": [ + 241, + 242, + 243 + ], + "current_status": "該当機能未実装", + "current_note": "キャラクターデータはあるが指定イラスト一式はない。 メイン/サブ立ち絵、衣装/表情/ルート差分・イベントCGの制作と接続が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "キャラクターデータはあるが指定イラスト一式はない。", + "gap_or_acceptance": "メイン/サブ立ち絵、衣装/表情/ルート差分・イベントCGの制作と接続が不足。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-13.2", + "section": "13.2", + "requirement": "SDモーション/エフェクト", + "source_lines": [ + 244, + 245, + 246 + ], + "current_status": "一部実装・差異あり", + "current_note": "ドット人物と一部状態モーションあり。 原本が求める全モーションと盾/煙/マナ等の全演出資産は未完成。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "ドット人物と一部状態モーションあり。", + "gap_or_acceptance": "原本が求める全モーションと盾/煙/マナ等の全演出資産は未完成。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-13.3", + "section": "13.3", + "requirement": "Lv1〜3表裏タイル/背景", + "source_lines": [ + 247, + 248 + ], + "current_status": "一部実装・差異あり", + "current_note": "現行施設内装・背景・タイル素材あり。 全施設Lv1〜3の表裏外観/内装差分とLvUP表示接続が不足。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "現行施設内装・背景・タイル素材あり。", + "gap_or_acceptance": "全施設Lv1〜3の表裏外観/内装差分とLvUP表示接続が不足。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scripts/data/facility_catalog.gd", + "line": 28, + "symbol": "const FACILITY_PATHS" + }, + { + "path": "scripts/data/facility_definition.gd", + "line": 32, + "symbol": "@export var footprint" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 386, + "symbol": "FacilityCatalog.FACILITY_PATHS" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/s0/test_definition_registry.gd", + "tests/s1/test_runtime_world_construction.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-13.4", + "section": "13.4", + "requirement": "表裏スキン/感情/覗き素材", + "source_lines": [ + 249, + 250 + ], + "current_status": "一部実装・差異あり", + "current_note": "現行管理UI・ボタン・欲求バー等あり。 表裏スキン完成版、3感情グラフ、専用カメラオーバーレイなし。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "P", + "implemented": "現行管理UI・ボタン・欲求バー等あり。", + "gap_or_acceptance": "表裏スキン完成版、3感情グラフ、専用カメラオーバーレイなし。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + }, + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd", + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-13.5", + "section": "13.5", + "requirement": "BGM/環境/操作/成人向けSE/声", + "source_lines": [ + 251, + 252 + ], + "current_status": "該当機能未実装", + "current_note": "assets内にogg/wav/mp3がなく、本体コードにAudioStreamPlayer参照がない。 動的BGM、環境/操作SE、ボイス等の同梱・再生が未実装。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "M", + "implemented": "assets内にogg/wav/mp3がなく、本体コードにAudioStreamPlayer参照がない。", + "gap_or_acceptance": "動的BGM、環境/操作SE、ボイス等の同梱・再生が未実装。", + "source_evidence": [ + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-14.1", + "section": "14.1", + "requirement": "2026/03〜08 プロトタイプ", + "source_lines": [ + 255 + ], + "current_status": "工程要件・完了未認定", + "current_note": "MVP相当の開業ループと試験は存在する。 工程要件。2026/03〜08の納期達成や当時の受入完了を現在コードからは認定できない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "MVP相当の開業ループと試験は存在する。", + "gap_or_acceptance": "工程要件。2026/03〜08の納期達成や当時の受入完了を現在コードからは認定できない。", + "source_evidence": [ + { + "path": "scripts/world/world_service.gd", + "line": 103, + "symbol": "func get_zone" + }, + { + "path": "scripts/construction/construction_service.gd", + "line": 56, + "symbol": "func validate_placement" + }, + { + "path": "scripts/progression/progression_service.gd", + "line": 143, + "symbol": "func evaluate_rank" + }, + { + "path": "scripts/guest/guest_runtime.gd", + "line": 34, + "symbol": "var wallet_gold" + }, + { + "path": "scripts/guest/guest_service.gd", + "line": 1367, + "symbol": "func _primary_need_priority" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 7723, + "symbol": "func _advance_guest" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/s1/test_world_service.gd", + "tests/s1/test_construction_service.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/s3/test_guest_service.gd", + "tests/s3/test_runtime_actor_cycle.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-14.2", + "section": "14.2", + "requirement": "2026/09〜2027/02 アルファ", + "source_lines": [ + 256 + ], + "current_status": "工程要件・完了未認定", + "current_note": "パラメータ基盤あり。現日はアルファ予定期間内。 量産素材・シナリオ・通常BGMの工程完了は未達。機能完了数に混ぜない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "パラメータ基盤あり。現日はアルファ予定期間内。", + "gap_or_acceptance": "量産素材・シナリオ・通常BGMの工程完了は未達。機能完了数に混ぜない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-14.3", + "section": "14.3", + "requirement": "2027/03〜08 ベータ/成人向け", + "source_lines": [ + 257 + ], + "current_status": "工程要件・完了未認定", + "current_note": "原本に将来工程として記載。 2027/03〜08の完成条件であり、現在完了扱いにしない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "原本に将来工程として記載。", + "gap_or_acceptance": "2027/03〜08の完成条件であり、現在完了扱いにしない。", + "source_evidence": [ + { + "path": "scripts/data/mvp_content_catalog.gd", + "line": 33, + "symbol": "const REQUIRED_COUNTS" + }, + { + "path": "data/characters/mvp_unique_characters.json", + "line": 16, + "symbol": "\"mechanical_effects\"" + }, + { + "path": "autoload/simulation_runtime.gd", + "line": 4895, + "symbol": "func _initialize_s4_relationship_events" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/mvp/test_mvp_content_catalog.gd", + "tests/mvp/test_runtime_content_progression.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + }, + { + "id": "B-14.4", + "section": "14.4", + "requirement": "2027/09〜12 ポリッシュ", + "source_lines": [ + 258 + ], + "current_status": "工程要件・完了未認定", + "current_note": "デバッグ・性能確認の実施記録はある。 2027/09〜12のマスターアップ工程全体の承認証拠はない。個別PASSで代替しない。", + "previous_status": "未完了・現行版の全受入未確認", + "status_code": "N", + "implemented": "デバッグ・性能確認の実施記録はある。", + "gap_or_acceptance": "2027/09〜12のマスターアップ工程全体の承認証拠はない。個別PASSで代替しない。", + "source_evidence": [ + { + "path": "scenes/main/main.gd", + "line": 13, + "symbol": "const MVP_FACILITY_IDS" + }, + { + "path": "scripts/ui/guest_observation_panel.gd", + "line": 15, + "symbol": "func" + }, + { + "path": "scripts/ui/business_management_panel.gd", + "line": 171, + "symbol": "func submit_policy" + }, + { + "path": "scripts/ui/pixel_people.gd", + "line": 10, + "symbol": "func" + }, + { + "path": "scripts/ui/pixel_tower_art.gd", + "line": 13, + "symbol": "func" + }, + { + "path": "scenes/main/world_preview.gd", + "line": 468, + "symbol": "func _draw_background" + } + ], + "related_component_tests": [ + "tests/ui/test_main_dashboard.gd", + "tests/ui/test_business_management.gd", + "tests/ui/test_guest_observation.gd", + "tests/ui/test_pixel_tower.gd", + "tests/ui/test_room_presentation.gd" + ], + "audit_date": "2026-09-29", + "audit_report": "audit-2026-09-29/TRACEABILITY.md" + } +] diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 12ddf2f..ea4e188 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -1,21 +1,32 @@ extends Control +const RankDialogScript := preload("res://scripts/ui/world_tree_rank_dialog.gd") + const OpeningGuideScript := preload("res://scripts/ui/inn_opening_guide.gd") const OperatingLedgerPanelScript := preload("res://scripts/ui/operating_ledger_panel.gd") const StaffManagementPanelScript := preload("res://scripts/ui/staff_management_panel.gd") const SupplyManagementPanelScript := preload("res://scripts/ui/supply_management_panel.gd") const BusinessManagementPanelScript := preload("res://scripts/ui/business_management_panel.gd") +const DefenseManagementPanelScript := preload("res://scripts/ui/defense_management_panel.gd") const WorldPreviewScript := preload("res://scenes/main/world_preview.gd") +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres") +var _last_presented_close_minute := 0 + const MANUAL_SAVE_PATH := "user://saves/manual_ui" const MVP_FACILITY_IDS: Array[StringName] = [ + &"cleaning_room", + &"chapel", &"reception", &"standard_room", + &"premium_room", &"cafeteria", &"warehouse", &"kitchen", &"public_bath", &"staff_waiting_room", + &"staff_dormitory", + &"infirmary", &"security_station", &"tavern", &"massage_room", @@ -40,6 +51,9 @@ const FACILITY_STATE_NAMES := [ const ELEVATOR_PHASE_NAMES := {&"IDLE": "待機", &"PICKUP": "迎車中", &"BOARDING": "乗車中", &"TRAVEL": "運行中", &"ALIGHTING": "降車中"} const FACILITY_REASON_NAMES := { &"BROKEN": "故障", + &"DESTROYED": "破壊による休止", + &"RAIDED": "摘発による休止", + &"RELATED_FACILITY_INACTIVE": "関連施設が未稼働", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力供給なし", &"NO_STAFF": "必要人員不足", @@ -83,7 +97,7 @@ const STAFF_ROLE_NAMES := { &"masseur": "施術", &"medic": "医療", &"porter": "物流", - &"receptionist": "受付", + &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者", } const STAFF_SHIFT_NAMES := { &"day": "日勤", @@ -102,9 +116,16 @@ var manual_save_path := MANUAL_SAVE_PATH var time_label: Label var phase_label: Label -var guest_count_label: Label +var rank_button: Button +var rank_dialog: ConfirmationDialog +var weather_label: Label +var guest_count_label: Button +var dimension_migration_dialog: ConfirmationDialog +var _dimension_migration_digest := "" +var guest_observer: AcceptDialog var lodging_count_label: Label var alert_count_label: Label +var defense_manager: AcceptDialog var alert_icon_node: TextureRect var resource_labels: Dictionary = {} var resource_icon_nodes: Dictionary = {} @@ -112,6 +133,7 @@ var world_preview: WorldPreview var front_button: Button var back_button: Button var excavation_button: Button +var pan_button: Button var pause_button: Button var speed_buttons: Dictionary = {} var dashboard_summary_label: Label @@ -127,6 +149,8 @@ var staff_candidate_toggle_button: Button var staff_candidate_buttons: Dictionary = {} var selected_heading_label: Label var selected_detail_label: Label +var related_facility_picker: OptionButton +var _pending_related_commands: Dictionary = {} var selected_entity_icon: TextureRect var selected_elevator_controls: VBoxContainer var elevator_operation_button: Button @@ -134,6 +158,7 @@ var elevator_operation_hint: Label var build_status_label: Label var build_buttons: Dictionary = {} var navigation_buttons: Dictionary = {} +var _interface_layer := -1 var opening_guide: Control var operating_ledger_panel: VBoxContainer var supply_management_panel: VBoxContainer @@ -197,6 +222,12 @@ func _ready() -> void: "本番運営を開始できませんでした: %s" % _result_message(_operations_start_report), false ) + _focus_initial_view.call_deferred() + + +func _focus_initial_view() -> void: + await get_tree().process_frame + world_preview.focus_operating_area() func _pause_fresh_opening() -> void: @@ -240,8 +271,8 @@ func _unhandled_key_input(event: InputEvent) -> void: _set_speed(1) KEY_2: _set_speed(2) - KEY_4: - _set_speed(4) + KEY_3: + _set_speed(3) KEY_R: if _selected_definition == null: handled = false @@ -255,6 +286,7 @@ func _unhandled_key_input(event: InputEvent) -> void: func _build_interface() -> void: var background := ColorRect.new() + background.name = "InterfaceBackground" background.color = _visual_color(&"window_background", Color("24170f")) background.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) background.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -298,7 +330,7 @@ func _build_interface() -> void: map_panel.add_child(world_preview) opening_guide = OpeningGuideScript.new() world_preview.add_child(opening_guide) - opening_guide.setup(_runtime, GameClock) + opening_guide.setup(_runtime, GameClock, GameState.active_layer) opening_guide.position = Vector2(8, 8) opening_guide.size = Vector2(302, 0) opening_guide.action_requested.connect(_on_opening_action) @@ -334,11 +366,23 @@ func _create_top_bar() -> Control: title.add_theme_font_size_override("font_size", 17) title.add_theme_color_override("font_color", Color("fff0c8")) title_box.add_child(title) - var subtitle := Label.new() - subtitle.text = "世界樹の宿" - subtitle.add_theme_font_size_override("font_size", 10) - subtitle.add_theme_color_override("font_color", Color("e5c992")) - title_box.add_child(subtitle) + rank_button=Button.new() + rank_button.name="WorldTreeRankButton" + rank_button.text="世界樹ランク 1" + rank_button.flat=true + rank_button.alignment=HORIZONTAL_ALIGNMENT_LEFT + rank_button.add_theme_font_size_override("font_size",10) + rank_button.add_theme_stylebox_override("normal",StyleBoxEmpty.new()) + rank_button.tooltip_text="世界樹のランクと昇格条件を確認" + title_box.add_child(rank_button) + rank_dialog=RankDialogScript.new() + rank_dialog.configure(_runtime) + rank_dialog.status_requested.connect(func(message: String, success: bool) -> void: + _set_status(message,success) + _refresh_dashboard() + ) + add_child(rank_dialog) + rank_button.pressed.connect(rank_dialog.show_rank) row.add_child(VSeparator.new()) time_label = Label.new() @@ -351,8 +395,18 @@ func _create_top_bar() -> Control: phase_label.add_theme_font_size_override("font_size", 12) phase_label.add_theme_color_override("font_color", Color("ffd37a")) row.add_child(phase_label) + weather_label = _hud_value_label("晴れ", 48) + weather_label.name = "WeatherLabel" + weather_label.tooltip_text = "嵐の間は新規入館が停止します。現在のお客様は館内に残ります。" + weather_label.mouse_filter = Control.MOUSE_FILTER_STOP + row.add_child(weather_label) - guest_count_label = _hud_value_label("来客 0人", 72) + guest_count_label = Button.new() + guest_count_label.text = "来客 0人" + guest_count_label.tooltip_text = "来客の欲求と現在の目的地を見る" + guest_count_label.custom_minimum_size.x = 72 + guest_count_label.add_theme_font_size_override("font_size", 12) + guest_count_label.pressed.connect(_show_guest_observer) lodging_count_label = _hud_value_label("宿泊 0人", 72) row.add_child(guest_count_label) row.add_child(lodging_count_label) @@ -374,6 +428,9 @@ func _create_top_bar() -> Control: resource_block.add_child(label) row.add_child(resource_block) alert_count_label = _hud_value_label("警戒 0", 64) + alert_count_label.mouse_filter = Control.MOUSE_FILTER_STOP + alert_count_label.focus_mode = Control.FOCUS_ALL + alert_count_label.gui_input.connect(_on_defense_notice_input) alert_count_label.add_theme_color_override("font_color", Color("ffb095")) alert_icon_node = _icon_rect(visual_theme.control_icon(&"alert") if visual_theme != null else null, 20) alert_icon_node.name = "AlertIcon" @@ -384,7 +441,7 @@ func _create_top_bar() -> Control: row.add_child(spacer) pause_button = _button("停止", _toggle_pause, 52) row.add_child(pause_button) - for value: int in [1, 2, 4]: + for value: int in [1, 2, 3]: var speed_button := _button("x%d" % value, _set_speed.bind(value), 42) speed_button.toggle_mode = true speed_buttons[value] = speed_button @@ -424,7 +481,7 @@ func _create_navigation_rail() -> Control: column.add_child(nav_spacer) var layer_hint := Label.new() layer_hint.text = "TAB\n表 / 裏" - layer_hint.tooltip_text = "ホイール: 拡大縮小\n中ボタンドラッグ: 移動\nHome: 全体表示" + layer_hint.tooltip_text = "ホイール: 拡大縮小\n画面移動ボタン → 左ドラッグ: 移動\n中ボタンドラッグ: 移動\nHome: 全体表示" layer_hint.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER layer_hint.add_theme_font_size_override("font_size", 10) layer_hint.add_theme_color_override("font_color", Color("e2c38c")) @@ -487,6 +544,9 @@ func _create_side_panel() -> Control: var candidate_heading_spacer := Control.new() candidate_heading_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL candidate_heading_row.add_child(candidate_heading_spacer) + var defense_button := _button("防衛", _show_defense_manager, 52) + defense_button.name = "DefenseManagementButton" + candidate_heading_row.add_child(defense_button) staff_management_toggle_button = _button("雇用済み", _toggle_staff_management_panel, 62) staff_management_toggle_button.name = "StaffManagementToggleButton" staff_management_toggle_button.toggle_mode = true @@ -542,6 +602,12 @@ func _create_side_panel() -> Control: selected_detail_label.add_theme_color_override("font_color", Color("4d341f")) selected_detail_label.add_theme_font_size_override("font_size", 13) selected_row.add_child(selected_detail_label) + related_facility_picker = OptionButton.new() + related_facility_picker.name = "RelatedFacilityPicker" + related_facility_picker.visible = false + related_facility_picker.tooltip_text = "自施設の稼働に必要な関連施設を指定します。休止状態は解除しません。" + related_facility_picker.item_selected.connect(_on_related_facility_selected) + column.add_child(related_facility_picker) selected_elevator_controls = VBoxContainer.new() selected_elevator_controls.name = "SelectedElevatorControls" selected_elevator_controls.visible = false @@ -557,7 +623,7 @@ func _create_side_panel() -> Control: operating_ledger_panel = OperatingLedgerPanelScript.new() root_column.add_child(operating_ledger_panel) - operating_ledger_panel.setup(_runtime, GameClock) + operating_ledger_panel.setup(_runtime, GameClock, _apply_button_style, GameState.active_layer) operating_ledger_panel.visible = false supply_management_panel = SupplyManagementPanelScript.new() supply_management_panel.configure(_runtime, _apply_button_style) @@ -607,6 +673,15 @@ func _create_bottom_bar() -> Control: _apply_button_icon(rotate_button, visual_theme.control_icon(&"rotate") if visual_theme != null else null, 16) row.add_child(rotate_button) row.add_child(_button("解除 Esc", _clear_active_tool, 80)) + pan_button = _button("画面移動", _toggle_pan_mode, 90) + pan_button.name = "PanModeButton" + pan_button.toggle_mode = true + pan_button.tooltip_text = "左ドラッグで画面を移動。もう一度押すか Esc で解除。ホイールで拡大縮小、Homeで全体表示。" + row.add_child(pan_button) + var focus_button := _button("営業階", func() -> void: world_preview.focus_operating_area(), 64) + focus_button.name = "FocusOperatingAreaButton" + focus_button.tooltip_text = "現在の層の建物を見やすい大きさで表示。Homeで全体表示。" + row.add_child(focus_button) var spacer := Control.new() spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL row.add_child(spacer) @@ -617,7 +692,18 @@ func _create_bottom_bar() -> Control: var save_button := _button("保存", _save_manual, 62) _apply_button_icon(save_button, visual_theme.control_icon(&"save") if visual_theme != null else null, 16) row.add_child(save_button) - row.add_child(_button("読込", _load_manual, 56)) + var load_menu := MenuButton.new() + load_menu.name = "SaveLoadMenu" + load_menu.text = "読込 ▾" + load_menu.custom_minimum_size = Vector2(64,28) + load_menu.add_theme_font_size_override("font_size",12) + _apply_button_style(load_menu) + load_menu.get_popup().add_item("手動セーブを読み込む",0) + load_menu.get_popup().add_item("旧寸法を修正して読み込む",1) + load_menu.get_popup().id_pressed.connect(func(id: int): + if id == 0: _load_manual() + elif id == 1: _preview_dimension_migration()) + row.add_child(load_menu) palette_scroll = ScrollContainer.new() palette_scroll.name = "BuildPaletteScroll" @@ -654,6 +740,9 @@ func _set_build_palette_open(open: bool) -> void: if palette_toggle_button != null: palette_toggle_button.set_pressed_no_signal(open) palette_toggle_button.text = "建築を閉じる B" if open else "建築 B" + if not open: + var bottom := find_child("BottomBar", true, false) as Control + if bottom != null: bottom.reset_size.call_deferred() func _toggle_management_panel() -> void: @@ -697,7 +786,7 @@ func _activate_management_mode(mode: StringName) -> void: &"investment": _set_status("投資管理は一覧画面の実装準備中です。", true, true) &"ledger": - _set_status("本日の資金増減と、06:00に確定した営業日の損益を表示します。", true, true) + _set_status("本日の資金増減と、00:00に確定した営業日の損益を表示します。", true, true) func _set_ledger_panel_open(open: bool) -> void: @@ -776,14 +865,23 @@ func _connect_signals() -> void: GameClock.phase_changed.connect(_on_phase_changed) GameState.resource_changed.connect(_on_resource_changed) world_preview.facility_selected.connect(_on_facility_selected) + world_preview.staff_assignment_dropped.connect(func(data: Dictionary, facility_id: StringName) -> void: + staff_management_panel.assign_from_drop(data, facility_id) + ) world_preview.legacy_stair_selected.connect(_on_legacy_stair_selected) world_preview.construction_selected.connect(_on_construction_selected) world_preview.cell_clicked.connect(_on_map_cell_clicked) world_preview.build_preview_changed.connect(_on_build_preview_changed) world_preview.excavation_preview_changed.connect(_on_excavation_preview_changed) if _runtime != null and _runtime is Object: - if _runtime.has_signal(&"snapshot_published"): + if _runtime.has_signal(&"state_updated"): + _runtime.connect(&"state_updated", Callable(self, "_on_snapshot_published")) + elif _runtime.has_signal(&"snapshot_published"): _runtime.connect(&"snapshot_published", Callable(self, "_on_snapshot_published")) + if _runtime.has_signal(&"checkpoint_presentation_requested"): + _runtime.connect(&"checkpoint_presentation_requested", Callable(self, "_on_checkpoint_presentation_requested")) + if _runtime.has_signal(&"save_operation_completed"): + _runtime.connect(&"save_operation_completed", Callable(self, "_on_save_operation_completed")) if _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", Callable(self, "_on_command_completed")) @@ -847,7 +945,7 @@ func _populate_staff_candidates() -> void: content.add_child(detail) var hire_button := Button.new() hire_button.name = "Hire_%s" % String(candidate_id) - hire_button.text = "雇用する" + hire_button.text = "無料で加入" if bool(summary.get(&"unique", false)) else "雇用する" hire_button.custom_minimum_size.y = 30 _apply_button_style(hire_button) hire_button.pressed.connect(_hire_staff_candidate.bind(candidate_id)) @@ -950,7 +1048,7 @@ func _refresh_staff_candidates() -> void: continue var pending := _pending_hire_commands.values().has(candidate_id) button.disabled = hired or pending - button.text = "雇用済み" if hired else "手続き中…" if pending else "雇用する" + button.text = ("加入済み" if bool(summary.get(&"unique", false)) else "雇用済み") if hired else "手続き中…" if pending else ("無料で加入" if bool(summary.get(&"unique", false)) else "雇用する") staff_candidate_summary_label.text = "未雇用 %d人 / 雇用済み %d人" % [ maxi(summaries.size() - hired_count, 0), hired_count, @@ -1022,8 +1120,8 @@ func _hire_failure_message(result: Variant) -> String: if details is Dictionary: var reason := StringName(details.get(&"reason", &"")) match reason: - &"ARREARS_REMAIN": - return "雇用不可: 未払給与 G%dを清算してください。" % int(details.get(&"arrears_total", 0)) + &"ARREARS_REMAIN", &"UNPAID_EXPENSES": + return "雇用不可: 未払経費 G%dを清算してください。" % int(details.get(&"arrears_total", 0)) &"BANKRUPT": return "雇用不可: 資金破綻後は新しい契約を結べません。" &"EXTERNAL_ROLLBACK_FAILED": @@ -1065,6 +1163,7 @@ func _select_build_definition(definition_id: StringName) -> void: _set_status("選択した施設定義が見つかりません。", false) return _set_excavation_mode(false, false) + _set_pan_mode(false) _selected_definition_id = definition_id _selected_definition = definition _build_rotation = 0 @@ -1089,6 +1188,10 @@ func _clear_build_selection(announce := true) -> void: func _clear_active_tool() -> void: + if world_preview.pan_mode: + _set_pan_mode(false) + _set_status("画面移動を解除しました。施設をクリックすると情報を表示します。", true, true) + return if _supply_panel_open or _business_panel_open: _set_management_panel_open(false) return @@ -1110,7 +1213,24 @@ func _toggle_excavation_mode() -> void: _set_excavation_mode(not _excavation_mode) +func _toggle_pan_mode() -> void: + _set_pan_mode(not world_preview.pan_mode) + _set_status("画面移動中:左ドラッグで移動、ホイールで拡大縮小。Escで解除。" if world_preview.pan_mode else "画面移動を解除しました。施設をクリックすると情報を表示します。", true, true) + + +func _set_pan_mode(enabled: bool) -> void: + if enabled: + _clear_build_selection(false) + _set_excavation_mode(false, false) + _set_build_palette_open(false) + world_preview.set_pan_mode(enabled) + if pan_button != null: + pan_button.set_pressed_no_signal(enabled) + + func _set_excavation_mode(enabled: bool, announce := true) -> void: + if enabled: + _set_pan_mode(false) _excavation_mode = enabled if excavation_button != null: excavation_button.set_pressed_no_signal(_excavation_mode) @@ -1316,6 +1436,13 @@ func _on_construction_selected(job_id: StringName) -> void: func _refresh_dashboard() -> void: + if rank_button != null and _service(&"progression_service") != null: + rank_button.text="世界樹ランク %d" % _service(&"progression_service").current_rank + if rank_dialog != null and rank_dialog.visible: rank_dialog.refresh() + if weather_label != null and is_instance_valid(_runtime) and _runtime.has_method("get_weather_report"): + var weather_report: Dictionary = _runtime.get_weather_report() + var weather_names := {&"clear": "晴れ", &"rain": "雨", &"storm": "嵐", &"mana_fog": "マナ霧"} + weather_label.text = weather_names.get(weather_report.get(&"kind", &""), "天候 —") var guests := _call_array(_service(&"guest_service"), &"get_guests") var queued_guests := 0 var served_guests := 0 @@ -1360,12 +1487,22 @@ func _refresh_dashboard() -> void: if foreground != null: foreground_text = String(foreground.resolved_event_id) _refresh_foreground_event(event_service) + var defense_summary: Dictionary = {} + var defense: Variant = _service(&"defense_service") + if defense != null and defense.has_method(&"get_threat_summary"): + defense_summary = defense.call(&"get_threat_summary") + var threat_count := int(defense_summary.get(&"total", 0)) + var defense_text := "防衛 準備中 %d / 戦闘中 %d / 次波待ち %d" % [ + int(defense_summary.get(&"preparing", 0)), int(defense_summary.get(&"active", 0)), + int(defense_summary.get(&"between_waves", 0))] var operations_text := _operations_dashboard_text() dashboard_summary_label.text = ( "客 %d人(待機 %d / 利用 %d)\n" % [guests.size(), queued_guests, served_guests] + "従業員 %d人(待機 %d / 作業 %d)\n" % [staff_members.size(), available_staff, working_staff] + "イベント %d件 前景: %s\n" % [active_events, foreground_text] + "警戒度 %s  建設中 %d件" % [heat_text, active_jobs] + # Keep a stable row so threat changes do not move staff drag targets. + + "\n" + defense_text + operations_text ) if guest_count_label != null: @@ -1377,10 +1514,11 @@ func _refresh_dashboard() -> void: lodging_guests += 1 lodging_count_label.text = "宿泊 %d人" % lodging_guests if alert_count_label != null: - alert_count_label.text = "警戒 %d" % heat_value + alert_count_label.text = "警戒 %d" % heat_value + (" / 襲撃 %d" % threat_count if threat_count > 0 else "") + alert_count_label.tooltip_text = (defense_text + "\n防衛参加中の従業員は配属変更できません。") if threat_count > 0 else "進行中の襲撃はありません。" alert_count_label.add_theme_color_override( "font_color", - Color("ffb498") if heat_value >= 60 else Color("ebca84") if heat_value >= 30 else Color("c7d3a8") + Color("ffb498") if heat_value >= 60 or threat_count > 0 else Color("ebca84") if heat_value >= 30 else Color("c7d3a8") ) _refresh_staff_candidates() _refresh_selected_entity() @@ -1502,7 +1640,7 @@ func _submit_foreground_event_choice( func _operations_dashboard_text() -> String: if _runtime == null or not _runtime.has_method(&"get_operations_report"): return "" - var report_value: Variant = _runtime.call(&"get_operations_report") + var report_value: Variant = _runtime.call(&"get_operations_dashboard_report") if _runtime.has_method(&"get_operations_dashboard_report") else _runtime.call(&"get_operations_report") if not report_value is Dictionary: return "" var report: Dictionary = report_value @@ -1513,7 +1651,7 @@ func _operations_dashboard_text() -> String: var terminal := StringName(coordinator.get(&"terminal_state", &"RUNNING")) var state_text := "営業中" if terminal == &"BANKRUPT": - state_text = "破綻(終了)" + state_text = "ゲーム終了" elif bool(report.get(&"runtime_failure_latched", false)): state_text = "処理失敗・再試行待ち" elif bool(report.get(&"tick_blocked", false)): @@ -1524,16 +1662,19 @@ func _operations_dashboard_text() -> String: var latest_value: Variant = summaries_value.back() if latest_value is Dictionary: day_profit_text = "G %+d" % int(latest_value.get(&"net_profit", 0)) + var old_financial_history: bool = summaries_value is Array and not summaries_value.is_empty() and int(summaries_value.back().get(&"day", 0)) <= int(coordinator.get(&"legacy_history_last_day", 0)) return ( - "\n行動点 %d / 6  未払給与 G %d\n" % [ + "\n行動点 %d / 6  未払経費 G %d\n" % [ int(report.get(&"personal_action_points", 0)), - int(coordinator.get(&"arrears_total", 0)), + int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0))), ] + "運営状態 %s  前日損益 %s" % [state_text, day_profit_text] + + "\n%s %d / 3回" % ["旧版の連続未払い" if old_financial_history else "連続赤字", int(coordinator.get(&"consecutive_insolvency_count", 0))] ) func _refresh_selected_entity() -> void: + related_facility_picker.visible = false selected_elevator_controls.visible = false selected_detail_label.tooltip_text = "" if _selected_entity_id.is_empty(): @@ -1576,12 +1717,26 @@ func _refresh_selected_entity() -> void: selected_entity_icon.texture = null return var construction_definition_id := StringName(construction_facility.get(&"definition_id", &"")) - selected_heading_label.text = String(construction_definition_id) + var infrastructure_definition: Variant = _facility_definition(construction_definition_id) + selected_heading_label.text = String(_definition_value(infrastructure_definition, &"display_name", construction_definition_id)) selected_entity_icon.texture = visual_theme.facility_icon(construction_definition_id) if visual_theme != null else null - selected_detail_label.text = "施設データを運営サービスへ同期しています。" + selected_detail_label.text = "設備完成\n" + _facility_upkeep_text(_selected_entity_id) return var definition: Variant = _facility_definition(facility.definition_id) selected_entity_icon.texture = visual_theme.facility_icon(facility.definition_id) if visual_theme != null else null + related_facility_picker.clear() + related_facility_picker.add_item("関連施設:指定なし") + related_facility_picker.set_item_metadata(0, &"") + for related: FacilityInstance in _runtime.facility_service.get_instances(): + if related.instance_id == facility.instance_id: + continue + var related_definition: Variant = _facility_definition(related.definition_id) + related_facility_picker.add_item("関連施設:%s (%d, %d)" % [String(_definition_value(related_definition, &"display_name", related.definition_id)), related.origin.x, related.origin.y]) + var index := related_facility_picker.item_count - 1 + related_facility_picker.set_item_metadata(index, related.instance_id) + if related.instance_id == facility.related_facility_id: + related_facility_picker.select(index) + related_facility_picker.visible = true var display_name := String(_definition_value(definition, &"display_name", facility.definition_id)) var state_index := clampi(int(facility.state), 0, FACILITY_STATE_NAMES.size() - 1) var reasons := _facility_reasons(facility.state_reasons) @@ -1589,16 +1744,43 @@ func _refresh_selected_entity() -> void: var service_count: int = facility.active_services.size() var staff_count: int = facility.active_staff_ids.size() var occupied_slots: int = service_count + var related_name := "なし" + if not facility.related_facility_id.is_empty(): + var related: FacilityInstance = _runtime.facility_service.get_instance(facility.related_facility_id) + related_name = "撤去済み" if related == null else "%s (%d, %d)" % [String(_definition_value(_facility_definition(related.definition_id), &"display_name", related.definition_id)), related.origin.x, related.origin.y] selected_heading_label.text = display_name selected_detail_label.text = ( "状態 %s%s\n" % [FACILITY_STATE_NAMES[state_index], "(%s)" % reasons if not reasons.is_empty() else ""] + "待機列 %d / %d  サービス %d / %d\n" % [queue_count, int(facility.queue_capacity), occupied_slots, int(facility.capacity)] + + "稼働区分 %s\n顧客同時利用 %d / %s\n" % [["未稼働", "稼働", "休止"][facility.operation_state], facility.customer_count(), str(facility.customer_capacity) if facility.customer_capacity >= 0 else "未設定"] + + "関連施設 %s(%s)\n従業員上限 %d\n" % [related_name, "稼働条件成立" if facility.related_facility_operating else "未成立", facility.staff_max] + "配置人員 %d / 必要 %d  耐久 %d%%\n" % [staff_count, int(facility.staff_min), int(facility.durability) / 100] + + _facility_upkeep_text(_selected_entity_id) + "基本料金 G %d  所要 %d分\n" % [int(facility.base_price), int(facility.base_service_minutes)] + + "提供サービス %s\n" % {&"": "未設定", &"appetite": "食欲", &"relaxation": "リラックス欲", &"sexual": "性欲", &"gambling": "ギャンブル欲"}.get(facility.service_attribute, "未設定") + "座標 (%d, %d) %s" % [facility.origin.x, facility.origin.y, "FRONT" if facility.origin.z == 0 else "BACK"] ) +func _facility_upkeep_text(facility_id: StringName) -> String: + if _runtime == null or not _runtime.has_method(&"get_facility_upkeep_report"): + return "" + var report: Dictionary = _runtime.call(&"get_facility_upkeep_report", facility_id) + var pending := float(report[&"unsettled_units"]) / float(report[&"gold_denominator"]) + return "維持費 %dG / 日\n精算前 約%.2fG 未払い %dG\n閉店・休止中も計上\n完成から撤去までの時間で按分\n" % [int(report[&"daily_rate"]), pending, int(report[&"arrears"])] + + +func _on_related_facility_selected(index: int) -> void: + if _runtime == null or _selected_entity_id.is_empty(): + return + var related_id := StringName(related_facility_picker.get_item_metadata(index)) + var result: Variant = _runtime.call(&"submit_command", &"set_facility_conditions", {&"facility_id": _selected_entity_id, &"conditions": {&"related_facility_id": related_id}}) + if result.is_success() and not result.details.has(&"instance_id"): + _pending_related_commands[int(result.command_id)] = true + _set_status("関連施設の変更を受け付けました。" if result.is_success() else result.message, result.is_success(), true) + _refresh_selected_entity() + + func _refresh_selected_stairs() -> bool: var from_cell := Vector3i.ZERO var to_cell := Vector3i.ZERO @@ -1703,12 +1885,13 @@ func _move_selected_detail_first(first: bool) -> void: var selected_row := selected_detail_label.get_parent() # The transport action fits without scrolling; other management views keep # their existing staff-first layout. - var desired_index := 0 if first else column.get_child_count()-3 + var desired_index := 0 if first else column.get_child_count()-4 if selected_heading_label.get_index() == desired_index: return column.move_child(selected_heading_label, 0 if first else -1) column.move_child(selected_row, 1 if first else -1) column.move_child(selected_elevator_controls, 2 if first else -1) + column.move_child(related_facility_picker, 3 if first else -1) func _elevator_floor_name(floor_index: int) -> String: @@ -1894,9 +2077,9 @@ func _excavation_gate_message() -> String: return "資金破綻により新しい掘削を開始できません" if bool(coordinator.get(&"external_quarantined", false)): return "運営データの復旧が必要なため掘削を開始できません" - var arrears := int(coordinator.get(&"arrears_total", 0)) + var arrears := int(report.get(&"arrears_total", coordinator.get(&"arrears_total", 0))) if arrears > 0: - return "未払給与 G %dを清算するまで掘削できません" % arrears + return "未払経費 G %dを清算するまで掘削できません" % arrears return "" @@ -1908,8 +2091,8 @@ func _excavation_command_failure_message(result: Variant) -> String: return _excavation_failure_message(report_value) var reason := StringName(details.get(&"reason", &"")) match reason: - &"ARREARS_REMAIN": - return "掘削不可: 未払給与 G %dを清算してください" % int(details.get(&"arrears_total", 0)) + &"ARREARS_REMAIN", &"UNPAID_EXPENSES": + return "掘削不可: 未払経費 G %dを清算してください" % int(details.get(&"arrears_total", 0)) &"BANKRUPT": return "掘削不可: 資金破綻により新しい工事は開始できません" &"EXTERNAL_ROLLBACK_FAILED": @@ -1942,14 +2125,14 @@ func _save_manual() -> void: if directory_error != OK and directory_error != ERR_ALREADY_EXISTS: _set_status("保存フォルダを作成できませんでした。", false) return - var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存"}) + var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存", &"interface_selection": _capture_interface_selection()}) if result is Dictionary and bool(result.get(&"ok", false)): _set_status("手動セーブを保存しました。", true) else: _set_status("保存に失敗しました: %s" % _result_message(result), false) -func _load_manual() -> void: +func _load_manual(strict: bool = false) -> void: if _runtime == null or ( not _runtime.has_method(&"load_with_recovery") and not _runtime.has_method(&"load_from_path") @@ -1958,32 +2141,135 @@ func _load_manual() -> void: return var result: Variant = ( _runtime.call(&"load_with_recovery", manual_save_path) - if _runtime.has_method(&"load_with_recovery") + if not strict and _runtime.has_method(&"load_with_recovery") else _runtime.call(&"load_from_path", manual_save_path) ) if result is Dictionary and bool(result.get(&"ok", false)): - _pending_build_commands.clear() - _pending_excavation_commands.clear() - _pending_hire_commands.clear() - _pending_elevator_commands.clear() - _synchronize_ui_command_state() - if staff_management_panel != null: - staff_management_panel.refresh() - if supply_management_panel != null: - supply_management_panel.refresh() - if business_management_panel != null: - business_management_panel.refresh() - _selected_entity_id = &"" - _selected_entity_kind = &"" - world_preview.selected_entity_id = &"" - world_preview.queue_redraw() - _refresh_dashboard() - _update_pause_and_speed() + # Real Runtime already emitted its common completion notification. + if not _runtime.has_signal(&"save_operation_completed"): + _apply_loaded_interface(result) _set_status("手動セーブを読み込みました。", true) else: _set_status("読み込みに失敗しました: %s" % _result_message(result), false) +func _capture_interface_selection() -> Dictionary: + var selection := {&"version": 1, &"kind": String(_selected_entity_kind), &"id": String(_selected_entity_id), &"staff_open": _staff_management_panel_open, &"guest_open": false} + if staff_management_panel != null: + selection[&"staff_id"] = String(staff_management_panel.get("selected_staff_id")) + if guest_observer != null: + var panel := guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel + if panel != null: + selection[&"guest_id"] = String(panel.selected_guest_id) + selection[&"guest_open"] = guest_observer.visible + if defense_manager != null: + var panel := defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel + if panel != null: + selection[&"defense_id"] = String(panel.selected_raid_id) + selection[&"defense_open"] = defense_manager.visible + return selection + + +func _on_checkpoint_presentation_requested(presentation: Dictionary) -> void: + presentation[&"interface_selection"] = _capture_interface_selection() + + +func _on_save_operation_completed(result: Dictionary) -> void: + if not bool(result.get(&"ok", false)) or StringName(result.get(&"operation", &"")) not in [&"load", &"recovery", &"recovery_candidate"]: + return + _apply_loaded_interface(result) + + +func _apply_loaded_interface(result: Dictionary) -> void: + _pending_build_commands.clear() + _pending_excavation_commands.clear() + _pending_hire_commands.clear() + _pending_elevator_commands.clear() + _synchronize_ui_command_state() + if staff_management_panel != null: + staff_management_panel.refresh() + if supply_management_panel != null: + supply_management_panel.refresh() + if business_management_panel != null: + business_management_panel.refresh() + _selected_entity_id = &"" + _selected_entity_kind = &"" + world_preview.selected_entity_id = &"" + world_preview.set_runtime(_runtime) + _set_layer(GameState.active_layer) + _restore_interface_selection(result.get(&"manifest", {})) + _restore_actor_interface_selection(result.get(&"manifest", {})) + _restore_defense_interface_selection(result.get(&"manifest", {})) + _refresh_dashboard() + _update_pause_and_speed() + + +func _restore_interface_selection(manifest: Variant) -> void: + # Presentation metadata is optional. It must never invalidate gameplay, + # and recovery must use the manifest of the actually loaded candidate. + if not manifest is Dictionary: + return + var selection: Variant = manifest.get("interface_selection", {}) + if not selection is Dictionary or selection.get("version", 0) != 1: + return + var kind_value: Variant = selection.get("kind", "") + var id_value: Variant = selection.get("id", "") + if not kind_value is String or not id_value is String or id_value.is_empty() or id_value.length() > 256: + return + var kind := StringName(kind_value) + var entity_id := StringName(id_value) + var exists := false + match kind: + &"facility": + exists = _service(&"facility_service").get_instance(entity_id) != null or not _construction_facility(entity_id).is_empty() + &"construction": + exists = _service(&"construction_service").get_job(entity_id) != null + &"excavation": + exists = not _service(&"excavation_service").get_job(entity_id).is_empty() + &"legacy_stairs": + for stair: Dictionary in world_preview.get_legacy_stair_records(): + if StringName(stair.get(&"id", &"")) == entity_id: + exists = true + break + if not exists: + return + _selected_entity_kind = kind + _selected_entity_id = entity_id + world_preview.selected_entity_id = entity_id if kind != &"excavation" else &"" + _set_management_panel_open(true) + + +func _restore_actor_interface_selection(manifest: Variant) -> void: + # Only successful gameplay loads reach here. Reset stale live selections, + # then restore each optional inspector independently of the map selection. + _set_staff_management_panel_open(false) + if staff_management_panel != null: + staff_management_panel.call("select_staff", &"") + if guest_observer != null: + guest_observer.hide() + var old_panel := guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel + if old_panel != null: + old_panel.selected_guest_id = &"" + old_panel.refresh() + if not manifest is Dictionary: + return + var selection: Variant = manifest.get("interface_selection", {}) + if not selection is Dictionary or selection.get("version", 0) != 1: + return + var staff_id: Variant = selection.get("staff_id", "") + if staff_id is String and not staff_id.is_empty() and staff_id.length() <= 256 and _service(&"staff_service").has_staff(StringName(staff_id)): + staff_management_panel.call("select_staff", StringName(staff_id)) + if selection.get("staff_open", false) is bool and selection.get("staff_open", false): + _activate_management_mode(&"staff") + var guest_id: Variant = selection.get("guest_id", "") + if guest_id is String and not guest_id.is_empty() and guest_id.length() <= 256 and _service(&"guest_service").has_guest(StringName(guest_id)): + var panel := _ensure_guest_observer() + panel.selected_guest_id = StringName(guest_id) + panel.refresh() + if selection.get("guest_open", false) is bool and selection.get("guest_open", false): + _show_guest_observer() + + func _result_message(result: Variant) -> String: if result is Dictionary: var message := String(result.get(&"message", result.get(&"code", "保存データがありません"))) @@ -2058,15 +2344,55 @@ func _advance_ui_sequence_from_key(key: String) -> void: _elevator_sequence = maxi(_elevator_sequence, next_sequence) -func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: - _synchronize_elevator_pending() +var _dashboard_refresh_pending := false + + +func _queue_dashboard_refresh() -> void: + if _dashboard_refresh_pending: + return + _dashboard_refresh_pending = true + _flush_dashboard_refresh.call_deferred() + + +func _flush_dashboard_refresh() -> void: + if not _dashboard_refresh_pending or not is_inside_tree(): + return + _dashboard_refresh_pending = false _refresh_dashboard() + + +func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: + _synchronize_elevator_pending() + _queue_dashboard_refresh() _update_pause_and_speed() + var operation: Dictionary = _snapshot.get(&"systems", {}).get(&"runtime", {}).get(&"operations", {}).get(&"coordinator", {}) + var summaries: Array = operation.get(&"day_summaries", []) + if summaries.is_empty(): + _last_presented_close_minute = 0 + return + var latest: Dictionary = summaries.back() + var closed_at := int(latest.get(&"closed_at", 0)) + if closed_at > _last_presented_close_minute: + _last_presented_close_minute = closed_at + operating_ledger_panel.select_latest_close() + _activate_management_mode(&"ledger") + var losses := int(latest.get(&"consecutive_insolvency_count", 0)) + if int(latest[&"day"]) <= int(operation.get(&"legacy_history_last_day", 0)): + _set_status("旧版で確定した決算を表示しています。", true, true) + return + _set_status("3回連続の赤字決算でゲーム終了です。帳簿を確認できます。" if String(latest.get(&"terminal_state", "")) == "BANKRUPT" else "第%d日 決算:利益 %+dG・連続赤字 %d回" % [int(latest[&"day"]), int(latest[&"net_profit"]), losses], true, true) + else: + _last_presented_close_minute = closed_at func _on_command_completed(result: Variant) -> void: if result == null: return + if _pending_related_commands.has(int(result.command_id)): + _pending_related_commands.erase(int(result.command_id)) + _set_status("関連施設の設定を反映しました。" if result.is_success() else result.message, result.is_success()) + _refresh_selected_entity() + return if _pending_elevator_commands.has(int(result.command_id)): _pending_elevator_commands.erase(int(result.command_id)) _set_status("昇降機の運行設定を反映しました。" if result.is_success() else "昇降機の運行設定を反映できませんでした。", result.is_success()) @@ -2115,6 +2441,19 @@ func _set_layer(value: int) -> void: front_button.set_pressed_no_signal(normalized == 0) if back_button != null: back_button.set_pressed_no_signal(normalized == 1) + for label: Label in [dashboard_summary_label, staff_candidate_summary_label, selected_detail_label, elevator_operation_hint]: + if label != null: LayerSkin.register_text(label, normalized) + # Styles are registered in the saved layer during construction. + # Reapplying them before the initial container layout causes extra sizing. + if _interface_layer >= 0 and _interface_layer != normalized: + LayerSkin.apply_tree(self, normalized) + if rank_dialog != null: rank_dialog.apply_layer_skin(normalized) + _interface_layer = normalized + if foreground_event_label != null: + foreground_event_label.add_theme_color_override("font_color", Color("ffb498") if normalized == 1 else Color("8f3f29")) + var background := get_node_or_null("InterfaceBackground") as ColorRect + if background != null: + background.color = Color("10191e") if normalized == 1 else _visual_color(&"window_background", Color("24170f")) EventBus.layer_changed.emit(normalized) @@ -2166,7 +2505,7 @@ func _update_pause_and_speed() -> void: var operations_terminal := false var event_waiting := false if _runtime != null and _runtime.has_method(&"get_operations_report"): - var report_value: Variant = _runtime.call(&"get_operations_report") + var report_value: Variant = _runtime.call(&"get_operations_dashboard_report") if _runtime.has_method(&"get_operations_dashboard_report") else _runtime.call(&"get_operations_report") if report_value is Dictionary: operations_deferred = bool(report_value.get(&"enabled", false)) \ and int(report_value.get(&"deferred_runtime_minute", -1)) >= 0 @@ -2237,7 +2576,16 @@ func _on_resource_changed(resource_name: StringName, value: int) -> void: &"materials": "資材", &"faith": "信仰", } - (resource_labels[resource_name] as Label).text = "%s %d" % [titles[resource_name], value] + var label := resource_labels[resource_name] as Label + label.text = "%s %d" % [titles[resource_name], value] + if resource_name == &"mana": + label.mouse_filter = Control.MOUSE_FILTER_STOP + label.tooltip_text = "残量0で全館停止。接客・調理・搬入・エレベーターと新規入館が停止します。\n残量と配電が回復すると再開します。破壊・摘発による休止は解除されません。" + if value <= 0: + label.text += "・停止" + label.add_theme_color_override("font_color", Color("ffb095")) + else: + label.add_theme_color_override("font_color", _visual_color(&"text_light", Color("fff2d0"))) func _button(text_value: String, callback: Callable, minimum_width := 82) -> Button: @@ -2295,6 +2643,7 @@ func _section_heading(text_value: String) -> Label: heading.text = text_value heading.add_theme_font_size_override("font_size", 14) heading.add_theme_color_override("font_color", _visual_color(&"text_dark", Color("55371e"))) + LayerSkin.register_text(heading, GameState.active_layer) return heading @@ -2309,9 +2658,11 @@ func _panel_style(color: Color, border_color: Color, radius := 8, margin := 12) return style -func _themed_panel_style(_skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox: +func _themed_panel_style(skin_id: StringName, color: Color, border_color: Color, radius := 0, margin := 5) -> StyleBox: # Small flat frames keep attention on the animated cross-section. - return _panel_style(color, border_color, mini(radius, 1), mini(margin, 6)) + var style := _panel_style(color, border_color, mini(radius, 1), mini(margin, 6)) + LayerSkin.register_style(style, skin_id, GameState.active_layer) + return style func _set_status(message: String, success: bool, quiet := false) -> void: @@ -2326,21 +2677,6 @@ func _set_status(message: String, success: bool, quiet := false) -> void: func _visual_color(property_name: StringName, fallback: Color) -> Color: - var interface_colors := { - &"window_background": Color("17261f"), - &"dark_panel": Color("24392e"), - &"dark_panel_alt": Color("20342a"), - &"light_panel": Color("e9e6cd"), - &"border_gold": Color("778267"), - &"text_light": Color("eae7cc"), - &"text_dark": Color("344838"), - &"button_normal": Color("354b39"), - &"button_hover": Color("4c6246"), - &"button_pressed": Color("79623c"), - &"button_disabled": Color("354135"), - } - if interface_colors.has(property_name): - return interface_colors[property_name] if visual_theme == null: return fallback var value: Variant = visual_theme.get(property_name) @@ -2397,3 +2733,122 @@ func _definition_value(source: Variant, property_name: StringName, fallback: Var if StringName(property.get(&"name", &"")) == property_name: return source.get(property_name) return fallback + + +func _ensure_guest_observer() -> GuestObservationPanel: + if guest_observer == null: + guest_observer = AcceptDialog.new() + guest_observer.title = "来客の様子" + guest_observer.ok_button_text = "閉じる" + var content := preload("res://scripts/ui/guest_observation_panel.gd").new() + content.configure(_runtime) + var scroll := ScrollContainer.new() + scroll.name = "GuestObservationScroll" + scroll.custom_minimum_size = Vector2(420, 340) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + content.size_flags_horizontal = Control.SIZE_EXPAND_FILL + guest_observer.add_child(scroll) + scroll.add_child(content) + add_child(guest_observer) + return guest_observer.find_child("GuestObservationPanel", true, false) as GuestObservationPanel + + +func _show_guest_observer() -> void: + var panel := _ensure_guest_observer() + guest_observer.popup_centered(Vector2i(460, 410)) + panel.refresh() + + +func _preview_dimension_migration() -> void: + var report: Dictionary = _runtime.preview_facility_dimensions_at_path(manual_save_path) + if not report.get(&"ok",false): + _set_status("旧寸法の修正を検証できませんでした。保存データは変更していません。",false) + return + if report.get(&"changes",[]).is_empty(): + _set_status("この手動セーブに旧寸法の施設はありません。通常の読込を利用してください。",true) + return + _dimension_migration_digest=report[&"source_digest"] + if dimension_migration_dialog==null: + dimension_migration_dialog=ConfirmationDialog.new() + dimension_migration_dialog.name="DimensionMigrationDialog" + dimension_migration_dialog.title="旧寸法を修正して読み込む" + dimension_migration_dialog.ok_button_text="修正して読込" + dimension_migration_dialog.cancel_button_text="戻る" + dimension_migration_dialog.confirmed.connect(_confirm_dimension_migration) + dimension_migration_dialog.canceled.connect(func(): _dimension_migration_digest="") + add_child(dimension_migration_dialog) + var lines: PackedStringArray=[] + for change: Dictionary in report[&"changes"]: + lines.append("%s%s → %d×%d" % [change[&"name"],"(建設中)" if change[&"building"] else "",change[&"size"].x,change[&"size"].y]) + dimension_migration_dialog.dialog_text="保存済みの手動セーブを修正します。\n"+"\n".join(lines)+"\n\n修正前のバックアップを残します。\n現在の未保存の進行は読み込みで置き換わります。" + dimension_migration_dialog.popup_centered(Vector2i(520,280)) + +func _confirm_dimension_migration() -> void: + if _dimension_migration_digest.is_empty(): return + var expected := _dimension_migration_digest + _dimension_migration_digest="" + var report: Dictionary=_runtime.migrate_facility_dimensions_at_path(manual_save_path,&"",expected) + if not report.get(&"ok",false): + _set_status("旧寸法の修正に失敗しました。読込は行っていません。保存データを確認してください。",false) + return + _load_manual(true) + if build_status_label.text=="手動セーブを読み込みました。": + _set_status("旧寸法を修正して読み込みました。修正前のバックアップを保持しています。",true) + + +func _on_defense_notice_input(event: InputEvent) -> void: + if (event is InputEventMouseButton and event.pressed and event.button_index == MOUSE_BUTTON_LEFT) or event.is_action_pressed(&"ui_accept"): + alert_count_label.accept_event() + _show_defense_manager() + + +func _ensure_defense_manager() -> DefenseManagementPanel: + if defense_manager == null: + defense_manager = AcceptDialog.new() + defense_manager.name = "DefenseManager" + defense_manager.title = "世界樹の防衛" + defense_manager.ok_button_text = "閉じる" + var dialog_theme := Theme.new() + dialog_theme.set_stylebox("panel", "AcceptDialog", _themed_panel_style(&"panel_parchment", Color("f4e3bd"), Color("a96f2f"), 8, 10)) + var window_border := _themed_panel_style(&"panel_outer", Color("49311f"), Color("a96f2f"), 8, 10) + window_border.expand_margin_top = 32 + dialog_theme.set_stylebox("embedded_border", "Window", window_border) + dialog_theme.set_color("title_color", "Window", Color("fff0cf")) + defense_manager.theme = dialog_theme + _apply_button_style(defense_manager.get_ok_button()) + var panel := DefenseManagementPanelScript.new() + panel.configure(_runtime, _apply_button_style) + panel.status_requested.connect(_on_staff_management_status) + var scroll := ScrollContainer.new() + scroll.name = "DefenseManagementScroll" + scroll.custom_minimum_size = Vector2(480, 460) + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + panel.size_flags_horizontal = Control.SIZE_EXPAND_FILL + defense_manager.add_child(scroll) + scroll.add_child(panel) + add_child(defense_manager) + return defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel + + +func _show_defense_manager() -> void: + var panel := _ensure_defense_manager() + panel.refresh() + LayerSkin.apply_tree(defense_manager, GameState.active_layer) + defense_manager.popup_centered(Vector2i(520, 540)) + + +func _restore_defense_interface_selection(manifest: Variant) -> void: + if defense_manager != null: + defense_manager.hide() + var existing := defense_manager.find_child("DefenseManagementPanel", true, false) as DefenseManagementPanel + if existing != null: existing.select_raid(&"") + if not manifest is Dictionary: return + var selection: Variant = manifest.get("interface_selection", {}) + if not selection is Dictionary or selection.get("version", 0) != 1: return + var id_value: Variant = selection.get("defense_id", "") + if not id_value is String or id_value.is_empty() or id_value.length() > 256: return + var raid_id := StringName(id_value) + if _service(&"defense_service").get_management_detail(raid_id).is_empty(): return + _ensure_defense_manager().select_raid(raid_id) + if selection.get("defense_open", false) is bool and selection.get("defense_open", false): + _show_defense_manager() diff --git a/scenes/main/world_preview.gd b/scenes/main/world_preview.gd index c61a069..7825929 100644 --- a/scenes/main/world_preview.gd +++ b/scenes/main/world_preview.gd @@ -2,6 +2,7 @@ class_name WorldPreview extends Control signal facility_selected(instance_id: StringName) +signal staff_assignment_dropped(data: Dictionary, facility_id: StringName) signal construction_selected(job_id: StringName) signal legacy_stair_selected(stair_id: StringName) signal cell_clicked(cell: Vector3i) @@ -14,7 +15,12 @@ enum LayerMode { BACK, } +const RoomPresentation := preload("res://scripts/ui/room_presentation.gd") +const People := preload("res://scripts/ui/pixel_people.gd") const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd") +const InactiveLayerRooms := preload("res://scripts/ui/inactive_layer_rooms.gd") +const LAYER_LENS_SHADER := preload("res://assets/shaders/layer_lens.gdshader") +const LAYER_TRANSITION_SECONDS := 0.55 const MAP_PADDING := Vector2(76.0, 70.0) const INVALID_PREVIEW := {&"can_build": false, &"error_codes": [], &"reasons": [], &"cells": []} const INVALID_EXCAVATION_PREVIEW := { @@ -27,7 +33,10 @@ const INVALID_EXCAVATION_PREVIEW := { var layer_mode := LayerMode.FRONT: set(value): + var previous := layer_mode layer_mode = clampi(value, LayerMode.FRONT, LayerMode.BACK) as LayerMode + if previous != layer_mode and is_node_ready(): + _start_layer_transition() if _hovered_cell != null: _hovered_cell = Vector3i(_hovered_cell.x, _hovered_cell.y, layer_mode) queue_redraw() @@ -47,12 +56,29 @@ var visual_theme: VisualThemeProfile = null var zoom_level := 1.0 var pan_offset := Vector2.ZERO var show_grid := false +var pan_mode := false +var _drawing := false +var _draw_cache: Dictionary = {} + +var _inactive_layer_art: Node2D +var _layer_lens: ColorRect +var _layer_transition_elapsed := LAYER_TRANSITION_SECONDS + var _animation_time := 0.0 var _animation_frame := -1 var _actor_tracks: Dictionary = {} var _elevator_tracks: Dictionary = {} var _boarded_actor_ids: Dictionary = {} +var _carried_patients: Dictionary = {} +var _treated_patients: Dictionary = {} +var _resting_staff: Dictionary = {} +var _room_presentations: Dictionary = {} +var _queue_positions: Dictionary = {} +var _queue_hidden: Dictionary = {} +var _landing_badges: Array[Dictionary] = [] +var _transport_report: Dictionary = {} var _dragging := false +var _drag_button := MOUSE_BUTTON_NONE var _runtime: Variant = null var _hovered_cell: Variant = null @@ -70,6 +96,18 @@ func _ready() -> void: if _runtime == null: _runtime = get_node_or_null("/root/SimulationRuntime") _connect_runtime() + _inactive_layer_art = InactiveLayerRooms.new() + _inactive_layer_art.name = "InactiveLayerRooms" + add_child(_inactive_layer_art) + _layer_lens = ColorRect.new() + _layer_lens.name = "LayerLens" + _layer_lens.mouse_filter = Control.MOUSE_FILTER_IGNORE + _layer_lens.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var lens_material := ShaderMaterial.new() + lens_material.shader = LAYER_LENS_SHADER + _layer_lens.material = lens_material + _layer_lens.visible = false + add_child(_layer_lens) queue_redraw() @@ -173,6 +211,8 @@ func get_build_preview_report(cell: Variant = null) -> Dictionary: func get_map_rect() -> Rect2: + if _drawing and _draw_cache.has(&"rect"): + return _draw_cache[&"rect"] var bounds := _world_bounds() if bounds.is_empty(): return Rect2() @@ -181,10 +221,16 @@ func get_map_rect() -> Rect2: int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1 ) * dimensions - return Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size) + var result_value = Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size) + + if _drawing: + _draw_cache[&"rect"] = result_value + return result_value func _cell_dimensions() -> Vector2: + if _drawing and _draw_cache.has(&"dimensions"): + return _draw_cache[&"dimensions"] var bounds := _world_bounds() if bounds.is_empty(): return Vector2(32, 48) @@ -195,7 +241,11 @@ func _cell_dimensions() -> Vector2: # Native 16px units use integer magnification. Large worlds can be viewed # at overview scale, then enlarged with the same invertible camera mapping. var pixel := maxf(floorf(fit), 1.0) if fit >= 1.0 else maxf(fit, 0.25) - return (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6)) + var result_value = (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6)) + + if _drawing: + _draw_cache[&"dimensions"] = result_value + return result_value func set_zoom(value: float, anchor := Vector2(-1, -1)) -> void: @@ -217,12 +267,77 @@ func reset_view() -> void: queue_redraw() +func focus_operating_area() -> void: + # Explicit framing, never continuous tracking: the player's pan stays put. + zoom_level = 1.0 + pan_offset = Vector2.ZERO + var area := Rect2() + var construction: Variant = _service(&"construction_service") + if construction != null: + for item: Dictionary in construction.get_facilities(): + if int(item.get(&"layer", 0)) != layer_mode: + continue + var cells := _cells_from_serialized(item.get(&"occupied_cells", [])) + if cells.is_empty(): + continue + var rect := _cell_bounds_rect(cells) + area = area.merge(rect) if area.has_area() else rect + if area.has_area(): + var relative := (area.get_center()-get_map_rect().position) / get_map_rect().size + zoom_level = clampf(minf(size.x*0.68/area.size.x, size.y*0.65/area.size.y), 0.5, 2.0) + var rect := get_map_rect() + pan_offset += size*Vector2(0.5, 0.58) - (rect.position+rect.size*relative) + else: + zoom_level = 2.0 + pan_offset += size*Vector2(0.5, 0.65) - cell_to_canvas(Vector3i(0, 1, layer_mode)) + _clamp_pan() + clear_hovered_cell() + queue_redraw() + + +func set_pan_mode(enabled: bool) -> void: + pan_mode = enabled + _end_pan_drag() + clear_hovered_cell() + + +func _end_pan_drag() -> void: + _dragging = false + _drag_button = MOUSE_BUTTON_NONE + mouse_default_cursor_shape = Control.CURSOR_DRAG if pan_mode else Control.CURSOR_ARROW + + +func _notification(what: int) -> void: + if what == NOTIFICATION_APPLICATION_FOCUS_OUT: + _end_pan_drag() + + func _clamp_pan() -> void: var extent := get_map_rect().size * 0.5 + size * 0.3 pan_offset = pan_offset.clamp(-extent, extent) +func _start_layer_transition() -> void: + if _layer_lens == null: return + _layer_transition_elapsed = 0.0 + var center := size * 0.5 + if _hovered_cell != null: + center = cell_to_canvas(_hovered_cell) + var lens_material := _layer_lens.material as ShaderMaterial + lens_material.set_shader_parameter("center", center / size.max(Vector2.ONE)) + lens_material.set_shader_parameter("extent", size) + lens_material.set_shader_parameter("progress", 0.0) + _layer_lens.visible = true + + func _process(delta: float) -> void: + # UI transitions complete even in active pause; actor time remains frozen. + if _layer_lens != null and _layer_lens.visible: + _layer_transition_elapsed = minf(_layer_transition_elapsed + maxf(delta, 0.0), LAYER_TRANSITION_SECONDS) + var lens_material := _layer_lens.material as ShaderMaterial + lens_material.set_shader_parameter("extent", size) + lens_material.set_shader_parameter("progress", _layer_transition_elapsed / LAYER_TRANSITION_SECONDS) + _layer_lens.visible = _layer_transition_elapsed < LAYER_TRANSITION_SECONDS var clock := get_node_or_null("/root/GameClock") if clock != null and bool(clock.get("paused")): return @@ -294,9 +409,15 @@ func facility_at_canvas(canvas_position: Vector2) -> StringName: func _gui_input(event: InputEvent) -> void: if event is InputEventMouseButton: - if event.button_index == MOUSE_BUTTON_MIDDLE: - _dragging = event.pressed - mouse_default_cursor_shape = Control.CURSOR_DRAG if _dragging else Control.CURSOR_ARROW + if not event.pressed and event.button_index == _drag_button: + _end_pan_drag() + accept_event() + return + if event.button_index == MOUSE_BUTTON_MIDDLE or (pan_mode and event.button_index == MOUSE_BUTTON_LEFT): + if event.pressed: + _dragging = true + _drag_button = event.button_index + mouse_default_cursor_shape = Control.CURSOR_DRAG accept_event() return if event.pressed and event.button_index in [MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN]: @@ -304,13 +425,18 @@ func _gui_input(event: InputEvent) -> void: accept_event() return if event is InputEventMouseMotion and _dragging: + var mask := MOUSE_BUTTON_MASK_LEFT if _drag_button == MOUSE_BUTTON_LEFT else MOUSE_BUTTON_MASK_MIDDLE + if not event.button_mask & mask: + _end_pan_drag() + return pan_offset += event.relative _clamp_pan() queue_redraw() accept_event() return if event is InputEventMouseMotion: - _set_hovered_cell(canvas_to_cell(event.position)) + if not pan_mode: + _set_hovered_cell(canvas_to_cell(event.position)) return if not event is InputEventMouseButton or not event.pressed or event.button_index != MOUSE_BUTTON_LEFT: return @@ -348,6 +474,16 @@ func _gui_input(event: InputEvent) -> void: func _draw() -> void: + _drawing = true + _draw_cache.clear() + _draw_contents() + _drawing = false + _draw_cache.clear() + + +func _draw_contents() -> void: + if _inactive_layer_art != null: + _inactive_layer_art.visible = false _draw_background() var bounds := _world_bounds() if bounds.is_empty(): @@ -359,12 +495,14 @@ func _draw() -> void: _draw_world_cells(bounds, map_rect) if show_grid: _draw_congestion_overlay(bounds) + _prepare_room_presentations() + _prepare_queues() _draw_world_entity_icons() _draw_legacy_stairs() _draw_elevators() + _draw_other_layer() _draw_construction_labels() _draw_facility_labels() - _draw_queue_badges() _draw_actors() _draw_excavation_jobs() _draw_build_preview() @@ -519,6 +657,79 @@ func _draw_congestion_overlay(bounds: Dictionary) -> void: draw_line(Vector2(rect.end.x, rect.position.y), Vector2(rect.position.x, rect.end.y), color, 2.0) +func _completed_facilities_for_draw() -> Array: + if _drawing and _draw_cache.has(&"completed_facilities"): + return _draw_cache[&"completed_facilities"] + var construction: Variant = _service(&"construction_service") + var facilities: Array = construction.get_facilities() if construction != null else [] + if _drawing: + _draw_cache[&"completed_facilities"] = facilities + return facilities + + +func _construction_jobs_for_draw() -> Array: + if _drawing and _draw_cache.has(&"construction_jobs"): + return _draw_cache[&"construction_jobs"] + var construction: Variant = _service(&"construction_service") + var jobs: Array = construction.get_jobs() if construction != null else [] + if _drawing: + _draw_cache[&"construction_jobs"] = jobs + return jobs + + +func _other_layer_rooms() -> Array[Dictionary]: + var rooms: Array[Dictionary] = [] + for facility: Dictionary in _completed_facilities_for_draw(): + if int(facility.get(&"layer", 0)) == layer_mode: continue + var cells := _cells_from_serialized(facility.get(&"occupied_cells", [])) + if cells.is_empty(): continue + var rect := _cell_bounds_rect(cells) + if rect.intersects(Rect2(Vector2.ZERO, size)): + rooms.append({&"id": facility[&"instance_id"], &"rect": rect, &"definition_id": facility[&"definition_id"], &"origin_x": cells[0].x}) + for job: ConstructionJob in _construction_jobs_for_draw(): + if job.layer == layer_mode or job.state in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: continue + if job.reserved_cells.is_empty(): continue + var rect := _cell_bounds_rect(job.reserved_cells) + if rect.intersects(Rect2(Vector2.ZERO, size)): + rooms.append({&"id":job.job_id, &"rect":rect, &"definition_id":job.definition_id, + &"origin_x":job.origin.x, &"building":true, &"progress":job.progress}) + for stair: Dictionary in get_legacy_stair_records(): + var upper: Vector3i = stair[&"to"] if stair[&"to"].y > stair[&"from"].y else stair[&"from"] + if upper.z == layer_mode: continue + var rect := _legacy_stair_rect(upper) + if rect.intersects(Rect2(Vector2.ZERO,size)): + rooms.append({&"id":stair[&"id"], &"rect":rect, &"legacy_stair":true, + &"definition_id":&"service_stairs" if upper.z==LayerMode.BACK else &"guest_stairs", &"origin_x":upper.x}) + return rooms + + +func _draw_other_layer() -> void: + # Opposite-layer footprints are visual context only. Hit testing still uses + # the selected layer's WorldService occupancy, including overlapping rooms. + var rooms := _other_layer_rooms() + if _inactive_layer_art != null: + _inactive_layer_art.visible = layer_mode == LayerMode.FRONT + _inactive_layer_art.set("rooms", rooms) + _inactive_layer_art.set("pixel_scale", _cell_dimensions().x / 16.0) + _inactive_layer_art.set("dungeon", visual_theme.pixel_dungeon_tiles if visual_theme != null else null) + _inactive_layer_art.set("town", visual_theme.pixel_town_tiles if visual_theme != null else null) + _inactive_layer_art.queue_redraw() + for room: Dictionary in rooms: + var rect: Rect2 = room[&"rect"] + if bool(room.get(&"building", false)): + var color := Color(0.95,0.76,0.35,0.45) + draw_rect(rect.grow(-2),color,false,1.0) + _draw_hatch(rect.grow(-3),Color(color,0.24),_cell_dimensions().x) + _draw_readable_badge(rect.position+Vector2(3,3),"%s 工事%d%%" % ["裏" if layer_mode==LayerMode.FRONT else "表",clampi(int(room[&"progress"])/100,0,100)]) + continue + if layer_mode == LayerMode.FRONT: + draw_rect(rect.grow(-2), Color(0.6, 0.87, 0.9, 0.45), false, 1.0) + else: + var color := Color(0.94, 0.83, 0.54, 0.55) + draw_rect(rect.grow(-2), color, false, 1.0) + draw_line(rect.position+Vector2(3,3), rect.end-Vector2(3,3), Color(color,0.2), 1.0) + + func _draw_world_entity_icons() -> void: var construction: Variant = _service(&"construction_service") if construction == null or not construction.has_method(&"get_facilities"): @@ -526,7 +737,7 @@ func _draw_world_entity_icons() -> void: var p := _cell_dimensions().x / 16.0 var dungeon := visual_theme.pixel_dungeon_tiles if visual_theme != null else null var town := visual_theme.pixel_town_tiles if visual_theme != null else null - for value: Variant in construction.call(&"get_facilities"): + for value: Variant in _completed_facilities_for_draw(): if not value is Dictionary or int(value.get(&"layer",0)) != layer_mode: continue var facility: Dictionary = value @@ -537,15 +748,144 @@ func _draw_world_entity_icons() -> void: if not rect.intersects(Rect2(Vector2.ZERO,size)): continue var id := StringName(facility.get(&"definition_id",&"")) - PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x) + PixelArt.room(self,rect,id,p,dungeon,town,layer_mode==LayerMode.BACK,cells[0].x, _room_presentations.get(StringName(facility.get(&"instance_id", &"")), {})) var instance_id := StringName(facility.get(&"instance_id",&"")) # Thin status lamp conveys operational state without repainting a room. draw_rect(Rect2(rect.end.x-4*p,rect.position.y+2*p,2*p,2*p),_facility_color(instance_id)) if instance_id==selected_entity_id: draw_rect(rect.grow(-p),Color("f5d582"),false,2*p) + var data: Dictionary = _room_presentations.get(instance_id, {}) + if not data.is_empty() and (rect.size.x >= 80 or instance_id == selected_entity_id) and id not in [&"guest_corridor", &"service_corridor", &"guest_stairs", &"service_stairs", &"guest_elevator", &"service_elevator"]: + var status := RoomPresentation.label(data, RoomPresentation.slots(rect, p, id).size()) + _draw_readable_badge(rect.position+Vector2(2, 2), status.replace("利用", "治療") if id == &"infirmary" else status.replace("利用", "休憩") if id in [&"staff_waiting_room", &"staff_dormitory"] else status) + + +func _draw_readable_badge(at: Vector2, text: String) -> void: + var width := ThemeDB.fallback_font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10).x+6 + var rect := Rect2(at, Vector2(width, 14)) + if not rect.intersects(Rect2(Vector2.ZERO, size)): + return + draw_rect(rect, Color("34413b", 0.94)) + draw_string(ThemeDB.fallback_font, at+Vector2(3, 11), text, HORIZONTAL_ALIGNMENT_LEFT, -1, 10, Color("fff2ce")) + + +func _prepare_room_presentations() -> void: + _room_presentations.clear() + _carried_patients.clear() + _treated_patients.clear() + _resting_staff.clear() + var service: Variant = _service(&"facility_service") + if service == null: + return + for instance: Variant in service.get_instances(): + if instance.origin.z == layer_mode: + _room_presentations[instance.instance_id] = RoomPresentation.describe(instance) + + var staff: Variant = _service(&"staff_service") + if staff != null: + if _runtime is Object and _runtime.has_method("get_staff_rest_report"): + for member: StaffRuntime in staff.get_staff_members(): + if member.state != StaffRuntime.State.OFF_DUTY: continue + var rest: Dictionary = _runtime.get_staff_rest_report(member.staff_id) + if not rest.get(&"resting", false): continue + _resting_staff[member.staff_id] = rest[&"facility_id"] + var room: Dictionary = _room_presentations.get(rest[&"facility_id"], {}) + if not room.is_empty(): + room[&"people"].append(member.staff_id) + room[&"count"] += 1 + for task: StaffTask in staff.get_tasks(): + if task.task_type != &"rescue_transport" or task.is_terminal() or task.assigned_staff_id.is_empty(): continue + if task.state == StaffTask.State.WORKING: + _treated_patients[task.target_entity_id] = task.rescue_facility_id + var data: Dictionary = _room_presentations.get(task.rescue_facility_id, {}) + if not data.is_empty(): + data[&"people"].append(task.target_entity_id) + data[&"count"] += 1 + else: + _carried_patients[task.target_entity_id] = task.assigned_staff_id + + +func _prepare_queues() -> void: + _queue_positions.clear() + _queue_hidden.clear() + _landing_badges.clear() + _transport_report.clear() + var groups: Dictionary = {} + var facilities: Variant = _service(&"facility_service") + var guests: Variant = _service(&"guest_service") + if facilities != null and guests != null: + for instance: Variant in facilities.get_instances(): + for request_id: StringName in instance.queue_entries: + var request: Variant = facilities.get_request(request_id) + if request == null: + continue + for guest_id: StringName in request.guest_ids: + var guest: Variant = guests.get_guest(guest_id) + if guest != null and guest.state == GuestRuntime.State.QUEUING: + _add_queue_member(groups, "guest/"+String(guest_id), guest.current_cell) + var transport: Variant = _service(&"elevators") + if transport != null: + _transport_report = transport.get_report() + var trips: Array = _transport_report.get(&"trips", []) + trips.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return int(a[&"enqueued_at"]) < int(b[&"enqueued_at"]) or (a[&"enqueued_at"] == b[&"enqueued_at"] and String(a[&"actor_id"]) < String(b[&"actor_id"]))) + for trip: Dictionary in trips: + if trip[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED: + var cell: Variant = trip[&"from"] + if cell is Array: + cell = Vector3i(cell[0], cell[1], cell[2]) + _add_queue_member(groups, _elevator_actor_key(String(trip[&"actor_id"])), cell) + var lift_cells: Dictionary = {} + for shaft: Dictionary in _transport_report.get(&"shafts", []): + for stop: Vector3i in _cells_from_serialized(shaft.get(&"stops", [])): + lift_cells[stop] = true + var reserved: Dictionary = {} + var cells: Array = groups.keys() + cells.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: return a.y < b.y or (a.y == b.y and a.x < b.x)) + for cell: Vector3i in cells: + var members: Array = groups[cell] + var lane: Array[Vector3i] = [] + if not lift_cells.has(cell): + lane.append(cell) + var world: Variant = _service(&"world_service") + for direction: int in [-1, 1]: + for distance: int in range(1, 9): + var next := cell + Vector3i(direction*distance, 0, 0) + if world == null or not world.is_walkable(next): + break + var occupant: StringName = world.get_occupant(next) + var facility: Variant = facilities.get_instance(occupant) if facilities != null else null + if facility != null and facility.definition_id not in [&"guest_corridor", &"service_corridor"]: + break + lane.append(next) + var index := 0 + for target: Vector3i in lane: + if index >= members.size(): + break + if reserved.has(target): + continue + reserved[target] = true + _queue_positions[members[index]] = cell_to_canvas(target) + index += 1 + for hidden: int in range(index, members.size()): + _queue_hidden[members[hidden]] = true + var text := "待ち%d人" % members.size() + if index < members.size(): + text += "(他%d人)" % (members.size()-index) + _landing_badges.append({&"at": get_cell_rect(cell).position + Vector2(-80 if lift_cells.has(cell) else 0, -16), &"text": text}) + + +func _add_queue_member(groups: Dictionary, key: String, cell: Vector3i) -> void: + if cell.z != layer_mode: + return + if not groups.has(cell): + groups[cell] = [] + if not groups[cell].has(key): + groups[cell].append(key) func get_legacy_stair_records() -> Array[Dictionary]: + if _drawing and _draw_cache.has(&"legacy_stair_records"): + return _draw_cache[&"legacy_stair_records"] var result: Array[Dictionary] = [] var service: Variant = _service(&"legacy_stairs") if service == null or not service.has_method(&"create_snapshot"): @@ -560,6 +900,8 @@ func get_legacy_stair_records() -> Array[Dictionary]: var connector: Dictionary = path.call(&"get_connector", StringName(row[&"id"])) if path != null else {} record[&"access_id"] = connector.get(&"access_id", &"") result.append(record) + if _drawing: + _draw_cache[&"legacy_stair_records"] = result return result @@ -601,21 +943,6 @@ func _cell_bounds_rect(cells: Array[Vector3i]) -> Rect2: return result -func _draw_queue_badges() -> void: - var service: Variant = _service(&"facility_service") - if service==null or not service.has_method(&"get_instances"): - return - for instance: Variant in service.call(&"get_instances"): - if instance==null or instance.origin.z!=layer_mode or instance.queue_entries.is_empty(): - continue - var count: int = instance.queue_entries.size() - var rect := get_cell_rect(instance.origin) - var at := Vector2(rect.position.x+3,rect.position.y+3) - draw_rect(Rect2(at,Vector2(30,15)),Color("76593f")) - draw_rect(Rect2(at+Vector2.ONE,Vector2(28,13)),Color("bb974e")) - draw_string(ThemeDB.fallback_font,at+Vector2(3,11),"待%d" % count,HORIZONTAL_ALIGNMENT_LEFT,-1,10,Color("fff2ce")) - - func _draw_hatch(rect: Rect2, color: Color, cell_size: float) -> void: if cell_size < 8.0: return @@ -632,7 +959,7 @@ func _draw_construction_labels() -> void: if construction == null or not construction.has_method(&"get_jobs"): return var font := ThemeDB.fallback_font - for job: Variant in construction.call(&"get_jobs"): + for job: Variant in _construction_jobs_for_draw(): if job == null or int(job.layer) != layer_mode or int(job.state) in [2, 3]: continue var rect := get_cell_rect(job.origin) @@ -647,7 +974,7 @@ func _draw_facility_labels() -> void: var registry: Variant = _service(&"definition_registry") if construction == null or not construction.has_method(&"get_facilities"): return - for value: Variant in construction.call(&"get_facilities"): + for value: Variant in _completed_facilities_for_draw(): if not value is Dictionary or int(value.get(&"layer",0))!=layer_mode: continue var facility: Dictionary = value @@ -677,7 +1004,7 @@ func _draw_elevators() -> void: if transport == null or not transport.has_method(&"get_report"): _elevator_tracks.clear() return - var report_value: Variant = transport.call(&"get_report") + var report_value: Variant = _transport_report if not report_value is Dictionary: return var alive: Dictionary = {} @@ -708,7 +1035,8 @@ func _draw_elevators() -> void: var phase := StringName(shaft.get(&"phase", &"IDLE")) var doors_open := operational and phase in [&"IDLE", &"BOARDING", &"ALIGHTING"] PixelArt.elevator_cabin(self,cabin,p,layer_mode==LayerMode.BACK,operational) - var visible_count := mini(passengers.size(), maxi(width*2-1,1)) + var seat_columns := maxi(int((cabin.size.x / p - 4.0) / 8.0), 1) + var visible_count := mini(passengers.size(), seat_columns*2) for index: int in range(visible_count): var actor_key := _elevator_actor_key(String(passengers[index])) var is_staff := actor_key.begins_with("staff/") @@ -719,17 +1047,21 @@ func _draw_elevators() -> void: person = people.call(getter,StringName(actor_key.get_slice("/",1))) var carrying := is_staff and person != null and not String(person.carried_inventory_id).is_empty() var state := int(person.state) if person != null else 0 - var x := cabin.position.x+5*p+float(index)*(cabin.size.x-10*p)/maxf(float(visible_count-1),1.0) - if visible_count == 1: - x = cabin.get_center().x - var center := Vector2(x,cabin.end.y-_cell_dimensions().y*0.5) + var x := cabin.position.x + (2.0 + (index % seat_columns + 0.5)*8.0)*p + var feet := cabin.end.y - (2.0 + int(index / seat_columns)*9.0)*p + var center := Vector2(x, feet+3*p-_cell_dimensions().y*0.5) _draw_pixel_actor(center,actor_key,is_staff,state,false,carrying,&"",true) PixelArt.elevator_gate(self,cabin,p,doors_open) - var waiting := int(shaft.get(&"waiting_count",0)) - if waiting > 0: - var badge := Vector2(cabin.end.x+2*p,cabin.position.y+2*p) - draw_rect(Rect2(badge,Vector2(20,12)*p),Color("76593f")) - draw_string(ThemeDB.fallback_font,badge+Vector2(2,9)*p,"待%d" % waiting,HORIZONTAL_ALIGNMENT_LEFT,-1,clampi(int(7*p),8,13),Color("f6e8bc")) + var capacity := int(shaft.get(&"capacity", 0)) + var load_text := "%d/%d人" % [passengers.size(), capacity] + if capacity > 0 and passengers.size() >= capacity: + load_text = "満員 " + load_text + if not operational: + load_text = "停止 " + load_text + _draw_readable_badge(Vector2(cabin.position.x, cabin.position.y-14), load_text) + for badge: Dictionary in _landing_badges: + _draw_readable_badge(badge[&"at"], badge[&"text"]) + for shaft_id: Variant in _elevator_tracks.keys(): if not alive.has(shaft_id): _elevator_tracks.erase(shaft_id) @@ -769,14 +1101,18 @@ func _draw_actors() -> void: continue var key := "guest/"+String(guest.guest_id) alive[key] = true - if _boarded_actor_ids.has(key): + if _boarded_actor_ids.has(key) or _queue_hidden.has(key): _actor_tracks.erase(key) continue var position := _tracked_actor_position(key,guest.current_cell) if guest.current_cell.z==layer_mode: var receiving := not String(guest.current_service_id).is_empty() - if receiving: + if _queue_positions.has(key): + position = _queue_positions[key] + elif receiving: position = _service_use_position(guest,position,service_slots) + if not position.is_finite(): + continue else: position += _actor_offset(guest.current_cell,counts) _draw_pixel_actor(position,key,false,int(guest.state),receiving,false,_guest_activity(guest)) @@ -787,17 +1123,70 @@ func _draw_actors() -> void: continue var key := "staff/"+String(staff.staff_id) alive[key] = true - if _boarded_actor_ids.has(key): + if _boarded_actor_ids.has(key) or _queue_hidden.has(key): _actor_tracks.erase(key) continue var position := _tracked_actor_position(key,staff.current_cell) if staff.current_cell.z==layer_mode: - _draw_pixel_actor(position+_actor_offset(staff.current_cell,counts),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),staff.role_id) + if _carried_patients.has(staff.staff_id): + var carrier: Variant = staff_service.get_staff(_carried_patients[staff.staff_id]) + if carrier != null: + position = _tracked_actor_position("staff/"+String(carrier.staff_id), carrier.current_cell) - Vector2(0, 12*_cell_dimensions().x/16.0) + _draw_pixel_actor(position,key,true,int(staff.state),false,false) + continue + if _resting_staff.has(staff.staff_id): + position = _resting_staff_position(staff.staff_id, position) + if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false) + continue + if _treated_patients.has(staff.staff_id): + position = _rescue_treatment_position(staff.staff_id, position) + if position.is_finite(): _draw_pixel_actor(position,key,true,int(staff.state),false,false) + continue + _draw_pixel_actor(_queue_positions.get(key, _staff_work_position(staff, position+_actor_offset(staff.current_cell,counts))),key,true,int(staff.state),false,not String(staff.carried_inventory_id).is_empty(),_staff_work_animation(staff)) for key: Variant in _actor_tracks.keys(): if not alive.has(key): _actor_tracks.erase(key) +func _staff_work_animation(staff: StaffRuntime) -> StringName: + if staff.role_id != &"cleaner": + return &"work" + # A staffed base is not a cleaning site. Animate only a live job at its + # authoritative destination, including after load and interruption. + var service: Variant = _service(&"staff_service") + var bridge: Variant = _service(&"cleaning_bridge") + var task: StaffTask = service.get_task(staff.current_task_id) if service != null else null + if staff.state == StaffRuntime.State.WORKING and task != null and task.task_type == FacilityCleaningBridge.TASK_TYPE and task.state == StaffTask.State.WORKING and bridge != null and bridge.task_can_work(task, staff): + return &"clean" + return &"idle" + + +func _staff_work_position(staff: Variant, fallback: Vector2) -> Vector2: + if staff.state not in [StaffRuntime.State.WORKING, StaffRuntime.State.AVAILABLE]: + return fallback + var service: Variant = _service(&"facility_service") + var facility: Variant = service.get_instance(staff.assigned_facility_id) if service != null else null + if facility == null: + return fallback + var world: Variant = _service(&"world_service") + var cells: Array[Vector3i] = world.get_entity_cells(staff.assigned_facility_id) if world != null else [] + if not cells.has(staff.current_cell): + return fallback + var data: Dictionary = _room_presentations.get(staff.assigned_facility_id, {}) + var workers: Array = data.get(&"staff", []) + var slot := workers.find(staff.staff_id) + if slot < 0: + return fallback + var rect := _cell_bounds_rect(cells) + var p := _cell_dimensions().x / 16.0 + if rect.size.y < 40*p: + return fallback + var columns := maxi(int(rect.size.x / (16*p)), 1) + if slot >= columns: + return fallback + return Vector2(rect.position.x+(slot+0.5)*rect.size.x/columns, rect.position.y+26*p-_cell_dimensions().y*0.5) + + func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2: if not _actor_tracks.has(key): _actor_tracks[key] = {&"cell":cell,&"from":cell,&"at":_animation_time,&"direction":1.0} @@ -817,7 +1206,7 @@ func _tracked_actor_position(key: String, cell: Vector3i) -> Vector2: return cell_to_canvas(track[&"from"]).lerp(cell_to_canvas(cell),t) -func _service_use_position(guest: Variant, fallback: Vector2, slots: Dictionary) -> Vector2: +func _service_use_position(guest: Variant, fallback: Vector2, _slots: Dictionary) -> Vector2: # Guest paths stop at an access cell. While a service is active, project the # occupant into that facility's use position; authoritative paths stay intact. var id := StringName(guest.target_facility_id) @@ -828,11 +1217,19 @@ func _service_use_position(guest: Variant, fallback: Vector2, slots: Dictionary) if cells.is_empty() or cells[0].z!=layer_mode: return fallback var rect := _cell_bounds_rect(cells) - var slot := int(slots.get(id,0)) - slots[id] = slot+1 + var data: Dictionary = _room_presentations.get(id, {}) + var slot := (data.get(&"people", []) as Array).find(guest.guest_id) + if slot < 0: + return fallback var p := _cell_dimensions().x/16.0 - var x := rect.position.x+minf(9.0+float(slot%4)*14.0,rect.size.x/p-5.0)*p - return Vector2(x,rect.end.y-_cell_dimensions().y*0.5) + var anchors := RoomPresentation.slots(rect, p, _guest_activity(guest)) + if slot >= anchors.size(): + return Vector2(INF, INF) + var at := anchors[slot] + if _guest_activity(guest) in [&"standard_room", &"premium_room"]: + at.y -= 5*p + return at + Vector2(0, 3*p-_cell_dimensions().y*0.5) + func _guest_activity(guest: Variant) -> StringName: @@ -844,67 +1241,34 @@ func _guest_activity(guest: Variant) -> StringName: func _draw_pixel_actor(center: Vector2, key: String, staff: bool, state: int, receiving: bool, carrying: bool, activity: StringName = &"", riding := false) -> void: - var texture := visual_theme.pixel_dungeon_tiles if visual_theme != null else null - if texture==null: - return - var world_pixel := _cell_dimensions().x/16.0 - var p := maxf(floorf(world_pixel*0.5),1.0) if world_pixel>=1.0 else world_pixel - var track: Dictionary = _actor_tracks.get(key,{}) - var moving: bool = track.get(&"cell",Vector3i.ZERO)!=track.get(&"from",Vector3i.ZERO) and _animation_time-float(track.get(&"at",0.0))<0.42 + var world_pixel := _cell_dimensions().x / 16.0 + var scale := 0.5 if riding else 0.75 + var p := maxf(floorf(world_pixel * scale), 1.0) if world_pixel >= 2.0 else world_pixel * scale + var track: Dictionary = _actor_tracks.get(key, {}) + var moving: bool = not receiving and not _queue_positions.has(key) and track.get(&"cell", Vector3i.ZERO) != track.get(&"from", Vector3i.ZERO) and _animation_time - float(track.get(&"at", 0.0)) < 0.42 var clock := get_node_or_null("/root/GameClock") var paused := clock != null and bool(clock.get("paused")) moving = moving and not paused and not riding - var variant := int(absi(key.hash()))%5 - var tile_id := (96 if staff else 84)+variant - var frame := int(_animation_time*9.0+variant)%4 - var step := (1.0 if frame==0 else (-1.0 if frame==2 else 0.0)) if moving else 0.0 - var at := (center+Vector2(-8*p, _cell_dimensions().y*0.5-3*world_pixel-16*p)).round() - var source := Vector2((tile_id%12)*16,(tile_id/12)*16) - # Retain the same character; split only the feet into a four-phase walk. - # Neighbouring atlas tiles are other people, never animation frames. - var bob := -p if moving and frame%2==0 else 0.0 - draw_rect(Rect2(at+Vector2(4,14)*p,Vector2(8,1)*p),Color("3a453c",0.35)) - var flip := float(track.get(&"direction",1.0))<0.0 - var torso := Rect2(at+Vector2(0,bob),Vector2(16,15)*p) - if flip: - torso.position.x += torso.size.x - torso.size.x = -torso.size.x - draw_texture_rect_region(texture,torso,Rect2(source,Vector2(16,15))) - for leg in range(2): - var leg_x := float(leg)*8*p - var stride := step*p*(1.0 if leg==0 else -1.0) - var foot := Rect2(at+Vector2(leg_x,14*p+stride+bob),Vector2(8,2)*p) - if flip: - foot.position.x = at.x+float(2-leg)*8*p - foot.size.x = -foot.size.x - draw_texture_rect_region(texture,foot,Rect2(source+Vector2(leg*8,14),Vector2(8,2))) - if staff: - draw_rect(Rect2(at+Vector2(7,8)*p,Vector2(2,3)*p),Color("81c6ba")) - if carrying: - PixelArt.tile(self,texture,89,Rect2(at+Vector2(9,6)*p,Vector2(9,9)*p)) - var working := staff and state==StaffRuntime.State.WORKING and not riding - var resting := receiving and activity==&"standard_room" - if resting: - var lift := float(int(_animation_time*2)%3) if not paused else 0.0 - draw_string(ThemeDB.fallback_font,at+Vector2(11,-1-lift)*p,"z",HORIZONTAL_ALIGNMENT_LEFT,-1,maxi(int(6*p),7),Color("f7efd6")) - if staff and activity==&"cleaner" and (moving or working): - var sweep := step*p if working or moving else 0.0 - draw_rect(Rect2(at+Vector2(12*p+sweep,7*p),Vector2(p,8*p)),Color("b7975c")) - draw_rect(Rect2(at+Vector2(10*p+sweep,14*p),Vector2(5*p,2*p)),Color("cdb375")) - if receiving and activity in [&"cafeteria",&"tavern"]: - var lift := p if not paused and int(_animation_time*3)%2==0 else 0.0 - draw_rect(Rect2(at+Vector2(11*p,8*p-lift),Vector2(3*p,2*p)),Color("e6d7b5")) - var waiting := not staff and state==GuestRuntime.State.QUEUING and not riding - if (receiving and not resting) or working or waiting: - var badge := Rect2(at+Vector2(10,-4)*p,Vector2(6,5)*p) - draw_rect(badge,Color("f2e9cb")) - var mark := Color("c39150") if waiting else Color("61958a") - if waiting: - draw_rect(Rect2(badge.position+Vector2(2,1)*p,Vector2(1,2)*p),mark) - draw_rect(Rect2(badge.position+Vector2(2,3)*p,Vector2(2,1)*p),mark) - else: - var offset := 1.0 if not paused and int(_animation_time*3)%2==0 else 0.0 - draw_rect(Rect2(badge.position+Vector2(1+offset,2)*p,Vector2(3,1)*p),mark) + var variant := int(absi(key.hash())) % 15 + var frame := int(_animation_time * 12.0) + variant + var mode: StringName = &"idle" + if staff and state == StaffRuntime.State.INCAPACITATED: + mode = &"collapsed" + elif receiving and activity in [&"standard_room", &"premium_room"]: + mode = &"sleep" + elif moving: + mode = &"carry_walk" if carrying else &"walk" + elif carrying: + mode = &"carry" + elif staff and state == StaffRuntime.State.WORKING and not riding: + mode = activity if activity in [&"clean", &"idle", &"work"] else &"work" + elif receiving and activity in [&"cafeteria", &"tavern"]: + mode = &"eat" + elif _queue_positions.has(key) or (not staff and state == GuestRuntime.State.QUEUING): + mode = &"wait" + var at := (center + Vector2(-8 * p, _cell_dimensions().y * 0.5 - 3 * world_pixel - 17 * p)).round() + draw_rect(Rect2(at + Vector2(5, 17) * p, Vector2(8, 1) * p), Color("303c40", 0.2)) + People.draw(self, at, p, mode, frame, variant, staff, float(track.get(&"direction", 1.0)) < 0.0) func _draw_excavation_jobs() -> void: @@ -990,9 +1354,10 @@ func _draw_map_caption(_map_rect: Rect2) -> void: var caption := "世界樹の宿 / お客様のフロア" if layer_mode==LayerMode.FRONT else "世界樹の宿 / 従業員と物流" draw_rect(Rect2(14,12,244,26),Color("29483d",0.93)) draw_string(font,Vector2(24,30),caption,HORIZONTAL_ALIGNMENT_LEFT,-1,13,Color("f0e2bd")) - var status := "表示 %d%% · G マス目%s" % [int(zoom_level*100), " ON" if show_grid else ""] - draw_rect(Rect2(size.x-214,12,200,26),Color("29483d",0.93)) - draw_string(font,Vector2(size.x-204,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd")) + var status := "表示 %d%% · %s · G マス目%s" % [int(zoom_level*100), "裏層:半透明" if layer_mode == LayerMode.FRONT else "表層:輪郭", " ON" if show_grid else ""] + var status_width := font.get_string_size(status,HORIZONTAL_ALIGNMENT_LEFT,-1,12).x + 20.0 + draw_rect(Rect2(size.x-status_width-14,12,status_width,26),Color("29483d",0.93)) + draw_string(font,Vector2(size.x-status_width-4,30),status,HORIZONTAL_ALIGNMENT_LEFT,-1,12,Color("f0e2bd")) func _draw_center_message(message: String) -> void: @@ -1085,7 +1450,7 @@ func _on_mouse_exited() -> void: _set_hovered_cell(null) -func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: +func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: queue_redraw() _update_build_preview() _update_excavation_preview() @@ -1094,29 +1459,38 @@ func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: func _connect_runtime() -> void: if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"): return + var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published" var callback := Callable(self, "_on_snapshot_published") - if not _runtime.is_connected(&"snapshot_published", callback): - _runtime.connect(&"snapshot_published", callback) + if not _runtime.is_connected(notification, callback): + _runtime.connect(notification, callback) func _disconnect_runtime() -> void: if _runtime == null or not _runtime is Object or not _runtime.has_signal(&"snapshot_published"): return + var notification: StringName = &"state_updated" if _runtime.has_signal(&"state_updated") else &"snapshot_published" var callback := Callable(self, "_on_snapshot_published") - if _runtime.is_connected(&"snapshot_published", callback): - _runtime.disconnect(&"snapshot_published", callback) + if _runtime.is_connected(notification, callback): + _runtime.disconnect(notification, callback) func _service(property_name: StringName) -> Variant: if _runtime == null or not _runtime is Object: return null + if _drawing and _draw_cache.has(property_name): + return _draw_cache[property_name] for property: Dictionary in _runtime.get_property_list(): if StringName(property.get(&"name", &"")) == property_name: - return _runtime.get(property_name) + var value = _runtime.get(property_name) + if _drawing: + _draw_cache[property_name] = value + return value return null func _world_bounds() -> Dictionary: + if _drawing and _draw_cache.has(&"bounds"): + return _draw_cache[&"bounds"] var world: Variant = _service(&"world_service") if world == null or not world.has_method(&"get_bounds"): return {} @@ -1126,13 +1500,17 @@ func _world_bounds() -> Dictionary: for key: StringName in [&"min_x", &"max_x", &"min_y", &"max_y"]: if not result.has(key) and not result.has(String(key)): return {} - return { + var result_value = { &"min_x": int(result.get(&"min_x", result.get("min_x", 0))), &"max_x": int(result.get(&"max_x", result.get("max_x", -1))), &"min_y": int(result.get(&"min_y", result.get("min_y", 0))), &"max_y": int(result.get(&"max_y", result.get("max_y", -1))), } + if _drawing: + _draw_cache[&"bounds"] = result_value + return result_value + func _cells_from_serialized(value: Variant) -> Array[Vector3i]: var result: Array[Vector3i] = [] @@ -1155,3 +1533,37 @@ func _serialize_cells(cells: Array[Vector3i]) -> Array[Array]: func _normalized_rotation(value: int) -> int: return posmod(value, 360) / 90 * 90 + + +func _rescue_treatment_position(patient_id: StringName, fallback: Vector2) -> Vector2: + var facility_id: StringName = _treated_patients[patient_id] + var world: Variant = _service(&"world_service") + var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else [] + if cells.is_empty(): return fallback + var data: Dictionary = _room_presentations.get(facility_id, {}) + var slot := (data.get(&"people", []) as Array).find(patient_id) + var p := _cell_dimensions().x/16.0 + var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), p, &"infirmary") + if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF) + return anchors[slot]+Vector2(0,-2*p-_cell_dimensions().y*0.5) + + +func _resting_staff_position(staff_id: StringName, fallback: Vector2) -> Vector2: + var facility_id: StringName = _resting_staff[staff_id] + var world: Variant = _service(&"world_service") + var cells: Array[Vector3i] = world.get_entity_cells(facility_id) if world != null else [] + if cells.is_empty(): return fallback + var data: Dictionary = _room_presentations.get(facility_id, {}) + var slot := (data.get(&"people", []) as Array).find(staff_id) + var anchors := RoomPresentation.slots(_cell_bounds_rect(cells), _cell_dimensions().x/16.0, &"staff_waiting_room") + if slot < 0 or slot >= anchors.size(): return Vector2(INF,INF) + return anchors[slot]+Vector2(0,-_cell_dimensions().y*0.5) + + +func _can_drop_data(_position: Vector2, data: Variant) -> bool: + return data is Dictionary and data.get(&"kind", &"") == &"staff_assignment" and data.get(&"staff_id", &"") is StringName + + +func _drop_data(position: Vector2, data: Variant) -> void: + if _can_drop_data(position, data): + staff_assignment_dropped.emit(data, facility_at_canvas(position)) diff --git a/scripts/construction/facility_resize_preview.gd b/scripts/construction/facility_resize_preview.gd new file mode 100644 index 0000000..682d557 --- /dev/null +++ b/scripts/construction/facility_resize_preview.gd @@ -0,0 +1,129 @@ +extends RefCounted + +## Read-only geometry preflight. A successful preview is NOT a migration commit: +## Runtime ownership, routes, jobs, actor positions and backups need their own gate. +const DIMENSIONS := { + &"premium_room": {&"old": Vector2i(3, 2), &"new": Vector2i(4, 1), &"layer": 0}, + &"staff_dormitory": {&"old": Vector2i(4, 2), &"new": Vector2i(2, 1), &"layer": 1}, + &"black_market": {&"old": Vector2i(3, 2), &"new": Vector2i(4, 2), &"layer": 0}, +} + +static func inspect(world: WorldService, instance: Dictionary, weight_per_cell: int, entity_weights: Dictionary = {}) -> Dictionary: + var result := {&"geometry_ok": false, &"ready_to_apply": false, &"errors": [], + &"added": [], &"removed": [], &"target_cells": [], &"affected_entities": [], + &"remaining_gates": [&"DEFINITION_WEIGHT_VALIDATION", &"RUNTIME_CROSSLINKS", &"ROUTES_AND_ACTORS", &"CONSTRUCTION_JOBS", &"BACKUP_AND_ATOMIC_COMMIT"]} + if typeof(instance.get(&"instance_id")) not in [TYPE_STRING, TYPE_STRING_NAME] or typeof(instance.get(&"definition_id")) not in [TYPE_STRING, TYPE_STRING_NAME]: + result[&"errors"].append(&"INVALID_ID") + return result + var id := StringName(instance.get(&"instance_id", &"")) + var definition_id := StringName(instance.get(&"definition_id", &"")) + if world == null or id.is_empty() or not DIMENSIONS.has(definition_id) or weight_per_cell <= 0: + result[&"errors"].append(&"INVALID_INPUT") + return result + var origin: Variant = instance.get(&"origin") + var rotation: Variant = instance.get(&"rotation") + var cells: Variant = instance.get(&"occupied_cells") + if not origin is Vector3i or typeof(rotation) != TYPE_INT or rotation not in [0, 90, 180, 270] or not cells is Array: + result[&"errors"].append(&"INVALID_GEOMETRY") + return result + var specification: Dictionary = DIMENSIONS[definition_id] + if origin.z != specification[&"layer"]: + result[&"errors"].append(&"LAYER_MISMATCH") + return result + var old_size: Vector2i = specification[&"old"] + var new_size: Vector2i = specification[&"new"] + if rotation in [90, 270]: + old_size = Vector2i(old_size.y, old_size.x) + new_size = Vector2i(new_size.y, new_size.x) + var expected_old := world.cells_for_footprint(origin, old_size) + var target := world.cells_for_footprint(origin, new_size) + if not _same_cells(cells, world.get_entity_cells(id)) or (not _same_cells(cells, expected_old) and not _same_cells(cells, target)): + result[&"errors"].append(&"SOURCE_OWNERSHIP_OR_SHAPE_MISMATCH") + return result + result[&"target_cells"] = target.duplicate() + for cell: Vector3i in target: + if cell not in cells: result[&"added"].append(cell) + if not world.is_in_bounds(cell): result[&"errors"].append({&"code": &"OUT_OF_BOUNDS", &"cell": cell}) + elif cell.y < 0 and not world.is_excavated(cell): result[&"errors"].append({&"code": &"NOT_EXCAVATED", &"cell": cell}) + if definition_id == &"black_market" and cell.y >= 0: result[&"errors"].append({&"code": &"ZONE_MISMATCH", &"cell": cell}) + var other := world.get_occupant(cell) + if not other.is_empty() and other != id: + result[&"errors"].append({&"code": &"OVERLAP", &"cell": cell, &"entity_id": other}) + for cell: Vector3i in cells: + if cell not in target: result[&"removed"].append(cell) + if not result[&"errors"].is_empty(): return result + var candidate := WorldService.new() + if not candidate.restore_snapshot(world.create_snapshot()) or not candidate.release(id) or not candidate.occupy(id, target, world.get_entity_kind(id)): + result[&"errors"].append(&"CANDIDATE_REJECTED") + return result + if not candidate.validate_support(target, weight_per_cell, entity_weights)[&"ok"]: + result[&"errors"].append(&"TARGET_UNSUPPORTED") + # Recheck every previously supported occupied component; removal may split + # a component even when the resized facility itself remains anchored. + for row: Dictionary in world.create_snapshot()[&"occupancies"]: + if row[&"entity_id"] == id: continue + if world.validate_support(row[&"cells"], int(entity_weights.get(row[&"entity_id"], 1)), entity_weights)[&"ok"] and not candidate.validate_support(row[&"cells"], int(entity_weights.get(row[&"entity_id"], 1)), entity_weights)[&"ok"]: + result[&"affected_entities"].append(row[&"entity_id"]) + result[&"errors"].append({&"code": &"SUPPORT_LOSS", &"entity_id": row[&"entity_id"]}) + result[&"geometry_ok"] = result[&"errors"].is_empty() + return result + +static func _same_cells(a: Array, b: Array) -> bool: + if a.size() != b.size(): return false + var seen := {} + for value: Variant in a: + if not value is Vector3i or seen.has(value) or value not in b: return false + seen[value] = true + return true + +## Build a validated construction/world candidate without touching live state. +## Other Runtime services and disk backup still need validation before commit. +static func prepare_construction_candidate(world: WorldService, construction: ConstructionService, owner_id: StringName, weight: int, weights: Dictionary = {}) -> Dictionary: + var source := construction.create_snapshot() + var result := {&"ok": false, &"ready_to_apply": false, &"errors": []} + var validator := ConstructionService.new(world) + if source.is_empty() or not validator.restore_snapshot(source): + result[&"errors"].append(&"INVALID_SOURCE_CONSTRUCTION") + return result + var instance := construction.get_facility(owner_id) + var source_job_id: StringName = instance.get(&"source_job_id", &"") + if instance.is_empty(): + for job: ConstructionJob in construction.get_jobs(): + if job.job_id != owner_id or job.is_terminal(): continue + source_job_id = job.job_id + instance = {&"instance_id": job.job_id, &"definition_id": job.definition_id, + &"origin": job.origin, &"rotation": job.rotation, &"occupied_cells": job.reserved_cells.duplicate()} + break + if instance.is_empty(): + result[&"errors"].append(&"MIGRATABLE_OWNER_REQUIRED") + return result + var preview := inspect(world, instance, weight, weights) + result[&"preview"] = preview + if not preview[&"geometry_ok"]: + result[&"errors"] = preview[&"errors"].duplicate(true) + return result + var target: Array = preview[&"target_cells"] + var candidate_world := WorldService.new() + if not candidate_world.restore_snapshot(world.create_snapshot()): + result[&"errors"].append(&"CANDIDATE_WORLD_REJECTED") + return result + if not _same_cells(world.get_entity_cells(owner_id), target) and (not candidate_world.release(owner_id) or not candidate_world.occupy(owner_id, target, world.get_entity_kind(owner_id))): + result[&"errors"].append(&"CANDIDATE_WORLD_REJECTED") + return result + var encoded: Array = [] + for cell: Vector3i in target: encoded.append([cell.x, cell.y, cell.z]) + var candidate := source.duplicate(true) + for saved: Dictionary in candidate[&"jobs"]: + if StringName(saved[&"job_id"]) == source_job_id: + saved[&"reserved_cells"] = encoded.duplicate(true) + for saved: Dictionary in candidate[&"facilities"]: + if StringName(saved[&"instance_id"]) == owner_id: + saved[&"occupied_cells"] = encoded.duplicate(true) + var restored := ConstructionService.new(candidate_world) + if not restored.restore_snapshot(candidate): + result[&"errors"].append(&"CANDIDATE_CONSTRUCTION_REJECTED") + return result + result[&"ok"] = true + result[&"world"] = candidate_world.create_snapshot() + result[&"construction"] = restored.create_snapshot() + return result diff --git a/scripts/construction/facility_resize_preview.gd.uid b/scripts/construction/facility_resize_preview.gd.uid new file mode 100644 index 0000000..c48ddb9 --- /dev/null +++ b/scripts/construction/facility_resize_preview.gd.uid @@ -0,0 +1 @@ +uid://5pyjfutw6xl2 diff --git a/scripts/construction/facility_resize_routes.gd b/scripts/construction/facility_resize_routes.gd new file mode 100644 index 0000000..09093f4 --- /dev/null +++ b/scripts/construction/facility_resize_routes.gd @@ -0,0 +1,103 @@ +extends RefCounted + +## Compare actual guest/staff access queries on two isolated Runtime states. +## Reachability alone is not a relocation or in-flight transport migration. +static func inspect(before: Variant, after: Variant, facility_id: StringName) -> Dictionary: + var result := {&"ok": false, &"errors": [], &"guest_replans": [], &"checked_routes": 0} + var old_room: FacilityInstance = before.facility_service.get_instance(facility_id) + var new_room: FacilityInstance = after.facility_service.get_instance(facility_id) + if old_room == null or new_room == null: + result[&"errors"].append(&"FACILITY_REQUIRED") + return result + _check(result, before.get_facility_access_report(facility_id), after.get_facility_access_report(facility_id), &"ENTRANCE_ACCESS_LOST", &"") + for guest: GuestRuntime in before.guest_service.get_guests(): + if guest.target_facility_id != facility_id: continue + var restored: GuestRuntime = after.guest_service.get_guest(guest.guest_id) + if restored == null: + result[&"errors"].append({&"code": &"GUEST_MISSING", &"id": guest.guest_id}) + continue + var old_route: Dictionary = before._find_facility_access_path(guest.current_cell, old_room) + var new_route: Dictionary = after._find_facility_access_path(restored.current_cell, new_room) + _check(result, old_route, new_route, &"GUEST_ACCESS_LOST", guest.guest_id) + if restored.state == GuestRuntime.State.MOVING and new_route[&"reachable"]: + var target: Vector3i = after._path_handle_target(restored.path_handle, restored.current_cell) + # A new best destination must be applied explicitly to the saved route. + if target != new_route[&"target_cell"]: + result[&"guest_replans"].append({&"id": guest.guest_id, &"old_target": target, &"target": new_route[&"target_cell"]}) + for worker: StaffRuntime in before.staff_service.get_staff_members(): + var assignment: Dictionary = before.staff_service.get_work_assignment(worker.staff_id) + var rest: Dictionary = before.get_staff_rest_report(worker.staff_id) + var resting_here := StringName(rest.get(&"facility_id", &"")) == facility_id + if worker.assigned_facility_id != facility_id and StringName(assignment.get(&"facility_id", &"")) != facility_id and not resting_here: continue + var restored: StaffRuntime = after.staff_service.get_staff(worker.staff_id) + if restored == null: + result[&"errors"].append({&"code": &"STAFF_MISSING", &"id": worker.staff_id}) + continue + _check(result, before.staff_commute.find_facility_access_path(worker.current_cell, old_room), after.staff_commute.find_facility_access_path(restored.current_cell, new_room), &"STAFF_ACCESS_LOST", worker.staff_id) + var access_cells := {} + for cell: Vector3i in new_room.occupied_cells: + for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: access_cells[cell + offset] = true + for task: StaffTask in before.staff_service.get_tasks(): + if task.is_terminal() or (task.target_entity_id != facility_id and task.rescue_facility_id != facility_id): continue + var restored: StaffTask = after.staff_service.get_task(task.task_id) + if restored == null or restored.create_snapshot() != task.create_snapshot(): + result[&"errors"].append({&"code": &"TASK_STATE_CHANGED", &"id": task.task_id}) + continue + if not access_cells.has(task.target_cell): + result[&"errors"].append({&"code": &"TASK_DESTINATION_CHANGED", &"id": task.task_id}) + continue + if task.assigned_staff_id.is_empty(): continue + var old_worker: StaffRuntime = before.staff_service.get_staff(task.assigned_staff_id) + var new_worker: StaffRuntime = after.staff_service.get_staff(task.assigned_staff_id) + if old_worker == null or new_worker == null: + result[&"errors"].append({&"code": &"STAFF_MISSING", &"id": task.assigned_staff_id}) + continue + _check(result, {&"reachable":_task_reachable(before, old_worker, task)}, {&"reachable":_task_reachable(after, new_worker, restored)}, &"TASK_ACCESS_LOST", task.task_id) + result[&"ok"] = result[&"errors"].is_empty() and result[&"guest_replans"].is_empty() + return result + +static func _check(result: Dictionary, old_route: Dictionary, new_route: Dictionary, code: StringName, actor_id: StringName) -> void: + result[&"checked_routes"] += 1 + if old_route.get(&"reachable", false) and not new_route.get(&"reachable", false): + result[&"errors"].append({&"code": code, &"id": actor_id}) + +## Apply only to an isolated candidate. Validate the entire batch before changes. +## Position, movement credit, service request, wallet and transport stay intact. +static func retarget_guests(candidate: Variant, report: Dictionary) -> Dictionary: + var result := {&"ok": false, &"errors": [], &"changed": []} + if not report.get(&"errors", []).is_empty(): + result[&"errors"].append(&"ACCESS_LOSS") + return result + var updates: Array = [] + var seen := {} + for plan: Dictionary in report.get(&"guest_replans", []): + var id := StringName(plan.get(&"id", &"")) + var guest: GuestRuntime = candidate.guest_service.get_guest(id) + if guest == null or seen.has(id) or guest.state != GuestRuntime.State.MOVING or not guest.path_handle is Dictionary or not plan.get(&"target") is Vector3i: + result[&"errors"].append(&"INVALID_REPLAN") + return result + seen[id] = true + if candidate._path_handle_target(guest.path_handle, guest.current_cell) != plan.get(&"old_target"): + result[&"errors"].append(&"STALE_REPLAN") + return result + if not candidate.elevators.get_actor_trip(candidate._guest_path_actor_id(id)).is_empty(): + result[&"errors"].append(&"TRANSPORT_REPLAN_REQUIRED") + return result + if candidate.path_service.find_path(guest.current_cell, plan[&"target"]).is_empty(): + result[&"errors"].append(&"TARGET_UNREACHABLE") + return result + updates.append({&"guest": guest, &"target": plan[&"target"]}) + for update: Dictionary in updates: + var guest: GuestRuntime = update[&"guest"] + var handle: Dictionary = guest.path_handle.duplicate(true) + handle[&"target_cell"] = update[&"target"] + handle[&"graph_revision"] = candidate.path_service.graph_revision + guest.path_handle = handle + result[&"changed"].append(guest.guest_id) + result[&"ok"] = true + return result + +static func _task_reachable(runtime: Variant, worker: StaffRuntime, task: StaffTask) -> bool: + if task.task_type in [&"rescue_pickup", &"rescue_transport"]: + return not runtime.path_service.find_backyard_path(worker.current_cell, task.target_cell, true).is_empty() + return not runtime.path_service.find_path(worker.current_cell, task.target_cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]).is_empty() diff --git a/scripts/construction/facility_resize_routes.gd.uid b/scripts/construction/facility_resize_routes.gd.uid new file mode 100644 index 0000000..85b5fa8 --- /dev/null +++ b/scripts/construction/facility_resize_routes.gd.uid @@ -0,0 +1 @@ +uid://bu8b3e66tt4lc diff --git a/scripts/core/command_queue.gd b/scripts/core/command_queue.gd index 60109b6..95567ac 100644 --- a/scripts/core/command_queue.gd +++ b/scripts/core/command_queue.gd @@ -197,7 +197,7 @@ func create_snapshot() -> Dictionary: processed_data.append(result.to_dictionary()) var idempotency_data := {} var idempotency_keys: Array = _idempotency_to_command_id.keys() - idempotency_keys.sort() + idempotency_keys.sort_custom(func(a: Variant, b: Variant) -> bool: return String(a) < String(b)) for key: Variant in idempotency_keys: idempotency_data[String(key)] = int(_idempotency_to_command_id[key]) return { diff --git a/scripts/core/definition_registry.gd b/scripts/core/definition_registry.gd index 7b8fea3..7fea221 100644 --- a/scripts/core/definition_registry.gd +++ b/scripts/core/definition_registry.gd @@ -371,9 +371,15 @@ func _validate_facility(definition: FacilityDefinition, requested_path: String) if definition.staff_min < 0 or definition.staff_max < definition.staff_min: _add_error(&"value_out_of_range", facility_id, requested_path, &"staff_min", "Staff limits are inconsistent.") valid = false + if definition.customer_capacity < -1 or definition.service_attribute not in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"]: + _add_error(&"value_out_of_range", facility_id, requested_path, &"customer_capacity", "Customer capacity or service attribute is invalid.") + valid = false if definition.capacity < 0 or definition.queue_capacity < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"capacity", "Service and queue capacities cannot be negative.") valid = false + if definition.faith_per_service < 0: + _add_error(&"invalid_service_reward", facility_id, requested_path, &"faith_per_service", "Service Faith must be non-negative.") + valid = false if definition.base_service_minutes <= 0 or definition.base_price < 0: _add_error(&"value_out_of_range", facility_id, requested_path, &"base_service_minutes", "Service duration must be positive and price cannot be negative.") valid = false diff --git a/scripts/core/domain_event_stream.gd b/scripts/core/domain_event_stream.gd index 11b1e31..006b7da 100644 --- a/scripts/core/domain_event_stream.gd +++ b/scripts/core/domain_event_stream.gd @@ -8,6 +8,8 @@ const MAX_BATCH_SIZE := 4_096 var _subscribers: Dictionary = {} var _published_ids: Dictionary = {} +var _snapshot_ids: Array[String] = [] +var _snapshot_ids_dirty := true var _recent_events: Array[SimulationDomainEvent] = [] var _pending_deliveries: Array[SimulationDomainEvent] = [] var _delivery_active := false @@ -49,6 +51,8 @@ func publish_batch(events: Array) -> bool: return false batch_ids[event.event_id] = true committed.append(event.copy()) + if not committed.is_empty(): + _snapshot_ids_dirty = true for event: SimulationDomainEvent in committed: _published_ids[event.event_id] = true _recent_events.append(event.copy()) @@ -90,13 +94,13 @@ func recent_events() -> Array[SimulationDomainEvent]: return result func create_snapshot() -> Dictionary: - var published_ids: Array = _published_ids.keys() - published_ids.sort_custom(func(left: Variant, right: Variant) -> bool: - return String(left) < String(right) - ) - var saved_ids: Array[String] = [] - for event_id: Variant in published_ids: - saved_ids.append(String(event_id)) + if _snapshot_ids_dirty: + _snapshot_ids.clear() + for event_id: Variant in _published_ids: + _snapshot_ids.append(String(event_id)) + _snapshot_ids.sort() + _snapshot_ids_dirty = false + var saved_ids: Array[String] = _snapshot_ids.duplicate() var saved_recent: Array[Dictionary] = [] for event in _recent_events: saved_recent.append(event.to_dictionary()) @@ -131,6 +135,7 @@ func restore_snapshot(snapshot: Dictionary) -> bool: if restored_recent.size() > RECENT_EVENT_LIMIT: return false _published_ids = restored_ids + _snapshot_ids_dirty = true _recent_events = restored_recent _pending_deliveries.clear() return true diff --git a/scripts/core/performance_budget.gd b/scripts/core/performance_budget.gd index 4e79afb..5e921cb 100644 --- a/scripts/core/performance_budget.gd +++ b/scripts/core/performance_budget.gd @@ -16,7 +16,7 @@ const DEFAULT_SAMPLE_CAPACITY := 512 const DEFAULT_BUDGETS := { &"simulation_x1": {&"aggregation": Aggregation.AVERAGE, &"limit_us": 4_000}, - &"simulation_x4": {&"aggregation": Aggregation.P95, &"limit_us": 12_000}, + &"simulation_x3": {&"aggregation": Aggregation.P95, &"limit_us": 12_000}, &"path_query": {&"aggregation": Aggregation.P95, &"limit_us": 2_000}, &"main_thread_stall": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 50_000}, &"save": {&"aggregation": Aggregation.MAXIMUM, &"limit_us": 5_000_000}, @@ -71,12 +71,12 @@ func record_sample(metric_id: StringName, duration_us: int) -> Dictionary: func record_simulation_tick(duration_us: int, speed: int) -> Dictionary: - var metric_id := &"simulation_x4" if speed == 4 else &"simulation_x1" + var metric_id := &"simulation_x3" if speed == 3 else &"simulation_x1" return record_sample(metric_id, duration_us) ## Records the full main-thread cost of one rendered/process frame, including -## all game-minute ticks consumed at accelerated speed. This keeps x4 from +## all game-minute ticks consumed at accelerated speed. This keeps x3 from ## accidentally reporting four individually-fast ticks while the frame stalls. func record_simulation_frame(duration_us: int, speed: int, ticks_run: int) -> Dictionary: if ticks_run <= 0: diff --git a/scripts/core/save_service.gd b/scripts/core/save_service.gd index 83ce59f..69d6ed8 100644 --- a/scripts/core/save_service.gd +++ b/scripts/core/save_service.gd @@ -367,12 +367,27 @@ static func migrate_save( return _complete_timed_operation(result, &"save", started_at_us) +## Domain migrations keep the package format version but still require the +## same checksum-verified backup and atomic promotion as format migrations. +## The caller validates domain invariants before supplying the candidate. +static func replace_snapshot_with_backup(save_path: String, expected: Dictionary, candidate: Dictionary) -> Dictionary: + if expected.is_empty() or candidate.is_empty(): + return _failure(&"MIGRATION_SNAPSHOT_REQUIRED", save_path) + var validation := SimulationSnapshot.validate(candidate) + if not validation[&"ok"]: + return _failure(&"MIGRATION_INVALID_SNAPSHOT", ", ".join(validation[&"errors"])) + var started := Time.get_ticks_usec() + return _complete_timed_operation(_migrate_save_impl(save_path, null, FORMAT_VERSION, expected, candidate), &"save", started) + + static func _migrate_save_impl( save_path: String, registry: SaveMigrationRegistry, - target_version: int + target_version: int, + expected_snapshot: Dictionary = {}, + replacement_snapshot: Dictionary = {} ) -> Dictionary: - if registry == null: + if registry == null and replacement_snapshot.is_empty(): return _failure(&"MIGRATION_REGISTRY_REQUIRED", save_path) var resolved := _resolve_safe_save_path(save_path) if not resolved[&"ok"]: @@ -410,7 +425,14 @@ static func _migrate_save_impl( var from_version := int(source[&"manifest"].get("format_version", -1)) if target_version < from_version: return _failure(&"MIGRATION_DOWNGRADE_UNSUPPORTED", "%d -> %d" % [from_version, target_version]) - if target_version == from_version: + if not replacement_snapshot.is_empty(): + if from_version != target_version: + return _failure(&"FORMAT_MIGRATION_REQUIRED", save_path) + if source[&"snapshot"] == replacement_snapshot: + return {&"ok": true, &"code": &"ALREADY_CURRENT", &"backup_created": false, &"path": save_path} + if source[&"snapshot"] != expected_snapshot: + return _failure(&"MIGRATION_SOURCE_CHANGED", save_path) + if target_version == from_version and replacement_snapshot.is_empty(): var current_validation := SimulationSnapshot.validate(source[&"snapshot"]) if target_version == FORMAT_VERSION and not current_validation[&"ok"]: return _failure(&"INVALID_SNAPSHOT", ", ".join(current_validation[&"errors"])) @@ -427,7 +449,12 @@ static func _migrate_save_impl( &"manifest": source[&"manifest"].duplicate(true), &"snapshot": source[&"snapshot"].duplicate(true), } - var migrated := registry.migrate(payload, from_version, target_version) + var migrated: Dictionary + if replacement_snapshot.is_empty(): + migrated = registry.migrate(payload, from_version, target_version) + else: + payload[&"snapshot"] = replacement_snapshot.duplicate(true) + migrated = {&"ok": true, &"data": payload, &"applied_migration_ids": [&"domain_snapshot_replacement"]} if not migrated[&"ok"]: return _with_context(migrated, { &"path": save_path, diff --git a/scripts/core/simulation_digest.gd b/scripts/core/simulation_digest.gd index 57eb3ac..433939c 100644 --- a/scripts/core/simulation_digest.gd +++ b/scripts/core/simulation_digest.gd @@ -22,6 +22,10 @@ class KeyLayouts: extends RefCounted var strings: Dictionary = {} var names: Dictionary = {} + # Bounded per-call immutable scalar encodings; never retain mutable containers. + var integers: Dictionary = {} + var text_values: Dictionary = {} + var name_values: Dictionary = {} @@ -56,14 +60,19 @@ static func _encode_value(value: Variant, output: PackedByteArray, depth: int, l TYPE_BOOL: _append_ascii(output, "b1" if bool(value) else "b0") TYPE_INT: - _append_ascii(output, "i%d;" % int(value)) + if layouts.integers.has(value): + output.append_array(layouts.integers[value]) + else: + var encoded := ("i%d;" % int(value)).to_utf8_buffer() + output.append_array(encoded) + if layouts.integers.size() < 512: layouts.integers[value] = encoded TYPE_FLOAT: _append_ascii(output, "f") return _append_float64(output, float(value)) TYPE_STRING: - _append_sized_bytes(output, "s", String(value).to_utf8_buffer()) + _append_cached_text(output, "s", String(value), layouts.text_values) TYPE_STRING_NAME: - _append_sized_bytes(output, "q", String(value).to_utf8_buffer()) + _append_cached_text(output, "q", String(value), layouts.name_values) TYPE_VECTOR2: var vector: Vector2 = value _append_ascii(output, "v2") @@ -290,3 +299,15 @@ static func _append_sized_bytes(output: PackedByteArray, tag: String, value: Pac static func _append_ascii(output: PackedByteArray, value: String) -> void: output.append_array(value.to_utf8_buffer()) + + +static func _append_cached_text(output: PackedByteArray, tag: String, value: String, cache: Dictionary) -> void: + if cache.has(value): + output.append_array(cache[value]) + return + var bytes := value.to_utf8_buffer() + var encoded := ("%s%d:" % [tag, bytes.size()]).to_utf8_buffer() + encoded.append_array(bytes) + output.append_array(encoded) + if value.length() <= 128 and cache.size() < 256: + cache[value] = encoded diff --git a/scripts/core/simulation_kernel.gd b/scripts/core/simulation_kernel.gd index 0daef0b..8492ed1 100644 --- a/scripts/core/simulation_kernel.gd +++ b/scripts/core/simulation_kernel.gd @@ -25,7 +25,7 @@ enum TickPhase { const MINUTES_PER_DAY := 24 * 60 const START_ABSOLUTE_MINUTE := 6 * 60 -const REAL_SECONDS_PER_GAME_HOUR := 30.0 +const REAL_SECONDS_PER_GAME_HOUR := 10.0 const REAL_SECONDS_PER_GAME_MINUTE := REAL_SECONDS_PER_GAME_HOUR / 60.0 const MAX_TICKS_PER_FRAME := 8 const MAX_BACKLOG_MINUTES := 120 @@ -160,7 +160,7 @@ func request_current_tick_abort() -> void: _abort_current_tick_requested = true func set_speed(value: int) -> bool: - if value not in [1, 2, 4]: + if value not in [1, 2, 3]: return false speed = value paused = false @@ -218,7 +218,10 @@ func restore_clock(snapshot: Dictionary) -> bool: if not snapshot.has(&"absolute_minute"): return false var restored_speed := int(snapshot.get(&"speed", 1)) - if restored_speed not in [1, 2, 4]: + # Legacy x4 saves resume at the original specification maximum, x3. + if restored_speed == 4: + restored_speed = 3 + if restored_speed not in [1, 2, 3]: return false absolute_minute = maxi(int(snapshot[&"absolute_minute"]), 0) speed = restored_speed diff --git a/scripts/core/simulation_snapshot.gd b/scripts/core/simulation_snapshot.gd index 0c68bb8..e1cd087 100644 --- a/scripts/core/simulation_snapshot.gd +++ b/scripts/core/simulation_snapshot.gd @@ -23,7 +23,7 @@ static func validate(snapshot: Dictionary) -> Dictionary: var clock_data: Dictionary = snapshot[&"clock"] if int(clock_data.get(&"absolute_minute", -1)) < 0: errors.append("invalid_absolute_minute") - if int(clock_data.get(&"speed", 0)) not in [1, 2, 4]: + if int(clock_data.get(&"speed", 0)) not in [1, 2, 3, 4]: errors.append("invalid_speed") if not snapshot.get(&"game_state", null) is Dictionary: errors.append("missing_game_state") diff --git a/scripts/data/facility_catalog.gd b/scripts/data/facility_catalog.gd index 872b752..7129ba3 100644 --- a/scripts/data/facility_catalog.gd +++ b/scripts/data/facility_catalog.gd @@ -46,6 +46,16 @@ const FACILITY_PATHS: Array[String] = [ ## Required movement infrastructure added after the original sixteen-facility ## catalog. Separate paths preserve the baseline design table and legacy scans. +## Original-spec facilities integrated into normal construction beyond baseline. +const ORIGINAL_FACILITY_PATHS: Array[String] = [ + "res://data/facilities/expansion/cleaning_room.tres", + "res://data/facilities/expansion/chapel.tres", + "res://data/facilities/expansion/premium_room.tres", + "res://data/facilities/expansion/staff_dormitory.tres", +] + +const RESCUE_FACILITY_PATHS: Array[String] = ["res://data/facilities/rescue/infirmary.tres"] + const INFRASTRUCTURE_FACILITY_IDS: Array[StringName] = [ &"guest_stairs", &"service_stairs", @@ -130,6 +140,8 @@ static func load_available(include_expansion := false) -> Array[FacilityDefiniti static func by_id(facility_id: StringName) -> FacilityDefinition: var definitions := load_available(true) definitions.append_array(load_infrastructure()) + for path: String in RESCUE_FACILITY_PATHS + ORIGINAL_FACILITY_PATHS: + definitions.append(load(path) as FacilityDefinition) for definition in definitions: if definition.id == facility_id: return definition diff --git a/scripts/data/facility_definition.gd b/scripts/data/facility_definition.gd index 765b2f9..66eaf26 100644 --- a/scripts/data/facility_definition.gd +++ b/scripts/data/facility_definition.gd @@ -58,6 +58,8 @@ enum ViewLayer { @export var primary_skill: StringName = &"service" @export_group("Service") +@export var customer_capacity := -1 +@export var service_attribute: StringName = &"" @export var capacity := 0 @export var queue_capacity := 0 @export var base_service_minutes := 1 @@ -84,6 +86,7 @@ enum ViewLayer { @export var content_replacement_id: StringName = &"" @export_group("Progression") +@export var faith_per_service := 0 @export_range(1, 7, 1) var unlock_rank := 1 @export var research_requirements: Array[StringName] = [] @@ -105,10 +108,13 @@ func is_valid() -> bool: and maintenance_mana >= 0 \ and staff_min >= 0 \ and staff_max >= staff_min \ + and customer_capacity >= -1 \ + and service_attribute in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"] \ and capacity >= 0 \ and queue_capacity >= 0 \ and base_service_minutes > 0 \ and base_price >= 0 \ + and faith_per_service >= 0 \ and detailed_profile_is_valid() diff --git a/scripts/data/mvp_content_catalog.gd b/scripts/data/mvp_content_catalog.gd index 4394cf7..a036af8 100644 --- a/scripts/data/mvp_content_catalog.gd +++ b/scripts/data/mvp_content_catalog.gd @@ -33,7 +33,7 @@ const DEFAULT_PATHS := { const REQUIRED_COUNTS := { &"guest_classes": 8, &"species": 4, - &"staff_candidates": 20, + &"staff_candidates": 21, &"unique_characters": 2, } const REQUIRED_IDS := { @@ -45,7 +45,7 @@ const REQUIRED_IDS := { &"staff_akane", &"staff_bardo", &"staff_chika", &"staff_doran", &"staff_eri", &"staff_fugo", &"staff_gina", &"staff_haru", &"staff_iris", &"staff_juno", &"staff_klaus", &"staff_lina", &"staff_marc", &"staff_nadia", &"staff_otto", - &"staff_petra", &"staff_quinn", &"staff_rika", &"staff_sven", &"staff_tessa", + &"staff_petra", &"staff_quinn", &"staff_rika", &"staff_sven", &"staff_tessa", &"staff_uma", ], &"unique_characters": [&"heroine_anri", &"rival_rai"], } @@ -58,18 +58,35 @@ const SHIFT_IDS: Array[StringName] = [&"any", &"day", &"night"] const SCHEDULE_PROFILE_IDS: Array[StringName] = [&"day", &"evening", &"night"] const UNIQUE_KINDS: Array[StringName] = [&"heroine", &"rival"] const MECHANICAL_EFFECT_IDS: Array[StringName] = [ - &"assigned_floor_patience_loss_multiplier_bp", + &"assigned_floor_revenue_multiplier_bp", &"base_shield_attack", &"night_shield_attack_bonus", + &"cleaning_work_rate_multiplier", ] const HEROINE_EFFECT_IDS: Array[StringName] = [ - &"assigned_floor_patience_loss_multiplier_bp", + &"assigned_floor_revenue_multiplier_bp", ] const RIVAL_EFFECT_IDS: Array[StringName] = [ &"base_shield_attack", &"night_shield_attack_bonus", ] +# Trusted per-person declarations. JSON cannot supply or broaden these policies. +# The pair below preserves the current MVP JSON contract; it is not a new +# relationship assignment inferred for the other characters in original 6.2. +const UNIQUE_VALIDATION_POLICIES := { + &"heroine_anri": { + &"character_kind": &"heroine", + &"mechanical_effect_ids": HEROINE_EFFECT_IDS, + &"counterpart_id": &"rival_rai", + }, + &"rival_rai": { + &"character_kind": &"rival", + &"mechanical_effect_ids": RIVAL_EFFECT_IDS, + &"counterpart_id": &"heroine_anri", + }, +} + const GUEST_TAGS: Array[StringName] = [ &"combatant", &"diplomatic", &"faith_aligned", &"mana_sensitive", &"nocturnal", &"noise_sensitive", &"stealthy", &"trade_focused", @@ -85,6 +102,7 @@ const PREFERENCE_IDS: Array[StringName] = [ &"underground", &"vip", ] const ROLE_IDS: Array[StringName] = [ + &"cleric", &"attendant", &"bartender", &"clerk", &"cleaner", &"cook", &"guard", &"influencer", &"innkeeper", &"maintenance", &"masseur", &"medic", &"porter", &"receptionist", @@ -124,6 +142,8 @@ const UNIQUE_FIELDS: Array[StringName] = [ &"role_id", &"skills", &"traits", ] +# Transient revision of validated catalog content; never serialized. +var _manifest_generation := 0 var _entries_by_type: Dictionary = {} var _ordered_ids_by_type: Dictionary = {} var _errors: Array[Dictionary] = [] @@ -172,6 +192,10 @@ func rebuild_from_documents(documents: Dictionary) -> Dictionary: return get_report() +func get_manifest_generation() -> int: + return _manifest_generation + + func is_valid() -> bool: return _errors.is_empty() and _entries_by_type.size() == DEFAULT_PATHS.size() @@ -274,22 +298,36 @@ func build_all_guest_definitions() -> Array[GuestDefinition]: return result +## General candidates remain a separate catalog category. Only approved unique +## recruitment is exposed here; rival joining conditions are not inferred. +func get_recruitable_staff_ids() -> Array[StringName]: + var ids := get_staff_candidate_ids() + if not get_entry(&"unique_characters", &"heroine_anri").is_empty(): + ids.append(&"heroine_anri") + ids.sort_custom(_name_less) + return ids + + +func get_recruitment_entry(candidate_id: StringName) -> Dictionary: + return get_entry(&"unique_characters", candidate_id) if candidate_id == &"heroine_anri" else get_entry(&"staff_candidates", candidate_id) + + func staff_hire_payload(candidate_id: StringName) -> Dictionary: - var candidate := get_entry(&"staff_candidates", candidate_id) + var candidate := get_recruitment_entry(candidate_id) if candidate.is_empty(): return {} return { &"staff_id": candidate_id, &"role_id": StringName(candidate[&"role_id"]), - &"daily_wage": int(candidate[&"daily_wage"]), - &"signing_cost": int(candidate[&"signing_cost"]), + &"daily_wage": int(candidate.get(&"daily_wage", 0)), + &"signing_cost": int(candidate.get(&"signing_cost", 0)), &"preferred_shift": StringName(candidate[&"preferred_shift"]), &"adult_verified": bool(candidate[&"adult_verified"]), &"age": int(candidate[&"age"]), &"options": { - &"daily_wage": int(candidate[&"daily_wage"]), - &"employment_kind": &"FREE_HIRE", - &"permitted_task_tags": _to_name_array(candidate[&"permitted_task_tags"]), + &"daily_wage": int(candidate.get(&"daily_wage", 0)), + &"employment_kind": &"UNIQUE" if candidate_id == &"heroine_anri" else &"FREE_HIRE", + &"permitted_task_tags": _to_name_array(candidate.get(&"permitted_task_tags", [])), &"role_tags": [StringName(candidate[&"role_id"])], &"skills": _name_keyed_integer_map(candidate[&"skills"]), }, @@ -297,6 +335,7 @@ func staff_hire_payload(candidate_id: StringName) -> Dictionary: func _reset() -> void: + _manifest_generation += 1 _entries_by_type.clear() _ordered_ids_by_type.clear() _errors.clear() @@ -645,10 +684,20 @@ func _validate_unique(entry_id: StringName, entry: Dictionary) -> void: _add_error(content_type, entry_id, &"character_kind", &"unknown_character_kind", "") elif not String(entry_id).begins_with("%s_" % String(kind)): _add_error(content_type, entry_id, &"id", &"invalid_namespace", "%s_" % String(kind)) - var raw_counterpart_id: Variant = _value(entry, &"counterpart_id", null) - var counterpart_id := StringName(raw_counterpart_id) if _is_string_variant(raw_counterpart_id) else &"" - if counterpart_id.is_empty() or counterpart_id == entry_id: - _add_error(content_type, entry_id, &"counterpart_id", &"invalid_counterpart", "") + var policy: Dictionary = _unique_validation_policy(entry_id) + if policy.is_empty(): + _add_error(content_type, entry_id, &"id", &"missing_unique_policy", "") + elif kind != StringName(policy[&"character_kind"]): + _add_error(content_type, entry_id, &"character_kind", &"character_policy_mismatch", "") + if policy.has(&"counterpart_id"): + var raw_counterpart_id: Variant = _value(entry, &"counterpart_id", null) + var counterpart_id := StringName(raw_counterpart_id) if _is_string_variant(raw_counterpart_id) else &"" + if counterpart_id.is_empty() or counterpart_id == entry_id: + _add_error(content_type, entry_id, &"counterpart_id", &"invalid_counterpart", "") + elif not policy.is_empty() and (entry.has(&"counterpart_id") or entry.has("counterpart_id")): + # An unspecified relationship is represented by omission, not null, + # an empty ID, a fabricated actor or an automatic reciprocal pair. + _add_error(content_type, entry_id, &"counterpart_id", &"unexpected_counterpart", "") var traits := _to_name_array(_value(entry, &"traits", [])) if not traits.has(&"unique") or not traits.has(&"unsellable"): _add_error(content_type, entry_id, &"traits", &"unique_safety_traits_required", "") @@ -669,7 +718,9 @@ func _validate_unique(entry_id: StringName, entry: Dictionary) -> void: if not MECHANICAL_EFFECT_IDS.has(effect_id) \ or not _mechanical_effect_value_is_valid(effect_id, effect_value): _add_error(content_type, entry_id, &"mechanical_effects", &"invalid_mechanical_effect", String(raw_key)) - var expected_effects: Array[StringName] = HEROINE_EFFECT_IDS if kind == &"heroine" else RIVAL_EFFECT_IDS + var expected_effects: Array[StringName] = [] + if not policy.is_empty(): + expected_effects.assign(policy[&"mechanical_effect_ids"]) for expected_effect_id: StringName in expected_effects: if not seen_effects.has(expected_effect_id): _add_error(content_type, entry_id, &"mechanical_effects", &"missing_mechanical_effect", String(expected_effect_id)) @@ -678,6 +729,11 @@ func _validate_unique(entry_id: StringName, entry: Dictionary) -> void: _add_error(content_type, entry_id, &"mechanical_effects", &"effect_kind_mismatch", String(effect_id)) +func _unique_validation_policy(entry_id: StringName) -> Dictionary: + var policy: Dictionary = UNIQUE_VALIDATION_POLICIES.get(entry_id, {}) + return policy.duplicate(true) + + func _validate_adult(content_type: StringName, entry_id: StringName, entry: Dictionary) -> void: var verified: Variant = _value(entry, &"adult_verified", null) if not verified is bool or not bool(verified): @@ -714,12 +770,22 @@ func _validate_cross_references() -> void: var unique_index: Dictionary = _entries_by_type.get(&"unique_characters", {}) for entry_id: StringName in get_unique_character_ids(): var entry: Dictionary = unique_index[entry_id] + var policy: Dictionary = _unique_validation_policy(entry_id) + if policy.is_empty(): + _add_error(&"unique_characters", entry_id, &"id", &"missing_unique_policy", "") + continue + if not policy.has(&"counterpart_id"): + continue var counterpart_id := StringName(entry[&"counterpart_id"]) if not unique_index.has(counterpart_id): _add_error(&"unique_characters", entry_id, &"counterpart_id", &"missing_counterpart", String(counterpart_id)) continue + if counterpart_id != StringName(policy[&"counterpart_id"]): + _add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_policy_mismatch", String(counterpart_id)) var counterpart: Dictionary = unique_index[counterpart_id] - if StringName(counterpart[&"counterpart_id"]) != entry_id: + var counterpart_policy: Dictionary = _unique_validation_policy(counterpart_id) + if StringName(_value(counterpart, &"counterpart_id", &"")) != entry_id \ + or StringName(counterpart_policy.get(&"counterpart_id", &"")) != entry_id: _add_error(&"unique_characters", entry_id, &"counterpart_id", &"counterpart_not_reciprocal", String(counterpart_id)) if StringName(counterpart[&"character_kind"]) == StringName(entry[&"character_kind"]): _add_error(&"unique_characters", entry_id, &"character_kind", &"counterpart_kind_conflict", String(counterpart_id)) @@ -983,10 +1049,14 @@ static func _mechanical_effect_value_is_valid(effect_id: StringName, raw_value: var value := int(raw_value) if value < 0 or value > MAX_MECHANICAL_EFFECT_VALUE: return false - if effect_id == &"assigned_floor_patience_loss_multiplier_bp": + if effect_id == &"assigned_floor_revenue_multiplier_bp": return value >= 2500 and value <= 30_000 if effect_id in [&"base_shield_attack", &"night_shield_attack_bonus"]: return value <= 100 + if effect_id == &"cleaning_work_rate_multiplier": + # Original 6.2 specifies exactly three. This validator does not register + # Fine, assign an employment profile or activate any Runtime effect. + return value == 3 return false diff --git a/scripts/defense/defense_service.gd b/scripts/defense/defense_service.gd index ed36951..5d8fa33 100644 --- a/scripts/defense/defense_service.gd +++ b/scripts/defense/defense_service.gd @@ -103,6 +103,30 @@ func get_raid_ids() -> Array[StringName]: return _sorted_keys(_raids) +## Explicit IDs in valid older saves need not have advanced Runtime's allocator. +## Query the existing history without exposing or copying its mutable payloads. +func has_resolution(raid_id: StringName, resolution_id: StringName) -> bool: + return _raids.has(raid_id) and (_raids[raid_id][&"resolved_rounds"] as Dictionary).has(resolution_id) + + +## Detached primitives only; ordinary ticks must not clone growing round history. +## The interval is already part of each persisted raid, not a new balance value. +func get_due_rounds(absolute_minute: int) -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for raid_id: StringName in _sorted_keys(_raids): + var raid: Dictionary = _raids[raid_id] + if int(raid[&"state"]) != RaidState.ACTIVE: + continue + var earliest := int(raid[&"state_changed_at"]) + int(raid[&"round_minutes"]) + if earliest <= absolute_minute: + result.append({ + &"raid_id": raid_id, + &"earliest_minute": earliest, + &"round_number": int(raid[&"current_round"]) + 1, + }) + return result + + func get_active_raid_ids() -> Array[StringName]: var result: Array[StringName] = [] for raid_id: StringName in _sorted_keys(_raids): @@ -112,6 +136,75 @@ func get_active_raid_ids() -> Array[StringName]: return result +## Compact, owned projection: never copy combatants or accumulated round history +## just to display the current threats. Preparing raids also lock employees. +func get_threat_summary() -> Dictionary: + var counts := {&"preparing": 0, &"active": 0, &"between_waves": 0, &"total": 0} + for raid: Dictionary in _raids.values(): + match int(raid[&"state"]): + RaidState.PREPARING: + counts[&"preparing"] += 1 + RaidState.ACTIVE: + counts[&"active"] += 1 + RaidState.BETWEEN_WAVES: + counts[&"between_waves"] += 1 + counts[&"total"] = counts[&"preparing"] + counts[&"active"] + counts[&"between_waves"] + return counts + + +## Owned UI projections. Deliberately omit resolved rounds and skill history. +func get_management_rows() -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + for raid_id: StringName in _sorted_keys(_raids): + var raid: Dictionary = _raids[raid_id] + var missing: Array[StringName] = [] + for objective_id: StringName in raid[&"objective_ids"]: + if not (raid[&"assets"] as Dictionary).has(objective_id): + missing.append(objective_id) + var defender_ids: Array[StringName] = [] + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + if raid[&"combatants"][actor_id][&"side"] == &"DEFENDER": defender_ids.append(actor_id) + rows.append({ + &"raid_id": raid_id, &"state": int(raid[&"state"]), + &"source_id": raid[&"source_id"], &"current_round": int(raid[&"current_round"]), + &"source_kind": String((raid.get(&"event_source", {}) as Dictionary).get("source_kind", "")), + &"created_at": int(raid[&"created_at"]), + &"current_wave": int(raid[&"current_wave_index"]) + 1, + &"wave_count": (raid[&"waves"] as Array).size(), + &"retreat_policy": raid[&"retreat_policy"], + &"defender_retreat_threshold": int(raid[&"defender_retreat_threshold"]), + &"objective_ids": (raid[&"objective_ids"] as Array).duplicate(), + &"missing_objectives": missing, + &"defender_ids": defender_ids, &"asset_ids": _sorted_keys(raid[&"assets"]), + &"result": raid[&"result"], &"result_reason": raid[&"result_reason"], + }) + return rows + + +func get_management_detail(raid_id: StringName) -> Dictionary: + if not _raids.has(raid_id): + return {} + var raid: Dictionary = _raids[raid_id] + var defenders: Array[Dictionary] = [] + for actor_id: StringName in _sorted_keys(raid[&"combatants"]): + var actor: Dictionary = raid[&"combatants"][actor_id] + if actor[&"side"] == &"DEFENDER": + defenders.append({ + &"actor_id": actor_id, &"health": actor[&"health"], + &"max_health": actor[&"max_health"], &"combat": actor[&"combat"], + &"fatigue": actor[&"fatigue"], &"state": actor[&"state"], + }) + var assets: Array[Dictionary] = [] + for asset_id: StringName in _sorted_keys(raid[&"assets"]): + var asset: Dictionary = raid[&"assets"][asset_id] + assets.append({ + &"asset_id": asset_id, &"kind": asset[&"kind"], + &"integrity": asset[&"integrity"], &"max_integrity": asset[&"max_integrity"], + &"defense": asset[&"defense"], + }) + return {&"defenders": defenders, &"assets": assets} + + func create_raid( raid_id: StringName, absolute_minute: int, @@ -124,6 +217,11 @@ func create_raid( if _raids.has(raid_id): return _duplicate("Raid", raid_id) + var event_source_value: Variant = _value(definition, &"event_source", {}) + if not _is_valid_event_source(event_source_value): + return _invalid("Event threat provenance is malformed.") + var event_source: Dictionary = event_source_value.duplicate(true) + var entry_points := _sorted_unique_names(_value(definition, &"entry_points", [])) var objective_ids := _sorted_unique_names(_value(definition, &"objective_ids", [])) if entry_points.is_empty() or objective_ids.is_empty(): @@ -146,6 +244,7 @@ func create_raid( var raid := { &"raid_id": raid_id, &"source_id": StringName(_value(definition, &"source_id", &"")), + &"event_source": event_source, &"state": RaidState.PREPARING, &"created_at": absolute_minute, &"started_at": -1, @@ -1372,6 +1471,7 @@ func _raid_to_snapshot(raid: Dictionary) -> Dictionary: var snapshot := { &"raid_id": String(raid[&"raid_id"]), &"source_id": String(raid[&"source_id"]), + &"event_source": _canonical_snapshot_value(raid.get(&"event_source", {})), &"state": RaidState.keys()[int(raid[&"state"])], &"created_at": raid[&"created_at"], &"started_at": raid[&"started_at"], @@ -1432,6 +1532,9 @@ func _raid_to_snapshot(raid: Dictionary) -> Dictionary: func _raid_from_snapshot(raw: Dictionary) -> Dictionary: + var event_source_value: Variant = _value(raw, &"event_source", {}) + if not _is_valid_event_source(event_source_value): + return {&"ok": false, &"error": "Event threat provenance snapshot is malformed."} for collection_key: StringName in [&"waves", &"combatants", &"assets", &"queued_skills", &"applied_skill_ids", &"resolved_rounds"]: if not _value(raw, collection_key, null) is Array: return {&"ok": false, &"error": "Malformed Raid collection: %s" % collection_key} @@ -1442,6 +1545,7 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary: var raid := { &"raid_id": raid_id, &"source_id": StringName(_value(raw, &"source_id", &"")), + &"event_source": event_source_value.duplicate(true), &"state": state_index, &"created_at": int(_value(raw, &"created_at", -1)), &"started_at": int(_value(raw, &"started_at", -1)), @@ -1563,6 +1667,8 @@ func _raid_from_snapshot(raw: Dictionary) -> Dictionary: func _validate_raid(raid: Dictionary) -> Dictionary: var errors: Array[String] = [] + if not _is_valid_event_source(raid.get(&"event_source", {})): + errors.append("Event threat provenance is invalid.") var raid_id := StringName(raid.get(&"raid_id", &"")) var state := int(raid.get(&"state", -1)) var created_at := int(raid.get(&"created_at", -1)) @@ -1749,7 +1855,10 @@ func _validate_raid(raid: Dictionary) -> Dictionary: var listed_objective: bool = objective_ids is Array and objective_ids.has(asset_id) if listed_objective != (kind == AssetKind.OBJECTIVE): errors.append("Defense objective registration mismatch: %s" % asset_id) - if state != RaidState.PREPARING and assets is Dictionary and objective_ids is Array: + # Preparation may be cancelled before all objective assets are registered. + # A raid that actually started must retain those assets even after cancellation. + var cancelled_before_start := state == RaidState.CANCELLED and started_at == -1 + if state != RaidState.PREPARING and not cancelled_before_start and assets is Dictionary and objective_ids is Array: for objective_id: StringName in objective_ids: if not assets.has(objective_id): errors.append("Started Raid is missing objective asset: %s" % objective_id) @@ -2127,3 +2236,28 @@ func _not_found(kind: String, value_id: StringName) -> CommandResult: last_error, {&"id": value_id} ) + + +## Optional provenance is absent in legacy/manual raids. Configured event raids +## retain it so a replay cannot silently replace an already-running incident. +func _is_valid_event_source(raw: Variant) -> bool: + if not raw is Dictionary: + return false + if raw.is_empty(): + return true + var keys := ["event_instance_id", "event_action_id", "template_id", "template_digest", "source_kind"] + if raw.size() != keys.size(): + return false + for key: String in keys: + var value: Variant = _value(raw, StringName(key), null) + if typeof(value) not in [TYPE_STRING, TYPE_STRING_NAME] or String(value).is_empty(): + return false + if String(_value(raw, &"source_kind", "")) not in ["THIEF", "BANKRUPTCY_RIOT", "BANK_DELINQUENCY", "AUTHORITY_ESCALATION"]: + return false + var digest := String(_value(raw, &"template_digest", "")) + if digest.length() != 64: + return false + for character: String in digest: + if character not in "0123456789abcdef": + return false + return true diff --git a/scripts/economy/facility_upkeep_accrual.gd b/scripts/economy/facility_upkeep_accrual.gd new file mode 100644 index 0000000..127e6bc --- /dev/null +++ b/scripts/economy/facility_upkeep_accrual.gd @@ -0,0 +1,208 @@ +class_name FacilityUpkeepAccrual +extends RefCounted + +## Exact upkeep accumulation in gold-minute units. The caller supplies the +## applicable daily rates for [current_minute, target_minute); this component +## deliberately does not decide whether closed/suspended rooms are billable. +## No money is rounded, debited, or forgiven here. Settlement owns those rules. +const SNAPSHOT_VERSION := 1 +const MINUTES_PER_DAY := 1440 +const MAX_DAILY_RATE := 1000000000 +const MAX_FACILITIES := 20000 +const MAX_PERIODS := 10000 +const MAX_MINUTE := 1000000000 + +var _started_at := -1 +var _current_minute := -1 +var _period_start := -1 +var _pending_units: Dictionary = {} +var _closed_periods: Array[Dictionary] = [] + + +func start_at(minute: int) -> Dictionary: + if _started_at >= 0: + return _failure(&"ALREADY_STARTED") + if minute < 0 or minute > MAX_MINUTE: + return _failure(&"INVALID_MINUTE") + _started_at = minute + _current_minute = minute + _period_start = minute + return {&"ok": true} + + +func get_current_minute() -> int: + return _current_minute + + +func advance_to(minute: int, daily_rates: Dictionary) -> Dictionary: + if _started_at < 0: + return _failure(&"NOT_STARTED") + if minute < _current_minute or minute > MAX_MINUTE: + return _failure(&"INVALID_MINUTE") + # A bounded batch also supports migration/testing without an unbounded loop. + if minute - _current_minute > MINUTES_PER_DAY: + return _failure(&"INTERVAL_TOO_LARGE") + var parsed := _parse_units(daily_rates, MAX_DAILY_RATE) + if not parsed[&"ok"]: + return _failure(&"INVALID_RATES") + var rates: Dictionary = parsed[&"value"] + var touched := _pending_units.duplicate() + for id: String in rates: + if int(rates[id]) > 0: + touched[id] = 0 + if touched.size() > MAX_FACILITIES: + return _failure(&"TOO_MANY_FACILITIES") + var closures := minute / MINUTES_PER_DAY - _current_minute / MINUTES_PER_DAY + if _closed_periods.size() + closures > MAX_PERIODS: + return _failure(&"TOO_MANY_PERIODS") + if minute == _current_minute: + return {&"ok": true, &"advanced_minutes": 0, &"closed_periods": []} + var before := _current_minute + var newly_closed: Array[Dictionary] = [] + while _current_minute < minute: + var midnight := (_current_minute / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY + var until := mini(midnight, minute) + var elapsed := until - _current_minute + for id: String in rates: + var rate := int(rates[id]) + if rate > 0: + _pending_units[id] = int(_pending_units.get(id, 0)) + rate * elapsed + _current_minute = until + if until == midnight: + var closed := { + &"period_start": _period_start, + &"period_end": until, + &"facility_units": _canonical_units(_pending_units), + } + _closed_periods.append(closed) + newly_closed.append(closed.duplicate(true)) + _pending_units = {} + _period_start = until + return {&"ok": true, &"advanced_minutes": minute - before, &"closed_periods": newly_closed} + + +func get_unsettled_units(facility_id: StringName, settled_periods: int) -> int: + var id := String(facility_id) + var units := int(_pending_units.get(id, 0)) + for index in range(clampi(settled_periods, 0, _closed_periods.size()), _closed_periods.size()): + units += int(_closed_periods[index][&"facility_units"].get(id, 0)) + return units + + +func get_closed_periods_from(index: int) -> Array: + return _closed_periods.slice(clampi(index, 0, _closed_periods.size())).duplicate(true) + + +func get_report() -> Dictionary: + return { + &"current_minute": _current_minute, + &"period_start": _period_start, + &"gold_denominator": MINUTES_PER_DAY, + &"pending_facility_units": _canonical_units(_pending_units), + &"closed_periods": _closed_periods.duplicate(true), + } + + +func create_snapshot() -> Dictionary: + return { + &"version": SNAPSHOT_VERSION, + &"started_at": _started_at, + &"current_minute": _current_minute, + &"period_start": _period_start, + &"pending_units": _canonical_units(_pending_units), + &"closed_periods": _closed_periods.duplicate(true), + } + + +func restore_snapshot(source: Dictionary) -> bool: + # Parse and cross-check the complete candidate before mutating live state. + if not _exact_keys(source, ["version", "started_at", "current_minute", "period_start", "pending_units", "closed_periods"]): + return false + for key: String in ["version", "started_at", "current_minute", "period_start"]: + if typeof(source[key]) != TYPE_INT: + return false + if source["version"] != SNAPSHOT_VERSION or not source["closed_periods"] is Array: + return false + var started := int(source["started_at"]) + var current := int(source["current_minute"]) + var period_start := int(source["period_start"]) + var raw_periods: Array = source["closed_periods"] + if raw_periods.size() > MAX_PERIODS: + return false + if started == -1: + if current != -1 or period_start != -1 or not raw_periods.is_empty() \ + or not source["pending_units"] is Dictionary or not source["pending_units"].is_empty(): + return false + elif started < 0 or current < started or current > MAX_MINUTE \ + or period_start < started or period_start > current \ + or period_start / MINUTES_PER_DAY != current / MINUTES_PER_DAY: + return false + var periods: Array[Dictionary] = [] + var expected_start := started + for raw: Variant in raw_periods: + if not raw is Dictionary or not _exact_keys(raw, ["period_start", "period_end", "facility_units"]): + return false + if typeof(raw["period_start"]) != TYPE_INT or typeof(raw["period_end"]) != TYPE_INT: + return false + var end := int(raw["period_end"]) + if raw["period_start"] != expected_start or end > current \ + or end != (expected_start / MINUTES_PER_DAY + 1) * MINUTES_PER_DAY: + return false + var units := _parse_units(raw["facility_units"], MAX_DAILY_RATE * (end - expected_start), false) + if not units[&"ok"]: + return false + periods.append({&"period_start": expected_start, &"period_end": end, &"facility_units": units[&"value"]}) + expected_start = end + if expected_start != period_start: + return false + var pending := _parse_units(source["pending_units"], MAX_DAILY_RATE * maxi(current - period_start, 0), false) + if not pending[&"ok"]: + return false + _started_at = started + _current_minute = current + _period_start = period_start + _pending_units = pending[&"value"] + _closed_periods = periods + return true + + +static func _parse_units(value: Variant, maximum: int, allow_zero := true) -> Dictionary: + if not value is Dictionary or value.size() > MAX_FACILITIES: + return {&"ok": false} + var result: Dictionary = {} + for raw_id: Variant in value: + if typeof(raw_id) not in [TYPE_STRING, TYPE_STRING_NAME]: + return {&"ok": false} + var id := String(raw_id) + if id.is_empty() or id.length() > 128 or result.has(id): + return {&"ok": false} + var amount: Variant = value[raw_id] + if typeof(amount) != TYPE_INT or amount < (0 if allow_zero else 1) or amount > maximum: + return {&"ok": false} + result[id] = amount + return {&"ok": true, &"value": _canonical_units(result)} + + +static func _canonical_units(source: Dictionary) -> Dictionary: + var keys := source.keys() + keys.sort() + var result: Dictionary = {} + for key: Variant in keys: + result[String(key)] = source[key] + return result + + +static func _exact_keys(value: Dictionary, expected: Array) -> bool: + if value.size() != expected.size(): + return false + for key: Variant in value: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: + return false + for key: String in expected: + if not value.has(key): + return false + return true + + +static func _failure(reason: StringName) -> Dictionary: + return {&"ok": false, &"reason": reason} diff --git a/scripts/economy/facility_upkeep_accrual.gd.uid b/scripts/economy/facility_upkeep_accrual.gd.uid new file mode 100644 index 0000000..769e191 --- /dev/null +++ b/scripts/economy/facility_upkeep_accrual.gd.uid @@ -0,0 +1 @@ +uid://df3eypvgf3f2v diff --git a/scripts/economy/facility_upkeep_settlement.gd b/scripts/economy/facility_upkeep_settlement.gd new file mode 100644 index 0000000..cd89ec1 --- /dev/null +++ b/scripts/economy/facility_upkeep_settlement.gd @@ -0,0 +1,252 @@ +class_name FacilityUpkeepSettlement +extends RefCounted + +## Settles exact accrued upkeep against the existing balanced ledger. +## Whole gold is recognized as expense even when cash is insufficient. Sub-gold +## fractions carry forward; arrears survive the removal of the owning facility. +## The caller publishes the returned transaction notification after committing +## its wider Runtime transaction (as with payroll). +const Accrual = preload("res://scripts/economy/facility_upkeep_accrual.gd") +const VERSION := 1 +const CASH: StringName = &"PLAYER_CASH" +const EXPENSE: StringName = &"MAINTENANCE_EXPENSE" +const PAYABLE: StringName = &"MAINTENANCE_PAYABLE" + +var _receipts: Array[Dictionary] = [] +var _arrears: Dictionary = {} +var _fractions: Dictionary = {} + + +static func prepare_accounts(economy: EconomyService) -> bool: + if economy == null or not economy.has_account(CASH): + return false + # Reject incompatible accounts before registering anything. + if economy.has_account(EXPENSE) and economy.account_allows_negative(EXPENSE): + return false + if economy.has_account(PAYABLE) and not economy.account_allows_negative(PAYABLE): + return false + if not economy.has_account(EXPENSE): + economy.register_account(EXPENSE) + if not economy.has_account(PAYABLE): + economy.register_account(PAYABLE, true) + return true + + +func settle(period: Dictionary, economy: EconomyService) -> Dictionary: + var canonical := _parse_period(period) + if canonical.is_empty(): + return _failure(&"INVALID_PERIOD") + for saved: Dictionary in _receipts: + if saved.period.period_end == canonical.period_end: + if saved.period != canonical or not validate_ledger(economy): + return _failure(&"REPLAY_MISMATCH") + return {&"ok": true, &"replayed": true, &"receipt": saved.duplicate(true), &"transaction_id": &""} + if not _receipts.is_empty() and canonical.period_start != _receipts[-1].period.period_end: + return _failure(&"PERIOD_GAP_OR_REWIND") + if _receipts.size() >= Accrual.MAX_PERIODS: + return _failure(&"TOO_MANY_PERIODS") + if not validate_ledger(economy): + return _failure(&"LEDGER_MISMATCH") + var plan := _plan(canonical, economy.get_balance(CASH)) + if not plan[&"ok"]: + return plan + var entries: Array = _entries(canonical, plan.rows) + var transaction_id: StringName = &"" + if not entries.is_empty(): + var result := economy.post(entries, StringName("upkeep.period.%d" % int(canonical.period_end)), &"", false) + if not result.is_success(): + return _failure(&"LEDGER_POST_FAILED") + transaction_id = StringName(result.details[&"transaction_id"]) + # No fallible mutation follows the single, atomic ledger post. + _arrears = plan.arrears + _fractions = plan.fractions + var receipt := {&"period": canonical, &"rows": plan.rows, &"transaction_id": String(transaction_id)} + _receipts.append(receipt) + return {&"ok": true, &"replayed": false, &"receipt": receipt.duplicate(true), &"transaction_id": transaction_id} + + +func get_arrears_through(minute: int) -> int: + var low := 0 + var high := _receipts.size() + while low < high: + var middle := (low + high) / 2 + if int(_receipts[middle][&"period"][&"period_end"]) <= minute: + low = middle + 1 + else: + high = middle + if low == 0: + return 0 + var amount := 0 + for row: Dictionary in _receipts[low - 1][&"rows"]: + amount += int(row[&"arrears"]) + return amount + + +func get_facility_balance(facility_id: StringName) -> Dictionary: + var id := String(facility_id) + return {&"arrears": int(_arrears.get(id, 0)), &"fraction_units": int(_fractions.get(id, 0))} + + +func get_settled_period_count() -> int: + return _receipts.size() + + +func get_arrears_total() -> int: + var debt := 0 + for amount: int in _arrears.values(): + debt += amount + return debt + + +func get_report() -> Dictionary: + return {&"arrears_total": get_arrears_total(), &"arrears_by_facility": _arrears.duplicate(), + &"fraction_units": _fractions.duplicate(), &"gold_denominator": Accrual.MINUTES_PER_DAY, + &"receipts": _receipts.duplicate(true)} + + +func create_snapshot() -> Dictionary: + # Derived balances are reconstructed, not trusted from the save file. + return {&"version": VERSION, &"receipts": _receipts.duplicate(true)} + + +func restore_snapshot(source: Dictionary, economy: EconomyService) -> bool: + if not Accrual._exact_keys(source, ["version", "receipts"]) \ + or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ + or not source["receipts"] is Array or source["receipts"].size() > Accrual.MAX_PERIODS: + return false + var candidate := FacilityUpkeepSettlement.new() + for raw: Variant in source["receipts"]: + if not raw is Dictionary or not Accrual._exact_keys(raw, ["period", "rows", "transaction_id"]) \ + or not raw["period"] is Dictionary or not raw["rows"] is Array \ + or typeof(raw["transaction_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + var period := _parse_period(raw["period"]) + if period.is_empty() or (not candidate._receipts.is_empty() \ + and period.period_start != candidate._receipts[-1].period.period_end): + return false + var budget := 0 + for row: Variant in raw["rows"]: + if not row is Dictionary or not Accrual._exact_keys(row, ["facility_id", "new_expense", "paid", "arrears", "fraction_units"]) \ + or typeof(row["facility_id"]) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + for field: String in ["new_expense", "paid", "arrears", "fraction_units"]: + if typeof(row[field]) != TYPE_INT or row[field] < 0 or row[field] > 9000000000000000: + return false + if budget > 9000000000000000 - int(row["paid"]): + return false + budget += int(row["paid"]) + var plan := candidate._plan(period, budget) + if not plan[&"ok"] or plan.rows != raw["rows"]: + return false + candidate._arrears = plan.arrears + candidate._fractions = plan.fractions + candidate._receipts.append({&"period": period, &"rows": plan.rows, &"transaction_id": String(raw["transaction_id"])}) + if not candidate.validate_ledger(economy): + return false + _receipts = candidate._receipts + _arrears = candidate._arrears + _fractions = candidate._fractions + return true + + +func validate_ledger(economy: EconomyService) -> bool: + if economy == null or not economy.has_account(CASH) or not economy.has_account(EXPENSE) \ + or not economy.has_account(PAYABLE) or economy.account_allows_negative(EXPENSE) \ + or not economy.account_allows_negative(PAYABLE): + return false + var expected_expense := 0 + var expected_debt := 0 + var owned_transactions: Dictionary = {} + for receipt: Dictionary in _receipts: + var expected: Array = _entries(receipt.period, receipt.rows) + var transaction := StringName(receipt.transaction_id) + if economy.get_transaction_id_for_idempotency_key(StringName("upkeep.period.%d" % int(receipt.period.period_end))) != transaction: + return false + if expected.is_empty(): + if not transaction.is_empty(): + return false + continue + if transaction.is_empty() or owned_transactions.has(transaction): + return false + owned_transactions[transaction] = true + var actual := economy.get_transaction_entries(transaction) + if actual.size() != expected.size(): + return false + for i in range(expected.size()): + var a: LedgerEntry = actual[i] + var e: LedgerEntry = expected[i] + if a.amount != e.amount or a.absolute_minute != e.absolute_minute or a.category != e.category \ + or a.debit_account != e.debit_account or a.credit_account != e.credit_account \ + or a.source_entity_id != e.source_entity_id or a.memo_key != e.memo_key \ + or not a.related_transaction_id.is_empty(): + return false + for row: Dictionary in receipt.rows: + expected_expense += int(row.new_expense) + for value: int in _arrears.values(): + expected_debt += value + for entry: LedgerEntry in economy.get_entries(): + if entry.debit_account in [EXPENSE, PAYABLE] or entry.credit_account in [EXPENSE, PAYABLE]: + if not owned_transactions.has(entry.transaction_id): + return false + return economy.get_balance(EXPENSE) == expected_expense and economy.get_balance(PAYABLE) == -expected_debt + + +func _plan(period: Dictionary, cash: int) -> Dictionary: + if cash < 0: + return _failure(&"INVALID_CASH") + var ids: Dictionary = {} + for source: Dictionary in [_arrears, _fractions, period.facility_units]: + for id: String in source: + ids[id] = true + if ids.size() > Accrual.MAX_FACILITIES: + return _failure(&"TOO_MANY_FACILITIES") + var ordered := ids.keys() + ordered.sort() + var rows: Array[Dictionary] = [] + var arrears: Dictionary = {} + var fractions: Dictionary = {} + for id: String in ordered: + var units := int(period.facility_units.get(id, 0)) + int(_fractions.get(id, 0)) + var expense := units / Accrual.MINUTES_PER_DAY + var fraction := units % Accrual.MINUTES_PER_DAY + var debt := int(_arrears.get(id, 0)) + expense + var paid := mini(debt, cash) + cash -= paid + if debt > paid: + arrears[id] = debt - paid + if fraction > 0: + fractions[id] = fraction + rows.append({&"facility_id": id, &"new_expense": expense, &"paid": paid, + &"arrears": debt - paid, &"fraction_units": fraction}) + return {&"ok": true, &"rows": rows, &"arrears": arrears, &"fractions": fractions} + + +static func _parse_period(source: Dictionary) -> Dictionary: + if not Accrual._exact_keys(source, ["period_start", "period_end", "facility_units"]) \ + or typeof(source["period_start"]) != TYPE_INT or typeof(source["period_end"]) != TYPE_INT: + return {} + var start := int(source["period_start"]) + var end := int(source["period_end"]) + if start < 0 or end > Accrual.MAX_MINUTE or end != (start / 1440 + 1) * 1440: + return {} + var units := Accrual._parse_units(source["facility_units"], Accrual.MAX_DAILY_RATE * (end - start), false) + if not units[&"ok"]: + return {} + return {&"period_start": start, &"period_end": end, &"facility_units": units[&"value"]} + + +static func _entries(period: Dictionary, rows: Array) -> Array: + var entries: Array = [] + for row: Dictionary in rows: + var id := StringName(row.facility_id) + if int(row.new_expense) > 0: + entries.append(LedgerEntry.new().configure(&"", int(period.period_end) - 1, + EXPENSE, PAYABLE, int(row.new_expense), &"MAINTENANCE_ACCRUAL", id, &"", &"facility.upkeep.accrued")) + if int(row.paid) > 0: + entries.append(LedgerEntry.new().configure(&"", int(period.period_end), + PAYABLE, CASH, int(row.paid), &"MAINTENANCE_PAYMENT", id, &"", &"facility.upkeep.paid")) + return entries + + +static func _failure(reason: StringName) -> Dictionary: + return {&"ok": false, &"reason": reason} diff --git a/scripts/economy/facility_upkeep_settlement.gd.uid b/scripts/economy/facility_upkeep_settlement.gd.uid new file mode 100644 index 0000000..d4f2768 --- /dev/null +++ b/scripts/economy/facility_upkeep_settlement.gd.uid @@ -0,0 +1 @@ +uid://b3e5slv6c8nb4 diff --git a/scripts/environment/facility_cleaning_bridge.gd b/scripts/environment/facility_cleaning_bridge.gd new file mode 100644 index 0000000..55e390b --- /dev/null +++ b/scripts/environment/facility_cleaning_bridge.gd @@ -0,0 +1,115 @@ +class_name FacilityCleaningBridge +extends RefCounted + +## Jobs live in StaffService and their effects in FacilityCleanlinessService. +## No independent clock, movement, or hidden completion timer. +const TASK_TYPE := &"facility_clean" +var _runtime: Variant + +func configure(runtime: Variant) -> void: + _runtime = runtime + +static func is_deployed(worker: StaffRuntime, room: FacilityInstance, staff: StaffService) -> bool: + if worker == null or room == null or room.definition_id != &"cleaning_room" or worker.role_id != &"cleaner" or worker.assigned_facility_id != room.instance_id or not worker.can_take_shift(): return false + var task := staff.get_task(worker.current_task_id) + return task != null and task.task_type == TASK_TYPE and not task.is_terminal() and task.assigned_staff_id == worker.staff_id and worker.state in [StaffRuntime.State.MOVING_TO_TASK, StaffRuntime.State.WORKING] + +func can_dispatch(worker: StaffRuntime) -> bool: + if worker == null or worker.role_id != &"cleaner": return false + var room: FacilityInstance = _runtime.facility_service.get_instance(worker.assigned_facility_id) + return room != null and room.definition_id == &"cleaning_room" and room.operation_state == FacilityInstance.OperationState.OPERATING and _runtime.staff_commute.is_present(worker, room) + +func task_can_work(task: StaffTask, worker: StaffRuntime) -> bool: + if task == null or worker == null or not is_deployed(worker, _runtime.facility_service.get_instance(worker.assigned_facility_id), _runtime.staff_service): return false + if task.state == StaffTask.State.WORKING and (worker.current_cell != task.target_cell or not _runtime.path_service.has_cell(task.target_cell) or _runtime.is_staff_in_transit(worker.staff_id)): return false + var room: FacilityInstance = _runtime.facility_service.get_instance(worker.assigned_facility_id) + return room.operation_state == FacilityInstance.OperationState.OPERATING and _runtime.facility_service.has_instance(task.target_entity_id) and not _runtime.facility_cleanliness.get_facility(task.target_entity_id).is_empty() + +func tick(minute: int) -> void: + var pending: Dictionary = {} + for task: StaffTask in _runtime.staff_service.get_tasks(): + if task.task_type != TASK_TYPE or task.is_terminal(): continue + var worker: StaffRuntime = _runtime.staff_service.get_staff(task.assigned_staff_id) + if not _runtime.facility_service.has_instance(task.target_entity_id) or (worker != null and not task_can_work(task, worker)): + _runtime.staff_service.cancel_task(task.task_id, &"CLEANING_UNAVAILABLE", minute) + continue + if pending.has(task.target_entity_id): + _runtime.staff_service.cancel_task(task.task_id, &"DUPLICATE_CLEANING", minute) + continue + if task.is_dispatchable(): + for candidate: StaffRuntime in _runtime.staff_service.get_staff_members(): + if can_dispatch(candidate) and prepare_route(task, candidate): break + pending[task.target_entity_id] = true + var rules: Dictionary = _runtime.cleaning_policy.create_snapshot() + for id: StringName in _runtime.facility_cleanliness.get_dirty_facility_ids(rules["clean_at_or_below"]): + if pending.has(id): continue + var facility: FacilityInstance = _runtime.facility_service.get_instance(id) + var cells := access_cells(facility, _runtime.path_service) + if cells.is_empty(): continue + _runtime.staff_service.create_task(TASK_TYPE, 50, minute, { + &"target_entity_id": id, &"target_cell": cells[0], + &"required_role_tags": [&"cleaner"], &"task_tags": [&"cleaning"], + &"estimated_minutes": rules["work_minutes"], &"interrupt_level": 0, + &"preserve_progress_on_interrupt": false, + }) + +static func access_cells(facility: FacilityInstance, path: PathService, walkable_only := true) -> Array[Vector3i]: + var result: Array[Vector3i] = [] + if facility == null: return result + var occupied := facility.occupied_cells.duplicate() + if occupied.is_empty(): occupied.append(facility.origin) + for cell: Vector3i in occupied: + for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT]: + var candidate := cell + offset + if (not walkable_only or path.has_cell(candidate)) and not result.has(candidate): result.append(candidate) + result.sort_custom(func(a: Vector3i,b: Vector3i)->bool: + if a.z != b.z: return a.z < b.z + if a.y != b.y: return a.y < b.y + return a.x < b.x) + return result + +func completed(task: StaffTask, worker: StaffRuntime) -> bool: + if task == null or worker == null or task.state != StaffTask.State.COMPLETED or worker.current_cell != task.target_cell: return false + return _runtime.facility_cleanliness.record_cleaning(task.target_entity_id, task.task_id, _runtime.cleaning_policy.create_snapshot()["recovery_per_completed_task"]) + +func status(facility_id: StringName) -> String: + for task: StaffTask in _runtime.staff_service.get_tasks(): + if task.task_type != TASK_TYPE or task.target_entity_id != facility_id or task.is_terminal(): continue + if task.state == StaffTask.State.WORKING: + var rate := work_units_per_minute(_runtime.staff_service.get_staff(task.assigned_staff_id)) + if rate > 1: + return "清掃中(%d倍速・残り%d分)" % [rate,ceili(float(task.estimated_minutes-task.progress_minutes)/rate)] + return "清掃中 %d/%d分" % [task.progress_minutes,task.estimated_minutes] + if task.state in [StaffTask.State.MOVING,StaffTask.State.RESERVED]: return "清掃員が移動中" + return "清掃待ち(清掃員室・配置・経路を確認)" + var row: Dictionary = _runtime.facility_cleanliness.get_facility(facility_id) + if not row.is_empty() and row["current"] <= _runtime.cleaning_policy.create_snapshot()["clean_at_or_below"]: return "清掃待ち(作業経路を確認)" + return "" + + +## Preserve a reachable reserved destination; reselect only if it is cut off. +## A moving cleaner waits in place if every access is isolated, never teleports. +func prepare_route(task: StaffTask, worker: StaffRuntime) -> bool: + if task == null or worker == null: return false + var facility: FacilityInstance = _runtime.facility_service.get_instance(task.target_entity_id) + var cells := access_cells(facility, _runtime.path_service) + if task.target_cell in cells and not _runtime.path_service.find_path(worker.current_cell, task.target_cell, [0,1], [&"staff"]).is_empty(): return true + var best: Variant = null + var best_cost := 9223372036854775807 + for cell: Vector3i in cells: + var route: Array[Vector3i] = _runtime.path_service.find_path(worker.current_cell, cell, [0,1], [&"staff"]) + if route.is_empty(): continue + var cost: int = _runtime.path_service.calculate_path_cost(route, [&"staff"]) + if cost >= 0 and cost < best_cost: + best = cell + best_cost = cost + if best == null: return false + task.target_cell = best + return true + +## Original 6.2: Fine cleans three times as fast. Task progress remains work +## units, so an eight-unit task completes on the third one-minute tick. +static func work_units_per_minute(worker: StaffRuntime) -> int: + if worker != null and worker.staff_id == &"heroine_fine" and worker.role_id == &"cleaner" and worker.employment_kind == StaffRuntime.EmploymentKind.UNIQUE: + return 3 + return 1 diff --git a/scripts/environment/facility_cleaning_bridge.gd.uid b/scripts/environment/facility_cleaning_bridge.gd.uid new file mode 100644 index 0000000..3066b86 --- /dev/null +++ b/scripts/environment/facility_cleaning_bridge.gd.uid @@ -0,0 +1 @@ +uid://cuqm1vvtndcsg diff --git a/scripts/environment/facility_cleaning_policy.gd b/scripts/environment/facility_cleaning_policy.gd new file mode 100644 index 0000000..10735e3 --- /dev/null +++ b/scripts/environment/facility_cleaning_policy.gd @@ -0,0 +1,36 @@ +class_name FacilityCleaningPolicy +extends RefCounted + +## Approved trial values live in data, never in scheduler defaults. +var last_error := "" +var _values: Dictionary = {} + +func load_file(path: String = "res://data/environment/cleaning_policy.json") -> bool: + var parsed: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) + if not parsed is Dictionary: + last_error = "Cleaning policy must be a JSON object" + return false + return configure(parsed) + +func configure(values: Dictionary) -> bool: + var keys := ["schema_version", "initial_cleanliness", "maximum_cleanliness", "clean_at_or_below", "work_minutes", "recovery_per_completed_task"] + if values.size() != keys.size(): + last_error = "Unexpected cleaning policy fields" + return false + var candidate: Dictionary = {} + for key: String in keys: + var value: Variant = values.get(key) + # JSON numbers arrive as floats; accept only finite exact small integers. + if typeof(value) not in [TYPE_INT, TYPE_FLOAT] or not is_finite(float(value)) or float(value) != floor(float(value)) or float(value) < 0 or float(value) > 2147483647: + last_error = "Invalid cleaning policy integer: " + key + return false + candidate[key] = int(value) + if candidate["schema_version"] != 1 or candidate["maximum_cleanliness"] <= 0 or candidate["initial_cleanliness"] > candidate["maximum_cleanliness"] or candidate["clean_at_or_below"] >= candidate["maximum_cleanliness"] or candidate["work_minutes"] <= 0 or candidate["recovery_per_completed_task"] <= 0: + last_error = "Invalid cleaning policy bounds" + return false + _values = candidate + last_error = "" + return true + +func create_snapshot() -> Dictionary: + return _values.duplicate(true) diff --git a/scripts/environment/facility_cleaning_policy.gd.uid b/scripts/environment/facility_cleaning_policy.gd.uid new file mode 100644 index 0000000..3f60e3e --- /dev/null +++ b/scripts/environment/facility_cleaning_policy.gd.uid @@ -0,0 +1 @@ +uid://cxaxvqpfyjfkc diff --git a/scripts/environment/facility_cleanliness_service.gd b/scripts/environment/facility_cleanliness_service.gd new file mode 100644 index 0000000..27ca3f0 --- /dev/null +++ b/scripts/environment/facility_cleanliness_service.gd @@ -0,0 +1,123 @@ +class_name FacilityCleanlinessService +extends RefCounted + +## Parameterized cleanliness state. Runtime supplies approved initial/max values +## and completed-use/task IDs; this model invents no operating balance values. +var last_error := "" +var _facilities: Dictionary = {} +var _receipts: Array[Dictionary] = [] +var _receipt_index: Dictionary = {} + +func register_facility(id: StringName, initial: int, maximum: int) -> bool: + if id.is_empty() or _facilities.has(id) or maximum <= 0 or initial < 0 or initial > maximum: + return _fail("Invalid or duplicate cleanliness facility") + _facilities[id] = {"id": String(id), "initial": initial, "maximum": maximum, "current": initial} + last_error = "" + return true + +func unregister_facility(id: StringName) -> bool: + if not _facilities.has(id): return _fail("Unknown cleanliness facility") + _facilities.erase(id) + var retained: Array[Dictionary] = [] + _receipt_index.clear() + for receipt: Dictionary in _receipts: + if StringName(receipt["facility_id"]) != id: + retained.append(receipt) + _receipt_index[_receipt_key(receipt)] = receipt + _receipts = retained + last_error = "" + return true + +func record_use(id: StringName, service_id: StringName, soil_amount: int) -> bool: + return _apply(id, service_id, soil_amount, "use") + +func record_cleaning(id: StringName, task_id: StringName, recovery_amount: int) -> bool: + return _apply(id, task_id, recovery_amount, "clean") + +func get_facility(id: StringName) -> Dictionary: + return (_facilities.get(id, {}) as Dictionary).duplicate(true) + +func get_dirty_facility_ids(threshold: int) -> Array[StringName]: + var result: Array[StringName] = [] + for id: StringName in _facilities: + if int(_facilities[id]["current"]) <= threshold: + result.append(id) + result.sort_custom(_id_before) + return result + +func create_snapshot() -> Dictionary: + var rows: Array[Dictionary] = [] + var ids: Array = _facilities.keys() + ids.sort_custom(_id_before) + for id: StringName in ids: rows.append(_facilities[id].duplicate(true)) + return {"schema_version": 1, "facilities": rows, "receipts": _receipts.duplicate(true)} + +func restore_snapshot(snapshot: Dictionary) -> bool: + if not _keys(snapshot, ["schema_version", "facilities", "receipts"]) or typeof(snapshot.get("schema_version")) != TYPE_INT or snapshot["schema_version"] != 1: + return _fail("Unsupported cleanliness snapshot") + if not snapshot["facilities"] is Array or not snapshot["receipts"] is Array: + return _fail("Invalid cleanliness collections") + var candidate := FacilityCleanlinessService.new() + var expected: Dictionary = {} + for value: Variant in snapshot["facilities"]: + if not value is Dictionary or not _keys(value, ["id", "initial", "maximum", "current"]): + return _fail("Invalid cleanliness facility row") + if typeof(value["id"]) != TYPE_STRING or typeof(value["initial"]) != TYPE_INT or typeof(value["maximum"]) != TYPE_INT or typeof(value["current"]) != TYPE_INT: + return _fail("Invalid cleanliness facility value types") + var id := StringName(value["id"]) + if not candidate.register_facility(id, value["initial"], value["maximum"]): + return _fail(candidate.last_error) + expected[id] = value["current"] + for value: Variant in snapshot["receipts"]: + if not value is Dictionary or not _keys(value, ["facility_id", "source_id", "kind", "amount"]): + return _fail("Invalid cleanliness receipt") + if typeof(value["facility_id"]) != TYPE_STRING or typeof(value["source_id"]) != TYPE_STRING or typeof(value["kind"]) != TYPE_STRING or typeof(value["amount"]) != TYPE_INT: + return _fail("Invalid cleanliness receipt value types") + if candidate._receipt_index.has(_receipt_key(value)) or not candidate._apply(StringName(value["facility_id"]), StringName(value["source_id"]), value["amount"], value["kind"]): + return _fail("Invalid, duplicated or dangling cleanliness receipt") + for id: StringName in expected: + if candidate._facilities[id]["current"] != expected[id]: + return _fail("Cleanliness value does not match its completed work") + _facilities = candidate._facilities + _receipts = candidate._receipts + _receipt_index = candidate._receipt_index + last_error = "" + return true + +func _apply(id: StringName, source_id: StringName, amount: int, kind: String) -> bool: + if not _facilities.has(id) or source_id.is_empty() or amount < 0 or kind not in ["use", "clean"]: + return _fail("Invalid cleanliness effect") + var receipt := {"facility_id": String(id), "source_id": String(source_id), "kind": kind, "amount": amount} + var key := _receipt_key(receipt) + if _receipt_index.has(key): + if _receipt_index[key] != receipt: return _fail("Conflicting cleanliness completion replay") + last_error = "" + return true + var row: Dictionary = _facilities[id] + # Clamp the operand before addition/subtraction to avoid int overflow. + if kind == "use": row["current"] -= mini(amount, int(row["current"])) + else: row["current"] += mini(amount, int(row["maximum"]) - int(row["current"])) + _receipts.append(receipt) + _receipt_index[key] = receipt + last_error = "" + return true + +static func _receipt_key(receipt: Dictionary) -> String: + return String(receipt["kind"]) + ":" + String(receipt["source_id"]) + +static func _keys(value: Dictionary, expected: Array) -> bool: + if value.size() != expected.size(): return false + for key: Variant in value: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: return false + for key: String in expected: + if not value.has(key): return false + return true + +func _fail(message: String) -> bool: + last_error = message + return false + +# StringName's built-in ordering is process-local. Persisted row ordering and +# dispatch priority must instead use the stable textual facility identifier. +static func _id_before(a: StringName, b: StringName) -> bool: + return String(a) < String(b) diff --git a/scripts/environment/facility_cleanliness_service.gd.uid b/scripts/environment/facility_cleanliness_service.gd.uid new file mode 100644 index 0000000..07c2460 --- /dev/null +++ b/scripts/environment/facility_cleanliness_service.gd.uid @@ -0,0 +1 @@ +uid://dt4s42nme54s1 diff --git a/scripts/environment/weather_timeline.gd b/scripts/environment/weather_timeline.gd new file mode 100644 index 0000000..ea4c0a0 --- /dev/null +++ b/scripts/environment/weather_timeline.gd @@ -0,0 +1,94 @@ +extends RefCounted + +## Weather state and scheduled transitions. The caller owns generation policy; +## this model invents neither probabilities nor rain/fog balance multipliers. +const VERSION := 1 +const KINDS := [&"clear", &"rain", &"storm", &"mana_fog"] +const MAX_MINUTE := 2147483647 +const MAX_PENDING := 4096 +var _minute := -1 +var _kind: StringName = &"" +var _since := -1 +var _pending: Array[Dictionary] = [] + +func initialize(kind: Variant, minute: Variant) -> bool: + if _minute >= 0 or not _valid_kind(kind) or not _valid_minute(minute): + return false + _minute = minute + _since = minute + _kind = StringName(kind) + return true + +func get_kind() -> StringName: + return _kind + +func get_minute() -> int: + return _minute + +func permits_new_arrivals() -> bool: + return _minute >= 0 and _kind != &"storm" + +func schedule(kind: Variant, minute: Variant) -> Dictionary: + if _minute < 0 or not _valid_kind(kind) or not _valid_minute(minute) or minute <= _minute: + return {&"ok": false, &"code": &"INVALID_TRANSITION"} + for row: Dictionary in _pending: + if row[&"minute"] == minute: + return {&"ok": row[&"kind"] == StringName(kind), &"replayed": row[&"kind"] == StringName(kind)} + if _pending.size() >= MAX_PENDING: + return {&"ok": false, &"code": &"SCHEDULE_FULL"} + _pending.append({&"kind": StringName(kind), &"minute": minute}) + _pending.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a[&"minute"] < b[&"minute"]) + return {&"ok": true, &"replayed": false} + +func advance_to(minute: Variant) -> Dictionary: + if _minute < 0 or not _valid_minute(minute) or minute < _minute: + return {&"ok": false, &"code": &"INVALID_TIME"} + var changes: Array[Dictionary] = [] + var replayed: bool = minute == _minute + while not _pending.is_empty() and int(_pending[0][&"minute"]) <= minute: + var transition: Dictionary = _pending.pop_front() + if transition[&"kind"] == _kind: + continue + changes.append({&"from": _kind, &"to": transition[&"kind"], &"minute": transition[&"minute"]}) + _kind = transition[&"kind"] + _since = transition[&"minute"] + _minute = minute + return {&"ok": true, &"replayed": replayed, &"changes": changes} + +func create_snapshot() -> Dictionary: + return {&"version": VERSION, &"minute": _minute, &"kind": _kind, + &"since": _since, &"pending": _pending.duplicate(true)} + +func restore_snapshot(source: Dictionary) -> bool: + if not _keys(source, ["version", "minute", "kind", "since", "pending"]) \ + or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ + or not _valid_minute(source["minute"]) or not _valid_minute(source["since"]) \ + or source["since"] > source["minute"] or not _valid_kind(source["kind"]) \ + or not source["pending"] is Array or source["pending"].size() > MAX_PENDING: + return false + var previous: int = source["minute"] + var pending: Array[Dictionary] = [] + for value: Variant in source["pending"]: + if not value is Dictionary or not _keys(value, ["kind", "minute"]) \ + or not _valid_kind(value["kind"]) or not _valid_minute(value["minute"]) \ + or value["minute"] <= previous: + return false + previous = value["minute"] + pending.append({&"kind": StringName(value["kind"]), &"minute": value["minute"]}) + _minute = source["minute"] + _kind = StringName(source["kind"]) + _since = source["since"] + _pending = pending + return true + +static func _valid_kind(value: Variant) -> bool: + return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and StringName(value) in KINDS + +static func _valid_minute(value: Variant) -> bool: + return typeof(value) == TYPE_INT and value >= 0 and value <= MAX_MINUTE + +static func _keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): return false + for key: Variant in source: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: return false + return true diff --git a/scripts/environment/weather_timeline.gd.uid b/scripts/environment/weather_timeline.gd.uid new file mode 100644 index 0000000..e35b32c --- /dev/null +++ b/scripts/environment/weather_timeline.gd.uid @@ -0,0 +1 @@ +uid://bihm0hldfc1im diff --git a/scripts/events/event_action.gd b/scripts/events/event_action.gd index 5211606..573f632 100644 --- a/scripts/events/event_action.gd +++ b/scripts/events/event_action.gd @@ -13,6 +13,7 @@ enum Type { TELEPORT_ENTITY_FOR_RECOVERY, SCHEDULE_EVENT, SET_ROUTE_LOCK, + CREATE_DEFENSE_RAID, } var action_id: StringName = &"" @@ -32,7 +33,7 @@ func configure( func is_valid() -> bool: - return not action_id.is_empty() and type >= Type.SET_FLAG and type <= Type.SET_ROUTE_LOCK + return not action_id.is_empty() and type >= Type.SET_FLAG and type <= Type.CREATE_DEFENSE_RAID func create_snapshot() -> Dictionary: @@ -48,6 +49,18 @@ static func from_variant(raw_value: Variant) -> EventAction: return raw_value if not raw_value is Dictionary: return null + # Serialized action enums are authoritative. Keep an invalid object in the + # action list so EventDefinition/EventChoice reject it instead of silently + # dropping it, clamping it to a destructive action, or choosing SET_FLAG. + var raw_type: Variant = _value(raw_value, &"type", "SET_FLAG") + if raw_type is int: + if int(raw_type) < Type.SET_FLAG or int(raw_type) > Type.CREATE_DEFENSE_RAID: + return EventAction.new() + elif typeof(raw_type) in [TYPE_STRING, TYPE_STRING_NAME]: + if Type.keys().find(String(raw_type).to_upper()) < 0: + return EventAction.new() + else: + return EventAction.new() return EventAction.new().configure( StringName(_value(raw_value, &"action_id", &"")), _value(raw_value, &"type", "SET_FLAG"), @@ -57,7 +70,7 @@ static func from_variant(raw_value: Variant) -> EventAction: static func _type_from_variant(raw_value: Variant) -> Type: if raw_value is int: - return clampi(int(raw_value), Type.SET_FLAG, Type.SET_ROUTE_LOCK) as Type + return clampi(int(raw_value), Type.SET_FLAG, Type.CREATE_DEFENSE_RAID) as Type var index := Type.keys().find(String(raw_value).to_upper()) return (index if index >= 0 else Type.SET_FLAG) as Type diff --git a/scripts/facility/facility_instance.gd b/scripts/facility/facility_instance.gd index c1073f6..6cef9d2 100644 --- a/scripts/facility/facility_instance.gd +++ b/scripts/facility/facility_instance.gd @@ -5,6 +5,8 @@ const ServiceInstanceType = preload("res://scripts/facility/service_instance.gd" const FacilityPricingType = preload("res://scripts/facility/facility_pricing.gd") ## Values are deliberately ordered by operational priority. +enum OperationState { INACTIVE, OPERATING, SUSPENDED } + enum State { BROKEN, NO_ACCESS, @@ -42,6 +44,13 @@ var statistics_today: Dictionary = {} var lifetime_statistics: Dictionary = {} var created_at := 0 +var customer_capacity := -1 # Unspecified; never inferred from storage/production capacity. +var service_attribute: StringName = &"" +var related_facility_id: StringName = &"" +var related_facility_operating := true +var suspension_reason: StringName = &"" +var operation_state: OperationState = OperationState.INACTIVE + var capacity := 1 var queue_capacity := 0 var base_service_minutes := 1 @@ -70,6 +79,11 @@ func configure( durability = clampi(int(_value(options, &"durability", DEFAULT_DURABILITY)), 0, DEFAULT_DURABILITY) local_inventory_id = StringName(_value(options, &"local_inventory_id", &"")) capacity = maxi(int(_value(options, &"capacity", 1)), 1) + customer_capacity = int(_value(options, &"customer_capacity", -1)) + service_attribute = StringName(_value(options, &"service_attribute", &"")) + related_facility_id = StringName(_value(options, &"related_facility_id", &"")) + related_facility_operating = related_facility_id.is_empty() + suspension_reason = StringName(_value(options, &"suspension_reason", &"")) queue_capacity = maxi(int(_value(options, &"queue_capacity", 0)), 0) base_service_minutes = maxi(int(_value(options, &"base_service_minutes", 1)), 1) base_quality = int(_value(options, &"base_quality", 0)) @@ -97,6 +111,9 @@ func is_valid() -> bool: return not instance_id.is_empty() \ and not definition_id.is_empty() \ and capacity > 0 \ + and customer_capacity >= -1 \ + and service_attribute in [&"", &"appetite", &"relaxation", &"sexual", &"gambling"] \ + and suspension_reason in [&"", &"DESTROYED", &"RAIDED"] \ and queue_capacity >= 0 \ and base_service_minutes > 0 \ and staff_min >= 0 \ @@ -140,6 +157,11 @@ func is_within_opening_hours(absolute_minute: int) -> bool: func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> bool: + if _has_value(conditions, &"related_facility_id"): + related_facility_id = StringName(_value(conditions, &"related_facility_id", &"")) + related_facility_operating = related_facility_id.is_empty() + if StringName(_value(conditions, &"suspension_reason", &"")) in [&"DESTROYED", &"RAIDED"]: + suspension_reason = StringName(_value(conditions, &"suspension_reason", &"")) if _has_value(conditions, &"durability"): durability = clampi(int(_value(conditions, &"durability", durability)), 0, DEFAULT_DURABILITY) if _has_value(conditions, &"access_available"): @@ -158,6 +180,8 @@ func set_operational_conditions(conditions: Dictionary, absolute_minute: int) -> func evaluate_state(absolute_minute: int) -> bool: + if durability <= 0: + suspension_reason = &"DESTROYED" var next_reasons: Array[StringName] = [] var next_state := State.OPEN if durability <= 0: @@ -192,7 +216,19 @@ func evaluate_state(absolute_minute: int) -> bool: next_reasons.append(reason) if next_state > State.DEGRADED: next_state = State.DEGRADED - var changed := state != next_state + if not related_facility_operating: + next_reasons.append(&"RELATED_FACILITY_INACTIVE") + next_state = mini(next_state, State.CLOSED) as State + if not suspension_reason.is_empty(): + next_reasons.append(suspension_reason) + next_state = State.BROKEN + var next_operation := OperationState.INACTIVE + if not suspension_reason.is_empty(): + next_operation = OperationState.SUSPENDED + elif next_state in [State.OPEN, State.DEGRADED]: + next_operation = OperationState.OPERATING + var changed := state != next_state or operation_state != next_operation + operation_state = next_operation state = next_state state_reasons = next_reasons return changed @@ -203,8 +239,35 @@ func can_start_service(absolute_minute: int) -> bool: return state in [State.OPEN, State.DEGRADED] and available_slots() > 0 +func allows_logistics() -> bool: + return operator_open and power_available and durability > 0 and suspension_reason.is_empty() and related_facility_operating + + func available_slots() -> int: - return maxi(capacity - active_services.size(), 0) + var slots := maxi(capacity - active_services.size(), 0) + if customer_capacity >= 0: + slots = mini(slots, maxi(customer_capacity - customer_count(), 0)) + return slots + + +func customer_count() -> int: + var customers: Dictionary = {} + for service: ServiceInstance in active_services.values(): + for guest_id: StringName in service.guest_ids: + customers[guest_id] = true + return customers.size() + + +func can_accept_customers(guest_ids: Array[StringName]) -> bool: + if customer_capacity < 0: + return true + var customers: Dictionary = {} + for service: ServiceInstance in active_services.values(): + for guest_id: StringName in service.guest_ids: + customers[guest_id] = true + for guest_id: StringName in guest_ids: + customers[guest_id] = true + return customers.size() <= customer_capacity func has_queue_space() -> bool: @@ -281,6 +344,10 @@ func create_snapshot() -> Dictionary: &"statistics_today": statistics_today.duplicate(true), &"lifetime_statistics": lifetime_statistics.duplicate(true), &"created_at": created_at, + &"customer_capacity": customer_capacity, + &"service_attribute": service_attribute, + &"related_facility_id": related_facility_id, + &"suspension_reason": suspension_reason, &"capacity": capacity, &"queue_capacity": queue_capacity, &"base_service_minutes": base_service_minutes, @@ -309,6 +376,10 @@ static func from_snapshot(snapshot: Dictionary) -> FacilityInstance: &"level": snapshot.get(&"level", 1), &"durability": snapshot.get(&"durability", DEFAULT_DURABILITY), &"local_inventory_id": snapshot.get(&"local_inventory_id", ""), + &"customer_capacity": snapshot.get(&"customer_capacity", -1), + &"service_attribute": snapshot.get(&"service_attribute", ""), + &"related_facility_id": snapshot.get(&"related_facility_id", ""), + &"suspension_reason": snapshot.get(&"suspension_reason", ""), &"capacity": snapshot.get(&"capacity", 1), &"queue_capacity": snapshot.get(&"queue_capacity", 0), &"base_service_minutes": snapshot.get(&"base_service_minutes", 1), @@ -350,10 +421,17 @@ static func from_snapshot(snapshot: Dictionary) -> FacilityInstance: instance.active_services[service.service_id] = service if not instance.is_valid() or instance.active_services.size() > instance.capacity: return null + if instance.customer_capacity >= 0 and instance.customer_count() > instance.customer_capacity: + return null return instance static func _snapshot_shapes_are_valid(snapshot: Dictionary) -> bool: + if snapshot.has(&"customer_capacity") and (typeof(snapshot[&"customer_capacity"]) != TYPE_INT or int(snapshot[&"customer_capacity"]) < -1): + return false + for field: StringName in [&"service_attribute", &"related_facility_id", &"suspension_reason"]: + if snapshot.has(field) and typeof(snapshot[field]) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false var array_fields := [ &"origin", &"occupied_cells", &"state_reasons", &"active_staff_ids", &"queue_entries", &"active_services", &"opening_intervals", &"degraded_reasons", diff --git a/scripts/facility/facility_pricing.gd b/scripts/facility/facility_pricing.gd index 8d99bda..50d9855 100644 --- a/scripts/facility/facility_pricing.gd +++ b/scripts/facility/facility_pricing.gd @@ -27,6 +27,23 @@ static func quote(base_price: int, policy: Dictionary) -> int: return result +static func apply_multiplier(price: int, multiplier_bp: int) -> int: + # Same nearest-G, half-up convention as policy pricing. + @warning_ignore("integer_division") + return (maxi(price, 0) * maxi(multiplier_bp, 0) + 5000) / 10000 + + +## Caller supplies already-determined deficient attributes. Threshold and surcharge +## are deliberately not invented. An unresolved quote must not be charged. +static func quote_service(base_price: int, policy: Dictionary, service_attribute: StringName, deficient_attributes: Array[StringName], surcharge_amount: int = -1) -> Dictionary: + var base := quote(base_price, policy) + var matches := not service_attribute.is_empty() and deficient_attributes.has(service_attribute) + var resolved := not matches or surcharge_amount > 0 + var surcharge := surcharge_amount if matches and resolved else 0 + return {&"base_price": base, &"matches": matches, &"resolved": resolved, + &"surcharge": surcharge, &"total": base + surcharge, &"eligible": true} + + static func satisfaction_modifier( policy: Dictionary, quality: int, diff --git a/scripts/facility/facility_service.gd b/scripts/facility/facility_service.gd index 4a94827..18ec310 100644 --- a/scripts/facility/facility_service.gd +++ b/scripts/facility/facility_service.gd @@ -123,6 +123,7 @@ func unregister_instance(instance_id: StringName) -> CommandResult: {&"instance_id": instance_id} ) _instances.erase(instance_id) + refresh_operating_dependencies(current_minute) instance_unregistered.emit(instance_id) last_error = "" return _success({&"instance_id": instance_id}) @@ -136,9 +137,29 @@ func set_operational_status( var instance := get_instance(instance_id) if instance == null: return _not_found("Facility instance", instance_id) - var minute := _resolve_minute(absolute_minute) var old_state := instance.state + if _has_value(conditions, &"related_facility_id"): + var raw_related: Variant = _value(conditions, &"related_facility_id", null) + if typeof(raw_related) not in [TYPE_STRING, TYPE_STRING_NAME]: + return _invalid("Related facility must be one facility ID.") + var related := StringName(raw_related) + if related == instance_id or (not related.is_empty() and not has_instance(related)): + return _invalid("Related facility is missing or refers to itself.") + var seen := {instance_id: true} + var cursor := related + while not cursor.is_empty() and has_instance(cursor): + if seen.has(cursor): + return _invalid("Related facilities cannot form a cycle.") + seen[cursor] = true + cursor = get_instance(cursor).related_facility_id + if _has_value(conditions, &"related_facility_operating") or _has_value(conditions, &"operation_state"): + return _invalid("Operating flags are computed from actual facility conditions.") + if _has_value(conditions, &"suspension_reason") and (typeof(_value(conditions, &"suspension_reason", null)) not in [TYPE_STRING, TYPE_STRING_NAME] or StringName(_value(conditions, &"suspension_reason", &"")) not in [&"DESTROYED", &"RAIDED"]): + return _invalid("Suspension recovery is not specified.") + var minute := _resolve_minute(absolute_minute) instance.set_operational_conditions(conditions, minute) + if old_state != instance.state or _has_value(conditions, &"related_facility_id"): + refresh_operating_dependencies(minute) _emit_state_change(instance, old_state) last_error = "" return _success({ @@ -161,6 +182,7 @@ func set_opening_hours( return _invalid("Opening hours must be valid minute-of-day values.") var old_state := instance.state instance.evaluate_state(_resolve_minute(absolute_minute)) + refresh_operating_dependencies(current_minute) _emit_state_change(instance, old_state) last_error = "" return _success({&"instance_id": instance_id}) @@ -191,6 +213,7 @@ func set_policy( var changed := instance.apply_policy_patch(normalized_patch) var old_state := instance.state instance.evaluate_state(current_minute) + refresh_operating_dependencies(current_minute) _emit_state_change(instance, old_state) last_error = "" return _success({ @@ -319,6 +342,30 @@ func cancel_request(request_id: StringName, reason: StringName = &"GUEST_LEFT") return _not_found("Queued service request", request_id) +func refresh_operating_dependencies(minute: int) -> void: + var resolved: Dictionary = {} + for instance_id: StringName in _sorted_keys(_instances): + _resolve_operating_dependency(instance_id, minute, resolved, {}) + + +func _resolve_operating_dependency(id: StringName, minute: int, resolved: Dictionary, visiting: Dictionary) -> bool: + if resolved.has(id): + return bool(resolved[id]) + if visiting.has(id) or not has_instance(id): + return false + visiting[id] = true + var instance := get_instance(id) + instance.apply_pending_policy(minute) + var related := instance.related_facility_id + instance.related_facility_operating = related.is_empty() or _resolve_operating_dependency(related, minute, resolved, visiting) + var old_state := instance.state + instance.evaluate_state(minute) + _emit_state_change(instance, old_state) + visiting.erase(id) + resolved[id] = instance.operation_state == FacilityInstanceType.OperationState.OPERATING + return bool(resolved[id]) + + func start_next_service( instance_id: StringName, absolute_minute: int = -1, @@ -328,6 +375,7 @@ func start_next_service( if instance == null: return _not_found("Facility instance", instance_id) var minute := _resolve_minute(absolute_minute) + _resolve_operating_dependency(instance_id, minute, {}, {}) instance.apply_pending_policy(minute) var old_state := instance.state instance.evaluate_state(minute) @@ -347,6 +395,8 @@ func start_next_service( var request := get_request(instance.queue_entries[0]) if request == null: return _invalid("The next queue entry is corrupt.") + if not instance.can_accept_customers(request.guest_ids): + return _invalid("Simultaneous customer limit reached.", {&"reason": &"CUSTOMER_CAPACITY"}) var effective_validator := validator if validator.is_valid() else _start_validator var callback_failure := _run_boundary_callback( effective_validator, @@ -376,6 +426,8 @@ func start_next_service( } ) instance.queue_entries.remove_at(0) + # Report this start's service index, independent of customer headcount and callbacks. + var slot := instance.active_services.size() instance.active_services[service_id] = service _known_service_ids[service_id] = true _increment_stat(instance.statistics_today, &"services_started") @@ -386,7 +438,7 @@ func start_next_service( &"instance_id": instance_id, &"request_id": request.request_id, &"service_id": service_id, - &"slot": instance.capacity - instance.available_slots() - 1, + &"slot": slot, }) @@ -416,6 +468,7 @@ func advance_one_minute(absolute_minute: int = -1) -> Array[CommandResult]: return [_invalid("FacilityService cannot advance backwards in time.")] else: current_minute = absolute_minute + refresh_operating_dependencies(current_minute) var results: Array[CommandResult] = [] for instance_id: StringName in _sorted_keys(_instances): var instance: FacilityInstance = _instances[instance_id] @@ -632,10 +685,23 @@ func restore_snapshot(snapshot: Dictionary) -> bool: _known_service_ids = restored_known_services _next_service_sequence = restored_next_sequence current_minute = restored_minute + for instance: FacilityInstance in _instances.values(): + instance.related_facility_operating = _restored_dependency_operates(instance.related_facility_id, {}) + instance.operation_state = FacilityInstanceType.OperationState.SUSPENDED if not instance.suspension_reason.is_empty() else (FacilityInstanceType.OperationState.OPERATING if instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and instance.related_facility_operating else FacilityInstanceType.OperationState.INACTIVE) last_error = "" return true +func _restored_dependency_operates(id: StringName, seen: Dictionary) -> bool: + if id.is_empty(): + return true + if seen.has(id) or not has_instance(id): + return false + seen[id] = true + var instance := get_instance(id) + return instance.suspension_reason.is_empty() and instance.state in [FacilityInstanceType.State.OPEN, FacilityInstanceType.State.DEGRADED] and _restored_dependency_operates(instance.related_facility_id, seen) + + func _saved_policy_is_valid(policy: Dictionary) -> bool: var validation := FacilityPricingType.validate_patch(policy) if not bool(validation[&"valid"]): @@ -656,6 +722,14 @@ func _validate_restored_graph( var active_service_ids: Dictionary = {} for instance_id: StringName in _sorted_keys(instances): var instance: FacilityInstance = instances[instance_id] + var dependency_seen: Dictionary = {instance_id: true} + var dependency := instance.related_facility_id + while not dependency.is_empty() and instances.has(dependency): + if dependency_seen.has(dependency): + last_error = "Related facility cycle in snapshot." + return false + dependency_seen[dependency] = true + dependency = (instances[dependency] as FacilityInstance).related_facility_id if not known_instances.has(instance_id): last_error = "Active facility ID is absent from known IDs." return false @@ -703,6 +777,13 @@ func _prepare_service_for_tick( if instance.state == FacilityInstanceType.State.BROKEN: _fail_service(instance, service, INTERRUPTION_BROKEN, current_minute) return _success({&"service_id": service.service_id, &"failed": true}) + if not instance.related_facility_operating: + if service.state == ServiceInstanceType.State.ACTIVE: + service.pause(&"RELATED_FACILITY_INACTIVE", current_minute) + service_interrupted.emit(service, &"RELATED_FACILITY_INACTIVE") + return null + if service.state == ServiceInstanceType.State.PAUSED and service.interruption_reason == &"RELATED_FACILITY_INACTIVE": + service.resume() if not instance.power_available: if service.state == ServiceInstanceType.State.ACTIVE: service.pause(INTERRUPTION_NO_POWER, current_minute) diff --git a/scripts/guest/guest_service.gd b/scripts/guest/guest_service.gd index 3b957ac..61a63ff 100644 --- a/scripts/guest/guest_service.gd +++ b/scripts/guest/guest_service.gd @@ -22,6 +22,7 @@ const PATH_FAILURE_LIMIT := 3 const STUCK_REPLAN_MINUTES := 60 const DEFAULT_WAIT_HARD_LIMIT := 120 const DEFAULT_EXIT_THRESHOLD := 15 +const PRIMARY_NEEDS: Array[StringName] = [&"hunger", &"sleep", &"leisure", &"intimacy"] const NEED_IDS: Array[StringName] = [ &"hunger", @@ -518,6 +519,7 @@ func evaluate_candidate(guest_id: StringName, candidate: Dictionary) -> Dictiona ) var result := candidate.duplicate(true) result[&"facility_id"] = _facility_id(candidate) + result[&"need_priority"] = _primary_need_priority(guest, candidate) result[&"utility"] = utility result[&"urgency"] = urgency result[&"travel_minutes"] = travel_minutes @@ -554,9 +556,10 @@ func select_best_candidate( exit_threshold: int = DEFAULT_EXIT_THRESHOLD ) -> Dictionary: var ranked := rank_candidates(guest_id, candidates) - if ranked.is_empty() or int(ranked[0][&"utility"]) < exit_threshold: - return {} - return ranked[0].duplicate(true) + for candidate: Dictionary in ranked: + if int(candidate[&"utility"]) >= exit_threshold: + return candidate.duplicate(true) + return {} func decide( @@ -574,7 +577,7 @@ func decide( var reserve_callback: Callable = _value(context, &"reserve_callback", Callable()) for candidate: Dictionary in ranked: if int(candidate[&"utility"]) < threshold: - break + continue if reserve_callback.is_valid(): var reservation_result: Variant = reserve_callback.call( guest_id, @@ -1203,6 +1206,8 @@ func _candidate_rejection_reason( ) -> StringName: if not _candidate_need_maps_are_valid(candidate): return &"INVALID_NEED_MAP" + if not bool(_value(candidate, &"inputs_available", true)): + return &"MISSING_INPUTS" var adult_only := bool(_value( candidate, &"adult_only", @@ -1361,7 +1366,44 @@ func _candidate_urgency(guest: GuestRuntime, candidate: Dictionary) -> int: return result +func _primary_need_priority(guest: GuestRuntime, candidate: Dictionary) -> int: + var relief: Variant = _value(candidate, &"need_relief", {}) + if not relief is Dictionary: + return 0 + var normalized: Dictionary = _normalized_partial_need_map(relief) + var priority := 0 + for need: StringName in PRIMARY_NEEDS: + if int(normalized.get(need, 0)) > 0: + priority = maxi(priority, int(guest.needs[need])) + return priority + + +func get_observation(guest_id: StringName) -> Dictionary: + var guest := get_guest(guest_id) + if guest == null: + return {} + var highest := 0 + var primary: Array[StringName] = [] + for need: StringName in PRIMARY_NEEDS: + var value := int(guest.needs[need]) + if value > highest: + highest = value + primary.clear() + if value == highest and value > 0: + primary.append(need) + var observed_needs: Dictionary = {} + for need: StringName in NEED_IDS: + observed_needs[need] = guest.needs[need] + return {&"guest_id": guest.guest_id, &"state": guest.state, &"needs": observed_needs, + &"primary_needs": primary, &"target_facility_id": guest.target_facility_id, + &"wallet_gold": guest.wallet_gold, &"patience": guest.patience_remaining} + + func _candidate_precedes(a: Dictionary, b: Dictionary) -> bool: + var a_priority := int(a.get(&"need_priority", 0)) + var b_priority := int(b.get(&"need_priority", 0)) + if a_priority != b_priority: + return a_priority > b_priority var a_utility := int(a[&"utility"]) var b_utility := int(b[&"utility"]) if a_utility != b_utility: diff --git a/scripts/mana/mana_power_projection.gd b/scripts/mana/mana_power_projection.gd new file mode 100644 index 0000000..c8f57b7 --- /dev/null +++ b/scripts/mana/mana_power_projection.gd @@ -0,0 +1,29 @@ +extends RefCounted + +## Derived power conditions for the Runtime-owned facility network. +## Does not change durability, operator policy, dependency or suspension status. +## A missing consumer denotes a facility with no network consumption; the reserve +## still gates it. Runtime must register every positive-demand consumer first. +static func apply(facilities: FacilityService, network: ManaNetworkService, reserve: int, minute: int) -> Dictionary: + if facilities == null or network == null or reserve < 0 or minute < 0: + return {&"ok": false, &"code": &"INVALID_POWER_CONTEXT"} + var changed: Array[StringName] = [] + for facility: FacilityInstance in facilities.get_instances(): + var node := network.get_node(facility.instance_id) + var supplied := node.is_empty() or int(node.get(&"state", ManaNetworkService.ConsumerState.NO_POWER)) in [ManaNetworkService.ConsumerState.ACTIVE, ManaNetworkService.ConsumerState.DEGRADED] + # Preserve the existing construction-to-first-evaluation contract. An + # unevaluated node is not an observed outage; reserve zero still wins. + if not node.is_empty() and &"awaiting_evaluation" in node.get(&"state_reasons", []): + supplied = facility.power_available + var available := reserve > 0 and supplied + var reasons: Array[StringName] = facility.degraded_reasons.duplicate() + reasons.erase(&"MANA_BROWNOUT") + if reserve > 0 and not node.is_empty() and int(node.get(&"state", -1)) == ManaNetworkService.ConsumerState.DEGRADED: + reasons.append(&"MANA_BROWNOUT") + if available == facility.power_available and reasons == facility.degraded_reasons: + continue + var result := facilities.set_operational_status(facility.instance_id, {&"power_available": available, &"degraded_reasons": reasons}, minute) + if not result.is_success(): + return {&"ok": false, &"code": &"POWER_PROJECTION_FAILED", &"facility_id": facility.instance_id} + changed.append(facility.instance_id) + return {&"ok": true, &"depleted": reserve == 0, &"changed_facilities": changed} diff --git a/scripts/mana/mana_power_projection.gd.uid b/scripts/mana/mana_power_projection.gd.uid new file mode 100644 index 0000000..d508e90 --- /dev/null +++ b/scripts/mana/mana_power_projection.gd.uid @@ -0,0 +1 @@ +uid://dkowhbe1mgbdn diff --git a/scripts/mana/mana_reserve_balance.gd b/scripts/mana/mana_reserve_balance.gd new file mode 100644 index 0000000..fcfa4e2 --- /dev/null +++ b/scripts/mana/mana_reserve_balance.gd @@ -0,0 +1,99 @@ +extends RefCounted + +## Finite mana reserve; timing and production rates belong to the caller. +## Every advance supplies totals for ONE interval, not a per-minute rate. +## Generation first covers demand, then unused mana enters storage up to capacity. +## No Runtime wiring or default balance values are implied by this component. +const VERSION := 1 +const LIMIT := 1000000000 +var _capacity := -1 +var _amount := -1 +var _minute := -1 +var _last_flow: Dictionary = {} + +func initialize(amount: Variant, capacity: Variant, minute: Variant) -> Dictionary: + if _minute >= 0: + return _failure(&"ALREADY_INITIALIZED") + if not _integer(amount) or not _integer(capacity) or not _integer(minute) or amount > capacity: + return _failure(&"INVALID_INITIAL_STATE") + _capacity = capacity + _amount = amount + _minute = minute + return {&"ok": true} + +func get_amount() -> int: + return _amount + +func is_depleted() -> bool: + return _minute >= 0 and _amount == 0 + +func advance_to(minute: Variant, generation: Variant, demand: Variant) -> Dictionary: + if _minute < 0: + return _failure(&"NOT_INITIALIZED") + if not _integer(minute) or not _integer(generation) or not _integer(demand) or minute < _minute: + return _failure(&"INVALID_FLOW") + if minute == _minute: + if not _last_flow.is_empty() and generation == _last_flow[&"generation"] and demand == _last_flow[&"demand"]: + return {&"ok": true, &"replayed": true, &"flow": _last_flow.duplicate(true)} + return _failure(&"REPLAY_MISMATCH") + var flow := _plan(_amount, _capacity, generation, demand, _minute, minute) + _amount = flow[&"after"] + _minute = minute + _last_flow = flow + return {&"ok": true, &"replayed": false, &"flow": flow.duplicate(true)} + +func create_snapshot() -> Dictionary: + return {&"version": VERSION, &"capacity": _capacity, &"amount": _amount, + &"minute": _minute, &"last_flow": _last_flow.duplicate(true)} + +func restore_snapshot(source: Dictionary) -> bool: + if not _keys(source, ["version", "capacity", "amount", "minute", "last_flow"]) \ + or typeof(source["version"]) != TYPE_INT or source["version"] != VERSION \ + or not _integer(source["capacity"]) or not _integer(source["amount"]) \ + or not _integer(source["minute"]) or source["amount"] > source["capacity"] \ + or not source["last_flow"] is Dictionary: + return false + var flow: Dictionary = source["last_flow"] + if not flow.is_empty(): + if not _keys(flow, ["from_minute", "to_minute", "before", "generation", "demand", "consumed", "shortage", "overflow", "after"]): + return false + for key: Variant in flow: + # Overflow may include an entire reserve plus this interval's supply. + if typeof(flow[key]) != TYPE_INT or flow[key] < 0 or flow[key] > LIMIT * 2: + return false + if not _integer(flow["generation"]) or not _integer(flow["demand"]) \ + or not _integer(flow["from_minute"]) or flow["from_minute"] >= flow["to_minute"] \ + or flow["to_minute"] != source["minute"] or flow["before"] > source["capacity"]: + return false + var expected := _plan(flow["before"], source["capacity"], flow["generation"], flow["demand"], flow["from_minute"], flow["to_minute"]) + if expected != flow or flow["after"] != source["amount"]: + return false + # Commit only after validation; invalid loads leave the existing reserve intact. + _capacity = source["capacity"] + _amount = source["amount"] + _minute = source["minute"] + _last_flow = flow.duplicate(true) + return true + +static func _plan(before: int, capacity: int, generation: int, demand: int, start: int, end: int) -> Dictionary: + var available := before + generation + var consumed := mini(available, demand) + var remaining := available - consumed + return {&"from_minute": start, &"to_minute": end, &"before": before, + &"generation": generation, &"demand": demand, &"consumed": consumed, + &"shortage": demand - consumed, &"overflow": maxi(remaining - capacity, 0), + &"after": mini(remaining, capacity)} + +static func _integer(value: Variant) -> bool: + return typeof(value) == TYPE_INT and value >= 0 and value <= LIMIT + +static func _keys(source: Dictionary, expected: Array) -> bool: + if source.size() != expected.size(): + return false + for key: Variant in source: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME] or String(key) not in expected: + return false + return true + +static func _failure(code: StringName) -> Dictionary: + return {&"ok": false, &"code": code} diff --git a/scripts/mana/mana_reserve_balance.gd.uid b/scripts/mana/mana_reserve_balance.gd.uid new file mode 100644 index 0000000..d77619b --- /dev/null +++ b/scripts/mana/mana_reserve_balance.gd.uid @@ -0,0 +1 @@ +uid://bq7s1rvh3rqy0 diff --git a/scripts/operations/operations_coordinator.gd b/scripts/operations/operations_coordinator.gd index 1475bde..499d26b 100644 --- a/scripts/operations/operations_coordinator.gd +++ b/scripts/operations/operations_coordinator.gd @@ -17,13 +17,13 @@ signal terminal_state_changed(old_state: StringName, new_state: StringName, reas const CommandResultType = preload("res://scripts/core/command_result.gd") -const SNAPSHOT_VERSION := 3 +const SNAPSHOT_VERSION := 4 const MIN_SUPPORTED_SNAPSHOT_VERSION := 1 const MINUTES_PER_DAY := 1_440 const DEMAND_INTERVAL_MINUTES := 60 const ARRIVAL_INTERVAL_MINUTES := 30 const MORNING_MINUTE_OF_DAY := 360 -const FIRST_DAILY_CLOSE_MINUTE := MORNING_MINUTE_OF_DAY + MINUTES_PER_DAY +const FIRST_DAILY_CLOSE_MINUTE := MINUTES_PER_DAY const KEY_SEPARATOR := "\u001f" const MAX_START_MINUTE := 360 const MAX_TRACKED_DAYS := 31 @@ -98,6 +98,11 @@ var last_error := "" var _origin_minute := 0 +# Old saves finish their open 06:00 period once, then use midnight. +# Historical insolvency evidence is retained without mixing it into new loss streaks. +var _legacy_calendar_last_day := 0 +var _legacy_history_last_day := 0 + var _config: Dictionary = DEFAULT_CONFIG.duplicate(true) var _demand_profiles: Dictionary = {} var _arrival_carry: Dictionary = {} @@ -111,6 +116,7 @@ var _processed_arrival_minutes: Dictionary = {} var _population_count := 0 var _arrears_total := 0 +# Serialized field name retained for compatibility; new closes count net losses. var _consecutive_insolvency_count := 0 var _terminal_state: StringName = TERMINAL_RUNNING var _next_arrival_plan_sequence := 1 @@ -129,6 +135,7 @@ var _notification_queue: Array[Dictionary] = [] var _notification_delivery_deferred := false var _initialization_valid := true +var _midnight_boundary := Callable() var _daily_close_boundary := Callable() var _arrival_boundary := Callable() var _morning_boundary := Callable() @@ -163,11 +170,13 @@ func _init(config: Dictionary = {}) -> void: func configure_callbacks( daily_close_boundary: Callable = Callable(), arrival_boundary: Callable = Callable(), - morning_boundary: Callable = Callable() + morning_boundary: Callable = Callable(), + midnight_boundary: Callable = Callable() ) -> OperationsCoordinator: _daily_close_boundary = daily_close_boundary _arrival_boundary = arrival_boundary _morning_boundary = morning_boundary + _midnight_boundary = midnight_boundary return self @@ -548,6 +557,19 @@ func can_start_construction_or_investment() -> Dictionary: } +## Compact, defensive projection for the continuously visible operations HUD. +func get_dashboard_summary() -> Dictionary: + var latest_day := -1 + for day: int in _day_summaries: + latest_day = maxi(latest_day, day) + var summaries: Array[Dictionary] = [] + if latest_day >= 0: + var latest: Dictionary = _day_summaries[latest_day] + summaries.append({&"day":latest[&"day"], &"net_profit":latest[&"net_profit"]}) + return {&"terminal_state":_terminal_state, &"consecutive_insolvency_count":_consecutive_insolvency_count, + &"legacy_history_last_day":_legacy_history_last_day, &"day_summaries":summaries} + + func get_day_summary(day: int) -> Dictionary: if not _day_summaries.has(day): return {} @@ -598,7 +620,8 @@ func create_snapshot() -> Dictionary: if _notification_delivery_deferred or not _notification_queue.is_empty(): last_error = "Deferred Operations notifications must be resolved before snapshotting." return {} - var internal_report := _variant_tree_report({ + var field_cache: Dictionary = {} + var internal_report := _snapshot_root_report({ &"config": _config, &"demand_profiles": _demand_profiles, &"arrival_carry": _arrival_carry, @@ -608,7 +631,7 @@ func create_snapshot() -> Dictionary: &"morning_reports": _morning_reports, &"alerts": _alerts, &"quarantine_report": _quarantine_report, - }) + }, field_cache, true) var shape_error := _internal_snapshot_shape_error() if not bool(internal_report[&"ok"]) or not shape_error.is_empty(): last_error = "Operations state cannot be serialized: %s" % [ @@ -636,20 +659,27 @@ func create_snapshot() -> Dictionary: var saved_mornings: Array[Dictionary] = [] for day: int in _sorted_int_keys(_morning_reports): - saved_mornings.append((_morning_reports[day] as Dictionary).duplicate(true)) + saved_mornings.append(_morning_reports[day]) + var saved_days: Array[Dictionary] = [] + for day: int in _sorted_int_keys(_day_summaries): + saved_days.append(_day_summaries[day]) + # These borrowed containers never leave this synchronous method. The final + # canonical conversion recursively creates owned dictionaries and arrays. var raw_snapshot := { &"schema_version": SNAPSHOT_VERSION, + &"legacy_calendar_last_day": _legacy_calendar_last_day, + &"legacy_history_last_day": _legacy_history_last_day, &"origin_minute": _origin_minute, &"current_minute": current_minute, &"config": _string_key_dictionary(_config), &"demand_profiles": saved_profiles, &"arrival_carry": saved_carry, - &"demand_history": _demand_history.duplicate(true), - &"arrival_reports": _arrival_reports.duplicate(true), - &"day_summaries": get_day_summaries(), + &"demand_history": _demand_history, + &"arrival_reports": _arrival_reports, + &"day_summaries": saved_days, &"morning_reports": saved_mornings, - &"alerts": _alerts.duplicate(true), + &"alerts": _alerts, &"processed_demand_minutes": _sorted_int_keys(_processed_demand_minutes), &"processed_arrival_minutes": _sorted_int_keys(_processed_arrival_minutes), &"population_count": _population_count, @@ -666,13 +696,13 @@ func create_snapshot() -> Dictionary: &"deferred_boundary_minute": _deferred_boundary_minute, &"pause_reason": String(_pause_reason), &"external_quarantined": _external_quarantined, - &"quarantine_report": _quarantine_report.duplicate(true), + &"quarantine_report": _quarantine_report, } - var tree_report := _variant_tree_report(raw_snapshot) + var tree_report := _snapshot_root_report(raw_snapshot, field_cache, false) if not bool(tree_report[&"ok"]): last_error = "Operations state cannot be serialized: %s" % tree_report[&"code"] return {} - return _to_snapshot_variant(raw_snapshot) + return tree_report[&"value"] func restore_snapshot(snapshot: Dictionary) -> bool: @@ -686,6 +716,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool: return false var state: Dictionary = parsed[&"state"] # Atomic swap begins only after the entire candidate passed validation. + _legacy_calendar_last_day = int(state[&"legacy_calendar_last_day"]) + _legacy_history_last_day = int(state[&"legacy_history_last_day"]) _origin_minute = int(state[&"origin_minute"]) current_minute = int(state[&"current_minute"]) _config = (state[&"config"] as Dictionary).duplicate(true) @@ -840,9 +872,15 @@ func validate_invariants() -> Dictionary: func _process_minute_boundaries(minute: int, counts: Dictionary) -> CommandResult: - # Fixed 06:00 boundary order: prior operating-day finance, morning - # operations, demand, arrivals. The first 06:00 opens Day 1; the first close - # occurs at Day 2 06:00 after a complete 24-hour operating period. + # Migrated 06:00 financial periods retain their original payroll/history. + # Calendar-midnight obligations still execute atomically at 00:00. Their + # external receipt owns idempotency when a later boundary in this minute fails. + if minute % MINUTES_PER_DAY == 0 and not _is_daily_close_boundary(minute) and _midnight_boundary.is_valid(): + var midnight := _run_boundary_transaction(_midnight_boundary, {&"boundary_minute": minute}, {&"ok": true}) + if not bool(midnight[&"ok"]): + return _transaction_boundary_failure(midnight, &"MIDNIGHT_UPKEEP", minute, &"MIDNIGHT_UPKEEP_FAILED") + # Finance precedes demand/arrivals at midnight. Morning work stays at 06:00. + # During legacy migration, the final 06:00 close also precedes morning work. if _is_daily_close_boundary(minute): var close := _process_daily_close(minute) if not close.is_success(): @@ -1086,7 +1124,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult: &"operation": &"DAILY_CLOSE", &"day": completed_day, &"boundary_minute": boundary_minute, - &"period_start_minute": boundary_minute - MINUTES_PER_DAY, + &"period_start_minute": get_financial_period_start(completed_day), &"period_end_minute_exclusive": boundary_minute, &"idempotency_key": "daily-close:%d" % completed_day, &"prior_arrears_total": _arrears_total, @@ -1097,7 +1135,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult: &"GENERATE_FACILITY_PROFIT_LOSS", &"DETECT_ANOMALIES", &"BUILD_DAY_SUMMARY", - &"EVALUATE_ARREARS_AND_BANKRUPTCY", + &"EVALUATE_LOSS_STREAK_AND_BANKRUPTCY", ], } var transaction := _run_boundary_transaction( @@ -1141,14 +1179,15 @@ func _process_daily_close(boundary_minute: int) -> CommandResult: previous_profit = int((_day_summaries[completed_day - 1] as Dictionary)[&"net_profit"]) var rolling := _rolling_profit_with(net_profit) var arrears := int((result[&"settlement"] as Dictionary)[&"arrears_total"]) - var consecutive := _consecutive_insolvency_count + 1 if arrears > 0 else 0 + var prior_streak := 0 if completed_day == _legacy_history_last_day + 1 else _consecutive_insolvency_count + var consecutive := prior_streak + 1 if net_profit < 0 else 0 var future_terminal := _terminal_state if consecutive >= int(_config[&"bankruptcy_close_count"]): future_terminal = TERMINAL_BANKRUPT var reconciliation_difference := int(result[&"reconciliation_difference"]) var summary := { &"day": completed_day, - &"period_start_minute": boundary_minute - MINUTES_PER_DAY, + &"period_start_minute": get_financial_period_start(completed_day), &"period_end_minute_exclusive": boundary_minute, &"closed_at": boundary_minute, &"category_totals": category_totals, @@ -1181,6 +1220,9 @@ func _process_daily_close(boundary_minute: int) -> CommandResult: &"difference": reconciliation_difference, &"automatic_correction": false, }) + if net_profit < 0: + _append_alert({&"code": &"LOSS_STREAK", &"minute": boundary_minute, + &"day": completed_day, &"net_profit": net_profit, &"consecutive_count": consecutive}) if arrears > 0: _append_alert({ &"code": &"ARREARS_REMAIN", @@ -1201,7 +1243,7 @@ func _process_daily_close(boundary_minute: int) -> CommandResult: }) _queue_notification( &"terminal", - [old_state, _terminal_state, &"CONSECUTIVE_INSOLVENCY"] + [old_state, _terminal_state, &"CONSECUTIVE_LOSSES"] ) _queue_notification(&"daily", [summary.duplicate(true)]) return _success(summary) @@ -1771,8 +1813,10 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String: &"deferred_boundary_minute", &"pause_reason", &"external_quarantined", &"quarantine_report", ] + if int(_value(snapshot, &"schema_version", 0)) >= 4: + top_keys.append_array([&"legacy_calendar_last_day", &"legacy_history_last_day"]) if not _dictionary_has_exact_string_keys(snapshot, top_keys): - return "Operations v3 snapshot has missing or unknown root fields." + return "Operations snapshot has missing or unknown root fields." var profiles: Variant = _value(snapshot, &"demand_profiles", null) var carry: Variant = _value(snapshot, &"arrival_carry", null) @@ -1782,33 +1826,33 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String: var mornings: Variant = _value(snapshot, &"morning_reports", null) if not profiles is Array or not carry is Array or not demand_history is Array \ or not arrivals is Array or not days is Array or not mornings is Array: - return "Operations v3 snapshot history collections are malformed." + return "Operations snapshot history collections are malformed." for raw_profile: Variant in profiles: if not raw_profile is Dictionary or not _dictionary_has_exact_string_keys( raw_profile, [&"facility_tag", &"guest_class", &"time_band", &"components", &"demand_index", &"last_evaluated_at"] ): - return "Operations v3 demand profile has missing or unknown fields." + return "Operations demand profile has missing or unknown fields." for raw_carry: Variant in carry: if not raw_carry is Dictionary or not _dictionary_has_exact_string_keys( raw_carry, [&"facility_tag", &"guest_class", &"time_band", &"numerator"] ): - return "Operations v3 arrival carry row has missing or unknown fields." + return "Operations arrival carry row has missing or unknown fields." for raw_report: Variant in demand_history: if not raw_report is Dictionary or not _dictionary_has_exact_string_keys( raw_report, [&"minute", &"values"] ): - return "Operations v3 demand history row has missing or unknown fields." + return "Operations demand history row has missing or unknown fields." var values: Variant = (raw_report as Dictionary).get(&"values", null) if not values is Array: - return "Operations v3 demand history values are malformed." + return "Operations demand history values are malformed." for raw_value: Variant in values: if not raw_value is Dictionary or not _dictionary_has_exact_string_keys( raw_value, [&"facility_tag", &"guest_class", &"time_band", &"demand_index"] ): - return "Operations v3 demand history value has missing or unknown fields." + return "Operations demand history value has missing or unknown fields." for raw_report: Variant in arrivals: if not raw_report is Dictionary or not _dictionary_has_exact_string_keys( raw_report, @@ -1816,18 +1860,18 @@ func _current_snapshot_exact_shape_error(snapshot: Dictionary) -> String: &"plans", &"total_count", &"population_after", &"capacity_remaining", &"carry_capped"] ): - return "Operations v3 arrival report has missing or unknown fields." + return "Operations arrival report has missing or unknown fields." var plans: Variant = (raw_report as Dictionary).get(&"plans", null) var capped: Variant = (raw_report as Dictionary).get(&"carry_capped", null) if not plans is Array or not capped is Array: - return "Operations v3 arrival report collections are malformed." + return "Operations arrival report collections are malformed." for raw_plan: Variant in plans: if not raw_plan is Dictionary or not _dictionary_has_exact_string_keys( raw_plan, [&"plan_id", &"minute", &"facility_tag", &"guest_class", &"time_band", &"demand_index", &"count", &"guest_ids"] ): - return "Operations v3 arrival plan has missing or unknown fields." + return "Operations arrival plan has missing or unknown fields." for raw_capped: Variant in capped: if not raw_capped is Dictionary or not _dictionary_has_exact_string_keys( raw_capped, [&"facility_tag", &"guest_class", &"discarded_numerator"] @@ -1918,7 +1962,7 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary: var version := int(version_value) if version < MIN_SUPPORTED_SNAPSHOT_VERSION or version > SNAPSHOT_VERSION: return {&"ok": false, &"error": "Unsupported operations snapshot version."} - if version == SNAPSHOT_VERSION: + if version >= 3: var exact_shape_error := _current_snapshot_exact_shape_error(snapshot) if not exact_shape_error.is_empty(): return {&"ok": false, &"error": exact_shape_error} @@ -2106,7 +2150,29 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary: if typeof(quarantine_value) != TYPE_BOOL or not quarantine_report_value is Dictionary: return {&"ok": false, &"error": "External quarantine snapshot state is malformed."} + var legacy_history := 0 + var legacy_calendar := 0 + if version < 4: + for day: int in histories[&"day_summaries"]: + legacy_history = maxi(legacy_history, day) + legacy_calendar = legacy_history + 1 + else: + var calendar_value: Variant = _value(snapshot, &"legacy_calendar_last_day", null) + var history_value: Variant = _value(snapshot, &"legacy_history_last_day", null) + if not _is_integral_value(calendar_value) or not _is_integral_value(history_value): + return {&"ok": false, &"error": "Invalid legacy calendar markers."} + legacy_calendar = int(calendar_value) + legacy_history = int(history_value) + if legacy_history < 0 or legacy_calendar < 0 or legacy_calendar > MAX_TRACKED_DAYS + 1 \ + or (legacy_calendar != 0 and legacy_calendar != legacy_history + 1) \ + or (legacy_calendar == 0 and legacy_history != 0): + return {&"ok": false, &"error": "Invalid legacy calendar transition."} + for day in range(1, legacy_history + 1): + if not (histories[&"day_summaries"] as Dictionary).has(day): + return {&"ok": false, &"error": "Missing legacy financial history."} var state := { + &"legacy_calendar_last_day": legacy_calendar, + &"legacy_history_last_day": legacy_history, &"origin_minute": restored_origin, &"current_minute": restored_minute, &"config": normalized_config[&"config"], @@ -2138,6 +2204,8 @@ func _parse_snapshot(snapshot: Dictionary) -> Dictionary: # Validate the fully assembled state using a temporary instance without # copying any callback into save data. var candidate := OperationsCoordinator.new(state[&"config"]) + candidate._legacy_calendar_last_day = legacy_calendar + candidate._legacy_history_last_day = legacy_history candidate._origin_minute = restored_origin candidate.current_minute = restored_minute candidate._config = (state[&"config"] as Dictionary).duplicate(true) @@ -2459,7 +2527,7 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void: var expected_close := _daily_close_minute_for_day(day) if int(_value(summary, &"day", 0)) != day \ or int(_value(summary, &"closed_at", -1)) != expected_close \ - or int(_value(summary, &"period_start_minute", -1)) != expected_close - MINUTES_PER_DAY \ + or int(_value(summary, &"period_start_minute", -1)) != get_financial_period_start(day) \ or int(_value(summary, &"period_end_minute_exclusive", -1)) != expected_close \ or expected_close <= _origin_minute \ or expected_close > maximum_committed_minute: @@ -2520,7 +2588,10 @@ func _validate_financial_history(errors: Array[Dictionary]) -> void: or int(_value(settlement_value, &"arrears_total", -2)) != expected_arrears \ or not _variant_tree_report(settlement_value)[&"ok"]: errors.append({&"code": &"SETTLEMENT_ARREARS_MISMATCH", &"day": day}) - expected_consecutive = expected_consecutive + 1 if expected_arrears > 0 else 0 + if day == _legacy_history_last_day + 1: + expected_consecutive = 0 + var failed_close := expected_arrears > 0 if day <= _legacy_history_last_day else net < 0 + expected_consecutive = expected_consecutive + 1 if failed_close else 0 if int(_value(summary, &"consecutive_insolvency_count", -1)) != expected_consecutive: errors.append({&"code": &"INSOLVENCY_COUNT_MISMATCH", &"day": day}) if expected_consecutive >= int(_config[&"bankruptcy_close_count"]): @@ -2614,6 +2685,7 @@ func _validate_partial_boundary_prefix(minute: int, errors: Array[Dictionary]) - func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void: var imbalance_by_day: Dictionary = {} var arrears_by_day: Dictionary = {} + var losses_by_day: Dictionary = {} var bankrupt_count := 0 var quarantine_count := 0 for alert: Dictionary in _alerts: @@ -2625,6 +2697,16 @@ func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void: &"LEDGER_IMBALANCE": var day := int(_value(alert, &"day", 0)) imbalance_by_day[day] = int(imbalance_by_day.get(day, 0)) + 1 + &"LOSS_STREAK": + var day := int(_value(alert, &"day", 0)) + losses_by_day[day] = int(losses_by_day.get(day, 0)) + 1 + var summary: Dictionary = _day_summaries.get(day, {}) + if summary.is_empty() or day <= _legacy_history_last_day \ + or int(_value(alert, &"net_profit", 0)) >= 0 \ + or _value(alert, &"minute", null) != summary.get(&"closed_at") \ + or _value(alert, &"net_profit", null) != summary.get(&"net_profit") \ + or _value(alert, &"consecutive_count", null) != summary.get(&"consecutive_insolvency_count"): + errors.append({&"code": &"INVALID_LOSS_ALERT", &"day": day}) &"ARREARS_REMAIN": var day := int(_value(alert, &"day", 0)) arrears_by_day[day] = int(arrears_by_day.get(day, 0)) + 1 @@ -2641,6 +2723,9 @@ func _validate_alerts_and_pause(errors: Array[Dictionary]) -> void: var imbalanced := int(_value(summary, &"reconciliation_difference", 0)) != 0 if int(imbalance_by_day.get(day, 0)) != int(imbalanced): errors.append({&"code": &"IMBALANCE_ALERT_MISMATCH", &"day": day}) + var new_loss := day > _legacy_history_last_day and int(_value(summary, &"net_profit", 0)) < 0 + if int(losses_by_day.get(day, 0)) != int(new_loss): + errors.append({&"code": &"LOSS_ALERT_MISMATCH", &"day": day}) var has_arrears := int(_value(summary, &"arrears_total", 0)) > 0 if int(arrears_by_day.get(day, 0)) != int(has_arrears): errors.append({&"code": &"ARREARS_ALERT_MISMATCH", &"day": day}) @@ -2725,16 +2810,32 @@ func _minute_has_scheduled_boundary(minute: int) -> bool: func _is_daily_close_boundary(minute: int) -> bool: - return minute >= FIRST_DAILY_CLOSE_MINUTE \ - and minute % MINUTES_PER_DAY == MORNING_MINUTE_OF_DAY + var day := minute / MINUTES_PER_DAY + return day > 0 and minute == get_daily_close_minute(day) func _financial_day_for_boundary(boundary_minute: int) -> int: - return (boundary_minute - MORNING_MINUTE_OF_DAY) / MINUTES_PER_DAY + return boundary_minute / MINUTES_PER_DAY func _daily_close_minute_for_day(day: int) -> int: - return day * MINUTES_PER_DAY + MORNING_MINUTE_OF_DAY + return get_daily_close_minute(day) + + +func get_daily_close_minute(day: int) -> int: + return day * MINUTES_PER_DAY + (MORNING_MINUTE_OF_DAY if day <= _legacy_calendar_last_day else 0) + + +func is_legacy_financial_history(day: int) -> bool: + return day <= _legacy_history_last_day + + +func get_open_financial_day() -> int: + return _day_summaries.size() + 1 + + +func get_financial_period_start(day: int) -> int: + return get_daily_close_minute(day - 1) if day > 1 else (MORNING_MINUTE_OF_DAY if _legacy_calendar_last_day > 0 else 0) func _max_carry_for_config(config: Dictionary) -> int: @@ -2764,67 +2865,169 @@ static func _is_save_safe(value: Variant, depth := 0) -> bool: static func _variant_tree_report(value: Variant) -> Dictionary: - var budget := {&"nodes": 0, &"string_length": 0} + # Shared per-traversal counters: [visited nodes, total string length]. + var budget: Array[int] = [0, 0] var issue := _inspect_variant_tree(value, budget, 0) if not issue.is_empty(): return {&"ok": false, &"code": issue} return { &"ok": true, &"code": &"OK", - &"nodes": int(budget[&"nodes"]), - &"string_length": int(budget[&"string_length"]), + &"nodes": budget[0], + &"string_length": budget[1], } static func _inspect_variant_tree( value: Variant, - budget: Dictionary, + budget: Array[int], depth: int ) -> StringName: + var issues: Array[StringName] = [] + _visit_snapshot_tree(value, budget, depth, issues, false) + return issues[0] if not issues.is_empty() else &"" + + +static func _snapshot_tree_report(value: Variant) -> Dictionary: + var budget: Array[int] = [0, 0] + var issues: Array[StringName] = [] + var encoded: Variant = _visit_snapshot_tree(value, budget, 0, issues, true) + if not issues.is_empty(): + return {&"ok": false, &"code": issues[0]} + return {&"ok": true, &"code": &"OK", &"nodes": budget[0], + &"string_length": budget[1], &"value": encoded} + + +# Capture validated, canonical copies for unchanged root fields. +# The cache never survives create_snapshot and only root-depth subtrees reuse it. +# Use only for that synchronous internal-to-output conversion: these four fields +# have identical source values and depths in both root dictionaries. No callbacks +# or state mutation may occur between capture and reuse. Aggregate-limit failure +# still uses the original visitor to retain its exact error precedence. +static func _snapshot_root_report(source: Dictionary, cache: Dictionary, capture: bool) -> Dictionary: + var budget: Array[int] = [1, 0] + var issues: Array[StringName] = [] + var converted: Dictionary = {} + if source.size() > MAX_SAFE_COLLECTION_SIZE: + return {&"ok": false, &"code": &"COLLECTION_TOO_LARGE"} + if not _canonical_key_collision(source).is_empty(): + return {&"ok": false, &"code": &"DUPLICATE_CANONICAL_KEY"} + for key: Variant in source: + if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]: + return {&"ok": false, &"code": &"INVALID_DICTIONARY_KEY"} + budget[0] += 1 + if budget[0] > MAX_VARIANT_NODES: + return {&"ok": false, &"code": &"MAX_VARIANT_NODES"} + var name := str(key) + var length := name.length() + if length > MAX_SINGLE_STRING_LENGTH: + return {&"ok": false, &"code": &"STRING_TOO_LONG"} + budget[1] += length + if budget[1] > MAX_TOTAL_STRING_LENGTH: + return {&"ok": false, &"code": &"TOTAL_STRING_BUDGET"} + var node_start := budget[0] + var string_start := budget[1] + var reusable := name in ["demand_history", "arrival_reports", "alerts", "quarantine_report"] + var value: Variant + var cached: Dictionary = cache.get(name, {}) if not capture else {} + if not cached.is_empty() and budget[0] + cached[&"nodes"] <= MAX_VARIANT_NODES and budget[1] + cached[&"strings"] <= MAX_TOTAL_STRING_LENGTH: + budget[0] += cached[&"nodes"] + budget[1] += cached[&"strings"] + value = cached[&"value"] + else: + # Fall back to the original source-order visitor when an aggregate + # limit can fail, preserving which budget error occurs first. + value = _visit_snapshot_tree(source[key], budget, 1, issues, not capture or reusable) + if not issues.is_empty(): + return {&"ok": false, &"code": issues[0]} + if capture and reusable: + cache[name] = {&"nodes": budget[0] - node_start, &"strings": budget[1] - string_start, &"value": value} + elif not capture: + converted[name] = value + converted.sort() + return {&"ok": true, &"code": &"OK", &"nodes": budget[0], &"string_length": budget[1], &"value": converted} + + +static func _snapshot_tree_issue(issues: Array[StringName], code: StringName) -> Variant: + issues.append(code) + return null + + +static func _visit_snapshot_tree( + value: Variant, + budget: Array[int], + depth: int, + issues: Array[StringName], + encode: bool +) -> Variant: + # The validator and encoder share one traversal so their depth, key and + # aggregate budgets cannot drift. Validation-only callers allocate no copies. if depth > MAX_VARIANT_DEPTH: - return &"MAX_VARIANT_DEPTH" - budget[&"nodes"] = int(budget[&"nodes"]) + 1 - if int(budget[&"nodes"]) > MAX_VARIANT_NODES: - return &"MAX_VARIANT_NODES" + return _snapshot_tree_issue(issues, &"MAX_VARIANT_DEPTH") + budget[0] += 1 + if budget[0] > MAX_VARIANT_NODES: + return _snapshot_tree_issue(issues, &"MAX_VARIANT_NODES") match typeof(value): TYPE_NIL, TYPE_BOOL, TYPE_INT: - return &"" + return value TYPE_FLOAT: - return &"" if is_finite(float(value)) else &"NON_FINITE_NUMBER" + return value if is_finite(float(value)) else _snapshot_tree_issue(issues, &"NON_FINITE_NUMBER") TYPE_STRING, TYPE_STRING_NAME: var text := String(value) if text.length() > MAX_SINGLE_STRING_LENGTH: - return &"STRING_TOO_LONG" - budget[&"string_length"] = int(budget[&"string_length"]) + text.length() - return &"" if int(budget[&"string_length"]) <= MAX_TOTAL_STRING_LENGTH \ - else &"TOTAL_STRING_BUDGET" + return _snapshot_tree_issue(issues, &"STRING_TOO_LONG") + budget[1] += text.length() + if budget[1] > MAX_TOTAL_STRING_LENGTH: + return _snapshot_tree_issue(issues, &"TOTAL_STRING_BUDGET") + return text TYPE_ARRAY: var values: Array = value if values.size() > MAX_SAFE_COLLECTION_SIZE: - return &"COLLECTION_TOO_LARGE" + return _snapshot_tree_issue(issues, &"COLLECTION_TOO_LARGE") + var converted: Variant = [] if encode else null for child: Variant in values: - var issue := _inspect_variant_tree(child, budget, depth + 1) - if not issue.is_empty(): - return issue - return &"" + var encoded: Variant = _visit_snapshot_tree(child, budget, depth + 1, issues, encode) + if not issues.is_empty(): + return null + if encode: + converted.append(encoded) + return converted TYPE_DICTIONARY: var source: Dictionary = value if source.size() > MAX_SAFE_COLLECTION_SIZE: - return &"COLLECTION_TOO_LARGE" + return _snapshot_tree_issue(issues, &"COLLECTION_TOO_LARGE") if not _canonical_key_collision(source).is_empty(): - return &"DUPLICATE_CANONICAL_KEY" + return _snapshot_tree_issue(issues, &"DUPLICATE_CANONICAL_KEY") + var converted: Variant = {} if encode else null + # Validate in source order, retaining the historical error precedence. for key: Variant in source: if typeof(key) not in [TYPE_STRING, TYPE_STRING_NAME, TYPE_INT]: - return &"INVALID_DICTIONARY_KEY" - var key_issue := _inspect_variant_tree(str(key), budget, depth + 1) - if not key_issue.is_empty(): - return key_issue - var value_issue := _inspect_variant_tree(source[key], budget, depth + 1) - if not value_issue.is_empty(): - return value_issue - return &"" + return _snapshot_tree_issue(issues, &"INVALID_DICTIONARY_KEY") + if depth + 1 > MAX_VARIANT_DEPTH: + return _snapshot_tree_issue(issues, &"MAX_VARIANT_DEPTH") + budget[0] += 1 + if budget[0] > MAX_VARIANT_NODES: + return _snapshot_tree_issue(issues, &"MAX_VARIANT_NODES") + var string_key := str(key) + var key_length := string_key.length() + if key_length > MAX_SINGLE_STRING_LENGTH: + return _snapshot_tree_issue(issues, &"STRING_TOO_LONG") + budget[1] += key_length + if budget[1] > MAX_TOTAL_STRING_LENGTH: + return _snapshot_tree_issue(issues, &"TOTAL_STRING_BUDGET") + var encoded: Variant = _visit_snapshot_tree(source[key], budget, depth + 1, issues, encode) + if not issues.is_empty(): + return null + if encode: + converted[string_key] = encoded + if not encode: + return null + # Every key is now a unique String. Native string sorting preserves + # canonical byte order without repeated Variant-to-string callbacks. + converted.sort() + return converted _: - return &"UNSAVEABLE_VARIANT" + return _snapshot_tree_issue(issues, &"UNSAVEABLE_VARIANT") static func _canonical_key_collision(source: Dictionary) -> Dictionary: diff --git a/scripts/staff/staff_runtime.gd b/scripts/staff/staff_runtime.gd index b506f01..d686331 100644 --- a/scripts/staff/staff_runtime.gd +++ b/scripts/staff/staff_runtime.gd @@ -269,6 +269,12 @@ func add_stress(amount: int) -> int: return stress +func get_effective_daily_wage() -> int: + # Preserve historical stored wages for exact save round trips; UNIQUE + # characters accrue no new upkeep under original section 6.1. + return 0 if employment_kind == EmploymentKind.UNIQUE else daily_wage + + func get_employment_kind_name() -> StringName: return StringName(EmploymentKind.keys()[employment_kind]) diff --git a/scripts/staff/staff_service.gd b/scripts/staff/staff_service.gd index ccd794b..0eda0f5 100644 --- a/scripts/staff/staff_service.gd +++ b/scripts/staff/staff_service.gd @@ -50,6 +50,16 @@ var _item_releaser := Callable() var _travel_estimator := Callable() var _external_task_driver := Callable() var _attendance_driver := Callable() +var _arrival_driver := Callable() + +# Optional scheduler policy: zero delegates work progression to Runtime. +var _work_progress_driver: Callable +func configure_work_progress_driver(driver: Callable) -> void: + _work_progress_driver = driver + + +func configure_arrival_driver(driver: Callable) -> void: + _arrival_driver = driver func configure_callbacks( @@ -170,7 +180,7 @@ func get_daily_payroll_rows() -> Array[Dictionary]: rows.append({ &"staff_id": staff.staff_id, &"employment_kind": staff.get_employment_kind_name(), - &"daily_wage": staff.daily_wage, + &"daily_wage": staff.get_effective_daily_wage(), }) return rows @@ -193,7 +203,7 @@ func get_payroll_obligations() -> Array[Dictionary]: &"staff_id": staff_id, &"payroll_status": &"ACTIVE", &"employment_kind": staff.get_employment_kind_name(), - &"daily_wage": staff.daily_wage, + &"daily_wage": staff.get_effective_daily_wage(), &"unpaid_wages": staff.unpaid_wages, } for staff_id: StringName in _sorted_keys(_departed_payroll): @@ -730,6 +740,8 @@ func begin_task(task_id: StringName, absolute_minute: int = -1) -> CommandResult return _conflict("Task cannot begin from its current state.") if staff.state not in [StaffRuntimeType.State.MOVING_TO_TASK, StaffRuntimeType.State.AVAILABLE]: return _conflict("Staff cannot begin the reserved Task from its current state.") + if _arrival_driver.is_valid() and not bool(_arrival_driver.call(task, staff, minute)): + return _conflict("Task arrival was redirected or is no longer valid.") # Both state preconditions are checked before either side is mutated. var task_state_before: StaffTask.State = task.state var task_changed_at_before := task.state_changed_at @@ -829,7 +841,11 @@ func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]: StaffTaskType.State.WORKING: if is_externally_driven_task(task_id): continue - var progress_result := progress_task(task.task_id, 1, absolute_minute) + var units := 1 + if _work_progress_driver.is_valid(): + units = int(_work_progress_driver.call(task.task_id)) + if units <= 0: continue + var progress_result := progress_task(task.task_id, units, absolute_minute) if progress_result.is_success(): actions.append({ &"task_id": task.task_id, @@ -878,6 +894,8 @@ func remove_staff( var staff := get_staff(staff_id) if staff == null: return _not_found("Staff", staff_id) + if staff.employment_kind == StaffRuntimeType.EmploymentKind.UNIQUE: + return _conflict("Unique Staff cannot be dismissed, sold or permanently removed.") if is_externally_driven_task(staff.current_task_id): return _conflict("Finish the employee external operation before removal.") var minute := current_minute if absolute_minute < 0 else absolute_minute @@ -921,6 +939,8 @@ func retire_staff(staff_id: StringName, absolute_minute: int = -1) -> CommandRes var staff := get_staff(staff_id) if staff == null: return _not_found("Staff", staff_id) + if staff.employment_kind == StaffRuntimeType.EmploymentKind.UNIQUE: + return _conflict("Unique Staff cannot be dismissed, sold or permanently removed.") if is_externally_driven_task(staff.current_task_id): return _conflict("Finish the employee external operation before retirement.") staff.is_retiring = true @@ -1409,7 +1429,8 @@ func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", re staff.shift_id = &"" if staff.assigned_facility_id == shift.assigned_facility_id: staff.assigned_facility_id = &"" - staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"SHIFT_ENDED", absolute_minute) + if staff.can_take_shift(): + staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"SHIFT_ENDED", absolute_minute) shift.close(absolute_minute) shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) shift = null @@ -1550,7 +1571,7 @@ func _build_payroll_preview(day: int) -> Dictionary: # Daily close covers [0, day * 1440). A hire exactly on the end # boundary belongs to the next close; carried debt remains due now. var newly_accrued := ( - staff.daily_wage if staff.employment_effective_minute < period_end else 0 + staff.get_effective_daily_wage() if staff.employment_effective_minute < period_end else 0 ) if ( prior < 0 diff --git a/scripts/staff/staff_task.gd b/scripts/staff/staff_task.gd index 7a5434b..3d52e54 100644 --- a/scripts/staff/staff_task.gd +++ b/scripts/staff/staff_task.gd @@ -20,6 +20,7 @@ var task_type: StringName = &"" var priority := 0 var created_at := 0 var deadline_at := -1 +var rescue_facility_id: StringName = &"" var target_entity_id: StringName = &"" var target_cell := Vector3i.ZERO var required_role_tags: Array[StringName] = [] @@ -53,6 +54,7 @@ func configure( priority = clampi(p_priority, 0, MAX_PRIORITY) created_at = maxi(p_created_at, 0) deadline_at = int(_value(options, &"deadline_at", -1)) + rescue_facility_id = StringName(_value(options, &"rescue_facility_id", &"")) target_entity_id = StringName(_value(options, &"target_entity_id", &"")) target_cell = _vector3i(_value(options, &"target_cell", Vector3i.ZERO)) required_role_tags = _sorted_unique_names(_value(options, &"required_role_tags", [])) @@ -224,6 +226,7 @@ func create_snapshot() -> Dictionary: &"priority": priority, &"created_at": created_at, &"deadline_at": deadline_at, + &"rescue_facility_id": String(rescue_facility_id), &"target_entity_id": String(target_entity_id), &"target_cell": [target_cell.x, target_cell.y, target_cell.z], &"required_role_tags": _names_to_strings(required_role_tags), @@ -258,6 +261,7 @@ static func from_snapshot(snapshot: Dictionary) -> StaffTask: int(snapshot.get(&"created_at", 0)), { &"deadline_at": snapshot.get(&"deadline_at", -1), + &"rescue_facility_id": snapshot.get(&"rescue_facility_id", ""), &"target_entity_id": snapshot.get(&"target_entity_id", ""), &"target_cell": snapshot.get(&"target_cell", []), &"required_role_tags": snapshot.get(&"required_role_tags", []), @@ -283,7 +287,8 @@ static func from_snapshot(snapshot: Dictionary) -> StaffTask: static func _snapshot_shape_is_valid(snapshot: Dictionary) -> bool: return ( - not String(snapshot.get(&"task_id", "")).is_empty() + (snapshot.get(&"rescue_facility_id", "") is String or snapshot.get(&"rescue_facility_id", "") is StringName) + and not String(snapshot.get(&"task_id", "")).is_empty() and not String(snapshot.get(&"task_type", "")).is_empty() and snapshot.get(&"target_cell", null) is Array and snapshot.get(&"required_role_tags", null) is Array diff --git a/scripts/supply/kitchen_production_service.gd b/scripts/supply/kitchen_production_service.gd index f524109..328f1f1 100644 --- a/scripts/supply/kitchen_production_service.gd +++ b/scripts/supply/kitchen_production_service.gd @@ -237,6 +237,8 @@ func _complete_batch(batch: Dictionary, minute: int) -> Dictionary: func _production_state(facility: FacilityInstance, plan: Dictionary, batch: Dictionary, minute: int) -> StringName: if not bool(plan[&"enabled"]): return &"DISABLED" if facility.durability <= 0: return &"BROKEN" + if not facility.suspension_reason.is_empty(): return &"SUSPENDED" + if not facility.related_facility_operating: return &"RELATED_FACILITY_INACTIVE" if not facility.access_available: return &"NO_ACCESS" if not facility.power_available: return &"NO_POWER" if facility.active_staff_ids.size() < maxi(facility.staff_min, 1): return &"NO_STAFF" diff --git a/scripts/supply/supply_logistics_bridge.gd b/scripts/supply/supply_logistics_bridge.gd index 0b1bdd5..a0774bd 100644 --- a/scripts/supply/supply_logistics_bridge.gd +++ b/scripts/supply/supply_logistics_bridge.gd @@ -171,7 +171,7 @@ func is_returning(request_id: StringName) -> bool: func _dispatch(request: LogisticsRequest, minute: int) -> void: var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id) var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id) - if source == null or destination == null or not source.operator_open or not destination.operator_open: + if source == null or destination == null or not source.allows_logistics() or not destination.allows_logistics(): request.state_reason = "FACILITY_UNAVAILABLE" return if not _has_storage_space(destination, request.total_weight): @@ -216,10 +216,10 @@ func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, mi var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id) if source == null or not _cell_touches_facility(staff.current_cell, source): return _block_before_pickup(request, "INVALID_PICKUP_ACCESS", minute) and false - if not source.operator_open: + if not source.allows_logistics(): return _block_before_pickup(request, "SOURCE_CLOSED", minute) and false var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id) - if destination == null or not destination.operator_open or not _has_storage_space(destination, request.total_weight): + if destination == null or not destination.allows_logistics() or not _has_storage_space(destination, request.total_weight): return _block_before_pickup(request, "DESTINATION_FULL_OR_CLOSED", minute) and false var route := _access_path(staff.current_cell, destination) if not bool(route.get(&"reachable", false)): @@ -251,7 +251,7 @@ func _deliver(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, m request.state_reason = "INVALID_DELIVERY_ACCESS" return false var quantities := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)) - if destination == null or not destination.operator_open or not _has_storage_space(destination, SupplyCatalog.inventory_weight(_runtime.inventory_service, _cargo_id(staff.staff_id))): + if destination == null or not destination.allows_logistics() or not _has_storage_space(destination, SupplyCatalog.inventory_weight(_runtime.inventory_service, _cargo_id(staff.staff_id))): if not returning: request.state_reason = "DESTINATION_FULL" return false diff --git a/scripts/supply/supply_management_service.gd b/scripts/supply/supply_management_service.gd index 75c8fae..4e3d4aa 100644 --- a/scripts/supply/supply_management_service.gd +++ b/scripts/supply/supply_management_service.gd @@ -23,7 +23,7 @@ func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: Strin quote[&"cash_after"] = cash - int(quote[&"total_cost"]) if quantity <= 0 or quantity > 10000: quote[&"reason"] = &"INVALID_QUANTITY" - elif not facility.operator_open or facility.durability <= 0: + elif not facility.allows_logistics(): quote[&"reason"] = &"FACILITY_CLOSED" elif int(quote[&"total_cost"]) > cash: quote[&"reason"] = &"INSUFFICIENT_FUNDS" @@ -109,7 +109,7 @@ func get_report(runtime: Variant) -> Dictionary: func ensure_deliveries(runtime: Variant, minute: int) -> void: var facilities := _facilities(runtime) for destination: FacilityInstance in facilities: - if not destination.operator_open or destination.durability <= 0: + if not destination.allows_logistics(): continue var source_type: StringName var item_id: StringName @@ -131,7 +131,7 @@ func ensure_deliveries(runtime: Variant, minute: int) -> void: continue var sources: Array[Dictionary] = [] for source: FacilityInstance in facilities: - if source.definition_id != source_type or not source.operator_open: + if source.definition_id != source_type or not source.allows_logistics(): continue if source_type == &"kitchen" and not _has_production_plan(runtime, source.instance_id): continue diff --git a/scripts/transport/elevator_service.gd b/scripts/transport/elevator_service.gd index 7eff76c..cb927ba 100644 --- a/scripts/transport/elevator_service.gd +++ b/scripts/transport/elevator_service.gd @@ -174,6 +174,34 @@ func request_trip(actor_id: StringName, from: Vector3i, to: Vector3i, minute: in return {&"ok": true, &"replay": false, &"trip": trip.duplicate(true)} +## Reserve two real seats atomically; no synthetic passenger or hidden capacity. +func request_pair(first: StringName, second: StringName, from: Vector3i, to: Vector3i, + minute: int, access: Array[StringName] = []) -> Dictionary: + if first.is_empty() or second.is_empty() or first == second: + return _failure(&"INVALID_PAIR", "Two distinct passengers are required.") + if _trips.has(first) or _trips.has(second): + if _trips.has(first) and _trips.has(second) and _trips[first].get(&"companion_id", &"") == second and _trips[second].get(&"companion_id", &"") == first and _trips[first][&"from"] == from and _trips[first][&"to"] == to: + return {&"ok": true, &"replay": true, &"trip": _trips[first].duplicate(true)} + return _failure(&"ACTOR_ALREADY_TRAVELLING", "Pair member already owns another trip.") + var path_before := _path.create_snapshot() + var sequence_before := _sequence + var a := request_trip(first, from, to, minute, access) + if not a[&"ok"]: + _path.restore_snapshot(path_before) + _sequence = sequence_before + return a + var b := request_trip(second, from, to, minute, access) + if not b[&"ok"] or _shafts[_trips[first][&"shaft_id"]][&"config"][&"capacity"] < 2: + _path.restore_snapshot(path_before) + _trips.erase(first) + _trips.erase(second) + _sequence = sequence_before + return _failure(&"PAIR_REJECTED", "Both passengers must fit in the same cabin queue.") + _trips[first][&"companion_id"] = second + _trips[second][&"companion_id"] = first + return {&"ok": true, &"replay": false, &"trip": _trips[first].duplicate(true)} + + func advance_one_minute(minute: int) -> Array[Dictionary]: var arrivals: Array[Dictionary] = [] if minute == _minute: return arrivals @@ -206,6 +234,22 @@ func get_actor_trip(actor_id: StringName) -> Dictionary: func cancel_actor(actor_id: StringName) -> bool: + var partner: StringName = _trips.get(actor_id, {}).get(&"companion_id", &"") + if not partner.is_empty(): + if _trips[actor_id][&"state"] == PathService.ELEVATOR_REQUEST_BOARDED: + last_error = "An onboard pair must alight together at a real landing." + return false + var path_before := _path.create_snapshot() + var trips_before := _trips.duplicate(true) + if not _cancel_single(actor_id) or not _cancel_single(partner): + _path.restore_snapshot(path_before) + _trips = trips_before + return false + return true + return _cancel_single(actor_id) + + +func _cancel_single(actor_id: StringName) -> bool: if not _trips.has(actor_id): return true var trip: Dictionary = _trips[actor_id] if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED: @@ -259,8 +303,9 @@ func _apply_operational(shaft_id: StringName, operational: bool, minute: int) -> # landing; movement remains held and requests remain owned. car[&"operational"] = false for actor_id: StringName in _names(_trips): + if not _trips.has(actor_id): continue var waiting: Dictionary = _trips[actor_id] - if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and cancel_actor(actor_id): + if waiting[&"shaft_id"] == shaft_id and waiting[&"state"] == PathService.ELEVATOR_REQUEST_QUEUED and _cancel_single(actor_id): arrivals.append({&"actor_id": actor_id, &"request_id": waiting[&"request_id"], &"cell": waiting[&"from"], &"emergency": false, &"cancelled": true}) _publish_cabin(car) return arrivals @@ -364,7 +409,11 @@ func restore_snapshot(snapshot: Dictionary) -> bool: for value: Variant in snapshot[&"trips"]: if not value is Dictionary: return _invalid("Elevator trip must be a Dictionary.") var trip: Dictionary = value - if not _exact_keys(trip, [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"]): + var keys: Array = [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"from", &"to", &"enqueued_at", &"state"] + if trip.has(&"companion_id"): + if not (trip[&"companion_id"] is String or trip[&"companion_id"] is StringName) or String(trip[&"companion_id"]).is_empty(): return _invalid("Invalid companion identity.") + keys.append(&"companion_id") + if not _exact_keys(trip, keys): return _invalid("Elevator trip fields are invalid.") for key: StringName in [&"actor_id", &"request_id", &"shaft_id", &"connector_id", &"state"]: if not (trip[key] is String or trip[key] is StringName) or str(trip[key]).is_empty(): return _invalid("Elevator trip names are invalid.") @@ -395,6 +444,15 @@ func restore_snapshot(snapshot: Dictionary) -> bool: if not found: return _invalid("Queued trip details disagree with Path queue.") else: return _invalid("Unknown elevator trip state.") next_trips[actor_id] = trip.duplicate(true) + for actor_id: StringName in next_trips: + var trip: Dictionary = next_trips[actor_id] + var partner: StringName = trip.get(&"companion_id", &"") + if partner.is_empty(): continue + if partner == actor_id or not next_trips.has(partner): return _invalid("Missing companion trip.") + var other: Dictionary = next_trips[partner] + if other.get(&"companion_id", &"") != actor_id: return _invalid("Companion ownership is not reciprocal.") + for key: StringName in [&"shaft_id", &"connector_id", &"from", &"to", &"state", &"enqueued_at"]: + if trip[key] != other[key]: return _invalid("Companion trip was split.") for actor_id: StringName in boarded_owners: if not next_trips.has(actor_id): return _invalid("Cabin seat has no matching trip.") var path_snapshot := _path.create_snapshot() @@ -444,7 +502,10 @@ func _dispatch(car: Dictionary) -> void: func _board(car: Dictionary) -> void: var config: Dictionary = car[&"config"] var connector_id := _connector_id(config[&"shaft_id"], car[&"boarding_floor"], car[&"target_floor"]) - var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"]) + var companions: Dictionary = {} + for actor: StringName in _trips: + if _trips[actor].has(&"companion_id"): companions[actor] = _trips[actor][&"companion_id"] + var boarded := _path.dequeue_elevator_boarding(connector_id, car[&"current_floor"], _minute, config[&"capacity"], companions) car[&"boarded"] = [] for request: Dictionary in boarded: var actor_id: StringName = request[&"unit_id"] diff --git a/scripts/ui/business_management_panel.gd b/scripts/ui/business_management_panel.gd index 868e593..068ba00 100644 --- a/scripts/ui/business_management_panel.gd +++ b/scripts/ui/business_management_panel.gd @@ -1,14 +1,16 @@ class_name BusinessManagementPanel extends VBoxContainer +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + ## Policy drafts are local; accepted commands and read models belong to Runtime. signal status_requested(message: String, success: bool) signal action_requested(action: StringName) const KEY_PREFIX := "ui.business." const COMMAND: StringName = &"set_facility_policy" -const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与は発生します。" -const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与も発生します。" +const GUEST_POLICY_EXPLANATION := "料金変更は新規受付から。受付済みの料金は変わりません。\n休業すると待機中のお客様は別の施設を探します。開始済みのサービスは完了まで続きます。休業中も給与と施設維持費は発生します。" +const SUPPLY_POLICY_EXPLANATION := "休業中は調理と新しい補給を止めます。在庫と運搬中の荷物は保持し、積込済みの荷物は納品先の営業状況に従って配送します。消費期限は経過し、給与と施設維持費も発生します。" const STATE_NAMES := {&"BROKEN": "故障中", &"NO_ACCESS": "経路なし", &"NO_POWER": "魔力不足", &"NO_STAFF": "職員不足", &"CLOSED": "休業中", &"DEGRADED": "状態悪化", &"OPEN": "稼働中"} const REASONS := { &"FACILITY_NOT_FOUND": "施設が見つかりません。選び直してください。", @@ -53,7 +55,9 @@ func _ready() -> void: _build_interface() _built = true if _runtime != null: - if _runtime.has_signal(&"snapshot_published"): + if _runtime.has_signal(&"state_updated"): + _runtime.connect(&"state_updated", _on_snapshot_published) + elif _runtime.has_signal(&"snapshot_published"): _runtime.connect(&"snapshot_published", _on_snapshot_published) if _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", _on_command_completed) @@ -85,6 +89,7 @@ func _build_interface() -> void: for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]: business_open_button.add_theme_color_override(color_name, Color("55371e")) business_open_button.add_theme_color_override("font_disabled_color", Color("766650")) + LayerSkin.register_toggle_text(business_open_button, LayerSkin.active_layer(self)) business_open_button.add_theme_font_size_override("font_size", 13) business_open_button.toggled.connect(func(_enabled: bool) -> void: _store_draft()) add_child(business_open_button) @@ -129,9 +134,13 @@ func refresh() -> void: business_open_button.set_pressed_no_signal(bool(draft.get(&"business_open", true))) var pending := _has_pending(selected_facility_id) var can_price := bool(row.get(&"can_price", false)) + var is_cleaning := StringName(row.get(&"definition_id", &"")) == &"cleaning_room" var is_supply := StringName(row.get(&"definition_id", &"")) in [&"kitchen", &"warehouse"] business_open_button.text = "営業する" if is_supply else "新規の受付を開く" explanation_label.text = SUPPLY_POLICY_EXPLANATION if is_supply else GUEST_POLICY_EXPLANATION + if is_cleaning: + business_open_button.text = "清掃業務を有効にする" + explanation_label.text = "配置した清掃員が汚れた施設へ向かいます。清掃業務を無効にすると、途中の清掃も中断します。維持費と給与は継続します。" facility_option.disabled = _rows(&"facilities").is_empty() price_option.disabled = row.is_empty() or not can_price or pending or price_option.item_count == 0 business_open_button.disabled = row.is_empty() or pending @@ -141,21 +150,36 @@ func refresh() -> void: facility_status_label.text = "施設が完成すると営業を設定できます。" quote_label.text = "施設を選んでください。" effect_label.text = "" + if int(row.get(&"faith_per_service", 0)) > 0: + effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"]) today_label.text = "本日の実績はまだありません。" else: - facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_count", 0)), int(row.get(&"active_count", 0))] + facility_status_label.text = "%s · %s\n待ち %d人 / 利用中 %d人\n同時利用上限 %s" % [("営業する設定" if is_supply else "受付を開く設定") if bool(row.get(&"business_open", true)) else ("休業する設定" if is_supply else "新規受付を停止中"), String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中")), int(row.get(&"queued_customer_count", 0)), int(row.get(&"active_customer_count", 0)), ("%d人" % int(row[&"customer_capacity"])) if int(row.get(&"customer_capacity", -1)) >= 0 else "未設定"] if is_supply: facility_status_label.text = "%s · %s\n保管・補給の状況は「商品」で確認。" % ["営業する設定" if bool(row.get(&"business_open", true)) else "休業する設定", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))] + if is_cleaning: + facility_status_label.text = "%s · %s\n顧客利用なし・清掃員の業務拠点" % ["清掃業務を有効にする設定" if bool(row.get(&"business_open", true)) else "清掃業務を停止中", String(STATE_NAMES.get(StringName(row.get(&"state", &"")), "状態確認中"))] + if int(row.get(&"cleanliness", -1)) >= 0: + facility_status_label.text += "\n清潔度 %d / 100" % int(row[&"cleanliness"]) + if not String(row.get(&"cleaning_status", "")).is_empty(): + facility_status_label.text += "\n" + String(row[&"cleaning_status"]) if can_price: var mode := StringName(draft.get(&"price_mode", &"standard")) - var quoted := FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode}) - var unit := String({&"standard_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回")) + var quoted := FacilityPricing.apply_multiplier(FacilityPricing.quote(int(row.get(&"base_price", 0)), {&"price_mode": mode}) * int(row.get(&"donation_multiplier", 1)), int(row.get(&"floor_price_multiplier_bp", 10000))) + var unit := String({&"standard_room": "泊", &"premium_room": "泊", &"cafeteria": "食"}.get(StringName(row.get(&"definition_id", &"")), "回")) quote_label.text = "設定後 %dG / %s\n現在 %dG · 標準 %dG" % [quoted, unit, int(row.get(&"price", 0)), int(row.get(&"base_price", 0))] effect_label.text = _price_effect(mode) + if int(row.get(&"donation_multiplier", 1)) == 3: + effect_label.text += "\nアメリア配置:寄付金3倍" + if int(row.get(&"floor_price_multiplier_bp", 10000)) == 12000: + effect_label.text += "\nアンリ配置フロア:利用料金+20%" else: quote_label.text = "この施設に利用料金はありません。" effect_label.text = "営業・休業を設定できます。" if is_supply else "受付の開閉だけを設定できます。" - today_label.text = "本日の記帳 · 06:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌朝の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))] + if is_cleaning: effect_label.text = "清掃業務の有効・無効を設定できます。" + if int(row.get(&"faith_per_service", 0)) > 0: + effect_label.text += "\n礼拝完了ごとに信仰+%d" % int(row[&"faith_per_service"]) + today_label.text = "本日の記帳 · 00:00〜\n売上 %dG · 直接支出 %dG\n給与の配賦と利益は翌日の帳簿で確認。" % [int(row.get(&"today_revenue", 0)), int(row.get(&"today_expenses", 0))] _refreshing = false @@ -263,7 +287,7 @@ func _on_command_completed(result: Variant) -> void: refresh() -func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: +func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: if is_visible_in_tree(): refresh() @@ -344,7 +368,7 @@ func _facility_row(id: StringName) -> Dictionary: func _set_result(message: String, success: bool) -> void: result_label.text = message - result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e")) + LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self)) status_requested.emit(message, success) @@ -357,6 +381,7 @@ func _label(value: String, node_name := "") -> Label: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.add_theme_font_size_override("font_size", 13) label.add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(label, LayerSkin.active_layer(self)) return label diff --git a/scripts/ui/defense_management_panel.gd b/scripts/ui/defense_management_panel.gd new file mode 100644 index 0000000..cbe697d --- /dev/null +++ b/scripts/ui/defense_management_panel.gd @@ -0,0 +1,418 @@ +class_name DefenseManagementPanel +extends VBoxContainer + +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + +## Player controls for an incident that already exists. Threat creation, +## combat balancing, skill authoring and automatic combat belong to Runtime. +signal status_requested(message: String, success: bool) + +const KEY_PREFIX := "ui.defense." +const COMMANDS := [&"register_defense_combatant", &"register_defense_asset", &"start_defense_raid", &"set_defense_retreat_policy", &"begin_next_defense_wave", &"retreat_defenders"] +const STATE_NAMES := ["準備中", "戦闘中", "次の襲撃待ち", "撃退", "突破された", "撤退", "準備終了"] +const SOURCE_NAMES := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"} +const POLICIES := [&"HOLD", &"MANUAL", &"THRESHOLD", &"PROTECT_AND_RETREAT"] +const POLICY_LABELS := ["持ち場を守る", "自分で撤退を指示", "体力が減ったら撤退", "体力・施設の損傷で撤退"] + +var selected_raid_id: StringName = &"" +var selected_staff_id: StringName = &"" +var selected_objective_id: StringName = &"" +var raid_option: OptionButton +var staff_option: OptionButton +var objective_option: OptionButton +var policy_option: OptionButton +var threshold_spin: SpinBox +var summary_label: Label +var defenders_label: Label +var assets_label: Label +var staff_label: Label +var objective_label: Label +var result_label: Label +var register_staff_button: Button +var register_objective_button: Button +var start_button: Button +var policy_button: Button +var next_wave_button: Button +var retreat_button: Button +var confirmation: ConfirmationDialog + +var _runtime: Variant +var _style_button: Callable +var _report: Dictionary = {} +var _raid_labels: Dictionary = {} +var _pending: Dictionary = {} +var _queue_instance_id := 0 +var _sequence := 1 +var _built := false +var _confirmation_context: Dictionary = {} +var _policy_drafts: Dictionary = {} +var _refreshing := false + + +func configure(runtime: Variant, style_button := Callable()) -> void: + _runtime = runtime + _style_button = style_button + + +func _ready() -> void: + name = "DefenseManagementPanel" + size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 6) + _build_interface() + _built = true + if _runtime != null: + if _runtime.has_signal(&"state_updated"): + _runtime.connect(&"state_updated", _on_snapshot_published) + elif _runtime.has_signal(&"snapshot_published"): + _runtime.connect(&"snapshot_published", _on_snapshot_published) + if _runtime.has_signal(&"command_completed"): + _runtime.connect(&"command_completed", _on_command_completed) + refresh() + + +func _build_interface() -> void: + raid_option = _option("DefenseRaidOption") + raid_option.fit_to_longest_item = false + raid_option.clip_text = true + raid_option.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + raid_option.item_selected.connect(func(index: int) -> void: select_raid(StringName(raid_option.get_item_metadata(index)))) + add_child(raid_option) + summary_label = _label("", "DefenseSummary") + add_child(summary_label) + defenders_label = _label("", "DefenseParticipants") + add_child(defenders_label) + assets_label = _label("", "DefenseAssets") + add_child(assets_label) + add_child(HSeparator.new()) + add_child(_label("参加する従業員")) + staff_option = _option("DefenseStaffOption") + staff_option.item_selected.connect(func(index: int) -> void: + selected_staff_id = StringName(staff_option.get_item_metadata(index)) + _refresh_controls()) + add_child(staff_option) + staff_label = _label("", "DefenseStaffStatus") + add_child(staff_label) + register_staff_button = _button("防衛に参加", "DefenseRegisterStaff", _register_staff) + add_child(register_staff_button) + add_child(_label("防衛する施設")) + objective_option = _option("DefenseObjectiveOption") + objective_option.item_selected.connect(func(index: int) -> void: + selected_objective_id = StringName(objective_option.get_item_metadata(index)) + _refresh_controls()) + add_child(objective_option) + objective_label = _label("", "DefenseObjectiveStatus") + add_child(objective_label) + register_objective_button = _button("防衛対象に登録", "DefenseRegisterObjective", _register_objective) + add_child(register_objective_button) + add_child(HSeparator.new()) + add_child(_label("撤退の方針")) + policy_option = _option("DefenseRetreatPolicy") + for i: int in POLICIES.size(): + policy_option.add_item(POLICY_LABELS[i]) + policy_option.set_item_metadata(i, POLICIES[i]) + policy_option.item_selected.connect(func(_index: int) -> void: _store_policy_draft()) + add_child(policy_option) + var threshold_row := HBoxContainer.new() + threshold_row.add_child(_label("撤退する残量(%)")) + threshold_spin = SpinBox.new() + threshold_spin.name = "DefenseRetreatThreshold" + threshold_spin.min_value = 0 + threshold_spin.max_value = 100 + threshold_spin.step = 1 + threshold_spin.value_changed.connect(func(_value: float) -> void: _store_policy_draft()) + threshold_row.add_child(threshold_spin) + add_child(threshold_row) + policy_button = _button("撤退方針を適用", "DefenseApplyPolicy", _apply_policy) + add_child(policy_button) + var actions := HBoxContainer.new() + start_button = _button("防衛を開始", "DefenseStart", func() -> void: _submit(&"start_defense_raid")) + next_wave_button = _button("次の襲撃に対応", "DefenseNextWave", func() -> void: _submit(&"begin_next_defense_wave")) + retreat_button = _button("撤退する", "DefenseRetreat", _request_retreat_confirmation) + for button: Button in [start_button, next_wave_button, retreat_button]: + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + actions.add_child(button) + add_child(actions) + result_label = _label("", "DefenseResult") + add_child(result_label) + confirmation = ConfirmationDialog.new() + confirmation.name = "DefenseConfirmation" + confirmation.ok_button_text = "実行する" + confirmation.cancel_button_text = "戻る" + confirmation.confirmed.connect(_confirm_operation) + confirmation.canceled.connect(func() -> void: _confirmation_context.clear()) + add_child(confirmation) + confirmation.get_label().add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(confirmation.get_label(), LayerSkin.active_layer(self)) + if _style_button.is_valid(): + for button: Button in [confirmation.get_ok_button(), confirmation.get_cancel_button()]: + _style_button.call(button) + + +func select_raid(raid_id: StringName) -> void: + selected_raid_id = raid_id + selected_staff_id = &"" + selected_objective_id = &"" + _confirmation_context.clear() + if confirmation != null: confirmation.hide() + refresh() + + +func refresh() -> void: + if not _built: + return + var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null + if queue != null and queue.get_instance_id() != _queue_instance_id: + _queue_instance_id = queue.get_instance_id() + _policy_drafts.clear() + _confirmation_context.clear() + confirmation.hide() + for option: OptionButton in [raid_option, staff_option, objective_option, policy_option]: + option.get_popup().hide() + _synchronize_commands(queue) + _report = _runtime.call(&"get_defense_management_report", selected_raid_id) if _runtime != null and _runtime.has_method(&"get_defense_management_report") else {} + _raid_labels = _build_raid_labels(_report.get(&"raids", [])) + selected_raid_id = StringName((_report.get(&"selected", {}) as Dictionary).get(&"raid_id", &"")) + selected_raid_id = _fill_option(raid_option, _report.get(&"raids", []), &"raid_id", selected_raid_id) + selected_staff_id = _fill_option(staff_option, _report.get(&"staff", []), &"staff_id", selected_staff_id) + selected_objective_id = _fill_option(objective_option, _report.get(&"objectives", []), &"facility_id", selected_objective_id) + var row: Dictionary = _report.get(&"selected", {}) + var draft: Dictionary = _policy_drafts.get(selected_raid_id, row) + var policy := StringName(draft.get(&"retreat_policy", &"HOLD")) + _refreshing = true + policy_option.select(maxi(POLICIES.find(policy), 0)) + threshold_spin.set_value_no_signal(int(draft.get(&"defender_retreat_threshold", 0))) + _refreshing = false + summary_label.text = "進行中・保存済みの防衛戦はありません。" if row.is_empty() else "%s · %s\n襲撃 %d / %d · 攻防 %d回\n%s" % [String(_raid_labels.get(selected_raid_id, _raid_base_label(row))), STATE_NAMES[int(row[&"state"])], int(row[&"current_wave"]), int(row[&"wave_count"]), int(row[&"current_round"]), "防衛対象が未登録です。" if not (row[&"missing_objectives"] as Array).is_empty() else "防衛対象は登録済みです。"] + var names: Array[String] = [] + for defender: Dictionary in _report.get(&"defenders", []): + var employee := _row(&"staff", &"staff_id", defender[&"actor_id"]) + names.append("%s 体力%d/%d・戦闘%d" % [employee.get(&"display_name", defender[&"actor_id"]), defender[&"health"], defender[&"max_health"], defender[&"combat"]]) + defenders_label.text = "参加者:" + ("、".join(names) if not names.is_empty() else "未登録") + names.clear() + for asset: Dictionary in _report.get(&"assets", []): + var facility := _row(&"objectives", &"facility_id", asset[&"asset_id"]) + names.append("%s 耐久%d/%d" % [facility.get(&"display_name", asset[&"asset_id"]), asset[&"integrity"], asset[&"max_integrity"]]) + assets_label.text = "対象:" + ("、".join(names) if not names.is_empty() else "未登録") + _refresh_controls() + + +func _refresh_controls() -> void: + var row: Dictionary = _report.get(&"selected", {}) + var state := int(row.get(&"state", -1)) + var pending := _has_pending(selected_raid_id) + var blocked := row.is_empty() or pending or not bool(_report.get(&"can_control", false)) + var staff := _row(&"staff", &"staff_id", selected_staff_id) + var objective := _row(&"objectives", &"facility_id", selected_objective_id) + staff_label.text = "従業員がいません。" if staff.is_empty() else "体力%d/100・戦闘%d\n%s" % [staff[&"health"], staff[&"combat"], "参加できます。参加中は配属を変更できません。" if staff[&"can_register"] else staff[&"reason"]] + objective_label.text = "防衛対象がありません。" if objective.is_empty() else "耐久%d/10000\n%s" % [objective[&"durability"], "登録できます。" if objective[&"can_register"] else objective[&"reason"]] + register_staff_button.disabled = blocked or staff.is_empty() or not bool(staff.get(&"can_register", false)) + register_objective_button.disabled = blocked or objective.is_empty() or not bool(objective.get(&"can_register", false)) + start_button.visible = state == DefenseService.RaidState.PREPARING + start_button.disabled = blocked or not (row.get(&"missing_objectives", []) as Array).is_empty() + next_wave_button.visible = state == DefenseService.RaidState.BETWEEN_WAVES + next_wave_button.disabled = blocked + retreat_button.visible = state in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES] + retreat_button.disabled = blocked + var terminal := state in DefenseService.TERMINAL_STATES or state < 0 + policy_option.disabled = blocked or terminal + var chosen_policy := StringName(policy_option.get_item_metadata(policy_option.selected)) + threshold_spin.editable = not blocked and not terminal and chosen_policy in [&"THRESHOLD", &"PROTECT_AND_RETREAT"] + policy_button.disabled = blocked or terminal + staff_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or staff.is_empty() + objective_option.disabled = blocked or state != DefenseService.RaidState.PREPARING or objective.is_empty() + if pending: result_label.text = "指示を受理しました。次の時間更新で反映します。" + + +func _register_staff() -> void: + var staff := _row(&"staff", &"staff_id", selected_staff_id) + if staff.is_empty() or not bool(staff.get(&"can_register", false)): return + _submit(&"register_defense_combatant", {&"actor_id": selected_staff_id, &"side": &"DEFENDER", &"combat": int(staff[&"combat"])}) + + +func _register_objective() -> void: + var facility := _row(&"objectives", &"facility_id", selected_objective_id) + if facility.is_empty() or not bool(facility.get(&"can_register", false)): return + # Plain objectives have no defense-device bonus. Omit numeric overrides and + # use the existing Runtime command defaults and current live durability. + _submit(&"register_defense_asset", {&"asset_id": selected_objective_id, &"kind": &"OBJECTIVE"}) + + +func _apply_policy() -> void: + _submit(&"set_defense_retreat_policy", {&"policy": policy_option.get_item_metadata(policy_option.selected), &"threshold": int(threshold_spin.value)}) + + +func _store_policy_draft() -> void: + if _refreshing or selected_raid_id.is_empty() or _has_pending(selected_raid_id): return + _policy_drafts[selected_raid_id] = {&"retreat_policy": policy_option.get_item_metadata(policy_option.selected), &"defender_retreat_threshold": int(threshold_spin.value)} + _refresh_controls() + + +func _request_retreat_confirmation() -> void: + refresh() + var row: Dictionary = _report.get(&"selected", {}) + if _has_pending(selected_raid_id) or selected_raid_id.is_empty() or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: return + _confirmation_context = {&"raid_id": selected_raid_id, &"queue_instance_id": _queue_instance_id} + confirmation.title = "撤退の確認" + confirmation.dialog_text = "撤退すると防衛戦は終了します。参加者の状態と施設の損傷が反映されます。" + confirmation.popup_centered(Vector2i(430, 170)) + + +func _confirm_operation() -> void: + var context := _confirmation_context.duplicate() + _confirmation_context.clear() + var queue: Variant = _runtime.get(&"command_queue") if _runtime != null else null + if queue == null or queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)) or context.get(&"raid_id", &"") != selected_raid_id: + _set_result("対象が変わりました。防衛戦を選び直してください。", false) + refresh() + return + refresh() + if context.get(&"raid_id", &"") != selected_raid_id: + _set_result("対象が変わりました。防衛戦を選び直してください。", false) + return + var row: Dictionary = _report.get(&"selected", {}) + if _has_pending(selected_raid_id) or not bool(_report.get(&"can_control", false)) or int(row.get(&"state", -1)) not in [DefenseService.RaidState.ACTIVE, DefenseService.RaidState.BETWEEN_WAVES]: + _set_result("現在の防衛戦では撤退を指示できません。", false) + return + _submit(&"retreat_defenders") + + +func _submit(command_type: StringName, patch: Dictionary = {}) -> void: + if selected_raid_id.is_empty() or _has_pending(selected_raid_id) or command_type not in COMMANDS: return + var payload := patch.duplicate(true) + payload[&"raid_id"] = selected_raid_id + var key := StringName("%s%08d" % [KEY_PREFIX, _sequence]) + _sequence += 1 + var result: Variant = _runtime.call(&"submit_command", command_type, payload, &"player", selected_raid_id, 75, -1, key) + if result == null or not result.has_method(&"is_success"): + _set_result("防衛の指示に応答がありません。", false) + return + var queue: Variant = _runtime.get(&"command_queue") + var queued := false + for command: Dictionary in queue.create_snapshot().get("pending", []): + queued = queued or int(command.get("command_id", 0)) == int(result.command_id) + if result.is_success() and queued: + _pending[int(result.command_id)] = payload + else: + _show_result(result, payload) + refresh() + + +func _show_result(result: Variant, payload: Dictionary) -> void: + if result.is_success() and payload.has(&"policy"): + _policy_drafts.erase(StringName(payload.get(&"raid_id", &""))) + _set_result("防衛の指示を反映しました。" if result.is_success() else "指示を反映できませんでした。体調・業務中の状態・対象施設を確認してください。", result.is_success()) + + +func _synchronize_commands(queue: Variant) -> void: + _pending.clear() + for metadata: Dictionary in queue.get_accepted_command_metadata(): + var key := String(metadata.get("idempotency_key", "")) + if key.begins_with(KEY_PREFIX) and key.trim_prefix(KEY_PREFIX).is_valid_int(): + _sequence = maxi(_sequence, int(key.trim_prefix(KEY_PREFIX)) + 1) + for command: Dictionary in queue.create_snapshot().get("pending", []): + if StringName(command.get("command_type", &"")) in COMMANDS: + _pending[int(command.get("command_id", 0))] = command.get("payload", {}).duplicate(true) + + +func _on_command_completed(result: Variant) -> void: + if not _pending.has(int(result.command_id)): return + var payload: Dictionary = _pending[int(result.command_id)] + _pending.erase(int(result.command_id)) + _show_result(result, payload) + refresh() + + +func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: + if is_visible_in_tree(): refresh() + + +func _has_pending(raid_id: StringName) -> bool: + for payload: Dictionary in _pending.values(): + if payload.get(&"raid_id", &"") == raid_id: return true + return false + + +func _row(key: StringName, id_key: StringName, entity_id: StringName) -> Dictionary: + for row: Dictionary in _report.get(key, []): + if row.get(id_key, &"") == entity_id: return row + return {} + + +func _fill_option(option: OptionButton, rows: Array, id_key: StringName, selected: StringName) -> StringName: + if option.get_popup().visible: return selected + option.clear() + if rows.is_empty(): + option.add_item("対象なし") + option.set_item_metadata(0, &"") + return &"" + var index := 0 + for row: Dictionary in rows: + var id := StringName(row[id_key]) + var display_name := String(_raid_labels.get(id, _raid_base_label(row))) if id_key == &"raid_id" else String(row.get(&"display_name", id)) + option.add_item(display_name) + if id_key == &"raid_id": option.set_item_tooltip(option.item_count - 1, display_name) + option.set_item_metadata(option.item_count - 1, id) + if id == selected: index = option.item_count - 1 + option.select(index) + return StringName(option.get_item_metadata(index)) + + +## Cause labels are display-only. Never infer cause from an internal ID or +## source_id, and never use the label as an option's metadata or command target. +func _build_raid_labels(rows: Array) -> Dictionary: + var totals: Dictionary = {} + for row: Dictionary in rows: + var base := _raid_base_label(row) + totals[base] = int(totals.get(base, 0)) + 1 + var seen: Dictionary = {} + var labels: Dictionary = {} + for row: Dictionary in rows: + var base := _raid_base_label(row) + seen[base] = int(seen.get(base, 0)) + 1 + labels[StringName(row[&"raid_id"])] = base + ("(%d)" % int(seen[base]) if int(totals[base]) > 1 else "") + return labels + + +func _raid_base_label(row: Dictionary) -> String: + var cause := String(SOURCE_NAMES.get(String(row.get(&"source_kind", "")), "防衛戦")) + var raw_minute: Variant = row.get(&"created_at", null) + if typeof(raw_minute) != TYPE_INT or int(raw_minute) < 0: + return cause + " · 時刻不明" + var minute := int(raw_minute) + return "%s · %d日目 %02d:%02d" % [cause, minute / 1440 + 1, minute % 1440 / 60, minute % 60] + + +func _set_result(message: String, success: bool) -> void: + result_label.text = message + LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self)) + status_requested.emit(message, success) + + +func _label(text_value: String, node_name := "") -> Label: + var label := Label.new() + if not node_name.is_empty(): label.name = node_name + label.text = text_value + label.size_flags_horizontal = Control.SIZE_EXPAND_FILL + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_size_override("font_size", 13) + label.add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(label, LayerSkin.active_layer(self)) + return label + + +func _option(node_name: String) -> OptionButton: + var option := OptionButton.new() + option.name = node_name + option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + if _style_button.is_valid(): _style_button.call(option) + return option + + +func _button(text_value: String, node_name: String, callback: Callable) -> Button: + var button := Button.new() + button.name = node_name + button.text = text_value + button.custom_minimum_size.y = 30 + button.pressed.connect(callback) + if _style_button.is_valid(): _style_button.call(button) + return button diff --git a/scripts/ui/defense_management_panel.gd.uid b/scripts/ui/defense_management_panel.gd.uid new file mode 100644 index 0000000..506a1f8 --- /dev/null +++ b/scripts/ui/defense_management_panel.gd.uid @@ -0,0 +1 @@ +uid://s1oh611b0xrj diff --git a/scripts/ui/guest_observation_panel.gd b/scripts/ui/guest_observation_panel.gd new file mode 100644 index 0000000..8279c2f --- /dev/null +++ b/scripts/ui/guest_observation_panel.gd @@ -0,0 +1,149 @@ +class_name GuestObservationPanel +extends VBoxContainer + +const NEED_NAMES := {&"hunger": "食欲", &"sleep": "睡眠欲", &"leisure": "娯楽欲", &"intimacy": "性欲", &"hygiene": "衛生", &"medical": "救護"} +const STATES := ["到着", "入館", "行先を検討", "移動中", "待機中", "利用中", "退館中", "退館", "再検討", "待機終了", "中断", "避難中", "気絶"] +const REJECTION_NAMES := { + "MISSING_INPUTS": "食材・資材不足", "FACILITY_CLOSED": "閉店・稼働停止", "UNREACHABLE": "経路なし", + "CANNOT_PAY": "利用可能な資金が不足", "ETA_AFTER_EXIT": "出発予定に間に合わない", + "WAIT_HARD_LIMIT": "待ち時間が長すぎる", "QUALITY_TOO_LOW": "希望する品質に届かない", + "RISK_REFUSED": "危険度が高い", "ILLEGALITY_REFUSED": "違法性を敬遠", + "MISSING_PERMISSION": "利用資格なし", "FORBIDDEN_GUEST_TAG": "利用対象外", + "GUEST_TYPE_NOT_ALLOWED": "利用対象外", "ADULT_NOT_VERIFIED": "年齢条件を満たさない", + "CONTENT_FILTERED": "利用設定の対象外", "INVALID_NEED_MAP": "施設設定を確認できない", +} +var guests: OptionButton +var details: Label +var decision_label: Label +var priority_label: Label +var bars: Dictionary = {} +var selected_guest_id: StringName = &"" +var _runtime: Variant +var _ids: Array[StringName] = [] +var _elapsed := 0.0 + +func configure(runtime: Variant) -> void: + _runtime = runtime + +func _ready() -> void: + name = "GuestObservationPanel" + custom_minimum_size = Vector2(420, 340) + add_theme_constant_override("separation", 8) + guests = OptionButton.new() + guests.item_selected.connect(func(index: int) -> void: + selected_guest_id = StringName(guests.get_item_metadata(index)) + refresh()) + add_child(guests) + details = _label() + for need: StringName in GuestService.PRIMARY_NEEDS: + var row := HBoxContainer.new() + var title := Label.new() + title.text = NEED_NAMES[need] + title.custom_minimum_size.x = 85 + row.add_child(title) + var bar := ProgressBar.new() + bar.max_value = 100 + bar.size_flags_horizontal = Control.SIZE_EXPAND_FILL + row.add_child(bar) + bars[need] = bar + add_child(row) + priority_label = _label() + decision_label = _label() + _label().text = "欲求が高いほど、そのサービスを優先します。\n利用できる施設の中で欲求を優先し、同じ優先度なら距離・行列・料金などで比較します。\n表示は現在の欲求です。移動・利用の途中で目的地を勝手に変更しません。" + refresh() + +func _label() -> Label: + var label := Label.new() + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + add_child(label) + return label + +func _process(delta: float) -> void: + if not is_visible_in_tree() or not get_window().visible: + return + _elapsed += delta + if _elapsed >= 0.5: + _elapsed = 0 + refresh() + +func refresh() -> void: + if guests == null or _runtime == null: + return + var service: Variant = _runtime.get(&"guest_service") + if service == null: + return + var next: Array[StringName] = [] + for guest: GuestRuntime in service.get_guests(): next.append(guest.guest_id) + if next != _ids: + _ids = next + guests.clear() + for id: StringName in _ids: + guests.add_item(String(id)) + guests.set_item_metadata(guests.item_count-1, id) + if not _ids.has(selected_guest_id): + selected_guest_id = _ids[0] if not _ids.is_empty() else &"" + guests.disabled = _ids.is_empty() + if not _ids.is_empty(): guests.select(_ids.find(selected_guest_id)) + var report: Dictionary = service.get_observation(selected_guest_id) + for need: StringName in bars: + bars[need].value = report.get(&"needs", {}).get(need, 0) + if report.is_empty(): + details.text = "現在、来客はいません。" + priority_label.text = "" + decision_label.text = "" + return + var target := String(report[&"target_facility_id"]) + var facility: Variant = _runtime.facility_service.get_instance(StringName(target)) + if facility != null: + var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id) + if definition != null: target = definition.display_name + if target.is_empty(): target = "未選択" + details.text = "%s · 所持金 %dG · 忍耐 %d\n目的地: %s" % [STATES[int(report[&"state"])], report[&"wallet_gold"], report[&"patience"], target] + var names: PackedStringArray = [] + for need: StringName in report[&"primary_needs"]: names.append(NEED_NAMES[need]) + priority_label.text = "現在最も高い欲求: " + ("なし" if names.is_empty() else "・".join(names)) + decision_label.text = _decision_text(_runtime.get_guest_decision_record(selected_guest_id)) + + +func _decision_text(record: Dictionary) -> String: + if record.is_empty(): + return "直近の行先判断:記録なし(判断前、旧セーブ、または履歴の保存範囲外)" + var minute := int(record.get(&"absolute_minute", 0)) + var title := "直近の行先判断 · %d日目 %02d:%02d" % [minute / 1440 + 1, (minute % 1440) / 60, minute % 60] + if StringName(record.get(&"action", &"")) == &"LEAVE": + return title + "\n条件を満たして選べる施設がなく、退館を選びました。" + _rejection_text(record) + var id := StringName(record.get(&"facility_id", &"")) + var destination := String(id) + var facility: Variant = _runtime.facility_service.get_instance(id) + if facility != null: + var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id) + if definition != null: destination = definition.display_name + var needs: PackedStringArray = [] + for need: StringName in record.get(&"primary_needs", []): + needs.append("%s %d" % [NEED_NAMES.get(need, String(need)), int(record.get(&"needs", {}).get(need, 0))]) + return title + "\n選択:%s / 判断時の最大欲求:%s\n移動 %d分・待ち %d分・見込料金 %dG\n選択候補の欲求優先度 %d・比較評価 %d(判断時点)" % [destination, "・".join(needs) if not needs.is_empty() else "なし", int(record.get(&"travel_minutes", 0)), int(record.get(&"wait_minutes", 0)), int(record.get(&"estimated_total_gold", 0)), int(record.get(&"need_priority", 0)), int(record.get(&"utility", 0))] + _rejection_text(record) + + +func _rejection_text(record: Dictionary) -> String: + if not record.has(&"rejection_counts"): + return "\n候補の除外理由:この記録にはありません。" + var counts: Dictionary = record[&"rejection_counts"] + if counts.is_empty(): + return "\n今回検討した候補には、利用条件による除外はありません。" + var reasons: PackedStringArray = [] + var keys := counts.keys() + keys.sort() + for reason: String in keys: + reasons.append("%s %d施設" % [REJECTION_NAMES.get(reason, "条件を満たさない"), int(counts[reason])]) + var text := "\n判断時の除外理由:" + "・".join(reasons) + for row: Dictionary in record.get(&"rejection_examples", []): + var id := StringName(row.get(&"facility_id", &"")) + var facility: Variant = _runtime.facility_service.get_instance(id) + var label := String(id) + if facility != null: + var definition: Variant = _runtime.definition_registry.get_facility(facility.definition_id) + if definition != null: label = "%s(%s)" % [definition.display_name, id] + text += "\n・%s:%s" % [label, REJECTION_NAMES.get(String(row.get(&"reason", "")), "条件を満たさない")] + var omitted: int = int(record.get(&"rejection_count", 0)) - record.get(&"rejection_examples", []).size() + if omitted > 0: text += "\nほか%d施設(件数は上記に含みます)" % omitted + return text diff --git a/scripts/ui/guest_observation_panel.gd.uid b/scripts/ui/guest_observation_panel.gd.uid new file mode 100644 index 0000000..f163d6f --- /dev/null +++ b/scripts/ui/guest_observation_panel.gd.uid @@ -0,0 +1 @@ +uid://diaqemp3k1wj diff --git a/scripts/ui/inactive_layer_rooms.gd b/scripts/ui/inactive_layer_rooms.gd new file mode 100644 index 0000000..fba1f81 --- /dev/null +++ b/scripts/ui/inactive_layer_rooms.gd @@ -0,0 +1,18 @@ +extends Node2D + +# Reuse the same room art at low opacity; this layer has no input handlers. +const PixelArt := preload("res://scripts/ui/pixel_tower_art.gd") +var rooms: Array[Dictionary] = [] +var pixel_scale := 1.0 +var dungeon: Texture2D +var town: Texture2D +func _ready() -> void: + modulate = Color(1,1,1,0.22) + texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST +func _draw() -> void: + for room: Dictionary in rooms: + if bool(room.get(&"building", false)): continue + if bool(room.get(&"legacy_stair", false)): + PixelArt.stairs(self,room[&"rect"],room[&"rect"].size.x/14.0,true,true) + continue + PixelArt.room(self,room[&"rect"],room[&"definition_id"],pixel_scale,dungeon,town,true,room[&"origin_x"],{}) diff --git a/scripts/ui/inactive_layer_rooms.gd.uid b/scripts/ui/inactive_layer_rooms.gd.uid new file mode 100644 index 0000000..85bd91c --- /dev/null +++ b/scripts/ui/inactive_layer_rooms.gd.uid @@ -0,0 +1 @@ +uid://cn241cup6ht38 diff --git a/scripts/ui/inn_opening_guide.gd b/scripts/ui/inn_opening_guide.gd index fcfb0cb..26325aa 100644 --- a/scripts/ui/inn_opening_guide.gd +++ b/scripts/ui/inn_opening_guide.gd @@ -1,6 +1,8 @@ class_name InnOpeningGuide extends PanelContainer +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + ## Read-only guidance. Every action goes back through the normal dashboard. signal action_requested(action: StringName) @@ -15,7 +17,7 @@ var _sale_economy_id := 0 var _first_sale_minute := -1 -func setup(runtime: Variant, clock: Variant) -> void: +func setup(runtime: Variant, clock: Variant, layer := 0) -> void: _runtime = runtime _clock = clock name = "InnOpeningGuide" @@ -25,6 +27,7 @@ func setup(runtime: Variant, clock: Variant) -> void: skin.border_color = Color("b58a45") skin.set_border_width_all(1) skin.set_content_margin_all(8) + LayerSkin.register_style(skin, &"panel_leather", layer) add_theme_stylebox_override("panel", skin) var column := VBoxContainer.new() column.add_theme_constant_override("separation", 5) @@ -32,7 +35,8 @@ func setup(runtime: Variant, clock: Variant) -> void: _heading = Button.new() _heading.alignment = HORIZONTAL_ALIGNMENT_LEFT _heading.add_theme_font_size_override("font_size", 14) - _heading.pressed.connect(func() -> void: _body.visible = not _body.visible) + _heading.pressed.connect(_toggle_body) + _style_button(_heading, layer) column.add_child(_heading) _body = VBoxContainer.new() column.add_child(_body) @@ -41,14 +45,33 @@ func setup(runtime: Variant, clock: Variant) -> void: _detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART _detail.add_theme_font_size_override("font_size", 13) _detail.add_theme_color_override("font_color", Color("fff0cf")) + LayerSkin.register_text(_detail, layer) _body.add_child(_detail) _action = Button.new() _action.add_theme_font_size_override("font_size", 13) + _style_button(_action, layer) _action.pressed.connect(func() -> void: action_requested.emit(StringName(_progress.get(&"action", &"")))) _body.add_child(_action) refresh() +func _toggle_body() -> void: + _body.visible = not _body.visible + reset_size.call_deferred() + + +func _style_button(button: Button, layer: int) -> void: + button.add_theme_color_override("font_color", Color("fff0cf")) + for state_name: StringName in [&"normal", &"hover", &"pressed", &"focus", &"disabled"]: + var style := StyleBoxFlat.new() + style.bg_color = Color("30261b") if state_name == &"normal" else Color("51402a") + style.border_color = Color("b58a45") + style.set_border_width_all(1) + style.set_content_margin_all(4) + LayerSkin.register_style(style, StringName("button_"+String(state_name if state_name != &"focus" else &"pressed")), layer) + button.add_theme_stylebox_override(state_name, style) + + func refresh() -> void: if _runtime == null or _heading == null: return @@ -70,14 +93,14 @@ func _read_progress() -> Dictionary: if first_sale >= 0 and _runtime.operations_coordinator != null: for summary: Dictionary in _runtime.operations_coordinator.get_day_summaries(): if int(summary.get(&"closed_at", -1)) > first_sale: - return _step(&"complete", "初日の営業を達成", "宿泊売上と翌朝の決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る") + return _step(&"complete", "初日の営業を達成", "宿泊売上と日次決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る") var reception: FacilityInstance var rooms: Array[FacilityInstance] = [] for facility: FacilityInstance in _runtime.facility_service.get_instances(): if facility.definition_id == &"reception": if reception == null or _reception_score(facility) > _reception_score(reception): reception = facility - elif facility.definition_id == &"standard_room": + elif _is_guest_lodging(facility.definition_id): rooms.append(facility) if reception == null: return _build_step(&"reception", &"reception", "1/6 受付をつくる", "表の地表階に受付を配置します。完成した受付の上に客室を増築できます。", &"build_reception") @@ -95,7 +118,7 @@ func _read_progress() -> Dictionary: return _step(&"staff", "4/6 受付係を配属", "従業員から受付係を雇い、雇用済み一覧でこの受付と勤務帯を選び「配属・勤務帯を変更」を押します。", &"staff", "従業員を開く") if first_sale < 0: return _step(&"sale", "5/6 最初の宿泊売上", "時間を進めて、お客さまの到着と宿泊を見守りましょう。売上は現在のサービスが完了すると記帳されます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る") - return _step(&"close", "6/6 翌朝の決算へ", "最初の宿泊売上が入りました。06:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る") + return _step(&"close", "6/6 深夜の決算へ", "最初の宿泊売上が入りました。00:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る") func _can_reach_facility(facility: FacilityInstance) -> bool: @@ -103,6 +126,8 @@ func _can_reach_facility(facility: FacilityInstance) -> bool: return false # Runtime uses the same public entrance and access cells as real guests. # Component-only guide fixtures retain their explicit availability flags. + if _runtime.has_method(&"is_facility_reachable"): + return bool(_runtime.call(&"is_facility_reachable", facility.instance_id)) if _runtime.has_method(&"get_facility_access_report"): var report: Dictionary = _runtime.call(&"get_facility_access_report", facility.instance_id) return bool(report.get(&"reachable", false)) @@ -114,18 +139,37 @@ func _reception_score(facility: FacilityInstance) -> int: func _has_reception_assignment(reception: FacilityInstance) -> bool: - var assigned := not reception.active_staff_ids.is_empty() - # A repeating plan remains valid off-duty. Historical or cancelled shifts - # cannot conceal a deliberate unassignment made before the first sale. + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(reception.definition_id) + if definition == null: + return false + var unplanned_staff: Dictionary = {} + # Repeating plans remain valid off-duty; explicit plans take precedence + # over historical shifts. Management placement is not receptionist work. for staff: StaffRuntime in _runtime.staff_service.get_staff_members(): + if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles: + continue + if staff.staff_id in reception.active_staff_ids: + return true var plan: Dictionary = _runtime.staff_service.get_work_assignment(staff.staff_id) if not plan.is_empty(): - assigned = assigned or StringName(plan.get(&"facility_id", &"")) == reception.instance_id - continue - for shift: ShiftInstance in _runtime.staff_service.get_shifts(): - if shift.staff_id == staff.staff_id and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id: - assigned = true - return assigned + if StringName(plan.get(&"facility_id", &"")) == reception.instance_id: + return true + else: + unplanned_staff[staff.staff_id] = true + if unplanned_staff.is_empty(): + return false + # Enumerate/sort the historical shifts once, not once per employee. + for shift: ShiftInstance in _runtime.staff_service.get_shifts(): + if unplanned_staff.has(shift.staff_id) and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id: + return true + return false + + +func _is_guest_lodging(definition_id: StringName) -> bool: + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id) + return definition != null and definition.layer == FacilityDefinition.ViewLayer.FRONT and ( + definition.service_types.has(&"lodging") or definition.tags.has(&"lodging") + ) func _first_lodging_sale() -> int: @@ -139,10 +183,10 @@ func _first_lodging_sale() -> int: return _first_sale_minute var room_ids: Dictionary = {} for job: ConstructionJob in _runtime.construction_service.get_jobs(): - if job.definition_id == &"standard_room" and not job.facility_instance_id.is_empty(): + if _is_guest_lodging(job.definition_id) and not job.facility_instance_id.is_empty(): room_ids[job.facility_instance_id] = true for facility: FacilityInstance in _runtime.facility_service.get_instances(): - if facility.definition_id == &"standard_room": + if _is_guest_lodging(facility.definition_id): room_ids[facility.instance_id] = true if room_ids.is_empty(): return -1 @@ -154,7 +198,7 @@ func _first_lodging_sale() -> int: func _build_step(stage: StringName, definition_id: StringName, title: String, detail: String, action: StringName) -> Dictionary: for job: ConstructionJob in _runtime.construction_service.get_jobs(): - if job.definition_id == definition_id and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: + if (job.definition_id == definition_id or (stage == &"room" and _is_guest_lodging(job.definition_id))) and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: return _step(stage, title, "工事中 %d%%。時間を進めると完成します。" % (job.progress / 100), &"resume" if _clock.paused else &"", "時間を進める") var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id) if definition != null: diff --git a/scripts/ui/layer_interface_skin.gd b/scripts/ui/layer_interface_skin.gd new file mode 100644 index 0000000..cbf2c52 --- /dev/null +++ b/scripts/ui/layer_interface_skin.gd @@ -0,0 +1,59 @@ +class_name LayerInterfaceSkin +extends RefCounted +## Visual-only palette for the persistent HUD. No simulation state is owned here. +const STYLE_META := &"layer_interface_front_style" +const TEXT_META := &"layer_interface_front_text" +const BACK_TEXT_META := &"layer_interface_back_text" +const BACK_PANELS := { + &"panel_outer": Color("10191e"), &"panel_wood": Color("1a282e"), + &"panel_leather": Color("172229"), &"panel_parchment": Color("202e34"), + &"button_normal": Color("293c43"), &"button_hover": Color("385860"), + &"button_pressed": Color("275954"), &"button_disabled": Color("293135"), +} +static func register_style(style: StyleBoxFlat, role: StringName, layer: int) -> void: + style.set_meta(STYLE_META, {&"role":role, &"background":style.bg_color, &"border":style.border_color}) + apply_style(style, layer) +static func apply_style(style: StyleBoxFlat, layer: int) -> void: + if not style.has_meta(STYLE_META): return + var front: Dictionary = style.get_meta(STYLE_META) + var background: Color = BACK_PANELS.get(front[&"role"], Color("202e34")) if layer == 1 else front[&"background"] + var border: Color = Color("73989a") if layer == 1 else front[&"border"] + if style.bg_color != background: style.bg_color = background + if style.border_color != border: style.border_color = border +static func register_text(label: Label, layer: int) -> void: + if not label.has_meta(TEXT_META): label.set_meta(TEXT_META, label.get_theme_color("font_color")) + var color: Color = label.get_meta(BACK_TEXT_META, Color("d8eeea")) if layer == 1 else label.get_meta(TEXT_META) + if label.get_theme_color("font_color") != color: label.add_theme_color_override("font_color", color) +static func register_status_text(label: Label, success: bool, layer: int) -> void: + label.set_meta(TEXT_META, Color("397346") if success else Color("a7402e")) + label.set_meta(BACK_TEXT_META, Color("b5e7ba") if success else Color("ffb498")) + register_text(label, layer) +static func active_layer(node: Node) -> int: + if not node.is_inside_tree(): return 0 + var state := node.get_node_or_null("/root/GameState") + return int(state.get("active_layer")) if state != null else 0 +static func register_toggle_text(button: BaseButton, layer: int) -> void: + const META := &"layer_interface_toggle_text" + if not button.has_meta(META): + var colors := {} + for key: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color", &"font_disabled_color"]: + colors[key] = button.get_theme_color(key) + button.set_meta(META, colors) + var front: Dictionary = button.get_meta(META) + for key: StringName in front: + var color: Color = front[key] if layer == 0 else (Color("8da4a4") if key == &"font_disabled_color" else Color("d8eeea")) + if button.get_theme_color(key) != color: button.add_theme_color_override(key, color) +static func apply_tree(node: Node, layer: int) -> void: + if node is Window and node.theme != null: + for entry: Array in [[&"panel", &"AcceptDialog"], [&"embedded_border", &"Window"]]: + if node.theme.has_stylebox(entry[0], entry[1]): + var window_style: StyleBox = node.theme.get_stylebox(entry[0], entry[1]) + if window_style is StyleBoxFlat: apply_style(window_style, layer) + if node is Control: + for key: StringName in [&"panel", &"normal", &"hover", &"pressed", &"focus", &"disabled"]: + if node.has_theme_stylebox_override(key): + var style: StyleBox = node.get_theme_stylebox(key) + if style is StyleBoxFlat: apply_style(style, layer) + if node is Label and node.has_meta(TEXT_META): register_text(node, layer) + if node is BaseButton and node.has_meta(&"layer_interface_toggle_text"): register_toggle_text(node, layer) + for child: Node in node.get_children(): apply_tree(child, layer) diff --git a/scripts/ui/layer_interface_skin.gd.uid b/scripts/ui/layer_interface_skin.gd.uid new file mode 100644 index 0000000..2c07818 --- /dev/null +++ b/scripts/ui/layer_interface_skin.gd.uid @@ -0,0 +1 @@ +uid://bl3cvg8q4ykt7 diff --git a/scripts/ui/operating_ledger_panel.gd b/scripts/ui/operating_ledger_panel.gd index 6a72654..cc3d9bf 100644 --- a/scripts/ui/operating_ledger_panel.gd +++ b/scripts/ui/operating_ledger_panel.gd @@ -1,7 +1,10 @@ class_name OperatingLedgerPanel extends VBoxContainer +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + ## Projection only: authoritative close and ledger entries own all amounts. +var _initial_layer := 0 var _runtime: Variant var _clock: Variant var _cash_label: Label @@ -12,18 +15,21 @@ var _selected_day := -1 var _picker_days: Array[int] = [] -func setup(runtime: Variant, clock: Variant) -> void: +func setup(runtime: Variant, clock: Variant, style_button := Callable(), layer := 0) -> void: + _initial_layer = layer _runtime = runtime _clock = clock name = "OperatingLedgerPanel" add_theme_constant_override("separation", 8) var heading := Label.new() - heading.text = "帳簿 · 06:00締め" + heading.text = "帳簿 · 00:00締め" heading.add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(heading, _initial_layer) add_child(heading) _cash_label = _label() _day_picker = OptionButton.new() _day_picker.name = "LedgerDayPicker" + if style_button.is_valid(): style_button.call(_day_picker) _day_picker.item_selected.connect(func(index: int) -> void: _selected_day = _day_picker.get_item_id(index) refresh()) @@ -33,16 +39,26 @@ func setup(runtime: Variant, clock: Variant) -> void: refresh() +func select_latest_close() -> void: + _selected_day = -1 + _day_picker.get_popup().hide() + refresh() + + func get_report() -> Dictionary: if _runtime == null: return {} var minute: int = _clock.absolute_minute - var business_day := maxi(1, int(floor(float(minute - 360) / 1440.0)) + 1) - var start := (business_day - 1) * 1440 + 360 + var business_day := minute / 1440 + 1 + var start := (business_day - 1) * 1440 + if _runtime.operations_coordinator != null: + business_day = _runtime.operations_coordinator.get_open_financial_day() + start = _runtime.operations_coordinator.get_financial_period_start(business_day) var inflow := 0 var outflow := 0 var construction_outflow := 0 var wage_outflow := 0 + var upkeep_outflow := 0 for entry: LedgerEntry in _runtime.economy_service.get_entries(): if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION": continue @@ -52,6 +68,8 @@ func get_report() -> Dictionary: outflow += entry.amount if entry.category == &"CONSTRUCTION": construction_outflow += entry.amount + elif entry.category == &"MAINTENANCE_PAYMENT": + upkeep_outflow += entry.amount elif entry.category == &"WAGE": wage_outflow += entry.amount var summaries: Array[Dictionary] = [] @@ -68,6 +86,8 @@ func get_report() -> Dictionary: &"cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"), &"cash_in": inflow, &"cash_out": outflow, &"cash_net": inflow - outflow, &"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow, + &"upkeep_cash_out": upkeep_outflow, + &"upkeep_arrears": _runtime.upkeep_settlement.get_arrears_total(), &"closed_days": summaries, &"selected_close": selected.duplicate(true), } @@ -78,13 +98,13 @@ func refresh() -> void: if _day_picker.get_popup().visible: return var report := get_report() - _cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"cash_net"]] + _cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG / 維持費支払 %dG\n未払い維持費 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"upkeep_cash_out"], report[&"upkeep_arrears"], report[&"cash_net"]] var summary: Dictionary = report[&"selected_close"] if summary.is_empty(): if not _day_picker.disabled or _day_picker.item_count == 0: _day_picker.clear() _picker_days.clear() - _day_picker.add_item("決算は翌朝06:00に確定します") + _day_picker.add_item("決算は翌日00:00に確定します") _day_picker.disabled = true _closed_label.text = "建築後の手持ち資金だけで営業成績は決まりません。宿泊などの売上と日々の費用をここで確認できます。" _facility_label.text = "" @@ -105,6 +125,10 @@ func refresh() -> void: for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]: other += int(totals.get(key, 0)) _closed_label.text = "売上 %dG / 返金 %dG\n運営費(仕入れ・契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"] + var legacy: bool = _runtime.operations_coordinator.is_legacy_financial_history(int(summary[&"day"])) + _closed_label.text += "\n%s %d回%s" % ["旧版の連続未払い" if legacy else "連続赤字", int(summary.get(&"consecutive_insolvency_count", 0)), "" if legacy else "(3回でゲーム終了)"] + var close_minute := int(summary[&"closed_at"]) % 1440 + _closed_label.text += "\n確定時刻 %02d:%02d" % [close_minute / 60, close_minute % 60] var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"] var rows: Dictionary = summary.get(&"facility_profit_loss", {}) var ids := rows.keys() @@ -115,6 +139,14 @@ func refresh() -> void: if facility != null: var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility.definition_id) label = "%s (%d,%d)" % [definition.display_name if definition != null else facility.definition_id, facility.origin.x, facility.origin.y] + else: + # Corridors and stairs are completed construction entities without + # service instances, but their ownership upkeep still appears here. + var completed: Dictionary = _runtime.construction_service.get_facility(StringName(id)) + if not completed.is_empty(): + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(completed[&"definition_id"]) + var origin: Vector3i = completed[&"origin"] + label = "%s (%d,%d)" % [definition.display_name if definition != null else completed[&"definition_id"], origin.x, origin.y] var row: Dictionary = rows[id] lines.append("%s\n%dG / %dG / %+dG" % [label, row.get(&"revenue", 0), row.get(&"expenses", 0), row.get(&"net_profit", 0)]) _facility_label.text = "\n".join(lines) if not rows.is_empty() else "施設別の記帳はまだありません。" @@ -124,6 +156,7 @@ func _label() -> Label: var label := Label.new() label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(label, _initial_layer) label.add_theme_font_size_override("font_size", 13) add_child(label) return label diff --git a/scripts/ui/pixel_people.gd b/scripts/ui/pixel_people.gd new file mode 100644 index 0000000..8473026 --- /dev/null +++ b/scripts/ui/pixel_people.gd @@ -0,0 +1,75 @@ +class_name PixelPeople +extends RefCounted + +## Authored side-view poses on a 16x18 grid. No neighbouring atlas characters +## are used as animation frames. All motion is presentation-only. +const COATS := [Color("688da0"), Color("b87659"), Color("a69a63"), Color("89789e"), Color("638d72")] +const SKINS := [Color("edc49d"), Color("c99371"), Color("a56f50")] +const INK := Color("303c40") + +static func pose(mode: StringName, frame: int, variant: int, staff: bool) -> Array[Dictionary]: + var marks: Array[Dictionary] = [] + var skin: Color = SKINS[posmod(variant, SKINS.size())] + var coat: Color = Color("719d94") if staff else COATS[posmod(variant, COATS.size())] + var phase := posmod(frame, 8) + var walk := mode == &"walk" or mode == &"carry_walk" + var work := mode in [&"work", &"clean", &"eat"] + var stride := [0, 1, 2, 1, 0, -1, -2, -1][phase] as int + var bob := -1 if walk and phase in [2, 6] else 0 + if mode in [&"sleep", &"collapsed"]: + mark(marks, Rect2(2, 14, 12, 2), INK) + mark(marks, Rect2(3, 12, 4, 3), skin) + mark(marks, Rect2(2, 12, 2, 3), INK) + mark(marks, Rect2(7, 12, 6, 3), coat) + mark(marks, Rect2(13, 13, 2, 2), INK) + if mode == &"sleep": + mark(marks, Rect2(3 + phase / 3, 8 - phase / 3, 2, 1), Color("eee6c7")) + return marks + # Far arm and leg, then silhouette, face, near arm and leg. + mark(marks, Rect2(6, 8 + bob, 2, 5), coat.darkened(0.25)) + var far := -stride if walk else 0 + var near := stride if walk else 0 + mark(marks, Rect2(7 + far, 12 + bob, 2, 4 - bob), INK.lightened(0.12)) + mark(marks, Rect2(7 + far, 16, 3, 1), INK) + mark(marks, Rect2(6, 6 + bob, 5, 7), INK) + mark(marks, Rect2(7, 7 + bob, 4, 5), coat) + mark(marks, Rect2(7, 1 + bob, 4, 5), skin) + mark(marks, Rect2(6, 1 + bob, 4, 2), INK) + mark(marks, Rect2(6, 3 + bob, 1, 2), INK) + mark(marks, Rect2(11, 3 + bob, 1, 2), skin) + mark(marks, Rect2(10, 3 + bob, 1, 1), INK) + mark(marks, Rect2(8, 6 + bob, 2, 1), skin) + mark(marks, Rect2(8 + near, 12 + bob, 2, 4 - bob), INK) + mark(marks, Rect2(8 + near, 16, 3, 1), INK) + var arm := -stride if walk else 0 + if work: + arm = 2 if phase < 4 else 1 + mark(marks, Rect2(9 + arm, 8 + bob, 2, 3), coat.lightened(0.12)) + mark(marks, Rect2(9 + arm, 11 + bob, 2, 1), skin) + if staff: + mark(marks, Rect2(7, 9 + bob, 2, 3), Color("e3dfc2")) + if mode in [&"carry", &"carry_walk"]: + mark(marks, Rect2(10, 8 + bob, 5, 5), Color("956940")) + mark(marks, Rect2(10, 8 + bob, 5, 1), Color("d3ad6b")) + mark(marks, Rect2(12, 9 + bob, 1, 4), Color("b48850")) + elif mode == &"clean": + mark(marks, Rect2(11 + arm, 9, 1, 7), Color("af9059")) + mark(marks, Rect2(9 + arm, 16, 5, 1), Color("d8bc78")) + elif mode == &"eat": + mark(marks, Rect2(11, 6 if phase < 4 else 10, 3, 2), Color("f3ebcf")) + elif mode == &"work": + mark(marks, Rect2(11, 12, 4, 1), Color("efe7ce")) + mark(marks, Rect2(12 + phase % 2, 10, 1, 2), Color("805e44")) + elif mode == &"wait": + mark(marks, Rect2(9, 9, 3, 1), skin) + return marks + +static func mark(marks: Array[Dictionary], rect: Rect2, color: Color) -> void: + marks.append({&"rect": rect, &"color": color}) + +static func draw(canvas: CanvasItem, origin: Vector2, pixel: float, mode: StringName, frame: int, variant: int, staff: bool, facing_left: bool) -> void: + for item: Dictionary in pose(mode, frame, variant, staff): + var rect: Rect2 = item[&"rect"] + if facing_left: + rect.position.x = 16 - rect.end.x + canvas.draw_rect(Rect2(origin + rect.position * pixel, rect.size * pixel), item[&"color"]) diff --git a/scripts/ui/pixel_people.gd.uid b/scripts/ui/pixel_people.gd.uid new file mode 100644 index 0000000..265f4bf --- /dev/null +++ b/scripts/ui/pixel_people.gd.uid @@ -0,0 +1 @@ +uid://bvhvqan76tawa diff --git a/scripts/ui/pixel_tower_art.gd b/scripts/ui/pixel_tower_art.gd index 6e84ce9..eaa4e9c 100644 --- a/scripts/ui/pixel_tower_art.gd +++ b/scripts/ui/pixel_tower_art.gd @@ -3,6 +3,7 @@ extends RefCounted ## Small, code-native room furnishings. Coordinates use the same pixel size as ## Kenney's 16 px sprites. This renderer never changes simulation state. +const Layout := preload("res://scripts/ui/room_presentation.gd") const INK := Color("34413b") const WOOD := Color("8e6348") const WOOD_LIGHT := Color("c39665") @@ -15,7 +16,7 @@ static func tile(canvas: CanvasItem, texture: Texture2D, index: int, target: Rec canvas.draw_texture_rect_region(texture, target, Rect2((index % 12) * 16, (index / 12) * 16, 16, 16), tint) -static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0) -> void: +static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, dungeon: Texture2D, town: Texture2D, back: bool, decoration_seed := 0, presentation: Dictionary = {}) -> void: var p := pixel if id in [&"guest_stairs", &"service_stairs"]: stairs(canvas, rect, p, id == &"service_stairs") @@ -69,17 +70,14 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, counter(canvas, Vector2(mid-9*p, floor_y-6*p), 18*p, p) canvas.draw_rect(Rect2(mid-6*p, floor_y-8*p, 4*p, 2*p), CREAM) canvas.draw_rect(Rect2(mid+4*p, floor_y-8*p, 2*p, 2*p), Color("d6ad52")) - &"standard_room", &"adult_private_room": - bed(canvas, Vector2(rect.position.x+2*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13) - if w >= 28*p: - bed(canvas, Vector2(rect.end.x-15*p, floor_y-9*p), p, Color("709b94") if not dark else Color("a37b91"),13) + &"standard_room", &"premium_room", &"staff_dormitory", &"adult_private_room", &"infirmary": + for at: Vector2 in Layout.slots(rect, p, id): + bed(canvas, at + Vector2(-7, -9)*p, p, Color("709b94") if not dark else Color("a37b91"), 13) &"cafeteria", &"tavern": - for x in range(int(rect.position.x+5*p), int(rect.end.x-13*p), int(20*p)): - table(canvas, Vector2(x, floor_y-7*p), p) - if rect.size.y > 28*p: - counter(canvas, Vector2(rect.position.x+7*p, top+13*p), w-14*p, p) - for x in range(int(rect.position.x+10*p), int(rect.end.x-8*p), int(12*p)): - canvas.draw_rect(Rect2(x, top+11*p, 3*p, 2*p), CREAM) + for at: Vector2 in Layout.slots(rect, p, id): + table(canvas, at + Vector2(-2, -7)*p, p) + &"chapel": + chapel_furnishings(canvas, rect, p) &"public_bath": canvas.draw_rect(Rect2(rect.position.x+4*p, floor_y-7*p, w-8*p, 7*p), CREAM) canvas.draw_rect(Rect2(rect.position.x+6*p, floor_y-6*p, w-12*p, 4*p), Color("72b6b8")) @@ -92,6 +90,15 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, &"warehouse": for x in range(int(rect.position.x+2*p), int(rect.end.x-12*p), int(13*p)): tile(canvas, dungeon, 63, Rect2(x, floor_y-12*p, 12*p, 12*p)) + &"cleaning_room": + # Original code-drawn supply rack, mop and bucket; existing palette. + counter(canvas, Vector2(rect.position.x+3*p, floor_y-10*p), 10*p, p) + for shelf in 2: + canvas.draw_rect(Rect2(rect.position.x+4*p,floor_y-(12+3*shelf)*p,8*p,2*p),CREAM) + canvas.draw_rect(Rect2(mid+3*p,floor_y-16*p,p,14*p),WOOD_LIGHT) + canvas.draw_rect(Rect2(mid+p,floor_y-3*p,6*p,2*p),CREAM) + canvas.draw_rect(Rect2(rect.end.x-9*p,floor_y-6*p,5*p,5*p),TEAL) + canvas.draw_rect(Rect2(rect.end.x-8*p,floor_y-7*p,3*p,p),INK) &"staff_waiting_room": table(canvas, Vector2(mid-6*p, floor_y-7*p), p) tile(canvas, dungeon, 89, Rect2(rect.position.x+2*p, floor_y-12*p, 12*p, 12*p)) @@ -103,9 +110,65 @@ static func room(canvas: CanvasItem, rect: Rect2, id: StringName, pixel: float, _: table(canvas, Vector2(mid-6*p, floor_y-7*p), p) + if not presentation.is_empty(): + if rect.size.y >= 40*p and id != &"chapel": + var workers: Array = presentation.get(&"staff", []) + var columns := maxi(int(w / (16*p)), 1) + if not workers.is_empty(): + canvas.draw_rect(Rect2(rect.position.x+p,top+24*p,w-2*p,p), WOOD_LIGHT) + for slot: int in mini(workers.size(), columns): + counter(canvas, Vector2(rect.position.x+(slot+0.5)*w/columns-6*p, top+20*p), 12*p, p) + + var operation := int(presentation[&"operation"]) + if operation != FacilityInstance.OperationState.OPERATING: + canvas.draw_rect(rect.grow(-p), Color(0.12, 0.16, 0.18, 0.42)) + # A closed shutter differs in shape as well as colour from an open room. + for y: int in range(int(top+10*p), int(top+17*p), maxi(int(3*p), 1)): + canvas.draw_rect(Rect2(rect.position.x+2*p, y, w-4*p, p), WOOD) + if operation == FacilityInstance.OperationState.SUSPENDED: + canvas.draw_line(rect.position+Vector2(3,10)*p, rect.end-Vector2(3,5)*p, Color("ca9c62"), 2*p) + canvas.draw_line(Vector2(rect.end.x-3*p,top+10*p), Vector2(rect.position.x+3*p,rect.end.y-5*p), Color("ca9c62"), 2*p) + elif int(presentation[&"count"]) > 0: + canvas.draw_rect(Rect2(lamp_x-2*p, top+5*p, 4*p, 2*p), Color("ffe9a8")) + + ## The upper landing owns a short flight down to the floor below. Six square ## treads and a stepped handrail keep the shape legible at the 16 px room scale. +static func chapel_furnishings(canvas: CanvasItem, rect: Rect2, p: float) -> void: + # Two rows share the live visitor anchors. Keep the central aisle clear; + # the altar and world-tree glass occupy the otherwise unused upper wall. + var mid := rect.get_center().x + var top := rect.position.y + canvas.draw_rect(Rect2(mid-6*p,top+3*p,12*p,19*p), INK) + canvas.draw_rect(Rect2(mid-5*p,top+4*p,10*p,17*p), Color("6c8993")) + canvas.draw_rect(Rect2(mid-4*p,top+6*p,8*p,13*p), Color("adc5b4")) + canvas.draw_rect(Rect2(mid-p,top+9*p,2*p,10*p), WOOD) + for row in 3: + var width := float(row+1)*2*p + canvas.draw_rect(Rect2(mid-width/2,top+(6+row*3)*p,width,3*p), Color("52836c")) + canvas.draw_rect(Rect2(mid-p,top+5*p,2*p,p), Color("edc97c")) + var altar_y := top+28*p + counter(canvas,Vector2(mid-9*p,altar_y),18*p,p) + canvas.draw_rect(Rect2(mid-9*p,altar_y,18*p,2*p), CREAM) + canvas.draw_rect(Rect2(mid-2*p,altar_y+2*p,4*p,4*p), TEAL) + for direction in [-1,1]: + canvas.draw_rect(Rect2(mid+direction*6*p,altar_y-3*p,p,3*p), CREAM) + canvas.draw_rect(Rect2(mid+direction*6*p,altar_y-4*p,p,p), Color("edc97c")) + var rows: Dictionary = {} + for at: Vector2 in Layout.slots(rect,p,&"chapel"): + rows[at.y] = true + for y: float in rows: + for side in 2: + var x := rect.position.x+3*p if side==0 else mid+3*p + var width := rect.size.x/2-6*p + canvas.draw_rect(Rect2(x,y-8*p,width,5*p), WOOD) + canvas.draw_rect(Rect2(x,y-8*p,width,p), WOOD_LIGHT) + canvas.draw_rect(Rect2(x-p,y-3*p,width+2*p,2*p), WOOD_LIGHT) + canvas.draw_rect(Rect2(x,y-p,p,p), INK) + canvas.draw_rect(Rect2(x+width-p,y-p,p,p), INK) + + static func stairs(canvas: CanvasItem, rect: Rect2, p: float, staff_only: bool, compact := false) -> void: if not compact: canvas.draw_rect(rect, INK) diff --git a/scripts/ui/room_presentation.gd b/scripts/ui/room_presentation.gd new file mode 100644 index 0000000..5945440 --- /dev/null +++ b/scripts/ui/room_presentation.gd @@ -0,0 +1,52 @@ +extends RefCounted + +## Read-only presentation. Counts refer to people, never storage capacity. +static func describe(instance: Variant) -> Dictionary: + if instance == null: + return {} + var ids: Array[StringName] = [] + for service: Variant in instance.active_services.values(): + for id: StringName in service.guest_ids: + if not ids.has(id): + ids.append(id) + ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return {&"operation": int(instance.operation_state), &"people": ids, + &"count": ids.size(), &"limit": int(instance.customer_capacity), + &"staff": instance.active_staff_ids.duplicate(), &"suspension": StringName(instance.suspension_reason)} + + +static func slots(rect: Rect2, p: float, id: StringName) -> Array[Vector2]: + # Each anchor owns a 16x18 person footprint. Overflow is reported as a count, + # not drawn on top of another person. Furniture uses these same anchors. + var result: Array[Vector2] = [] + var step := 20.0 if id in [&"cafeteria", &"tavern"] else 16.0 + var columns := maxi(int(rect.size.x / p / step), 1) + var rows := maxi(int((rect.size.y / p - 10.0) / 20.0), 1) + if id == &"chapel": + columns = mini(columns, 4) + rows = maxi(int((rect.size.y / p - 30.0) / 20.0), 1) + for row: int in rows: + for col: int in columns: + if id == &"chapel" and result.size() == 8: + return result + result.append(Vector2(rect.position.x + (float(col) + 0.5) * rect.size.x / columns, + rect.end.y - (4.0 + row * 20.0) * p)) + return result + + +static func label(data: Dictionary, visible_count: int) -> String: + if data.is_empty(): + return "" + var operation := int(data[&"operation"]) + if operation == FacilityInstance.OperationState.SUSPENDED: + return "休止・摘発" if data[&"suspension"] == &"RAIDED" else "休止・破壊" + if operation == FacilityInstance.OperationState.INACTIVE: + return "未稼働" + var count := int(data[&"count"]) + var limit := int(data[&"limit"]) + var value := "利用%d/%d人" % [count, limit] if limit >= 0 else "利用%d人" % count + if limit > 0 and count >= limit: + value = "満員 " + value + if count > visible_count: + value += "(他%d人)" % (count - visible_count) + return value diff --git a/scripts/ui/room_presentation.gd.uid b/scripts/ui/room_presentation.gd.uid new file mode 100644 index 0000000..32a6d63 --- /dev/null +++ b/scripts/ui/room_presentation.gd.uid @@ -0,0 +1 @@ +uid://dllkdogv348p diff --git a/scripts/ui/staff_management_panel.gd b/scripts/ui/staff_management_panel.gd index 07519cc..66c378a 100644 --- a/scripts/ui/staff_management_panel.gd +++ b/scripts/ui/staff_management_panel.gd @@ -1,6 +1,8 @@ class_name StaffManagementPanel extends VBoxContainer +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + ## Roster and assignment editing only. Runtime owns Shift, staffing, movement, ## and persistence; this panel never edits a Staff or Shift instance directly. signal status_requested(message: String, success: bool) @@ -11,7 +13,7 @@ const ROLE_NAMES := { &"attendant": "接客", &"bartender": "酒場", &"clerk": "会計", &"cleaner": "清掃", &"cook": "調理", &"guard": "警備", &"maintenance": "保守", &"masseur": "施術", &"medic": "医療", - &"porter": "物流", &"receptionist": "受付", + &"porter": "物流", &"receptionist": "受付", &"innkeeper": "女将", &"cleric": "聖職者", } const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"] @@ -51,7 +53,9 @@ func _ready() -> void: _build_interface() _built = true if _runtime != null: - if _runtime.has_signal(&"snapshot_published"): + if _runtime.has_signal(&"state_updated"): + _runtime.connect(&"state_updated", _on_snapshot_published) + elif _runtime.has_signal(&"snapshot_published"): _runtime.connect(&"snapshot_published", _on_snapshot_published) if _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", _on_command_completed) @@ -139,11 +143,12 @@ func refresh() -> void: button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS button.toggle_mode = true button.pressed.connect(select_staff.bind(staff_id)) + button.set_drag_forwarding(_staff_drag_data.bind(staff_id), Callable(), Callable()) _style(button) roster_list.add_child(button) roster_buttons[staff_id] = button button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""] - button.tooltip_text = "%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)] + button.tooltip_text = "施設へドラッグして配属(選択中の勤務帯を適用)\n%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)] for raw_id: Variant in roster_buttons.keys(): if not live_ids.has(StringName(raw_id)): var stale := roster_buttons[raw_id] as Button @@ -182,11 +187,16 @@ func _refresh_editor() -> void: if not _draft_dirty: _draft_facility_id = current_facility _draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY" - staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 疲労 %d / 未払 G%d" % [ + staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 体力 %d / 疲労 %d / 未払 G%d" % [ row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)), _facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務", - _state_name(row), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)), + _state_name(row), int(row.get(&"health", 100)), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)), ] + staff_detail_label.text += _unique_contract_text(row) + if int(row.get(&"state", 0)) == StaffRuntime.State.INCAPACITATED: + staff_detail_label.text += "\n" + String(row.get(&"rescue_status", "救護待ち")) + if not String(row.get(&"rest_status", "")).is_empty(): + staff_detail_label.text += "\n" + String(row[&"rest_status"]) var effective_from := int(row.get(&"effective_from", -1)) var now := int(get_node("/root/GameClock").get("absolute_minute")) if effective_from > now: @@ -216,12 +226,35 @@ func _refresh_editor() -> void: shift_option.disabled = pending var can_edit := bool(row.get(&"can_edit", true)) apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index) - apply_button.tooltip_text = "現在の作業や拘束が終わってから変更できます。" if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。" + apply_button.tooltip_text = _assignment_block_text(row) if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。" if not can_edit: - staff_detail_label.text += "\n現在の作業や拘束が終わってから変更できます。" + staff_detail_label.text += "\n" + _assignment_block_text(row) apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更" +## Runtime supplies canonical, owned presentation data after every normal +## load. The panel neither infers a unique identity from wages nor edits a save. +func _unique_contract_text(row: Dictionary) -> String: + var contract: Dictionary = row.get(&"unique_contract", {}) + if contract.is_empty(): + return "" + var text := "" + if bool(contract.get(&"free_employment", false)) and bool(contract.get(&"free_upkeep", false)) and bool(contract.get(&"permanent_removal_forbidden", false)): + text = "\nユニーク:雇用・維持費無料 / 解雇・売却不可" + for effect: Dictionary in contract.get(&"passive_effects", []): + if effect.get(&"effect_id") != &"assigned_floor_revenue_multiplier_bp" or effect.get(&"scope") != &"ASSIGNED_FLOOR_ALL_LAYERS": + continue + var bonus_percent := int((int(effect[&"multiplier_bp"]) - 10000) / 100) + text += "\n[%s] 配置階の全施設売上+%d%%" % [effect[&"display_name"], bonus_percent] + if bool(effect.get(&"applied", false)) and effect.has(&"floor_y"): + var floor_y := int(effect[&"floor_y"]) + var floor_name := "%dF" % (floor_y + 1) if floor_y >= 0 else "B%d" % -floor_y + text += "\n対象:%s(表層・裏層の全施設)" % floor_name + else: + text += "\n未適用(配属先なし・施設不在)" + return text + + func _on_facility_changed(index: int) -> void: _draft_facility_id = StringName(facility_option.get_item_metadata(index)) _draft_dirty = true @@ -288,6 +321,18 @@ func _show_command_result(result: Variant, staff_id: StringName = &"") -> void: func _failure_message(result: Variant) -> String: var details: Dictionary = result.details if result.details is Dictionary else {} var reason := StringName(details.get(&"reason", details.get("reason", &""))) + var translated := _assignment_reason_text(reason) + if not translated.is_empty(): + return translated + return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。" + + +func _assignment_block_text(row: Dictionary) -> String: + var translated := _assignment_reason_text(StringName(row.get(&"assignment_block_reason", &""))) + return translated if not translated.is_empty() else "現在の作業や拘束が終わってから変更できます。" + + +func _assignment_reason_text(reason: StringName) -> String: var reasons := { &"STAFF_NOT_FOUND": "従業員が見つかりません。", &"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。", @@ -306,9 +351,7 @@ func _failure_message(result: Variant) -> String: &"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。", &"BANKRUPT": "運営終了後は配属を変更できません。", } - if reasons.has(reason): - return reasons[reason] - return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。" + return reasons.get(reason, "") func _synchronize_commands(queue: Variant) -> void: @@ -324,7 +367,7 @@ func _synchronize_commands(queue: Variant) -> void: _pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &""))) -func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: +func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: if is_visible_in_tree(): refresh() @@ -340,7 +383,7 @@ func _on_command_completed(result: Variant) -> void: func _set_result(message: String, success: bool) -> void: result_label.text = message - result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e")) + LayerSkin.register_status_text(result_label, success, _active_layer()) status_requested.emit(message, success) @@ -400,10 +443,49 @@ func _label(text_value: String) -> Label: label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART label.add_theme_font_size_override("font_size", 12) label.add_theme_color_override("font_color", Color("394937")) + LayerSkin.register_text(label, _active_layer()) return label +func _active_layer() -> int: + var state := get_node_or_null("/root/GameState") + return int(state.get("active_layer")) if state != null else 0 + + func _style(button: Button) -> void: button.add_theme_font_size_override("font_size", 12) if _style_button.is_valid(): _style_button.call(button) + + +func _staff_drag_data(_position: Vector2, staff_id: StringName) -> Variant: + if selected_staff_id != staff_id: + select_staff(staff_id) + if _pending_commands.values().has(staff_id) or not bool(_selected_row().get(&"can_edit", false)): + _set_result("変更を処理中です。" if _pending_commands.values().has(staff_id) else _assignment_block_text(_selected_row()), false) + return null + var preview := Label.new() + preview.text = "%s:%s → 施設へ配置" % [_selected_row().get(&"display_name", staff_id), "夜勤" if _draft_template_id == &"NIGHT" else "日勤"] + (roster_buttons[staff_id] as Control).set_drag_preview(preview) + return {&"kind": &"staff_assignment", &"staff_id": staff_id, &"template_id": _draft_template_id, &"queue_instance_id": _queue_instance_id} + + +func assign_from_drop(data: Dictionary, facility_id: StringName) -> void: + # A successful load replaces the queue, even when staff/facility IDs match. + # Never carry an unfinished pointer gesture into that restored world. + refresh() + if data.get(&"queue_instance_id", 0) != _queue_instance_id: + _set_result("読み込み前のドラッグは取り消しました。従業員を選び直して配置してください。", false) + return + if facility_id.is_empty(): + _set_result("完成した配属先施設へドロップしてください。表裏の層も確認してください。", false) + return + var staff_id := StringName(data.get(&"staff_id", &"")) + if not roster_buttons.has(staff_id): + _set_result("従業員が見つかりません。", false) + return + select_staff(staff_id) + _draft_facility_id = facility_id + _draft_template_id = StringName(data.get(&"template_id", &"DAY")) + _draft_dirty = true + submit_assignment() diff --git a/scripts/ui/supply_management_panel.gd b/scripts/ui/supply_management_panel.gd index 79efe87..2c9e024 100644 --- a/scripts/ui/supply_management_panel.gd +++ b/scripts/ui/supply_management_panel.gd @@ -1,6 +1,8 @@ class_name SupplyManagementPanel extends VBoxContainer +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + ## Read models and commands only. Prices, stock, recipes and delivery progress ## belong to Runtime; changing a control never mutates inventory directly. signal status_requested(message: String, success: bool) @@ -11,6 +13,8 @@ const PURCHASE: StringName = &"purchase_supplies" const PRODUCTION: StringName = &"set_kitchen_production" const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"} const STATE_NAMES := { + &"SUSPENDED": "破壊・摘発による休止", + &"RELATED_FACILITY_INACTIVE": "関連施設が未稼働", &"OPEN": "補給待ち", &"RESERVED": "運搬員待ち", &"ASSIGNED": "荷元へ移動", &"PICKING_UP": "積込中", &"DELIVERING": "配送中", &"COMPLETED": "納品済み", &"BLOCKED": "配送停止", &"CANCELLED": "取消済み", &"WORKING": "調理中", @@ -99,7 +103,9 @@ func _ready() -> void: _build_interface() _built = true if _runtime != null: - if _runtime.has_signal(&"snapshot_published"): + if _runtime.has_signal(&"state_updated"): + _runtime.connect(&"state_updated", _on_snapshot_published) + elif _runtime.has_signal(&"snapshot_published"): _runtime.connect(&"snapshot_published", _on_snapshot_published) if _runtime.has_signal(&"command_completed"): _runtime.connect(&"command_completed", _on_command_completed) @@ -159,6 +165,7 @@ func _build_interface() -> void: for color_name: StringName in [&"font_color", &"font_pressed_color", &"font_hover_color", &"font_hover_pressed_color", &"font_focus_color"]: production_enabled.add_theme_color_override(color_name, Color("55371e")) production_enabled.add_theme_color_override("font_disabled_color", Color("766650")) + LayerSkin.register_toggle_text(production_enabled, LayerSkin.active_layer(self)) production_enabled.toggled.connect(_on_production_enabled_changed) kitchen_page.add_child(production_enabled) kitchen_page.add_child(_label("目標在庫 · 完成料理")) @@ -395,7 +402,7 @@ func _on_command_completed(result: Variant) -> void: refresh() -func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: +func _on_snapshot_published(_snapshot: Dictionary = {}, _digest: String = "") -> void: if is_visible_in_tree(): refresh() @@ -519,7 +526,7 @@ func _reasons_text(value: Variant) -> String: func _set_result(message: String, success: bool) -> void: result_label.text = message - result_label.add_theme_color_override("font_color", Color("397346") if success else Color("a7402e")) + LayerSkin.register_status_text(result_label, success, LayerSkin.active_layer(self)) status_requested.emit(message, success) @@ -532,6 +539,7 @@ func _label(value: String, node_name := "") -> Label: label.size_flags_horizontal = Control.SIZE_EXPAND_FILL label.add_theme_font_size_override("font_size", 13) label.add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(label, LayerSkin.active_layer(self)) return label diff --git a/scripts/ui/world_tree_rank_dialog.gd b/scripts/ui/world_tree_rank_dialog.gd new file mode 100644 index 0000000..4292c77 --- /dev/null +++ b/scripts/ui/world_tree_rank_dialog.gd @@ -0,0 +1,123 @@ +class_name WorldTreeRankDialog +extends ConfirmationDialog + +const LayerSkin := preload("res://scripts/ui/layer_interface_skin.gd") + +signal status_requested(message: String, success: bool) +const LABELS := { + &"reputation":"評判", &"asset_gold":"所持金", &"faith_lifetime":"累計信仰", &"guest_lifetime":"累計来客数", + &"underground_influence":"裏社会への影響", &"reputation_or_underground_influence":"評判または裏社会への影響", + &"active_above_ground_facilities":"地上の稼働施設", &"active_underground_facilities":"地下の稼働施設", + &"important_rank_5":"重要クエスト", &"main_chapter_complete":"本編の章目標", +} +var _runtime: Variant +var _target := -1 +var _pending_id := 0 +var _queue_id := 0 +var _skin_styles: Array[StyleBoxFlat] = [] +func configure(runtime: Variant) -> void: _runtime=runtime +func _ready() -> void: + name="WorldTreeRankDialog" + title="世界樹ランク" + get_ok_button().text="昇格する" + get_cancel_button().text="閉じる" + confirmed.connect(_promote) + _runtime.command_completed.connect(_on_command_completed) + _build_skin() + apply_layer_skin(LayerSkin.active_layer(self)) +func show_rank() -> void: + refresh() + popup_centered(Vector2i(480,260)) +func refresh() -> void: + var queue_id: int=_runtime.command_queue.get_instance_id() + if queue_id!=_queue_id: + _pending_id=0 + _queue_id=queue_id + for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]): + if command.get("command_type","")=="set_world_tree_rank": + _pending_id=int(command["command_id"]) + break + var report: Dictionary=_runtime.get_live_progression_report() + var rank: int=report[&"snapshot"][&"current_rank"] + var evaluation: Dictionary=report[&"evaluation"] + _target=rank+1 + var lines: Array[String]=["現在のランク:%d" % rank] + if rank>=ProgressionService.MAX_RANK: + lines.append("最高ランクに到達しています。") + else: + lines.append("ランク%dへの条件" % _target) + for requirement: Dictionary in evaluation[&"requirements"]: + var label: String=LABELS.get(StringName(requirement[&"id"]),"進行条件") + var value: String="%d / %d" % [requirement[&"current"],requirement[&"required"]] + if requirement[&"comparison"]==&"QUEST_COMPLETED": value="達成済み" if requirement[&"met"] else "未達成" + lines.append("%s %s:%s" % ["✓" if requirement[&"met"] else "・",label,value]) + if evaluation.get(&"eligible",false):lines.append("条件を満たしています。昇格すると建設範囲が広がります。") + elif evaluation.get(&"reason",&"")==&"QUARANTINED":lines.append("進行データの確認が必要なため、現在は昇格できません。") + if evaluation.get(&"reason",&"")==&"OPERATIONS_BLOCKED": + var reason: StringName=evaluation.get(&"block_reason",&"") + lines.append("未払い経費を精算するまで昇格できません。" if reason in [&"ARREARS_REMAIN",&"UNPAID_EXPENSES"] else "ゲーム終了後は昇格できません。" if reason==&"BANKRUPT" else "営業状態の復旧が必要なため、現在は昇格できません。") + if evaluation.get(&"reason",&"")==&"UNAVAILABLE":lines.append("条件を確認できません。もう一度開き直してください。") + if _pending_id!=0:lines.append("昇格申請を受理しました。時間更新を待っています。") + dialog_text="\n\n".join(lines) + get_ok_button().disabled=rank>=ProgressionService.MAX_RANK or not evaluation.get(&"eligible",false) or _pending_id!=0 +func _promote() -> void: + if _pending_id!=0 or _target<2:return + var result: CommandResult=_runtime.submit_command(&"set_world_tree_rank",{&"rank":_target,&"request_id":StringName("ui_rank_%d" % _target)}) + for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending",[]): + if result.is_success() and int(command["command_id"])==result.command_id: + _pending_id=result.command_id + status_requested.emit("昇格申請を受理しました。次の時間更新で反映します。",true) + return + _finish(result) +func _on_command_completed(result: CommandResult) -> void: + if _pending_id!=0 and result.command_id==_pending_id: + _pending_id=0 + _finish(result) +func _finish(result: CommandResult) -> void: + status_requested.emit("世界樹ランクが上がりました。" if result.is_success() else "昇格できませんでした。現在の条件を確認してください。",result.is_success()) + refresh() + + +func _build_skin() -> void: + # Window uses a local Theme rather than Control theme overrides. + # Keep native dialog input, focus and confirmation behaviour intact. + theme = Theme.new() + var panel := _skin_box(Color("f4e3bd"), &"panel_parchment") + theme.set_stylebox("panel", "AcceptDialog", panel) + var border := _skin_box(Color("49311f"), &"panel_outer") + border.expand_margin_top = 32 + theme.set_stylebox("embedded_border", "Window", border) + theme.set_color("title_color", "Window", Color("fff0cf")) + get_label().add_theme_color_override("font_color", Color("55371e")) + LayerSkin.register_text(get_label(), 0) + for button: Button in [get_ok_button(), get_cancel_button()]: + for key: StringName in [&"normal", &"hover", &"pressed", &"disabled", &"focus"]: + var role: StringName = &"button_normal" + var color := Color("65452d") + if key in [&"hover", &"focus"]: role = &"button_hover"; color = Color("896139") + elif key == &"pressed": role = &"button_pressed"; color = Color("49311f") + elif key == &"disabled": role = &"button_disabled"; color = Color("786852") + button.add_theme_stylebox_override(key, _skin_box(color, role)) + for key: StringName in [&"font_color", &"font_hover_color", &"font_pressed_color", &"font_hover_pressed_color", &"font_focus_color"]: + button.add_theme_color_override(key, Color("fff0cf")) + button.add_theme_color_override("font_disabled_color", Color("c7bda9")) + LayerSkin.register_toggle_text(button, 0) + + +func _skin_box(background: Color, role: StringName) -> StyleBoxFlat: + var style := StyleBoxFlat.new() + style.bg_color = background + style.border_color = Color("a96f2f") + style.set_border_width_all(2) + style.set_content_margin_all(10) + LayerSkin.register_style(style, role, 0) + _skin_styles.append(style) + return style + + +func apply_layer_skin(layer: int) -> void: + for style: StyleBoxFlat in _skin_styles: + LayerSkin.apply_style(style, layer) + LayerSkin.register_text(get_label(), layer) + for button: Button in [get_ok_button(), get_cancel_button()]: + LayerSkin.register_toggle_text(button, layer) diff --git a/scripts/ui/world_tree_rank_dialog.gd.uid b/scripts/ui/world_tree_rank_dialog.gd.uid new file mode 100644 index 0000000..b43ead6 --- /dev/null +++ b/scripts/ui/world_tree_rank_dialog.gd.uid @@ -0,0 +1 @@ +uid://lr3xhtjonmwu diff --git a/scripts/world/facility_infrastructure_projector.gd b/scripts/world/facility_infrastructure_projector.gd index f5e1bea..ce43024 100644 --- a/scripts/world/facility_infrastructure_projector.gd +++ b/scripts/world/facility_infrastructure_projector.gd @@ -107,6 +107,9 @@ var _processed_requests: Dictionary = {} var _quarantined := false var _quarantine_reason: StringName = &"" var _transaction_active := false +# Private serialized copy: equality is checked against a fresh snapshot on every +# validation. Live dependency checks remain unconditional, even on a cache hit. +var _validated_owned_snapshot: Dictionary = {} ## The explicit three-way binding is intentional: exact rollback must restore @@ -503,14 +506,24 @@ func restore_snapshot(snapshot: Dictionary) -> bool: return true +func is_bound_to(adapter: RouteSubslotPathAdapter, source: SpatialSubslotService, path: PathService) -> bool: + return _configured and _adapter == adapter and _source == source and _path == path + + func validate_invariants() -> Dictionary: var errors: Array[String] = [] if not _configured or _adapter == null or _source == null or _path == null: errors.append("Facility infrastructure projector is not configured.") return {&"ok": false, &"errors": errors} - var parsed := _parse_snapshot(create_snapshot()) - if not bool(parsed[&"ok"]): - errors.append(String(parsed[&"message"])) + var owned_snapshot := create_snapshot() + if owned_snapshot != _validated_owned_snapshot: + var parsed := _parse_snapshot(owned_snapshot) + if not bool(parsed[&"ok"]): + errors.append(String(parsed[&"message"])) + else: + # create_snapshot allocates every container; no caller or live state + # retains a mutable reference to this successful validation input. + _validated_owned_snapshot = owned_snapshot if not _quarantined and not _dependencies_match_current_state(): errors.append("Facility infrastructure live crosslinks are stale.") return {&"ok": errors.is_empty(), &"errors": errors} @@ -991,9 +1004,11 @@ func _dependencies_match_projection_map(projections: Dictionary) -> bool: if _adapter == null or _source == null or _path == null: return false if not _adapter.is_bound_to(_source, _path) \ - or _adapter.is_quarantined() \ - or _adapter.is_projection_stale(): + or _adapter.is_quarantined(): return false + # For this exact, non-quarantined binding, adapter validation also checks + # projection staleness. Keep the independent source invariant audit, but + # avoid repeating the same live projection scan through is_projection_stale(). if not bool(_source.validate_invariants().get(&"ok", false)) \ or not bool(_adapter.validate_invariants().get(&"ok", false)): return false diff --git a/scripts/world/path_service.gd b/scripts/world/path_service.gd index 63a80d9..ed4f3ca 100644 --- a/scripts/world/path_service.gd +++ b/scripts/world/path_service.gd @@ -14,7 +14,7 @@ const ORTHOGONAL_MOVE_COST := 100 const DEFAULT_CONNECTOR_COST := 150 const DEFAULT_CELL_CAPACITY := 4 const CONGESTION_COST_PER_EXCESS := 75 -const MINIMUM_SPEED_MULTIPLIER := 0.25 +const MINIMUM_SPEED_MULTIPLIER := 0.5 const STUCK_THRESHOLD_MINUTES := 30 const ELEVATOR_STARVATION_MINUTES := 60 const SNAPSHOT_VERSION := 5 @@ -569,13 +569,10 @@ func get_speed_multiplier(cell: Vector3i) -> float: var traffic: Dictionary = _cell_traffic.get(cell, {}) if traffic.is_empty(): return 1.0 - var capacity := int(traffic[&"base_capacity"]) - var density := int(traffic[&"current_density"]) - return clampf( - float(capacity) / float(maxi(capacity, density)), - MINIMUM_SPEED_MULTIPLIER, - 1.0 - ) + # Original 4.3: overlapping units move at half speed. A wide corridor's + # pathfinding capacity must not silently redefine this movement debuff. + return MINIMUM_SPEED_MULTIPLIER if int(traffic[&"current_density"]) > 1 else 1.0 + func project_actor_position( @@ -839,7 +836,8 @@ func dequeue_elevator_boarding( connector_id: StringName, current_floor: int, absolute_minute: int, - available_capacity: int = -1 + available_capacity: int = -1, + companions: Dictionary = {} ) -> Array[Dictionary]: var boarded: Array[Dictionary] = [] if not _connectors.has(connector_id): @@ -860,12 +858,27 @@ func dequeue_elevator_boarding( return boarded var queue := get_elevator_queue(connector_id, absolute_minute) + var selected: Dictionary = {} + var by_actor: Dictionary = {} + for request: Dictionary in queue: by_actor[request[&"unit_id"]] = request + for request: Dictionary in queue: + var actor: StringName = request[&"unit_id"] + if selected.has(actor) or int(request[&"floor_from"]) != current_floor: continue + var partner: StringName = companions.get(actor, &"") + var group: Array[Dictionary] = [request] + if not partner.is_empty(): + if companions.get(partner, &"") != actor or not by_actor.has(partner): continue + var other: Dictionary = by_actor[partner] + if other[&"floor_from"] != current_floor or other[&"floor_to"] != request[&"floor_to"]: continue + group.append(other) + if boarded.size() + group.size() > boarding_capacity: + break # Keep the oldest pair together and ahead of later requests. + for member: Dictionary in group: + selected[member[&"unit_id"]] = true + boarded.append(member.duplicate(true)) var remaining: Array = [] for request: Dictionary in queue: - if boarded.size() < boarding_capacity and int(request[&"floor_from"]) == current_floor: - boarded.append(request.duplicate(true)) - else: - remaining.append(request.duplicate(true)) + if not selected.has(request[&"unit_id"]): remaining.append(request.duplicate(true)) connector[&"request_queue"] = remaining connector[&"current_floor"] = current_floor connector[&"cabin_state"] = CABIN_DOOR_OPEN if not boarded.is_empty() else CABIN_IDLE @@ -1100,6 +1113,19 @@ func cached_path_count() -> int: return _path_cache.size() +## Compare graph membership without serializing traffic, actors and connectors. +## Duplicate expected cells are invalid, just as in snapshot crosslink validation. +func matches_passable_cells(expected_cells: Array[Vector3i]) -> bool: + if expected_cells.size() != _passable_cells.size(): + return false + var seen: Dictionary = {} + for cell: Vector3i in expected_cells: + if seen.has(cell) or not _passable_cells.has(cell): + return false + seen[cell] = true + return true + + func has_cell(cell: Vector3i) -> bool: return _passable_cells.has(cell) @@ -1295,7 +1321,7 @@ func create_snapshot() -> Dictionary: var saved_cell_traffic: Array = [] for cell_value: Variant in cells: var cell: Vector3i = cell_value - var traffic: Dictionary = _cell_traffic.get(cell, _default_cell_traffic()) + var traffic: Dictionary = _cell_traffic[cell] if _cell_traffic.has(cell) else _default_cell_traffic() saved_cell_traffic.append({ &"cell": cell, &"base_capacity": int(traffic[&"base_capacity"]), @@ -1579,17 +1605,20 @@ func _run_a_star( if start == goal: return [start] - var open_set: Dictionary = {start: true} + var frontier: Array[Array] = [] + _heap_push(frontier, [start, 0, _heuristic(start, goal)]) var closed_set: Dictionary = {} var came_from: Dictionary = {} var g_score: Dictionary = {start: 0} - while not open_set.is_empty(): - var current := _select_best_open_node(open_set, g_score, goal) + while not frontier.is_empty(): + var entry := _heap_pop(frontier) + var current: Vector3i = entry[0] + if closed_set.has(current) or int(entry[1]) != int(g_score[current]): + continue if current == goal: return _reconstruct_path(came_from, current) - open_set.erase(current) closed_set[current] = true var edges: Array = _adjacency.get(current, []) for edge_value: Variant in edges: @@ -1612,25 +1641,45 @@ func _run_a_star( g_score[neighbor] = tentative_cost if closed_set.has(neighbor): closed_set.erase(neighbor) - open_set[neighbor] = true + _heap_push(frontier, [neighbor, tentative_cost, tentative_cost + _heuristic(neighbor, goal)]) return [] -func _select_best_open_node(open_set: Dictionary, g_score: Dictionary, goal: Vector3i) -> Vector3i: - var best := Vector3i.ZERO - var has_best := false - var best_score := _INFINITE_COST - for candidate_value: Variant in open_set: - var candidate: Vector3i = candidate_value - var score: int = int(g_score[candidate]) + _heuristic(candidate, goal) - if not has_best or score < best_score: - best = candidate - best_score = score - has_best = true - elif score == best_score and _coordinate_less(candidate, best): - best = candidate - return best +# Query-local heap. Stale improved-cost entries are skipped on removal. +# Keep the original f-score / x / y / layer order, including reopened nodes. +func _heap_less(left: Array, right: Array) -> bool: + return int(left[2]) < int(right[2]) or (int(left[2]) == int(right[2]) and _coordinate_less(left[0], right[0])) + + +func _heap_push(heap: Array[Array], entry: Array) -> void: + heap.append(entry) + var index := heap.size() - 1 + while index > 0: + var parent := (index - 1) >> 1 + if not _heap_less(entry, heap[parent]): + break + heap[index] = heap[parent] + index = parent + heap[index] = entry + + +func _heap_pop(heap: Array[Array]) -> Array: + var first := heap[0] + var last: Array = heap.pop_back() + if heap.is_empty(): + return first + var index := 0 + while index * 2 + 1 < heap.size(): + var child := index * 2 + 1 + if child + 1 < heap.size() and _heap_less(heap[child + 1], heap[child]): + child += 1 + if not _heap_less(heap[child], last): + break + heap[index] = heap[child] + index = child + heap[index] = last + return first func _heuristic(from_cell: Vector3i, to_cell: Vector3i) -> int: @@ -2607,3 +2656,47 @@ func _dictionary_value(source: Dictionary, key: StringName, default_value: Varia return source[key] var string_key := str(key) return source.get(string_key, default_value) + + +## Rescue uses BACK circulation, with FRONT access legs at either endpoint. +## Optional managed elevators must have room for both patient and carrier. +func find_backyard_path(start: Vector3i, goal: Vector3i, allow_elevators := false) -> Array[Vector3i]: + if not has_cell(start) or not has_cell(goal) or _cell_is_temporarily_blocked(start) or _cell_is_temporarily_blocked(goal): + return [] + if start == goal: + return [start] + var first := Vector4i(start.x, start.y, start.z, 2 if start.z == 1 else 0) + var frontier: Array[Vector4i] = [first] + var visited := {first: true} + var parents: Dictionary = {} + var cursor := 0 + while cursor < frontier.size(): + var state := frontier[cursor] + cursor += 1 + var cell := Vector3i(state.x,state.y,state.z) + for edge: Dictionary in _adjacency.get(cell, []): + var next: Vector3i = edge[&"to"] + if _cell_is_temporarily_blocked(next) or not _edge_meets_vertical_policy(cell, edge) or not _edge_is_accessible(edge, [&"staff"]): + continue + var connector: Dictionary = _connectors.get(edge[&"connector_id"], {}) + if connector.get(&"movement_type", &"") == MOVEMENT_ELEVATOR: + if not allow_elevators or cell.z != 1 or next.z != 1 or not String(edge[&"connector_id"]).begins_with("transport.") or int(connector.get(&"cabin_capacity", 0)) < 2: + continue + if state.w == 3 and next.z == 1: + continue + var phase := state.w + if next.z == 1: phase = 1 if state.w == 0 else 2 + elif state.w == 1: continue + elif state.w == 2: phase = 3 + var key := Vector4i(next.x,next.y,next.z,phase) + if visited.has(key): continue + visited[key] = true + parents[key] = state + if next == goal and phase > 0: + var result: Array[Vector3i] = [next] + while parents.has(key): + key = parents[key] + result.push_front(Vector3i(key.x,key.y,key.z)) + return result + frontier.append(key) + return [] diff --git a/scripts/world/route_subslot_path_adapter.gd b/scripts/world/route_subslot_path_adapter.gd index 2bb23da..93e06d0 100644 --- a/scripts/world/route_subslot_path_adapter.gd +++ b/scripts/world/route_subslot_path_adapter.gd @@ -53,6 +53,8 @@ var _processed_requests: Dictionary = {} var _quarantined := false var _quarantine_reason: StringName = &"" var _transaction_active := false +# One private successful parse input; live graph/source checks still run each time. +var _validated_owned_snapshot: Dictionary = {} ## Binds the two authoritative services once and establishes projection 1. @@ -283,9 +285,13 @@ func validate_invariants() -> Dictionary: if not _configured or _source == null or _path == null: errors.append("Route projection adapter is not configured.") return {&"ok": false, &"errors": errors} - var parsed := _parse_snapshot(create_snapshot()) - if not bool(parsed[&"ok"]): - errors.append(String(parsed[&"message"])) + var owned_snapshot := create_snapshot() + if owned_snapshot != _validated_owned_snapshot: + var parsed := _parse_snapshot(owned_snapshot) + if not bool(parsed[&"ok"]): + errors.append(String(parsed[&"message"])) + else: + _validated_owned_snapshot = owned_snapshot if not _quarantine_state_is_valid(_quarantined, _quarantine_reason): errors.append("Route projection quarantine state is inconsistent.") if not _quarantined: diff --git a/scripts/world/world_service.gd b/scripts/world/world_service.gd index 92df4ac..9e84feb 100644 --- a/scripts/world/world_service.gd +++ b/scripts/world/world_service.gd @@ -51,12 +51,16 @@ var _excavated: Dictionary = {} var _support_capacity: Dictionary = {} var _support_anchors: Dictionary = {} var _dirty_cells: Dictionary = {} +var _snapshot_geometry: Dictionary = {} +var _snapshot_geometry_revision := -1 func reset(bounds: Variant) -> bool: var normalized := _normalize_bounds(bounds) if normalized.is_empty(): return false + _snapshot_geometry.clear() + _snapshot_geometry_revision = -1 _bounds = normalized _occupants.clear() _entity_cells.clear() @@ -298,11 +302,15 @@ func is_support_anchor(cell: Vector3i) -> bool: ## Validates connected structural components touched by the proposed cells. ## Existing structural occupant kinds contribute their configured capacity/load. -func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary: +func validate_support(cells: Array, weight_per_cell := 1, entity_weights: Dictionary = {}) -> Dictionary: var base_report := _validate_cells(cells) var errors: Array[StringName] = base_report[&"errors"].duplicate() if weight_per_cell <= 0: _add_error(errors, ERROR_INVALID_WEIGHT) + # Optional definition weights are read-only and apply to existing occupants. + for id: Variant in entity_weights: + if typeof(id) not in [TYPE_STRING, TYPE_STRING_NAME] or not _entity_cells.has(id) or typeof(entity_weights[id]) != TYPE_INT or int(entity_weights[id]) <= 0: + _add_error(errors, ERROR_INVALID_WEIGHT) var proposed: Array[Vector3i] = base_report[&"valid_cells"].duplicate() proposed.sort_custom(_cell_less) var result := { @@ -325,7 +333,7 @@ func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary: structural_set[cell] = true for entity_id: StringName in _sorted_entity_ids(): var kind: StringName = _entity_kinds[entity_id] - if _kind_support_capacity(kind) <= 0 and _kind_weight(kind) <= 0 and not _kind_is_anchor(kind): + if _kind_support_capacity(kind) <= 0 and int(entity_weights.get(entity_id, _kind_weight(kind))) <= 0 and not _kind_is_anchor(kind): continue for occupied_cell: Vector3i in _entity_cells[entity_id]: structural_set[occupied_cell] = true @@ -351,7 +359,7 @@ func validate_support(cells: Array, weight_per_cell := 1) -> Dictionary: component_proposed.append(component_cell) required += weight_per_cell if _occupants.has(component_cell) and not proposed_set.has(component_cell): - required += _kind_weight(get_occupant_kind(component_cell)) + required += int(entity_weights.get(get_occupant(component_cell), _kind_weight(get_occupant_kind(component_cell)))) capacity += get_support_capacity(component_cell) anchored = anchored or is_support_anchor(component_cell) component_proposed.sort_custom(_cell_less) @@ -393,6 +401,22 @@ func consume_dirty_cells() -> Array[Vector3i]: func create_snapshot() -> Dictionary: + # Geometry only changes through world mutations. Dirty-cell consumption and + # public revision counters are sampled separately on every read. + if _snapshot_geometry_revision != revision or _snapshot_geometry.is_empty(): + _snapshot_geometry = _create_geometry_snapshot() + _snapshot_geometry_revision = revision + var snapshot := _snapshot_geometry.duplicate(true) + snapshot[&"dirty_cells"] = get_dirty_cells() + snapshot[&"revisions"] = { + &"world": revision, &"occupancy": occupancy_revision, + &"walkability": walkability_revision, &"excavation": excavation_revision, + &"support": support_revision, + } + return snapshot + + +func _create_geometry_snapshot() -> Dictionary: var occupancies: Array[Dictionary] = [] for entity_id: StringName in _sorted_entity_ids(): occupancies.append({ @@ -426,14 +450,6 @@ func create_snapshot() -> Dictionary: &"walkable_cells": _sorted_dictionary_cells(_walkable), &"excavation_overrides": excavation_overrides, &"support": support_entries, - &"dirty_cells": get_dirty_cells(), - &"revisions": { - &"world": revision, - &"occupancy": occupancy_revision, - &"walkability": walkability_revision, - &"excavation": excavation_revision, - &"support": support_revision, - }, } @@ -548,6 +564,8 @@ func restore_snapshot(snapshot: Dictionary) -> bool: ): return false + _snapshot_geometry.clear() + _snapshot_geometry_revision = -1 _bounds = new_bounds _occupants = new_occupants _entity_cells = new_entity_cells diff --git a/tests/environment/test_cleaning_access.gd b/tests/environment/test_cleaning_access.gd new file mode 100644 index 0000000..11206fd --- /dev/null +++ b/tests/environment/test_cleaning_access.gd @@ -0,0 +1,32 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok: bool,label:String)->void: + checks+=1 + if not ok:failures+=1;push_error(label) +class Fixture extends RefCounted: + var facility_service:=FacilityService.new() + var path_service:=PathService.new() +func _initialize()->void:call_deferred("run") +func run()->void: + var fixture:=Fixture.new() + var bridge=preload("res://scripts/environment/facility_cleaning_bridge.gd").new() + bridge.configure(fixture) + fixture.facility_service.create_instance({&"instance_id":&"target",&"definition_id":&"reception",&"origin":Vector3i(3,0,0),&"occupied_cells":[Vector3i(3,0,0),Vector3i(4,0,0)]}) + var worker:=StaffRuntime.new();worker.current_cell=Vector3i(6,0,0) + var task:=StaffTask.new().configure(&"clean",&"facility_clean",50,0,{&"target_entity_id":&"target",&"target_cell":Vector3i(2,0,0),&"estimated_minutes":8}) + var cells: Array[Vector3i]=[Vector3i(2,0,0),Vector3i(4,0,0),Vector3i(5,0,0),Vector3i(6,0,0)] + fixture.path_service.rebuild_graph(cells) + expect(bridge.prepare_route(task,worker) and task.target_cell==Vector3i(5,0,0),"isolated first access is replaced by reachable closest access") + var destination:=task.target_cell + expect(bridge.prepare_route(task,worker) and task.target_cell==destination,"reachable destination remains stable") + cells=[Vector3i(2,0,0),Vector3i(4,0,0),Vector3i(6,0,0)] + fixture.path_service.rebuild_graph(cells) + expect(destination in bridge.access_cells(fixture.facility_service.get_instance(&"target"),fixture.path_service,false) and destination not in bridge.access_cells(fixture.facility_service.get_instance(&"target"),fixture.path_service),"blocked saved destination remains geometrically valid while not walkable") + expect(not bridge.prepare_route(task,worker) and worker.current_cell==Vector3i(6,0,0) and task.target_cell==destination,"total isolation neither moves worker nor invents destination") + cells=[Vector3i(2,0,0),Vector3i(3,0,0),Vector3i(4,0,0),Vector3i(5,0,0),Vector3i(6,0,0)] + fixture.path_service.rebuild_graph(cells) + expect(bridge.prepare_route(task,worker),"route resumes after reconnection") + fixture.facility_service.unregister_instance(&"target") + expect(not bridge.prepare_route(task,worker),"removed facility has no work destination") + print("Cleaning access: %d checks, %d failures"%[checks,failures]);quit(failures) diff --git a/tests/environment/test_cleaning_access.gd.uid b/tests/environment/test_cleaning_access.gd.uid new file mode 100644 index 0000000..303ecba --- /dev/null +++ b/tests/environment/test_cleaning_access.gd.uid @@ -0,0 +1 @@ +uid://rvfm06u8dee3 diff --git a/tests/environment/test_facility_cleanliness.gd b/tests/environment/test_facility_cleanliness.gd new file mode 100644 index 0000000..28f24af --- /dev/null +++ b/tests/environment/test_facility_cleanliness.gd @@ -0,0 +1,89 @@ +extends SceneTree +const Cleanliness = preload("res://scripts/environment/facility_cleanliness_service.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var room: FacilityDefinition = load("res://data/facilities/expansion/cleaning_room.tres") + expect(room.is_valid() and room.footprint == Vector2i(2,1) and room.layer == FacilityDefinition.ViewLayer.BACK, "original cleaning room dimensions and layer") + expect(room.construction_cost == {&"gold":350,&"materials":4} and room.build_minutes == 60 and room.maintenance_gold == 2, "approved trial construction and upkeep") + expect(room.staff_min == 1 and room.staff_max == 2 and room.required_roles == [&"cleaner"], "approved cleaner staffing") + expect(room.customer_capacity == 0 and room.base_price == 0, "cleaning room has no customer use or fee") + var policy: Dictionary = JSON.parse_string(FileAccess.get_file_as_string("res://data/environment/cleaning_policy.json")) + expect(policy["initial_cleanliness"] == 100 and policy["maximum_cleanliness"] == 100 and policy["clean_at_or_below"] == 75 and policy["work_minutes"] == 8 and policy["recovery_per_completed_task"] == 25, "approved trial cleaning policy") + var rules = preload("res://scripts/environment/facility_cleaning_policy.gd").new() + expect(rules.load_file(), "approved JSON policy loads through strict validator") + var approved: Dictionary = rules.create_snapshot() + var corrupt_policies: Array = [] + for change: Dictionary in [{"schema_version":2}, {"initial_cleanliness":101}, {"maximum_cleanliness":0}, {"clean_at_or_below":100}, {"work_minutes":0}, {"work_minutes":8.5}, {"recovery_per_completed_task":0}, {"work_minutes":true}, {"work_minutes":INF}]: + var invalid_policy: Dictionary = approved.duplicate(true) + invalid_policy.merge(change, true) + corrupt_policies.append(invalid_policy) + var missing_policy: Dictionary = approved.duplicate(true) + missing_policy.erase("work_minutes") + corrupt_policies.append(missing_policy) + for invalid_policy: Dictionary in corrupt_policies: + expect(not rules.configure(invalid_policy) and rules.create_snapshot() == approved, "invalid policy rejects without replacing approved values") + var detached_policy: Dictionary = rules.create_snapshot() + detached_policy["work_minutes"] = 99 + expect(rules.create_snapshot()["work_minutes"] == 8, "policy read cannot change work duration") + var ordered = Cleanliness.new() + var names: Array[String] = [] + for index in range(31, -1, -1): + var name := "clean_order_%03d" % ((index * 17) % 32) + names.append(name) + ordered.register_facility(StringName(name), 75, 100) + names.sort() + var dirty := ordered.get_dirty_facility_ids(75) + var rows: Array = ordered.create_snapshot()["facilities"] + var text_order_matches := true + for index in names.size(): + text_order_matches = text_order_matches and String(dirty[index]) == names[index] and rows[index]["id"] == names[index] + expect(text_order_matches, "generated IDs use lexical order for saves and cleaning dispatch") + var reordered: Dictionary = ordered.create_snapshot() + reordered["facilities"].reverse() + expect(ordered.restore_snapshot(reordered) and ordered.create_snapshot()["facilities"] == rows, "legacy row order restores to canonical lexical order") + var service = Cleanliness.new() + expect(service.register_facility(&"b", 100, 100) and service.register_facility(&"a", 50, 100), "explicit initial and maximum values") + expect(not service.register_facility(&"b", 100,100), "duplicate facility rejected") + expect(not service.register_facility(&"bad", -1,100), "invalid initial value rejected") + expect(service.record_use(&"b", &"visit",25), "completed visit soils facility") + expect(service.get_facility(&"b")["current"]==75, "soil amount applied") + expect(service.get_dirty_facility_ids(75)==[&"a",&"b"], "threshold inclusive with stable ordering") + expect(service.get_dirty_facility_ids(74)==[&"a"], "threshold excludes cleaner facility") + var saved: Dictionary=service.create_snapshot() + expect(service.record_use(&"b", &"visit",25) and service.create_snapshot()==saved, "replayed completion does not double soil") + expect(not service.record_use(&"b", &"visit",26) and service.create_snapshot()==saved, "conflicting completion is atomic") + expect(not service.record_use(&"missing", &"unknown",1), "unknown facility cannot receive effect") + expect(not service.record_cleaning(&"b",&"negative",-1), "negative recovery rejected") + expect(service.record_cleaning(&"b",&"work",9223372036854775807) and service.get_facility(&"b")["current"]==100, "recovery clamps without overflow") + expect(service.record_use(&"b",&"dirty",9223372036854775807) and service.get_facility(&"b")["current"]==0, "soil clamps without overflow") + expect(service.record_cleaning(&"b",&"work",9223372036854775807) and service.get_facility(&"b")["current"]==0, "old cleaning completion does not clean later dirt again") + var restored=Cleanliness.new() + expect(restored.restore_snapshot(service.create_snapshot()), "history and state restore") + expect(restored.create_snapshot()==service.create_snapshot(), "exact roundtrip") + expect(restored.record_cleaning(&"b",&"next",25) and service.record_cleaning(&"b",&"next",25) and restored.create_snapshot()==service.create_snapshot(), "restored continuation matches") + var detached: Dictionary=restored.get_facility(&"b");detached["current"]=99 + expect(restored.get_facility(&"b")["current"]==25, "read result detached") + var intact: Dictionary=restored.create_snapshot() + var bad_cases: Array=[] + var bad: Dictionary=intact.duplicate(true);bad["schema_version"]=2;bad_cases.append(bad) + bad=intact.duplicate(true);bad["facilities"][0]["current"]=33;bad_cases.append(bad) + bad=intact.duplicate(true);bad["facilities"].append(bad["facilities"][0].duplicate(true));bad_cases.append(bad) + bad=intact.duplicate(true);bad["receipts"].append(bad["receipts"][0].duplicate(true));bad_cases.append(bad) + bad=intact.duplicate(true);bad["receipts"][0]["facility_id"]="missing";bad_cases.append(bad) + bad=intact.duplicate(true);bad["receipts"][0]["amount"]=1.5;bad_cases.append(bad) + bad=intact.duplicate(true);bad["receipts"][0]["kind"]="unknown";bad_cases.append(bad) + bad=intact.duplicate(true);bad["facilities"]=false;bad_cases.append(bad) + bad=intact.duplicate(true);bad["unexpected"]=1;bad_cases.append(bad) + for candidate: Dictionary in bad_cases: + expect(not restored.restore_snapshot(candidate) and restored.create_snapshot()==intact, "malformed or inconsistent save preserves live state") + var mutated: Dictionary=restored.create_snapshot();mutated["receipts"].clear();mutated["facilities"].clear() + expect(restored.create_snapshot()==intact, "saved arrays do not alias live state") + expect(restored.unregister_facility(&"b") and restored.get_facility(&"b").is_empty(), "demolition removes facility state") + var after_remove=Cleanliness.new() + expect(after_remove.restore_snapshot(restored.create_snapshot()), "demolition retains no dangling completion receipts") + print("Facility cleanliness: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/environment/test_facility_cleanliness.gd.uid b/tests/environment/test_facility_cleanliness.gd.uid new file mode 100644 index 0000000..1e1bf01 --- /dev/null +++ b/tests/environment/test_facility_cleanliness.gd.uid @@ -0,0 +1 @@ +uid://b0g1ir0itwtlx diff --git a/tests/environment/test_runtime_cleanliness.gd b/tests/environment/test_runtime_cleanliness.gd new file mode 100644 index 0000000..cb6937a --- /dev/null +++ b/tests/environment/test_runtime_cleanliness.gd @@ -0,0 +1,74 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0); state.reset() + expect(runtime.initialize(clock,state,42630)[&"valid"], "initialize with validated approved policy") + clock.set_paused(true) + expect(runtime.submit_command(&"build_facility", {&"definition_id":&"reception",&"origin":Vector3i(-6,0,0)}).is_success(), "normal paid construction begins") + expect(runtime.facility_cleanliness.create_snapshot()["facilities"].is_empty(), "unfinished building has no cleanliness record") + clock.advance_minutes(120) + var facility: FacilityInstance = runtime.facility_service.get_instances()[0] + var id := facility.instance_id + expect(runtime.facility_cleanliness.get_facility(id)["current"] == 100, "construction completion starts clean") + expect(runtime.submit_command(&"set_facility_conditions",{&"facility_id":id,&"conditions":{&"active_staff_ids":[&"reception_fixture"]}}).is_success(), "controlled staffing for transaction test") + expect(runtime.submit_command(&"register_guest_wallet",{&"guest_id":&"payer",&"gold":100}).is_success(), "payer wallet created") + expect(runtime.submit_command(&"create_service_request",{&"request_id":&"visit1",&"facility_id":id,&"guest_ids":[&"payer"],&"service_type":&"check_in"}).is_success(), "normal service request") + clock.advance_one_minute() + var active: Array = runtime.facility_service.get_active_services(id) + expect(active.size()==1, "service actually starts") + if active.is_empty(): runtime.free(); clock.free(); state.free(); quit(failures); return + var service_id: StringName = active[0].service_id + expect(runtime.facility_cleanliness.get_facility(id)["current"]==100, "starting a visit does not apply completion dirt") + var midpoint: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(midpoint), "in-progress service with cleanliness restores") + clock.advance_minutes(21) + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED and runtime.facility_cleanliness.get_facility(id)["current"]==99, "completed reception visit applies defined dirt once") + var complete: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(complete) and runtime.create_snapshot()==complete, "completed dirt and visit roundtrip exactly") + clock.advance_one_minute() + expect(runtime.facility_cleanliness.get_facility(id)["current"]==99, "saved completion does not repeat dirt") + expect(runtime.restore_snapshot(midpoint), "rewind visit") + clock.advance_minutes(21) + expect(runtime.create_snapshot()==complete, "save continuation reproduces full Runtime exactly") + var intact: Dictionary=runtime.create_snapshot() + var bad: Dictionary=intact.duplicate(true) + bad[&"systems"][&"runtime"].erase(&"facility_cleanliness") + expect(not runtime.restore_snapshot(bad) and runtime.create_snapshot()==intact, "v12 missing cleanliness rejects atomically") + bad=intact.duplicate(true) + bad[&"systems"][&"runtime"][&"facility_cleanliness"]["receipts"][0]["source_id"]="invented" + expect(not runtime.restore_snapshot(bad) and runtime.create_snapshot()==intact, "receipt without completed visit rejects atomically") + bad=intact.duplicate(true) + bad[&"systems"][&"runtime"][&"facility_cleanliness"]["facilities"]=[] + bad[&"systems"][&"runtime"][&"facility_cleanliness"]["receipts"]=[] + expect(not runtime.restore_snapshot(bad) and runtime.create_snapshot()==intact, "missing live facility cleanliness rejects") + var legacy: Dictionary=intact.duplicate(true) + legacy[&"systems"][&"runtime"][&"runtime_schema_version"]=11 + legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") + expect(runtime.restore_snapshot(legacy) and runtime.facility_cleanliness.get_facility(id)["current"]==100, "v11 initializes cleanliness without guessing past dirt") + expect(runtime.create_snapshot()[&"systems"][&"runtime"][&"weather"]==intact[&"systems"][&"runtime"][&"weather"], "v11 weather survives migration unchanged") + expect(runtime.restore_snapshot(intact), "restore current dirt before rollback checks") + var rollback: Dictionary=runtime.call("_capture_infrastructure_demolition_state") + runtime.facility_cleanliness.unregister_facility(id) + expect(runtime.call("_restore_infrastructure_demolition_state",rollback) and runtime.create_snapshot()==intact, "infrastructure rollback includes cleanliness") + # Force the final dirt boundary to fail after payment/output staging; no partial settlement. + expect(runtime.restore_snapshot(midpoint), "rewind for failed cleanliness commit") + runtime.facility_cleanliness.unregister_facility(id) + clock.advance_minutes(21) + expect(runtime.facility_service.get_service(service_id).state!=ServiceInstance.State.COMPLETED, "missing dirt participant prevents partial service completion") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:payer")==100, "failed cleanliness transaction leaves wallet unchanged") + runtime.facility_cleanliness.register_facility(id,100,100) + clock.advance_one_minute() + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED and runtime.facility_cleanliness.get_facility(id)["current"]==99, "failed boundary retries once after recovery") + expect(runtime.submit_command(&"demolish_facility",{&"instance_id":id}).is_success(), "normal demolition command") + expect(runtime.facility_cleanliness.get_facility(id).is_empty() and runtime.facility_cleanliness.create_snapshot()["receipts"].is_empty(), "demolition removes state and dangling receipts") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "post-demolition Runtime remains restorable") + runtime.free();clock.free();state.free() + print("Runtime cleanliness: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/environment/test_runtime_cleanliness.gd.uid b/tests/environment/test_runtime_cleanliness.gd.uid new file mode 100644 index 0000000..06774a9 --- /dev/null +++ b/tests/environment/test_runtime_cleanliness.gd.uid @@ -0,0 +1 @@ +uid://didx6256rlc4d diff --git a/tests/environment/test_runtime_weather.gd b/tests/environment/test_runtime_weather.gd new file mode 100644 index 0000000..579ece1 --- /dev/null +++ b/tests/environment/test_runtime_weather.gd @@ -0,0 +1,51 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0); state.reset() + expect(runtime.initialize(clock,state,424242)[&"valid"], "initialize Runtime") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable production arrivals") + var original: Dictionary = runtime.create_snapshot() + var legacy := original.duplicate(true) + legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 10 + legacy[&"systems"][&"runtime"].erase(&"weather") + legacy[&"systems"][&"runtime"].erase(&"facility_cleanliness") + expect(runtime.restore_snapshot(legacy) and runtime.weather.get_kind() == &"clear" and runtime.weather.get_minute() == 360, "v10 migrates to neutral weather at saved minute") + expect(runtime.create_snapshot() == original, "old neutral save migration preserves other state") + var malformed := original.duplicate(true) + malformed[&"systems"][&"runtime"][&"weather"][&"minute"] = 361 + expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot() == original, "future weather clock rejects atomically") + malformed = original.duplicate(true) + malformed[&"systems"][&"runtime"].erase(&"weather") + expect(not runtime.restore_snapshot(malformed), "new schema cannot silently lose weather") + expect(runtime.weather.schedule(&"storm",390)[&"ok"] and runtime.weather.schedule(&"clear",420)[&"ok"], "schedule weather boundaries") + clock.advance_minutes(29) + var before: Dictionary = runtime.create_snapshot() + expect(runtime.weather.get_kind() == &"clear", "storm does not apply before boundary") + var spawned := {&"count":0} + runtime.guest_service.guest_spawned.connect(func(_guest: GuestRuntime) -> void: spawned[&"count"] += 1) + clock.advance_minutes(1) + expect(runtime.weather.get_kind() == &"storm" and spawned[&"count"] == 0, "storm starts before same-minute production admission") + var boundary: Dictionary = runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + expect(boundary[&"capacity_remaining"] == 0, "storm exposes zero admission capacity") + var stopped: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(stopped) and runtime.create_snapshot() == stopped, "storm and recovery schedule round trip exactly") + clock.advance_minutes(30) + expect(runtime.weather.get_kind() == &"clear", "scheduled recovery resumes weather at boundary") + boundary = runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + expect(boundary[&"capacity_remaining"] > 0, "recovery restores admission capacity") + var recovered: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(before), "rewind before storm") + clock.advance_minutes(31) + expect(runtime.create_snapshot() == recovered, "full Runtime weather and arrivals replay identically") + expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "weather does not quarantine operations") + runtime.free(); clock.free(); state.free() + print("Runtime weather: %d checks, %d failures" % [checks, failures]); quit(failures) diff --git a/tests/environment/test_runtime_weather.gd.uid b/tests/environment/test_runtime_weather.gd.uid new file mode 100644 index 0000000..f83f3ef --- /dev/null +++ b/tests/environment/test_runtime_weather.gd.uid @@ -0,0 +1 @@ +uid://c524n3edolklf diff --git a/tests/environment/test_weather_recovery.gd b/tests/environment/test_weather_recovery.gd new file mode 100644 index 0000000..969f26c --- /dev/null +++ b/tests/environment/test_weather_recovery.gd @@ -0,0 +1,39 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0); state.reset() + expect(runtime.initialize(clock,state,424242)[&"valid"], "initialize") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable arrivals") + expect(runtime.weather.schedule(&"storm",390)[&"ok"] and runtime.weather.schedule(&"clear",420)[&"ok"], "schedule storm and recovery") + clock.advance_minutes(29) + runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", func(_payload: Dictionary, _phase: StringName, _token: Variant) -> Dictionary: return {&"ok": false}) + clock.advance_minutes(1) + expect(clock.absolute_minute == 390 and runtime.get_operations_report()[&"runtime_failure_latched"], "arrival failure pauses at weather boundary") + expect(runtime.weather.get_kind() == &"storm" and runtime.weather.get_minute() == 390, "weather commits before failed arrival") + var weather_before: Dictionary = runtime.weather.create_snapshot() + var path := "user://tests/weather_recovery_%d" % OS.get_process_id() + expect(runtime.save_to_path(path)[&"ok"], "write interrupted weather to actual save package") + expect(runtime.load_from_path(path)[&"ok"] and runtime.weather.create_snapshot() == weather_before, "disk load preserves storm and pending recovery") + expect(runtime.get_operations_report()[&"deferred_runtime_minute"] == 390, "disk load preserves failed boundary for retry") + runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", Callable()) + var arrivals := {&"count":0} + runtime.guest_service.guest_spawned.connect(func(_guest: GuestRuntime) -> void: arrivals[&"count"] += 1) + expect(runtime.acknowledge_operations_pause()[&"ok"], "retry failed storm boundary") + expect(arrivals[&"count"] == 0 and runtime.weather.create_snapshot() == weather_before, "retry neither admits storm guests nor duplicates weather transition") + var completed: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(completed) and runtime.create_snapshot() == completed, "completed retry restores exactly") + clock.advance_minutes(30) + expect(runtime.weather.get_kind() == &"clear" and runtime.weather.create_snapshot()[&"pending"].is_empty(), "disk-restored recovery occurs once at scheduled minute") + expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "recovery resumes ordinary operations") + expect(SaveService.delete_save(path), "delete isolated test save") + runtime.free(); clock.free(); state.free() + print("Weather recovery: %d checks, %d failures" % [checks, failures]); quit(failures) diff --git a/tests/environment/test_weather_recovery.gd.uid b/tests/environment/test_weather_recovery.gd.uid new file mode 100644 index 0000000..ef6693c --- /dev/null +++ b/tests/environment/test_weather_recovery.gd.uid @@ -0,0 +1 @@ +uid://dgtxyr8gxg6nj diff --git a/tests/environment/test_weather_timeline.gd b/tests/environment/test_weather_timeline.gd new file mode 100644 index 0000000..a63f440 --- /dev/null +++ b/tests/environment/test_weather_timeline.gd @@ -0,0 +1,43 @@ +extends SceneTree +const Weather = preload("res://scripts/environment/weather_timeline.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: + var weather = Weather.new() + expect(not weather.permits_new_arrivals(), "uninitialized weather never grants admission") + expect(not weather.initialize(&"unknown", 360) and not weather.initialize(&"clear", 1.5), "invalid initial data rejected") + expect(weather.initialize(&"clear", 360), "caller explicitly chooses initial weather and time") + expect(not weather.initialize(&"storm", 400), "initialize cannot reset a live simulation") + expect(weather.schedule(&"clear", 390)[&"ok"] and weather.schedule(&"storm", 380)[&"ok"] and weather.schedule(&"rain", 370)[&"ok"], "out of order scheduling retains chronological execution") + expect(weather.schedule(&"storm", 380)[&"replayed"] and not weather.schedule(&"rain", 380)[&"ok"], "same boundary retry is idempotent and conflict rejected") + expect(not weather.schedule(&"storm", 360)[&"ok"], "past and current weather cannot be retroactively changed") + var checkpoint: Dictionary = weather.create_snapshot() + expect(weather.advance_to(379)[&"changes"] == [{&"from": &"clear", &"to": &"rain", &"minute": 370}], "rain changes at its scheduled time") + expect(weather.permits_new_arrivals(), "rain is not treated as a storm admission ban") + expect(weather.advance_to(380)[&"changes"].size() == 1 and not weather.permits_new_arrivals(), "storm blocks admission at exact boundary") + var storm: Dictionary = weather.create_snapshot() + var replay: Dictionary = weather.advance_to(380) + expect(replay[&"replayed"] and replay[&"changes"].is_empty() and weather.create_snapshot() == storm, "same minute cannot duplicate a transition") + expect(not weather.advance_to(379)[&"ok"] and weather.create_snapshot() == storm, "time reversal cannot change state") + expect(weather.advance_to(390)[&"ok"] and weather.permits_new_arrivals(), "clear restores admission") + var finished: Dictionary = weather.create_snapshot() + expect(weather.restore_snapshot(checkpoint), "restore earlier weather and pending schedule") + expect(weather.advance_to(390)[&"changes"].size() == 3 and weather.create_snapshot() == finished, "jump and minute-step replay produce identical weather state") + var encoded: Dictionary = bytes_to_var(var_to_bytes(storm)) + expect(weather.restore_snapshot(encoded) and weather.get_kind() == &"storm", "production Variant codec preserves integer weather time and IDs") + var baseline: Dictionary = weather.create_snapshot() + for field: String in ["version", "minute", "kind", "since", "pending"]: + var bad := baseline.duplicate(true); bad.erase(field) + expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "missing field rejects atomically: " + field) + for change: Dictionary in [{"version": 2}, {"minute": 380.0}, {"kind": "snow"}, {"since": 381}, {"pending": [{"kind":"clear","minute":380}]}, {"pending": [{"kind":"clear","minute":400},{"kind":"rain","minute":390}]}, {"extra":true}]: + var bad := baseline.duplicate(true) + bad.merge(change, true) + expect(not weather.restore_snapshot(bad) and weather.create_snapshot() == baseline, "malformed or contradictory state rejects atomically") + var detached: Dictionary = weather.create_snapshot(); detached[&"pending"].clear() + expect(not weather.create_snapshot()[&"pending"].is_empty(), "caller cannot mutate pending weather through snapshot alias") + expect(weather.schedule(&"mana_fog", 400)[&"ok"] and weather.advance_to(400)[&"ok"] and weather.get_kind() == &"mana_fog", "mana fog is persisted without invented efficiency multipliers") + print("Weather timeline: %d checks, %d failures" % [checks, failures]) + quit(failures) diff --git a/tests/environment/test_weather_timeline.gd.uid b/tests/environment/test_weather_timeline.gd.uid new file mode 100644 index 0000000..71ce784 --- /dev/null +++ b/tests/environment/test_weather_timeline.gd.uid @@ -0,0 +1 @@ +uid://cmqkqajl1u1d diff --git a/tests/export/test_exported_opening.gd b/tests/export/test_exported_opening.gd index 38fa63a..b59bba4 100644 --- a/tests/export/test_exported_opening.gd +++ b/tests/export/test_exported_opening.gd @@ -17,14 +17,14 @@ func _run() -> void: _expect(not FileAccess.file_exists("res://project.godot") and not FileAccess.file_exists("res://data/facilities/reception.tres"), "release regression has no source checkout fallback") print("RECEPTION_RESOURCE file=%s resource=%s" % [FileAccess.file_exists("res://data/facilities/reception.tres"), ResourceLoader.exists("res://data/facilities/reception.tres")]) _expect(runtime.definition_registry.is_valid(), "exported facility definitions validate: %s" % runtime.definition_registry.get_report()) - _expect(runtime.definition_registry.facility_count() == 18, "all 18 buildable definitions are present in the shipped pack") + _expect(runtime.definition_registry.facility_count() == 23, "all 23 buildable definitions are present in the shipped pack") var main: Control = load("res://scenes/main/main.tscn").instantiate() root.add_child(main) runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) await process_frame await process_frame var buttons: Dictionary = main.get("build_buttons") - _expect(buttons.size() == 18, "the actual build palette exposes all 18 facilities") + _expect(buttons.size() == 23, "the actual build palette exposes all 23 facilities") _expect(buttons.has(&"reception"), "reception is selectable in the actual build palette") if not buttons.has(&"reception"): _finish() @@ -49,6 +49,15 @@ func _run() -> void: reception = facility _expect(reception != null, "the reception completes from the exported game data") _expect(guide != null and guide.get_progress().get(&"stage") == &"room", "opening guide advances to building a room") + (main.get("guest_count_label") as Button).pressed.emit() + await process_frame + var observer: Variant = main.get("guest_observer") + _expect(observer != null and observer.visible, "shipped HUD opens guest observation") + var guest_panel: Variant = observer.find_child("GuestObservationPanel", true, false) if observer != null else null + _expect(guest_panel != null and guest_panel.bars.size() == 4, "shipped observation includes all four need bars") + if observer != null: + observer.hide() + await _check_original_facilities(main, runtime, clock) if OS.get_environment("THE_TREE_EXPORT_CAPTURE") == "1": main.call("_refresh_dashboard") await process_frame @@ -65,3 +74,42 @@ func _expect(ok: bool, label: String) -> void: func _finish() -> void: print("Exported opening: %d checks, %d failures" % [_checks, _failures]) quit(1 if _failures else 0) + +func _check_original_facilities(main: Control, runtime: Variant, clock: Variant) -> void: + var buttons: Dictionary = main.get("build_buttons") + for entry: Dictionary in [ + {&"id": &"premium_room", &"origin": Vector3i(-8,2,0), &"size":Vector2i(4,1), &"upkeep":5}, + {&"id": &"warehouse", &"origin": Vector3i(-8,0,1), &"size":Vector2i(4,2), &"upkeep":3}, + {&"id": &"staff_dormitory", &"origin": Vector3i(-8,2,1), &"size":Vector2i(2,1), &"upkeep":4}, + ]: + var definition: Variant = runtime.definition_registry.get_facility(entry[&"id"]) + _expect(definition != null and definition.footprint == entry[&"size"] and definition.maintenance_gold == entry[&"upkeep"], "packed original geometry and upkeep: %s" % entry[&"id"]) + if definition == null or not buttons.has(entry[&"id"]): return + buttons[entry[&"id"]].button_pressed = false + buttons[entry[&"id"]].button_pressed = true + _expect(main.get("_selected_definition_id") == entry[&"id"] and main.get("world_preview").layer_mode == entry[&"origin"].z, "packed palette selects correct facility/layer: %s" % entry[&"id"]) + main.call("_on_map_cell_clicked", entry[&"origin"]) + clock.advance_minutes(definition.build_minutes) + var found := false + for facility: Variant in runtime.facility_service.get_instances(): + if facility.definition_id == entry[&"id"]: + found = facility.occupied_cells.size() == entry[&"size"].x * entry[&"size"].y + _expect(found, "packed construction completes correct occupied cells: %s" % entry[&"id"]) + var path := "user://tests/export_original_%d" % Time.get_ticks_usec() + var snapshot: Dictionary = runtime.create_snapshot() + _expect(runtime.save_to_path(path)[&"ok"], "packed runtime writes completed original facilities") + clock.advance_minutes(3) + var loaded: Dictionary = runtime.load_from_path(path) + var restored: Dictionary = runtime.create_snapshot() + var expected_runtime: Dictionary = snapshot[&"systems"][&"runtime"] + var restored_runtime: Dictionary = restored[&"systems"][&"runtime"] + var events: Dictionary = restored_runtime[&"domain_events"] + var last: Dictionary = events["recent_events"].back() + _expect(loaded[&"ok"] and last["event_type"] == &"SAVE_LOADED" and events["published_ids"].size() == expected_runtime[&"domain_events"]["published_ids"].size()+1, "load publishes exactly one successful lifecycle event") + # Loading intentionally appends SAVE_LOADED; all other saved gameplay state + # must match, including every ID counter except this one new event. + expected_runtime[&"domain_events"] = events + expected_runtime[&"id_generator"]["counters"]["domain_event"] += 1 + _expect(restored == snapshot, "packed runtime restores complete gameplay state with only the load event added") + SaveService.delete_save(path) + _expect(main.find_child("SaveLoadMenu", true, false) != null, "packed UI includes migrated-save load menu") diff --git a/tests/fixtures/legacy_premium_room.tres b/tests/fixtures/legacy_premium_room.tres new file mode 100644 index 0000000..e154af1 --- /dev/null +++ b/tests/fixtures/legacy_premium_room.tres @@ -0,0 +1,24 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"premium_room" +display_name = "上級客室" +description = "富裕客と長期滞在客向けの静かな地上客室。一般客室より高い品質と料金を持つ。" +zone = 1 +layer = 0 +footprint = Vector2i(3, 2) +construction_cost = {&"gold": 900, &"materials": 10} +build_minutes = 150 +weight_per_cell = 2 +maintenance_mana = 1 +staff_min = 0 +staff_max = 0 +capacity = 2 +queue_capacity = 2 +base_service_minutes = 720 +base_quality = 70 +base_price = 220 +tags = Array[StringName]([&"expansion", &"lodging", &"premium", &"quiet"]) diff --git a/tests/fixtures/legacy_staff_dormitory.tres b/tests/fixtures/legacy_staff_dormitory.tres new file mode 100644 index 0000000..975c00b --- /dev/null +++ b/tests/fixtures/legacy_staff_dormitory.tres @@ -0,0 +1,25 @@ +[gd_resource type="Resource" script_class="FacilityDefinition" load_steps=2 format=3] + +[ext_resource type="Script" path="res://scripts/data/facility_definition.gd" id="1"] + +[resource] +script = ExtResource("1") +id = &"staff_dormitory" +display_name = "従業員宿舎" +description = "勤務の合間に従業員が睡眠と長時間休息を取るBACK居住区画。" +zone = 1 +layer = 1 +footprint = Vector2i(4, 2) +construction_cost = {&"gold": 650, &"materials": 8} +build_minutes = 150 +weight_per_cell = 1 +maintenance_mana = 1 +power_priority = 35 +staff_min = 0 +staff_max = 0 +capacity = 12 +queue_capacity = 0 +base_service_minutes = 360 +base_quality = 58 +base_price = 0 +tags = Array[StringName]([&"back_of_house", &"expansion", &"recovery", &"staff_only", &"staff_support"]) diff --git a/tests/fixtures/pre_upkeep_runtime.gd b/tests/fixtures/pre_upkeep_runtime.gd new file mode 100644 index 0000000..f8ba547 --- /dev/null +++ b/tests/fixtures/pre_upkeep_runtime.gd @@ -0,0 +1,24 @@ +extends RefCounted + +## Build a genuine pre-v10 fixture only when no upkeep ledger history exists. +static func strip_empty_upkeep(data: Dictionary) -> void: + for entry: Dictionary in data[&"economy"][&"entries"]: + assert(String(entry.get(&"category", "")) not in ["MAINTENANCE_ACCRUAL", "MAINTENANCE_PAYMENT"]) + data.erase(&"weather") + data.erase(&"facility_cleanliness") + data.erase(&"facility_upkeep") + data.erase(&"upkeep_settlement") + var accounts: Array = data[&"economy"][&"accounts"] + for index in range(accounts.size() - 1, -1, -1): + if String(accounts[index][&"account_id"]) in ["MAINTENANCE_EXPENSE", "MAINTENANCE_PAYABLE"]: + accounts.remove_at(index) + +static func migrated_accrual(minute: int) -> Dictionary: + var accrual := FacilityUpkeepAccrual.new() + accrual.start_at(minute) + return accrual.create_snapshot() + +static func migrated_weather(minute: int) -> Dictionary: + var weather = preload("res://scripts/environment/weather_timeline.gd").new() + weather.initialize(&"clear", minute) + return weather.create_snapshot() diff --git a/tests/fixtures/pre_upkeep_runtime.gd.uid b/tests/fixtures/pre_upkeep_runtime.gd.uid new file mode 100644 index 0000000..361a252 --- /dev/null +++ b/tests/fixtures/pre_upkeep_runtime.gd.uid @@ -0,0 +1 @@ +uid://b0s2m5gt63axs diff --git a/tests/mvp/fixtures/README.md b/tests/mvp/fixtures/README.md new file mode 100644 index 0000000..2ad31a9 --- /dev/null +++ b/tests/mvp/fixtures/README.md @@ -0,0 +1,11 @@ +# Operations legacy fixture + +`operations_v3_0600.txt` is a Godot `var_to_str` snapshot generated using the unchanged +`OperationsCoordinator` at commit `6077e02`, before the midnight migration. +Only its class name was renamed to load it alongside the current coordinator. + +Start minute: 0. Advance target: 3300. Daily callback: revenue 100, wages 10, +arrears 10, no facilities/anomalies, reconciliation difference 0. +The two positive-profit summaries close at 1800 and 3240; the legacy arrears +counter is 2. These deliberately distinguish historical arrears from new losses. +The fixture is read as data with `str_to_var`; it never touches player saves. diff --git a/tests/mvp/fixtures/operations_v3_0600.txt b/tests/mvp/fixtures/operations_v3_0600.txt new file mode 100644 index 0000000..dc4d83d --- /dev/null +++ b/tests/mvp/fixtures/operations_v3_0600.txt @@ -0,0 +1,1398 @@ +{ +"alerts": [{ +"arrears_total": 10, +"code": "ARREARS_REMAIN", +"consecutive_count": 1, +"day": 1, +"minute": 1800 +}, { +"arrears_total": 10, +"code": "ARREARS_REMAIN", +"consecutive_count": 2, +"day": 2, +"minute": 3240 +}], +"arrears_total": 10, +"arrival_carry": [], +"arrival_plan_high_water": 0, +"arrival_reports": [{ +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 0, +"fair_start_index": 0, +"minute": 30, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 1, +"fair_start_index": 0, +"minute": 60, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 2, +"fair_start_index": 0, +"minute": 90, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 3, +"fair_start_index": 0, +"minute": 120, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 4, +"fair_start_index": 0, +"minute": 150, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 5, +"fair_start_index": 0, +"minute": 180, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 6, +"fair_start_index": 0, +"minute": 210, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 7, +"fair_start_index": 0, +"minute": 240, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 8, +"fair_start_index": 0, +"minute": 270, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 9, +"fair_start_index": 0, +"minute": 300, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 10, +"fair_start_index": 0, +"minute": 330, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 11, +"fair_start_index": 0, +"minute": 360, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 12, +"fair_start_index": 0, +"minute": 390, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 13, +"fair_start_index": 0, +"minute": 420, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 14, +"fair_start_index": 0, +"minute": 450, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 15, +"fair_start_index": 0, +"minute": 480, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 16, +"fair_start_index": 0, +"minute": 510, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 17, +"fair_start_index": 0, +"minute": 540, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 18, +"fair_start_index": 0, +"minute": 570, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 19, +"fair_start_index": 0, +"minute": 600, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 20, +"fair_start_index": 0, +"minute": 630, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 21, +"fair_start_index": 0, +"minute": 660, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 22, +"fair_start_index": 0, +"minute": 690, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 23, +"fair_start_index": 0, +"minute": 720, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 24, +"fair_start_index": 0, +"minute": 750, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 25, +"fair_start_index": 0, +"minute": 780, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 26, +"fair_start_index": 0, +"minute": 810, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 27, +"fair_start_index": 0, +"minute": 840, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 28, +"fair_start_index": 0, +"minute": 870, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 29, +"fair_start_index": 0, +"minute": 900, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 30, +"fair_start_index": 0, +"minute": 930, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 31, +"fair_start_index": 0, +"minute": 960, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 32, +"fair_start_index": 0, +"minute": 990, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 33, +"fair_start_index": 0, +"minute": 1020, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 34, +"fair_start_index": 0, +"minute": 1050, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 35, +"fair_start_index": 0, +"minute": 1080, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 36, +"fair_start_index": 0, +"minute": 1110, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 37, +"fair_start_index": 0, +"minute": 1140, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 38, +"fair_start_index": 0, +"minute": 1170, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 39, +"fair_start_index": 0, +"minute": 1200, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 40, +"fair_start_index": 0, +"minute": 1230, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 41, +"fair_start_index": 0, +"minute": 1260, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 42, +"fair_start_index": 0, +"minute": 1290, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 43, +"fair_start_index": 0, +"minute": 1320, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 44, +"fair_start_index": 0, +"minute": 1350, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 45, +"fair_start_index": 0, +"minute": 1380, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 46, +"fair_start_index": 0, +"minute": 1410, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 47, +"fair_start_index": 0, +"minute": 1440, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 48, +"fair_start_index": 0, +"minute": 1470, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 49, +"fair_start_index": 0, +"minute": 1500, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 50, +"fair_start_index": 0, +"minute": 1530, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 51, +"fair_start_index": 0, +"minute": 1560, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 52, +"fair_start_index": 0, +"minute": 1590, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 53, +"fair_start_index": 0, +"minute": 1620, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 54, +"fair_start_index": 0, +"minute": 1650, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 55, +"fair_start_index": 0, +"minute": 1680, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 56, +"fair_start_index": 0, +"minute": 1710, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 57, +"fair_start_index": 0, +"minute": 1740, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 58, +"fair_start_index": 0, +"minute": 1770, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 59, +"fair_start_index": 0, +"minute": 1800, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 60, +"fair_start_index": 0, +"minute": 1830, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 61, +"fair_start_index": 0, +"minute": 1860, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 62, +"fair_start_index": 0, +"minute": 1890, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 63, +"fair_start_index": 0, +"minute": 1920, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 64, +"fair_start_index": 0, +"minute": 1950, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 65, +"fair_start_index": 0, +"minute": 1980, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 66, +"fair_start_index": 0, +"minute": 2010, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 67, +"fair_start_index": 0, +"minute": 2040, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 68, +"fair_start_index": 0, +"minute": 2070, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 69, +"fair_start_index": 0, +"minute": 2100, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 70, +"fair_start_index": 0, +"minute": 2130, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 71, +"fair_start_index": 0, +"minute": 2160, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 72, +"fair_start_index": 0, +"minute": 2190, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 73, +"fair_start_index": 0, +"minute": 2220, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 74, +"fair_start_index": 0, +"minute": 2250, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 75, +"fair_start_index": 0, +"minute": 2280, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 76, +"fair_start_index": 0, +"minute": 2310, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 77, +"fair_start_index": 0, +"minute": 2340, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 78, +"fair_start_index": 0, +"minute": 2370, +"plans": [], +"population_after": 0, +"time_band": "DAY", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 79, +"fair_start_index": 0, +"minute": 2400, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 80, +"fair_start_index": 0, +"minute": 2430, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 81, +"fair_start_index": 0, +"minute": 2460, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 82, +"fair_start_index": 0, +"minute": 2490, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 83, +"fair_start_index": 0, +"minute": 2520, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 84, +"fair_start_index": 0, +"minute": 2550, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 85, +"fair_start_index": 0, +"minute": 2580, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 86, +"fair_start_index": 0, +"minute": 2610, +"plans": [], +"population_after": 0, +"time_band": "EVENING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 87, +"fair_start_index": 0, +"minute": 2640, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 88, +"fair_start_index": 0, +"minute": 2670, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 89, +"fair_start_index": 0, +"minute": 2700, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 90, +"fair_start_index": 0, +"minute": 2730, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 91, +"fair_start_index": 0, +"minute": 2760, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 92, +"fair_start_index": 0, +"minute": 2790, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 93, +"fair_start_index": 0, +"minute": 2820, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 94, +"fair_start_index": 0, +"minute": 2850, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 95, +"fair_start_index": 0, +"minute": 2880, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 96, +"fair_start_index": 0, +"minute": 2910, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 97, +"fair_start_index": 0, +"minute": 2940, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 98, +"fair_start_index": 0, +"minute": 2970, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 99, +"fair_start_index": 0, +"minute": 3000, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 100, +"fair_start_index": 0, +"minute": 3030, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 101, +"fair_start_index": 0, +"minute": 3060, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 102, +"fair_start_index": 0, +"minute": 3090, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 103, +"fair_start_index": 0, +"minute": 3120, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 104, +"fair_start_index": 0, +"minute": 3150, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 105, +"fair_start_index": 0, +"minute": 3180, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 106, +"fair_start_index": 0, +"minute": 3210, +"plans": [], +"population_after": 0, +"time_band": "NIGHT", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 107, +"fair_start_index": 0, +"minute": 3240, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 108, +"fair_start_index": 0, +"minute": 3270, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}, { +"capacity_remaining": 100, +"carry_capped": [], +"fair_rotation_counter": 109, +"fair_start_index": 0, +"minute": 3300, +"plans": [], +"population_after": 0, +"time_band": "MORNING", +"total_count": 0 +}], +"config": { +"arrival_carry_interval_limit": 3, +"arrival_rate_denominator": 100, +"arrival_rate_numerator": 1, +"bankruptcy_close_count": 3, +"max_arrivals_per_interval": 10, +"max_component_magnitude": 10000, +"population_cap": 100, +"world_seed": 1 +}, +"consecutive_insolvency_count": 2, +"current_minute": 3300, +"day_summaries": [{ +"anomalies": [], +"arrears_total": 10, +"balanced": true, +"category_totals": { +"construction_cash_flow": 0, +"cost_of_goods": 0, +"fines": 0, +"maintenance": 0, +"refunds": 0, +"revenue": 100, +"taxes_and_interest": 0, +"utilities": 0, +"wages": 10 +}, +"closed_at": 1800, +"consecutive_insolvency_count": 1, +"day": 1, +"facility_profit_loss": {}, +"net_profit": 90, +"period_end_minute_exclusive": 1800, +"period_start_minute": 360, +"previous_day_delta": 0, +"reconciliation_difference": 0, +"settlement": { +"arrears_total": 10 +}, +"seven_day_average": 90, +"seven_day_average_detail": { +"count": 1, +"quotient": 90, +"remainder": 0, +"sum": 90 +}, +"terminal_state": "RUNNING" +}, { +"anomalies": [], +"arrears_total": 10, +"balanced": true, +"category_totals": { +"construction_cash_flow": 0, +"cost_of_goods": 0, +"fines": 0, +"maintenance": 0, +"refunds": 0, +"revenue": 100, +"taxes_and_interest": 0, +"utilities": 0, +"wages": 10 +}, +"closed_at": 3240, +"consecutive_insolvency_count": 2, +"day": 2, +"facility_profit_loss": {}, +"net_profit": 90, +"period_end_minute_exclusive": 3240, +"period_start_minute": 1800, +"previous_day_delta": 0, +"reconciliation_difference": 0, +"settlement": { +"arrears_total": 10 +}, +"seven_day_average": 90, +"seven_day_average_detail": { +"count": 2, +"quotient": 90, +"remainder": 0, +"sum": 180 +}, +"terminal_state": "RUNNING" +}], +"deferred_boundary_minute": -1, +"demand_history": [{ +"minute": 60, +"values": [] +}, { +"minute": 120, +"values": [] +}, { +"minute": 180, +"values": [] +}, { +"minute": 240, +"values": [] +}, { +"minute": 300, +"values": [] +}, { +"minute": 360, +"values": [] +}, { +"minute": 420, +"values": [] +}, { +"minute": 480, +"values": [] +}, { +"minute": 540, +"values": [] +}, { +"minute": 600, +"values": [] +}, { +"minute": 660, +"values": [] +}, { +"minute": 720, +"values": [] +}, { +"minute": 780, +"values": [] +}, { +"minute": 840, +"values": [] +}, { +"minute": 900, +"values": [] +}, { +"minute": 960, +"values": [] +}, { +"minute": 1020, +"values": [] +}, { +"minute": 1080, +"values": [] +}, { +"minute": 1140, +"values": [] +}, { +"minute": 1200, +"values": [] +}, { +"minute": 1260, +"values": [] +}, { +"minute": 1320, +"values": [] +}, { +"minute": 1380, +"values": [] +}, { +"minute": 1440, +"values": [] +}, { +"minute": 1500, +"values": [] +}, { +"minute": 1560, +"values": [] +}, { +"minute": 1620, +"values": [] +}, { +"minute": 1680, +"values": [] +}, { +"minute": 1740, +"values": [] +}, { +"minute": 1800, +"values": [] +}, { +"minute": 1860, +"values": [] +}, { +"minute": 1920, +"values": [] +}, { +"minute": 1980, +"values": [] +}, { +"minute": 2040, +"values": [] +}, { +"minute": 2100, +"values": [] +}, { +"minute": 2160, +"values": [] +}, { +"minute": 2220, +"values": [] +}, { +"minute": 2280, +"values": [] +}, { +"minute": 2340, +"values": [] +}, { +"minute": 2400, +"values": [] +}, { +"minute": 2460, +"values": [] +}, { +"minute": 2520, +"values": [] +}, { +"minute": 2580, +"values": [] +}, { +"minute": 2640, +"values": [] +}, { +"minute": 2700, +"values": [] +}, { +"minute": 2760, +"values": [] +}, { +"minute": 2820, +"values": [] +}, { +"minute": 2880, +"values": [] +}, { +"minute": 2940, +"values": [] +}, { +"minute": 3000, +"values": [] +}, { +"minute": 3060, +"values": [] +}, { +"minute": 3120, +"values": [] +}, { +"minute": 3180, +"values": [] +}, { +"minute": 3240, +"values": [] +}, { +"minute": 3300, +"values": [] +}], +"demand_profiles": [], +"external_quarantined": false, +"fair_rotation_counter": 110, +"guest_high_water": 0, +"morning_reports": [{ +"day": 1, +"event_payload": {}, +"minute": 360 +}, { +"day": 2, +"event_payload": {}, +"minute": 1800 +}, { +"day": 3, +"event_payload": {}, +"minute": 3240 +}], +"next_arrival_plan_sequence": 1, +"next_guest_sequence": 1, +"origin_minute": 0, +"pause_reason": "", +"pause_requested": false, +"pending_boundary_minute": -1, +"population_count": 0, +"processed_arrival_minutes": [30, 60, 90, 120, 150, 180, 210, 240, 270, 300, 330, 360, 390, 420, 450, 480, 510, 540, 570, 600, 630, 660, 690, 720, 750, 780, 810, 840, 870, 900, 930, 960, 990, 1020, 1050, 1080, 1110, 1140, 1170, 1200, 1230, 1260, 1290, 1320, 1350, 1380, 1410, 1440, 1470, 1500, 1530, 1560, 1590, 1620, 1650, 1680, 1710, 1740, 1770, 1800, 1830, 1860, 1890, 1920, 1950, 1980, 2010, 2040, 2070, 2100, 2130, 2160, 2190, 2220, 2250, 2280, 2310, 2340, 2370, 2400, 2430, 2460, 2490, 2520, 2550, 2580, 2610, 2640, 2670, 2700, 2730, 2760, 2790, 2820, 2850, 2880, 2910, 2940, 2970, 3000, 3030, 3060, 3090, 3120, 3150, 3180, 3210, 3240, 3270, 3300], +"processed_demand_minutes": [60, 120, 180, 240, 300, 360, 420, 480, 540, 600, 660, 720, 780, 840, 900, 960, 1020, 1080, 1140, 1200, 1260, 1320, 1380, 1440, 1500, 1560, 1620, 1680, 1740, 1800, 1860, 1920, 1980, 2040, 2100, 2160, 2220, 2280, 2340, 2400, 2460, 2520, 2580, 2640, 2700, 2760, 2820, 2880, 2940, 3000, 3060, 3120, 3180, 3240, 3300], +"quarantine_report": {}, +"schema_version": 3, +"terminal_state": "RUNNING" +} \ No newline at end of file diff --git a/tests/mvp/test_amelia_donations.gd b/tests/mvp/test_amelia_donations.gd new file mode 100644 index 0000000..8c390cf --- /dev/null +++ b/tests/mvp/test_amelia_donations.gd @@ -0,0 +1,52 @@ +extends "res://tests/ui/test_chapel_gameplay.gd" +## Original effect fixture only: recruitment values remain unconfigured. +func cleric_id() -> StringName: return &"heroine_amelia" +func donation_price() -> int: return 60 +func recruit_cleric(_main: Control, runtime: Variant) -> void: + expect(runtime.submit_command(&"register_staff", {&"staff_id":cleric_id(), &"role_id":&"cleric", &"options":{&"employment_kind":&"UNIQUE", &"current_cell":Vector3i(-8,0,0)}}).is_success(), "register unique Amelia test fixture") +func verify_effect(runtime: Variant, chapel: FacilityInstance) -> void: + expect(runtime.get_facility_business_price(chapel)==60, "assigned Amelia triples chapel donation") + var saved: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(saved), "placement passive restores") + chapel=runtime.facility_service.get_instance(chapel.instance_id) + expect(runtime.get_facility_business_price(chapel)==60, "restoring never stacks donation multiplier") + var worker: StaffRuntime=runtime.staff_service.get_staff(cleric_id()) + worker.employment_kind=StaffRuntime.EmploymentKind.FREE_HIRE + expect(runtime.get_facility_business_price(chapel)==20, "same ID with ordinary employment has no unique effect") + worker.employment_kind=StaffRuntime.EmploymentKind.UNIQUE + worker.role_id=&"porter" + expect(runtime.get_facility_business_price(chapel)==20, "unrelated role has no donation effect") + worker.role_id=&"cleric" + var other:=FacilityInstance.new() + other.instance_id=&"other_chapel";other.definition_id=&"chapel" + expect(runtime.get_donation_multiplier(other)==1, "other chapel does not inherit placement effect") + other.instance_id=chapel.instance_id;other.definition_id=&"standard_room" + expect(runtime.get_donation_multiplier(other)==1, "lodging is not a donation") + expect(runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"heroine_anri"}).is_success(), "normal Anri recruitment for combined effect") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri", &"facility_id":chapel.instance_id, &"template_id":&"DAY"}).is_success(), "assign manager alongside chapel cleric") + expect(runtime.get_facility_business_price(chapel)==72, "triple donation combines once with approved floor20percent") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri", &"facility_id":&"", &"template_id":&"DAY"}).is_success(), "remove manager without changing cleric") + expect(runtime.get_facility_business_price(chapel)==60, "removing manager keeps exactly one donation passive") + var business: BusinessManagementPanel=root.get_node("Main").find_child("BusinessManagementPanel",true,false) + business.select_facility(chapel.instance_id) + expect(business.quote_label.text.contains("60G") and business.effect_label.text.contains("寄付金3倍"), "normal business UI explains actual donation") +func verify_after_start(runtime: Variant, chapel: FacilityInstance) -> void: + var guest: GuestRuntime=runtime.guest_service.get_guest(&"worship_walker") + var service: ServiceInstance=runtime.facility_service.get_service(guest.current_service_id) + expect(service.price_snapshot==60, "autonomous visitor freezes triple donation before worship") + expect(runtime.submit_command(&"set_facility_policy", {&"facility_id":chapel.instance_id, &"patch":{&"price_mode":&"expensive"}}).is_success(), "change donation policy during worship") + expect(runtime.get_facility_business_price(runtime.facility_service.get_instance(chapel.instance_id))==90 and service.price_snapshot==60, "new90G quote never rewrites accepted60G donation") + var saved: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(saved), "save active fixed quote with changed current policy") + expect(runtime.facility_service.get_service(service.service_id).price_snapshot==60, "restore preserves original donation") + expect(runtime.submit_command(&"set_facility_policy", {&"facility_id":chapel.instance_id, &"patch":{&"price_mode":&"standard"}}).is_success(), "return policy through ordinary command") + +func verify_completion(runtime: Variant, chapel: FacilityInstance, service_id: StringName) -> void: + var sales := 0 + var donor_paid := 0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.source_entity_id==chapel.instance_id and entry.category==&"SERVICE_SALE": + sales+=entry.amount + if entry.credit_account==&"GUEST_WALLET:worship_walker": donor_paid+=entry.amount + expect(donor_paid==60 and sales>=donor_paid and runtime.economy_service.validate_ledger()[&"ok"], "visitor60G payment is recorded exactly once as chapel revenue") + expect(runtime.facility_service.get_service(service_id).price_snapshot==60, "completion retains original quote") diff --git a/tests/mvp/test_amelia_donations.gd.uid b/tests/mvp/test_amelia_donations.gd.uid new file mode 100644 index 0000000..47ccdf6 --- /dev/null +++ b/tests/mvp/test_amelia_donations.gd.uid @@ -0,0 +1 @@ +uid://b1fq74rwro825 diff --git a/tests/mvp/test_anri_floor_pricing.gd b/tests/mvp/test_anri_floor_pricing.gd new file mode 100644 index 0000000..b9cc74f --- /dev/null +++ b/tests/mvp/test_anri_floor_pricing.gd @@ -0,0 +1,118 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0); state.reset(); runtime.initialize(clock,state,42602); clock.set_paused(true) + for row: Array in [[&"reception",Vector3i(-6,0,0)],[&"warehouse",Vector3i(-6,0,1)],[&"premium_room",Vector3i(-6,2,0)],[&"warehouse",Vector3i(-6,2,1)],[&"service_stairs",Vector3i(-7,1,1)],[&"service_stairs",Vector3i(-7,2,1)]]: + var built: CommandResult = runtime.submit_command(&"build_facility",{&"definition_id":row[0],&"origin":row[1]}) + expect(built.is_success(), "build floor-effect fixture %s: %s" % [row[0],built.message]) + if not built.is_success(): runtime.free();clock.free();state.free();quit(failures);return + clock.advance_minutes(runtime.definition_registry.get_facility(row[0]).build_minutes) + var room: FacilityInstance + var workplace: FacilityInstance + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"premium_room": room=facility + if facility.definition_id == &"warehouse" and facility.origin.y==2: workplace=facility + expect(runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"heroine_anri"}).is_success(), "recruit canonical free Anri") + var assigned: CommandResult=runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri",&"facility_id":workplace.instance_id,&"template_id":&"DAY"}) + expect(assigned.is_success(), "manager placement uses real capacity and accessible path: %s" % assigned.message) + expect(runtime.get_floor_price_multiplier(2)==12000 and runtime.get_floor_price_multiplier(0)==10000, "placement affects only assigned floor across layers") + expect(runtime.get_facility_business_price(room)==264, "premium room on opposite layer quotes 220 plus20 percent") + clock.advance_minutes(20) + expect(workplace.active_staff_ids.is_empty(), "manager placement does not impersonate required warehouse porters") + var rows: Array=runtime.get_business_management_report()[&"facilities"] + for row: Dictionary in rows: + if row[&"facility_id"]==room.instance_id: expect(row[&"price"]==264 and row[&"floor_price_multiplier_bp"]==12000, "business UI report matches charged price") + expect(runtime.submit_command(&"register_guest_wallet",{&"guest_id":&"anri_payer",&"gold":1000}).is_success(), "fund controlled service wallet") + expect(runtime.submit_command(&"create_service_request",{&"request_id":&"anri_stay",&"facility_id":room.instance_id,&"guest_ids":[&"anri_payer"],&"service_type":&"lodging"}).is_success(), "normal request captures floor-adjusted price") + clock.advance_one_minute() + var active: Array=runtime.facility_service.get_active_services(room.instance_id) + expect(active.size()==1 and active[0].price_snapshot==264, "started stay freezes approved guest price") + if active.is_empty(): runtime.free();clock.free();state.free();quit(failures);return + var service_id: StringName=active[0].service_id + var saved: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(saved), "save restores unique placement and frozen service together") + expect(runtime.get_floor_price_multiplier(2)==12000, "restored placement derives multiplier without stacking") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri",&"facility_id":&"",&"template_id":&"DAY"}).is_success(), "unassign manager while guest stay continues") + expect(runtime.get_floor_price_multiplier(2)==10000 and runtime.get_facility_business_price(runtime.facility_service.get_instance(room.instance_id))==220, "new prices revert after unassignment") + clock.advance_minutes(721) + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED, "frozen-price service completes normally") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:anri_payer")==736, "customer pays exactly264 once despite later unassignment") + var revenue:=0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.source_entity_id==room.instance_id and entry.category==&"SERVICE_SALE": revenue+=entry.amount + expect(revenue==264 and runtime.economy_service.validate_ledger()[&"ok"], "facility receives exactly the customer's payment") + autonomous_flow(runtime,clock,room.instance_id,workplace.instance_id) + placement_boundaries(runtime,room.instance_id,workplace.instance_id) + runtime.free();clock.free();state.free() + print("Anri floor pricing: %d checks, %d failures" % [checks,failures]);quit(failures) + +func autonomous_flow(runtime: Variant, clock: Variant, room_id: StringName, workplace_id: StringName) -> void: + for floor_y in [1,2]: + var built: CommandResult=runtime.submit_command(&"build_facility",{&"definition_id":&"guest_stairs",&"origin":Vector3i(-7,floor_y,0)}) + expect(built.is_success(), "build public stairs for autonomous guest") + if not built.is_success(): return + clock.advance_minutes(runtime.definition_registry.get_facility(&"guest_stairs").build_minutes) + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri",&"facility_id":workplace_id,&"template_id":&"DAY"}).is_success(), "reassign manager for autonomous guest flow") + expect(runtime.submit_command(&"spawn_guest",{&"guest_id":&"anri_walker",&"guest_type_id":&"traveler",&"gold":1000, + &"options":{&"current_cell":Vector3i(-7,0,0),&"state":GuestRuntime.State.DECIDING,&"planned_exit_at":clock.absolute_minute+2000, + &"needs":{&"sleep":100,&"hunger":0,&"hygiene":0,&"medical":0,&"leisure":0,&"intimacy":0}}}).is_success(), "spawn guest without forcing destination, route or price") + var selected := false + var moving_save: Dictionary={} + var service_id: StringName=&"" + for minute in 40: + clock.advance_one_minute() + var guest: GuestRuntime=runtime.guest_service.get_guest(&"anri_walker") + if guest==null: break + selected=selected or guest.target_facility_id==room_id + if guest.current_cell==Vector3i(-7,1,0) and moving_save.is_empty(): moving_save=runtime.create_snapshot() + if not guest.current_service_id.is_empty(): service_id=guest.current_service_id;break + expect(selected, "autonomous needs select Anri-adjusted premium lodging") + expect(not moving_save.is_empty(), "guest physically traverses public stairs") + expect(not service_id.is_empty(), "arrival automatically starts service") + if service_id.is_empty() or moving_save.is_empty(): return + var started: Dictionary=runtime.create_snapshot() + expect(runtime.facility_service.get_service(service_id).price_snapshot==264, "autonomous service freezes same 264G price as management quote") + expect(runtime.restore_snapshot(moving_save), "restore guest movement and Anri assignment together") + for minute in 40: + clock.advance_one_minute() + if not runtime.guest_service.get_guest(&"anri_walker").current_service_id.is_empty(): break + expect(runtime.create_snapshot()==started, "movement save replay reproduces adjusted service exactly") + for minute in 721: + clock.advance_one_minute() + if runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED: break + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED, "autonomous adjusted stay completes") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:anri_walker")==736, "autonomous guest pays exactly264G once") + var revenue:=0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.source_entity_id==room_id and entry.category==&"SERVICE_SALE": revenue+=entry.amount + expect(revenue==528 and runtime.economy_service.validate_ledger()[&"ok"], "both264G payments equal facility revenue with balanced ledger") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "completed autonomous guest and adjusted ledger remain saveable") + +func placement_boundaries(runtime: Variant, room_id: StringName, upper_workplace_id: StringName) -> void: + var ground_id: StringName=&"" + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id==&"warehouse" and facility.origin.y==0: ground_id=facility.instance_id + var transfer: CommandResult=runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri",&"facility_id":ground_id,&"template_id":&"NIGHT"}) + expect(transfer.is_success(), "move manager to another floor with night shift") + expect(runtime.get_floor_price_multiplier(0)==12000 and runtime.get_floor_price_multiplier(2)==10000, "floor transfer removes old bonus and applies new bonus exactly once") + expect(runtime.get_facility_business_price(runtime.facility_service.get_instance(room_id))==220, "old floor quotes base lodging price after transfer") + var assigned: Dictionary=runtime.staff_service.create_snapshot() + var rejected: CommandResult=runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri",&"facility_id":room_id,&"template_id":&"NIGHT"}) + expect(not rejected.is_success() and rejected.details.get(&"reason")==&"FACILITY_UNSTAFFED", "manager cannot bypass zero staff capacity") + expect(runtime.staff_service.create_snapshot()==assigned and runtime.get_floor_price_multiplier(0)==12000, "rejected placement preserves schedule and active floor bonus") + var demolition: CommandResult=runtime.submit_command(&"demolish_facility",{&"instance_id":ground_id}) + expect(not demolition.is_success() and demolition.details.get(&"reason")==&"STAFF_ASSIGNED", "assigned manager prevents workplace demolition") + expect(runtime.facility_service.get_instance(ground_id)!=null and runtime.get_floor_price_multiplier(0)==12000, "failed demolition preserves workplace and bonus") + var saved: Dictionary=runtime.create_snapshot() + for repetition in 3: + expect(runtime.restore_snapshot(saved) and runtime.create_snapshot()==saved and runtime.get_floor_price_multiplier(0)==12000 and runtime.get_floor_price_multiplier(2)==10000, "repeated night-assignment load preserves exact state without stacking") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"heroine_anri",&"facility_id":upper_workplace_id,&"template_id":&"DAY"}).is_success(), "return manager to original floor") + expect(runtime.get_floor_price_multiplier(0)==10000 and runtime.get_facility_business_price(runtime.facility_service.get_instance(room_id))==264, "return transfer restores only original floor bonus") diff --git a/tests/mvp/test_anri_floor_pricing.gd.uid b/tests/mvp/test_anri_floor_pricing.gd.uid new file mode 100644 index 0000000..3e0e885 --- /dev/null +++ b/tests/mvp/test_anri_floor_pricing.gd.uid @@ -0,0 +1 @@ +uid://5my1upi2ch2t diff --git a/tests/mvp/test_approved_free_unique_arrears.gd b/tests/mvp/test_approved_free_unique_arrears.gd new file mode 100644 index 0000000..f79a1ea --- /dev/null +++ b/tests/mvp/test_approved_free_unique_arrears.gd @@ -0,0 +1,219 @@ +extends SceneTree +## Candidate only; not compiled or executed. No new production tuning values. +## The only cash fixture is a disclosed ledger spend at 23:59. Production +## reception cost, build time and upkeep rates remain exactly unchanged. +class ExplicitHireCatalog: + extends MvpContentCatalog + var fixture_payload: Dictionary = {} + func staff_hire_payload(candidate_id: StringName) -> Dictionary: + return fixture_payload.duplicate(true) if candidate_id == &"heroine_anri" else super.staff_hire_payload(candidate_id) + +class GateReasonFixture: + extends OperationsCoordinator + var reason: StringName = &"UNPAID_EXPENSES" + func can_start_construction_or_investment() -> Dictionary: + return {&"allowed":false,&"reason":reason,&"arrears_total":1} + +var checks := 0 +var failures := 0 +var next_direct_id := 980_000 + +func _initialize() -> void: + call_deferred("run") + +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("FREE_UNIQUE: " + label) + +func direct_command(payload: Dictionary) -> SimulationCommand: + next_direct_id += 1 + return SimulationCommand.new(next_direct_id,0,0,0,&"hire_staff_candidate",&"player",&"",payload) + +func fixture() -> Dictionary: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0) + state.reset() + expect(runtime.initialize(clock,state,424242)[&"valid"],"canonical content initializes") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"],"real production Operations enabled") + var built: CommandResult = runtime.submit_command(&"build_facility",{&"definition_id":&"reception",&"origin":Vector3i(-6,0,0)}) + expect(built.is_success(),"normal construction command creates reception") + if not built.is_success(): + return {&"runtime":runtime,&"clock":clock,&"state":state,&"ok":false} + clock.advance_minutes(runtime.definition_registry.get_facility(&"reception").build_minutes) + clock.advance_minutes(1439-clock.absolute_minute) + var cash: int = runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) + # Disclosed economic boundary fixture, not evidence of natural spending. + var spent: CommandResult = runtime.economy_service.transfer(runtime.PLAYER_CASH_ACCOUNT,&"CONSTRUCTION_EXPENSE",cash,clock.absolute_minute,&"CONSTRUCTION",&"",&"acceptance.free_unique.cash_fixture") + expect(spent.is_success(),"controlled ledger spend leaves no available cash") + clock.advance_one_minute() + expect(clock.absolute_minute == 1440 and runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) == 0,"real first midnight reached with zero cash") + expect(runtime.upkeep_settlement.get_arrears_total() > 0 and runtime.operations_coordinator.get_arrears_total() == runtime.upkeep_settlement.get_arrears_total(),"actual approved upkeep produces reconciled unpaid balance") + expect(not runtime.operations_coordinator.is_terminal() and not runtime._operations_should_block_tick(),"ordinary unpaid Operations remain running") + return {&"runtime":runtime,&"clock":clock,&"state":state,&"ok":true} + +func free_fixture(value: Dictionary) -> void: + value[&"runtime"].free() + value[&"clock"].free() + value[&"state"].free() + +func rejected_atomic(runtime: Variant, payload: Dictionary, label: String) -> CommandResult: + var before: Dictionary = runtime.create_snapshot() + var result: CommandResult = runtime._execute_hire_staff_candidate(direct_command(payload)) + expect(not result.is_success() and runtime.create_snapshot() == before,label + " rejects before any Runtime state mutation") + return result + +func strict_factory_and_state_guards(runtime: Variant) -> void: + var paid_payload := {&"candidate_id":&"staff_tessa",&"staff_id":&"heroine_anri",&"signing_cost":0,&"daily_wage":0,&"employment_kind":&"UNIQUE",&"options":{&"employment_kind":&"UNIQUE",&"daily_wage":0}} + var paid := rejected_atomic(runtime,paid_payload,"forged free fields on paid Tessa") + expect(paid.details.get(&"reason") == &"ARREARS_REMAIN","paid canonical costs retain arrears gate") + for payload: Dictionary in [{&"candidate_id":&"heroine_amelia"},{&"candidate_id":&"rival_rai"},{&"candidate_id":true},{&"candidate_id":&"unknown",&"staff_id":&"heroine_anri",&"employment_kind":&"UNIQUE"}]: + rejected_atomic(runtime,payload,"unapproved identity cannot claim Anri exception") + for payload: Dictionary in [{&"candidate_id":&"heroine_anri",&"current_cell":Vector3i(99999,99999,0)},{&"candidate_id":&"heroine_anri",&"current_cell":"bad"},{&"candidate_id":&"heroine_anri",&"facility_id":&"absent-workplace"}]: + rejected_atomic(runtime,payload,"free cost cannot bypass placement or path validation") + var source_catalog: MvpContentCatalog = runtime.mvp_content_catalog + var canonical := source_catalog.staff_hire_payload(&"heroine_anri") + var alterations: Array[Dictionary] = [] + for field: StringName in [&"signing_cost",&"daily_wage"]: + for invalid: Variant in [1,0.0,"0",false,null]: + var changed := canonical.duplicate(true) + changed[field] = invalid + alterations.append(changed) + for invalid: Variant in [1,0.0,"0",false,null]: + var changed := canonical.duplicate(true) + changed[&"options"][&"daily_wage"] = invalid + alterations.append(changed) + for fields: Dictionary in [{&"staff_id":&"staff_tessa"},{&"role_id":&"receptionist"},{&"adult_verified":false}]: + var changed := canonical.duplicate(true) + changed.merge(fields,true) + alterations.append(changed) + var generic := canonical.duplicate(true) + generic[&"options"][&"employment_kind"] = &"FREE_HIRE" + alterations.append(generic) + for changed: Dictionary in alterations: + var replacement := ExplicitHireCatalog.new() + expect(replacement.rebuild()[&"valid"],"factory override starts from otherwise valid catalog") + replacement.fixture_payload = changed + var before: Dictionary = runtime.create_snapshot() + runtime.mvp_content_catalog = replacement + var denied: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + runtime.mvp_content_catalog = source_catalog + expect(not denied.is_success() and runtime.create_snapshot() == before,"nonzero/mistyped/identity/employment/adult canonical payload cannot use exemption") + var invalid_catalog := ExplicitHireCatalog.new() # deliberately not rebuilt + invalid_catalog.fixture_payload = canonical + var invalid_before: Dictionary = runtime.create_snapshot() + runtime.mvp_content_catalog = invalid_catalog + var invalid_result: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + runtime.mvp_content_catalog = source_catalog + expect(not invalid_result.is_success() and runtime.create_snapshot() == invalid_before,"invalid catalog cannot qualify despite a copied free payload") + # Injected safety boundaries are executor guards only, not valid-save or + # normal-production fixture claims. Restore fields before canonical snapshots. + var coordinator: OperationsCoordinator = runtime.operations_coordinator + for field: String in ["pause_requested","_external_quarantined"]: + var previous: Variant = coordinator.get(field) + var staff_before: Dictionary = runtime.staff_service.create_snapshot() + var economy_before: Dictionary = runtime.economy_service.create_snapshot() + coordinator.set(field,true) + var denied: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + coordinator.set(field,previous) + expect(not denied.is_success() and runtime.staff_service.create_snapshot() == staff_before and runtime.economy_service.create_snapshot() == economy_before,"paused/quarantined Operations do not permit free joining") + var prior_failure: bool = runtime._operations_runtime_failure_latched + runtime._operations_runtime_failure_latched = true + var failure_denied: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + runtime._operations_runtime_failure_latched = prior_failure + expect(not failure_denied.is_success(),"latched Runtime failure never hides behind free expense") + var reason_fixture := GateReasonFixture.new() + runtime.operations_coordinator = reason_fixture + var canonical_free: bool = runtime._is_approved_free_unique_hire(&"heroine_anri",canonical) + expect(canonical_free,"actual canonical approved payload qualifies for exact free policy") + for reason: StringName in [&"BANKRUPT",&"EXTERNAL_ROLLBACK_FAILED",&"FUTURE_BLOCK"]: + reason_fixture.reason = reason + var denied: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + expect(not denied.is_success() and denied.details.get(&"reason") == reason,"only explicit unpaid reasons may be exempted") + runtime.operations_coordinator = coordinator + var unpaid_expenses_before: Dictionary = runtime.create_snapshot() + reason_fixture.reason = &"UNPAID_EXPENSES" + runtime.operations_coordinator = reason_fixture + var zero_cost: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + runtime.operations_coordinator = coordinator + expect(zero_cost.is_success() and zero_cost.details.get(&"employment_kind") == &"UNIQUE","explicit UNPAID_EXPENSES gate permits canonical zero-cost joining") + expect(runtime.restore_snapshot(unpaid_expenses_before) and runtime.create_snapshot() == unpaid_expenses_before,"synthetic gate proof restores the original valid unpaid Runtime") + var callback := Callable(runtime,"_on_staff_registered") + expect(runtime.staff_service.staff_registered.is_connected(callback),"normal employee projection callback exists") + if runtime.staff_service.staff_registered.is_connected(callback): + var before: Dictionary = runtime.create_snapshot() + runtime.staff_service.staff_registered.disconnect(callback) + var failed: CommandResult = runtime._execute_hire_staff_candidate(direct_command({&"candidate_id":&"heroine_anri"})) + runtime.staff_service.staff_registered.connect(callback) + expect(not failed.is_success() and runtime.create_snapshot() == before,"post-creation projection failure atomically rolls free Staff/Shift/Path/history/ledger back") + +func run() -> void: + var current := fixture() + if not current[&"ok"]: + free_fixture(current) + quit(failures) + return + var runtime: Variant = current[&"runtime"] + var clock: Variant = current[&"clock"] + strict_factory_and_state_guards(runtime) + var economy_before: Dictionary = runtime.economy_service.create_snapshot() + var arrears_before: int = runtime.upkeep_settlement.get_arrears_total() + var key: StringName = &"acceptance.approved_anri.unpaid" + var joined: CommandResult = runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"heroine_anri",&"employment_kind":&"FREE_HIRE",&"daily_wage":999,&"signing_cost":999},&"player",&"heroine_anri",75,-1,key) + expect(joined.is_success(),"zero-cash unpaid player joins approved Anri through real command queue") + if not joined.is_success(): + free_fixture(current) + quit(failures) + return + var anri: StaffRuntime = runtime.staff_service.get_staff(&"heroine_anri") + expect(anri != null and anri.employment_kind == StaffRuntime.EmploymentKind.UNIQUE and anri.daily_wage == 0,"caller-paid aliases cannot change canonical free UNIQUE employee") + expect(joined.details.get(&"employment_kind") == &"UNIQUE" and joined.details.get(&"signing_cost") == 0 and joined.details.get(&"daily_wage") == 0,"successful public result describes actual zero-cost UNIQUE contract") + expect(runtime.economy_service.create_snapshot() == economy_before and runtime.upkeep_settlement.get_arrears_total() == arrears_before,"free recruitment neither spends cash nor forgives debt") + var hire_events := 0 + for event: SimulationDomainEvent in runtime.domain_events.recent_events(): + if event.event_type == &"STAFF_CANDIDATE_HIRED" and &"heroine_anri" in event.entity_ids: + hire_events += 1 + expect(event.payload.get(&"employment_kind") == &"UNIQUE" and event.payload.get(&"signing_cost") == 0,"one emitted hire event matches canonical mode and amount") + expect(hire_events == 1,"one successful joining event") + var committed: Dictionary = runtime.create_snapshot() + var resent: CommandResult = runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"heroine_anri"},&"player",&"heroine_anri",75,-1,key) + expect(not resent.is_success() and resent.code == CommandResult.Code.DUPLICATE_IDEMPOTENCY_KEY and runtime.create_snapshot() == committed,"same queued request cannot join or charge twice") + var stored: CommandResult = runtime.command_queue.get_result_by_idempotency_key(key) + expect(stored != null and stored.is_success() and stored.is_replay and stored.details.get(&"employment_kind") == &"UNIQUE","persisted result replay retains corrected UNIQUE mode") + rejected_atomic(runtime,{&"candidate_id":&"heroine_anri"},"different request cannot duplicate employee") + expect(not runtime.staff_service.remove_staff(&"heroine_anri",&"SOLD").is_success() and not runtime.staff_service.retire_staff(&"heroine_anri").is_success() and runtime.create_snapshot() == committed,"free joining retains original sale/retirement protection") + var copy := fixture_shell() + var resumed: Variant = copy[&"runtime"] + expect(resumed.restore_snapshot(committed) and resumed.create_snapshot() == committed,"new Runtime instance restores complete unpaid joined state exactly") + var replay: CommandResult = resumed.command_queue.get_result_by_idempotency_key(key) + expect(replay != null and replay.details.get(&"employment_kind") == &"UNIQUE" and resumed.get_staff_management_report()[&"staff"][0][&"daily_wage"] == 0,"load retains queue result mode and management zero upkeep") + var corrupted := committed.duplicate(true) + for row: Dictionary in corrupted[&"systems"][&"runtime"][&"staff"][&"staff"]: + if row[&"staff_id"] == "heroine_anri": + row[&"employment_kind"] = "FREE_HIRE" + expect(not resumed.restore_snapshot(corrupted) and resumed.create_snapshot() == committed,"saved employee cannot be downgraded to removable ordinary hire") + free_fixture(copy) + clock.advance_minutes(2880-clock.absolute_minute) + expect(runtime.operations_coordinator.get_day_summary(2)[&"category_totals"][&"wages"] == 0 and runtime.staff_service.get_daily_payroll_total() == 0,"following actual midnight creates no unique salary") + clock.advance_minutes(4320-clock.absolute_minute) + expect(runtime.operations_coordinator.is_terminal(),"actual third operating loss reaches existing BANKRUPT") + var terminal: Dictionary = runtime.create_snapshot() + var denied: CommandResult = runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"heroine_anri"}) + expect(not denied.is_success() and denied.details.get(&"reason") == &"BANKRUPT" and runtime.create_snapshot() == terminal,"terminal normal API stays blocked before command acceptance") + free_fixture(current) + print("APPROVED_FREE_UNIQUE checks=%d failures=%d" % [checks,failures]) + quit(failures) + +func fixture_shell() -> Dictionary: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0) + state.reset() + expect(runtime.initialize(clock,state,424242)[&"valid"],"separate restore Runtime initializes") + clock.set_paused(true) + return {&"runtime":runtime,&"clock":clock,&"state":state} diff --git a/tests/mvp/test_approved_free_unique_arrears.gd.uid b/tests/mvp/test_approved_free_unique_arrears.gd.uid new file mode 100644 index 0000000..1fe995b --- /dev/null +++ b/tests/mvp/test_approved_free_unique_arrears.gd.uid @@ -0,0 +1 @@ +uid://c3emkf34nje4k diff --git a/tests/mvp/test_chapel_boundaries.gd b/tests/mvp/test_chapel_boundaries.gd new file mode 100644 index 0000000..cdbf1cf --- /dev/null +++ b/tests/mvp/test_chapel_boundaries.gd @@ -0,0 +1,88 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok:bool,label:String)->void: + checks+=1 + if not ok:failures+=1;push_error(label) +func _initialize()->void:call_deferred("run") +func run()->void: + var runtime:Variant=load("res://autoload/simulation_runtime.gd").new() + var clock:Variant=load("res://autoload/game_clock.gd").new() + var state:Variant=load("res://autoload/game_state.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42608);clock.set_paused(true) + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"chapel",&"origin":Vector3i(-6,0,0)}).is_success(),"build normal catalog chapel") + clock.advance_minutes(180) + var chapel:FacilityInstance + for f:FacilityInstance in runtime.facility_service.get_instances(): + if f.definition_id==&"chapel":chapel=f + if chapel==null:expect(false,"chapel exists");quit(failures);return + var facility_id:StringName=chapel.instance_id + expect(runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"staff_uma"}).is_success(),"hire real cleric") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"staff_uma",&"facility_id":facility_id,&"template_id":&"DAY"}).is_success(),"assign cleric") + clock.advance_minutes(20) + expect(chapel.active_staff_ids==[&"staff_uma"],"actual cleric attendance opens chapel") + for index in 9: + var guest_id:=StringName("chapel_capacity_%d"%index) + expect(runtime.submit_command(&"register_guest_wallet",{&"guest_id":guest_id,&"gold":100}).is_success(),"fund controlled capacity payer") + expect(runtime.submit_command(&"create_service_request",{&"request_id":StringName("worship_%d"%index),&"facility_id":facility_id,&"guest_ids":[guest_id],&"service_type":&"worship"}).is_success(),"request capacity worship") + if index==7:clock.advance_one_minute() + clock.advance_one_minute() + expect(runtime.facility_service.get_active_services(facility_id).size()==8 and chapel.queue_entries.size()==1,"eight simultaneous customers and one separate waiting request") + var occupied:Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(occupied) and runtime.create_snapshot()==occupied,"full chapel and waiting queue restore exactly") + var services:Array=runtime.facility_service.get_active_services(facility_id) + var service_id:StringName=services[0].service_id + var elapsed:int=services[0].elapsed_minutes + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"staff_uma",&"facility_id":&"",&"template_id":&"DAY"}).is_success(),"normal unassignment removes required cleric") + clock.advance_minutes(2) + var interrupted:ServiceInstance=runtime.facility_service.get_service(service_id) + expect(interrupted.state==ServiceInstance.State.WAITING_FOR_STAFF and interrupted.elapsed_minutes==elapsed,"cleric loss pauses worship without consuming service time") + expect(state.get_resource(&"faith")==0 and runtime.economy_service.get_balance(&"GUEST_WALLET:chapel_capacity_0")==100,"interrupted worship grants no faith and takes no donation") + var paused:Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(paused) and runtime.create_snapshot()==paused,"interrupted services and grace deadline restore together") + clock.advance_minutes(6) + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.FAILED,"continued absence fails worship after existing replacement grace") + expect(state.get_resource(&"faith")==0 and runtime.economy_service.get_balance(&"GUEST_WALLET:chapel_capacity_0")==100,"failed worship grants no reward or charge") + expect(runtime.restore_snapshot(occupied),"restore staffed capacity scenario") + var exceeded:=false + for minute in 125: + clock.advance_one_minute() + exceeded=exceeded or runtime.facility_service.get_active_services(facility_id).size()>8 + expect(not exceeded,"queue continuation never exceeds eight simultaneous users") + expect(state.get_resource(&"faith")==9 and runtime.progression_service.metrics[&"faith_lifetime"]==9,"all nine requests complete with nine faith total") + var revenue:=0 + for entry:LedgerEntry in runtime.economy_service.get_entries(): + if entry.category==&"SERVICE_SALE" and entry.source_entity_id==facility_id:revenue+=entry.amount + expect(revenue==180 and runtime.economy_service.validate_ledger()[&"ok"],"nine donations produce180G with balanced ledger") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"finished capacity services and Faith remain saveable") + # Start a separate normal operating game; enabling midway is invalid by design. + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42609);clock.set_paused(true) + var enabled:Dictionary=runtime.enable_production_operations() + expect(enabled[&"ok"],"enable normal arrivals and daily closing from new game") + if not enabled[&"ok"]:runtime.free();clock.free();state.free();quit(failures);return + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"chapel",&"origin":Vector3i(-6,0,0)}).is_success(),"construct chapel in normal operating game") + clock.advance_minutes(180) + for f:FacilityInstance in runtime.facility_service.get_instances(): + if f.definition_id==&"chapel":facility_id=f.instance_id + expect(runtime.submit_command(&"hire_staff_candidate",{&"candidate_id":&"staff_uma"}).is_success(),"hire cleric in normal operating game") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":&"staff_uma",&"facility_id":facility_id,&"template_id":&"DAY"}).is_success(),"staff chapel in normal operating game") + clock.advance_minutes(1439-clock.absolute_minute) + var before_close:Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(before_close),"chapel23:59 operating state restores") + clock.advance_one_minute() + var first_day:Dictionary=runtime.operations_coordinator.get_day_summary(1) + expect(not first_day.is_empty() and first_day[&"category_totals"][&"maintenance"]==3,"first partial ownership day charges prorated chapel upkeep") + clock.advance_minutes(1439) + var second_close:Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(second_close),"second operating day saves before settlement") + clock.advance_one_minute() + var second_day:Dictionary=runtime.operations_coordinator.get_day_summary(2) + expect(not second_day.is_empty() and second_day[&"category_totals"][&"maintenance"]==6,"full ownership day charges approved6G chapel upkeep") + var all_sales:=0 + for entry:LedgerEntry in runtime.economy_service.get_entries(): + if entry.category==&"SERVICE_SALE" and entry.source_entity_id==facility_id:all_sales+=entry.amount + expect(all_sales>0 and all_sales==int(state.get_resource(&"faith"))*20 and runtime.progression_service.metrics[&"faith_lifetime"]==state.get_resource(&"faith"),"two-day natural operations reconcile donations with Faith and lifetime progress") + expect(runtime.economy_service.validate_ledger()[&"ok"] and not runtime.get_operations_report()[&"runtime_failure_latched"],"two-day chapel operation closes without ledger or runtime failure") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"second chapel close restores without repeating settlement") + runtime.free();clock.free();state.free() + print("Chapel boundaries: %d checks, %d failures"%[checks,failures]);quit(failures) diff --git a/tests/mvp/test_chapel_boundaries.gd.uid b/tests/mvp/test_chapel_boundaries.gd.uid new file mode 100644 index 0000000..0fc2358 --- /dev/null +++ b/tests/mvp/test_chapel_boundaries.gd.uid @@ -0,0 +1 @@ +uid://delupsod4ws8p diff --git a/tests/mvp/test_chapel_service.gd b/tests/mvp/test_chapel_service.gd new file mode 100644 index 0000000..db8e4d6 --- /dev/null +++ b/tests/mvp/test_chapel_service.gd @@ -0,0 +1,60 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok: bool,label: String) -> void: + checks+=1 + if not ok: failures+=1;push_error(label) +func _initialize() -> void:call_deferred("run") +func run() -> void: + var runtime: Variant=load("res://autoload/simulation_runtime.gd").new() + var clock: Variant=load("res://autoload/game_clock.gd").new() + var state: Variant=load("res://autoload/game_state.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42606);clock.set_paused(true) + var paths: Array[String]=FacilityCatalog.FACILITY_PATHS+FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS+FacilityCatalog.RESCUE_FACILITY_PATHS+FacilityCatalog.ORIGINAL_FACILITY_PATHS + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"],"staged chapel definition passes registry validation") + var definition: FacilityDefinition=runtime.definition_registry.get_facility(&"chapel") + expect(definition!=null and definition.footprint==Vector2i(4,3) and definition.layer==0,"original chapel dimensions and front layer") + expect(definition.construction_cost=={&"gold":1200,&"materials":12} and definition.build_minutes==180 and definition.maintenance_gold==6,"approved chapel construction and daily upkeep") + expect(definition.customer_capacity==8 and definition.staff_min==1 and definition.staff_max==2 and definition.required_roles==[&"cleric"],"approved dedicated customer capacity and cleric requirements") + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"chapel",&"origin":Vector3i(-6,0,0)}).is_success(),"build staged chapel through normal construction command") + clock.advance_minutes(180) + var chapel: FacilityInstance + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id==&"chapel":chapel=facility + expect(chapel!=null and chapel.state==FacilityInstance.State.NO_STAFF,"chapel cannot operate without staff") + if chapel==null:runtime.free();clock.free();state.free();quit(failures);return + # Staffing fixture only; normal cleric recruitment is a separate integration. + expect(runtime.submit_command(&"set_facility_conditions",{&"facility_id":chapel.instance_id,&"conditions":{&"active_staff_ids":[&"cleric_fixture"]}}).is_success(),"controlled staffed service boundary") + expect(runtime.submit_command(&"register_guest_wallet",{&"guest_id":&"worship_payer",&"gold":100}).is_success(),"fund worship payer") + expect(runtime.submit_command(&"create_service_request",{&"request_id":&"worship_once",&"facility_id":chapel.instance_id,&"guest_ids":[&"worship_payer"],&"service_type":&"worship"}).is_success(),"request worship") + clock.advance_one_minute() + var active: Array=runtime.facility_service.get_active_services(chapel.instance_id) + expect(active.size()==1,"worship starts with approved staffing") + if active.is_empty():runtime.free();clock.free();state.free();quit(failures);return + var service_id: StringName=active[0].service_id + clock.advance_minutes(30) + var saved: Dictionary=runtime.create_snapshot() + expect(state.get_resource(&"faith")==0 and runtime.economy_service.get_balance(&"GUEST_WALLET:worship_payer")==100,"unfinished worship grants neither faith nor sale") + expect(runtime.restore_snapshot(saved),"restore worship in progress") + clock.advance_minutes(31) + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED,"worship completes after restore") + expect(state.get_resource(&"faith")==1 and runtime.progression_service.metrics[&"faith_lifetime"]==1,"faith inventory and lifetime rank metric gain one together") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:worship_payer")==80 and runtime.economy_service.validate_ledger()[&"ok"],"one20G donation with balanced ledger") + var completed: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(completed),"completed reward and ledger restore") + clock.advance_minutes(2) + expect(state.get_resource(&"faith")==1 and runtime.economy_service.get_balance(&"GUEST_WALLET:worship_payer")==80,"completed worship never repeats rewards after load") + expect(runtime.restore_snapshot(saved),"rewind to in-progress worship") + clock.advance_minutes(31) + expect(runtime.create_snapshot()==completed,"in-progress replay yields identical completion including faith and ledger") + expect(runtime.restore_snapshot(saved),"rewind for failed progression transaction") + runtime.progression_service.quarantined=true + clock.advance_minutes(31) + expect(runtime.facility_service.get_service(service_id).state!=ServiceInstance.State.COMPLETED,"unavailable progression prevents partial completion") + expect(state.get_resource(&"faith")==0 and runtime.economy_service.get_balance(&"GUEST_WALLET:worship_payer")==100,"faith failure rolls back guest payment and faith") + expect(runtime.economy_service.validate_ledger()[&"ok"],"failed faith reward leaves ledger balanced") + runtime.progression_service.quarantined=false + clock.advance_one_minute() + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED and state.get_resource(&"faith")==1 and runtime.economy_service.get_balance(&"GUEST_WALLET:worship_payer")==80,"recovered boundary retries with exactly one donation and reward") + runtime.free();clock.free();state.free() + print("Chapel service: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/mvp/test_chapel_service.gd.uid b/tests/mvp/test_chapel_service.gd.uid new file mode 100644 index 0000000..e1ed029 --- /dev/null +++ b/tests/mvp/test_chapel_service.gd.uid @@ -0,0 +1 @@ +uid://b5mh6cdy2ebrf diff --git a/tests/mvp/test_fine_cleaning_effect.gd b/tests/mvp/test_fine_cleaning_effect.gd new file mode 100644 index 0000000..8cd04b1 --- /dev/null +++ b/tests/mvp/test_fine_cleaning_effect.gd @@ -0,0 +1,13 @@ +extends "res://tests/ui/test_cleaning_gameplay.gd" +## Recruitment values are pending; this is a registered character fixture, +## not evidence of Fine being available from the normal recruitment screen. +func cleaner_id() -> StringName: return &"heroine_fine" +func work_ticks() -> int: return 3 +func hire_cleaner(_main: Control) -> void: + expect(runtime.submit_command(&"register_staff", {&"staff_id":cleaner_id(), &"role_id":&"cleaner", &"options":{&"employment_kind":&"UNIQUE", &"current_cell":Vector3i(-8,0,0)}}).is_success(), "register unique Fine test fixture") + var worker: StaffRuntime=runtime.staff_service.get_staff(cleaner_id()) + expect(FacilityCleaningBridge.work_units_per_minute(worker)==3,"original Fine cleans at triple rate") + var ordinary:=StaffRuntime.new().configure(&"heroine_fine",&"cleaner") + expect(FacilityCleaningBridge.work_units_per_minute(ordinary)==1,"ordinary worker cannot inherit unique effect by name") + var different_role:=StaffRuntime.new().configure(cleaner_id(),&"porter",{&"employment_kind":&"UNIQUE"}) + expect(FacilityCleaningBridge.work_units_per_minute(different_role)==1,"Fine identity does not accelerate unrelated roles") diff --git a/tests/mvp/test_fine_cleaning_effect.gd.uid b/tests/mvp/test_fine_cleaning_effect.gd.uid new file mode 100644 index 0000000..58c406d --- /dev/null +++ b/tests/mvp/test_fine_cleaning_effect.gd.uid @@ -0,0 +1 @@ +uid://ckaflk5hmiirm diff --git a/tests/mvp/test_mvp_content_catalog.gd b/tests/mvp/test_mvp_content_catalog.gd index 8334b32..1ada554 100644 --- a/tests/mvp/test_mvp_content_catalog.gd +++ b/tests/mvp/test_mvp_content_catalog.gd @@ -37,7 +37,7 @@ func _test_baseline_catalog() -> void: _expect(int(report[&"error_count"]) == 0, "baseline character data has no validation errors") _expect(int(report[&"counts"]["guest_classes"]) == 8, "all eight guest classes are indexed") _expect(int(report[&"counts"]["species"]) == 4, "all four independent species are indexed") - _expect(int(report[&"counts"]["staff_candidates"]) == 20, "all twenty free-hire candidates are indexed") + _expect(int(report[&"counts"]["staff_candidates"]) == 21, "twenty-one free-hire candidates are indexed") _expect(int(report[&"counts"]["unique_characters"]) == 2, "one heroine and one rival are indexed") _expect(catalog.get_guest_class_ids() == [ &"cleric", &"diplomat", &"mage", &"merchant", &"noble", &"outsider", &"rogue", &"warrior", @@ -46,7 +46,7 @@ func _test_baseline_catalog() -> void: &"beastfolk", &"demonfolk", &"elf", &"human", ], "species use stable lexical order") _expect(catalog.get_staff_candidate_ids().front() == &"staff_akane", "candidate IDs are stable and namespaced") - _expect(catalog.get_staff_candidate_ids().back() == &"staff_tessa", "all candidate IDs participate in deterministic order") + _expect(catalog.get_staff_candidate_ids().back() == &"staff_uma", "all candidate IDs participate in deterministic order") func _test_guest_combinations() -> void: @@ -124,15 +124,16 @@ func _test_unique_pair_and_defensive_copies() -> void: catalog.rebuild() var heroine := catalog.get_entry(&"unique_characters", &"heroine_anri") var rival := catalog.get_entry(&"unique_characters", &"rival_rai") + _expect(not heroine[&"mechanical_effects"].has(&"assigned_floor_patience_loss_multiplier_bp"), "unapproved patience substitute is absent from the original Anri definition") _expect(heroine[&"character_kind"] == &"heroine" and rival[&"character_kind"] == &"rival", "unique characters have distinct mechanical kinds") _expect(heroine[&"counterpart_id"] == &"rival_rai" and rival[&"counterpart_id"] == &"heroine_anri", "heroine and rival references are reciprocal") _expect(heroine[&"adult_verified"] and rival[&"adult_verified"], "both relationship participants are explicitly adult-verified") - _expect(heroine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] == 9000, "heroine baseline effect is represented in integer basis points") + _expect(heroine[&"mechanical_effects"][&"assigned_floor_revenue_multiplier_bp"] == 12000, "original Anri floor revenue bonus is +20 percent in integer basis points") heroine[&"display_name"] = "tampered" - heroine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] = 1 + heroine[&"mechanical_effects"][&"assigned_floor_revenue_multiplier_bp"] = 1 var pristine := catalog.get_entry(&"unique_characters", &"heroine_anri") _expect(pristine[&"display_name"] == "アンリ", "returned definitions cannot mutate catalog identity") - _expect(pristine[&"mechanical_effects"][&"assigned_floor_patience_loss_multiplier_bp"] == 9000, "returned nested data is also a defensive copy") + _expect(pristine[&"mechanical_effects"][&"assigned_floor_revenue_multiplier_bp"] == 12000, "returned nested data is also a defensive copy") func _test_invalid_content_is_fail_closed() -> void: @@ -242,7 +243,7 @@ func _test_unique_character_safety_contract() -> void: _expect(&"effect_kind_mismatch" in codes, "effects cannot cross the heroine/rival capability boundary") documents = _load_documents() - documents[&"unique_characters"]["entries"][0]["mechanical_effects"]["assigned_floor_patience_loss_multiplier_bp"] = 2_000 + documents[&"unique_characters"]["entries"][0]["mechanical_effects"]["assigned_floor_revenue_multiplier_bp"] = 2_000 report = catalog.rebuild_from_documents(documents) _expect(&"invalid_mechanical_effect" in _issue_codes(report[&"errors"]), "basis-point effects below the global multiplier floor are rejected") diff --git a/tests/mvp/test_operations_coordinator.gd b/tests/mvp/test_operations_coordinator.gd index 142c94d..9869c84 100644 --- a/tests/mvp/test_operations_coordinator.gd +++ b/tests/mvp/test_operations_coordinator.gd @@ -13,6 +13,7 @@ func _initialize() -> void: func _run() -> void: print("MVP: deterministic 14-day operations coordinator") + _test_root_snapshot_reuse() _test_demand_schema_formula_and_clamp() _test_boundary_order_leap_and_replay() _test_fixed_point_arrivals_capacity_and_caps() @@ -28,6 +29,11 @@ func _run() -> void: _test_fair_arrivals_seed_and_id_high_water() _test_snapshot_canonical_safety_and_derived_crosschecks() _test_signal_reentrancy_and_deferred_pause() + _test_snapshot_ownership() + _test_canonical_sort_compatibility() + _test_checked_snapshot_encoding() + _test_variant_budget_boundaries() + _test_dictionary_key_budget_boundaries() for service: RefCounted in _callback_services: if service != null and service.has_method("configure_callbacks"): service.call("configure_callbacks") @@ -119,12 +125,12 @@ func _test_boundary_order_leap_and_replay() -> void: ) var result := service.advance_to(1_800) _expect(result.is_success() and service.current_minute == 1_800, "one leap advances through every crossed operating boundary") - _expect(result.details[&"boundary_counts"][&"daily_closes"] == 1, "leap performs the crossed 06:00 operating-day close exactly once") + _expect(result.details[&"boundary_counts"][&"daily_closes"] == 1, "leap performs the crossed midnight close exactly once") _expect(result.details[&"boundary_counts"][&"morning_boundaries"] == 2, "leap performs both crossed 06:00 morning boundaries") _expect(result.details[&"boundary_counts"][&"demand_updates"] == 30, "leap never thins 60-minute demand updates") _expect(result.details[&"boundary_counts"][&"arrival_boundaries"] == 60, "leap never thins 30-minute arrival planning") - _expect(order == ["morning:360:capture", "daily:1800:capture", "morning:1800:capture"], "06:00 closes the prior operating day before morning work") - _expect(service.get_day_summaries().size() == 1 and service.get_morning_report(1)[&"minute"] == 360, "the first morning opens Day 1 and the next 06:00 closes it") + _expect(order == ["morning:360:capture", "daily:1440:capture", "morning:1800:capture"], "midnight closes the prior day before the next morning work") + _expect(service.get_day_summaries().size() == 1 and service.get_morning_report(1)[&"minute"] == 360, "the first morning opens Day 1 and the next midnight closes it") var digest_before := service.deterministic_digest() var replay := service.advance_to(1_800) _expect(replay.is_success() and replay.is_replay, "same-time advance is an explicit replay success") @@ -288,13 +294,13 @@ func _test_daily_close_summary_order_and_rolling_average() -> void: return {&"ok": true} ) var result := service.advance_to(8 * 1_440 + 360) - _expect(result.is_success() and service.get_day_summaries().size() == 8, "eight crossed 06:00 boundaries create exactly eight DaySummaries") + _expect(result.is_success() and service.get_day_summaries().size() == 8, "eight crossed midnight boundaries create exactly eight DaySummaries") _expect(phases_by_day[1] == [&"capture", &"validate", &"apply"], "daily close callback uses fixed capture/validate/apply order") var day1 := service.get_day_summary(1) _expect(day1[&"net_profit"] == 90 and day1[&"previous_day_delta"] == 0, "Day 1 classified revenue and expense derive net profit with a zero baseline delta") _expect(day1[&"category_totals"][&"revenue"] == 100 and day1[&"category_totals"][&"cost_of_goods"] == 10, "DaySummary retains classified income and expense totals") _expect(day1[&"facility_profit_loss"][&"fac-01"][&"net_profit"] == 90, "DaySummary retains reconciled facility-level profit and loss") - _expect(day1[&"period_start_minute"] == 360 and day1[&"period_end_minute_exclusive"] == 1_800, "financial period is exactly the prior 06:00-to-06:00 operating day") + _expect(day1[&"period_start_minute"] == 0 and day1[&"period_end_minute_exclusive"] == 1_440, "financial period is exactly the prior midnight-to-midnight day") var day2 := service.get_day_summary(2) _expect(day2[&"net_profit"] == 180 and day2[&"previous_day_delta"] == 90, "previous-day delta compares adjacent closed days") _expect(day2[&"seven_day_average"] == 135, "moving average uses all available days before seven closes") @@ -329,13 +335,13 @@ func _test_daily_close_failure_and_ledger_imbalance_pause() -> void: return {&"ok": true} return {&"ok": false} ) - var failed := service.advance_to(1_800) - _expect(not failed.is_success() and service.current_minute == 1_799, "failed daily close leaves its 06:00 boundary pending for exact retry") + var failed := service.advance_to(1_440) + _expect(not failed.is_success() and service.current_minute == 1_439, "failed daily close leaves its 00:00 boundary pending for exact retry") _expect(external[&"posted"] == 0 and external[&"rollbacks"] == 1, "partial external daily application rolls back to captured state") _expect(service.get_day_summaries().is_empty(), "failed daily transaction creates no DaySummary or insolvency state") var retried := service.advance_to(1_860) _expect(retried.is_success() and service.get_day_summaries().size() == 1, "retry commits one and only one daily summary") - _expect(service.current_minute == 1_800, "imbalance pause stops a larger requested leap at the closing boundary") + _expect(service.current_minute == 1_440, "imbalance pause stops a larger requested leap at the closing boundary") _expect(external[&"posted"] == 100 and int(external[&"apply_calls"]) == 2, "daily retry applies the external transaction once after rollback") var summary := service.get_day_summary(1) _expect(summary[&"reconciliation_difference"] == 7 and not summary[&"balanced"], "non-zero ledger difference is preserved instead of corrected") @@ -343,9 +349,9 @@ func _test_daily_close_failure_and_ledger_imbalance_pause() -> void: var alerts := service.get_alerts() _expect(alerts.size() == 1 and alerts[0][&"code"] == &"LEDGER_IMBALANCE", "ledger imbalance emits one structured alert") _expect(not alerts[0][&"automatic_correction"], "imbalance alert proves no balancing entry was invented") - _expect(not service.advance_to(1_801).is_success(), "pause request blocks further automatic advance") + _expect(not service.advance_to(1_441).is_success(), "pause request blocks further automatic advance") _expect(service.acknowledge_pause().is_success() and not service.pause_requested, "caller can explicitly acknowledge a non-terminal diagnostic pause") - _expect(service.advance_to(1_801).is_success(), "advance resumes only after explicit acknowledgement") + _expect(service.advance_to(1_441).is_success(), "advance resumes only after explicit acknowledgement") var malformed_external := {&"value": 0, &"rollback": 0} var malformed := OperationsCoordinatorType.new() @@ -365,7 +371,7 @@ func _test_daily_close_failure_and_ledger_imbalance_pause() -> void: return {&"ok": true} return {&"ok": false} ) - _expect(not malformed.advance_to(1_800).is_success(), "malformed successful-looking daily result is rejected") + _expect(not malformed.advance_to(1_440).is_success(), "malformed successful-looking daily result is rejected") _expect(malformed_external[&"value"] == 0 and malformed_external[&"rollback"] == 1, "post-apply schema failure also rolls back external state") _expect(malformed.get_day_summaries().is_empty(), "malformed result cannot partly commit coordinator state") @@ -381,27 +387,27 @@ func _test_arrears_clear_and_three_close_bankruptcy() -> void: if phase == &"validate": return {&"ok": true, &"token": token} if phase == &"apply": - return _daily_result(100, 100, int(arrears_by_day[int(payload[&"day"])])) + return _daily_result(100, 100 if int(payload[&"day"]) == 2 else 101, int(arrears_by_day[int(payload[&"day"])])) return {&"ok": true} ) - service.advance_to(1_800) + service.advance_to(1_440) _expect(service.get_arrears_total() == 50 and service.get_consecutive_insolvency_count() == 1, "first unpaid close records arrears and one warning") var blocked := service.can_start_construction_or_investment() _expect(not blocked[&"allowed"] and blocked[&"reason"] == &"ARREARS_REMAIN", "arrears prohibit new construction and ordinary investment") - service.advance_to(3_240) + service.advance_to(2_880) _expect(service.get_arrears_total() == 0 and service.get_consecutive_insolvency_count() == 0, "fully paid next close clears the consecutive warning count") _expect(service.can_start_construction_or_investment()[&"allowed"], "cleared arrears restore construction/investment eligibility") - service.advance_to(4_680) + service.advance_to(4_320) _expect(service.get_consecutive_insolvency_count() == 1, "new arrears after clearance begin a fresh consecutive sequence") - service.advance_to(6_120) - _expect(service.get_consecutive_insolvency_count() == 2 and not service.is_terminal(), "second consecutive unpaid close warns but remains playable") - var terminal_result := service.advance_to(7_560) - _expect(terminal_result.is_success() and service.get_consecutive_insolvency_count() == 3, "third consecutive unpaid close commits before entering terminal state") - _expect(service.is_terminal() and service.get_terminal_state() == &"BANKRUPT", "three consecutive insolvent closes enter BANKRUPT terminal state") - _expect(service.current_minute == 7_560, "terminal transition stops precisely at the closing boundary") + service.advance_to(5_760) + _expect(service.get_consecutive_insolvency_count() == 2 and not service.is_terminal(), "second consecutive loss close warns but remains playable") + var terminal_result := service.advance_to(7_200) + _expect(terminal_result.is_success() and service.get_consecutive_insolvency_count() == 3, "third consecutive loss close commits before entering terminal state") + _expect(service.is_terminal() and service.get_terminal_state() == &"BANKRUPT", "three consecutive loss closes enter BANKRUPT terminal state") + _expect(service.current_minute == 7_200, "terminal transition stops precisely at the closing boundary") var terminal_query := service.can_start_construction_or_investment() _expect(not terminal_query[&"allowed"] and terminal_query[&"reason"] == &"BANKRUPT", "bankrupt terminal always prohibits construction and investment") - _expect(not service.advance_to(7_561).is_success(), "terminal operation rejects subsequent time advance") + _expect(not service.advance_to(7_201).is_success(), "terminal operation rejects subsequent time advance") _expect(not service.acknowledge_pause().is_success(), "terminal state cannot be cleared through pause acknowledgement") var warning_count := 0 var bankrupt_count := 0 @@ -509,8 +515,14 @@ func _test_snapshot_round_trip_migration_and_atomic_rejection() -> void: _expect(not restored.restore_snapshot(future_version), "unknown future snapshot schema is rejected") _expect(restored.create_snapshot() == before, "future-schema rejection is atomic") - var legacy := snapshot.duplicate(true) + var legacy: Dictionary = str_to_var(FileAccess.get_file_as_string("res://tests/mvp/fixtures/operations_v3_0600.txt")) legacy[&"schema_version"] = 1 + legacy[&"arrears_total"] = 0 + legacy[&"consecutive_insolvency_count"] = 0 + for row: Dictionary in legacy[&"day_summaries"]: + row[&"arrears_total"] = 0 + row[&"settlement"][&"arrears_total"] = 0 + row[&"consecutive_insolvency_count"] = 0 legacy.erase(&"alerts") legacy.erase(&"demand_history") var migrated := OperationsCoordinatorType.new() @@ -736,7 +748,7 @@ func _test_origin_coverage_limits_and_legacy_population() -> void: v2_snapshot.erase(field) var v2_migrated := OperationsCoordinatorType.new() _expect(v2_migrated.restore_snapshot(v2_snapshot), "v2 snapshot migrates using origin zero and derived high-water/fairness fields") - _expect(v2_migrated.create_snapshot()[&"schema_version"] == 3 and v2_migrated.validate_invariants()[&"ok"], "v2 migration re-emits a valid current-version snapshot") + _expect(v2_migrated.create_snapshot()[&"schema_version"] == OperationsCoordinatorType.SNAPSHOT_VERSION and v2_migrated.validate_invariants()[&"ok"], "v2 migration re-emits a valid current-version snapshot") var downgraded_v2 := v2_source.create_snapshot() downgraded_v2[&"schema_version"] = 2 downgraded_v2[&"origin_minute"] = 360 @@ -896,17 +908,17 @@ func _test_signal_reentrancy_and_deferred_pause() -> void: return {&"ok": true} ) var paused_result := paused.advance_to(1_860) - _expect(paused_result.is_success() and paused.current_minute == 1_800 and paused.pause_requested, "ledger imbalance commits the 06:00 close then stops a larger leap") - _expect(paused.get_demand_history()[-1][&"minute"] == 1_740 and paused.get_arrival_reports()[-1][&"minute"] == 1_770, "paused 06:00 close runs no same-minute morning, demand or arrival work") + _expect(paused_result.is_success() and paused.current_minute == 1_440 and paused.pause_requested, "ledger imbalance commits the 00:00 close then stops a larger leap") + _expect(paused.get_demand_history()[-1][&"minute"] == 1_380 and paused.get_arrival_reports()[-1][&"minute"] == 1_410, "paused 00:00 close runs no same-minute morning, demand or arrival work") var paused_snapshot := paused.create_snapshot() var resumed := OperationsCoordinatorType.new() _expect(resumed.restore_snapshot(paused_snapshot) and resumed.validate_invariants()[&"ok"], "paused partial boundary snapshot retains a valid deferred suffix") _expect(resumed.acknowledge_pause().is_success(), "explicit acknowledgement releases only the ledger diagnostic pause") - var resumed_result := resumed.advance_to(1_800) - _expect(resumed_result.is_success() and resumed.get_morning_report(2)[&"minute"] == 1_800 and resumed.get_demand_history()[-1][&"minute"] == 1_800 and resumed.get_arrival_reports()[-1][&"minute"] == 1_800, "acknowledged replay completes the deferred morning, demand and arrival suffix") + var resumed_result := resumed.advance_to(1_440) + _expect(resumed_result.is_success() and resumed.get_morning_report(2).is_empty() and resumed.get_demand_history()[-1][&"minute"] == 1_440 and resumed.get_arrival_reports()[-1][&"minute"] == 1_440, "acknowledged replay completes the deferred demand and arrival suffix without morning work") _expect(not resumed_result.is_replay and resumed_result.details[&"deferred_completed"], "deferred completion is reported as new work rather than a side-effect-free replay") var resumed_digest := resumed.deterministic_digest() - resumed.advance_to(1_800) + resumed.advance_to(1_440) _expect(resumed.deterministic_digest() == resumed_digest, "completed deferred boundary cannot duplicate daily, demand, or arrival work") @@ -1052,3 +1064,197 @@ func _expect(condition: bool, label: String) -> void: return _failures += 1 push_error(" ✗ %s" % label) + + +func _test_snapshot_ownership() -> void: + var service := _deterministic_fixture(53) + service.advance_to(1800) + var before := service.create_snapshot() + var exported := service.create_snapshot() + _scramble_snapshot(exported) + _expect(service.create_snapshot() == before, "mutating every exported snapshot container cannot alter live operations history") + var before_copy := before.duplicate(true) + service.advance_to(3240) + var resumed := _deterministic_fixture(53) + _expect(resumed.restore_snapshot(before), "earlier snapshot remains restorable after live history advances") + resumed.advance_to(3240) + _expect(resumed.create_snapshot() == service.create_snapshot() and before == before_copy, "snapshot restore and replay preserve owned history and deterministic result") + + +func _scramble_snapshot(value: Variant) -> void: + if value is Dictionary: + for key: Variant in value.keys(): + _scramble_snapshot(value[key]) + value.clear() + elif value is Array: + for child: Variant in value: + _scramble_snapshot(child) + value.clear() + + +func _test_canonical_sort_compatibility() -> void: + var cases: Array = [ + {10: "ten", 2: "two", -1: "negative", "a": {&"z": 3, "b": [1, &"text"]}}, + {"あ": 1, "é": 2, "Z": 3, "a": 4, "🙂": 5, "": 6}, + {&"nested": [{&"field": "value", &"empty": {}}, []], &"flag": true}, + ] + var service := _deterministic_fixture(17) + service.advance_to(1800) + cases.append(service.create_snapshot()) + for candidate: Variant in cases: + var actual: Variant = OperationsCoordinatorType._to_snapshot_variant(candidate) + var expected: Variant = _reference_snapshot_variant(candidate) + _expect(JSON.stringify(actual, "", false, true) == JSON.stringify(expected, "", false, true), "canonical output preserves legacy bytes including insertion order") + var invalid := {1: "integer", "1": "string"} + _expect(not OperationsCoordinatorType._variant_tree_report(invalid)[&"ok"], "colliding canonical keys remain rejected before serialization") + + +func _reference_snapshot_variant(value: Variant) -> Variant: + match typeof(value): + TYPE_STRING_NAME: + return String(value) + TYPE_ARRAY: + var result: Array = [] + for child: Variant in value: + result.append(_reference_snapshot_variant(child)) + return result + TYPE_DICTIONARY: + var result: Dictionary = {} + var keys: Array = value.keys() + keys.sort_custom(func(a: Variant, b: Variant) -> bool: return str(a) < str(b)) + for key: Variant in keys: + result[str(key)] = _reference_snapshot_variant(value[key]) + return result + _: + return value + + +func _test_variant_budget_boundaries() -> void: + var report: Dictionary = OperationsCoordinatorType._variant_tree_report({"a": ["あ", 2]}) + _expect(report[&"ok"] and report[&"nodes"] == 5 and report[&"string_length"] == 2, "tree budget counts dictionary keys and Unicode characters exactly") + var budget: Array[int] = [OperationsCoordinatorType.MAX_VARIANT_NODES - 1, 0] + _expect(OperationsCoordinatorType._inspect_variant_tree(null, budget, 0) == &"", "node budget accepts exact limit") + _expect(OperationsCoordinatorType._inspect_variant_tree(null, budget, 0) == &"MAX_VARIANT_NODES", "node budget rejects next node") + budget = [0, OperationsCoordinatorType.MAX_TOTAL_STRING_LENGTH - 1] + _expect(OperationsCoordinatorType._inspect_variant_tree("あ", budget, 0) == &"", "text budget accepts exact limit") + _expect(OperationsCoordinatorType._inspect_variant_tree("a", budget, 0) == &"TOTAL_STRING_BUDGET", "text budget rejects next character") + budget = [0, 0] + _expect(OperationsCoordinatorType._inspect_variant_tree(null, budget, OperationsCoordinatorType.MAX_VARIANT_DEPTH) == &"", "tree depth accepts exact limit") + _expect(OperationsCoordinatorType._inspect_variant_tree(null, budget, OperationsCoordinatorType.MAX_VARIANT_DEPTH + 1) == &"MAX_VARIANT_DEPTH", "tree depth rejects next level") + _expect(OperationsCoordinatorType._variant_tree_report("a".repeat(OperationsCoordinatorType.MAX_SINGLE_STRING_LENGTH + 1))[&"code"] == &"STRING_TOO_LONG", "single string limit remains enforced") + var oversized: Array = [] + oversized.resize(OperationsCoordinatorType.MAX_SAFE_COLLECTION_SIZE + 1) + _expect(OperationsCoordinatorType._variant_tree_report(oversized)[&"code"] == &"COLLECTION_TOO_LARGE", "collection limit remains enforced") + _expect(OperationsCoordinatorType._variant_tree_report(INF)[&"code"] == &"NON_FINITE_NUMBER", "non-finite numbers remain rejected") + +func _test_dictionary_key_budget_boundaries() -> void: + var budget: Array[int]=[0,0] + _expect(OperationsCoordinatorType._inspect_variant_tree({"a":null}, budget, OperationsCoordinatorType.MAX_VARIANT_DEPTH)==&"MAX_VARIANT_DEPTH", "dictionary key exceeds depth before counting key node") + _expect(budget==[1,0], "depth failure leaves only dictionary node counted") + budget=[OperationsCoordinatorType.MAX_VARIANT_NODES-1,0] + _expect(OperationsCoordinatorType._inspect_variant_tree({"a":null},budget,0)==&"MAX_VARIANT_NODES", "dictionary key enforces node limit before string budget") + budget=[0,OperationsCoordinatorType.MAX_TOTAL_STRING_LENGTH] + _expect(OperationsCoordinatorType._inspect_variant_tree({"a":null},budget,0)==&"TOTAL_STRING_BUDGET", "dictionary key enforces aggregate text limit") + budget=[0,0] + _expect(OperationsCoordinatorType._inspect_variant_tree({"a".repeat(OperationsCoordinatorType.MAX_SINGLE_STRING_LENGTH+1):null},budget,0)==&"STRING_TOO_LONG", "dictionary key enforces single string limit") + budget=[0,0] + _expect(OperationsCoordinatorType._inspect_variant_tree({12:null},budget,0)==&"" and budget==[3,2], "integer key uses canonical string length and original node accounting") + + +func _test_checked_snapshot_encoding() -> void: + var cases: Array = [ + null, true, false, 42, -7, 1.25, &"name", "", "Japanese and Unicode: あ🙂", + {10: "ten", 2: "two", -1: "negative", "a": {&"z": 3, "b": [1, &"text"]}}, + {"あ": 1, "é": 2, "Z": 3, "a": 4, "🙂": 5, "": 6}, + {&"nested": [{&"field": "value", &"empty": {}}, []], &"flag": true}, + ] + var coordinator := _deterministic_fixture(17) + coordinator.advance_to(1800) + cases.append(coordinator.create_snapshot()) + for candidate: Variant in cases: + var encoded: Dictionary = OperationsCoordinatorType._snapshot_tree_report(candidate) + _expect(encoded[&"ok"], "checked encoder accepts safe scalar or tree") + _expect(JSON.stringify(encoded.get(&"value"), "", false, true) == JSON.stringify(_reference_snapshot_variant(candidate), "", false, true), "checked encoder retains legacy bytes and key insertion order") + var invalid_cases: Array = [ + [INF, &"NON_FINITE_NUMBER"], [NAN, &"NON_FINITE_NUMBER"], + [{1: "integer", "1": "string"}, &"DUPLICATE_CANONICAL_KEY"], + [{Vector2i(1,2): 1}, &"INVALID_DICTIONARY_KEY"], + [Vector3i(1,2,3), &"UNSAVEABLE_VARIANT"], + ["a".repeat(OperationsCoordinatorType.MAX_SINGLE_STRING_LENGTH + 1), &"STRING_TOO_LONG"], + ] + var oversized: Array = [] + oversized.resize(OperationsCoordinatorType.MAX_SAFE_COLLECTION_SIZE + 1) + invalid_cases.append([oversized, &"COLLECTION_TOO_LARGE"]) + var deep: Variant = null + for depth in OperationsCoordinatorType.MAX_VARIANT_DEPTH + 1: + deep = [deep] + invalid_cases.append([deep, &"MAX_VARIANT_DEPTH"]) + # A later invalid value must not reorder which historical error is surfaced. + invalid_cases.append([{"z": INF, "a": Vector2i.ZERO}, &"NON_FINITE_NUMBER"]) + for pair: Array in invalid_cases: + var result: Dictionary = OperationsCoordinatorType._snapshot_tree_report(pair[0]) + _expect(not result[&"ok"] and result[&"code"] == pair[1] and not result.has(&"value"), "checked encoding rejects unsafe tree without publishing a partial copy") + var exact := OperationsCoordinatorType._snapshot_tree_report({"a": ["あ", 2]}) + _expect(exact[&"nodes"] == 5 and exact[&"string_length"] == 2, "encoding counts canonical dictionary keys and Unicode characters") + for encode: bool in [false, true]: + var budget: Array[int] = [OperationsCoordinatorType.MAX_VARIANT_NODES - 1, 0] + var issues: Array[StringName] = [] + OperationsCoordinatorType._visit_snapshot_tree(null, budget, 0, issues, encode) + _expect(issues.is_empty(), "both visitor modes accept exact node budget") + OperationsCoordinatorType._visit_snapshot_tree(null, budget, 0, issues, encode) + _expect(issues == [&"MAX_VARIANT_NODES"], "both visitor modes reject excess node budget") + budget = [0, OperationsCoordinatorType.MAX_TOTAL_STRING_LENGTH - 1] + issues.clear() + OperationsCoordinatorType._visit_snapshot_tree("あ", budget, 0, issues, encode) + _expect(issues.is_empty(), "both visitor modes accept exact text budget") + OperationsCoordinatorType._visit_snapshot_tree("a", budget, 0, issues, encode) + _expect(issues == [&"TOTAL_STRING_BUDGET"], "both visitor modes reject excess text budget") + var source := {&"nested": [{&"value": 7}]} + var output: Dictionary = OperationsCoordinatorType._snapshot_tree_report(source)[&"value"] + source[&"nested"][0][&"value"] = 8 + _expect(output["nested"][0]["value"] == 7, "encoder detaches nested source containers") + output["nested"].clear() + _expect(source[&"nested"].size() == 1, "mutating encoded output cannot change live input") + + +func _compare_root_snapshot_reuse(source: Dictionary, prefix: Dictionary = {}): + var cache := {} + var first := OperationsCoordinatorType._snapshot_root_report(source,cache,true) + var old_first := OperationsCoordinatorType._variant_tree_report(source) + _expect(first[&"ok"]==old_first[&"ok"] and first[&"code"]==old_first[&"code"],"initial validation preserves error precedence") + if not first[&"ok"]:return + _expect(first[&"nodes"]==old_first[&"nodes"] and first[&"string_length"]==old_first[&"string_length"],"initial node and text accounting identical") + var raw := prefix.duplicate(true) + raw.merge(source) + var before := OperationsCoordinatorType._snapshot_tree_report(raw) + var after := OperationsCoordinatorType._snapshot_root_report(raw,cache,false) + _expect(after[&"ok"]==before[&"ok"] and after[&"code"]==before[&"code"],"second pass preserves error precedence including aggregate overflow") + if after[&"ok"]: + _expect(after[&"nodes"]==before[&"nodes"] and after[&"string_length"]==before[&"string_length"],"second pass counts identical") + _expect(JSON.stringify(after[&"value"],"",false,true)==JSON.stringify(before[&"value"],"",false,true),"canonical bytes identical") +func _test_root_snapshot_reuse(): + var cases: Array = [null,true,12,1.5,&"name",{"あ":[1,&"text"],2:{&"deep":false}},INF,NAN,{1:1,"1":2},{Vector2i.ZERO:1},Vector3i.ZERO,"a".repeat(4097),{"z":INF,"a":Vector2i.ZERO}] + var deep: Variant = null + for i in 33:deep=[deep] + cases.append(deep) + var huge := [];huge.resize(20001);cases.append(huge) + for value in cases: + _compare_root_snapshot_reuse({&"arrival_reports":value}) + _compare_root_snapshot_reuse({1:1,"1":2}) + _compare_root_snapshot_reuse({Vector2i.ZERO:1}) + _compare_root_snapshot_reuse({"a".repeat(4097):1}) + for i in 100: + _compare_root_snapshot_reuse({&"demand_history":[{&"z":i,"a":[i%7,&"文字",null]}],&"alerts":[i]}, {&"version":1,&"extra":[i]}) + var text_values := [] + text_values.resize(1999);text_values.fill("あ".repeat(4000)) + _compare_root_snapshot_reuse({&"arrival_reports":text_values},{&"first":"b".repeat(4096)}) + var nodes := [];var row := [];row.resize(19999) + for i in 99:nodes.append(row) + var prefix_nodes := [];prefix_nodes.resize(19999) + _compare_root_snapshot_reuse({&"demand_history":nodes},{&"first":prefix_nodes}) + var owned_source := {&"alerts":[{&"text":"original"}]} + var cache := {} + OperationsCoordinatorType._snapshot_root_report(owned_source,cache,true) + var encoded := OperationsCoordinatorType._snapshot_root_report(owned_source,cache,false) + (encoded[&"value"]["alerts"] as Array).clear() + _expect(owned_source[&"alerts"].size()==1,"cached output never aliases live source") diff --git a/tests/mvp/test_original_midnight_settlement.gd b/tests/mvp/test_original_midnight_settlement.gd new file mode 100644 index 0000000..58ab26e --- /dev/null +++ b/tests/mvp/test_original_midnight_settlement.gd @@ -0,0 +1,98 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + var service := OperationsCoordinator.new({&"start_minute": 360}) + _bind(service, [-1, 0, -1, 1, -1, -1, -1], 0) + _expect(service.advance_to(1439).is_success() and service.get_day_summaries().is_empty(), "23:59 remains open") + var before_close := service.create_snapshot() + var boundary_restored := OperationsCoordinator.new() + _expect(boundary_restored.restore_snapshot(before_close), "23:59 save restores before the first close") + _bind(boundary_restored, [-1, 0, -1, 1, -1, -1, -1], 0) + boundary_restored.advance_to(1440) + _expect(service.advance_to(1440).is_success(), "first partial opening day closes at midnight") + _expect(boundary_restored.deterministic_digest() == service.deterministic_digest(), "save immediately before midnight closes and pays exactly once") + var legacy_open := before_close.duplicate(true) + legacy_open["schema_version"] = 3 + legacy_open.erase("legacy_calendar_last_day") + legacy_open.erase("legacy_history_last_day") + var legacy_restored := OperationsCoordinator.new() + _expect(legacy_restored.restore_snapshot(legacy_open), "legacy save before its first close restores") + legacy_restored.advance_to(1500) + _expect(legacy_restored.get_day_summaries().is_empty(), "unclosed legacy first day is not retroactively settled at midnight") + legacy_restored.advance_to(1800) + _expect(legacy_restored.get_day_summary(1)[&"closed_at"] == 1800, "legacy first open day closes once at 06:00") + var day := service.get_day_summary(1) + _expect(day[&"closed_at"] == 1440 and day[&"period_start_minute"] == 0, "first day uses calendar midnight bounds") + _expect(service.get_consecutive_insolvency_count() == 1 and service.get_arrears_total() == 0, "a paid loss counts even without arrears") + service.advance_to(1800) + _expect(service.get_day_summaries().size() == 1 and not service.get_morning_report(2).is_empty(), "06:00 performs morning work without another close") + service.advance_to(2880) + _expect(service.get_consecutive_insolvency_count() == 0, "break-even resets loss streak") + service.advance_to(4320) + service.advance_to(5760) + _expect(service.get_consecutive_insolvency_count() == 0, "profit resets loss streak") + service.advance_to(8640) + var saved := service.create_snapshot() + var restored := OperationsCoordinator.new() + _expect(restored.restore_snapshot(saved), "two-loss snapshot restores") + var missing_alert := saved.duplicate(true) + missing_alert["alerts"].pop_back() + _expect(not restored.restore_snapshot(missing_alert) and restored.create_snapshot() == saved, "missing loss warning rejects atomically") + _bind(restored, [-1, 0, -1, 1, -1, -1, -1], 0) + service.advance_to(10200) + restored.advance_to(10200) + _expect(service.current_minute == 10080 and service.is_terminal(), "third loss stops a larger advance exactly at midnight") + _expect(service.deterministic_digest() == restored.deterministic_digest(), "save/load continuation preserves exact terminal outcome") + _expect(service.validate_invariants()[&"ok"], "loss history and terminal invariants reconcile") + var unpaid := OperationsCoordinator.new() + _bind(unpaid, [1, 1, 1, 1], 50) + unpaid.advance_to(5760) + _expect(not unpaid.is_terminal() and unpaid.get_consecutive_insolvency_count() == 0, "profitable closes with arrears never count as losses") + _expect(not unpaid.can_start_construction_or_investment()[&"allowed"], "unpaid wages still restrict new investment separately") + _test_legacy() + print("%s: %d checks" % ["PASS" if _failures == 0 else "FAIL", _checks]) + quit(_failures) + +func _test_legacy() -> void: + var old: Dictionary = str_to_var(FileAccess.get_file_as_string("res://tests/mvp/fixtures/operations_v3_0600.txt")) + var service := OperationsCoordinator.new() + _expect(service.restore_snapshot(old), "genuine pre-change v3 financial history restores") + _expect(service.get_day_summaries() == old["day_summaries"], "legacy closed financial records remain unchanged") + _expect(service.get_consecutive_insolvency_count() == 2, "historical unpaid streak remains auditable") + _bind(service, [-1, -1, -1, -1, -1], 0) + service.advance_to(4320) + _expect(service.get_day_summaries().size() == 2, "migration does not duplicate the still-open legacy day at midnight") + service.advance_to(4680) + _expect(service.get_day_summary(3)[&"period_start_minute"] == 3240 and service.get_consecutive_insolvency_count() == 1, "open legacy day closes once; new loss streak excludes old arrears count") + var snapshot := service.create_snapshot() + var restored := OperationsCoordinator.new() + _expect(restored.restore_snapshot(snapshot), "transition save restores") + _bind(restored, [-1, -1, -1, -1, -1], 0) + service.advance_to(5760) + restored.advance_to(5760) + _expect(service.get_day_summary(4)[&"period_start_minute"] == 4680 and service.get_day_summary(4)[&"closed_at"] == 5760, "18-hour transition has no gap or overlap") + _expect(service.deterministic_digest() == restored.deterministic_digest(), "migration replay is deterministic") + var before := restored.create_snapshot() + var bad := before.duplicate(true) + bad["legacy_calendar_last_day"] = 1 + _expect(not restored.restore_snapshot(bad) and restored.create_snapshot() == before, "inconsistent transition marker rejects atomically") + service.advance_to(8000) + _expect(service.current_minute == 7200 and service.is_terminal() and service.validate_invariants()[&"ok"], "migrated save ends on three new losses at midnight") + +func _bind(service: OperationsCoordinator, profits: Array, arrears: int) -> void: + service.configure_callbacks(func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + if phase != &"apply": return {&"ok": true, &"token": token} + var profit := int(profits[int(payload[&"day"]) - 1]) + return {&"ok": true, &"category_totals": {&"revenue": 100 + profit, &"wages": 100}, &"facility_profit_loss": {}, &"anomalies": [], &"reconciliation_difference": 0, &"settlement": {&"arrears_total": arrears}}) + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/mvp/test_original_midnight_settlement.gd.uid b/tests/mvp/test_original_midnight_settlement.gd.uid new file mode 100644 index 0000000..b2fe97a --- /dev/null +++ b/tests/mvp/test_original_midnight_settlement.gd.uid @@ -0,0 +1 @@ +uid://bpr5lgs5yx5yb diff --git a/tests/mvp/test_original_room_zones.gd b/tests/mvp/test_original_room_zones.gd new file mode 100644 index 0000000..e201410 --- /dev/null +++ b/tests/mvp/test_original_room_zones.gd @@ -0,0 +1,41 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0); state.reset(); runtime.initialize(clock,state,42571); clock.set_paused(true) + runtime.submit_command(&"change_resources", {&"deltas": {&"gold": 5000, &"materials": 100}}) + runtime.submit_command(&"update_progression_metrics", {&"metrics": {&"reputation": 25}}) + expect(runtime.submit_command(&"set_world_tree_rank", {&"rank":2}).is_success(), "fixture unlocks underground by rank command") + for id: StringName in [&"standard_room", &"premium_room", &"staff_dormitory"]: + var definition: FacilityDefinition = runtime.definition_registry.get_facility(id) + var origin := Vector3i(2,-1,definition.layer) + var report: PlacementReport = runtime.construction_service.validate_placement(definition,origin) + expect(report.zone_mismatch_cells.is_empty() and not report.unexcavated_cells.is_empty(), "%s permits underground but still requires excavation" % id) + expect(not runtime.submit_command(&"build_facility", {&"definition_id":id,&"origin":origin}).is_success(), "unexcavated build rejected") + var wrong: PlacementReport = runtime.construction_service.validate_placement(definition,Vector3i(2,-1,1-definition.layer)) + expect(not wrong.layer_mismatch_cells.is_empty(), "%s preserves original layer restriction" % id) + for x in range(0,10): + expect(runtime.submit_command(&"start_excavation", {&"cell":Vector3i(x,-1,0)}).is_success(), "normal command excavates underground footprint") + clock.advance_minutes(60) + for x in range(0,10): expect(runtime.world_service.is_excavated(Vector3i(x,-1,0)), "excavation completes") + for item: Array in [[&"standard_room",Vector3i(1,-1,0)],[&"premium_room",Vector3i(4,-1,0)],[&"staff_dormitory",Vector3i(1,-1,1)]]: + var report: PlacementReport = runtime.construction_service.validate_placement(runtime.definition_registry.get_facility(item[0]),item[1]) + expect(report.can_build, "%s underground placement is valid" % item[0]) + var result: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id":item[0],&"origin":item[1]}) + expect(result.is_success(), "%s constructs in excavated underground: %s" % [item[0],result.message]) + clock.advance_minutes(150) + for id: StringName in [&"standard_room", &"premium_room", &"staff_dormitory"]: + var found := false + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == id and facility.origin.y == -1: found = true + expect(found, "%s finishes as a real underground facility" % id) + expect(runtime.restore_snapshot(runtime.create_snapshot()), "underground rooms survive save restore") + runtime.free();clock.free();state.free() + print("Original room zones: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/mvp/test_original_room_zones.gd.uid b/tests/mvp/test_original_room_zones.gd.uid new file mode 100644 index 0000000..4c55bc4 --- /dev/null +++ b/tests/mvp/test_original_room_zones.gd.uid @@ -0,0 +1 @@ +uid://dswpwd2gkbxnf diff --git a/tests/mvp/test_premium_room.gd b/tests/mvp/test_premium_room.gd new file mode 100644 index 0000000..7722b70 --- /dev/null +++ b/tests/mvp/test_premium_room.gd @@ -0,0 +1,48 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42501);clock.set_paused(true) + var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"premium_room") + expect(definition != null and definition.footprint == Vector2i(4,1), "normal Runtime registers original-size premium room") + expect(definition.maintenance_gold == 5 and definition.customer_capacity == 2, "daily upkeep follows approved half-percent initial rule and people limit is explicit") + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"reception",&"origin":Vector3i(-6,0,0)}).is_success(), "build supporting reception") + clock.advance_minutes(120) + var built: CommandResult = runtime.submit_command(&"build_facility",{&"definition_id":&"premium_room",&"origin":Vector3i(-6,2,0)}) + expect(built.is_success(), "normal construction command accepts premium room") + if not built.is_success(): runtime.free();clock.free();state.free();quit(failures);return + clock.advance_minutes(150) + var id: StringName = &"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id == &"premium_room": id = room.instance_id + expect(not id.is_empty(), "premium room completes into operating facility service") + if id.is_empty(): runtime.free();clock.free();state.free();quit(failures);return + expect(runtime.facility_service.get_instance(id).occupied_cells.size()==4, "operating room owns four original cells") + expect(runtime.submit_command(&"register_guest_wallet",{&"guest_id":&"premium_payer",&"gold":1000}).is_success(), "fund service fixture wallet") + var requested: CommandResult = runtime.submit_command(&"create_service_request",{&"request_id":&"premium_stay",&"facility_id":id,&"guest_ids":[&"premium_payer"],&"service_type":&"lodging"}) + expect(requested.is_success(), "premium stay enters normal service queue at definition price") + clock.advance_one_minute() + var active: Array = runtime.facility_service.get_active_services(id) + expect(active.size()==1, "normal operating tick starts premium lodging") + if active.is_empty(): runtime.free();clock.free();state.free();quit(failures);return + var service_id: StringName = active[0].service_id + expect(active[0].duration_minutes==720 and active[0].price_snapshot==220, "premium lodging uses existing duration and price") + clock.advance_minutes(360) + var halfway: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(halfway), "mid-stay current-schema snapshot restores") + clock.advance_minutes(361) + var completed: ServiceInstance = runtime.facility_service.get_service(service_id) + expect(completed.state==ServiceInstance.State.COMPLETED, "restored premium stay completes") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:premium_payer")==780, "premium stay charges exactly 220 gold once") + expect(runtime.economy_service.validate_ledger()[&"ok"], "premium revenue balances the ledger") + clock.advance_minutes(1440-clock.absolute_minute) + expect(runtime.restore_snapshot(runtime.create_snapshot()), "midnight upkeep and premium room persist together") + runtime.free();clock.free();state.free() + print("Premium room integration: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/mvp/test_premium_room.gd.uid b/tests/mvp/test_premium_room.gd.uid new file mode 100644 index 0000000..0744491 --- /dev/null +++ b/tests/mvp/test_premium_room.gd.uid @@ -0,0 +1 @@ +uid://doijy3o7nbtl2 diff --git a/tests/mvp/test_premium_room_guest_flow.gd b/tests/mvp/test_premium_room_guest_flow.gd new file mode 100644 index 0000000..e27a00c --- /dev/null +++ b/tests/mvp/test_premium_room_guest_flow.gd @@ -0,0 +1,114 @@ +extends SceneTree +var checks := 0 +var failures := 0 +var runtime: Variant +var clock: Variant +var state: Variant +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func build(id: StringName, origin: Vector3i) -> bool: + var result: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id":id,&"origin":origin}) + expect(result.is_success(), "normal build %s: %s" % [id,result.message]) + if not result.is_success(): return false + clock.advance_minutes(runtime.definition_registry.get_facility(id).build_minutes) + return true +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();runtime.initialize(clock,state,42502);clock.set_paused(true) + if not build(&"reception",Vector3i(-6,0,0)) or not build(&"premium_room",Vector3i(-6,2,0)) or not build(&"guest_stairs",Vector3i(-7,1,0)) or not build(&"guest_stairs",Vector3i(-7,2,0)): + finish();return + var room_id: StringName = &"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"premium_room": room_id=room.instance_id + expect(runtime.get_facility_access_report(room_id)[&"reachable"], "normal stairs make premium room reachable from entrance") + var spawn: CommandResult = runtime.submit_command(&"spawn_guest",{&"guest_id":&"premium_walker",&"guest_type_id":&"traveler",&"gold":1000, + &"options":{&"current_cell":Vector3i(-7,0,0),&"state":GuestRuntime.State.DECIDING,&"planned_exit_at":clock.absolute_minute+2000, + &"needs":{&"sleep":100,&"hunger":0,&"hygiene":0,&"medical":0,&"leisure":0,&"intimacy":0}}}) + expect(spawn.is_success(), "guest enters deciding state without forcing target or route") + var used_stairs := false + var selected_room := false + var service_id: StringName = &"" + var moving_save := {} + for minute in 40: + clock.advance_one_minute() + var guest: GuestRuntime = runtime.guest_service.get_guest(&"premium_walker") + if guest==null: break + selected_room = selected_room or guest.target_facility_id==room_id + if guest.current_cell==Vector3i(-7,1,0): + used_stairs=true + if moving_save.is_empty(): moving_save=runtime.create_snapshot() + if not guest.current_service_id.is_empty(): + service_id=guest.current_service_id;break + expect(selected_room, "highest sleep need selects premium room autonomously") + expect(used_stairs, "guest physically traverses first staircase landing") + expect(not service_id.is_empty(), "guest arrival automatically queues and starts lodging") + if service_id.is_empty(): + var guest: GuestRuntime=runtime.guest_service.get_guest(&"premium_walker") + print("Guest diagnostic: ", guest.create_snapshot() if guest!=null else {}) + finish();return + var started: Dictionary=runtime.create_snapshot() + expect(not moving_save.is_empty() and runtime.restore_snapshot(moving_save), "actual stair movement save restores") + for minute in 40: + clock.advance_one_minute() + if not runtime.guest_service.get_guest(&"premium_walker").current_service_id.is_empty(): break + expect(runtime.create_snapshot()==started, "stair-save replay reproduces arrival and queued service exactly") + clock.advance_minutes(360) + var mid: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(mid), "real guest and in-progress premium service restore together") + var elapsed: int=runtime.facility_service.get_service(service_id).elapsed_minutes + state.set_resource(&"mana",0) + clock.advance_minutes(15) + var paused: ServiceInstance=runtime.facility_service.get_service(service_id) + expect(paused.state==ServiceInstance.State.PAUSED and paused.elapsed_minutes==elapsed, "zero mana pauses actual guest lodging without consuming service time") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:premium_walker")==1000, "outage does not charge real lodging guest") + var disk_path := "user://saves/premium_guest_outage_%d" % Time.get_ticks_usec() + var outage: Dictionary=runtime.create_snapshot() + expect(SaveService.save_snapshot(disk_path,outage)[&"ok"], "write real guest outage snapshot to disposable save") + var saved := SaveService.load_snapshot(disk_path) + expect(saved[&"ok"] and runtime.restore_snapshot(saved[&"snapshot"]), "restore real guest, paused service and empty mana from disk") + SaveService.delete_save(disk_path) + state.set_resource(&"mana",100) + + for minute in 370: + clock.advance_one_minute() + if runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED: break + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED, "autonomous premium stay completes after restored continuation") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:premium_walker")==780, "real guest pays one premium lodging charge") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "completed guest/service/path/economy crosslinks remain saveable") + clock.advance_minutes(maxi(1440-clock.absolute_minute,0)) + expect(runtime.economy_service.validate_ledger()[&"ok"] and runtime.restore_snapshot(runtime.create_snapshot()), "autonomous lodging and upkeep survive midnight ledger close and restore") + capacity_flow() + finish() +func finish() -> void: + runtime.free();clock.free();state.free() + print("Premium guest flow: %d checks, %d failures" % [checks,failures]);quit(failures) + +func capacity_flow() -> void: + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42503);clock.set_paused(true) + if not build(&"reception",Vector3i(-6,0,0)) or not build(&"premium_room",Vector3i(-6,2,0)) or not build(&"guest_stairs",Vector3i(-7,1,0)) or not build(&"guest_stairs",Vector3i(-7,2,0)): return + var room_id: StringName=&"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"premium_room": room_id=room.instance_id + for index in 3: + var id:=StringName("premium_capacity_%d" % index) + expect(runtime.submit_command(&"spawn_guest",{&"guest_id":id,&"guest_type_id":&"traveler",&"gold":1000, + &"options":{&"current_cell":Vector3i(-7-index,0,0),&"state":GuestRuntime.State.DECIDING,&"planned_exit_at":clock.absolute_minute+2000, + &"needs":{&"sleep":100,&"hunger":0,&"hygiene":0,&"medical":0,&"leisure":0,&"intimacy":0}}}).is_success(), "spawn autonomous capacity guest") + var maximum := 0 + var saw_queue := false + var exceeded := false + for minute in 60: + clock.advance_one_minute() + var using: int=runtime.facility_service.get_active_services(room_id).size() + maximum=maxi(maximum,using) + exceeded=exceeded or using>2 + if using==2: + for guest: GuestRuntime in runtime.guest_service.get_guests(): + if guest.target_facility_id==room_id and guest.state==GuestRuntime.State.QUEUING: saw_queue=true + expect(maximum==2 and not exceeded, "three autonomous guests never exceed two simultaneous lodging users") + expect(saw_queue, "third guest waits separately from the two using the room") + var crowded: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(crowded) and runtime.create_snapshot()==crowded, "occupied room and separate queue restore exactly") diff --git a/tests/mvp/test_premium_room_guest_flow.gd.uid b/tests/mvp/test_premium_room_guest_flow.gd.uid new file mode 100644 index 0000000..f3d11fb --- /dev/null +++ b/tests/mvp/test_premium_room_guest_flow.gd.uid @@ -0,0 +1 @@ +uid://d1jwox0u5ar5x diff --git a/tests/mvp/test_runtime_content_progression.gd b/tests/mvp/test_runtime_content_progression.gd index 11656a5..2121792 100644 --- a/tests/mvp/test_runtime_content_progression.gd +++ b/tests/mvp/test_runtime_content_progression.gd @@ -30,6 +30,7 @@ func _run() -> void: _test_catalog_bootstrap_and_queries() _test_progression_resource_and_guest_facts() _test_catalog_staff_hire_transaction() + _test_unique_runtime_protection() _test_rank_two_three_and_atomic_failure() _test_v3_migration_and_v4_tamper_guards() _test_twenty_four_hour_replay() @@ -71,7 +72,7 @@ func _test_catalog_bootstrap_and_queries() -> void: _expect(bool(report[&"valid"]), "MVP catalog report is valid") _expect(int(report[&"counts"]["guest_classes"]) == 8, "catalog exposes eight Guest classes") _expect(int(report[&"counts"]["species"]) == 4, "catalog exposes four independent species") - _expect(int(report[&"counts"]["staff_candidates"]) == 20, "catalog exposes twenty fixed Staff candidates") + _expect(int(report[&"counts"]["staff_candidates"]) == 21, "catalog exposes twenty-one fixed Staff candidates") _expect(int(report[&"counts"]["unique_characters"]) == 2, "catalog exposes both unique characters") var catalog_ids: Array[StringName] = _runtime.get_mvp_guest_definition_ids() _expect(catalog_ids.size() == 32, "Runtime registers every class/species Guest definition") @@ -382,6 +383,7 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: v3_runtime.erase(&"kitchen_production") v3_runtime.erase(&"elevators") v3_runtime.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(v3_runtime) v3_runtime[&"path"][&"schema_version"] = 3 v3_runtime[&"path"].erase(&"vertical_walk_blockers") v3_runtime[&"path"].erase(&"vertical_walk_requires_connector") @@ -402,7 +404,7 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: _expect(int(_runtime.progression_service.metrics[&"guest_lifetime"]) == 2, "v3 migration derives lifetime Guests from active plus persistent records") _expect(_runtime.get_mvp_guest_definition_ids().size() == 32, "v3 migration registers the current 32 catalog definitions") var migrated: Dictionary = _runtime.create_snapshot() - _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 9, "v3 migration emits canonical Runtime schema v9") + _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 13, "v3 migration emits canonical Runtime schema v13") var digest := SimulationDigest.compute(migrated) _expect(_runtime.restore_snapshot(migrated), "canonical v9 snapshot restores") _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v9 restore is exact") @@ -505,3 +507,24 @@ func _expect(condition: bool, label: String) -> void: return _failures += 1 push_error(" ✗ %s" % label) + + +func _test_unique_runtime_protection() -> void: + _reset_runtime(82_017) + var staff_before: Dictionary = _runtime.staff_service.create_snapshot() + var economy_before: Dictionary = _runtime.economy_service.create_snapshot() + for id: StringName in _runtime.mvp_content_catalog.get_unique_character_ids(): + var forged: CommandResult = _runtime.submit_command(&"register_staff", { + &"staff_id": id, &"role_id": &"receptionist", &"options": {&"employment_kind": &"FREE_HIRE"}}) + _expect(not forged.is_success() and _runtime.staff_service.create_snapshot() == staff_before and _runtime.economy_service.create_snapshot() == economy_before, "reserved unique identity cannot bypass its protected recruitment contract") + # Generic legacy fixture checks UNIQUE behavior without inventing Anri's joining conditions. + var created: CommandResult = _runtime.submit_command(&"register_staff", { + &"staff_id": &"unique_runtime_fixture", &"role_id": &"receptionist", + &"options": {&"employment_kind": &"UNIQUE", &"daily_wage": 75, &"current_cell": Vector3i.ZERO}}) + _expect(created.is_success() and _runtime.economy_service.create_snapshot() == economy_before, "unique runtime fixture registers without signing payment") + var report: Dictionary = _runtime.get_staff_management_report() + _expect(report[&"staff"].size() == 1 and report[&"staff"][0][&"daily_wage"] == 0, "normal staff panel report shows effective zero unique wage") + var before: Dictionary = _runtime.create_snapshot() + _expect(not _runtime.staff_service.retire_staff(&"unique_runtime_fixture").is_success() and _runtime.create_snapshot() == before, "unique retirement rejection preserves complete runtime including paths and ledger") + _expect(_runtime.restore_snapshot(before) and _runtime.create_snapshot() == before, "runtime unique snapshot restores exactly") + _expect(_runtime.get_staff_management_report()[&"staff"][0][&"daily_wage"] == 0 and _runtime.staff_service.get_daily_payroll_total() == 0, "restored runtime and UI both retain zero unique upkeep") diff --git a/tests/mvp/test_runtime_mana_depletion.gd b/tests/mvp/test_runtime_mana_depletion.gd new file mode 100644 index 0000000..18a4b63 --- /dev/null +++ b/tests/mvp/test_runtime_mana_depletion.gd @@ -0,0 +1,81 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: + call_deferred("_run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 424242)[&"valid"], "initialize") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable natural operations") + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"reception", &"origin": Vector3i(-6, 0, 0)}).is_success(), "build reception") + clock.advance_minutes(120) + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-6, 2, 0)}).is_success(), "build zero-demand room on supported floor") + clock.advance_minutes(60) + var reception: FacilityInstance + var room: FacilityInstance + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"reception": reception = facility + if facility.definition_id == &"standard_room": room = facility + expect(room != null and reception != null, "both facilities complete normally") + if room == null or reception == null: + runtime.free(); clock.free(); state.free(); quit(failures); return + # Only reserve depletion is a diagnostic input; no numeric generation policy + # is asserted by this test. Everything else advances through Runtime ticks. + state.set_resource(&"mana", 0) + # Pre-fix saves legitimately contain zero reserve with old powered flags. + var legacy: Dictionary = runtime.create_snapshot() + var legacy_data: Dictionary = legacy[&"systems"][&"runtime"] + legacy_data[&"runtime_schema_version"] = 9 + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(legacy_data) + var source_copy := legacy.duplicate(true) + var old_reception_id := reception.instance_id + var old_room_id := room.instance_id + expect(runtime.restore_snapshot(legacy), "pre-upkeep zero-reserve save migrates with old facility flags") + expect(legacy == source_copy, "migration does not mutate its source save") + expect(state.get_resource(&"mana") == 0 and clock.absolute_minute == 540 and clock.paused, "legacy migration preserves empty reserve saved time and pause") + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 0, "migration does not charge pre-save ownership or mana consumption") + var no_admission: Dictionary = runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + expect(no_admission[&"capacity_remaining"] == 0, "legacy empty reserve rejects admission even before first resumed tick") + var migrated: Dictionary = runtime.create_snapshot() + var invalid: Dictionary = migrated.duplicate(true) + invalid[&"game_state"][&"resources"][&"mana"] = -1 + expect(not runtime.restore_snapshot(invalid) and runtime.create_snapshot() == migrated, "invalid negative-reserve save rejects without changing active state") + clock.advance_minutes(1) + reception = runtime.facility_service.get_instance(old_reception_id) + room = runtime.facility_service.get_instance(old_room_id) + expect(clock.absolute_minute == 541 and not reception.power_available, "depletion gates powered facilities outside ten-minute evaluation") + expect(not room.power_available and room.operation_state == FacilityInstance.OperationState.INACTIVE, "zero-demand room also stops") + var capture: Dictionary = runtime.call("_operations_arrival_boundary", {}, OperationsCoordinator.PHASE_CAPTURE, null) + expect(capture[&"ok"] and capture[&"capacity_remaining"] == 0, "arrival transaction exposes no admission capacity during outage") + var stopped: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(stopped), "outage restores through full runtime") + expect(runtime.create_snapshot() == stopped, "outage snapshot is exact") + clock.advance_minutes(9) + expect(not runtime.facility_service.get_instance(room.instance_id).power_available, "scheduled positive network output cannot bypass empty reserve") + var room_id := room.instance_id + var reception_id := reception.instance_id + runtime.facility_service.set_operational_status(room_id, {&"suspension_reason": &"DESTROYED"}, clock.absolute_minute) + state.set_resource(&"mana", 1) + clock.advance_minutes(1) + room = runtime.facility_service.get_instance(room_id) + reception = runtime.facility_service.get_instance(reception_id) + expect(reception.power_available, "reserve restoration restores actual network supply next minute") + expect(room.power_available and room.operation_state == FacilityInstance.OperationState.SUSPENDED and room.suspension_reason == &"DESTROYED", "power recovery does not reverse destruction suspension") + var recovered: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(recovered), "recovered runtime and persistent suspension restore") + expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "depletion and recovery do not latch a runtime fault") + runtime.free() + clock.free() + state.free() + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/mvp/test_runtime_mana_depletion.gd.uid b/tests/mvp/test_runtime_mana_depletion.gd.uid new file mode 100644 index 0000000..2747861 --- /dev/null +++ b/tests/mvp/test_runtime_mana_depletion.gd.uid @@ -0,0 +1 @@ +uid://brqwbggfrwla6 diff --git a/tests/mvp/test_runtime_operations_acceptance.gd b/tests/mvp/test_runtime_operations_acceptance.gd index f03f16c..a69526d 100644 --- a/tests/mvp/test_runtime_operations_acceptance.gd +++ b/tests/mvp/test_runtime_operations_acceptance.gd @@ -1,7 +1,7 @@ extends SceneTree const FOURTEEN_DAYS_MINUTES := 14 * SimulationKernel.MINUTES_PER_DAY -const RUNTIME_SNAPSHOT_VERSION := 9 +const RUNTIME_SNAPSHOT_VERSION := 13 var _checks := 0 var _failures := 0 @@ -39,7 +39,7 @@ func _new_fixture(seed: int, enable_operations := true) -> Dictionary: int(initial_snapshot[&"systems"][&"runtime"].get( &"runtime_schema_version", -1 )) == RUNTIME_SNAPSHOT_VERSION, - "fixture emits Runtime snapshot schema v9" + "fixture emits Runtime snapshot schema v10" ) if enable_operations: var enabled: Dictionary = runtime.enable_production_operations() @@ -80,6 +80,9 @@ func _advance_operations_boundary_slice(fixture: Dictionary, amount: int) -> int clock.absolute_minute = target clock.call("_sync_calendar_fields") clock.snapshot_version += step + # Weather belongs to TIME_BOUNDARIES even when AI is skipped. + runtime.call("_advance_weather", target) + runtime.call("_advance_facility_upkeep", target) runtime.call("_advance_operations_time", target) if bool(runtime.get_operations_report().get(&"runtime_failure_latched", false)): break @@ -283,17 +286,17 @@ func _test_full_partial_payroll_bankruptcy_and_v9_tamper() -> void: _expect(bool(enabled.get(&"ok", false)), "payroll fixture enables Operations after the pre-opening hire") var full_advanced := _advance_operations_boundary_slice( - fixture, SimulationKernel.MINUTES_PER_DAY + fixture, SimulationKernel.MINUTES_PER_DAY - 360 ) var full: Dictionary = runtime.staff_service.get_payroll_settlement(&"payroll-day-00000001") _expect( - full_advanced == SimulationKernel.MINUTES_PER_DAY + full_advanced == SimulationKernel.MINUTES_PER_DAY - 360 and _operations_slice_is_healthy(fixture) - and clock.absolute_minute == SimulationKernel.MINUTES_PER_DAY + 6 * 60 + and clock.absolute_minute == SimulationKernel.MINUTES_PER_DAY and int(full.get(&"new_wages_total", -1)) == daily_wage and int(full.get(&"paid_total", -1)) == daily_wage and int(full.get(&"unpaid_after_total", -1)) == 0, - "Day 1 payroll is paid in full and closes at exactly 06:00" + "Day 1 payroll is paid in full and closes at exactly 00:00" ) var cash_now: int = runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) @@ -318,7 +321,7 @@ func _test_full_partial_payroll_bankruptcy_and_v9_tamper() -> void: _expect( partial_advanced == SimulationKernel.MINUTES_PER_DAY and _operations_slice_is_healthy(fixture) - and clock.absolute_minute == 2 * SimulationKernel.MINUTES_PER_DAY + 6 * 60 + and clock.absolute_minute == 2 * SimulationKernel.MINUTES_PER_DAY and int(partial.get(&"new_wages_total", -1)) == daily_wage and int(partial.get(&"paid_total", -1)) == 50 and int(partial.get(&"unpaid_after_total", -1)) == daily_wage - 50, @@ -326,24 +329,21 @@ func _test_full_partial_payroll_bankruptcy_and_v9_tamper() -> void: ) var terminal_advanced := _advance_operations_boundary_slice( - fixture, 2 * SimulationKernel.MINUTES_PER_DAY + fixture, SimulationKernel.MINUTES_PER_DAY ) var coordinator: OperationsCoordinator = runtime.operations_coordinator var last_three_negative := true - for day in [2, 3, 4]: + for day in [1, 2, 3]: var summary: Dictionary = coordinator.get_day_summary(day) last_three_negative = last_three_negative \ - and int(summary.get(&"net_profit", 0)) < 0 \ - and int((summary.get(&"settlement", {}) as Dictionary).get( - &"arrears_total", 0 - )) > 0 + and int(summary.get(&"net_profit", 0)) < 0 _expect( - terminal_advanced == 2 * SimulationKernel.MINUTES_PER_DAY - and clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60 + terminal_advanced == SimulationKernel.MINUTES_PER_DAY + and clock.absolute_minute == 3 * SimulationKernel.MINUTES_PER_DAY and last_three_negative and coordinator.get_consecutive_insolvency_count() == 3 and coordinator.get_terminal_state() == &"BANKRUPT", - "three consecutive negative closes with arrears enter BANKRUPT at the third boundary" + "three consecutive negative closes, including the fully paid first day, enter BANKRUPT at the third boundary" ) var terminal_snapshot: Dictionary = runtime.create_snapshot() var terminal_digest := SimulationDigest.compute(terminal_snapshot) @@ -353,7 +353,7 @@ func _test_full_partial_payroll_bankruptcy_and_v9_tamper() -> void: ) clock.advance_minutes(180) _expect( - clock.absolute_minute == 4 * SimulationKernel.MINUTES_PER_DAY + 6 * 60 + clock.absolute_minute == 3 * SimulationKernel.MINUTES_PER_DAY and SimulationDigest.compute(runtime.create_snapshot()) == terminal_digest, "BANKRUPT is terminal: later time requests are exact no-ops" ) @@ -362,6 +362,9 @@ func _test_full_partial_payroll_bankruptcy_and_v9_tamper() -> void: "BANKRUPT cannot be acknowledged away" ) + var blocked_command: CommandResult = runtime.submit_command(&"change_resources", {&"deltas": {&"gold": 500}}) + _expect(not blocked_command.is_success() and SimulationDigest.compute(runtime.create_snapshot()) == terminal_digest, "terminal rejects new commands without changing funds, command IDs, or history") + _assert_v9_tamper_rejected( runtime, terminal_snapshot, diff --git a/tests/mvp/test_runtime_operations_integration.gd b/tests/mvp/test_runtime_operations_integration.gd index 4d65497..eaee7f1 100644 --- a/tests/mvp/test_runtime_operations_integration.gd +++ b/tests/mvp/test_runtime_operations_integration.gd @@ -20,6 +20,7 @@ func _run() -> void: _test_after_mutation_notifications_are_atomic() _test_deferred_failure_backlog_and_exact_retry() _test_daily_payroll_financials_and_crosslink_tamper() + _test_legacy_financial_calendar() _cleanup() if _failures == 0: print("PASS: %d checks" % _checks) @@ -57,7 +58,7 @@ func _test_disabled_compatibility_and_enable() -> void: _expect(not disabled[&"enabled"], "Operations remain disabled for existing Runtime tests") var disabled_snapshot: Dictionary = _runtime.create_snapshot() var disabled_runtime: Dictionary = disabled_snapshot[&"systems"][&"runtime"] - _expect(int(disabled_runtime[&"runtime_schema_version"]) == 9, "Runtime snapshot advances to schema v9") + _expect(int(disabled_runtime[&"runtime_schema_version"]) == 13, "Runtime snapshot advances to schema v13") _expect( disabled_runtime[&"operations"] == { &"wrapper_version": 1, @@ -226,7 +227,7 @@ func _test_deferred_failure_backlog_and_exact_retry() -> void: &"phase": phase, } ) - _clock.set_speed(4) + _clock.set_speed(3) var processed: int = _clock.process_real_delta(5.0) var failed_report: Dictionary = _runtime.get_operations_report() _expect( @@ -297,9 +298,9 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: var settlement: Dictionary = summary.get(&"settlement", {}) _expect( _clock.absolute_minute == 1_800 \ - and int(summary.get(&"closed_at", -1)) == 1_800 \ + and int(summary.get(&"closed_at", -1)) == 1_440 \ and int(summary[&"category_totals"][&"wages"]) == 120, - "06:00 closes Day 1 once and accrues only the new daily wage" + "00:00 closes Day 1 once and accrues only the new daily wage" ) _expect( int(settlement.get(&"paid_total", -1)) == 120 \ @@ -314,7 +315,7 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: _expect( wage_entries.size() == 1 \ and wage_entries[0].category == &"WAGE" \ - and wage_entries[0].absolute_minute == 1_800 \ + and wage_entries[0].absolute_minute == 1_440 \ and wage_entries[0].amount == 120, "paid payroll posts one canonical WAGE ledger line at the closing boundary" ) @@ -363,6 +364,7 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: downgraded[&"systems"][&"runtime"].erase(&"kitchen_production") downgraded[&"systems"][&"runtime"].erase(&"elevators") downgraded[&"systems"][&"runtime"].erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(downgraded[&"systems"][&"runtime"]) for infrastructure_key: StringName in [&"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure"]: downgraded[&"systems"][&"runtime"].erase(infrastructure_key) downgraded[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 @@ -376,6 +378,35 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: ) +func _test_legacy_financial_calendar() -> void: + _reset_runtime(811_005) + _expect(_runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": &"staff_akane", &"current_cell": Vector3i.ZERO}).is_success(), "legacy Runtime fixture hires a real wage-bearing employee") + _expect(bool(_runtime.enable_production_operations().get(&"ok", false)), "legacy Runtime fixture enables operations") + # Reproduce the old calendar and its zero-arrears counter. Payroll and ledger + # entries stay untouched; only the new loss metadata is converted to v3 semantics. + _runtime.operations_coordinator._legacy_calendar_last_day = 1 + _clock.advance_minutes(1440) + var snapshot: Dictionary = _runtime.create_snapshot() + var old: Dictionary = snapshot[&"systems"][&"runtime"][&"operations"][&"coordinator"] + old[&"schema_version"] = 3 + old.erase(&"legacy_calendar_last_day") + old.erase(&"legacy_history_last_day") + old[&"consecutive_insolvency_count"] = 0 + for row: Dictionary in old[&"day_summaries"]: + row[&"consecutive_insolvency_count"] = 0 + old[&"alerts"] = (old[&"alerts"] as Array).filter(func(row: Dictionary) -> bool: return String(row.get(&"code", "")) != "LOSS_STREAK") + _expect(int(old[&"day_summaries"][0][&"settlement"][&"paid_total"]) == 120, "legacy fixture contains an actual paid payroll at 06:00") + _expect(_runtime.restore_snapshot(snapshot), "Runtime restores v3 06:00 payroll and ledger crosslinks") + _clock.advance_minutes(1440) + _expect(_runtime.operations_coordinator.get_day_summary(2)[&"closed_at"] == 3240, "legacy open day completes at 06:00 once") + var transition: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(transition), "Runtime restores the transition checkpoint") + _clock.advance_minutes(1080) + var summary: Dictionary = _runtime.operations_coordinator.get_day_summary(3) + _expect(summary[&"closed_at"] == 4320 and summary[&"period_start_minute"] == 3240, "Runtime transitions to midnight with a continuous 18-hour financial period") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "midnight transition preserves payroll and ledger crosslinks on load") + + func _rejects_atomically(candidate: Dictionary) -> bool: var before: Dictionary = _runtime.create_snapshot() return not _runtime.restore_snapshot(candidate) \ diff --git a/tests/mvp/test_runtime_upkeep.gd b/tests/mvp/test_runtime_upkeep.gd new file mode 100644 index 0000000..77233ac --- /dev/null +++ b/tests/mvp/test_runtime_upkeep.gd @@ -0,0 +1,103 @@ +extends SceneTree +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 clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 424242)[&"valid"], "initialize") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable") + var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"standard_room") + var original_rate := definition.maintenance_gold + definition.maintenance_gold = 24 + expect(runtime.facility_service.create_instance({&"instance_id": &"upkeep-room", &"definition_id": &"standard_room"}).is_success(), "completed room") + runtime.facility_service.set_operational_status(&"upkeep-room", {&"suspension_reason": &"DESTROYED"}, 360) + clock.advance_minutes(60) + expect(runtime.facility_upkeep.get_report()[&"pending_facility_units"].get("upkeep-room", 0) == 1440, "suspended room accrues one hour") + runtime.facility_service.unregister_instance(&"upkeep-room") + clock.advance_minutes(1019) + var before: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(before), "23:59 save restores incurred upkeep after demolition") + clock.advance_minutes(1) + var summary: Dictionary = runtime.operations_coordinator.get_day_summary(1) + expect(not summary.is_empty(), "midnight closes") + if not summary.is_empty(): + expect(summary[&"category_totals"][&"maintenance"] == 1, "one hour charged, post demolition no accrual") + expect(summary[&"facility_profit_loss"].get(&"upkeep-room", {}).get(&"expenses", 0) == 1, "demolished facility expense retained") + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1, "expense ledger") + var saved: Dictionary = runtime.create_snapshot() + DirAccess.make_dir_recursive_absolute("res://artifacts/upkeep-2026-09-29") + FileAccess.open("res://artifacts/upkeep-2026-09-29/preclose-snapshot.txt", FileAccess.WRITE).store_string(var_to_str(before)) + FileAccess.open("res://artifacts/upkeep-2026-09-29/closed-snapshot.txt", FileAccess.WRITE).store_string(var_to_str(saved)) + expect(runtime.restore_snapshot(saved), "closed upkeep save roundtrip") + expect(runtime.create_snapshot() == saved, "closed snapshot exact") + var panel := OperatingLedgerPanel.new() + panel.setup(runtime, clock) + expect(panel.get_report()[&"upkeep_cash_out"] == 1, "UI shows midnight maintenance payment") + expect(panel.get_report()[&"upkeep_arrears"] == 0, "UI distinguishes unpaid balance") + panel.free() + var tampered := saved.duplicate(true) + tampered[&"systems"][&"runtime"][&"facility_upkeep"][&"closed_periods"][0][&"facility_units"]["upkeep-room"] += 1 + expect(not runtime.restore_snapshot(tampered) and runtime.create_snapshot() == saved, "receipt cannot diverge from accrued units") + expect(runtime.restore_snapshot(before), "restore before unpaid close") + var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH") + expect(runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", cash, 1439, &"CONSTRUCTION").is_success(), "spend cash before midnight") + clock.advance_minutes(1) + expect(runtime.upkeep_settlement.get_report()[&"arrears_total"] == 1, "insufficient funds retain full upkeep debt") + expect(runtime.operations_coordinator.get_arrears_total() == 1, "coordinator includes upkeep debt") + expect(runtime.operations_coordinator.get_day_summary(1)[&"category_totals"][&"maintenance"] == 1, "unpaid upkeep still reduces profit") + var unpaid: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(unpaid), "unpaid close saves and restores") + expect(runtime.create_snapshot() == unpaid, "unpaid close does not charge twice") + # A controlled construction refund funds next-day repayment. This is a ledger + # fixture, not evidence of natural sales or a normal-play balance result. + expect(runtime.economy_service.transfer(&"CONSTRUCTION_EXPENSE", &"PLAYER_CASH", 2, 1440, &"CONSTRUCTION_REFUND").is_success(), "refund provides repayment funds") + var funded: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(funded), "funded debt survives save restore") + clock.advance_minutes(1439) + expect(runtime.upkeep_settlement.get_arrears_total() == 1 and runtime.economy_service.get_balance(&"PLAYER_CASH") == 2, "debt awaits next midnight") + var repayment_before: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(repayment_before), "next 23:59 restores outstanding debt") + clock.advance_minutes(1) + expect(not runtime.get_operations_report()[&"runtime_failure_latched"], "next-day repayment completes without failure") + expect(runtime.upkeep_settlement.get_arrears_total() == 0 and runtime.operations_coordinator.get_arrears_total() == 0, "next midnight clears runtime and coordinator debt") + expect(runtime.economy_service.get_balance(&"PLAYER_CASH") == 1, "old debt paid exactly once") + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1, "repayment does not recognize expense twice") + var next_summary: Dictionary = runtime.operations_coordinator.get_day_summary(2) + expect(not next_summary.is_empty() and next_summary[&"category_totals"][&"maintenance"] == 0, "next-day profit excludes prior expense repayment") + var repaid: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(repaid) and runtime.create_snapshot() == repaid, "repaid snapshot roundtrip is exact") + # Failure after posting upkeep must roll back receipts, ledger, cash and payroll. + expect(runtime.restore_snapshot(before), "restore before failed close") + expect(runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", 1, 1439, &"UNKNOWN_TEST_CATEGORY").is_success(), "inject classification failure") + var ledger_before: Dictionary = runtime.economy_service.create_snapshot() + clock.advance_minutes(1) + expect(runtime.get_operations_report()[&"runtime_failure_latched"], "failed close pauses runtime") + expect(runtime.economy_service.create_snapshot() == ledger_before, "post-upkeep failure rolls ledger back exactly") + expect(runtime.upkeep_settlement.get_report()[&"receipts"].is_empty(), "failed close leaves no settlement receipt") + # Old saves start charging only from the restored minute. + expect(runtime.restore_snapshot(before), "recover canonical preclose state") + var legacy := before.duplicate(true) + var data: Dictionary = legacy[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 9 + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) + expect(runtime.restore_snapshot(legacy), "genuine v9 migrates") + expect(runtime.facility_upkeep.create_snapshot()[&"started_at"] == 1439, "migration starts at save minute without retroactive charge") + clock.advance_minutes(1) + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 0, "legacy save never bills pre-migration ownership") + definition.maintenance_gold = original_rate + runtime.free() + clock.free() + state.free() + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/mvp/test_runtime_upkeep.gd.uid b/tests/mvp/test_runtime_upkeep.gd.uid new file mode 100644 index 0000000..5a04f7d --- /dev/null +++ b/tests/mvp/test_runtime_upkeep.gd.uid @@ -0,0 +1 @@ +uid://bm2d2aq11wnev diff --git a/tests/mvp/test_staff_unique_contract_report.gd b/tests/mvp/test_staff_unique_contract_report.gd new file mode 100644 index 0000000..70b005d --- /dev/null +++ b/tests/mvp/test_staff_unique_contract_report.gd @@ -0,0 +1,117 @@ +extends SceneTree +## Boundary coverage for owned canonical unique employment reports. +## Current definitions supply values; synthetic identity/link cases are disclosed. +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("UNIQUE_CONTRACT_REPORT: " + label) + +func row_for(staff_id: StringName) -> Dictionary: + for row: Dictionary in runtime.get_staff_management_report()[&"staff"]: + if row[&"staff_id"] == staff_id: + return row + return {} + +func effect_for(staff_id: StringName) -> Dictionary: + var row := row_for(staff_id) + var contract: Dictionary = row.get(&"unique_contract", {}) + var effects: Array = contract.get(&"passive_effects", []) + return effects[0] if effects.size() == 1 else {} + +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() + expect(runtime.initialize(clock, state, 42602)[&"valid"], "canonical content initializes") + clock.set_paused(true) + expect(runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": &"heroine_anri"}).is_success(), "normal approved command joins canonical Anri") + expect(runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": &"staff_akane"}).is_success(), "normal paid candidate remains available") + var unassigned := row_for(&"heroine_anri") + var contract: Dictionary = unassigned.get(&"unique_contract", {}) + expect(contract.get(&"canonical_id") == &"heroine_anri" and contract.get(&"free_employment") == true and contract.get(&"free_upkeep") == true and contract.get(&"permanent_removal_forbidden") == true, "report connects canonical free and protected employment") + expect(row_for(&"staff_akane").get(&"unique_contract", {}).is_empty(), "ordinary paid actor receives no unique contract") + expect(effect_for(&"heroine_anri").get(&"multiplier_bp") == 12000 and effect_for(&"heroine_anri").get(&"applied") == false and not effect_for(&"heroine_anri").has(&"floor_y"), "unassigned report preserves original effect but has no invented target") + var panel := StaffManagementPanel.new() + var unassigned_text: String = panel._unique_contract_text(unassigned) + expect(unassigned_text.contains("雇用・維持費無料") and unassigned_text.contains("解雇・売却不可") and unassigned_text.contains("未適用(配属先なし・施設不在)"), "UI presents valid unassigned contract without a false active floor") + expect(panel._unique_contract_text(row_for(&"staff_akane")).is_empty(), "ordinary UI does not infer unique identity from wages") + identity_boundaries() + for request: Array in [[&"reception", Vector3i(-6,0,0)], [&"warehouse", Vector3i(-6,0,1)], [&"premium_room", Vector3i(-6,2,0)], [&"warehouse", Vector3i(-6,2,1)], [&"service_stairs", Vector3i(-7,1,1)], [&"service_stairs", Vector3i(-7,2,1)]]: + var built: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": request[0], &"origin": request[1]}) + expect(built.is_success(), "normal construction provides accessible upper-floor fixtures") + if not built.is_success(): + finish(panel) + return + clock.advance_minutes(runtime.definition_registry.get_facility(request[0]).build_minutes) + var workplace: FacilityInstance + var room: FacilityInstance + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse" and facility.origin.y == 2: + workplace = facility + if facility.definition_id == &"premium_room": + room = facility + expect(workplace != null and room != null, "real upper workplace and opposite-layer room exist") + if workplace == null or room == null: + finish(panel) + return + expect(runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": &"heroine_anri", &"facility_id": workplace.instance_id, &"template_id": &"NIGHT"}).is_success(), "normal assignment uses existing path/role/capacity checks") + var effect := effect_for(&"heroine_anri") + expect(effect.get(&"applied") == true and effect.get(&"floor_y") == 2 and effect.get(&"assigned_facility_id") == workplace.instance_id and effect.get(&"scope") == &"ASSIGNED_FLOOR_ALL_LAYERS", "report exposes actual planned target floor across both layers") + expect(runtime.get_floor_price_multiplier(2) == effect[&"multiplier_bp"] and runtime.get_floor_price_multiplier(0) == 10000 and runtime.get_facility_business_price(room) == 264, "presentation matches existing real pricing without changing its rounding") + expect(panel._unique_contract_text(row_for(&"heroine_anri")).contains("対象:3F(表層・裏層の全施設)"), "UI converts saved target Y with the existing floor naming convention") + var worker: StaffRuntime = runtime.staff_service.get_staff(&"heroine_anri") + var old_state := worker.state + worker.state = StaffRuntime.State.OFF_DUTY # Disclosed read-only applicability boundary fixture. + expect(effect_for(&"heroine_anri").get(&"applied") == true and runtime.get_floor_price_multiplier(2) == 12000, "passive placement does not acquire an attendance condition") + worker.state = old_state + var old_operation := workplace.operation_state + workplace.operation_state = FacilityInstance.OperationState.SUSPENDED # Presentation-only boundary fixture. + expect(effect_for(&"heroine_anri").get(&"applied") == true and runtime.get_floor_price_multiplier(2) == 12000, "passive placement does not acquire a workplace-operation condition") + workplace.operation_state = old_operation + var before: Dictionary = runtime.create_snapshot() + var before_bytes: PackedByteArray = var_to_bytes(before) + var exposed := row_for(&"heroine_anri") + exposed[&"unique_contract"][&"free_upkeep"] = false + exposed[&"unique_contract"][&"passive_effects"][0][&"floor_y"] = -999 + exposed[&"unique_contract"][&"passive_effects"].clear() + expect(effect_for(&"heroine_anri").get(&"floor_y") == 2 and row_for(&"heroine_anri")[&"unique_contract"][&"free_upkeep"] == true, "report callers cannot mutate future contract/effect presentation") + expect(runtime.create_snapshot() == before and var_to_bytes(runtime.create_snapshot()) == before_bytes, "report and text rendering leave every live field/event/RNG/ID/queue byte unchanged") + var missing_plan: Dictionary = runtime.staff_service._work_assignments[&"heroine_anri"].duplicate(true) + runtime.staff_service._work_assignments[&"heroine_anri"][&"facility_id"] = &"absent_contract_workplace" # Invalid-link observation fixture, never saved as valid data. + expect(effect_for(&"heroine_anri").get(&"applied") == false and not effect_for(&"heroine_anri").has(&"floor_y") and runtime.get_floor_price_multiplier(2) == 10000, "missing facility neither invents a target nor keeps an active bonus") + expect(panel._unique_contract_text(row_for(&"heroine_anri")).contains("未適用(配属先なし・施設不在)"), "missing target is actionable in the ordinary detail text") + runtime.staff_service._work_assignments[&"heroine_anri"] = missing_plan + expect(runtime.create_snapshot() == before and var_to_bytes(runtime.create_snapshot()) == before_bytes, "invalid-link fixture was fully restored before compatibility assertions") + var report_before: Dictionary = runtime.get_staff_management_report() + expect(runtime.restore_snapshot(before), "unchanged existing schema restores canonical hire and planned placement") + expect(runtime.create_snapshot() == before and var_to_bytes(runtime.create_snapshot()) == before_bytes and runtime.get_staff_management_report() == report_before, "normal restore reconstructs report from persisted existing fields without new save values") + expect(not runtime.staff_service.remove_staff(&"heroine_anri", &"SOLD").is_success() and not runtime.staff_service.retire_staff(&"heroine_anri").is_success() and runtime.create_snapshot() == before, "visible protection corresponds to existing atomic remove/retire rejection") + finish(panel) + +func identity_boundaries() -> void: + for options: Array in [[&"heroine_anri", &"innkeeper", &"FREE_HIRE"], [&"heroine_anri", &"cleaner", &"UNIQUE"], [&"unregistered_unique_fixture", &"innkeeper", &"UNIQUE"], [&"paid_zero_wage_fixture", &"receptionist", &"FREE_HIRE"]]: + var staff := StaffRuntime.new().configure(options[0], options[1], {&"employment_kind": options[2], &"daily_wage": 0}) + expect(runtime._staff_unique_contract_report(staff).is_empty(), "synthetic wrong identity/kind/role or zero salary is not promoted to canonical unique") + var rai := StaffRuntime.new().configure(&"rival_rai", &"influencer", {&"employment_kind": &"UNIQUE"}) + var contract: Dictionary = runtime._staff_unique_contract_report(rai) + expect(contract.get(&"canonical_id") == &"rival_rai" and contract.get(&"permanent_removal_forbidden") == true and contract.get(&"passive_effects", []).is_empty(), "canonical Rai gets general protection but no invented Anri effect or normal joining") + +func finish(panel: StaffManagementPanel) -> void: + panel.free() + runtime.free() + clock.free() + state.free() + print("STAFF_UNIQUE_CONTRACT_REPORT checks=%d failures=%d" % [checks, failures]) + quit(failures) diff --git a/tests/mvp/test_staff_unique_contract_report.gd.uid b/tests/mvp/test_staff_unique_contract_report.gd.uid new file mode 100644 index 0000000..b2bfdc2 --- /dev/null +++ b/tests/mvp/test_staff_unique_contract_report.gd.uid @@ -0,0 +1 @@ +uid://c1qit82sded3m diff --git a/tests/mvp/test_unique_character_validation_policy.gd b/tests/mvp/test_unique_character_validation_policy.gd new file mode 100644 index 0000000..86a6bfb --- /dev/null +++ b/tests/mvp/test_unique_character_validation_policy.gd @@ -0,0 +1,288 @@ +extends SceneTree +## Boundary coverage for trusted per-person character definitions. +## The synthetic worker borrows existing metadata solely to exercise validators. +## It is not Fine, a proposed profile, recruitment or an active Runtime effect. + +const CatalogType := preload("res://scripts/data/mvp_content_catalog.gd") +const FIXTURE_ID: StringName = &"heroine_policy_cleaner_fixture" + +class EntryProbe: + extends MvpContentCatalog + func inspect_entry(entry: Dictionary) -> Dictionary: + _reset() + _validate_entry(&"unique_characters", StringName(_value(entry, &"id", &"")), entry) + return get_report() + func inspect_links(entries: Array, staff_index: Dictionary = {}) -> Dictionary: + _reset() + var index: Dictionary = {} + var ids: Array[StringName] = [] + for entry: Dictionary in entries: + var entry_id: StringName = StringName(_value(entry, &"id", &"")) + var errors_before: int = _errors.size() + _validate_entry(&"unique_characters", entry_id, entry) + if _errors.size() == errors_before: + index[entry_id] = _normalized_entry(entry) + ids.append(entry_id) + ids.sort_custom(_name_less) + _entries_by_type[&"unique_characters"] = index + _entries_by_type[&"staff_candidates"] = staff_index.duplicate(true) + _ordered_ids_by_type[&"unique_characters"] = ids + _validate_cross_references() + return get_report() + func policy_copy(entry_id: StringName) -> Dictionary: + return _unique_validation_policy(entry_id) + +class ExplicitWorkerPolicyProbe: + extends EntryProbe + func _unique_validation_policy(entry_id: StringName) -> Dictionary: + if entry_id == &"heroine_policy_cleaner_fixture": + return { + &"character_kind": &"heroine", + &"mechanical_effect_ids": [&"cleaning_work_rate_multiplier"], + } + return super._unique_validation_policy(entry_id) + +var checks := 0 +var failures := 0 + +func _initialize() -> void: + call_deferred("run") + +func run() -> void: + baseline_contract() + explicit_worker_boundary() + person_effect_isolation() + pair_boundary() + common_safety_boundary() + roster_atomicity_and_recovery() + if failures == 0: + print("PASS: UniqueCharacterValidationPolicy (%d checks)" % checks) + else: + push_error("FAIL: UniqueCharacterValidationPolicy (%d of %d checks failed)" % [failures, checks]) + quit(failures) + +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("UNIQUE_POLICY: " + label) + +func codes(report: Dictionary) -> Array[StringName]: + var result: Array[StringName] = [] + for issue: Dictionary in report[&"errors"]: + result.append(StringName(issue[&"code"])) + return result + +func documents() -> Dictionary: + var result: Dictionary = {} + for content_type: StringName in CatalogType.DEFAULT_PATHS: + var file := FileAccess.open(CatalogType.DEFAULT_PATHS[content_type], FileAccess.READ) + if file == null: + expect(false, "canonical input readable: " + String(content_type)) + return {} + result[content_type] = JSON.parse_string(file.get_as_text()) + file.close() + return result + +func unique_entry(entry_id: StringName) -> Dictionary: + for entry: Dictionary in documents()[&"unique_characters"]["entries"]: + if StringName(entry["id"]) == entry_id: + return entry.duplicate(true) + return {} + +func worker_entry() -> Dictionary: + var entry: Dictionary = unique_entry(&"heroine_anri") + entry["id"] = String(FIXTURE_ID) + entry["display_name"] = "検証専用・架空の清掃担当" + entry["role_id"] = "cleaner" + entry["mechanical_effects"] = {"cleaning_work_rate_multiplier": 3} + entry.erase("counterpart_id") + return entry + +func baseline_contract() -> void: + var catalog := CatalogType.new() + var report: Dictionary = catalog.rebuild() + expect(bool(report[&"valid"]) and int(report[&"error_count"]) == 0, "unchanged two-person JSON validates") + expect(catalog.get_unique_character_ids() == [&"heroine_anri", &"rival_rai"], "normal unique roster remains exactly Anri and Rai") + expect(catalog.get_recruitable_staff_ids().size() == 22, "21 paid candidates plus approved Anri only") + expect(catalog.get_recruitment_entry(&"rival_rai").is_empty(), "Rai is not silently made recruitable") + expect(catalog.get_recruitment_entry(&"heroine_fine").is_empty(), "Fine is not registered") + expect(catalog.get_recruitment_entry(FIXTURE_ID).is_empty(), "synthetic worker is not registered") + var anri: Dictionary = catalog.get_entry(&"unique_characters", &"heroine_anri") + var rai: Dictionary = catalog.get_entry(&"unique_characters", &"rival_rai") + expect(anri[&"mechanical_effects"] == {&"assigned_floor_revenue_multiplier_bp": 12000}, "Anri's existing approved revenue definition is preserved") + expect(rai[&"mechanical_effects"] == {&"base_shield_attack": 8, &"night_shield_attack_bonus": 4}, "Rai's existing bounded attack definition is preserved") + expect(anri[&"counterpart_id"] == &"rival_rai" and rai[&"counterpart_id"] == &"heroine_anri", "explicit current MVP pair is preserved") + var payload: Dictionary = catalog.staff_hire_payload(&"heroine_anri") + expect(payload[&"options"][&"employment_kind"] == &"UNIQUE" and payload[&"options"][&"daily_wage"] == 0, "existing Anri hire payload remains free UNIQUE") + var probe := EntryProbe.new() + var policy: Dictionary = probe.policy_copy(&"heroine_anri") + policy[&"mechanical_effect_ids"].clear() + policy[&"counterpart_id"] = &"fabricated" + var pristine: Dictionary = probe.policy_copy(&"heroine_anri") + expect(pristine[&"mechanical_effect_ids"] == [&"assigned_floor_revenue_multiplier_bp"] and pristine[&"counterpart_id"] == &"rival_rai", "returned policy is an owned nested copy") + expect(probe.policy_copy(FIXTURE_ID).is_empty() and probe.policy_copy(&"heroine_fine").is_empty(), "production policy cannot infer synthetic or Fine identities") + +func explicit_worker_boundary() -> void: + var probe := ExplicitWorkerPolicyProbe.new() + var entry: Dictionary = worker_entry() + var report: Dictionary = probe.inspect_entry(entry) + expect(int(report[&"error_count"]) == 0, "explicit synthetic policy accepts cleaning 3 without revenue or counterpart") + expect(not entry.has("counterpart_id") and not entry["mechanical_effects"].has("assigned_floor_revenue_multiplier_bp"), "positive fixture has neither an invented partner nor Anri effect") + report = probe.inspect_links([entry]) + expect(int(report[&"error_count"]) == 0, "unpaired synthetic worker has no orphan or reciprocal requirement") + var ordinary_probe := EntryProbe.new() + report = ordinary_probe.inspect_entry(entry) + expect(&"missing_unique_policy" in codes(report), "same data without a trusted ID policy rejects") + for raw_counterpart: Variant in [null, "", "rival_rai", FIXTURE_ID, 7, true]: + entry = worker_entry() + entry["counterpart_id"] = raw_counterpart + report = probe.inspect_entry(entry) + expect(&"unexpected_counterpart" in codes(report), "unpaired policy rejects present counterpart value %s" % str(raw_counterpart)) + entry = worker_entry() + entry[&"counterpart_id"] = &"" + expect(&"unexpected_counterpart" in codes(probe.inspect_entry(entry)), "StringName counterpart presence also rejects") + for raw_value: Variant in [3, 3.0]: + entry = worker_entry() + entry["mechanical_effects"]["cleaning_work_rate_multiplier"] = raw_value + expect(int(probe.inspect_entry(entry)[&"error_count"]) == 0, "integer-compatible exact 3 follows existing numeric contract") + for raw_value: Variant in [0, 1, 2, 4, -3, 1000001, "3", true, null, 3.5, NAN, INF]: + entry = worker_entry() + entry["mechanical_effects"]["cleaning_work_rate_multiplier"] = raw_value + expect(&"invalid_mechanical_effect" in codes(probe.inspect_entry(entry)), "non-exact cleaning multiplier rejects: %s" % str(raw_value)) + entry = worker_entry() + entry["id"] = "heroine_unknown_worker" + expect(&"missing_unique_policy" in codes(probe.inspect_entry(entry)), "fixture override does not accept arbitrary other workers") + entry = worker_entry() + entry["unique_policy"] = {"mechanical_effect_ids": ["cleaning_work_rate_multiplier"]} + expect(&"unknown_field" in codes(probe.inspect_entry(entry)), "JSON cannot inject or widen trusted policy") + +func person_effect_isolation() -> void: + var probe := ExplicitWorkerPolicyProbe.new() + for person_id: StringName in [&"heroine_anri", &"rival_rai"]: + var entry: Dictionary = unique_entry(person_id) + entry["mechanical_effects"]["cleaning_work_rate_multiplier"] = 3 + expect(&"effect_kind_mismatch" in codes(probe.inspect_entry(entry)), "reserved cleaning validator does not grant effect to " + String(person_id)) + entry = unique_entry(person_id) + entry["mechanical_effects"] = {"cleaning_work_rate_multiplier": 3} + var issues: Array[StringName] = codes(probe.inspect_entry(entry)) + expect(&"missing_mechanical_effect" in issues and &"effect_kind_mismatch" in issues, "replacement cleaning effect cannot satisfy " + String(person_id)) + var worker: Dictionary = worker_entry() + worker["mechanical_effects"]["assigned_floor_revenue_multiplier_bp"] = 12000 + expect(&"effect_kind_mismatch" in codes(probe.inspect_entry(worker)), "worker cannot acquire Anri's revenue effect") + worker = worker_entry() + worker["mechanical_effects"] = {"assigned_floor_revenue_multiplier_bp": 12000} + var worker_codes: Array[StringName] = codes(probe.inspect_entry(worker)) + expect(&"missing_mechanical_effect" in worker_codes and &"effect_kind_mismatch" in worker_codes, "heroine kind is not a fallback to Anri effect") + worker = worker_entry() + worker["mechanical_effects"]["base_shield_attack"] = 8 + expect(&"effect_kind_mismatch" in codes(probe.inspect_entry(worker)), "worker cannot acquire Rai's attack") + worker = worker_entry() + worker["mechanical_effects"]["arbitrary_runtime_hook"] = 1 + var hook_codes: Array[StringName] = codes(probe.inspect_entry(worker)) + expect(&"invalid_mechanical_effect" in hook_codes and &"effect_kind_mismatch" in hook_codes, "unknown hook remains rejected by value and person boundaries") + worker = worker_entry() + worker["mechanical_effects"] = {} + expect(&"missing_mechanical_effect" in codes(probe.inspect_entry(worker)), "an explicit worker still requires its own effect") + var anri: Dictionary = unique_entry(&"heroine_anri") + anri["character_kind"] = "rival" + expect(&"character_policy_mismatch" in codes(probe.inspect_entry(anri)), "known person's kind cannot be forged") + for person_id: StringName in [&"heroine_anri", &"rival_rai"]: + var entry: Dictionary = unique_entry(person_id) + entry["mechanical_effects"] = {"arbitrary_runtime_hook": 1} + var issues: Array[StringName] = codes(probe.inspect_entry(entry)) + expect(&"invalid_mechanical_effect" in issues and &"effect_kind_mismatch" in issues and &"missing_mechanical_effect" in issues, "existing arbitrary hook safety contract retained for " + String(person_id)) + for raw_value: Variant in [2499, 30001, "12000", true, NAN, INF]: + anri = unique_entry(&"heroine_anri") + anri["mechanical_effects"]["assigned_floor_revenue_multiplier_bp"] = raw_value + expect(&"invalid_mechanical_effect" in codes(probe.inspect_entry(anri)), "existing Anri bounds and type rejection retained") + for raw_value: Variant in [-1, 101, "8", true, NAN, INF]: + var rai: Dictionary = unique_entry(&"rival_rai") + rai["mechanical_effects"]["base_shield_attack"] = raw_value + expect(&"invalid_mechanical_effect" in codes(probe.inspect_entry(rai)), "existing Rai bounds and type rejection retained") + +func pair_boundary() -> void: + var probe := ExplicitWorkerPolicyProbe.new() + var anri: Dictionary = unique_entry(&"heroine_anri") + var rai: Dictionary = unique_entry(&"rival_rai") + expect(int(probe.inspect_links([anri, rai, worker_entry()])[&"error_count"]) == 0, "existing explicit pair and a declared unpaired worker coexist at link boundary") + for person_id: StringName in [&"heroine_anri", &"rival_rai"]: + var paired_entry: Dictionary = unique_entry(person_id) + for raw_counterpart: Variant in [null, "", String(person_id), 7, true]: + paired_entry = unique_entry(person_id) + paired_entry["counterpart_id"] = raw_counterpart + expect(&"invalid_counterpart" in codes(probe.inspect_entry(paired_entry)), "paired person retains invalid counterpart rejection: " + String(person_id)) + paired_entry = unique_entry(person_id) + paired_entry.erase("counterpart_id") + expect(&"invalid_counterpart" in codes(probe.inspect_entry(paired_entry)), "paired person cannot omit explicit partner: " + String(person_id)) + anri = unique_entry(&"heroine_anri") + anri["counterpart_id"] = "missing_actor" + expect(&"missing_counterpart" in codes(probe.inspect_links([anri, rai])), "legacy orphan-reference error remains observable") + anri = unique_entry(&"heroine_anri") + anri["counterpart_id"] = String(FIXTURE_ID) + var issues: Array[StringName] = codes(probe.inspect_links([anri, rai, worker_entry()])) + expect(&"counterpart_policy_mismatch" in issues, "Anri cannot redirect to another declared worker") + expect(&"counterpart_not_reciprocal" in issues and &"counterpart_kind_conflict" in issues, "redirected relation still fails reciprocity and kind boundaries") + issues = codes(probe.inspect_links([worker_entry()], {FIXTURE_ID: {}})) + expect(&"runtime_actor_id_collision" in issues, "unpaired workers still participate in Runtime actor collision validation") + +func common_safety_boundary() -> void: + var probe := ExplicitWorkerPolicyProbe.new() + for raw_verified: Variant in [false, "true", 1, null]: + var entry: Dictionary = worker_entry() + entry["adult_verified"] = raw_verified + expect(&"adult_verification_required" in codes(probe.inspect_entry(entry)), "declared policy cannot bypass explicit adult bool") + for raw_age: Variant in [17, 10001, "25", true, NAN, INF]: + var entry: Dictionary = worker_entry() + entry["age"] = raw_age + expect(&"adult_age_required" in codes(probe.inspect_entry(entry)), "declared policy cannot bypass existing age type/bounds") + for trait_id: String in ["unique", "unsellable"]: + var entry: Dictionary = worker_entry() + entry["traits"].erase(trait_id) + expect(&"unique_safety_traits_required" in codes(probe.inspect_entry(entry)), "declared worker still requires " + trait_id) + var entry: Dictionary = worker_entry() + entry["role_id"] = "maid" + expect(&"unknown_role" in codes(probe.inspect_entry(entry)), "policy cannot invent an unregistered role") + entry = worker_entry() + entry["skills"]["operation"] = 101 + expect(&"map_value_out_of_range" in codes(probe.inspect_entry(entry)), "existing skill ceiling remains enforced") + entry = worker_entry() + entry["skills"].erase("operation") + expect(&"missing_map_key" in codes(probe.inspect_entry(entry)), "all canonical skills still required") + entry = worker_entry() + entry["mechanical_effects"][42] = 3 + expect(&"canonical_key_collision" in codes(probe.inspect_entry(entry)), "non-text effect key cannot be coerced into a trusted effect ID") + entry = worker_entry() + entry["preferred_shift"] = "invalid_shift" + expect(&"unknown_shift" in codes(probe.inspect_entry(entry)), "known policy still requires registered shift") + entry = worker_entry() + entry["display_name"] = "" + expect(&"missing_display_name" in codes(probe.inspect_entry(entry)), "known policy still requires safe visible name") + +func roster_atomicity_and_recovery() -> void: + var catalog := ExplicitWorkerPolicyProbe.new() + var baseline: Dictionary = documents() + var report: Dictionary = catalog.rebuild_from_documents(baseline) + expect(bool(report[&"valid"]), "fixture policy override does not alter current full baseline") + var generation: int = catalog.get_manifest_generation() + var changed: Dictionary = documents() + changed[&"unique_characters"]["entries"].append(worker_entry()) + report = catalog.rebuild_from_documents(changed) + var issues: Array[StringName] = codes(report) + expect(not bool(report[&"valid"]) and &"mvp_count_mismatch" in issues and &"mvp_id_set_mismatch" in issues, "even explicitly declared third actor cannot enter the unchanged full roster") + expect(catalog.get_unique_character_ids().is_empty() and catalog.get_staff_candidate_ids().is_empty() and catalog.build_all_guest_definitions().is_empty(), "failed roster rebuild exposes no partial content") + expect(catalog.get_recruitable_staff_ids().is_empty() and catalog.staff_hire_payload(&"heroine_anri").is_empty(), "failed catalog cannot generate recruitment payloads") + expect(catalog.get_manifest_generation() == generation + 1, "failed rebuild invalidates transient generation exactly once") + report = catalog.rebuild_from_documents(baseline) + expect(bool(report[&"valid"]) and catalog.get_unique_character_ids() == [&"heroine_anri", &"rival_rai"], "exact baseline recovers without retaining the worker") + expect(catalog.get_manifest_generation() == generation + 2, "recovery invalidates generation exactly once") + changed = documents() + changed[&"unique_characters"]["entries"][0]["id"] = "heroine_unknown" + report = catalog.rebuild_from_documents(changed) + expect(not bool(report[&"valid"]) and &"missing_unique_policy" in codes(report), "same-count unknown identity is rejected before normal exposure") + expect(catalog.get_unique_character_ids().is_empty() and catalog.get_recruitable_staff_ids().is_empty(), "unknown person leaves no accepted or recruitable partial content") + changed = documents() + changed[&"unique_characters"]["entries"][0]["counterpart_id"] = "missing_actor" + var first: Dictionary = catalog.rebuild_from_documents(changed) + var second: Dictionary = catalog.rebuild_from_documents(changed) + expect(first[&"errors"] == second[&"errors"] and &"missing_counterpart" in codes(second), "original deterministic orphan error contract retained") diff --git a/tests/mvp/test_unique_character_validation_policy.gd.uid b/tests/mvp/test_unique_character_validation_policy.gd.uid new file mode 100644 index 0000000..3a7242b --- /dev/null +++ b/tests/mvp/test_unique_character_validation_policy.gd.uid @@ -0,0 +1 @@ +uid://befg4kqgqt1b2 diff --git a/tests/mvp/test_upkeep_construction_boundaries.gd b/tests/mvp/test_upkeep_construction_boundaries.gd new file mode 100644 index 0000000..636b997 --- /dev/null +++ b/tests/mvp/test_upkeep_construction_boundaries.gd @@ -0,0 +1,46 @@ +extends SceneTree +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 clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 90416)[&"valid"], "initialize") + clock.set_paused(true) + var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor") + var old_rate := definition.maintenance_gold + definition.maintenance_gold = 1440 + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}).is_success(), "public construction starts") + var facility: Dictionary = {} + for minute in 100: + clock.advance_minutes(1) + var instances: Array = runtime.construction_service.get_facilities() + if not instances.is_empty(): + facility = instances[0] + break + expect(runtime.facility_upkeep.get_report()[&"pending_facility_units"].is_empty(), "construction time is not billable") + expect(not facility.is_empty(), "construction completes") + if not facility.is_empty(): + expect(runtime.facility_upkeep.get_report()[&"pending_facility_units"].is_empty(), "completion instant adds no earlier expense") + clock.advance_minutes(3) + expect(runtime.facility_upkeep.get_report()[&"pending_facility_units"].get(String(facility.instance_id), 0) == 4320, "owned infrastructure accrues exactly three minutes") + var mid: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(mid) and runtime.create_snapshot() == mid, "real construction ownership saves exactly") + expect(runtime.submit_command(&"demolish_facility", {&"instance_id": facility.instance_id}).is_success(), "public demolition") + clock.advance_minutes(3) + expect(runtime.facility_upkeep.get_report()[&"pending_facility_units"].get(String(facility.instance_id), 0) == 4320, "demolition stops further accrual") + definition.maintenance_gold = old_rate + runtime.free() + clock.free() + state.free() + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/mvp/test_upkeep_construction_boundaries.gd.uid b/tests/mvp/test_upkeep_construction_boundaries.gd.uid new file mode 100644 index 0000000..d2f251a --- /dev/null +++ b/tests/mvp/test_upkeep_construction_boundaries.gd.uid @@ -0,0 +1 @@ +uid://dgkvfdou0w0y7 diff --git a/tests/mvp/test_upkeep_legacy_midnight.gd b/tests/mvp/test_upkeep_legacy_midnight.gd new file mode 100644 index 0000000..cf00509 --- /dev/null +++ b/tests/mvp/test_upkeep_legacy_midnight.gd @@ -0,0 +1,79 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: + call_deferred("_run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 424242)[&"valid"], "initialize") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable") + var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor") + var old_rate := definition.maintenance_gold + definition.maintenance_gold = 1440 + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}).is_success(), "build completed asset") + runtime.operations_coordinator._legacy_calendar_last_day = 1 + clock.advance_minutes(1079) + var legacy: Dictionary = runtime.create_snapshot() + var data: Dictionary = legacy[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 9 + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) + data[&"operations"][&"coordinator"][&"schema_version"] = 3 + data[&"operations"][&"coordinator"].erase(&"legacy_calendar_last_day") + data[&"operations"][&"coordinator"].erase(&"legacy_history_last_day") + expect(runtime.restore_snapshot(legacy), "genuine v9/v3 calendar migrates before midnight") + var ledger_before: Dictionary = runtime.economy_service.create_snapshot() + runtime.set_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + return {&"ok": phase != runtime.OPERATIONS_TEST_AFTER_FIRST_MUTATION_PHASE}) + clock.advance_minutes(1) + expect(runtime.get_operations_report()[&"runtime_failure_latched"] and clock.absolute_minute == 1440, "midnight failure pauses before legacy financial close") + expect(runtime.economy_service.create_snapshot() == ledger_before and runtime.upkeep_settlement.get_settled_period_count() == 0, "midnight rollback restores cash ledger and receipt atomically") + var failed: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(failed), "failed midnight checkpoint restores") + runtime.set_operations_test_boundary_hook(&"MIDNIGHT_UPKEEP", Callable()) + expect(runtime.acknowledge_operations_pause()[&"ok"], "same midnight retries through existing acknowledgement") + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1 and runtime.upkeep_settlement.get_settled_period_count() == 1, "only post-migration minute settles at midnight") + expect(runtime.operations_coordinator.get_day_summaries().is_empty(), "legacy payroll and financial day are not rewritten") + var midnight: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(midnight) and runtime.create_snapshot() == midnight, "midnight interim payment roundtrips exactly") + clock.advance_minutes(360) + var summary: Dictionary = runtime.operations_coordinator.get_day_summary(1) + expect(not summary.is_empty() and summary[&"closed_at"] == 1800, "legacy financial close remains at 06:00") + if not summary.is_empty(): + expect(summary[&"category_totals"][&"maintenance"] == 1 and runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1, "06:00 reports midnight expense without a second charge") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "06:00 history and earlier midnight receipt reconcile on load") + # A later failure must retain the already committed midnight payment exactly once. + expect(runtime.restore_snapshot(legacy), "restore before later-boundary failure") + runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", func(_payload: Dictionary, phase: StringName, _token: Variant) -> Dictionary: + return {&"ok": phase != runtime.OPERATIONS_TEST_PREPARE_PHASE}) + clock.advance_minutes(1) + expect(runtime.get_operations_report()[&"runtime_failure_latched"] and runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1, "later failure preserves committed midnight payment") + var after_payment_failure: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(after_payment_failure), "save distinguishes committed payment from pending later boundary") + runtime.set_operations_test_boundary_hook(&"ARRIVAL_PLAN", Callable()) + expect(runtime.acknowledge_operations_pause()[&"ok"] and runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 1 and runtime.upkeep_settlement.get_settled_period_count() == 1, "retry never duplicates earlier midnight payment") + # New interim debt blocks spending immediately, without rewriting old summaries. + expect(runtime.restore_snapshot(legacy), "restore legacy insufficient-cash branch") + var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH") + expect(runtime.economy_service.transfer(&"PLAYER_CASH", &"CONSTRUCTION_EXPENSE", cash, 1439, &"CONSTRUCTION").is_success(), "deplete cash") + clock.advance_minutes(1) + expect(runtime.get_operations_report()[&"arrears_total"] == 1 and runtime.operations_coordinator.get_arrears_total() == 0, "live debt is visible while closed financial history stays unchanged") + clock.set_paused(true) + var blocked: CommandResult = runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(5, 1, 0)}) + expect(not blocked.is_success() and blocked.details.get(&"reason") == &"UNPAID_EXPENSES", "interim upkeep debt blocks new investment at midnight") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "interim debt saves and restores without a synthetic daily summary") + definition.maintenance_gold = old_rate + runtime.free() + clock.free() + state.free() + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/mvp/test_upkeep_legacy_midnight.gd.uid b/tests/mvp/test_upkeep_legacy_midnight.gd.uid new file mode 100644 index 0000000..7570df9 --- /dev/null +++ b/tests/mvp/test_upkeep_legacy_midnight.gd.uid @@ -0,0 +1 @@ +uid://iclut17j0oeq diff --git a/tests/mvp/test_upkeep_loss_streak.gd b/tests/mvp/test_upkeep_loss_streak.gd new file mode 100644 index 0000000..831d51f --- /dev/null +++ b/tests/mvp/test_upkeep_loss_streak.gd @@ -0,0 +1,50 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: + call_deferred("_run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _run() -> void: + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1, 6, 0) + state.reset() + expect(runtime.initialize(clock, state, 424242)[&"valid"], "initialize") + clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable production operations") + var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor") + var old_rate := definition.maintenance_gold + definition.maintenance_gold = 1440 + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}).is_success(), "normal construction") + clock.advance_minutes(10) + expect(runtime.construction_service.get_facilities().size() == 1, "owned infrastructure completed") + for day in range(1, 3): + clock.advance_minutes(day * 1440 - clock.absolute_minute) + var summary: Dictionary = runtime.operations_coordinator.get_day_summary(day) + expect(not summary.is_empty(), "calendar day %d closes" % day) + if not summary.is_empty(): + var expense := 1070 if day == 1 else 1440 + expect(summary[&"category_totals"][&"maintenance"] == expense and summary[&"net_profit"] == -expense, "only ownership upkeep causes day %d loss" % day) + expect(summary[&"arrears_total"] == 0 and runtime.operations_coordinator.get_consecutive_insolvency_count() == day, "fully paid loss still advances consecutive losses") + print("UPKEEP_LOSS_PROGRESS day=", day) + clock.advance_minutes(1439) + var before: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(before), "restore one minute before third maintenance loss") + clock.advance_minutes(1) + expect(clock.absolute_minute == 4320 and clock.paused and runtime.operations_coordinator.is_terminal(), "third paid maintenance loss ends game at midnight") + expect(runtime.economy_service.get_balance(&"PLAYER_CASH") == 1030, "cash reconciles construction and three prorated bills") + var final: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(final) and runtime.create_snapshot() == final, "terminal save preserves expenses and outcome exactly") + clock.advance_minutes(60) + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 3950 and runtime.upkeep_settlement.get_settled_period_count() == 3, "terminal clock never collects a fourth bill") + definition.maintenance_gold = old_rate + runtime.free() + clock.free() + state.free() + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/mvp/test_upkeep_loss_streak.gd.uid b/tests/mvp/test_upkeep_loss_streak.gd.uid new file mode 100644 index 0000000..f3a8d22 --- /dev/null +++ b/tests/mvp/test_upkeep_loss_streak.gd.uid @@ -0,0 +1 @@ +uid://cbkk7j2b5t15t diff --git a/tests/performance/fixtures/linear_path_reference.gd b/tests/performance/fixtures/linear_path_reference.gd new file mode 100644 index 0000000..9fb1114 --- /dev/null +++ b/tests/performance/fixtures/linear_path_reference.gd @@ -0,0 +1,65 @@ +extends PathService + +# Pre-optimization algorithm, retained as an exact route-order oracle. +func _run_a_star( + start: Vector3i, + goal: Vector3i, + allowed_layers: Array[int], + allowed_access_ids: Array[StringName] +) -> Array[Vector3i]: + if start == goal: + return [start] + + var open_set: Dictionary = {start: true} + var closed_set: Dictionary = {} + var came_from: Dictionary = {} + var g_score: Dictionary = {start: 0} + + while not open_set.is_empty(): + var current := _select_best_open_node(open_set, g_score, goal) + if current == goal: + return _reconstruct_path(came_from, current) + + open_set.erase(current) + closed_set[current] = true + var edges: Array = _adjacency.get(current, []) + for edge_value: Variant in edges: + var edge: Dictionary = edge_value + var neighbor: Vector3i = edge[&"to"] + if neighbor.z not in allowed_layers \ + or _cell_is_temporarily_blocked(neighbor) \ + or not _edge_meets_vertical_policy(current, edge) \ + or not _edge_is_accessible(edge, allowed_access_ids): + continue + var tentative_cost: int = int(g_score[current]) + _edge_total_cost(edge) + var known_cost: int = int(g_score.get(neighbor, _INFINITE_COST)) + var better_parent := false + if tentative_cost == known_cost and came_from.has(neighbor): + better_parent = _coordinate_less(current, came_from[neighbor]) + if tentative_cost > known_cost or (tentative_cost == known_cost and not better_parent): + continue + + came_from[neighbor] = current + g_score[neighbor] = tentative_cost + if closed_set.has(neighbor): + closed_set.erase(neighbor) + open_set[neighbor] = true + + return [] + + +func _select_best_open_node(open_set: Dictionary, g_score: Dictionary, goal: Vector3i) -> Vector3i: + var best := Vector3i.ZERO + var has_best := false + var best_score := _INFINITE_COST + for candidate_value: Variant in open_set: + var candidate: Vector3i = candidate_value + var score: int = int(g_score[candidate]) + _heuristic(candidate, goal) + if not has_best or score < best_score: + best = candidate + best_score = score + has_best = true + elif score == best_score and _coordinate_less(candidate, best): + best = candidate + return best + diff --git a/tests/performance/fixtures/linear_path_reference.gd.uid b/tests/performance/fixtures/linear_path_reference.gd.uid new file mode 100644 index 0000000..83a6f26 --- /dev/null +++ b/tests/performance/fixtures/linear_path_reference.gd.uid @@ -0,0 +1 @@ +uid://d0fbg8t52lexf diff --git a/tests/performance/fixtures/uncached_preview_reference.gd b/tests/performance/fixtures/uncached_preview_reference.gd new file mode 100644 index 0000000..9975370 --- /dev/null +++ b/tests/performance/fixtures/uncached_preview_reference.gd @@ -0,0 +1,56 @@ +extends WorldPreview + +# Uncached geometry and reflective service lookup oracle. +func get_map_rect() -> Rect2: + var bounds := _world_bounds() + if bounds.is_empty(): + return Rect2() + var dimensions := _cell_dimensions() + var map_size := Vector2( + int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, + int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1 + ) * dimensions + return Rect2(((size - map_size) * 0.5 + pan_offset).floor(), map_size) + + +func _cell_dimensions() -> Vector2: + var bounds := _world_bounds() + if bounds.is_empty(): + return Vector2(32, 48) + var columns := maxi(int(bounds[&"max_x"]) - int(bounds[&"min_x"]) + 1, 1) + var rows := maxi(int(bounds[&"max_y"]) - int(bounds[&"min_y"]) + 1, 1) + var available := (size - MAP_PADDING * 2.0).max(Vector2.ONE) + var fit := minf(available.x / (columns * 16.0), available.y / (rows * 24.0)) + # Native 16px units use integer magnification. Large worlds can be viewed + # at overview scale, then enlarged with the same invertible camera mapping. + var pixel := maxf(floorf(fit), 1.0) if fit >= 1.0 else maxf(fit, 0.25) + return (Vector2(16, 24) * pixel * zoom_level).round().max(Vector2(4, 6)) + + +func _service(property_name: StringName) -> Variant: + if _runtime == null or not _runtime is Object: + return null + for property: Dictionary in _runtime.get_property_list(): + if StringName(property.get(&"name", &"")) == property_name: + return _runtime.get(property_name) + return null + + +func _world_bounds() -> Dictionary: + var world: Variant = _service(&"world_service") + if world == null or not world.has_method(&"get_bounds"): + return {} + var result: Variant = world.call(&"get_bounds") + if not result is Dictionary: + return {} + for key: StringName in [&"min_x", &"max_x", &"min_y", &"max_y"]: + if not result.has(key) and not result.has(String(key)): + return {} + return { + &"min_x": int(result.get(&"min_x", result.get("min_x", 0))), + &"max_x": int(result.get(&"max_x", result.get("max_x", -1))), + &"min_y": int(result.get(&"min_y", result.get("min_y", 0))), + &"max_y": int(result.get(&"max_y", result.get("max_y", -1))), + } + + diff --git a/tests/performance/fixtures/uncached_preview_reference.gd.uid b/tests/performance/fixtures/uncached_preview_reference.gd.uid new file mode 100644 index 0000000..f37706b --- /dev/null +++ b/tests/performance/fixtures/uncached_preview_reference.gd.uid @@ -0,0 +1 @@ +uid://df7jpvnx5bhb diff --git a/tests/performance/perf_mvp_001_harness.gd b/tests/performance/perf_mvp_001_harness.gd index 5ec24b6..5696d62 100644 --- a/tests/performance/perf_mvp_001_harness.gd +++ b/tests/performance/perf_mvp_001_harness.gd @@ -28,7 +28,7 @@ const FULL_TARGET := { "unfinished_logistics": 200, "active_events": 50, "minutes": 1_440, - "speed": 4, + "speed": 3, } const SMOKE_TARGET := { @@ -40,7 +40,7 @@ const SMOKE_TARGET := { "unfinished_logistics": 20, "active_events": 5, "minutes": 1_440, - "speed": 4, + "speed": 3, } const STALE_BACKLOG_LIMIT_MINUTES := 120 @@ -144,14 +144,14 @@ func run(tier: StringName = &"smoke") -> Dictionary: var simulation_duration_us := Time.get_ticks_usec() - simulation_started # Feed raw path samples after timing the simulation. PerformanceBudget returns # a full percentile report per record, so doing this inside each path callback - # would make the profiler itself dominate the x4 measurement. + # would make the profiler itself dominate the x3 measurement. for duration_us: int in _path_duration_samples: profiler.record_sample(&"path_query", duration_us) report["simulation"] = { "requested_minutes": int(target["minutes"]), "completed_minutes": completed_minutes, - "x4_frames": int(ceil(float(completed_minutes) / 4.0)), + "x3_frames": int(ceil(float(completed_minutes) / 3.0)), "duration_ms": simulation_duration_us / 1_000.0, "game_minutes_per_real_second": ( float(completed_minutes) * 1_000_000.0 / float(maxi(simulation_duration_us, 1)) @@ -816,10 +816,10 @@ func _run_validations( _add_validation(validations, &"path_queries_reachable", _path_query_failures == 0, {"failures": _path_query_failures}) _validate_snapshot_round_trips(validations, services, cells) - var x4_report: Dictionary = profiler.get_report(&"simulation_x4") + var x3_report: Dictionary = profiler.get_report(&"simulation_x3") var path_report: Dictionary = profiler.get_report(&"path_query") var stall_report: Dictionary = profiler.get_report(&"main_thread_stall") - _add_validation(validations, &"simulation_x4_budget", not bool(x4_report[&"budget_exceeded"]), _primitive_dictionary(x4_report)) + _add_validation(validations, &"simulation_x3_budget", not bool(x3_report[&"budget_exceeded"]), _primitive_dictionary(x3_report)) _add_validation(validations, &"path_query_budget", not bool(path_report[&"budget_exceeded"]), _primitive_dictionary(path_report)) _add_validation(validations, &"main_thread_stall_budget", not bool(stall_report[&"budget_exceeded"]), _primitive_dictionary(stall_report)) @@ -906,7 +906,7 @@ func _count_terminal_logistics() -> int: func _performance_report(profiler: Variant) -> Dictionary: return { - "simulation_x4": _primitive_dictionary(profiler.get_report(&"simulation_x4")), + "simulation_x3": _primitive_dictionary(profiler.get_report(&"simulation_x3")), "path_query": _primitive_dictionary(profiler.get_report(&"path_query")), "main_thread_stall": _primitive_dictionary(profiler.get_report(&"main_thread_stall")), "domain_timings_per_game_minute": { diff --git a/tests/performance/profile_production_scene.gd b/tests/performance/profile_production_scene.gd new file mode 100644 index 0000000..ce2a4bd --- /dev/null +++ b/tests/performance/profile_production_scene.gd @@ -0,0 +1,137 @@ +extends SceneTree +## Diagnostic measurement, not a replacement for full-load acceptance. +var phase_samples: Dictionary = {} +var phase_start := 0 +var current_phase := -1 + +func finish_phase() -> void: + if current_phase >= 0: + if not phase_samples.has(current_phase): phase_samples[current_phase] = [] + phase_samples[current_phase].append(Time.get_ticks_usec()-phase_start) + current_phase = -1 + +func begin_phase(phase: int, _minute: int) -> void: + finish_phase() + current_phase = phase + phase_start = Time.get_ticks_usec() + +const Demo := preload("res://scripts/ui/pixel_tower_demo.gd") +func _initialize() -> void: call_deferred("run") +func stats(samples: Array[int]) -> Dictionary: + var sorted := samples.duplicate() + sorted.sort() + var total := 0 + for value: int in sorted: total += value + return {"samples":sorted.size(),"average_us":float(total)/sorted.size(),"p95_us":sorted[ceili(sorted.size()*0.95)-1],"max_us":sorted.back()} +func run() -> void: + var runtime: Variant = 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,424242);clock.set_paused(true) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + var demo := Demo.new() + if not await demo.prepare(runtime,clock,self): quit(1);return + var main: Control = load("res://scenes/main/main.tscn").instantiate() + root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame;await process_frame + clock.tick_phase_started.connect(begin_phase) + clock.tick_completed.connect(func(_minute: int): finish_phase()) + var checkpoint: Dictionary = runtime.create_snapshot() + var report := {"fixture":demo.describe(),"headless":DisplayServer.get_name()=="headless","limits":{"x1_average_us":4000,"x3_p95_us":12000},"measurements":{}} + for speed: int in [1,3]: + if not runtime.restore_snapshot(checkpoint):quit(2);return + phase_samples.clear() + var times: Array[int] = [] + for frame in 40: + var minute: int = clock.absolute_minute + var start := Time.get_ticks_usec() + clock.advance_minutes(speed) + # Include deferred dashboard cost in the same sample, rather than + # hiding work outside the synchronous timing interval. + if main.has_method("_flush_dashboard_refresh"): + main.call("_flush_dashboard_refresh") + times.append(Time.get_ticks_usec()-start) + if clock.absolute_minute != minute+speed:quit(3);return + await process_frame + report["measurements"]["x%d" % speed] = stats(times) + var phases := {} + for phase: int in phase_samples: + var values: Array[int] = [] + values.assign(phase_samples[phase]) + phases[SimulationKernel.TickPhase.keys()[phase]] = stats(values) + report["measurements"]["x%d" % speed]["phases"] = phases + # Distinguish full update cost from serialization and digest micro-costs. + var snapshots: Array[int] = [] + var digests: Array[int] = [] + for attempt in 20: + var start := Time.get_ticks_usec() + var snapshot: Dictionary = runtime.create_snapshot() + snapshots.append(Time.get_ticks_usec()-start) + start=Time.get_ticks_usec() + SimulationDigest.compute(snapshot) + digests.append(Time.get_ticks_usec()-start) + var section_costs := {} + var final_snapshot: Dictionary = runtime.create_snapshot() + for section: Variant in final_snapshot[&"systems"][&"runtime"]: + var values: Array[int] = [] + for attempt in 5: + var start := Time.get_ticks_usec() + SimulationDigest.compute(final_snapshot[&"systems"][&"runtime"][section]) + values.append(Time.get_ticks_usec()-start) + section_costs[String(section)] = stats(values) + report["runtime_section_digests"] = section_costs + var ui_costs := {} + for method: StringName in [&"_refresh_dashboard", &"_refresh_staff_candidates", &"_refresh_selected_entity", &"_operations_dashboard_text"]: + var values: Array[int] = [] + for attempt in 20: + var start := Time.get_ticks_usec() + main.call(method) + values.append(Time.get_ticks_usec()-start) + ui_costs[String(method)] = stats(values) + if main.opening_guide != null: + for method: StringName in [&"refresh", &"_read_progress", &"_first_lodging_sale"]: + var values: Array[int] = [] + for attempt in 20: + var start := Time.get_ticks_usec() + main.opening_guide.call(method) + values.append(Time.get_ticks_usec()-start) + ui_costs["opening_guide."+String(method)] = stats(values) + report["ui_refresh_costs"] = ui_costs + var validation_costs := {} + for service_name: StringName in [&"spatial_subslot_service", &"route_subslot_path_adapter", &"facility_infrastructure_projector", &"excavation_service"]: + var values: Array[int] = [] + for attempt in 20: + var start := Time.get_ticks_usec() + var result: Dictionary = runtime.get(service_name).validate_invariants() + values.append(Time.get_ticks_usec()-start) + if not result.get(&"ok", false):quit(5);return + validation_costs[String(service_name)] = stats(values) + report["infrastructure_validation_costs"] = validation_costs + var snapshot_costs := {} + for service_name: StringName in [&"domain_events", &"command_queue", &"world_service", &"path_service", &"construction_service", &"facility_service", &"guest_service", &"staff_service", &"operations_coordinator", &"facility_infrastructure_projector", &"route_subslot_path_adapter"]: + var values: Array[int] = [] + for attempt in 20: + var start := Time.get_ticks_usec() + runtime.get(service_name).create_snapshot() + values.append(Time.get_ticks_usec()-start) + snapshot_costs[String(service_name)] = stats(values) + var copies: Array[int] = [] + for attempt in 20: + var start := Time.get_ticks_usec() + final_snapshot.duplicate(true) + copies.append(Time.get_ticks_usec()-start) + snapshot_costs["whole_snapshot_deep_copy"] = stats(copies) + report["snapshot_component_costs"] = snapshot_costs + report["snapshot"] = stats(snapshots) + report["digest"] = stats(digests) + report["within_budget"] = report["measurements"]["x1"]["average_us"] <= 4000 and report["measurements"]["x3"]["p95_us"] <= 12000 + report["limitations"] = "Small disposable production scenario; auto-save disabled; synchronous tick includes UI subscribers, excludes frame rendering and GPU. Not full-load or Windows acceptance." + var path := OS.get_environment("THE_TREE_RUNTIME_PROFILE_PATH") + if path.is_empty():path="res://artifacts/facility-dimensions-2026-09-29/production-scene-profile.json" + var file := FileAccess.open(path,FileAccess.WRITE) + if file == null:quit(4);return + file.store_string(JSON.stringify(report," ")+"\n");file.close() + print("PRODUCTION_PROFILE ",JSON.stringify(report["measurements"])," snapshot=",report["snapshot"]," digest=",report["digest"]," within_budget=",report["within_budget"]) + main.queue_free();await process_frame + quit(0) diff --git a/tests/performance/profile_production_scene.gd.uid b/tests/performance/profile_production_scene.gd.uid new file mode 100644 index 0000000..4c78848 --- /dev/null +++ b/tests/performance/profile_production_scene.gd.uid @@ -0,0 +1 @@ +uid://cd34rvbc7t447 diff --git a/tests/performance/test_digest_performance.gd b/tests/performance/test_digest_performance.gd index c2182d3..4063a94 100644 --- a/tests/performance/test_digest_performance.gd +++ b/tests/performance/test_digest_performance.gd @@ -13,6 +13,8 @@ func _initialize() -> void: func _run() -> void: print("Simulation digest: v1 byte compatibility and growing-history benchmark") + _test_text_chunk_boundaries() + _test_scalar_cache_boundaries() _test_value_types() _test_dictionary_layouts() _test_mutable_values() @@ -220,3 +222,33 @@ func _expect(condition: bool, label: String) -> void: else: _failures += 1 push_error(" FAIL: %s" % label) + + +func _test_scalar_cache_boundaries() -> void: + var values: Array = [] + for i in 700: + values.append(i-350) + values.append("entry_%d" % i) + values.append(StringName("entry_%d" % i)) + values.append("x".repeat(129)) + values.append(StringName("x".repeat(129))) + values.append("utf8_" + String.chr(0x1f333)) + values.append(values.duplicate()) + _expect_bytes(values, "bounded scalar cache saturation preserves exact canonical bytes") + _expect_bytes([1,"1",&"1",1,"1",&"1"], "scalar cache preserves int string and name type tags") + var previous := Digest.compute(values) + values[0] = -999999 + _expect_bytes(values, "new digest reads changed input after prior cache lifetime") + _expect(Digest.compute(values) != previous, "per-call scalar caches never retain previous input") + + +func _test_text_chunk_boundaries() -> void: + for count: int in [63,64,65,129]: + var strings: Array[String] = [] + var names: Array[StringName] = [] + for index in count: + var text := "field_%d:" % index + String.chr(0x1f333) + String.chr(0) + "end" + strings.append(text) + names.append(StringName(text)) + _expect_bytes(strings, "UTF8 and NUL string chunk boundary %d" % count) + _expect_bytes(names, "StringName chunk boundary %d" % count) diff --git a/tests/performance/test_facility_reachability.gd b/tests/performance/test_facility_reachability.gd new file mode 100644 index 0000000..7fc2d1b --- /dev/null +++ b/tests/performance/test_facility_reachability.gd @@ -0,0 +1,51 @@ +extends SceneTree +const Demo := preload("res://scripts/ui/pixel_tower_demo.gd") +var checks := 0 +var failures := 0 +var runtime: Variant +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func compare(label: String) -> void: + for facility: FacilityInstance in runtime.facility_service.get_instances(): + expect(runtime.is_facility_reachable(facility.instance_id) == bool(runtime.get_facility_access_report(facility.instance_id)[&"reachable"]), label + ": " + String(facility.instance_id)) +func run() -> void: + 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,424242); clock.set_paused(true) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + var demo := Demo.new() + expect(await demo.prepare(runtime,clock,self), "prepare actual multi-floor topology") + if failures: quit(failures); return + compare("connected topology") + expect(not runtime.is_facility_reachable(&"missing"), "missing facility is unreachable") + var checkpoint: Dictionary = runtime.create_snapshot() + var facility: FacilityInstance = runtime.facility_service.get_instances()[0] + facility.access_available = false + expect(not runtime.is_facility_reachable(facility.instance_id), "operating access override is honored") + expect(runtime.restore_snapshot(checkpoint), "restore access override") + # Block every graph cell, including all candidate public entrances. Do not + # leave a second entrance that can legitimately reach the same destination. + for cell: Vector3i in runtime.world_service.get_walkable_cells(): + if runtime.path_service.has_cell(cell): runtime.path_service.set_cell_temporary_block(cell,true) + compare("blocked topology") + for item: FacilityInstance in runtime.facility_service.get_instances(): + expect(not runtime.is_facility_reachable(item.instance_id), "blocked entrance cannot report reachable") + expect(runtime.restore_snapshot(checkpoint), "restore graph after temporary blocks") + compare("restored topology") + var measurements := {} + for method: StringName in [&"get_facility_access_report", &"is_facility_reachable"]: measurements[String(method)] = [] + for attempt in 100: + var methods: Array[StringName] = [&"get_facility_access_report", &"is_facility_reachable"] + if attempt % 2: methods.reverse() + for method: StringName in methods: + var start := Time.get_ticks_usec() + for item: FacilityInstance in runtime.facility_service.get_instances(): runtime.call(method,item.instance_id) + measurements[String(method)].append(Time.get_ticks_usec()-start) + var output := OS.get_environment("THE_TREE_REACHABILITY_PROFILE") + if not output.is_empty(): + var file := FileAccess.open(output,FileAccess.WRITE) + file.store_string(JSON.stringify(measurements," "));file.close() + print("Facility reachability: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/performance/test_facility_reachability.gd.uid b/tests/performance/test_facility_reachability.gd.uid new file mode 100644 index 0000000..6e1f213 --- /dev/null +++ b/tests/performance/test_facility_reachability.gd.uid @@ -0,0 +1 @@ +uid://b87cjkd3d5k37 diff --git a/tests/performance/test_lazy_digest.gd b/tests/performance/test_lazy_digest.gd new file mode 100644 index 0000000..c2f6cb2 --- /dev/null +++ b/tests/performance/test_lazy_digest.gd @@ -0,0 +1,39 @@ +extends SceneTree +var failures := 0 +var checks := 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 runtime: Variant = 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,42515);clock.set_paused(true) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + var baseline: Dictionary = runtime.create_snapshot() + var notices: Array[int] = [] + runtime.state_updated.connect(func(_snapshot: Dictionary):notices.append(clock.absolute_minute)) + clock.advance_one_minute() + expect(runtime.get("_latest_digest_dirty"),"no digest consumer leaves computation pending") + var snapshot: Dictionary=runtime.latest_snapshot.duplicate(true) + expect(runtime.latest_digest==SimulationDigest.compute(snapshot),"lazy digest matches exact published snapshot") + expect(not runtime.get("_latest_digest_dirty"),"repeated reads reuse current publication digest") + var prior: String=runtime.latest_digest + clock.advance_one_minute() + expect(runtime.get("_latest_digest_dirty") and runtime.latest_digest!=prior,"new publication invalidates old hash") + expect(notices.size()==2,"lightweight notification emitted per publication") + var lazy_end: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(baseline),"restore pre-run state") + var observed: Array[Dictionary]=[] + var observer := func(value: Dictionary,digest: String): observed.append({"snapshot":value.duplicate(true),"digest":digest}) + runtime.snapshot_published.connect(observer) + clock.advance_minutes(2) + expect(observed.size()==2,"legacy observer still receives every publication") + for row: Dictionary in observed: + expect(row["digest"]==SimulationDigest.compute(row["snapshot"]),"legacy observer receives exact eager hash") + expect(runtime.create_snapshot()==lazy_end,"lazy and observed runs preserve identical simulation state") + runtime.snapshot_published.disconnect(observer) + clock.advance_one_minute() + expect(runtime.get("_latest_digest_dirty"),"disconnecting legacy observer restores lazy computation") + print("Lazy digest: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/performance/test_lazy_digest.gd.uid b/tests/performance/test_lazy_digest.gd.uid new file mode 100644 index 0000000..f576acd --- /dev/null +++ b/tests/performance/test_lazy_digest.gd.uid @@ -0,0 +1 @@ +uid://cyhrnv34kel3m diff --git a/tests/performance/test_mvp_manifest_cache.gd b/tests/performance/test_mvp_manifest_cache.gd new file mode 100644 index 0000000..bc14771 --- /dev/null +++ b/tests/performance/test_mvp_manifest_cache.gd @@ -0,0 +1,222 @@ +extends SceneTree +## Regression for catalog manifest ownership, invalidation, legacy saves and restore. + +class CountingCatalog: + extends MvpContentCatalog + var factory_calls := 0 + func build_all_guest_definitions() -> Array[GuestDefinition]: + factory_calls += 1 + return super.build_all_guest_definitions() + +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("MANIFEST: " + label) + +func manifest(catalog: MvpContentCatalog) -> Dictionary: + return runtime.call("_create_mvp_content_manifest", catalog) + +# Independent original construction, including the existing full factory. +# No ID list, original definition field, or class/species value is omitted. +func original_manifest(catalog: MvpContentCatalog) -> Dictionary: + var guest_ids: Array[String] = [] + for definition: GuestDefinition in catalog.build_all_guest_definitions(): + guest_ids.append(String(definition.guest_type_id)) + return { + &"manifest_version": runtime.MVP_CONTENT_MANIFEST_VERSION, + &"schema_version": MvpContentCatalog.SCHEMA_VERSION, + &"guest_class_ids": strings(catalog.get_guest_class_ids()), + &"species_ids": strings(catalog.get_species_ids()), + &"staff_candidate_ids": strings(catalog.get_staff_candidate_ids()), + &"unique_character_ids": strings(catalog.get_unique_character_ids()), + &"guest_definition_ids": guest_ids, + } + +func strings(values: Array[StringName]) -> Array[String]: + var result: Array[String] = [] + for value: StringName in values: + result.append(String(value)) + return result + +func documents() -> Dictionary: + var result: Dictionary = {} + for content_type: StringName in MvpContentCatalog.DEFAULT_PATHS: + var file := FileAccess.open(MvpContentCatalog.DEFAULT_PATHS[content_type], FileAccess.READ) + if file == null: + return {} + result[content_type] = JSON.parse_string(file.get_as_text()) + file.close() + return result + +func same(left: Variant, right: Variant) -> bool: + return runtime.call("_strict_variants_equal", left, right) + +func check_manifest_shape(expected: Dictionary, actual: Dictionary, label: String) -> void: + expect(actual.keys() == expected.keys(), label + " insertion order of manifest keys") + for key: Variant in actual: + expect(typeof(key) == TYPE_STRING_NAME, label + " key Variant type " + String(key)) + if expected[key] is Array: + var values: Array = actual[key] + expect(values.get_typed_builtin() == TYPE_STRING, label + " declared typed string Array " + String(key)) + var bytes := SimulationDigest.canonical_bytes(actual) + expect(not bytes.is_empty() and bytes == SimulationDigest.canonical_bytes(expected), label + " nonempty canonical bytes") + var digest := SimulationDigest.compute(actual) + expect(not digest.is_empty() and digest == SimulationDigest.compute(expected), label + " nonempty exact digest") + +func complete_live_guard() -> Dictionary: + return { + &"snapshot": runtime.create_snapshot(), + &"clock_paused": clock.paused, + &"save_policy": runtime.get_save_policy(), + &"pending_checkpoint_count": runtime.get_pending_checkpoint_count(), + &"last_daily_checkpoint_day": runtime.get_last_daily_checkpoint_day(), + &"content_filter": runtime.event_service.get_content_filter_profile(), + } + +func run() -> void: + clock = load("res://autoload/game_clock.gd").new() + state = load("res://autoload/game_state.gd").new() + runtime = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1,6,0) + state.reset() + expect(runtime.initialize(clock,state,840_101).get(&"valid", false), "canonical initialization") + clock.set_paused(true) + var cached: bool = runtime.has_method("_clear_mvp_content_manifest_cache") + expect(cached, "production manifest cache is installed") + var catalog := CountingCatalog.new() + expect(catalog.rebuild().get(&"valid", false), "counting fixture uses unchanged real catalog") + runtime.mvp_content_catalog = catalog + var expected := original_manifest(catalog) + catalog.factory_calls = 0 + var first := manifest(catalog) + expect(same(expected, first), "first read preserves all values and generated ID ordering") + check_manifest_shape(expected, first, "first fill") + first[&"manifest_version"] = -1 + first[&"staff_candidate_ids"].clear() + first[&"guest_definition_ids"][0] = "poisoned" + var second := manifest(catalog) + expect(same(expected, second), "first returned manifest never aliases live/private data") + check_manifest_shape(expected, second, "second read") + expect(catalog.factory_calls == (1 if cached else 2), "candidate fills once; reference rebuilds each read") + second[&"species_ids"].clear() + expect(same(expected, manifest(catalog)), "cache hit has independently owned nested arrays") + + var entry := catalog.get_entry(&"guest_classes", &"cleric") + entry[&"need_rates"][&"hunger"] = 99 + var ids := catalog.get_species_ids() + ids.clear() + var generated := catalog.build_guest_definition(&"cleric", &"human") + generated.guest_type_id = &"poisoned_returned_definition" + expect(same(expected, manifest(catalog)), "entry/ID/factory getters cannot mutate cached membership") + + var prior_generation: int = int(catalog.call("get_manifest_generation")) if cached else -1 + var builds_before := catalog.factory_calls + var docs := documents() + docs[&"guest_classes"]["entries"][0]["base_patience"] = 61 + expect(catalog.rebuild_from_documents(docs).get(&"valid", false), "same IDs with changed real fields rebuild validly") + if cached: + expect(int(catalog.call("get_manifest_generation")) > prior_generation, "rebuild advances generation even when IDs do not change") + expect(same(expected, manifest(catalog)) and catalog.factory_calls == builds_before+1, "changed definitions force original factory despite unchanged membership") + docs[&"guest_classes"]["entries"][0]["unknown_definition_field"] = 1 + expect(not catalog.rebuild_from_documents(docs).get(&"valid", true), "unknown original fields remain fail-closed") + var empty_expected := original_manifest(catalog) + expect(same(empty_expected, manifest(catalog)), "invalid rebuild cannot expose earlier valid manifest") + expect(catalog.rebuild().get(&"valid", false), "valid recovery uses normal file rebuild") + expect(same(expected, manifest(catalog)), "valid recovery invalidates empty generation") + expect(not catalog.rebuild({&"unknown_content_type":"res://missing.json"}).get(&"valid", true), "invalid path set is rejected") + expect(same(original_manifest(catalog), manifest(catalog)), "failed path validation invalidates manifest") + expect(catalog.rebuild().get(&"valid", false), "recover after path validation failure") + var missing_paths: Dictionary = MvpContentCatalog.DEFAULT_PATHS.duplicate(true) + missing_paths[&"guest_classes"] = "res://missing_manifest_cache_content.json" + expect(not catalog.rebuild(missing_paths).get(&"valid", true), "read failure remains fail-closed") + expect(same(original_manifest(catalog), manifest(catalog)), "read failure cannot retain prior successful generation") + expect(catalog.rebuild().get(&"valid", false), "recover after read failure") + + var replacement := CountingCatalog.new() + expect(replacement.rebuild().get(&"valid", false), "new catalog identity is valid") + runtime.mvp_content_catalog = replacement + expect(same(expected, manifest(replacement)) and replacement.factory_calls == 1, "different identity cannot hit previous template") + var same_generation := CountingCatalog.new() + expect(same_generation.rebuild().get(&"valid", false), "second fresh identity has equivalent content") + if cached: + expect(replacement.call("get_manifest_generation") == same_generation.call("get_manifest_generation"), "identity test deliberately uses equal generations") + runtime.mvp_content_catalog = same_generation + expect(same(expected, manifest(same_generation)) and same_generation.factory_calls == 1, "equal generation on a new identity must still rebuild") + replacement = same_generation + # Temporary restore catalogs must be fully built without changing live cache. + var temporary := CountingCatalog.new() + temporary.rebuild() + var live_calls := replacement.factory_calls + manifest(temporary) + manifest(temporary) + expect(temporary.factory_calls == 2, "temporary catalog remains uncached for explicit restore validation") + expect(same(expected, manifest(replacement)), "temporary validation preserves current content") + if cached: + expect(replacement.factory_calls == live_calls, "temporary validation does not replace live cache") + + var old := expected.duplicate(true) + old[&"manifest_version"] = 1 + old[&"staff_candidate_ids"].erase("staff_uma") + var old_data := {&"catalog_manifest":old, &"hired_staff_candidate_ids":[], &"staff":{&"staff":[]}} + if cached: + runtime.call("_clear_mvp_content_manifest_cache") + expect(runtime.call("_saved_content_manifest_matches", replacement, old_data), "sole prior chapel manifest remains accepted on first fill") + expect(same(expected, manifest(replacement)), "legacy first-fill edits cannot poison current v2 template") + expect(runtime.call("_saved_content_manifest_matches", replacement, old_data), "legacy prior manifest remains accepted on a hit") + expect(same(expected, manifest(replacement)), "legacy hit edits leave staff_uma and manifest version intact") + + var guard_before := complete_live_guard() + var before: Dictionary = guard_before[&"snapshot"] + var tampered := before.duplicate(true) + for definition: Dictionary in tampered[&"systems"][&"runtime"][&"guests"][&"definitions"]: + if String(definition[&"guest_type_id"]) == String(expected[&"guest_definition_ids"][0]): + definition[&"wealth_min"] = int(definition[&"wealth_min"])+1 + break + var catalog_before: MvpContentCatalog = runtime.mvp_content_catalog + expect(not runtime.restore_snapshot(tampered), "cached manifest does not bypass full GuestDefinition value validation") + expect(runtime.mvp_content_catalog == catalog_before and same(complete_live_guard(), guard_before), "rejected restore preserves identity and all full save-lifecycle guard fields") + expect(runtime.restore_snapshot(before), "canonical save restores unchanged") + expect(runtime.mvp_content_catalog != catalog_before and same(runtime.create_snapshot(), before), "successful restore replaces identity while preserving complete saved state") + + var custom: GuestDefinition = runtime.guest_service.create_definition(&"manifest_late_guest", {&"adult_verified":true, &"wealth_min":10, &"wealth_max":20}) + expect(custom != null, "late custom registration remains supported") + if custom != null: + custom.base_patience = 42 + expect(runtime.guest_service.get_definition(&"manifest_late_guest").base_patience == 42, "live GuestService definition semantics remain intact") + expect(same(expected, manifest(runtime.mvp_content_catalog)), "custom registered definitions do not replace catalog membership") + var public_snapshot: Dictionary = runtime.create_snapshot() + public_snapshot[&"systems"][&"runtime"][&"catalog_manifest"][&"guest_definition_ids"].clear() + expect(same(expected, runtime.create_snapshot()[&"systems"][&"runtime"][&"catalog_manifest"]), "public full snapshot cannot mutate next snapshot manifest") + runtime.call("_publish_snapshot", clock.absolute_minute) + expect(same(runtime.latest_snapshot, runtime.create_snapshot()), "publication still eagerly contains all current components") + var published_digest: String = runtime.latest_digest + expect(published_digest == SimulationDigest.compute(runtime.latest_snapshot), "latest_digest remains exact") + var replay_start: Dictionary = runtime.create_snapshot() + clock.advance_minutes(7) + var replay_end: Dictionary = runtime.create_snapshot() + var retained_start := replay_start.duplicate(true) + expect(runtime.restore_snapshot(replay_start), "retained checkpoint restores for tick replay") + clock.advance_minutes(7) + var repeated: Dictionary = runtime.create_snapshot() + expect(same(replay_start, retained_start), "live ticks and restore never mutate retained public checkpoint") + expect(same(replay_end, repeated), "full tick replay preserves every stored event/history/counter and component") + expect(not SimulationDigest.canonical_bytes(repeated).is_empty() and SimulationDigest.canonical_bytes(repeated) == SimulationDigest.canonical_bytes(replay_end), "tick replay compares complete nonempty canonical bytes without event exclusions") + if cached: + var previous_identity: MvpContentCatalog = runtime.mvp_content_catalog + expect(runtime.initialize(clock,state,840_102).get(&"valid", false), "repeat initialization remains valid") + expect(runtime.mvp_content_catalog != previous_identity and same(expected, manifest(runtime.mvp_content_catalog)), "initialization clears old identity while preserving manifest values") + runtime.free() + clock.free() + state.free() + print("MANIFEST_CACHE_CORRECTNESS checks=%d failures=%d candidate=%s" % [checks, failures, cached]) + quit(failures) diff --git a/tests/performance/test_mvp_manifest_cache.gd.uid b/tests/performance/test_mvp_manifest_cache.gd.uid new file mode 100644 index 0000000..005858f --- /dev/null +++ b/tests/performance/test_mvp_manifest_cache.gd.uid @@ -0,0 +1 @@ +uid://cr07wcssbefah diff --git a/tests/performance/test_path_membership.gd b/tests/performance/test_path_membership.gd new file mode 100644 index 0000000..aa5057e --- /dev/null +++ b/tests/performance/test_path_membership.gd @@ -0,0 +1,24 @@ +extends SceneTree +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = root.get_node("SimulationRuntime") + var path := PathService.new() + var cells: Array[Vector3i] = [] + for x in 100: + for y in 20: cells.append(Vector3i(x,y,0)) + path.rebuild_graph(cells) + var cases: Array = [cells, [], cells.slice(1), cells.duplicate(), cells.duplicate()] + cases[3][0] = cells[1] + cases[4][0] = Vector3i(-1,0,0) + var reverse := cells.duplicate();reverse.reverse();cases.append(reverse) + var before := path.create_snapshot() + var failures := 0 + for candidate: Array in cases: + var typed: Array[Vector3i] = [];typed.assign(candidate) + var reference: bool = runtime._path_snapshot_cells_match_world(path.create_snapshot(),typed) + if path.matches_passable_cells(typed) != reference:failures+=1;push_error("membership differs from snapshot check") + if path.create_snapshot()!=before:failures+=1;push_error("membership mutated path state") + path.rebuild_graph([]) + if not path.matches_passable_cells([]):failures+=1;push_error("empty graphs must match") + print("Path membership: 8 checks, %d failures" % failures) + quit(failures) diff --git a/tests/performance/test_path_membership.gd.uid b/tests/performance/test_path_membership.gd.uid new file mode 100644 index 0000000..0f62ea5 --- /dev/null +++ b/tests/performance/test_path_membership.gd.uid @@ -0,0 +1 @@ +uid://co1jyiex61cfp diff --git a/tests/performance/test_snapshot_ownership.gd b/tests/performance/test_snapshot_ownership.gd new file mode 100644 index 0000000..708b8e3 --- /dev/null +++ b/tests/performance/test_snapshot_ownership.gd @@ -0,0 +1,64 @@ +extends SceneTree +const Demo := preload("res://scripts/ui/pixel_tower_demo.gd") +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 runtime: Variant = 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,424242); clock.set_paused(true) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + verify_detached(runtime, "new game") + var demo := Demo.new() + expect(await demo.prepare(runtime,clock,self), "populated production fixture prepares") + verify_detached(runtime, "populated game") + var retained: Dictionary = runtime.create_snapshot() + var original := retained.duplicate(true) + clock.advance_minutes(15) + expect(retained == original, "advancing live actors never changes a retained snapshot") + var after: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(retained), "owned snapshot restores before deterministic replay") + clock.advance_minutes(15) + expect(runtime.create_snapshot() == after, "owned snapshot replays all subsystems identically") + # Keep the general-purpose capture API's protective copy contract. + var extra := {&"caller": {&"rows": [{&"value":1}]}} + var generic: Dictionary = SimulationSnapshot.capture(clock,state,extra) + extra[&"caller"][&"rows"][0][&"value"] = 2 + expect(generic[&"systems"][&"caller"][&"rows"][0][&"value"] == 1, "general capture still detaches caller-owned extras") + print("Snapshot ownership: %d checks, %d failures" % [checks,failures]);quit(failures) + +func verify_detached(runtime: Variant, stage: String) -> void: + var expected: Dictionary = runtime.create_snapshot().duplicate(true) + for section: Variant in expected[&"systems"][&"runtime"]: + var candidate: Dictionary = runtime.create_snapshot() + poison(candidate[&"systems"][&"runtime"][section]) + expect(runtime.create_snapshot() == expected, "%s detached section: %s" % [stage,section]) + if failures: return + var candidate: Dictionary = runtime.create_snapshot() + poison(candidate[&"clock"]);poison(candidate[&"game_state"]) + expect(runtime.create_snapshot() == expected, "%s clock and resource snapshots remain detached" % stage) + +func poison(value: Variant) -> void: + if value is Dictionary: + for child: Variant in value.values(): poison(child) + value.clear() + value[&"ownership_probe"] = true + elif value is Array: + for child: Variant in value: poison(child) + value.clear() + match value.get_typed_builtin(): + TYPE_NIL: value.append(null) + TYPE_DICTIONARY: value.append({}) + TYPE_ARRAY: value.append([]) + TYPE_STRING: value.append("ownership_probe") + TYPE_STRING_NAME: value.append(&"ownership_probe") + TYPE_INT: value.append(99) + TYPE_FLOAT: value.append(99.0) + TYPE_BOOL: value.append(true) + TYPE_VECTOR2I: value.append(Vector2i(99,99)) + TYPE_VECTOR3I: value.append(Vector3i(99,99,99)) + _: expect(false, "ownership probe covers typed snapshot array %s" % value.get_typed_builtin()) diff --git a/tests/performance/test_snapshot_ownership.gd.uid b/tests/performance/test_snapshot_ownership.gd.uid new file mode 100644 index 0000000..45bd165 --- /dev/null +++ b/tests/performance/test_snapshot_ownership.gd.uid @@ -0,0 +1 @@ +uid://cq17j57epa3h5 diff --git a/tests/performance/test_system_optimization.gd b/tests/performance/test_system_optimization.gd new file mode 100644 index 0000000..d00b039 --- /dev/null +++ b/tests/performance/test_system_optimization.gd @@ -0,0 +1,84 @@ +extends SceneTree + +const Linear := preload("res://tests/performance/fixtures/linear_path_reference.gd") +const Uncached := preload("res://tests/performance/fixtures/uncached_preview_reference.gd") +var failures := 0 +var checks := 0 + +func _initialize() -> void: + call_deferred("_run") + +func expect(value: bool, label: String) -> void: + checks += 1 + if not value: + failures += 1 + push_error(label) + +func _run() -> void: + var fast := PathService.new() + var old := Linear.new() + var cells: Array[Vector3i] = [] + for z in 2: + for x in 30: + for y in 20: + cells.append(Vector3i(x, y, z)) + for service: PathService in [fast, old]: + service.rebuild_graph(cells) + service.configure_connector(&"door", Vector3i(15, 10, 0), Vector3i(15, 10, 1), 100, true, &"staff") + var rng := RandomNumberGenerator.new() + rng.seed = 728191 + var fast_us := 0 + var old_us := 0 + for index in 120: + var cell := cells[rng.randi_range(0, cells.size() - 1)] + for service: PathService in [fast, old]: + service.set_cell_dynamic_cost(cell, (index % 5) * 100) + service.set_cell_temporary_block(cell, index % 7 == 0) + service.set_connector_enabled(&"door", index % 9 != 0) + var start := cells[rng.randi_range(0, cells.size() - 1)] + var goal := cells[rng.randi_range(0, cells.size() - 1)] + var access: Array[StringName] = [] + if index % 4 != 0: + access.append(&"staff") + var began := Time.get_ticks_usec() + var expected := old.find_path(start, goal, null, access) + old_us += Time.get_ticks_usec() - began + began = Time.get_ticks_usec() + var actual := fast.find_path(start, goal, null, access) + fast_us += Time.get_ticks_usec() - began + expect(actual == expected and fast.last_path_cost == old.last_path_cost and fast.last_no_route_reason == old.last_no_route_reason, "route, cost, reason must match linear reference: %d" % index) + expect(fast.create_snapshot() == old.create_snapshot(), "query optimization must preserve snapshots") + print("PATH_BENCH old_us=%d new_us=%d ratio=%.3f queries=120 cells=1200" % [old_us, fast_us, float(fast_us)/old_us]) + var runtime: Variant = root.get_node("SimulationRuntime") + var preview := WorldPreview.new() + var reference := Uncached.new() + for view: WorldPreview in [preview, reference]: + view.size = Vector2(1100, 600) + view.set_runtime(runtime) + var timings: Array[int] = [] + for view: WorldPreview in [reference, preview]: + var began := Time.get_ticks_usec() + for frame in 20: + view._drawing = true + view._draw_cache.clear() + for i in 300: + view.get_cell_rect(Vector3i(i % 20 - 10, i % 8, 0)) + view._drawing = false + view._draw_cache.clear() + timings.append(Time.get_ticks_usec() - began) + for zoom: float in [0.5, 1.0, 2.0, 4.0]: + for view: WorldPreview in [preview, reference]: + view.zoom_level = zoom + view.pan_offset = Vector2(19, -33) + preview._drawing = true + preview._draw_cache.clear() + for i in 40: + expect(preview.get_cell_rect(Vector3i(i - 20, i % 7, 0)) == reference.get_cell_rect(Vector3i(i - 20, i % 7, 0)), "cached camera mapping preserves cell geometry") + preview._drawing = false + preview._draw_cache.clear() + expect(preview._draw_cache.is_empty(), "no derived cache survives a draw scope") + print("GEOMETRY_BENCH old_us=%d new_us=%d ratio=%.3f cells=6000" % [timings[0], timings[1], float(timings[1])/timings[0]]) + preview.free() + reference.free() + print("PASS: %d checks; failures=%d" % [checks, failures]) + quit(failures) diff --git a/tests/performance/test_system_optimization.gd.uid b/tests/performance/test_system_optimization.gd.uid new file mode 100644 index 0000000..fab49bf --- /dev/null +++ b/tests/performance/test_system_optimization.gd.uid @@ -0,0 +1 @@ +uid://duqbff3s0dowy diff --git a/tests/performance/test_world_snapshot_cache.gd b/tests/performance/test_world_snapshot_cache.gd new file mode 100644 index 0000000..5c554ea --- /dev/null +++ b/tests/performance/test_world_snapshot_cache.gd @@ -0,0 +1,37 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok: bool,label: String)->void: + checks+=1 + if not ok:failures+=1;push_error(label) +func _initialize()->void:call_deferred("run") +func run()->void: + var world:=WorldService.new() + var bounds:={&"min_x":0,&"max_x":5,&"min_y":-2,&"max_y":2} + world.reset(bounds) + world.set_walkable(Vector3i(0,0,0),true) + world.set_support(Vector3i(0,0,0),5,true) + var original:=world.create_snapshot() + var copy:=world.create_snapshot() + copy[&"support"][0][&"capacity"]=99 + copy[&"bounds"][&"max_x"]=99 + copy[&"walkable_cells"].clear() + expect(world.create_snapshot()==original,"cached snapshots remain caller-owned") + world.consume_dirty_cells() + expect(world.create_snapshot()[&"dirty_cells"].is_empty(),"dirty consumption is reflected without geometry revision") + world.set_walkable(Vector3i(1,0,0),true) + world.set_excavated(Vector3i(1,-1,0),true) + expect(world.create_snapshot()[&"walkable_cells"].size()==2 and world.create_snapshot()[&"excavation_overrides"].size()==1,"geometry mutation invalidates cache") + var replacement:=original.duplicate(true) + replacement[&"walkable_cells"]=[Vector3i(2,0,0)] + expect(world.restore_snapshot(replacement),"restore valid alternate world") + expect(world.create_snapshot()==replacement,"restore invalidates cached geometry even with reused revision") + replacement[&"walkable_cells"]=[Vector3i(3,0,0)] + expect(world.restore_snapshot(replacement) and world.create_snapshot()==replacement,"same-revision alternate restore cannot reuse old geometry") + var invalid:=replacement.duplicate(true);invalid[&"schema_version"]=-1 + expect(not world.restore_snapshot(invalid) and world.create_snapshot()==replacement,"failed restore preserves cached and authoritative state") + world.reset(bounds) + expect(world.create_snapshot()[&"support"].is_empty() and world.create_snapshot()[&"walkable_cells"].is_empty(),"reset invalidates same-revision geometry") + world.expand_bounds({&"min_x":0,&"max_x":8,&"min_y":-2,&"max_y":2}) + expect(world.create_snapshot()[&"bounds"][&"max_x"]==8,"expansion updates cached bounds") + print("World snapshot cache: %d checks, %d failures"%[checks,failures]);quit(failures) diff --git a/tests/performance/test_world_snapshot_cache.gd.uid b/tests/performance/test_world_snapshot_cache.gd.uid new file mode 100644 index 0000000..d89d93b --- /dev/null +++ b/tests/performance/test_world_snapshot_cache.gd.uid @@ -0,0 +1 @@ +uid://bqfwliej8uua6 diff --git a/tests/run_tests.gd b/tests/run_tests.gd index 2bbc138..1d59a8f 100644 --- a/tests/run_tests.gd +++ b/tests/run_tests.gd @@ -9,6 +9,7 @@ func _initialize() -> void: func _run() -> void: print("The Tree: automated checks") _test_game_clock() + _test_original_clock_scale() _test_game_state() _test_facility_data() load("res://tests/s0/test_kernel_and_save.gd").run(Callable(self, "_expect")) @@ -25,14 +26,32 @@ func _test_game_clock() -> void: clock.advance_minutes(4 * 60) _expect(clock.get_hour() == 10, "clock advances to 10:00") _expect(clock.get_phase() == clock.Phase.DAY, "10:00 is day phase") - clock.set_speed(4) - _expect(clock.speed == 4, "clock accepts the specified x4 speed") clock.set_speed(3) - _expect(clock.speed == 4, "clock rejects unsupported speed values") + _expect(clock.speed == 3, "clock accepts the original x3 speed") + clock.set_speed(4) + _expect(clock.speed == 3, "clock rejects unsupported x4") + _expect(clock.REAL_SECONDS_PER_GAME_HOUR == 10.0, "original scale is ten seconds per game hour") clock.advance_minutes(14 * 60) _expect(clock.day == 2 and clock.get_hour() == 0, "midnight starts the next day") clock.free() +func _test_original_clock_scale() -> void: + var clock := SimulationKernel.new() + for multiplier: int in [1, 2, 3]: + clock.reset() + clock.set_speed(multiplier) + for frame in 600: + clock.process_real_delta(1.0 / 60.0) + _expect(clock.absolute_minute == 360 + 60 * multiplier, "ten real seconds produce one hour at x%d" % multiplier) + clock.set_paused(true) + var minute := clock.absolute_minute + clock.process_real_delta(10.0) + _expect(clock.absolute_minute == minute, "pause preserves game time") + var legacy := clock.clock_snapshot() + legacy[&"speed"] = 4 + _expect(clock.restore_clock(legacy) and clock.speed == 3, "legacy x4 resumes at x3") + clock.free() + func _test_game_state() -> void: var state = load("res://autoload/game_state.gd").new() state.reset() diff --git a/tests/s0/test_command_snapshot_key_order.gd b/tests/s0/test_command_snapshot_key_order.gd new file mode 100644 index 0000000..aa1e370 --- /dev/null +++ b/tests/s0/test_command_snapshot_key_order.gd @@ -0,0 +1,171 @@ +extends SceneTree + +# Actual 53-key recurrence plus text edge cases. No environment variables required. +# Full Runtime state/event/suffix equivalence remains a separate required gate. +const CommandType = preload("res://scripts/core/command.gd") +const QueueType = preload("res://scripts/core/command_queue.gd") +const ResultType = preload("res://scripts/core/command_result.gd") +const OBSERVED_KEYS: Array[String] = [ + "guest.auto_queue.guest-auto-00000023.692", + "pixel_view.command.0044", + "pixel_view.command.0043", + "pixel_view.command.0042", + "pixel_view.command.0041", + "pixel_view.command.0013", + "pixel_view.command.0012", + "pixel_view.command.0011", + "pixel_view.command.0010", + "pixel_view.command.0009", + "pixel_view.command.0008", + "pixel_view.command.0004", + "pixel_view.command.0006", + "pixel_view.command.0007", + "pixel_view.command.0040", + "pixel_view.command.0039", + "pixel_view.command.0037", + "pixel_view.command.0036", + "pixel_view.command.0035", + "pixel_view.command.0034", + "pixel_view.command.0033", + "pixel_view.command.0029", + "pixel_view.command.0028", + "pixel_view.command.0027", + "pixel_view.command.0025", + "pixel_view.command.0024", + "pixel_view.command.0023", + "pixel_view.command.0022", + "pixel_view.command.0021", + "pixel_view.command.0020", + "pixel_view.command.0019", + "pixel_view.command.0017", + "pixel_view.command.0018", + "pixel_view.command.0016", + "pixel_view.command.0014", + "pixel_view.command.0015", + "pixel_view.command.0026", + "guest.auto_queue.guest-auto-00000024.692", + "guest.auto_queue.guest-auto-00000019.697", + "pixel_view.command.0005", + "guest.auto_queue.guest-auto-00000021.662", + "guest.auto_queue.guest-auto-00000026.722", + "guest.auto_queue.guest-auto-00000022.726", + "guest.auto_queue.guest-auto-00000022.662", + "pixel_view.command.0032", + "pixel_view.command.0031", + "pixel_view.command.0030", + "pixel_view.command.0001", + "pixel_view.command.0038", + "pixel_view.command.0002", + "guest.auto_queue.guest-auto-00000019.632", + "guest.auto_queue.guest-auto-00000020.632", + "pixel_view.command.0003", +] +var checks := 0 +var failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("Command Queue snapshot key-order regression") + var input: Array[String] = OBSERVED_KEYS.duplicate() + input.append_array(["A", "a", "a10", "a2", "alpha", "Alpha", "あ", "🌳"]) + var sorted_text: Array[String] = input.duplicate() + sorted_text.sort() + var reference := PackedByteArray() + var reference_final := PackedByteArray() + for iteration: int in range(2): + var insertion: Array[String] = input.duplicate() + if iteration == 1: + insertion.reverse() + var queue := QueueType.new() + for key: String in insertion: + var identity := sorted_text.find(key) + 1 + _expect(queue.enqueue(_command(identity, identity % 3, identity % 4, key)).is_success(), "accept unique actual/text fixture key") + var initially_ready := queue.drain_ready(0) + for index: int in range(initially_ready.size()): + if index % 2 == 0: + var command: SimulationCommand = initially_ready[index] + _expect(queue.complete(command.command_id, _result(command.command_id)).is_success(), "retain processed fixture result") + var snapshot := queue.create_snapshot() + var snapshot_bytes := var_to_bytes(snapshot) + _expect(not snapshot["pending"].is_empty() and not snapshot["in_flight"].is_empty() and not snapshot["processed"].is_empty(), + "fixture simultaneously covers pending, in-flight and retained results") + _assert_text_snapshot(snapshot, sorted_text) + if iteration == 0: + reference = snapshot_bytes + _expect(snapshot_bytes == reference, "complete typed snapshot ignores keyed command insertion order") + _expect(var_to_bytes(queue.create_snapshot()) == snapshot_bytes, "snapshot query is stable and read-only") + var reversed_source := snapshot.duplicate(true) + var reverse_keys: Array[String] = sorted_text.duplicate() + reverse_keys.reverse() + var raw_map: Dictionary = {} + for index: int in range(reverse_keys.size()): + var key: String = reverse_keys[index] + var raw_key: Variant = StringName(key) if index % 2 == 0 else key + raw_map[raw_key] = snapshot["idempotency"][key] + reversed_source["idempotency"] = raw_map + var restored := QueueType.new() + _expect(restored.restore_snapshot(reversed_source), "valid reordered mixed String/StringName saved map restores") + var restored_snapshot := restored.create_snapshot() + _assert_text_snapshot(restored_snapshot, sorted_text) + _expect(var_to_bytes(restored_snapshot) == snapshot_bytes, "all saved fields and map output bytes agree after restore") + for key: String in sorted_text: + _expect(queue.has_idempotency_key(StringName(key)) and restored.has_idempotency_key(StringName(key)), "every actual/text key remains available") + var before := queue.get_result_by_idempotency_key(StringName(key)) + var after := restored.get_result_by_idempotency_key(StringName(key)) + if before == null: + _expect(after == null, "unprocessed key does not invent a result") + else: + _expect(after != null and var_to_bytes(before.to_dictionary()) == var_to_bytes(after.to_dictionary()), "retained replay result agrees completely") + var duplicate := restored.enqueue(_command(10000 + sorted_text.find(key), 10, 0, key)) + _expect(duplicate.code == ResultType.Code.DUPLICATE_IDEMPOTENCY_KEY and int(duplicate.details.get("original_command_id", 0)) == sorted_text.find(key) + 1, + "restored mapping rejects duplicates with the original command ID") + _expect(var_to_bytes(restored.create_snapshot()) == snapshot_bytes, "rejected duplicate lookups preserve every saved field") + var owned := queue.create_snapshot() + owned["idempotency"].clear() + owned["accepted"].clear() + _expect(var_to_bytes(queue.create_snapshot()) == snapshot_bytes, "returned containers cannot corrupt accepted/idempotency state") + var original_drained := _finish(queue, snapshot) + var restored_drained := _finish(restored, restored_snapshot) + _expect(var_to_bytes(original_drained) == var_to_bytes(restored_drained), "restored queue executes every pending command in the same tick/priority/ID order") + var final_bytes := var_to_bytes(queue.create_snapshot()) + _expect(final_bytes == var_to_bytes(restored.create_snapshot()), "all final retained result/accepted/idempotency bytes agree") + if iteration == 0: + reference_final = final_bytes + _expect(final_bytes == reference_final, "both insertion orders preserve complete processed history") + print("Command Queue snapshot key-order regression: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) + +func _assert_text_snapshot(snapshot: Dictionary, sorted_text: Array[String]) -> void: + var converted: Array[String] = [] + for raw_key: Variant in snapshot["idempotency"]: + _expect(typeof(raw_key) == TYPE_STRING, "saved idempotency key remains String") + _expect(typeof(snapshot["idempotency"][raw_key]) == TYPE_INT and int(snapshot["idempotency"][raw_key]) == sorted_text.find(String(raw_key)) + 1, + "saved idempotency value retains int type and original command ID") + converted.append(String(raw_key)) + _expect(var_to_bytes(converted) == var_to_bytes(sorted_text), "saved key text matches ordinary String lexical order") + _expect(int(snapshot["version"]) == QueueType.SNAPSHOT_VERSION, "snapshot version remains unchanged") + +func _command(identity: int, tick: int, priority: int, text: String) -> SimulationCommand: + return CommandType.new(identity, 0, tick, priority, &"Test", &"actor", &"target", + {"original_key": text, "nested": {"fixture": &"preserve", "value": identity}}, StringName(text)) + +func _result(identity: int) -> CommandResult: + return ResultType.succeeded(identity, {"number": identity, "nested": {"fixture": &"preserve"}}) + +func _finish(queue: CommandQueue, snapshot: Dictionary) -> Array[Dictionary]: + for saved: Dictionary in snapshot["in_flight"]: + var identity := int(saved["command_id"]) + _expect(queue.complete(identity, _result(identity)).is_success(), "complete saved in-flight command with unchanged result") + var drained: Array[Dictionary] = [] + for command: SimulationCommand in queue.drain_ready(10): + drained.append(command.to_dictionary()) + _expect(queue.complete(command.command_id, _result(command.command_id)).is_success(), "complete ready command exactly once") + return drained + +func _expect(condition: bool, label: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(label) diff --git a/tests/s0/test_command_snapshot_key_order.gd.uid b/tests/s0/test_command_snapshot_key_order.gd.uid new file mode 100644 index 0000000..aa73434 --- /dev/null +++ b/tests/s0/test_command_snapshot_key_order.gd.uid @@ -0,0 +1 @@ +uid://b5tv71yuw2fg4 diff --git a/tests/s0/test_definition_registry.gd b/tests/s0/test_definition_registry.gd index 88cb2b2..b9d5c6c 100644 --- a/tests/s0/test_definition_registry.gd +++ b/tests/s0/test_definition_registry.gd @@ -13,6 +13,7 @@ func _initialize() -> void: func _run() -> void: _test_catalog_references() + _test_normal_catalog_lookup() _test_expansion_catalog() _test_default_registry() _test_detailed_definition_schema() @@ -83,6 +84,16 @@ func _test_catalog_references() -> void: _expect(FacilityCatalogScript.by_id(&"not_a_facility") == null, "catalog by_id rejects an unknown reference") +func _test_normal_catalog_lookup() -> void: + # Every normal-build registration must also resolve through the public catalog. + var paths: Array[String] = FacilityCatalogScript.FACILITY_PATHS + FacilityCatalogScript.INFRASTRUCTURE_FACILITY_PATHS + FacilityCatalogScript.RESCUE_FACILITY_PATHS + FacilityCatalogScript.ORIGINAL_FACILITY_PATHS + for path: String in paths: + var registered := load(path) as FacilityDefinition + _expect(registered != null, "normal catalog resource loads: %s" % path) + if registered != null: + _expect(FacilityCatalogScript.by_id(registered.id) == registered, "normal registration resolves to identical definition: %s" % registered.id) + + func _test_detailed_definition_schema() -> void: var registry = DefinitionRegistryScript.new() registry.rebuild() @@ -204,6 +215,7 @@ func _test_queries() -> void: &"service_elevator", &"staff_door", &"staff_waiting_room", + &"standard_room", &"tavern", &"warehouse", ] diff --git a/tests/s0/test_domain_events.gd b/tests/s0/test_domain_events.gd index 76e0e04..4d6536b 100644 --- a/tests/s0/test_domain_events.gd +++ b/tests/s0/test_domain_events.gd @@ -8,6 +8,7 @@ func _initialize() -> void: func _run() -> void: print("S0: domain events") + _test_snapshot_id_cache() _test_single_publish_and_snapshot() _test_atomic_batch_and_reentrant_delivery() _test_batch_rejection_is_atomic() @@ -119,3 +120,25 @@ func _expect(condition: bool, label: String) -> void: return _failures += 1 push_error(" ✗ %s" % label) + + +func _test_snapshot_id_cache() -> void: + var stream := DomainEventStream.new() + var ids: Array[String] = ["z", "a", "A", "a10", "a2", String.chr(0x1f333), String.chr(0x3042)] + _expect(stream.restore_snapshot({"version":1,"published_ids":ids,"recent_events":[]}), "restore unsorted history fixture") + var expected := ids.duplicate() + expected.sort_custom(func(a: String,b: String):return a < b) + var first := stream.create_snapshot() + _expect(first["published_ids"] == expected, "native string sort preserves reference order including Unicode") + first["published_ids"].clear() + _expect(stream.create_snapshot()["published_ids"] == expected, "returned ID array cannot corrupt cached history") + var event := SimulationDomainEvent.new(&"new_id", &"TEST", 360, &"test", [], {}, 1) + _expect(stream.publish(event), "publish invalidates cached IDs") + expected.append("new_id");expected.sort() + _expect(stream.create_snapshot()["published_ids"] == expected, "new ID appears once after cached snapshot") + _expect(not stream.publish(event) and stream.create_snapshot()["published_ids"] == expected, "rejected duplicate preserves cached state") + _expect(stream.restore_snapshot({"version":1,"published_ids":["replacement"],"recent_events":[]}), "replace history through restore") + _expect(stream.create_snapshot()["published_ids"] == ["replacement"], "restore invalidates old sorted cache") + var valid := stream.create_snapshot() + _expect(not stream.restore_snapshot({"version":1,"published_ids":["duplicate","duplicate"],"recent_events":[]}), "invalid restore rejected") + _expect(stream.create_snapshot() == valid, "failed restore preserves canonical snapshot") diff --git a/tests/s0/test_runtime_integration.gd b/tests/s0/test_runtime_integration.gd index a6f980b..57e1473 100644 --- a/tests/s0/test_runtime_integration.gd +++ b/tests/s0/test_runtime_integration.gd @@ -66,7 +66,8 @@ func _test_tick_command_execution() -> void: func _test_starter_world_layers() -> void: _reset_runtime() - _expect(_runtime.definition_registry.facility_count() == 18, "Runtime loads baseline sixteen plus two required stair definitions") + _expect(_runtime.definition_registry.facility_count() == 23, "Runtime loads baseline sixteen plus stairs, the infirmary and premium room/dormitory and chapel") + _expect(_runtime.definition_registry.has_facility(&"chapel"), "Runtime exposes the original chapel definition") var front_start := Vector3i(-2, 0, WorldService.LAYER_FRONT) var back_goal := Vector3i(2, 0, WorldService.LAYER_BACK) var guest_path: Array[Vector3i] = _runtime.path_service.find_path( diff --git a/tests/s1/test_dimension_batch_save.gd b/tests/s1/test_dimension_batch_save.gd new file mode 100644 index 0000000..7c9e6e8 --- /dev/null +++ b/tests/s1/test_dimension_batch_save.gd @@ -0,0 +1,56 @@ +extends SceneTree +var checks:=0 +var failures:=0 +var runtime: Variant +var clock: Variant +var state: Variant +func expect(ok: bool, message: String) -> void: + checks+=1 + if not ok: failures+=1;push_error(message) +func _initialize() -> void: call_deferred("run") +func build(id: StringName, origin: Vector3i) -> bool: + var result: CommandResult=runtime.submit_command(&"build_facility",{&"definition_id":id,&"origin":origin}) + expect(result.is_success(),"legacy batch build %s" % id) + if not result.is_success(): return false + clock.advance_minutes(runtime.definition_registry.get_facility(id).build_minutes) + return true +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();runtime.initialize(clock,state,42506);clock.set_paused(true) + var paths: Array[String]=FacilityCatalog.FACILITY_PATHS+FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS+FacilityCatalog.RESCUE_FACILITY_PATHS + paths.append_array(["res://tests/fixtures/legacy_premium_room.tres","res://tests/fixtures/legacy_staff_dormitory.tres"]) + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"],"load both legacy definitions") + if not build(&"reception",Vector3i(-6,0,0)) or not build(&"premium_room",Vector3i(-6,2,0)) or not build(&"warehouse",Vector3i(-6,0,1)) or not build(&"staff_dormitory",Vector3i(-6,2,1)): finish();return + if not build(&"service_stairs",Vector3i(-7,1,1)) or not build(&"service_stairs",Vector3i(-7,2,1)) or not build(&"service_stairs",Vector3i(-7,3,1)): finish();return + paths.erase("res://tests/fixtures/legacy_premium_room.tres");paths.erase("res://tests/fixtures/legacy_staff_dormitory.tres");paths.append_array(FacilityCatalog.ORIGINAL_FACILITY_PATHS) + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"],"switch batch to current definitions") + var before: Dictionary=runtime.create_snapshot() + var path: String="user://saves/dimension_batch_%d" % Time.get_ticks_usec() + expect(SaveService.save_snapshot(path,before)[&"ok"],"persist original two-facility source") + var migrated: Dictionary=runtime.migrate_facility_dimensions_at_path(path) + expect(migrated[&"ok"],"batch disk migration accepts both facilities") + var backup:=SaveService.load_snapshot(path+SaveService.MIGRATION_BACKUP_SUFFIX) + expect(backup[&"ok"] and backup[&"snapshot"]==before,"one backup contains both original footprints") + expect(runtime.create_snapshot()==before,"batch disk migration preserves live world") + var loaded:=SaveService.load_snapshot(path) + expect(loaded[&"ok"] and runtime.restore_snapshot(loaded[&"snapshot"]),"batch result restores all crosslinks") + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"premium_room": expect(room.occupied_cells.size()==4,"batch premium footprint is four cells") + if room.definition_id==&"staff_dormitory": expect(room.occupied_cells.size()==2,"batch dormitory footprint is two cells") + var repeated: Dictionary=runtime.migrate_facility_dimensions_at_path(path) + expect(repeated[&"ok"] and not repeated.get(&"backup_created",true) and SaveService.load_snapshot(path+SaveService.MIGRATION_BACKUP_SUFFIX)[&"snapshot"]==before,"repeat batch preserves first backup") + expect(runtime.restore_snapshot(before),"restore source to test late batch failure") + var dorm_id: StringName=&"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"staff_dormitory": dorm_id=room.instance_id + expect(runtime.staff_service.create_task(&"maintenance",10,clock.absolute_minute,{&"target_entity_id":dorm_id,&"target_cell":Vector3i(-7,3,1)},&"batch_blocker").is_success(),"create valid task at disappearing second-facility entrance") + var rejected_source: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(rejected_source) and SaveService.save_snapshot(path,rejected_source)[&"ok"],"late-failure source is valid and saved") + var rejected: Dictionary=runtime.migrate_facility_dimensions_at_path(path) + expect(not rejected[&"ok"] and rejected.get(&"details",{}).get(&"facility_id",&"")==dorm_id and rejected[&"details"].get(&"rolled_back",false),"second facility failure rolls back isolated first-facility conversion") + expect(SaveService.load_snapshot(path)[&"snapshot"]==rejected_source and runtime.create_snapshot()==rejected_source,"late rejection preserves entire disk source and live state") + expect(SaveService.load_snapshot(path+SaveService.MIGRATION_BACKUP_SUFFIX)[&"snapshot"]==before,"late rejected batch leaves prior backup intact") + SaveService.delete_save(path);SaveService.delete_save(path+SaveService.MIGRATION_BACKUP_SUFFIX) + finish() +func finish() -> void: + runtime.free();clock.free();state.free();print("Dimension batch save: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/s1/test_dimension_batch_save.gd.uid b/tests/s1/test_dimension_batch_save.gd.uid new file mode 100644 index 0000000..524d37e --- /dev/null +++ b/tests/s1/test_dimension_batch_save.gd.uid @@ -0,0 +1 @@ +uid://cr2qkdh3c3d2s diff --git a/tests/s1/test_facility_infrastructure_projector.gd b/tests/s1/test_facility_infrastructure_projector.gd index 3a21e91..42734d5 100644 --- a/tests/s1/test_facility_infrastructure_projector.gd +++ b/tests/s1/test_facility_infrastructure_projector.gd @@ -99,6 +99,7 @@ func _run() -> void: _test_callback_reentry_tamper_and_quarantine_recovery() _test_strict_snapshot_round_trip_and_atomic_rejection() _test_configuration_and_stale_dependency_boundaries() + _test_validation_cache_detects_changes() if _failures == 0: print("PASS: %d checks" % _checks) else: @@ -459,6 +460,39 @@ func _test_configuration_and_stale_dependency_boundaries() -> void: _expect(not collision_projector.configure(collision_adapter, collision_source, collision_path), "configuration rejects an untracked managed-ID namespace collision") +func _test_validation_cache_detects_changes() -> void: + var start := Vector3i(0, 0, PathType.FRONT) + var middle := Vector3i(1, 0, PathType.FRONT) + var goal := Vector3i(2, 0, PathType.FRONT) + var context := _context([start, goal]) + var instance := _instance(GUEST_CORRIDOR, &"facility_cache", middle, 10, &"construction_cache") + _expect(context.projector.project_facility(&"project_cache", GUEST_CORRIDOR, instance).is_success(), "cache fixture projects corridor") + _expect(context.projector.validate_invariants()[&"ok"], "initial complete validation succeeds") + _expect(context.projector.validate_invariants()[&"ok"], "unchanged validation succeeds") + var before: Dictionary = context.projector.create_snapshot() + var exported: Dictionary = context.projector.create_snapshot() + exported[&"projections"][0][&"cells"].clear() + _expect(context.projector.validate_invariants()[&"ok"] and context.projector.create_snapshot() == before, "exported containers cannot alter validation cache or live state") + var records: Dictionary = context.projector.get("_projections") + var fingerprint: String = records[&"facility_cache"][&"record_fingerprint"] + records[&"facility_cache"][&"record_fingerprint"] = "invalid" + _expect(not context.projector.validate_invariants()[&"ok"], "warm cache detects changed projection content at unchanged revision") + records[&"facility_cache"][&"record_fingerprint"] = fingerprint + _expect(context.projector.validate_invariants()[&"ok"], "repaired owned state validates") + var requests: Dictionary = context.projector.get("_processed_requests") + var request_fingerprint: String = requests[&"project_cache"][&"fingerprint"] + requests[&"project_cache"][&"fingerprint"] = "invalid" + _expect(not context.projector.validate_invariants()[&"ok"], "warm cache detects changed request history") + requests[&"project_cache"][&"fingerprint"] = request_fingerprint + var path_snapshot: Dictionary = context.path.create_snapshot() + _expect(context.path.rebuild_graph([start, goal]), "inject live path change without projector mutation") + _expect(not context.projector.validate_invariants()[&"ok"], "unchanged owned snapshot still validates live dependencies") + _expect(context.path.rebuild_graph([start, middle, goal]) and context.path.restore_snapshot(path_snapshot), "restore valid live graph and traffic snapshot") + _expect(context.projector.validate_invariants()[&"ok"], "repaired dependencies validate against warm cache") + _expect(context.projector.unproject_facility(&"unproject_cache", &"facility_cache").is_success(), "new history after cache warm can unproject") + _expect(context.projector.validate_invariants()[&"ok"], "new history receives full validation") + + func _context(base_cells: Array, custom_path: Variant = null) -> Dictionary: var source := SpatialType.new() var path: PathService = custom_path if custom_path != null else PathType.new() diff --git a/tests/s1/test_facility_resize_preview.gd b/tests/s1/test_facility_resize_preview.gd new file mode 100644 index 0000000..df37696 --- /dev/null +++ b/tests/s1/test_facility_resize_preview.gd @@ -0,0 +1,58 @@ +extends SceneTree +const Preview = preload("res://scripts/construction/facility_resize_preview.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: + var world := WorldService.new() + world.reset({&"min_x":-10,&"max_x":10,&"min_y":-5,&"max_y":10}) + var origin := Vector3i(0,0,0) + var cells := world.cells_for_footprint(origin,Vector2i(3,2)) + world.occupy(&"room",cells) + world.set_support(origin,0,true) + var item := {&"instance_id":&"room",&"definition_id":&"premium_room",&"origin":origin,&"rotation":0,&"occupied_cells":cells} + var before := world.create_snapshot() + var report := Preview.inspect(world,item,2) + expect(report[&"geometry_ok"] and report[&"target_cells"].size()==4 and report[&"added"].size()==1 and report[&"removed"].size()==3, "premium room preview changes 3x2 into 4x1") + expect(not report[&"ready_to_apply"] and not report[&"remaining_gates"].is_empty(), "geometry success never bypasses route save or job migration") + expect(world.create_snapshot()==before, "successful preview does not mutate live world") + world.occupy(&"neighbor",[Vector3i(3,0,0)]) + before=world.create_snapshot();report=Preview.inspect(world,item,2) + expect(not report[&"geometry_ok"] and report[&"errors"][0][&"code"]==&"OVERLAP", "expansion detects an occupied new cell") + expect(world.create_snapshot()==before, "rejected preview preserves ownership and revisions") + world.release(&"neighbor") + world.occupy(&"upper",[Vector3i(2,2,0)]) + report=Preview.inspect(world,item,2) + expect(not report[&"geometry_ok"] and &"upper" in report[&"affected_entities"], "shrink detects loss of support for upper facility") + var bad:=item.duplicate(true);bad[&"occupied_cells"][0]=Vector3i(9,9,0) + expect(not Preview.inspect(world,bad,2)[&"geometry_ok"], "source ownership cannot be guessed from current definition") + bad=item.duplicate(true);bad[&"occupied_cells"][1]=bad[&"occupied_cells"][0] + expect(not Preview.inspect(world,bad,2)[&"geometry_ok"], "duplicate saved cells reject") + bad=item.duplicate(true);bad[&"instance_id"]=42 + expect(not Preview.inspect(world,bad,2)[&"geometry_ok"], "malformed saved identifiers reject without conversion errors") + bad=item.duplicate(true);bad[&"rotation"]=45 + expect(not Preview.inspect(world,bad,2)[&"geometry_ok"], "invalid rotation rejects") + world.release(&"upper");world.release(&"room") + origin=Vector3i(0,0,1);cells=world.cells_for_footprint(origin,Vector2i(2,4)) + world.occupy(&"dorm",cells);world.set_support(origin,0,true) + item={&"instance_id":&"dorm",&"definition_id":&"staff_dormitory",&"origin":origin,&"rotation":90,&"occupied_cells":cells} + report=Preview.inspect(world,item,1) + expect(report[&"geometry_ok"] and report[&"target_cells"]==[Vector3i(0,0,1),Vector3i(0,1,1)], "rotated dorm shrinks on correct layer and axis") + world.release(&"dorm") + origin=Vector3i(7,-3,0);cells=world.cells_for_footprint(origin,Vector2i(3,2)) + world.occupy(&"market",cells);world.set_support(origin,0,true) + for cell: Vector3i in cells: world.set_excavated(cell,true) + item={&"instance_id":&"market",&"definition_id":&"black_market",&"origin":origin,&"rotation":0,&"occupied_cells":cells} + before=world.create_snapshot();report=Preview.inspect(world,item,2) + expect(not report[&"geometry_ok"] and world.create_snapshot()==before, "market cannot expand into unexcavated underground space") + world.set_excavated(Vector3i(10,-3,0),true);world.set_excavated(Vector3i(10,-2,0),true) + report=Preview.inspect(world,item,2) + expect(report[&"geometry_ok"] and report[&"target_cells"].size()==8 and report[&"added"].size()==2, "excavated market expands to original four-by-two footprint") + world.release(&"market") + origin=Vector3i(8,-3,0);cells=world.cells_for_footprint(origin,Vector2i(3,2)) + world.occupy(&"edge",cells);world.set_support(origin,0,true) + item={&"instance_id":&"edge",&"definition_id":&"black_market",&"origin":origin,&"rotation":0,&"occupied_cells":cells} + expect(not Preview.inspect(world,item,2)[&"geometry_ok"], "market expansion cannot leave map bounds") + print("Facility resize preview: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/s1/test_facility_resize_preview.gd.uid b/tests/s1/test_facility_resize_preview.gd.uid new file mode 100644 index 0000000..006a4a1 --- /dev/null +++ b/tests/s1/test_facility_resize_preview.gd.uid @@ -0,0 +1 @@ +uid://cja1om6ylyvfa diff --git a/tests/s1/test_original_facility_dimensions.gd b/tests/s1/test_original_facility_dimensions.gd new file mode 100644 index 0000000..c04d820 --- /dev/null +++ b/tests/s1/test_original_facility_dimensions.gd @@ -0,0 +1,46 @@ +extends SceneTree +var checks := 0 +var failures := 0 +class Budget: + extends RefCounted + var resources := {&"gold":100000, &"materials":100000} + func get_resource(id: StringName) -> int: return resources.get(id, 0) + func can_afford(cost: Dictionary) -> bool: + for id: Variant in cost: + if resources.get(id, 0) < cost[id]: return false + return true + func spend(cost: Dictionary) -> bool: + if not can_afford(cost): return false + for id: Variant in cost: resources[id] -= cost[id] + return true +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: + for entry: Array in [["premium_room",Vector2i(4,1),0,2],["staff_dormitory",Vector2i(2,1),1,2],["black_market",Vector2i(4,2),0,-5]]: + var definition: FacilityDefinition = load("res://data/facilities/expansion/%s.tres" % entry[0]) + expect(definition.footprint == entry[1] and definition.layer == entry[2], "canonical dimensions and layer: " + entry[0]) + for rotation: int in [0,90,180,270]: + var world := WorldService.new() + world.reset({&"min_x":-10,&"max_x":10,&"min_y":-10,&"max_y":10}) + var origin := Vector3i(0,entry[3],entry[2]) + var size: Vector2i = entry[1] + if rotation in [90,270]: size = Vector2i(size.y,size.x) + var expected := world.cells_for_footprint(origin,size) + world.set_support(origin,0,true) + for cell: Vector3i in expected: + if cell.y < 0: world.set_excavated(cell,true) + var budget := Budget.new() + var construction := ConstructionService.new(world,budget) + var started := construction.start_construction(definition,origin,rotation) + expect(started.is_success(), "canonical construction accepted: %s rotation %d" % [entry[0],rotation]) + if not started.is_success(): continue + var job := construction.get_jobs()[0] + expect(job.reserved_cells == expected, "reservation has exact rotated original footprint") + for minute in range(1,definition.build_minutes+1): construction.advance_one_minute(minute) + var completed := construction.get_facility(job.facility_instance_id) + expect(job.state == ConstructionJob.State.COMPLETED and completed.get(&"occupied_cells",[]) == expected and world.get_entity_cells(job.facility_instance_id).size() == expected.size(), "completion keeps original footprint") + expect(budget.get_resource(&"gold") == 100000-int(definition.construction_cost[&"gold"]), "dimension correction does not multiply construction payment") + var restored := ConstructionService.new(world,budget) + expect(restored.restore_snapshot(construction.create_snapshot()), "completed original-size construction restores") + print("Original facility dimensions: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/s1/test_original_facility_dimensions.gd.uid b/tests/s1/test_original_facility_dimensions.gd.uid new file mode 100644 index 0000000..a84d199 --- /dev/null +++ b/tests/s1/test_original_facility_dimensions.gd.uid @@ -0,0 +1 @@ +uid://crwpol62ko88p diff --git a/tests/s1/test_path_congestion.gd b/tests/s1/test_path_congestion.gd index 5026dd6..3882a64 100644 --- a/tests/s1/test_path_congestion.gd +++ b/tests/s1/test_path_congestion.gd @@ -59,7 +59,7 @@ func _test_cell_traffic_and_dynamic_route() -> void: _expect(int(traffic[&"current_density"]) == 4, "cell reports projected current density") _expect(int(traffic[&"dynamic_cost"]) == 25, "cell reports configured dynamic cost") _expect(service.get_congestion_cost(top_middle) == 225, "density excess costs 75 units per actor") - _expect(is_equal_approx(service.get_speed_multiplier(top_middle), 0.25), "soft collision speed clamps at 0.25") + _expect(is_equal_approx(service.get_speed_multiplier(top_middle), 0.5), "overlapping units move at half speed") var diverted: Array[Vector3i] = service.find_path(start, goal, service.FRONT) _expect(diverted.has(bottom_middle), "A* avoids the dynamically congested route") _expect(not diverted.has(top_middle), "locally invalidated cache never returns the stale congested route") @@ -118,7 +118,7 @@ func _test_two_hundred_actor_chokepoint() -> void: _expect(all_entered, "all 200 actors can share the chokepoint without a hard-cap deadlock") _expect(service.get_current_density(choke) == 200, "chokepoint density reaches 200 exactly") _expect(service.get_congestion_cost(choke) == 14_700, "200-actor chokepoint congestion cost is exact") - _expect(is_equal_approx(service.get_speed_multiplier(choke), 0.25), "extreme density remains bounded by minimum speed") + _expect(is_equal_approx(service.get_speed_multiplier(choke), 0.5), "extreme density preserves the specified half speed") var all_exited := true for index: int in range(200): var actor_id := StringName("guest_%03d" % index) diff --git a/tests/s1/test_route_subslot_path_adapter.gd b/tests/s1/test_route_subslot_path_adapter.gd index aa390ff..287aacd 100644 --- a/tests/s1/test_route_subslot_path_adapter.gd +++ b/tests/s1/test_route_subslot_path_adapter.gd @@ -63,6 +63,7 @@ func _run() -> void: _test_uncertain_rollback_quarantines_and_recovers() _test_strict_snapshot_round_trip_and_atomic_rejection() _test_strict_input_and_route_only_boundary() + _test_validation_cache_checks_history_and_live_graph() if _failures == 0: print("PASS: %d checks" % _checks) else: @@ -450,6 +451,35 @@ func _test_strict_input_and_route_only_boundary() -> void: _expect(not adapter.configure(source, path, [Vector3i.ZERO]), "configured adapter rejects reconfiguration") +func _test_validation_cache_checks_history_and_live_graph() -> void: + var source := SpatialType.new() + var path := PathType.new() + var adapter := AdapterType.new() + var start := Vector3i(0, 0, 0) + var middle := Vector3i(1, 0, 0) + var goal := Vector3i(2, 0, 0) + _expect(adapter.configure(source, path, [start, goal]), "cache fixture configures") + _expect(adapter.place_route(&"cache_place", &"cache_route", [middle]).is_success(), "cache fixture places route") + _expect(adapter.validate_invariants()[&"ok"] and adapter.validate_invariants()[&"ok"], "fresh and unchanged validations succeed") + var exported := adapter.create_snapshot() + exported[&"projected_walkable_cells"].clear() + exported[&"processed_requests"][0][&"input_cells"].clear() + _expect(adapter.validate_invariants()[&"ok"], "snapshot mutation cannot change live state or cached input") + var requests: Dictionary = adapter.get("_processed_requests") + var fingerprint: String = requests[&"cache_place"][&"fingerprint"] + requests[&"cache_place"][&"fingerprint"] = "invalid" + _expect(not adapter.validate_invariants()[&"ok"], "warm cache detects history corruption without revision change") + requests[&"cache_place"][&"fingerprint"] = fingerprint + _expect(adapter.validate_invariants()[&"ok"], "restored history validates") + var before := path.create_snapshot() + _expect(path.rebuild_graph([start, goal]), "remove live bridge independently") + _expect(not adapter.validate_invariants()[&"ok"], "warm cache still detects live graph divergence") + _expect(path.rebuild_graph([start, middle, goal]) and path.restore_snapshot(before), "restore live path") + _expect(adapter.validate_invariants()[&"ok"], "repaired graph validates") + _expect(adapter.remove_route(&"cache_remove", &"cache_route").is_success(), "route removal updates cached history input") + _expect(adapter.validate_invariants()[&"ok"], "updated history validates fully") + + func _assert_exact_rollback( adapter: RouteSubslotPathAdapter, source: SpatialSubslotService, diff --git a/tests/s1/test_runtime_infrastructure_integration.gd b/tests/s1/test_runtime_infrastructure_integration.gd index ae4dce8..fb3ed15 100644 --- a/tests/s1/test_runtime_infrastructure_integration.gd +++ b/tests/s1/test_runtime_infrastructure_integration.gd @@ -1,6 +1,6 @@ extends SceneTree -const RUNTIME_SNAPSHOT_VERSION := 9 +const RUNTIME_SNAPSHOT_VERSION := 13 const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ &"excavation", &"spatial_subslots", @@ -39,6 +39,22 @@ class SnapshotCountingDomainEventStream: return super.create_snapshot() +class CountingSpatialValidation: + extends SpatialSubslotService + var validation_calls := 0 + func validate_invariants() -> Dictionary: + validation_calls += 1 + return super.validate_invariants() + + +class CountingAdapterValidation: + extends RouteSubslotPathAdapter + var validation_calls := 0 + func validate_invariants() -> Dictionary: + validation_calls += 1 + return super.validate_invariants() + + var _checks := 0 var _failures := 0 @@ -51,8 +67,10 @@ func _run() -> void: print("S1: Runtime infrastructure integration") var probe := _new_fixture(81_001) var surface_ready := _test_runtime_surface(probe) + _test_exact_cell_sets(probe[&"runtime"]) _free_fixture(probe) if surface_ready: + _test_composed_infrastructure_validation() _test_completion_capture_fast_path() _test_rank_and_operations_gates() _test_excavation_tick_pause_resume_and_completion() @@ -75,6 +93,61 @@ func _finish() -> void: quit(_failures) +func _test_composed_infrastructure_validation() -> void: + var fixture := _new_fixture(81_099) + var runtime: Variant = fixture[&"runtime"] + var spatial := CountingSpatialValidation.new() + var adapter := CountingAdapterValidation.new() + var path := PathService.new() + var projector := FacilityInfrastructureProjector.new() + _expect(adapter.configure(spatial, path, runtime.world_service.get_walkable_cells()), "counted route dependencies configure") + _expect(projector.configure(adapter, spatial, path), "counted projector binds authoritative dependencies") + var validate := func() -> bool: + return runtime.call("_validate_infrastructure_crosslinks", runtime.world_service, path, + runtime.construction_service, runtime.construction_resource_state, runtime.excavation_service, + spatial, adapter, projector) + spatial.validation_calls = 0 + adapter.validation_calls = 0 + _expect(validate.call(), "composed infrastructure validation accepts healthy graph") + _expect(spatial.validation_calls == 1 and adapter.validation_calls == 1, "one graph audit validates each dependency exactly once") + _expect(projector.is_bound_to(adapter, spatial, path) and not projector.is_bound_to(adapter, SpatialSubslotService.new(), path), "binding test requires identical dependency objects") + spatial.revision = -1 + _expect(not validate.call(), "active composed check still catches raw source corruption") + spatial.revision = 0 + var prior_revision := adapter.projection_revision + adapter.projection_revision = -1 + _expect(not validate.call(), "active composed check still catches raw adapter corruption") + adapter.projection_revision = prior_revision + projector.set("_quarantined", true) + projector.set("_quarantine_reason", FacilityInfrastructureProjector.QUARANTINE_REASONS[0]) + spatial.validation_calls = 0 + adapter.validation_calls = 0 + _expect(validate.call(), "canonical quarantined projector retains previous graph-validation semantics") + _expect(spatial.validation_calls == 1 and adapter.validation_calls == 1, "quarantine retains independent dependency checks") + spatial.revision = -1 + _expect(not validate.call(), "quarantine never conceals malformed supplied source") + spatial.revision = 0 + projector.set("_quarantined", false) + projector.set("_quarantine_reason", &"") + var original_projector: FacilityInfrastructureProjector = runtime.facility_infrastructure_projector + spatial.revision = -1 + _expect(not runtime.call("_validate_infrastructure_crosslinks", runtime.world_service, path, + runtime.construction_service, runtime.construction_resource_state, runtime.excavation_service, + spatial, adapter, original_projector), "different projector binding does not bypass supplied-source validation") + _free_fixture(fixture) + + +func _test_exact_cell_sets(runtime: Variant) -> void: + var first := Vector3i(0, 0, 0) + var second := Vector3i(1, 0, 0) + _expect(runtime.call("_same_vector3i_set", [first, second], [second, first]), "cell equality ignores order") + _expect(not runtime.call("_same_vector3i_set", [first, second], [first, first]), "duplicate actual cell cannot conceal a missing cell") + _expect(not runtime.call("_same_vector3i_set", [first, first], [first, second]), "duplicate expected cell is rejected") + _expect(runtime.call("_same_vector3i_set", [], []), "empty cell sets match") + _expect(not runtime.call("_same_vector3i_set", [first], [Vector3i(0, 0, 1)]), "same coordinate in another layer does not match") + _expect(not runtime.call("_same_vector3i_set", [first], [Vector2i.ZERO]), "wrong cell type is rejected") + + func _new_fixture(seed: int) -> Dictionary: var clock: Variant = load("res://autoload/game_clock.gd").new() var state: Variant = load("res://autoload/game_state.gd").new() @@ -113,7 +186,7 @@ func _test_runtime_surface(fixture: Dictionary) -> bool: var snapshot: Dictionary = runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] var current_version := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION - _expect(current_version, "Runtime emits schema v9 with infrastructure state") + _expect(current_version, "Runtime emits schema v13 with infrastructure and weather state") ready = ready and current_version for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: var valid_dictionary := runtime_data.get(key, null) is Dictionary @@ -827,6 +900,7 @@ func _test_v5_proxy_migration_determinism() -> void: legacy_runtime.erase(&"kitchen_production") legacy_runtime.erase(&"elevators") legacy_runtime.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(legacy_runtime) legacy_runtime.erase(&"staff_commute") for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: legacy_runtime.erase(key) @@ -849,7 +923,7 @@ func _test_v5_proxy_migration_determinism() -> void: _expect(first_runtime.restore_snapshot(legacy), "v5 proxy snapshot migrates through Runtime restore") var first_migrated: Dictionary = first_runtime.create_snapshot() _expect( - int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 9, + int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 13, "v5 migration is immediately re-emitted as v9" ) _expect( diff --git a/tests/s1/test_weighted_resize_runtime.gd b/tests/s1/test_weighted_resize_runtime.gd new file mode 100644 index 0000000..c7849a2 --- /dev/null +++ b/tests/s1/test_weighted_resize_runtime.gd @@ -0,0 +1,188 @@ +extends SceneTree +const Preview = preload("res://scripts/construction/facility_resize_preview.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var world := WorldService.new() + world.reset({&"min_x":-10,&"max_x":10,&"min_y":-5,&"max_y":10}) + var origin := Vector3i(0,-3,0) + var cells := world.cells_for_footprint(origin,Vector2i(3,2)) + world.occupy(&"market",cells);world.occupy(&"heavy",[Vector3i(0,-4,0)]) + world.set_support(origin,10) + for cell: Vector3i in world.cells_for_footprint(origin,Vector2i(4,2)): world.set_excavated(cell,true) + var item := {&"instance_id":&"market",&"definition_id":&"black_market",&"origin":origin,&"rotation":0,&"occupied_cells":cells} + var weights := {&"market":1,&"heavy":3} + expect(world.validate_support(cells,1,weights)[&"ok"], "original occupied structure fits exact capacity") + expect(Preview.inspect(world,item,1)[&"geometry_ok"], "generic occupant weights alone miss the overload") + var before := world.create_snapshot() + var preview := Preview.inspect(world,item,1,weights) + expect(not preview[&"geometry_ok"] and &"TARGET_UNSUPPORTED" in preview[&"errors"], "exact neighboring definition weight rejects overloaded expansion") + expect(world.create_snapshot()==before, "weighted validation does not mutate world") + expect(not world.validate_support(cells,1,{&"missing":2})[&"ok"] and not world.validate_support(cells,1,{&"heavy":-1})[&"ok"], "unknown and negative weight overrides reject") + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,424242);clock.set_paused(true) + var paths: Array[String] = FacilityCatalog.FACILITY_PATHS + FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS + FacilityCatalog.RESCUE_FACILITY_PATHS + paths.append("res://tests/fixtures/legacy_premium_room.tres") + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"], "fixture explicitly registers archived legacy-size definition") + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"reception",&"origin":Vector3i(-6,0,0)}).is_success(), "build supporting reception at ground level") + clock.advance_minutes(120) + var built: CommandResult = runtime.submit_command(&"build_facility",{&"definition_id":&"premium_room",&"origin":Vector3i(-6,2,0)}) + expect(built.is_success(), "build above-ground old-size room: " + built.message) + if not built.is_success(): + runtime.free();clock.free();state.free();quit(failures);return + var active_id: StringName = &"" + for job: ConstructionJob in runtime.construction_service.get_jobs(): + if job.definition_id == &"premium_room" and not job.is_terminal(): active_id = job.job_id + paths.erase("res://tests/fixtures/legacy_premium_room.tres") + paths.append("res://data/facilities/expansion/premium_room.tres") + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"], "current original-size registry replaces legacy fixture after reservation") + clock.advance_minutes(7) + var active_before: Dictionary = runtime.create_snapshot() + var original_job: Dictionary = runtime.construction_service.get_job(active_id).create_snapshot() + var active_path := "user://saves/active_resize_%d" % Time.get_ticks_usec() + expect(SaveService.save_snapshot(active_path,active_before)[&"ok"], "save partially built legacy job") + var active_migration: Dictionary=runtime.migrate_facility_dimensions_at_path(active_path) + expect(active_migration[&"ok"] and active_migration.get(&"backup_created",false), "batch migrates partial job with verified backup") + var migrated_job_save := SaveService.load_snapshot(active_path) + expect(migrated_job_save[&"ok"] and runtime.restore_snapshot(migrated_job_save[&"snapshot"]), "full Runtime restores migrated construction reservation") + var current_job: ConstructionJob=runtime.construction_service.get_job(active_id) + expect(current_job.reserved_cells.size()==4 and current_job.progress==original_job[&"progress"] and current_job.construction_cost==original_job[&"construction_cost"], "full Runtime migration preserves job progress and paid cost") + clock.advance_minutes(150) + var finished_job: ConstructionJob=runtime.construction_service.get_job(active_id) + expect(finished_job.state==ConstructionJob.State.COMPLETED and runtime.facility_service.get_instance(finished_job.facility_instance_id).occupied_cells.size()==4, "migrated partial job completes as original-size operational room") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "completion after full job migration remains saveable") + expect(SaveService.load_snapshot(active_path+SaveService.MIGRATION_BACKUP_SUFFIX)[&"snapshot"]==active_before and runtime.restore_snapshot(active_before), "original job backup remains intact and legacy fixture can resume") + SaveService.delete_save(active_path);SaveService.delete_save(active_path+SaveService.MIGRATION_BACKUP_SUFFIX) + var candidate := Preview.prepare_construction_candidate(runtime.world_service, runtime.construction_service, active_id, 2) + expect(candidate[&"ok"] and not candidate[&"ready_to_apply"], "active construction has a validated candidate, not a Runtime commit") + if candidate[&"ok"]: + var active_row: Dictionary = {} + for row: Dictionary in candidate[&"construction"][&"jobs"]: + if StringName(row[&"job_id"]) == active_id: active_row = row + expect(active_row[&"reserved_cells"].size() == 4 and active_row[&"progress"] == original_job[&"progress"] and active_row[&"progress"] > 0, "partially built job converts without advancing paid work") + var resumed_world := WorldService.new() + expect(resumed_world.restore_snapshot(candidate[&"world"]), "candidate world restores for partial-job continuation") + var resumed := ConstructionService.new(resumed_world) + expect(resumed.restore_snapshot(candidate[&"construction"]), "partial job restores through production validator") + var job_before := resumed.get_job(active_id).create_snapshot() + var replay := resumed.progress_job(active_id, ConstructionJob.NORMAL_WORK_RATE, resumed.current_minute) + expect(replay.is_success() and replay.is_replay and resumed.get_job(active_id).create_snapshot() == job_before, "last saved construction minute is not charged as work twice") + var completion_count := [0] + resumed.facility_completed.connect(func(_facility: Dictionary): completion_count[0] += 1) + for minute in range(resumed.current_minute + 1, resumed.current_minute + 201): resumed.advance_one_minute(minute) + var finished := resumed.get_job(active_id) + expect(finished.state == ConstructionJob.State.COMPLETED and completion_count[0] == 1, "resized partial job completes exactly once") + var finished_room := resumed.get_facility(finished.facility_instance_id) + expect(finished_room[&"occupied_cells"].size() == 4 and resumed_world.get_entity_cells(finished.facility_instance_id) == finished_room[&"occupied_cells"], "completion occupies original-spec footprint with new facility ownership") + expect(finished.construction_cost == original_job[&"construction_cost"] and finished.build_minutes == original_job[&"build_minutes"] and finished.started_at == original_job[&"started_at"], "migration and completion retain paid cost, duration and start time") + var completed_restore := ConstructionService.new(resumed_world) + expect(completed_restore.restore_snapshot(resumed.create_snapshot()), "completed migrated source job and facility remain save-compatible") + expect(runtime.create_snapshot() == active_before, "active candidate leaves source world, jobs and money unchanged") + clock.advance_minutes(150) + var id: StringName = &"" + for facility: Dictionary in runtime.construction_service.get_facilities(): + if facility[&"definition_id"] == &"premium_room": id = facility[&"instance_id"] + expect(not id.is_empty(), "above-ground expansion completes") + if id.is_empty(): + runtime.free();clock.free();state.free();quit(failures);return + var full_before: Dictionary = runtime.create_snapshot() + var disk_path := "user://saves/resize_disk_%d" % Time.get_ticks_usec() + expect(SaveService.save_snapshot(disk_path, full_before)[&"ok"], "write isolated legacy-size disk fixture") + var disk_migration: Dictionary = runtime.migrate_facility_dimensions_at_path(disk_path, id) + expect(disk_migration[&"ok"] and disk_migration.get(&"backup_created",false), "disk migration verifies candidate and creates backup before promotion") + var disk_loaded := SaveService.load_snapshot(disk_path) + var disk_backup := SaveService.load_snapshot(disk_path + SaveService.MIGRATION_BACKUP_SUFFIX) + expect(disk_backup[&"ok"] and disk_backup[&"snapshot"] == full_before, "pre-migration disk backup retains original full snapshot") + var backup_bytes := FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(disk_path + SaveService.MIGRATION_BACKUP_SUFFIX + "/world.bin")) + expect(runtime.migrate_facility_dimensions_at_path(disk_path,id)[&"ok"] and FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(disk_path + SaveService.MIGRATION_BACKUP_SUFFIX + "/world.bin")) == backup_bytes, "repeated migration preserves original backup") + var alternate: Dictionary = full_before.duplicate(true) + alternate[&"clock"][&"absolute_minute"] += 1 + var stale := SaveService.replace_snapshot_with_backup(disk_path, full_before, alternate) + expect(not stale[&"ok"] and stale[&"code"] == &"MIGRATION_SOURCE_CHANGED" and SaveService.load_snapshot(disk_path)[&"snapshot"] == disk_loaded[&"snapshot"], "stale source rejects before overwriting migrated save") + var rejected_invalid := SaveService.replace_snapshot_with_backup(disk_path, disk_loaded[&"snapshot"], {&"invalid":true}) + expect(not rejected_invalid[&"ok"] and rejected_invalid[&"code"] == &"MIGRATION_INVALID_SNAPSHOT" and SaveService.load_snapshot(disk_path)[&"snapshot"] == disk_loaded[&"snapshot"], "invalid candidate preserves formal package") + expect(FileAccess.get_file_as_bytes(ProjectSettings.globalize_path(disk_path + SaveService.MIGRATION_BACKUP_SUFFIX + "/world.bin")) == backup_bytes, "failed migration preserves original backup bytes") + expect(runtime.create_snapshot() == full_before, "disk migration does not alter live Runtime") + var full_candidate: Dictionary = runtime.prepare_facility_dimension_snapshot(id) + expect(full_candidate[&"ok"] and runtime.create_snapshot() == full_before, "full snapshot candidate preserves live Runtime") + var isolated: Variant = load("res://autoload/simulation_runtime.gd").new() + var isolated_clock: Variant = load("res://autoload/game_clock.gd").new() + var isolated_state: Variant = load("res://autoload/game_state.gd").new() + isolated_clock.reset(1,6,0);isolated_state.reset();isolated.initialize(isolated_clock,isolated_state,424242) + isolated.definition_registry.rebuild_from_paths(paths) + expect(isolated.restore_snapshot(full_candidate[&"snapshot"]), "full migrated snapshot passes Runtime crosslinks in isolated instance") + expect(isolated.restore_snapshot(disk_loaded[&"snapshot"]) and isolated.facility_service.get_instance(id).occupied_cells.size() == 4, "migrated disk package restores original footprint in separate Runtime") + SaveService.delete_save(disk_path) + SaveService.delete_save(disk_path + SaveService.MIGRATION_BACKUP_SUFFIX) + expect(isolated.facility_service.get_instance(id).occupied_cells.size() == 4, "operating facility restores original-spec footprint") + var route_report := preload("res://scripts/construction/facility_resize_routes.gd").inspect(runtime, isolated, id) + expect(route_report[&"ok"] and route_report[&"checked_routes"] == 1, "isolated Runtime route comparison preserves existing entrance reachability") + # Controlled topology fixture for the route comparator, separate from the + # valid full-snapshot acceptance above. Upper-floor-only access is lost. + var old_graph: Array[Vector3i] = [Vector3i(-9,3,0), Vector3i(-8,3,0), Vector3i(-7,3,0)] + runtime.path_service.rebuild_graph(old_graph); isolated.path_service.rebuild_graph(old_graph) + var guest_type: StringName = runtime.get_mvp_guest_definition_ids()[0] + var observer := GuestRuntime.new().configure(&"route_observer", guest_type, {&"current_cell":old_graph[0], &"state":GuestRuntime.State.MOVING, &"target_facility_id":id, &"path_handle":{&"target_cell":old_graph[-1]}}) + expect(runtime.guest_service.register_guest(observer, false) and isolated.guest_service.register_guest(GuestRuntime.from_snapshot(observer.create_snapshot()), false), "route fixture registers matching moving guests") + route_report = preload("res://scripts/construction/facility_resize_routes.gd").inspect(runtime, isolated, id) + expect(not route_report[&"ok"] and route_report[&"errors"].any(func(error: Variant): return error is Dictionary and error[&"code"] == &"GUEST_ACCESS_LOST"), "shrinking upper row detects loss of guest access even with unchanged path graph") + # A replacement lower access remains reachable, but saved target needs update. + old_graph.append_array([Vector3i(-9,2,0),Vector3i(-8,2,0),Vector3i(-7,2,0)]) + runtime.path_service.rebuild_graph(old_graph);isolated.path_service.rebuild_graph(old_graph) + expect(runtime.path_service.add_connector(&"resize_stair",Vector3i(-9,3,0),Vector3i(-9,2,0)) and isolated.path_service.add_connector(&"resize_stair",Vector3i(-9,3,0),Vector3i(-9,2,0)), "fixture explicitly connects floors through a vertical connector") + route_report = preload("res://scripts/construction/facility_resize_routes.gd").inspect(runtime, isolated, id) + expect(route_report[&"errors"].is_empty() and route_report[&"guest_replans"].size() == 1 and not route_report[&"ok"], "reachable replacement still requires explicit moving-guest retargeting") + var source_guest: Dictionary = runtime.guest_service.get_guest(&"route_observer").create_snapshot() + var candidate_guest: Dictionary = isolated.guest_service.get_guest(&"route_observer").create_snapshot() + var invalid_batch: Dictionary = route_report.duplicate(true) + invalid_batch[&"guest_replans"].append({&"id":&"missing", &"old_target":Vector3i.ZERO, &"target":Vector3i.ZERO}) + var rejected := preload("res://scripts/construction/facility_resize_routes.gd").retarget_guests(isolated, invalid_batch) + expect(not rejected[&"ok"] and isolated.guest_service.get_guest(&"route_observer").create_snapshot() == candidate_guest, "invalid later replan leaves earlier guest unchanged") + var applied := preload("res://scripts/construction/facility_resize_routes.gd").retarget_guests(isolated, route_report) + expect(applied[&"ok"] and applied[&"changed"] == [&"route_observer"], "retarget batch applies reachable entrance to isolated guest") + var after_guest: Dictionary = isolated.guest_service.get_guest(&"route_observer").create_snapshot() + after_guest[&"path_handle"] = candidate_guest[&"path_handle"] + expect(after_guest == candidate_guest and runtime.guest_service.get_guest(&"route_observer").create_snapshot() == source_guest, "retarget preserves position, wallet, service state and source guest") + expect(not preload("res://scripts/construction/facility_resize_routes.gd").retarget_guests(isolated, route_report)[&"ok"], "stale original destination cannot overwrite an already updated route") + route_report = preload("res://scripts/construction/facility_resize_routes.gd").inspect(runtime, isolated, id) + expect(route_report[&"ok"], "corrected moving target passes route comparison") + var moving: GuestRuntime = isolated.guest_service.get_guest(&"route_observer") + var actor_id: StringName = isolated._guest_path_actor_id(moving.guest_id) + expect(isolated.path_service.project_actor_position(actor_id, moving.current_cell, isolated_clock.absolute_minute), "project candidate guest for actual movement") + for offset in range(1, 21): + isolated._advance_guest_path_travel(moving, actor_id, isolated_clock.absolute_minute + offset) + if moving.current_cell.y == 2: break + expect(moving.current_cell == Vector3i(-9,2,0), "production movement follows retargeted lower entrance instead of old upper entrance") + expect(runtime.restore_snapshot(full_before), "restore valid source after controlled topology fixture") + isolated.free();isolated_clock.free();isolated_state.free() + expect(runtime.submit_command(&"register_staff",{&"staff_id":&"resize_observer",&"role_id":&"clerk",&"options":{&"current_cell":Vector3i.ZERO}}).is_success(), "register diagnostic worker on an existing corridor") + # Diagnostic legacy-position input only; no claim of normal navigation to an + # upper decorative cell. Preview must report it, never relocate it silently. + runtime.staff_service.get_staff(&"resize_observer").current_cell = Vector3i(-6,3,0) + expect(not runtime.prepare_facility_dimension_snapshot(id)[&"ok"], "removed-cell actor requires explicit relocation before preparing full snapshot") + var completed_before: Dictionary = runtime.create_snapshot() + candidate = Preview.prepare_construction_candidate(runtime.world_service, runtime.construction_service, id, 2) + expect(candidate[&"ok"], "completed facility and source job migrate together") + if candidate[&"ok"]: + var migrated_world := WorldService.new() + migrated_world.restore_snapshot(candidate[&"world"]) + var migrated := ConstructionService.new(migrated_world) + expect(migrated.restore_snapshot(candidate[&"construction"]) and migrated.get_facility(id)[&"occupied_cells"].size() == 4, "candidate restores under production construction crosslink validation") + var retry := Preview.prepare_construction_candidate(migrated_world, migrated, id, 2) + expect(retry[&"ok"] and retry[&"construction"] == candidate[&"construction"] and retry[&"world"] == candidate[&"world"], "already migrated candidate is idempotent") + expect(runtime.create_snapshot() == completed_before, "completed candidate leaves source state untouched") + var snapshot: Dictionary = runtime.create_snapshot() + preview=runtime.preview_facility_dimension_migration(id) + expect(preview[&"construction_candidate"][&"ok"], "Runtime preview exposes validated construction candidate") + expect(preview[&"target_cells"].size()==4 and preview[&"definition_weights"][id]==2, "Runtime preview uses original dimension and live definition weight") + expect(preview[&"actors_on_removed_cells"].size()==1 and preview[&"actors_on_removed_cells"][0][&"id"]==&"resize_observer", "Runtime preview identifies actual worker requiring relocation") + expect(not preview[&"ready_to_apply"] and runtime.create_snapshot()==snapshot, "Runtime preview preserves all authoritative simulation state") + expect(not runtime.preview_facility_dimension_migration(&"missing")[&"ready_to_apply"], "unknown completed facility rejected") + runtime.free();clock.free();state.free() + print("Weighted resize Runtime: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/s1/test_weighted_resize_runtime.gd.uid b/tests/s1/test_weighted_resize_runtime.gd.uid new file mode 100644 index 0000000..a5440ce --- /dev/null +++ b/tests/s1/test_weighted_resize_runtime.gd.uid @@ -0,0 +1 @@ +uid://buaihdar1qamn diff --git a/tests/s2/test_facility_service.gd b/tests/s2/test_facility_service.gd index 3535733..ea22aab 100644 --- a/tests/s2/test_facility_service.gd +++ b/tests/s2/test_facility_service.gd @@ -41,7 +41,8 @@ func _test_state_priority_and_opening_hours() -> void: &"degraded_reasons": [&"DIRTY"], }, 600) _expect(facility.state == FacilityInstanceType.State.BROKEN, "BROKEN wins over every other state") - _expect(facility.state_reasons.size() == 6, "all simultaneous state reasons are retained") + _expect(facility.state_reasons.size() == 7, "all simultaneous state reasons are retained") + facility = _facility(&"undamaged_room", 1, 4, 1, 2) facility.set_operational_conditions({ &"durability": 5_000, &"access_available": false, @@ -50,7 +51,7 @@ func _test_state_priority_and_opening_hours() -> void: &"active_staff_ids": [], &"degraded_reasons": [], }, 600) - _expect(facility.state == FacilityInstanceType.State.NO_ACCESS, "NO_ACCESS wins after repair") + _expect(facility.state == FacilityInstanceType.State.NO_ACCESS, "NO_ACCESS wins in an undamaged facility") facility.set_operational_conditions({&"access_available": true}, 600) _expect(facility.state == FacilityInstanceType.State.NO_POWER, "NO_POWER wins over staffing and closure") facility.set_operational_conditions({&"power_available": true}, 600) diff --git a/tests/s2/test_facility_upkeep_accrual.gd b/tests/s2/test_facility_upkeep_accrual.gd new file mode 100644 index 0000000..0af03c3 --- /dev/null +++ b/tests/s2/test_facility_upkeep_accrual.gd @@ -0,0 +1,89 @@ +extends SceneTree + +const Accrual = preload("res://scripts/economy/facility_upkeep_accrual.gd") +var checks := 0 +var failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func expect(value: bool, label: String) -> void: + checks += 1 + if not value: + failures += 1 + push_error(label) + +func _run() -> void: + var service := Accrual.new() + expect(not service.advance_to(1, {}).ok, "an explicit starting minute is required; no retroactive fees") + expect(service.start_at(360).ok, "start at actual migration/start time") + expect(not service.start_at(0).ok, "starting twice cannot wipe accumulated charges") + service.advance_to(720, {&"room": 24, &"empty": 0}) + expect(service.get_report().pending_facility_units == {"room": 8640}, "six hours at 24/day accrues exactly six gold") + service.advance_to(1080, {&"room": 24, &"new_room": 48}) + var checkpoint := service.create_snapshot() + var restored := Accrual.new() + expect(restored.restore_snapshot(checkpoint), "partial day can be restored") + # The absent room has been demolished; its already accrued expense remains. + var close := service.advance_to(1440, {&"new_room": 48}) + restored.advance_to(1440, {&"new_room": 48}) + expect(close.closed_periods.size() == 1, "00:00 closes exactly one period") + var period: Dictionary = close.closed_periods[0] + expect(period.period_start == 360 and period.period_end == 1440, "first partial day never bills time before initialization") + expect(period.facility_units == {"new_room": 34560, "room": 17280}, "demolition stops future accrual but preserves incurred costs") + expect(service.create_snapshot() == restored.create_snapshot(), "save/load preserves exact fractional accumulation") + var before := service.create_snapshot() + expect(service.advance_to(1440, {&"new_room": 48}).ok and service.create_snapshot() == before, "same-minute replay cannot charge twice") + expect(not service.advance_to(1439, {}).ok and service.create_snapshot() == before, "rewind rejected without mutation") + close.closed_periods[0].facility_units.room = 999 + expect(service.create_snapshot() == before, "returned period cannot mutate authoritative accrual") + var report := service.get_report() + report.closed_periods.clear() + expect(service.create_snapshot() == before, "report is a detached copy") + for rates: Dictionary in [{"bad": -1}, {"bad": 0.5}, {"bad": true}, {"": 1}, {"bad": Accrual.MAX_DAILY_RATE + 1}, {1: 5}]: + expect(not service.advance_to(1441, rates).ok and service.create_snapshot() == before, "invalid rates reject atomically: %s" % str(rates)) + expect(not service.advance_to(2881, {}).ok and service.create_snapshot() == before, "unbounded batch rejected") + # Integer numerator is retained; neither per-minute nor per-day truncation. + var fractional := Accrual.new() + fractional.start_at(1439) + fractional.advance_to(1441, {"one_gold_per_day": 1}) + var fraction_report := fractional.get_report() + expect(fraction_report.closed_periods[0].facility_units.one_gold_per_day == 1, "one minute charge is not rounded to zero") + expect(fraction_report.pending_facility_units.one_gold_per_day == 1, "midnight split conserves the second minute") + expect(fraction_report.gold_denominator == 1440, "settlement receives exact monetary unit denominator") + var split := Accrual.new() + var batched := Accrual.new() + split.start_at(0) + batched.start_at(0) + for minute in range(1, 1441): + split.advance_to(minute, {"b": 17, "a": 5}) + batched.advance_to(1440, {"a": 5, "b": 17}) + expect(split.create_snapshot() == batched.create_snapshot(), "input order and batching cannot change charges") + var json_copy: Dictionary = JSON.parse_string(JSON.stringify(split.create_snapshot())) + # JSON.parse_string produces floats; the project's snapshot codec handles + # integer preservation. Do not accept lossy/unvalidated raw JSON as a save. + expect(not restored.restore_snapshot(json_copy), "raw floating JSON fields are not silently accepted as integers") + var valid := split.create_snapshot() + var fresh := Accrual.new() + expect(fresh.restore_snapshot(valid), "closed periods restore") + for change: Dictionary in [ + {"version": 2}, {"current_minute": 1439}, {"started_at": -2}, + {"period_start": 1441}, {"extra": 1}, {"pending_units": {"room": 1}}, + {"closed_periods": []}, {"current_minute": true}, + ]: + var invalid := valid.duplicate(true) + invalid.merge(change, true) + expect(not fresh.restore_snapshot(invalid) and fresh.create_snapshot() == valid, "invalid save rejected atomically: %s" % str(change)) + var invalid := valid.duplicate(true) + invalid.closed_periods.append(invalid.closed_periods[0].duplicate(true)) + expect(not fresh.restore_snapshot(invalid) and fresh.create_snapshot() == valid, "duplicated financial period rejected") + invalid = valid.duplicate(true) + invalid.closed_periods[0].facility_units.a = Accrual.MAX_DAILY_RATE * 1440 + 1 + expect(not fresh.restore_snapshot(invalid), "impossible stored amount rejected") + invalid = valid.duplicate(true) + invalid.closed_periods[0].period_start = 1 + expect(not fresh.restore_snapshot(invalid), "period gap rejected") + var empty := Accrual.new() + expect(fresh.restore_snapshot(empty.create_snapshot()), "unstarted state can restore") + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/s2/test_facility_upkeep_accrual.gd.uid b/tests/s2/test_facility_upkeep_accrual.gd.uid new file mode 100644 index 0000000..464b5df --- /dev/null +++ b/tests/s2/test_facility_upkeep_accrual.gd.uid @@ -0,0 +1 @@ +uid://bnxcvawdha8tw diff --git a/tests/s2/test_facility_upkeep_settlement.gd b/tests/s2/test_facility_upkeep_settlement.gd new file mode 100644 index 0000000..ca1a312 --- /dev/null +++ b/tests/s2/test_facility_upkeep_settlement.gd @@ -0,0 +1,128 @@ +extends SceneTree + +const Accrual = preload("res://scripts/economy/facility_upkeep_accrual.gd") +const Settlement = preload("res://scripts/economy/facility_upkeep_settlement.gd") +var checks := 0 +var failures := 0 + +class RejectingEconomy: + extends EconomyService + func post(_entries: Array, _key: StringName = &"", _transaction: StringName = &"", _notify := true) -> CommandResult: + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "injected refusal") + +func _initialize() -> void: + call_deferred("_run") + +func expect(value: bool, label: String) -> void: + checks += 1 + if not value: + failures += 1 + push_error(label) + +func _economy(cash: int) -> EconomyService: + var economy := EconomyService.new({&"PLAYER_CASH": false}) + if cash > 0: + economy.initialize_account(&"PLAYER_CASH", cash) + expect(Settlement.prepare_accounts(economy), "prepare designated expense/payable accounts") + return economy + +func _period(start: int, units: Dictionary) -> Dictionary: + return {&"period_start": start, &"period_end": (start / 1440 + 1) * 1440, &"facility_units": units} + +func _run() -> void: + var economy := _economy(5) + var service := Settlement.new() + var period := _period(0, {"a": 10 * 1440, "b": 2 * 1440 + 10}) + var first := service.settle(period, economy) + expect(first.ok and not first.replayed, "first midnight settles") + expect(economy.get_balance(Settlement.CASH) == 0, "cash cannot become negative") + expect(economy.get_balance(Settlement.EXPENSE) == 12, "expense includes unpaid amount") + expect(economy.get_balance(Settlement.PAYABLE) == -7, "seven gold remains payable") + expect(service.get_report().arrears_by_facility == {"a": 5, "b": 2}, "insufficient funds allocate deterministically by facility ID") + expect(service.get_report().fraction_units == {"b": 10}, "sub-gold expense is retained") + expect(economy.has_pending_transaction_notification(first.transaction_id), "notification is deferred to outer transaction commit") + expect(economy.commit_transaction_notification(first.transaction_id), "notification can be published after state commit") + var guard := economy.create_snapshot() + var snapshot := service.create_snapshot() + var replay := service.settle(period, economy) + expect(replay.ok and replay.replayed and replay.transaction_id == &"", "same settlement is idempotent and emits no notification") + expect(economy.create_snapshot() == guard and service.create_snapshot() == snapshot, "replay changes neither balances nor receipts") + var wrong := period.duplicate(true) + wrong.facility_units.a += 1 + expect(not service.settle(wrong, economy).ok and economy.create_snapshot() == guard, "changed request cannot reuse a closed period") + expect(not service.settle(_period(2880, {}), economy).ok, "missing financial day rejected") + # Facility a has been demolished; its debt must still be paid next midnight. + economy.initialize_account(Settlement.CASH, 20, 1500, &"cash.for.next.day") + var second := service.settle(_period(1440, {"b": 1430}), economy) + expect(second.ok, "following day settles including prior arrears") + expect(service.get_arrears_through(1439) == 0, "no historical debt before first settlement") + expect(service.get_arrears_through(1440) == 7, "historical close retains its original debt after repayment") + expect(service.get_arrears_through(2879) == 7, "interim query uses the last completed settlement") + expect(service.get_arrears_through(2880) == 0, "repayment boundary clears current debt") + expect(service.get_arrears_through(9999) == 0, "future query does not invent debt") + expect(economy.get_balance(Settlement.EXPENSE) == 13, "prior debt repayment is not a new expense") + expect(economy.get_balance(Settlement.CASH) == 12 and economy.get_balance(Settlement.PAYABLE) == 0, "eight gold pays debt and new fractional carry") + expect(service.get_report().arrears_total == 0 and service.get_report().fraction_units.is_empty(), "debt and fractions cleared after payment") + expect(service.validate_ledger(economy) and economy.validate_ledger().ok, "service and double-entry ledger reconcile") + var expense_entries := 0 + var payment_entries := 0 + for entry: LedgerEntry in economy.get_entries(): + if entry.category == &"MAINTENANCE_ACCRUAL": + expense_entries += 1 + expect(entry.absolute_minute in [1439, 2879], "expense belongs to the day being closed") + if entry.category == &"MAINTENANCE_PAYMENT": + payment_entries += 1 + expect(entry.absolute_minute in [1440, 2880], "cash payment records actual midnight") + expect(expense_entries == 3 and payment_entries == 3, "only positive recognized expenses and payments produce entries") + var loaded_economy := EconomyService.new() + expect(loaded_economy.restore_snapshot(economy.create_snapshot()), "ledger restores") + var loaded := Settlement.new() + expect(loaded.restore_snapshot(service.create_snapshot(), loaded_economy), "receipts restore with ledger crosschecks") + expect(loaded.get_report() == service.get_report(), "derived debt/fractions rebuild exactly") + var future := _period(2880, {"new": 3 * 1440}) + expect(service.settle(future, economy).ok and loaded.settle(future, loaded_economy).ok, "both timelines advance") + expect(service.create_snapshot() == loaded.create_snapshot() and economy.create_snapshot() == loaded_economy.create_snapshot(), "same future after reload") + var valid := loaded.create_snapshot() + var invalid := valid.duplicate(true) + invalid.receipts[0].rows[0].new_expense += 1 + expect(not loaded.restore_snapshot(invalid, loaded_economy) and loaded.create_snapshot() == valid, "forged expense rejected atomically") + invalid = valid.duplicate(true) + invalid.receipts[0].rows[0].paid = true + expect(not loaded.restore_snapshot(invalid, loaded_economy), "boolean amount rejected") + invalid = valid.duplicate(true) + invalid.receipts[0].transaction_id = "missing" + expect(not loaded.restore_snapshot(invalid, loaded_economy), "missing ledger transaction rejected") + invalid = valid.duplicate(true) + invalid.receipts[0].period.facility_units.a += 1 + expect(not loaded.restore_snapshot(invalid, loaded_economy), "forged numerator and unchanged receipt rejected") + invalid = valid.duplicate(true) + invalid.receipts.pop_front() + expect(not loaded.restore_snapshot(invalid, loaded_economy), "omitting settled debt history rejected by ledger ownership") + invalid = valid.duplicate(true) + invalid.version = 2 + expect(not loaded.restore_snapshot(invalid, loaded_economy), "unknown save version rejected") + var unrelated := _economy(100) + expect(not loaded.restore_snapshot(valid, unrelated), "receipts cannot be restored onto unrelated money") + var rejecting := RejectingEconomy.new({Settlement.CASH: false}) + expect(Settlement.prepare_accounts(rejecting), "prepare rejecting ledger") + var failed_service := Settlement.new() + var before_service := failed_service.create_snapshot() + var before_ledger := rejecting.create_snapshot() + expect(not failed_service.settle(_period(0, {"room": 1440}), rejecting).ok, "ledger failure propagates") + expect(failed_service.create_snapshot() == before_service and rejecting.create_snapshot() == before_ledger, "failed ledger post leaves all authoritative state unchanged") + var empty := Settlement.new() + var no_cash := _economy(0) + expect(empty.settle(_period(0, {}), no_cash).ok, "empty day requires no zero-value ledger entry") + var empty_loaded := Settlement.new() + expect(empty_loaded.restore_snapshot(empty.create_snapshot(), no_cash), "empty settled day persists") + # End-to-end cost math at completion/demolition boundaries with real ledger. + var accrual := Accrual.new() + accrual.start_at(360) + accrual.advance_to(720, {}) + accrual.advance_to(1080, {"built_at_noon": 24}) + var closed: Dictionary = accrual.advance_to(1440, {}).closed_periods[0] + var life := Settlement.new() + var life_cash := _economy(100) + expect(life.settle(closed, life_cash).ok and life_cash.get_balance(Settlement.EXPENSE) == 6, "six-hour completed ownership charges six gold after demolition") + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/s2/test_facility_upkeep_settlement.gd.uid b/tests/s2/test_facility_upkeep_settlement.gd.uid new file mode 100644 index 0000000..ffcd2db --- /dev/null +++ b/tests/s2/test_facility_upkeep_settlement.gd.uid @@ -0,0 +1 @@ +uid://bjqlm2h0tv05v diff --git a/tests/s2/test_kitchen_production.gd b/tests/s2/test_kitchen_production.gd index c8b5a31..c2714b0 100644 --- a/tests/s2/test_kitchen_production.gd +++ b/tests/s2/test_kitchen_production.gd @@ -123,7 +123,6 @@ func _test_interruptions() -> void: {&"off": {&"power_available": false}, &"on": {&"power_available": true}, &"state": &"NO_POWER"}, {&"off": {&"access_available": false}, &"on": {&"access_available": true}, &"state": &"NO_ACCESS"}, {&"off": {&"operator_open": false}, &"on": {&"operator_open": true}, &"state": &"CLOSED"}, - {&"off": {&"durability": 0}, &"on": {&"durability": 10000}, &"state": &"BROKEN"}, ] for item: Dictionary in cases: facilities.set_operational_status(&"kitchen.one", item[&"off"], kitchen.current_minute) @@ -138,6 +137,11 @@ func _test_interruptions() -> void: kitchen.set_plan(&"kitchen.one", true, 4) _tick(kitchen, 10) _expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "resuming earns only missing operating minutes") + facilities.set_operational_status(&"kitchen.one", {&"durability": 0}, kitchen.current_minute) + _expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"BROKEN", "destroyed kitchen stops production") + facilities.set_operational_status(&"kitchen.one", {&"durability": 10000}, kitchen.current_minute) + _expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"SUSPENDED", "repair does not invent suspension recovery") + _expect(not facilities.get_instance(&"kitchen.one").allows_logistics(), "suspended kitchen cannot supply deliveries") func _test_expiry_and_retry() -> void: diff --git a/tests/s2/test_mana_power_projection.gd b/tests/s2/test_mana_power_projection.gd new file mode 100644 index 0000000..d43ee75 --- /dev/null +++ b/tests/s2/test_mana_power_projection.gd @@ -0,0 +1,43 @@ +extends SceneTree +const PowerProjector = preload("res://scripts/mana/mana_power_projection.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _initialize() -> void: + var facilities := FacilityService.new() + var network := ManaNetworkService.new() + for id: StringName in [&"zero_cost", &"powered", &"damaged", &"closed"]: + facilities.create_instance({&"instance_id": id, &"definition_id": &"standard_room", &"staff_min": 0}) + facilities.set_operational_status(&"damaged", {&"suspension_reason": &"RAIDED"}, 0) + facilities.set_operational_status(&"closed", {&"operator_open": false}, 0) + network.register_source(&"source", 8) + network.register_consumer(&"powered", 10) + network.connect_nodes(&"source", &"powered") + network.evaluate_now(0) + var result := PowerProjector.apply(facilities, network, 10, 0) + expect(result[&"ok"] and facilities.get_instance(&"powered").degraded_reasons.has(&"MANA_BROWNOUT"), "partial network supply retains brownout reason") + result = PowerProjector.apply(facilities, network, 0, 1) + expect(result[&"ok"] and result[&"changed_facilities"].size() == 4, "zero reserve stops every facility including zero-demand facilities") + for facility: FacilityInstance in facilities.get_instances(): + expect(not facility.power_available and facility.operation_state != FacilityInstance.OperationState.OPERATING, "no facility operates with zero reserve: " + String(facility.instance_id)) + expect(PowerProjector.apply(facilities, network, 0, 1)[&"changed_facilities"].is_empty(), "unchanged projection emits no repeated status changes") + var checkpoint := facilities.create_snapshot() + var restored := FacilityService.new() + expect(restored.restore_snapshot(checkpoint), "power and suspension conditions survive service checkpoint") + PowerProjector.apply(restored, network, 4, 2) + expect(restored.get_instance(&"zero_cost").power_available, "zero-demand facility recovers supply") + expect(restored.get_instance(&"damaged").suspension_reason == &"RAIDED" and restored.get_instance(&"damaged").operation_state == FacilityInstance.OperationState.SUSPENDED, "reserve recovery never releases raid suspension") + expect(not restored.get_instance(&"closed").operator_open, "recovery never reopens player-closed facility") + expect(restored.get_instance(&"powered").degraded_reasons.has(&"MANA_BROWNOUT"), "recovery retains actual network brownout") + network.set_source_output(&"source", 0) + network.evaluate_now(3) + PowerProjector.apply(restored, network, 4, 3) + expect(not restored.get_instance(&"powered").power_available, "positive reserve does not override absent network supply") + var before := restored.create_snapshot() + expect(not PowerProjector.apply(restored, network, -1, 4)[&"ok"] and restored.create_snapshot() == before, "invalid reserve rejected without changes") + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/s2/test_mana_power_projection.gd.uid b/tests/s2/test_mana_power_projection.gd.uid new file mode 100644 index 0000000..d972857 --- /dev/null +++ b/tests/s2/test_mana_power_projection.gd.uid @@ -0,0 +1 @@ +uid://crrblhmc2umm8 diff --git a/tests/s2/test_mana_reserve_balance.gd b/tests/s2/test_mana_reserve_balance.gd new file mode 100644 index 0000000..cd7e76e --- /dev/null +++ b/tests/s2/test_mana_reserve_balance.gd @@ -0,0 +1,56 @@ +extends SceneTree +const Reserve = preload("res://scripts/mana/mana_reserve_balance.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _initialize() -> void: + var reserve = Reserve.new() + expect(not reserve.advance_to(1, 0, 0)[&"ok"], "uninitialized updates rejected") + expect(not reserve.initialize(3, 2, 0)[&"ok"], "invalid initial capacity rejected") + expect(reserve.initialize(7, 10, 360)[&"ok"], "caller supplies all initial values") + var flow: Dictionary = reserve.advance_to(370, 5, 8)[&"flow"] + expect(flow[&"consumed"] == 8 and flow[&"after"] == 4 and flow[&"shortage"] == 0, "generation funds demand before reserve changes") + var saved: Dictionary = reserve.create_snapshot() + expect(reserve.advance_to(370, 5, 8)[&"replayed"] and reserve.create_snapshot() == saved, "same boundary cannot consume twice") + expect(not reserve.advance_to(370, 5, 9)[&"ok"] and reserve.create_snapshot() == saved, "conflicting boundary replay rejected atomically") + flow = reserve.advance_to(380, 0, 6)[&"flow"] + expect(flow[&"consumed"] == 4 and flow[&"shortage"] == 2 and reserve.is_depleted(), "short supply depletes exactly without negative reserve") + var depleted: Dictionary = reserve.create_snapshot() + var resumed = Reserve.new() + expect(resumed.restore_snapshot(depleted) and resumed.is_depleted(), "depletion survives save restore") + flow = resumed.advance_to(390, 3, 0)[&"flow"] + expect(flow[&"after"] == 3 and not resumed.is_depleted(), "supply replenishes stopped reserve") + flow = resumed.advance_to(400, 10, 0)[&"flow"] + expect(flow[&"after"] == 10 and flow[&"overflow"] == 3, "overflow explicitly accounts for capacity cap") + flow = resumed.advance_to(410, 9, 12)[&"flow"] + expect(flow[&"after"] == 7 and flow[&"overflow"] == 0 and flow[&"consumed"] == 12, "full storage does not discard supply needed by same-interval demand") + for bad: Variant in [-1, 1.5, "3", true, Reserve.LIMIT + 1]: + saved = resumed.create_snapshot() + expect(not resumed.advance_to(420, bad, 1)[&"ok"] and resumed.create_snapshot() == saved, "invalid supply rejected without state change: %s" % bad) + var canonical: Dictionary = resumed.create_snapshot() + for field: String in ["amount", "capacity", "minute", "version"]: + var corrupt := canonical.duplicate(true) + corrupt[field] = -1 + expect(not resumed.restore_snapshot(corrupt) and resumed.create_snapshot() == canonical, "corrupt %s rejected atomically" % field) + for field: String in ["consumed", "shortage", "overflow", "after", "before", "to_minute"]: + var corrupt := canonical.duplicate(true) + corrupt[&"last_flow"][field] += 1 + expect(not resumed.restore_snapshot(corrupt) and resumed.create_snapshot() == canonical, "flow conservation tampering rejected: " + field) + var extra := canonical.duplicate(true) + extra[&"unknown"] = 1 + expect(not resumed.restore_snapshot(extra), "unknown schema field rejected") + var restored = Reserve.new() + expect(restored.restore_snapshot(canonical), "valid checkpoint restores") + expect(restored.advance_to(420, 2, 9) == resumed.advance_to(420, 2, 9), "restored next boundary reproduces same flow") + expect(restored.create_snapshot() == resumed.create_snapshot() and restored.is_depleted(), "exact zero depletion and replay state agree") + var limit = Reserve.new() + expect(limit.initialize(Reserve.LIMIT, Reserve.LIMIT, 0)[&"ok"], "upper bound initialization") + flow = limit.advance_to(1, Reserve.LIMIT, 0)[&"flow"] + expect(flow[&"overflow"] == Reserve.LIMIT and limit.get_amount() == Reserve.LIMIT, "large values conserve without wrap") + expect(restored.restore_snapshot(limit.create_snapshot()), "large flow snapshot accepted") + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/s2/test_mana_reserve_balance.gd.uid b/tests/s2/test_mana_reserve_balance.gd.uid new file mode 100644 index 0000000..8f4fdbd --- /dev/null +++ b/tests/s2/test_mana_reserve_balance.gd.uid @@ -0,0 +1 @@ +uid://djywnbowl8qkj diff --git a/tests/s2/test_original_facility_parameters.gd b/tests/s2/test_original_facility_parameters.gd new file mode 100644 index 0000000..7b252a1 --- /dev/null +++ b/tests/s2/test_original_facility_parameters.gd @@ -0,0 +1,72 @@ +extends SceneTree + +var checks := 0 +var failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func expect(value: bool, message: String) -> void: + checks += 1 + if not value: + failures += 1 + push_error(message) + +func _run() -> void: + var service := FacilityService.new() + for id: StringName in [&"a", &"b", &"c"]: + expect(service.create_instance({&"instance_id": id, &"definition_id": &"test", &"capacity": 100, &"customer_capacity": 3, &"queue_capacity": 10, &"base_service_minutes": 10}).is_success(), "register") + var a := service.get_instance(&"a") + expect(service.set_operational_status(&"a", {&"related_facility_id": &"b"}).is_success(), "set dependency") + expect(service.set_operational_status(&"b", {&"related_facility_id": &"c"}).is_success(), "set nested dependency") + expect(not service.set_operational_status(&"c", {&"related_facility_id": &"a"}).is_success(), "reject cycle") + service.set_operational_status(&"c", {&"power_available": false}) + expect(a.operation_state == FacilityInstance.OperationState.INACTIVE and not a.related_facility_operating, "nested dependency checks actual operation") + service.set_operational_status(&"c", {&"power_available": true}) + expect(a.operation_state == FacilityInstance.OperationState.OPERATING, "chain reopens deterministically") + var first := service.create_request(&"first", &"a", [&"g1", &"g2"], &"test", 0) + expect(service.enqueue_request(first).is_success(), "group enqueues") + expect(service.start_next_service(&"a").is_success(), "first group starts") + expect(a.customer_count() == 2 and a.available_slots() == 1, "count customers, not service instances or storage capacity") + var second := service.create_request(&"second", &"a", [&"g3", &"g4"], &"test", 0) + service.enqueue_request(second) + expect(not service.start_next_service(&"a").is_success(), "group cannot exceed simultaneous customer capacity") + expect(a.customer_count() == 2 and a.queue_entries.size() == 1, "waiting customers excluded") + service.set_operational_status(&"c", {&"operator_open": false}) + service.advance_one_minute(1) + var active: ServiceInstance = a.active_services.values()[0] + expect(active.elapsed_minutes == 0, "dependent service pauses without progress") + service.set_operational_status(&"c", {&"operator_open": true}) + service.advance_one_minute(2) + expect(active.elapsed_minutes == 1, "service resumes after dependency operates") + service.set_operational_status(&"a", {&"durability": 0}) + service.set_operational_status(&"a", {&"durability": 10000, &"operator_open": true}) + expect(a.operation_state == FacilityInstance.OperationState.SUSPENDED, "repair or opening does not release destruction suspension") + expect(not service.set_operational_status(&"a", {&"suspension_reason": &""}).is_success(), "reject undefined recovery") + service.advance_one_minute(3) + expect(a.active_services.is_empty(), "suspended facility terminates service") + var before_invalid := service.create_snapshot() + expect(not service.set_operational_status(&"c", {&"related_facility_id": &"missing"}, 1000).is_success() and service.create_snapshot() == before_invalid, "invalid dependency cannot advance facility clock") + service.set_opening_hours(&"c", 60, 120) + expect(not service.get_instance(&"b").related_facility_operating, "opening schedule immediately updates dependents") + var snapshot := service.create_snapshot() + var restored := FacilityService.new() + expect(restored.restore_snapshot(snapshot), "roundtrip") + expect(restored.create_snapshot() == snapshot, "saved data preserved") + expect(restored.get_instance(&"a").operation_state == FacilityInstance.OperationState.SUSPENDED, "suspension survives load") + var bad := snapshot.duplicate(true) + bad[&"instances"][0][&"customer_capacity"] = "3" + expect(not restored.restore_snapshot(bad) and restored.create_snapshot() == snapshot, "invalid capacity rejected atomically") + bad = snapshot.duplicate(true) + bad[&"instances"][2][&"related_facility_id"] = "a" + expect(not restored.restore_snapshot(bad), "saved cycle rejected") + var legacy := FacilityInstance.new().configure(&"legacy", &"warehouse", {&"capacity": 100}) + expect(legacy.customer_capacity == -1, "generic capacity never defines customer maximum") + var quote := FacilityPricing.quote_service(100, {}, &"appetite", [&"appetite"], 25) + expect(quote[&"total"] == 125 and quote[&"resolved"], "matching deficit adds specified surcharge") + quote = FacilityPricing.quote_service(100, {}, &"appetite", [&"relaxation"], 25) + expect(quote[&"total"] == 100 and quote[&"eligible"], "mismatch remains usable without surcharge") + quote = FacilityPricing.quote_service(100, {}, &"appetite", [&"appetite"]) + expect(not quote[&"resolved"], "unspecified surcharge does not invent a rate") + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/s2/test_original_facility_parameters.gd.uid b/tests/s2/test_original_facility_parameters.gd.uid new file mode 100644 index 0000000..16b8faf --- /dev/null +++ b/tests/s2/test_original_facility_parameters.gd.uid @@ -0,0 +1 @@ +uid://clr3yamqu0mbk diff --git a/tests/s2/test_runtime_operations_cycle.gd b/tests/s2/test_runtime_operations_cycle.gd index ba1680b..9fe2789 100644 --- a/tests/s2/test_runtime_operations_cycle.gd +++ b/tests/s2/test_runtime_operations_cycle.gd @@ -173,6 +173,20 @@ func _run_service_sale_with_round_trip() -> void: _expect(active[0].price_snapshot == 25 and active[0].duration_minutes == 3, "service freezes quoted price and duration") var mid_snapshot: Dictionary = _runtime.create_snapshot() + var before_elapsed: int = active[0].elapsed_minutes + var before_wallet: int = _runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") + _state.set_resource(&"mana", 0) + _clock.advance_minutes(5) + var paused_service: ServiceInstance = _runtime.facility_service.get_service(service_id) + _expect(paused_service.state == ServiceInstance.State.PAUSED and paused_service.elapsed_minutes == before_elapsed, "reserve depletion pauses active service without advancing work") + _expect(_runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == before_wallet, "paused service does not charge guest") + _expect(_runtime.inventory_service.total_quantity(_facility_inventory_id, &"ingredient/basic_meal") == 2, "paused service retains reserved ingredients") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "paused service and empty reserve restore together") + _state.set_resource(&"mana", 100) + # A just-built consumer may still await its first network allocation. + _clock.advance_minutes(10 - (_clock.absolute_minute % 10) + 3) + _expect(_runtime.facility_service.get_service(service_id).state == ServiceInstance.State.COMPLETED and _runtime.economy_service.get_balance(&"GUEST_WALLET:guest_1") == before_wallet - 25, "reserve recovery completes and charges once") + _expect(_runtime.restore_snapshot(mid_snapshot), "rewind service outage drill before ordinary replay") var mid_digest := SimulationDigest.compute(mid_snapshot) _clock.advance_minutes(3) var completed: ServiceInstance = _runtime.facility_service.get_service(service_id) diff --git a/tests/s2/test_service_start_slots.gd b/tests/s2/test_service_start_slots.gd new file mode 100644 index 0000000..e514e2a --- /dev/null +++ b/tests/s2/test_service_start_slots.gd @@ -0,0 +1,43 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func fixture(capacity: int, customer_capacity: int) -> FacilityService: + var service := FacilityService.new() + expect(service.create_instance({&"instance_id": &"room", &"definition_id": &"test", &"capacity": capacity, &"customer_capacity": customer_capacity, &"queue_capacity": 10}).is_success(), "create independent service/customer limits") + return service +func enqueue(service: FacilityService, id: StringName, guests: Array[StringName]) -> void: + expect(service.enqueue_request(service.create_request(id,&"room",guests,&"test",0)).is_success(), "queue " + String(id)) +func run() -> void: + for capacity: int in [2,100]: + var service := fixture(capacity,3) + enqueue(service,&"first",[&"a",&"b"]) + enqueue(service,&"second",[&"c"]) + enqueue(service,&"overflow",[&"d"]) + # Explicit priority keeps the intended arrival ordering independent of IDs. + service.get_request(&"first").priority=3 + service.get_request(&"second").priority=2 + var first := service.start_next_service(&"room",0) + expect(first.is_success() and first.details.get(&"slot",-1)==0,"first group uses service slot zero regardless of headcount limit") + var saved := service.create_snapshot() + var second := service.start_next_service(&"room",0) + expect(second.is_success() and second.details.get(&"slot",-1)==1,"second group uses service slot one") + expect(service.get_instance(&"room").customer_count()==3,"two services consume three customer places") + expect(not service.start_next_service(&"room",0).is_success(),"fourth customer still rejected") + expect(service.restore_snapshot(saved),"restore first group checkpoint") + var replay := service.start_next_service(&"room",0) + expect(replay.is_success() and replay.details==second.details,"restored start returns same service and slot") + var nested := fixture(2,3) + enqueue(nested,&"a",[&"a",&"b"]);enqueue(nested,&"b",[&"c"]) + var callback_results: Array[CommandResult] = [] + var on_started := func(started: ServiceInstance) -> void: + if started.request_id==&"a": callback_results.append(nested.start_next_service(&"room",0)) + nested.service_started.connect(on_started) + var outer := nested.start_next_service(&"room",0) + expect(outer.is_success() and outer.details.get(&"slot",-1)==0,"start result is fixed before notification callbacks") + expect(callback_results.size()==1 and callback_results[0].is_success() and callback_results[0].details.get(&"slot",-1)==1,"nested start has its own slot result") + nested.service_started.disconnect(on_started) + print("Service start slots: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/s2/test_service_start_slots.gd.uid b/tests/s2/test_service_start_slots.gd.uid new file mode 100644 index 0000000..36218c1 --- /dev/null +++ b/tests/s2/test_service_start_slots.gd.uid @@ -0,0 +1 @@ +uid://cwshoytei8ktv diff --git a/tests/s3/test_guest_observation_restore_order.gd b/tests/s3/test_guest_observation_restore_order.gd new file mode 100644 index 0000000..0453296 --- /dev/null +++ b/tests/s3/test_guest_observation_restore_order.gd @@ -0,0 +1,113 @@ +extends SceneTree + +# Regression for the observed midpoint restore order difference. No environment variables required. +# Full Runtime saved domain-event history preservation must be verified separately by strict whole-state replay. +const GuestDefinitionType = preload("res://scripts/guest/guest_definition.gd") +const GuestRuntimeType = preload("res://scripts/guest/guest_runtime.gd") +const GuestServiceType = preload("res://scripts/guest/guest_service.gd") +const EXPECTED_NEEDS: Array[StringName] = [ + &"hunger", &"sleep", &"hygiene", &"medical", &"leisure", &"intimacy", +] +const VALUES := {&"hunger": 90, &"sleep": 90, &"hygiene": 10, &"medical": 20, + &"leisure": 90, &"intimacy": 90} +var checks := 0 +var failures := 0 + +func _initialize() -> void: + call_deferred("_run") + +func _run() -> void: + print("Guest observation restore-order regression") + var reversed_order := EXPECTED_NEEDS.duplicate() + reversed_order.reverse() + var reference_observation := PackedByteArray() + var reference_decision := PackedByteArray() + for index: int in range(2): + var order: Array = EXPECTED_NEEDS if index == 0 else reversed_order + var input: Dictionary = {} + for position: int in range(order.size()): + var need: StringName = order[position] + var key: Variant = String(need) if index == 1 and position % 2 == 0 else need + input[key] = VALUES[need] + var service := _service(input) + var guest := service.get_guest(&"observation_guest") + if guest == null: + _expect(false, "valid guest fixture registers") + continue + var raw_before := var_to_bytes(guest.needs) + var snapshot := service.create_snapshot() + var snapshot_bytes := var_to_bytes(snapshot) + var observation := service.get_observation(guest.guest_id) + _expect(observation[&"needs"].keys() == EXPECTED_NEEDS, "public needs order is stable for input %d" % index) + for need: Variant in observation[&"needs"]: + _expect(typeof(need) == TYPE_STRING_NAME and typeof(observation[&"needs"][need]) == TYPE_INT and observation[&"needs"][need] == VALUES[need], + "public need preserves StringName key, int type and exact value") + _expect(var_to_bytes(guest.needs) == raw_before, "observation preserves model input order and values") + _expect(var_to_bytes(service.create_snapshot()) == snapshot_bytes, "observation preserves complete service snapshot") + _expect(observation[&"primary_needs"] == [&"hunger", &"sleep", &"leisure", &"intimacy"], "all maximum ties retain PRIMARY_NEEDS order") + var observation_bytes := var_to_bytes(observation) + if index == 0: + reference_observation = observation_bytes + _expect(observation_bytes == reference_observation, "typed observation agrees across source order and key types") + observation[&"needs"][&"hunger"] = 0 + observation[&"primary_needs"].clear() + _expect(var_to_bytes(guest.needs) == raw_before and var_to_bytes(service.create_snapshot()) == snapshot_bytes, + "caller mutation cannot change live model or saved state") + var restored := GuestServiceType.new() + _expect(restored.restore_snapshot(snapshot), "complete service snapshot restores") + var resumed := restored.get_guest(guest.guest_id) + if resumed == null: + _expect(false, "restored guest remains present") + continue + var saved_model: Dictionary = snapshot[&"guests"][0][&"needs"] + var restored_order: Array[StringName] = [] + for key: Variant in saved_model: + restored_order.append(StringName(key)) + _expect(resumed.needs.keys() == restored_order, "restore retains the snapshot input order, without model normalization") + var resumed_before := var_to_bytes(resumed.needs) + _expect(var_to_bytes(restored.get_observation(guest.guest_id)) == observation_bytes, "before/after restore typed observation is exact") + _expect(var_to_bytes(resumed.needs) == resumed_before and var_to_bytes(restored.create_snapshot()) == snapshot_bytes, + "restored observation does not mutate model or save serialization") + var food := _candidate(&"aa_food", &"hunger") + var room := _candidate(&"zz_room", &"sleep") + var food_score := service.evaluate_candidate(guest.guest_id, food) + var room_score := service.evaluate_candidate(guest.guest_id, room) + for field: StringName in [&"need_priority", &"utility", &"urgency", &"travel_minutes"]: + _expect(food_score[field] == room_score[field], "fixture reaches the final facility ID tie break: " + String(field)) + var uninterrupted := service.decide(guest.guest_id, [room, food], 1) + var replay := restored.decide(guest.guest_id, [food, room], 1) + _expect(bool(uninterrupted.get(&"ok", false)) and uninterrupted.get(&"candidate", {}).get(&"facility_id", &"") == &"aa_food", + "equal need scores use the stable lexical facility ID") + _expect(var_to_bytes(uninterrupted) == var_to_bytes(replay), "restored decision and reversed candidates agree with all typed result fields") + _expect(var_to_bytes(service.create_snapshot()) == var_to_bytes(restored.create_snapshot()), "complete post-decision service snapshots agree") + if index == 0: + reference_decision = var_to_bytes(uninterrupted) + _expect(var_to_bytes(uninterrupted) == reference_decision, "all source orders and key types preserve the full decision") + print("Guest observation restore-order diagnostic: %d checks, %d failures" % [checks, failures]) + quit(0 if failures == 0 else 1) + +func _service(needs: Dictionary) -> GuestService: + var service := GuestServiceType.new() + var definition := GuestDefinitionType.new().configure(&"observation_type", { + &"wealth_min": 1000, &"wealth_max": 1000, &"minimum_quality": 0, + &"risk_tolerance": 100, &"legal_sensitivity": 0, + }) + _expect(service.register_definition(definition), "valid definition registers") + var guest := service.spawn_guest(&"observation_guest", &"observation_type", 0, { + &"state": GuestRuntimeType.State.DECIDING, &"wallet_gold": 1000, + &"planned_exit_at": 1000, &"needs": needs, + }) + _expect(guest != null, "valid guest spawns") + return service + +func _candidate(id: StringName, need: StringName) -> Dictionary: + return {&"facility_id": id, &"is_open": true, &"reachable": true, + &"price_gold": 10, &"wait_minutes": 0, &"travel_minutes": 5, + &"risk_level": 0, &"illegal_level": 0, &"expected_quality": 100, + &"need_relief": {need: 80}} + +func _expect(condition: bool, label: String) -> void: + checks += 1 + if not condition: + failures += 1 + push_error(label) diff --git a/tests/s3/test_guest_observation_restore_order.gd.uid b/tests/s3/test_guest_observation_restore_order.gd.uid new file mode 100644 index 0000000..c6dbeda --- /dev/null +++ b/tests/s3/test_guest_observation_restore_order.gd.uid @@ -0,0 +1 @@ +uid://tdsph6wcj85t diff --git a/tests/s3/test_guest_service.gd b/tests/s3/test_guest_service.gd index ca0640b..eb0e231 100644 --- a/tests/s3/test_guest_service.gd +++ b/tests/s3/test_guest_service.gd @@ -21,6 +21,7 @@ func _run() -> void: _test_batched_need_updates_match_individual_api() _test_candidate_filtering() _test_integer_utility_and_stable_tie_break() + _test_primary_need_priority() _test_state_machine_and_loop_guard() _test_atomic_queue_abandonment() _test_service_interruption_is_idempotent() @@ -776,3 +777,54 @@ func _expect(condition: bool, label: String) -> void: return _failures += 1 push_error(" ✗ %s" % label) + + +func _test_primary_need_priority() -> void: + var service := _service_with_definitions() + var guest := service.spawn_guest(&"need_priority", &"adventurer", 0, { + &"wallet_gold": 1000, &"planned_exit_at": 1000, + &"state": GuestRuntime.State.DECIDING, + &"needs": {&"hunger": 90, &"sleep": 70}, + }) + var food := _candidate(&"food", {&"need_relief": {&"hunger": 80}, &"expected_quality": 40}) + var room := _candidate(&"room", {&"need_relief": {&"sleep": 90}, &"expected_quality": 100, &"preference": 100}) + _expect(service.evaluate_candidate(guest.guest_id, room)[&"utility"] > service.evaluate_candidate(guest.guest_id, food)[&"utility"], "regression scenario has a tempting lower-need facility") + _expect(service.select_best_candidate(guest.guest_id, [room,food])[&"facility_id"] == &"food", "highest need precedes quality and preference bonuses") + var near_food := food.duplicate(true) + near_food[&"facility_id"] = &"near_food" + near_food[&"travel_minutes"] = 1 + food[&"travel_minutes"] = 20 + _expect(service.select_best_candidate(guest.guest_id, [food,near_food])[&"facility_id"] == &"near_food", "same need still compares distance") + for field: StringName in [&"wait_minutes", &"price_burden"]: + var costly := near_food.duplicate(true) + costly[&"facility_id"] = &"costly" + costly[field] = 100 + _expect(service.select_best_candidate(guest.guest_id, [costly,near_food])[&"facility_id"] == &"near_food", "same need still compares queue or price") + var unavailable := food.duplicate(true) + unavailable[&"reachable"] = false + var accepted := service.extract_candidates(guest.guest_id, [unavailable,room]) + _expect(service.select_best_candidate(guest.guest_id, accepted)[&"facility_id"] == &"room", "unreachable highest-need facility does not block a usable alternative") + unavailable[&"reachable"] = true + unavailable[&"price_gold"] = 100000 + accepted = service.extract_candidates(guest.guest_id, [unavailable,room]) + _expect(service.select_best_candidate(guest.guest_id, accepted)[&"facility_id"] == &"room", "highest need does not bypass affordability") + var unattractive := food.duplicate(true) + unattractive[&"travel_minutes"] = 10000 + _expect(service.select_best_candidate(guest.guest_id, [unattractive,room])[&"facility_id"] == &"room", "below-threshold high-need candidate does not stop fallback search") + guest.needs[&"sleep"] = 90 + _expect(service.select_best_candidate(guest.guest_id, [food,room])[&"facility_id"] == &"room", "equal highest needs use the existing utility comparison") + guest.needs[&"sleep"] = 70 + var before := service.create_snapshot() + var observed := service.get_observation(guest.guest_id) + observed[&"needs"][&"hunger"] = 0 + _expect(guest.needs[&"hunger"] == 90 and service.create_snapshot() == before, "observation is read-only and owns its dictionaries") + _expect(service.restore_snapshot(before), "pre-decision save restores") + var replay := service.decide(guest.guest_id, [room,food], 1) + _expect(replay[&"candidate"][&"facility_id"] == &"food", "restored runtime uses the same highest-need decision") + var after := service.create_snapshot() + _expect(service.restore_snapshot(before), "decision can be replayed from snapshot") + service.decide(guest.guest_id, [food,room], 1) + _expect(service.create_snapshot() == after, "candidate insertion order cannot change saved decision state") + _expect(service.restore_snapshot(before), "reservation retry starts at the same state") + var reserved := service.decide(guest.guest_id, [food,room], 1, {&"reserve_callback": func(_id: StringName, facility: StringName, _candidate: Dictionary) -> bool: return facility != &"food"}) + _expect(reserved[&"candidate"][&"facility_id"] == &"room", "failed reservation tries the next usable candidate") diff --git a/tests/s3/test_runtime_path_traffic.gd b/tests/s3/test_runtime_path_traffic.gd index 24768fc..0cec20c 100644 --- a/tests/s3/test_runtime_path_traffic.gd +++ b/tests/s3/test_runtime_path_traffic.gd @@ -150,12 +150,12 @@ func _test_congestion_credit_and_alternate_route() -> void: var after_one: Array[Vector3i] = _guest_cells_with_prefix("slow_") _expect(after_one.all(func(cell: Vector3i) -> bool: return cell == origin), "over-capacity speed credit prevents a first-minute burst") _expect( - int(_runtime.get_actor_movement_credit(&"guest:slow_00").get(&"credit", -1)) == 250, + int(_runtime.get_actor_movement_credit(&"guest:slow_00").get(&"credit", -1)) == 500, "Path speed multiplier is stored as deterministic fixed-point credit" ) - _clock.advance_minutes(3) - var after_four: Array[Vector3i] = _guest_cells_with_prefix("slow_") - _expect(after_four.all(func(cell: Vector3i) -> bool: return cell != origin), "minimum speed guarantees progress by the fourth congested minute") + _clock.advance_minutes(1) + var after_two: Array[Vector3i] = _guest_cells_with_prefix("slow_") + _expect(after_two.all(func(cell: Vector3i) -> bool: return cell != origin), "half speed guarantees progress by the second congested minute") _expect(_runtime.path_service.get_current_density(origin) == 0, "sequential committed movement lowers source density exactly") _reset_runtime(8_103) @@ -318,7 +318,7 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _clock.advance_one_minute() var snapshot: Dictionary = _runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] - _expect(int(runtime_data[&"runtime_schema_version"]) == 9, "Runtime snapshot advertises schema v9") + _expect(int(runtime_data[&"runtime_schema_version"]) == 13, "Runtime snapshot advertises schema v13") _expect(not runtime_data[&"movement_credits"].is_empty(), "mid-move snapshot persists fixed-point movement credits") var digest := SimulationDigest.compute(snapshot) _expect(_runtime.restore_snapshot(snapshot), "valid mid-move v9 snapshot restores") @@ -358,6 +358,7 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: var legacy_data: Dictionary = legacy_v2[&"systems"][&"runtime"] legacy_data[&"runtime_schema_version"] = 2 _strip_post_v2_runtime_state(legacy_data) + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(legacy_data) legacy_data[&"path"][&"schema_version"] = 3 legacy_data[&"path"].erase(&"vertical_walk_blockers") legacy_data[&"path"].erase(&"vertical_walk_requires_connector") @@ -375,7 +376,7 @@ func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _expect(_runtime.restore_snapshot(legacy_v2), "legacy Runtime v2 deterministically rebuilds Path actors and credits") var migrated: Dictionary = _runtime.create_snapshot() _expect( - int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 9 + int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 13 and migrated[&"systems"][&"runtime"][&"path"][&"actor_cells"].size() == 2 and migrated[&"systems"][&"runtime"][&"movement_credits"].size() == 2 and bool(_runtime.path_service.get_cell_traffic(origin)[&"temporary_block"]), diff --git a/tests/s3/test_staff_service.gd b/tests/s3/test_staff_service.gd index 9f08f07..8b87064 100644 --- a/tests/s3/test_staff_service.gd +++ b/tests/s3/test_staff_service.gd @@ -29,6 +29,7 @@ func _run() -> void: _test_automatic_task_progression_once_per_tick() _test_employment_and_daily_payroll_contract() _test_retirement_preserves_unpaid_wages() + _test_unique_employment_protection() _test_daily_payroll_settlement_lifecycle() _test_payroll_validation_and_idempotency() _test_payroll_snapshot_migration_and_tamper() @@ -384,11 +385,11 @@ func _test_employment_and_daily_payroll_contract() -> void: ) var expected_rows: Array[Dictionary] = [ {&"staff_id": &"staff_a", &"employment_kind": &"ADULT_CONTRACT", &"daily_wage": 240}, - {&"staff_id": &"staff_m", &"employment_kind": &"UNIQUE", &"daily_wage": 75}, + {&"staff_id": &"staff_m", &"employment_kind": &"UNIQUE", &"daily_wage": 0}, {&"staff_id": &"staff_z", &"employment_kind": &"FREE_HIRE", &"daily_wage": 0}, ] _expect(service.get_daily_payroll_rows() == expected_rows, "daily payroll rows have an exact shape and lexical Staff order") - _expect(service.get_daily_payroll_total() == 315, "daily payroll total is the exact sum of active Staff wages") + _expect(service.get_daily_payroll_total() == 240, "daily payroll total is the exact sum of active Staff wages") var invalid_service := StaffServiceType.new() _expect( @@ -828,3 +829,24 @@ func _expect(condition: bool, label: String) -> void: return _failures += 1 push_error(" ✗ %s" % label) + + +func _test_unique_employment_protection() -> void: + var service := StaffServiceType.new() + var unique := _staff(&"original_unique", 10, {&"employment_kind": &"UNIQUE", &"daily_wage": 75, &"unpaid_wages": 25}) + _expect(service.register_staff(unique).is_success(), "legacy unique with stored wage remains loadable") + var before := service.create_snapshot() + for reason: StringName in [&"DISMISSED", &"SOLD", &"REMOVED"]: + _expect(not service.remove_staff(unique.staff_id, reason).is_success() and service.create_snapshot() == before, "unique removal is rejected atomically for %s" % reason) + _expect(not service.retire_staff(unique.staff_id).is_success() and not unique.is_retiring and service.create_snapshot() == before, "unique retirement rejects before setting retirement flag") + var preview := service.preview_daily_payroll(1) + _expect(preview[&"ok"] and preview[&"new_wages_total"] == 0 and preview[&"prior_arrears_total"] == 25 and preview[&"total_due"] == 25, "unique accrues zero wages without silently deleting historical liabilities") + _expect(service.get_daily_payroll_total() == 0 and service.get_payroll_obligations()[0][&"daily_wage"] == 0, "all payroll views use unique zero upkeep") + var restored := StaffServiceType.new() + _expect(restored.restore_snapshot(before) and restored.create_snapshot() == before and restored.get_daily_payroll_total() == 0, "unique zero-upkeep rule survives exact save round trip") + _expect(not restored.retire_staff(unique.staff_id).is_success() and restored.create_snapshot() == before, "restored unique remains protected against removal") + _expect(restored.apply_daily_payroll_settlement(&"unique_day1", 1, {unique.staff_id: 25}).is_success(), "unique settlement pays only preserved historical arrears") + _expect(restored.get_staff(unique.staff_id).unpaid_wages == 0 and restored.get_payroll_settlement(&"unique_day1")[&"new_wages_total"] == 0, "unique settlement adds no new upkeep") + var settled := restored.create_snapshot() + _expect(restored.apply_daily_payroll_settlement(&"unique_day1", 1, {unique.staff_id: 25}).is_success() and restored.create_snapshot() == settled, "unique settlement retry is idempotent") + _expect(restored.preview_daily_payroll(2)[&"total_due"] == 0, "following day keeps unique upkeep free") diff --git a/tests/s5/test_defense_service.gd b/tests/s5/test_defense_service.gd index 96cf164..ba2a396 100644 --- a/tests/s5/test_defense_service.gd +++ b/tests/s5/test_defense_service.gd @@ -12,6 +12,7 @@ func _initialize() -> void: func _run() -> void: print("S5: deterministic wave-by-wave Raid defense") + _test_threat_summary() _test_definition_and_registration_validation() _test_scores_penalties_and_stable_order() _test_layered_assets_and_breach() @@ -22,6 +23,7 @@ func _run() -> void: _test_rng_transaction_retry_stability() _test_retreat_policies_and_manual_retreat_replay() _test_cancel_transaction_and_reconciliation() + _test_cancel_incomplete_preparation() _test_resolution_operation_namespace_and_legacy_v1() _test_multiple_waves_require_explicit_transition() _test_raid_duration_and_wave_round_limits() @@ -39,6 +41,22 @@ func _run() -> void: quit(_failures) +func _test_threat_summary() -> void: + var service := DefenseServiceType.new() + _expect(service.get_threat_summary()[&"total"] == 0, "empty defense has no HUD threat") + _must(service.create_raid(&"preparing", 0, _definition()), "HUD preparing raid created") + _prepare_basic(service, &"active") + var before := service.create_snapshot() + var summary := service.get_threat_summary() + _expect(summary == {&"preparing": 1, &"active": 1, &"between_waves": 0, &"total": 2}, "HUD counts preparing and active raids separately") + summary[&"total"] = 99 + _expect(service.get_threat_summary()[&"total"] == 2 and service.create_snapshot() == before, "HUD projection is owned and read only") + _must(service.cancel_raid(&"preparing", 0), "HUD incomplete preparation can be cancelled") + _expect(service.get_threat_summary()[&"preparing"] == 0 and service.get_threat_summary()[&"total"] == 1, "cancelled history is not an ongoing threat") + var restored := DefenseServiceType.new() + _expect(restored.restore_snapshot(service.create_snapshot()) and restored.get_threat_summary() == service.get_threat_summary(), "HUD projection follows restored authoritative state") + + func _test_definition_and_registration_validation() -> void: var service := DefenseServiceType.new() _expect(not service.create_raid(&"", 0, _definition()).is_success(), "Raid ID is required") @@ -505,6 +523,7 @@ func _test_multiple_waves_require_explicit_transition() -> void: _must(service.start_raid(&"multi", 0), "multi-wave Raid starts") var wave_one = service.resolve_next_round(&"multi", &"wave_one_round", 5) _expect(wave_one.is_success() and wave_one.details[&"state"] == &"BETWEEN_WAVES", "first max-round wave ends BETWEEN_WAVES") + _expect(service.get_threat_summary()[&"between_waves"] == 1 and service.get_threat_summary()[&"active"] == 0, "HUD distinguishes waiting for next wave from combat") var between_snapshot := service.create_snapshot() _expect(not service.resolve_next_round(&"multi", &"illegal_between_round", 6).is_success(), "round cannot resolve while waiting between waves") _expect(service.create_snapshot() == between_snapshot, "failed between-wave round is atomic") @@ -515,6 +534,7 @@ func _test_multiple_waves_require_explicit_transition() -> void: _expect(not service.begin_next_wave(&"multi", 7).is_success(), "active wave cannot be skipped") var wave_two = service.resolve_next_round(&"multi", &"wave_two_round", 12) _expect(wave_two.is_success() and wave_two.details[&"state"] == &"DEFENDED", "last wave completion defends the Raid") + _expect(service.get_threat_summary()[&"total"] == 0, "defended raid is removed from ongoing HUD threats") var raid := service.get_raid(&"multi") _expect(raid[&"current_round"] == 2 and raid[&"elapsed_minutes"] == 10, "round and elapsed counters span all waves") _expect(raid[&"waves"][0][&"state"] == &"RESOLVED" and raid[&"waves"][1][&"state"] == &"RESOLVED", "each completed wave retains its own state") @@ -968,3 +988,29 @@ func _expect(condition: bool, message: String) -> bool: _failures += 1 push_error(" CHECK FAILED: %s" % message) return false + + +func _test_cancel_incomplete_preparation() -> void: + for partial: bool in [false, true]: + var service := DefenseServiceType.new() + _must(service.create_raid(&"unprepared", 0, _definition({&"objective_ids": [&"core", &"annex"]})), "create raid with incomplete objective registration") + if partial: + _must(service.register_asset(&"unprepared", &"core", &"OBJECTIVE", 1000, 0), "register only one of two objectives") + var before := service.create_snapshot() + _expect(not service.start_raid(&"unprepared", 0).is_success() and service.create_snapshot() == before, "incomplete preparation still cannot start combat") + var cancelled = service.cancel_raid(&"unprepared", 1, &"PLAYER_ABORT", &"cancel_unprepared") + _expect(cancelled.is_success(), "incomplete preparation can be cancelled") + if not cancelled.is_success(): + continue + var raid := service.get_raid(&"unprepared") + _expect(raid[&"state"] == DefenseServiceType.RaidState.CANCELLED and raid[&"started_at"] == -1 and raid[&"current_round"] == 0, "preparation cancellation does not invent combat") + _expect(raid[&"assets"].size() == (1 if partial else 0), "cancellation does not invent unregistered assets") + var saved := service.create_snapshot() + var restored := DefenseServiceType.new() + _expect(restored.restore_snapshot(JSON.parse_string(JSON.stringify(saved))), "cancelled preparation survives JSON save transport") + var replay = restored.cancel_raid(&"unprepared", 99, &"DIFFERENT", &"cancel_unprepared") + _expect(replay.is_success() and replay.is_replay and restored.create_snapshot() == saved, "cancel replay after restore preserves state") + var corrupt := saved.duplicate(true) + corrupt[&"raids"][0][&"started_at"] = 0 + _expect(not restored.restore_snapshot(corrupt) and restored.create_snapshot() == saved, "started cancelled raid still requires all objective assets and rejects corruption atomically") + _expect(restored.validate_invariants()[&"ok"], "cancelled incomplete preparation remains valid") diff --git a/tests/s5/test_runtime_defense_autonomy.gd b/tests/s5/test_runtime_defense_autonomy.gd new file mode 100644 index 0000000..441c3d4 --- /dev/null +++ b/tests/s5/test_runtime_defense_autonomy.gd @@ -0,0 +1,347 @@ +extends "res://tests/s5/test_runtime_defense_cycle.gd" + +## All threat strengths, skills, +## costs and wave limits below are disclosed fixtures, not production values. +## Autonomy is enabled in every Runtime, with no test-only opt-out switch. + + +func _run() -> void: + print("Defense autonomy: ordinary clock, command precedence and restart") + _test_ordinary_due_pause_two_waves_and_terminal() + _test_multiple_raids_stable_order() + _test_automatic_failure_pause_retry_and_atomicity() + _test_manual_failure_is_not_hidden_by_same_tick_auto() + _test_history_replay_does_not_skip_due_automatic_round() + _test_history_replay_preserves_prior_manual_failure_guard() + _test_resolution_history_is_scoped_per_raid() + _test_generated_ids_skip_existing_saved_history() + _test_exhausted_generated_ids_fail_atomically() + _test_restart_mid_interval_and_speed_equivalence() + _test_auto_breach_projects_facility_suspension() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_ordinary_due_pause_two_waves_and_terminal() -> void: + _reset_runtime(91_001) + _create_staff_and_facility(&"guard.auto", &"objective.auto", 100, 0, 9_900) + _prepare_started_raid(&"raid.auto", &"guard.auto", &"objective.auto", [ + _wave(&"wave.auto.1", 1_000, 1, [&"gate.north"]), + _wave(&"wave.auto.2", 1_000, 1, [&"gate.south"]), + ]) + # A second incident stays preparing even while the first battle advances. + _create_staff_and_facility(&"guard.prep", &"objective.prep") + _expect(_submit(&"create_defense_raid", { + &"raid_id": &"raid.prep", &"definition": _raid_definition( + &"objective.prep", [_wave(&"wave.prep", 100, 1, [&"gate.north"])] + ), + }).is_success(), "a separate preparing incident exists without automatic kickoff") + var start := int(_clock.absolute_minute) + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + _expect(_clock.process_real_delta(10.0) == 0 and _clock.absolute_minute == start, "real paused play advances no game minute or defense") + _advance(4) + _expect(_runtime.defense_service.get_raid(&"raid.auto")[&"current_round"] == 0 and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "deadline-minus-one neither attacks nor consumes combat RNG") + var due: Array[Dictionary] = _runtime.defense_service.get_due_rounds(start + 5) + _expect(due.size() == 1 and due[0][&"raid_id"] == &"raid.auto", "compact due projection includes only active due battles") + due[0][&"round_number"] = 999 + _expect(_runtime.defense_service.get_due_rounds(start + 5)[0][&"round_number"] == 1, "due projection cannot mutate its raid") + var processed_before := int(_runtime.command_queue.processed_count()) + _advance(1) + var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.auto") + _expect(raid[&"current_round"] == 1 and raid[&"state"] == DefenseService.RaidState.BETWEEN_WAVES, "exact ordinary deadline resolves one first wave") + _expect(_runtime.command_queue.processed_count() == processed_before, "automatic progression creates no fake player command") + _expect(_runtime.staff_service.get_staff(&"guard.auto").state == StaffRuntime.State.UNAVAILABLE and _runtime.facility_service.get_instance(&"objective.auto").durability == 9_900, "between-wave damage stays in Defense and employee remains reserved") + var between_rng: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + _advance(20) + _expect(_runtime.defense_service.get_raid(&"raid.auto")[&"current_round"] == 1 and _runtime.get_rng_stream(&"defense").snapshot() == between_rng, "between-wave waiting invents no cooldown or automatic next wave") + _expect(_runtime.defense_service.get_raid(&"raid.prep")[&"state"] == DefenseService.RaidState.PREPARING, "ordinary time never starts preparation automatically") + _expect(_submit(&"begin_next_defense_wave", {&"raid_id": &"raid.auto"}).is_success(), "existing player command explicitly begins the second wave") + _advance(5) + raid = _runtime.defense_service.get_raid(&"raid.auto") + _expect(raid[&"state"] == DefenseService.RaidState.DEFENDED and raid[&"current_round"] == 2, "second wave reaches terminal state from ordinary clock only") + var guard: StaffRuntime = _runtime.staff_service.get_staff(&"guard.auto") + _expect(guard.health == raid[&"combatants"][&"guard.auto"][&"health"] and guard.fatigue == raid[&"combatants"][&"guard.auto"][&"fatigue"] and guard.state == StaffRuntime.State.AVAILABLE, "automatic terminal boundary reconciles employee health, fatigue and availability") + _expect(_runtime.facility_service.get_instance(&"objective.auto").durability == raid[&"assets"][&"objective.auto"][&"integrity"], "automatic terminal boundary reconciles facility durability") + var terminal_rng: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var terminal_events := _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.auto") + _advance(10) + _expect(_runtime.get_rng_stream(&"defense").snapshot() == terminal_rng and _count_domain_events(&"DEFENSE_RAID_RESOLVED", &"raid.auto") == terminal_events, "terminal raids stop consuming combat RNG and emit no repeated resolution") + + +func _test_multiple_raids_stable_order() -> void: + _reset_runtime(91_002) + # Deliberately register in reverse order; tick order must use stable raid IDs. + for suffix: String in ["z", "a"]: + var guard := StringName("guard.order." + suffix) + var objective := StringName("objective.order." + suffix) + var raid_id := StringName("raid.order." + suffix) + _create_staff_and_facility(guard, objective) + _prepare_started_raid(raid_id, guard, objective, [_wave(StringName("wave.order." + suffix), 1_000, 1, [&"gate.north"])]) + var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng + expected_rng.restore(_runtime.get_rng_stream(&"defense").snapshot()) + var expected_rolls: Array[int] = [] + for _draw: int in 4: + expected_rolls.append(expected_rng.randi_range(0, 100)) + _advance(5) + var order: Array[StringName] = [] + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == &"DEFENSE_ROUND_RESOLVED": + order.append(StringName(event.payload[&"raid_id"])) + _expect(order == [&"raid.order.a", &"raid.order.z"], "simultaneous raids resolve in deterministic ID order regardless of creation order") + var roll_cursor := 0 + for raid_id: StringName in [&"raid.order.a", &"raid.order.z"]: + var raid: Dictionary = _runtime.defense_service.get_raid(raid_id) + var payload: Dictionary = (raid[&"resolved_rounds"] as Dictionary).values()[0] + _expect(payload[&"defense_roll"] == expected_rolls[roll_cursor] and payload[&"attack_roll"] == expected_rolls[roll_cursor + 1], "each simultaneous raid receives the exact stable-order RNG pair") + _expect(String(payload[&"resolution_id"]).begins_with("defense_round_resolution."), "automatic resolution uses the existing saved ID namespace") + roll_cursor += 2 + _expect(_runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "multi-raid tick consumes exactly two combat draws per round") + + +func _test_automatic_failure_pause_retry_and_atomicity() -> void: + _reset_runtime(91_003) + _create_staff_and_facility(&"guard.failure", &"objective.failure", 100, 0, 9_800) + _prepare_started_raid(&"raid.failure", &"guard.failure", &"objective.failure", [_wave(&"wave.failure", 500, 1, [&"gate.north"])]) + _expect(bool(_runtime.inventory_service.add_stack(&"auto.inventory", &"mana_bomb", 5, 100, -1, _clock.absolute_minute, 1, &"auto.stack").get(&"ok", false)), "disclosed skill-cost inventory fixture exists") + _expect(_submit(&"queue_defense_skill", { + &"raid_id": &"raid.failure", &"actor_id": &"guard.failure", &"kind": &"POWER_STRIKE", + &"options": {&"potency": 10, &"metadata": {&"cost": {&"gold": 125, &"mana": 9, &"items": [{&"inventory_id": &"auto.inventory", &"item_id": &"mana_bomb", &"quantity": 2}]}}}, + }).is_success(), "disclosed active skill queues before automatic resolution") + var before: Dictionary = _runtime.defense_service.create_snapshot() + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var id_before := int(_runtime.id_generator.get_counter(&"defense_round_resolution")) + var failure := {&"armed": true, &"calls": 0} + var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary") + var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + var report: Dictionary = normal_outcome.call(payload, phase, token) + if phase == &"apply": + failure[&"calls"] += 1 + if bool(failure[&"armed"]) and bool(report.get(&"ok", false)): + failure[&"armed"] = false + report[&"ok"] = false + report[&"error"] = "Disclosed automatic terminal post-apply failure." + return report + _runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary")) + _clock.set_paused(false) + _clock.advance_minutes(20) + var failed_minute := int(_clock.absolute_minute) + before[&"current_minute"] = failed_minute + _expect(_clock.paused and failed_minute == 365 and failure[&"calls"] == 1, "automatic failure pauses the long catch-up batch after one due attempt") + _expect(_runtime.defense_service.create_snapshot() == before and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "failed automatic transaction retains round, queued skill and RNG cursor") + _expect(_state.get_resource(&"gold") == 5_000 and _state.get_resource(&"mana") == 100 and _runtime.inventory_service.available_quantity(&"auto.inventory", &"mana_bomb", failed_minute) == 5, "failed automatic terminal outcome rolls all skill costs back") + _expect(_runtime.staff_service.get_staff(&"guard.failure").health == 100 and _runtime.staff_service.get_staff(&"guard.failure").fatigue == 0 and _runtime.facility_service.get_instance(&"objective.failure").durability == 9_800, "failed automatic terminal outcome rolls staff and facility changes back") + _expect(_runtime.id_generator.get_counter(&"defense_round_resolution") == id_before and _count_domain_events(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"raid.failure") == 1, "failed generated round ID rolls back and one diagnostic remains") + _runtime._advance_defense(failed_minute) + _expect(failure[&"calls"] == 1 and _clock.process_real_delta(5.0) == 0, "same-minute deferred reentry and paused real frames never retry the failure") + _runtime._configure_defense_boundaries() + _clock.set_paused(false) + _clock.advance_minutes(1) + _clock.set_paused(true) + var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.failure") + _expect(raid[&"state"] == DefenseService.RaidState.DEFENDED and raid[&"current_round"] == 1 and raid[&"elapsed_minutes"] == 5 and raid[&"state_changed_at"] == failed_minute + 1, "resuming retries once at the current minute without fabricating offline combat") + _expect(_state.get_resource(&"gold") == 4_875 and _state.get_resource(&"mana") == 91 and _runtime.inventory_service.available_quantity(&"auto.inventory", &"mana_bomb", _clock.absolute_minute) == 3, "automatic retry commits skill costs exactly once") + var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng + expected_rng.restore(rng_before) + var expected_defense := expected_rng.randi_range(0, 100) + var expected_attack := expected_rng.randi_range(0, 100) + var round_payload: Dictionary = (raid[&"resolved_rounds"] as Dictionary).values()[0] + _expect(round_payload[&"defense_roll"] == expected_defense and round_payload[&"attack_roll"] == expected_attack and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "retry consumes exactly the combat pair restored after failure") + + +func _test_manual_failure_is_not_hidden_by_same_tick_auto() -> void: + _reset_runtime(91_004) + _create_staff_and_facility(&"guard.manual", &"objective.manual") + _prepare_started_raid(&"raid.manual", &"guard.manual", &"objective.manual", [_wave(&"wave.manual", 500, 1, [&"gate.north"])]) + var failure := {&"armed": true, &"calls": 0} + var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary") + var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + var report: Dictionary = normal_outcome.call(payload, phase, token) + if phase == &"apply": + failure[&"calls"] += 1 + if bool(failure[&"armed"]): + failure[&"armed"] = false + report[&"ok"] = false + report[&"error"] = "Disclosed one-shot manual failure." + return report + _runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary")) + var attempt := _submit_round_at_due({&"raid_id": &"raid.manual"}) + var failed: CommandResult = attempt[&"result"] + _expect(not failed.is_success() and failure[&"calls"] == 1 and attempt[&"before"] == attempt[&"after"], "manual due failure remains a failed command with all domain boundaries rolled back") + _expect(_runtime.defense_service.get_raid(&"raid.manual")[&"current_round"] == 0 and _count_domain_events(&"DEFENSE_ROUND_RESOLVED", &"raid.manual") == 0, "automatic phase never hides that failure by immediately retrying the same raid") + # A successful restore at the very same clock minute must discard the old + # execution guard, including when this Runtime already attempted that minute. + var retryable: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(retryable), "failed but atomic manual round remains a valid save") + _runtime._advance_defense(_clock.absolute_minute) + _expect(_runtime.defense_service.get_raid(&"raid.manual")[&"current_round"] == 1, "successful same-minute restore clears ephemeral attempt state") + + +func _test_restart_mid_interval_and_speed_equivalence() -> void: + _reset_runtime(91_005) + _create_staff_and_facility(&"guard.restart", &"objective.restart") + _prepare_started_raid(&"raid.restart", &"guard.restart", &"objective.restart", [_wave(&"wave.restart", 10_000, 100, [&"gate.north"])]) + _advance(2) + _expect(_submit(&"queue_defense_skill", {&"raid_id": &"raid.restart", &"actor_id": &"guard.restart", &"kind": &"FORTIFY", &"options": {&"potency": 8, &"target_id": &"objective.restart"}}).is_success(), "mid-interval queued skill is an explicit disclosed fixture") + var origin: Dictionary = _runtime.create_snapshot() + _advance(8) + var expected: Dictionary = _runtime.create_snapshot() + _reset_runtime(91_005) + _expect(_runtime.restore_snapshot(origin), "independently initialized Runtime restores the mid-interval save") + _advance(8) + _expect(_runtime.create_snapshot() == expected, "restart reproduces full combat, RNG, queued skill, ID and event state without extra serialized timers") + _expect(_runtime.restore_snapshot(origin), "restore speed comparison origin") + _clock.process_real_delta(0.0) + _clock.set_speed(1) + _clock.set_paused(false) + var x1_ticks := 0 + for frame: int in 9: + x1_ticks += _clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE * 1.01) + var x1: Dictionary = _capture_defense_views() + _expect(_runtime.restore_snapshot(origin), "restore identical x3 origin") + _clock.process_real_delta(0.0) + _clock.set_speed(3) + _clock.set_paused(false) + var x3_ticks := 0 + for frame: int in 3: + x3_ticks += _clock.process_real_delta(SimulationKernel.REAL_SECONDS_PER_GAME_MINUTE * 1.01) + _expect(x1_ticks == 9 and x3_ticks == 9 and _capture_defense_views() == x1, "x1 and x3 real-frame execution agree after the same nine game minutes") + _clock.set_paused(true) + + +func _test_history_replay_does_not_skip_due_automatic_round() -> void: + _reset_runtime(91_007) + _create_staff_and_facility(&"guard.replay.auto", &"objective.replay.auto") + _prepare_started_raid(&"raid.replay.auto", &"guard.replay.auto", &"objective.replay.auto", [_wave(&"wave.replay.auto", 10_000, 100, [&"gate.north"])]) + var first_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.auto", &"resolution_id": &"round.replay.first"}) + var first: CommandResult = first_attempt[&"result"] + _expect(first.is_success() and not first.is_replay, "a genuine first round establishes canonical history") + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + # A new command key deliberately reads that old result at the second round's + # actual deadline; its command is processed before the automatic phase. + var replay_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.auto", &"resolution_id": &"round.replay.first"}, &"autonomy.replay.new.command.key") + var replay: CommandResult = replay_attempt[&"result"] + _expect(replay.is_success() and replay.is_replay and replay.command_id > first.command_id and replay.details == first.details, "new command key returns the unchanged canonical historical result") + _expect(replay_attempt[&"before"] == replay_attempt[&"after"], "history replay alone consumes no RNG, costs or combat state") + var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.replay.auto") + _expect(raid[&"current_round"] == 2 and _count_domain_events(&"DEFENSE_ROUND_RESOLVED", &"raid.replay.auto") == 2, "historical replay never skips the legitimate automatic round due in the same tick") + var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng + expected_rng.restore(rng_before) + var expected_defense := expected_rng.randi_range(0, 100) + var expected_attack := expected_rng.randi_range(0, 100) + var latest: Dictionary = {} + for payload: Dictionary in (raid[&"resolved_rounds"] as Dictionary).values(): + if payload[&"round_number"] == 2: + latest = payload + _expect(not latest.is_empty() and latest[&"defense_roll"] == expected_defense and latest[&"attack_roll"] == expected_attack and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "replay plus due automatic work consumes exactly one new round RNG pair") + + +func _test_history_replay_preserves_prior_manual_failure_guard() -> void: + _reset_runtime(91_008) + _create_staff_and_facility(&"guard.replay.failure", &"objective.replay.failure") + _prepare_started_raid(&"raid.replay.failure", &"guard.replay.failure", &"objective.replay.failure", [_wave(&"wave.replay.failure", 10_000, 2, [&"gate.north"])]) + var first_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.failure", &"resolution_id": &"round.replay.failure.first"}) + _expect((first_attempt[&"result"] as CommandResult).is_success(), "first round precedes the disclosed next-round failure") + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var failure := {&"armed": true, &"calls": 0} + var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary") + var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + var report: Dictionary = normal_outcome.call(payload, phase, token) + if phase == &"apply": + failure[&"calls"] += 1 + if bool(failure[&"armed"]): + failure[&"armed"] = false + report[&"ok"] = false + report[&"error"] = "Disclosed failure before historical replay." + return report + _runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary")) + var due := int(_clock.absolute_minute) + int(_runtime.defense_service.get_raid(&"raid.replay.failure")[&"round_minutes"]) + # Stable command-ID order executes this real failing transaction before the + # helper's subsequent historical replay, at the very same canonical minute. + var accepted: CommandResult = _runtime.submit_command(&"resolve_defense_round", {&"raid_id": &"raid.replay.failure", &"resolution_id": &"round.replay.failure.next"}, &"defense_controller", &"raid.replay.failure", 80, due, &"autonomy.real.failure.first") + var replay_attempt := _submit_round_at_due({&"raid_id": &"raid.replay.failure", &"resolution_id": &"round.replay.failure.first"}, &"autonomy.old.replay.after.failure") + var failed: CommandResult = _runtime.command_queue.get_processed_result(accepted.command_id) + var replay: CommandResult = replay_attempt[&"result"] + _expect(accepted.is_success() and failed != null and not failed.is_success() and replay.is_success() and replay.is_replay and replay.command_id > accepted.command_id, "real failure and following old replay preserve their distinct actual command results") + _expect(failure[&"calls"] == 1 and _runtime.defense_service.get_raid(&"raid.replay.failure")[&"current_round"] == 1 and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "old replay cannot erase a prior failed real-round guard or hide it with same-tick automatic success") + _runtime._configure_defense_boundaries() + _advance(1) + _expect(_runtime.defense_service.get_raid(&"raid.replay.failure")[&"current_round"] == 2, "the genuine failed round can progress automatically on a later game minute") + + +func _test_resolution_history_is_scoped_per_raid() -> void: + _reset_runtime(91_009) + var accepted: Array[CommandResult] = [] + for suffix: String in ["a", "b"]: + var guard := StringName("guard.shared.id." + suffix) + var objective := StringName("objective.shared.id." + suffix) + var raid_id := StringName("raid.shared.id." + suffix) + _create_staff_and_facility(guard, objective) + _prepare_started_raid(raid_id, guard, objective, [_wave(StringName("wave.shared.id." + suffix), 10_000, 100, [&"gate.north"])]) + accepted.append(_runtime.submit_command(&"resolve_defense_round", {&"raid_id": raid_id, &"resolution_id": &"round.shared.history.id"}, &"defense_controller", raid_id, 80, int(_clock.absolute_minute) + 5, StringName("autonomy.shared.history." + suffix))) + _advance(5) + for pending: CommandResult in accepted: + var completed: CommandResult = _runtime.command_queue.get_processed_result(pending.command_id) + _expect(pending.is_success() and completed != null and completed.is_success() and not completed.is_replay and completed.details[&"resolution_id"] == &"round.shared.history.id", "same explicit resolution ID belongs independently to each raid in the same minute") + _expect(_runtime.defense_service.get_raid(&"raid.shared.id.a")[&"current_round"] == 1 and _runtime.defense_service.get_raid(&"raid.shared.id.b")[&"current_round"] == 1, "per-raid manual guards prevent double resolution without suppressing the other raid") + + +func _test_generated_ids_skip_existing_saved_history() -> void: + _reset_runtime(91_010) + _create_staff_and_facility(&"guard.id.collision", &"objective.id.collision") + _prepare_started_raid(&"raid.id.collision", &"guard.id.collision", &"objective.id.collision", [_wave(&"wave.id.collision", 10_000, 100, [&"gate.north"])]) + for number: int in [1, 2]: + var explicit_id := StringName("defense_round_resolution.%012d" % number) + var attempt := _submit_round_at_due({&"raid_id": &"raid.id.collision", &"resolution_id": explicit_id}) + _expect((attempt[&"result"] as CommandResult).is_success(), "valid explicit resolution establishes saved namespace collision") + _expect(_runtime.id_generator.get_counter(&"defense_round_resolution") == 0, "existing explicit IDs legitimately leave the generated counter unchanged") + var origin: Dictionary = _runtime.create_snapshot() + _reset_runtime(91_010) + _expect(_runtime.restore_snapshot(origin), "valid mid-battle history with explicit namespace IDs remains loadable") + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var due := int(_clock.absolute_minute) + 5 + _advance(5) + var raid: Dictionary = _runtime.defense_service.get_raid(&"raid.id.collision") + _expect(raid[&"current_round"] == 3 and raid[&"state_changed_at"] == due, "namespace collision never delays the attack beyond its saved deadline") + _expect(_runtime.defense_service.has_resolution(&"raid.id.collision", &"defense_round_resolution.000000000003") and _runtime.id_generator.get_counter(&"defense_round_resolution") == 3, "generated resolution skips both existing IDs and commits one unused ID") + var expected_rng := DeterministicRng.from_root_seed(0, &"defense") as DeterministicRng + expected_rng.restore(rng_before) + var expected_defense := expected_rng.randi_range(0, 100) + var expected_attack := expected_rng.randi_range(0, 100) + var resolved: Dictionary = raid[&"resolved_rounds"][&"defense_round_resolution.000000000003"] + _expect(resolved[&"defense_roll"] == expected_defense and resolved[&"attack_roll"] == expected_attack and _runtime.get_rng_stream(&"defense").snapshot() == expected_rng.snapshot(), "skipping history IDs consumes exactly one attack's combat RNG pair") + _expect(raid[&"resolved_rounds"][&"defense_round_resolution.000000000001"][&"round_number"] == 1 and raid[&"resolved_rounds"][&"defense_round_resolution.000000000002"][&"round_number"] == 2, "skipping IDs preserves both canonical old results") + + +func _test_exhausted_generated_ids_fail_atomically() -> void: + _reset_runtime(91_011) + _create_staff_and_facility(&"guard.id.exhausted", &"objective.id.exhausted") + _prepare_started_raid(&"raid.id.exhausted", &"guard.id.exhausted", &"objective.id.exhausted", [_wave(&"wave.id.exhausted", 10_000, 100, [&"gate.north"])]) + var last_id := StringName("defense_round_resolution.%012d" % StableIdGenerator.MAX_COUNTER) + var attempt := _submit_round_at_due({&"raid_id": &"raid.id.exhausted", &"resolution_id": last_id}) + _expect((attempt[&"result"] as CommandResult).is_success(), "valid explicit history can occupy the last generated ID") + var allocator: Dictionary = _runtime.id_generator.create_snapshot() + allocator["counters"]["defense_round_resolution"] = StableIdGenerator.MAX_COUNTER - 1 + _expect(_runtime.id_generator.restore_snapshot(allocator), "valid near-exhausted allocator is a disclosed saved-state boundary") + var before: Dictionary = _runtime.defense_service.create_snapshot() + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + _advance(5) + before[&"current_minute"] = _clock.absolute_minute + _expect(before == _runtime.defense_service.create_snapshot() and rng_before == _runtime.get_rng_stream(&"defense").snapshot() and _clock.paused, "exhausted automatic allocator fails without advancing combat or RNG") + _expect(_state.get_resource(&"gold") == 5_000 and _state.get_resource(&"mana") == 100, "allocation failure charges no skill or outcome resources") + _expect(_count_domain_events(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"raid.id.exhausted") == 1 and _runtime.id_generator.get_counter(&"defense_round_resolution") == StableIdGenerator.MAX_COUNTER - 1, "exhaustion records one diagnostic and rolls skipped-ID allocation back") + + +func _test_auto_breach_projects_facility_suspension() -> void: + _reset_runtime(91_006) + _create_staff_and_facility(&"guard.breach.auto", &"objective.breach.auto", 100, 0, 1) + _prepare_started_raid(&"raid.breach.auto", &"guard.breach.auto", &"objective.breach.auto", [_wave(&"wave.breach.auto", 1_000, 1, [&"gate.north"])], 0, 0, {&"target_order": 1}, 1_000) + _advance(5) + var facility: FacilityInstance = _runtime.facility_service.get_instance(&"objective.breach.auto") + _expect(_runtime.defense_service.get_raid(&"raid.breach.auto")[&"state"] == DefenseService.RaidState.BREACHED and facility.durability == 0 and facility.state == FacilityInstance.State.BROKEN, "ordinary automatic combat destroys the objective through the actual facility boundary") + _expect(facility.suspension_reason == &"DESTROYED", "automatic destruction keeps the user-specified suspension state without inventing recovery") + var terminal: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(terminal), "automatically breached terminal state round-trips through ordinary Runtime save restoration") diff --git a/tests/s5/test_runtime_defense_autonomy.gd.uid b/tests/s5/test_runtime_defense_autonomy.gd.uid new file mode 100644 index 0000000..5f9ef69 --- /dev/null +++ b/tests/s5/test_runtime_defense_autonomy.gd.uid @@ -0,0 +1 @@ +uid://dnoi1lvhxm72 diff --git a/tests/s5/test_runtime_defense_cycle.gd b/tests/s5/test_runtime_defense_cycle.gd index f76b74f..8d51f38 100644 --- a/tests/s5/test_runtime_defense_cycle.gd +++ b/tests/s5/test_runtime_defense_cycle.gd @@ -140,10 +140,10 @@ func _test_all_commands_multiwave_and_terminal_replay() -> void: &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.early", }) _expect(not early.is_success(), "a round before its configured interval is rejected") - _advance(5) - var first_round := _submit(&"resolve_defense_round", { + var first_round_attempt := _submit_round_at_due({ &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.1", }) + var first_round: CommandResult = first_round_attempt[&"result"] var after_first: Dictionary = _runtime.defense_service.get_raid(&"raid.multi") _expect( first_round.is_success() @@ -165,10 +165,10 @@ func _test_all_commands_multiwave_and_terminal_replay() -> void: &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.same-minute", }) _expect(not same_minute.is_success(), "a different round ID cannot resolve again in the same minute") - _advance(5) - var terminal := _submit(&"resolve_defense_round", { + var terminal_attempt := _submit_round_at_due({ &"raid_id": &"raid.multi", &"resolution_id": &"round.multi.2", }) + var terminal: CommandResult = terminal_attempt[&"result"] var terminal_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.multi") var linked_staff: StaffRuntime = _runtime.staff_service.get_staff(&"guard.multi") var linked_facility: FacilityInstance = _runtime.facility_service.get_instance(&"objective.multi") @@ -243,10 +243,10 @@ func _test_manual_retreat_cancel_and_long_horizon() -> void: 0, {&"target_order": 1} ) - _advance(5) - var partial := _submit(&"resolve_defense_round", { + var partial_attempt := _submit_round_at_due({ &"raid_id": &"raid.cancel", &"resolution_id": &"round.cancel.partial", }) + var partial: CommandResult = partial_attempt[&"result"] var partial_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.cancel") _expect( partial.is_success() @@ -289,23 +289,20 @@ func _test_manual_retreat_cancel_and_long_horizon() -> void: var long_raid: Dictionary = _runtime.defense_service.get_raid(&"raid.long") _expect( _runtime.defense_service.current_minute == _clock.absolute_minute - and int(long_raid[&"current_round"]) == 0 - and int(long_raid[&"state"]) == DefenseService.RaidState.ACTIVE, - "24-hour ticking advances Defense time without automatically resolving rounds" + and int(long_raid[&"current_round"]) == 1 + and int(long_raid[&"state"]) == DefenseService.RaidState.DEFENDED + and int(long_raid[&"elapsed_minutes"]) == 5, + "ordinary ticking automatically reaches the existing max-duration boundary once" ) _expect( - _runtime.get_rng_stream(&"defense").snapshot() == long_rng_before, - "Defense ticking alone consumes no round RNG" + _runtime.get_rng_stream(&"defense").snapshot() != long_rng_before + and int(long_raid[&"finished_at"]) == int(long_raid[&"started_at"]) + 5, + "long horizon consumes combat RNG only for the configured due round" ) - var duration_terminal := _submit(&"resolve_defense_round", { - &"raid_id": &"raid.long", &"resolution_id": &"round.long.duration", - }) + var duration_payload: Dictionary = (long_raid[&"resolved_rounds"] as Dictionary).values()[0] _expect( - duration_terminal.is_success() - and int(_runtime.defense_service.get_raid(&"raid.long")[&"state"]) - == DefenseService.RaidState.DEFENDED - and duration_terminal.details.get(&"reason", &"") == &"RAID_DURATION_EXHAUSTED", - "the next explicit round applies max-duration terminal behavior" + duration_payload.get(&"reason", &"") == &"RAID_DURATION_EXHAUSTED", + "the automatic round applies the same max-duration outcome as the command API" ) _create_staff_and_facility(&"guard.breach", &"objective.breach", 100, 0, 1) @@ -317,10 +314,10 @@ func _test_manual_retreat_cancel_and_long_horizon() -> void: {&"target_order": 1}, 1_000 ) - _advance(5) - var breached := _submit(&"resolve_defense_round", { + var breached_attempt := _submit_round_at_due({ &"raid_id": &"raid.breach", &"resolution_id": &"round.breach.terminal", }) + var breached: CommandResult = breached_attempt[&"result"] _expect( breached.is_success() and int(_runtime.defense_service.get_raid(&"raid.breach")[&"state"]) @@ -427,7 +424,6 @@ func _test_rng_skill_and_outcome_atomicity() -> void: and String(queued.details.get(&"action_id", "")).begins_with("defense_skill_action."), "queued skill receives a stable generated action namespace" ) - _advance(5) var defense_before: Dictionary = _runtime.defense_service.create_snapshot() var defense_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() var authority_rng_before: Dictionary = _runtime.get_rng_stream(&"authority").snapshot() @@ -456,25 +452,36 @@ func _test_rng_skill_and_outcome_atomicity() -> void: failing_outcome, Callable(_runtime, "_apply_defense_rng_boundary") ) - var failed := _submit(&"resolve_defense_round", { + var failed_attempt := _submit_round_at_due({ &"raid_id": &"raid.atomic", &"resolution_id": &"round.atomic.retry", }) + var failed: CommandResult = failed_attempt[&"result"] + var atomic_before: Dictionary = failed_attempt[&"before"] + var atomic_after: Dictionary = failed_attempt[&"after"] + defense_before = atomic_before[&"defense"] + defense_rng_before = atomic_before[&"defense_rng"] + authority_rng_before = atomic_before[&"authority_rng"] + economy_before = atomic_before[&"economy"] + state_before = atomic_before[&"state"] + inventory_before = atomic_before[&"inventory"] + staff_before = atomic_before[&"staff"] + facilities_before = atomic_before[&"facilities"] _expect(not failed.is_success(), "an injected post-apply terminal failure rejects the round") _expect( - _runtime.defense_service.create_snapshot() == defense_before - and _runtime.get_rng_stream(&"defense").snapshot() == defense_rng_before - and _runtime.get_rng_stream(&"authority").snapshot() == authority_rng_before, + atomic_after[&"defense"] == defense_before + and atomic_after[&"defense_rng"] == defense_rng_before + and atomic_after[&"authority_rng"] == authority_rng_before, "failed outcome restores Defense state and its RNG without touching other streams" ) _expect( - _runtime.economy_service.create_snapshot() == economy_before - and _state.create_snapshot() == state_before - and _runtime.inventory_service.create_snapshot() == inventory_before, + atomic_after[&"economy"] == economy_before + and atomic_after[&"state"] == state_before + and atomic_after[&"inventory"] == inventory_before, "failed outcome restores Gold, Mana, item reservations and consumption" ) _expect( - _runtime.staff_service.create_snapshot() == staff_before - and _runtime.facility_service.create_snapshot() == facilities_before, + atomic_after[&"staff"] == staff_before + and atomic_after[&"facilities"] == facilities_before, "failed outcome restores Staff and Facility changes atomically" ) _expect( @@ -531,25 +538,32 @@ func _test_rng_skill_and_outcome_atomicity() -> void: ]}}}, }) _expect(reservation_skill.is_success(), "multi-item skill cost queues before boundary validation") - _advance(5) var reserve_defense_before: Dictionary = _runtime.defense_service.create_snapshot() var reserve_rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() var reserve_inventory_before: Dictionary = _runtime.inventory_service.create_snapshot() var reserve_economy_before: Dictionary = _runtime.economy_service.create_snapshot() var reserve_state_before: Dictionary = _state.create_snapshot() - var reservation_failed := _submit(&"resolve_defense_round", { + var reservation_attempt := _submit_round_at_due({ &"raid_id": &"raid.reserve", &"resolution_id": &"round.reserve.failed", }) + var reservation_failed: CommandResult = reservation_attempt[&"result"] + var reserve_before: Dictionary = reservation_attempt[&"before"] + var reserve_after: Dictionary = reservation_attempt[&"after"] + reserve_defense_before = reserve_before[&"defense"] + reserve_rng_before = reserve_before[&"defense_rng"] + reserve_inventory_before = reserve_before[&"inventory"] + reserve_economy_before = reserve_before[&"economy"] + reserve_state_before = reserve_before[&"state"] _expect(not reservation_failed.is_success(), "a later unavailable item rejects the whole skill-cost batch") _expect( - _runtime.inventory_service.create_snapshot() == reserve_inventory_before - and _runtime.economy_service.create_snapshot() == reserve_economy_before - and _state.create_snapshot() == reserve_state_before, + reserve_after[&"inventory"] == reserve_inventory_before + and reserve_after[&"economy"] == reserve_economy_before + and reserve_after[&"state"] == reserve_state_before, "partial item reservation failure rolls every external resource back" ) _expect( - _runtime.defense_service.create_snapshot() == reserve_defense_before - and _runtime.get_rng_stream(&"defense").snapshot() == reserve_rng_before, + reserve_after[&"defense"] == reserve_defense_before + and reserve_after[&"defense_rng"] == reserve_rng_before, "skill validation failure leaves Defense and round RNG untouched" ) @@ -676,10 +690,10 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void: 0, {&"target_order": 1} ) - _advance(5) - var first := _submit(&"resolve_defense_round", { + var first_attempt := _submit_round_at_due({ &"raid_id": &"raid.save", &"resolution_id": &"round.save.1", }) + var first: CommandResult = first_attempt[&"result"] _expect( first.is_success() and int(_runtime.defense_service.get_raid(&"raid.save")[&"state"]) @@ -699,17 +713,17 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void: }) _expect(queued.is_success(), "queued next-round skill is included in the mid-Raid snapshot") var origin: Dictionary = _runtime.create_snapshot() - _advance(5) - var first_branch := _submit_with_key(&"resolve_defense_round", { + var first_branch_attempt := _submit_round_at_due({ &"raid_id": &"raid.save", &"resolution_id": &"round.save.2", }, &"runtime.defense.save.resolve") + var first_branch: CommandResult = first_branch_attempt[&"result"] var branch_snapshot: Dictionary = _runtime.create_snapshot() _expect(first_branch.is_success(), "first post-save Defense branch resolves") _expect(_runtime.restore_snapshot(origin), "mid-round Runtime snapshot restores atomically") - _advance(5) - var replay_branch := _submit_with_key(&"resolve_defense_round", { + var replay_branch_attempt := _submit_round_at_due({ &"raid_id": &"raid.save", &"resolution_id": &"round.save.2", }, &"runtime.defense.save.resolve") + var replay_branch: CommandResult = replay_branch_attempt[&"result"] _expect( replay_branch.is_success() and replay_branch.details == first_branch.details, "restored mid-round branch reproduces the exact Defense resolution" @@ -796,6 +810,7 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void: legacy[&"systems"][&"runtime"].erase(&"kitchen_production") legacy[&"systems"][&"runtime"].erase(&"elevators") legacy[&"systems"][&"runtime"].erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(legacy[&"systems"][&"runtime"]) legacy[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 legacy[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") legacy[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_requires_connector") @@ -1029,3 +1044,64 @@ func _expect(condition: bool, label: String) -> void: return _failures += 1 push_error(" ✗ %s" % label) + + +## Explicit API coverage schedules the command at its actual deadline. The +## normal kernel executes APPLY_COMMANDS before automatic defense progression. +## Captures bracket that actual handler, excluding later unrelated tick phases. +func _submit_round_at_due( + payload: Dictionary, + idempotency_key: StringName = &"" +) -> Dictionary: + var raid_id := StringName(payload[&"raid_id"]) + var raid: Dictionary = _runtime.defense_service.get_raid(raid_id) + var due := int(raid[&"state_changed_at"]) + int(raid[&"round_minutes"]) + _expect(due > _clock.absolute_minute, "manual deadline helper receives a future configured deadline") + if due <= _clock.absolute_minute: + return {&"result": _submit(&"resolve_defense_round", payload), &"before": {}, &"after": {}} + if idempotency_key.is_empty(): + _command_sequence += 1 + idempotency_key = StringName("runtime.defense.%04d" % _command_sequence) + var captures := {&"before": {}, &"after": {}, &"signals": []} + var executors: Dictionary = _runtime.get("_executors") + var original: Callable = executors[&"resolve_defense_round"] + executors[&"resolve_defense_round"] = func(command: SimulationCommand) -> CommandResult: + captures[&"before"] = _capture_defense_views() + var result: CommandResult = original.call(command) + captures[&"after"] = _capture_defense_views() + return result + var accepted: CommandResult = _runtime.submit_command( + &"resolve_defense_round", payload, &"defense_controller", raid_id, + 80, due, idempotency_key + ) + var capture_completed := func(result: CommandResult) -> void: + if result.command_id == accepted.command_id: + (captures[&"signals"] as Array).append(result.copy()) + _runtime.command_completed.connect(capture_completed) + _clock.advance_minutes(due - _clock.absolute_minute) + _clock.set_paused(true) + _runtime.command_completed.disconnect(capture_completed) + executors[&"resolve_defense_round"] = original + var completed: CommandResult = _runtime.command_queue.get_processed_result(accepted.command_id) + _expect(accepted.is_success() and completed != null, "manual defense command is genuinely processed at its scheduled tick") + _expect( + (captures[&"signals"] as Array).size() == 1 + and completed != null + and (captures[&"signals"][0] as CommandResult).to_dictionary() == completed.to_dictionary(), + "command_completed emits the actual persisted result, including failures" + ) + captures[&"result"] = completed + return captures + + +func _capture_defense_views() -> Dictionary: + return { + &"defense": _runtime.defense_service.create_snapshot(), + &"defense_rng": _runtime.get_rng_stream(&"defense").snapshot(), + &"authority_rng": _runtime.get_rng_stream(&"authority").snapshot(), + &"economy": _runtime.economy_service.create_snapshot(), + &"state": _state.create_snapshot(), + &"inventory": _runtime.inventory_service.create_snapshot(), + &"staff": _runtime.staff_service.create_snapshot(), + &"facilities": _runtime.facility_service.create_snapshot(), + } diff --git a/tests/s5/test_runtime_event_defense.gd b/tests/s5/test_runtime_event_defense.gd new file mode 100644 index 0000000..e03724b --- /dev/null +++ b/tests/s5/test_runtime_event_defense.gd @@ -0,0 +1,321 @@ +extends "res://tests/s5/test_runtime_defense_cycle.gd" + +## Acceptance tests for the configured Event -> Defense bridge. Source causes, facilities and every +## combat value below are disclosed fixtures. This does not prove normal thief, +## male bankruptcy or bank delinquency generation or an approved balance. + + +func _run() -> void: + print("Configured Event -> Defense: identity, rollback and save") + _test_failure_policy_and_expiry() + _test_event_rollback_preserves_failed_round_guard() + _test_serialized_action_enum_rejection() + _test_source_families_from_ordinary_event_phase() + _test_replay_conflict_and_manual_provenance_rejection() + _test_rollback_of_later_failed_action() + _test_choice_replay_and_real_cost_commit_failure() + _test_save_branch_provenance_corruption_and_legacy() + _cleanup() + if _failures == 0: print("PASS: %d checks" % _checks) + else: push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _test_source_families_from_ordinary_event_phase() -> void: + _reset_runtime(92_001) + for kind: String in ["THIEF", "BANKRUPTCY_RIOT", "BANK_DELINQUENCY", "AUTHORITY_ESCALATION"]: + var objective := StringName("objective.event." + kind) + _create_staff_and_facility(StringName("guard.event." + kind), objective) + var action := _event_raid_action(objective, kind) + var instance := StringName("event.instance.fixture." + kind) + _expect(_register_threat_event(StringName("event.fixture." + kind), [action]).is_success(), "explicit configured event definition registers for " + kind) + _expect(_enqueue_threat_event(StringName("event.fixture." + kind), instance, StringName("cause.fixture." + kind)).is_success(), "disclosed cause enters the actual Event queue for " + kind) + var before := int(_runtime.defense_service.get_raid_ids().size()) + var commands_before := int(_runtime.command_queue.processed_count()) + _advance(1) + var raid_id := _event_raid_id(instance, action.action_id) + var raid: Dictionary = _runtime.defense_service.get_raid(raid_id) + _expect(_runtime.defense_service.get_raid_ids().size() == before + 1 and raid[&"state"] == DefenseService.RaidState.PREPARING, "ordinary event phase creates an existing preparing threat for " + kind) + _expect(raid[&"created_at"] == _clock.absolute_minute and raid[&"source_id"] == StringName("cause.fixture." + kind) and raid[&"event_source"]["source_kind"] == kind, "creation time and source families are preserved independently") + _expect(_runtime.event_service.get_instance(instance).state == EventInstance.State.COMPLETED and _count_domain_events(&"DEFENSE_RAID_CREATED", raid_id) == 1, "automatic Event action completes once and emits one real incident event") + _expect(_runtime.command_queue.processed_count() == commands_before, "ordinary event sink creates no synthetic player command") + # Incomplete templates fail closed rather than using Defense defaults. + var incomplete := _event_raid_action(&"objective.event.THIEF", "THIEF") + (incomplete.payload[&"definition"] as Dictionary).erase(&"round_minutes") + _expect(_register_threat_event(&"event.fixture.incomplete", [incomplete]).is_success(), "schema accepts an action whose runtime configuration must still be checked") + _expect(_enqueue_threat_event(&"event.fixture.incomplete", &"event.instance.incomplete", &"cause.incomplete").is_success(), "incomplete disclosed template enters the event queue") + var raids_before: Dictionary = _runtime.defense_service.create_snapshot() + _advance(1) + raids_before[&"current_minute"] = _clock.absolute_minute + _expect(_runtime.defense_service.create_snapshot() == raids_before and _runtime.event_service.get_instance(&"event.instance.incomplete").state == EventInstance.State.CANCELLED, "missing interval creates no threat and preserves every existing raid") + + +func _test_replay_conflict_and_manual_provenance_rejection() -> void: + _reset_runtime(92_002) + _create_staff_and_facility(&"guard.event.replay", &"objective.event.replay") + var action := _event_raid_action(&"objective.event.replay", "BANKRUPTCY_RIOT") + _expect(_register_threat_event(&"event.fixture.replay", [action]).is_success(), "replay fixture definition registers") + _expect(_enqueue_threat_event(&"event.fixture.replay", &"event.instance.replay", &"male.guest.cause.fixture").is_success(), "disclosed cause is queued") + _advance(1) + var raid_id := _event_raid_id(&"event.instance.replay", action.action_id) + _expect(_register_defender(raid_id, &"guard.event.replay", 50).is_success() and _register_objective(raid_id, &"objective.event.replay", 20).is_success(), "ordinary defense assignment connects real employee and facility") + _expect(_submit(&"start_defense_raid", {&"raid_id": raid_id}).is_success(), "ordinary start command begins the configured incident") + _advance(5) + var context := {&"event_instance_id": &"event.instance.replay", &"threat_source_id": &"male.guest.cause.fixture"} + var before: Dictionary = _runtime.create_snapshot() + var replay: Dictionary = _runtime._execute_event_action_boundary(action, &"apply", context, null) + _expect(replay.get(&"ok", false) and replay.get(&"is_replay", false) and _runtime.create_snapshot() == before, "event-action replay preserves active combat, staff locks, resources, RNG, clock and event log") + var conflicting := EventAction.from_variant(action.create_snapshot()) + (conflicting.payload[&"definition"][&"waves"] as Array)[0][&"threat_strength"] += 1 + var rejected: Dictionary = _runtime._execute_event_action_boundary(conflicting, &"apply", context, null) + _expect(not rejected.get(&"ok", true) and _runtime.create_snapshot() == before, "same cause/action with a conflicting template never replaces its existing battle") + var forged_definition := _threat_definition(&"objective.event.replay") + forged_definition[&"event_source"] = _runtime.defense_service.get_raid(raid_id)[&"event_source"] + var defense_before: Dictionary = _runtime.defense_service.create_snapshot() + _expect(not _submit(&"create_defense_raid", {&"raid_id": &"forged.manual.provenance", &"definition": forged_definition}).is_success() and _runtime.defense_service.create_snapshot() == defense_before, "manual raid API cannot forge event provenance") + + +func _test_rollback_of_later_failed_action() -> void: + _reset_runtime(92_003) + _create_staff_and_facility(&"guard.event.rollback", &"objective.event.rollback") + _expect(_runtime.staff_service.create_task(&"fixture.work", 20, _clock.absolute_minute, {}, &"task.event.duplicate").is_success(), "disclosed duplicate task is prepared for apply-time failure") + var threat := _event_raid_action(&"objective.event.rollback", "BANK_DELINQUENCY") + var duplicate := EventAction.new().configure(&"fail.after.threat", EventAction.Type.CREATE_TASK, {&"task_id": &"task.event.duplicate", &"task_type": &"fixture.work"}) + _expect(_register_threat_event(&"event.fixture.rollback", [threat, duplicate]).is_success(), "rollback-all event includes threat followed by an apply-time failure") + _expect(_enqueue_threat_event(&"event.fixture.rollback", &"event.instance.rollback", &"loan.overdue.cause.fixture").is_success(), "disclosed overdue cause enters the queue without claiming a real loan") + var before: Dictionary = _runtime.defense_service.create_snapshot() + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + _advance(1) + before[&"current_minute"] = _clock.absolute_minute + var raid_id := _event_raid_id(&"event.instance.rollback", threat.action_id) + _expect(_runtime.defense_service.create_snapshot() == before and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "later action failure rolls the new incident and its provenance back") + _expect(_runtime.defense_service.get_raid(raid_id).is_empty() and _count_domain_events(&"DEFENSE_RAID_CREATED", raid_id) == 0, "rolled-back incident leaves no committed creation event") + _expect(_runtime.event_service.get_instance(&"event.instance.rollback").state == EventInstance.State.CANCELLED, "failed event follows the existing cancellation policy") + + +func _test_save_branch_provenance_corruption_and_legacy() -> void: + _reset_runtime(92_004) + _create_staff_and_facility(&"guard.event.save", &"objective.event.save") + var action := _event_raid_action(&"objective.event.save", "THIEF") + _expect(_register_threat_event(&"event.fixture.save", [action]).is_success(), "save fixture definition registers") + _expect(_enqueue_threat_event(&"event.fixture.save", &"event.instance.save", &"thief.cause.fixture").is_success(), "save fixture source enters queue") + var queued: Dictionary = _runtime.create_snapshot() + _advance(1) + var expected: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(queued), "queued configured threat restores before execution") + _advance(1) + _expect(_runtime.create_snapshot() == expected, "save/restart branch produces the exact same event-derived raid ID, provenance, event and command state") + var raid_id := _event_raid_id(&"event.instance.save", action.action_id) + for kind: String in ["digest", "type", "event", "source", "interval", "wave_limit", "wave_strength"]: + var corrupt: Dictionary = expected.duplicate(true) + var raids: Array = corrupt[&"systems"][&"runtime"][&"defense"][&"raids"] + var raid: Dictionary = raids[0] + if kind == "digest": raid[&"event_source"]["template_digest"] = "a".repeat(64) + elif kind == "type": raid[&"event_source"] = [] + elif kind == "event": raid[&"event_source"]["event_instance_id"] = "missing.event" + elif kind == "interval": raid[&"round_minutes"] = 7 + elif kind == "wave_limit": (raid[&"waves"] as Array)[0][&"max_rounds"] += 1 + elif kind == "wave_strength": + (raid[&"waves"] as Array)[0][&"threat_strength"] += 1 + (raid[&"waves"] as Array)[0][&"remaining_threat"] += 1 + else: raid[&"source_id"] = "unrelated.cause" + _expect(not _runtime.restore_snapshot(corrupt) and _runtime.create_snapshot() == expected, "corrupt event provenance is rejected atomically: " + kind) + _expect(_runtime.defense_service.get_raid(raid_id)[&"event_source"]["template_digest"].length() == 64, "valid event provenance remains after all rejected restores") + _expect(_submit(&"set_defense_retreat_policy", {&"raid_id": raid_id, &"policy": &"PROTECT_AND_RETREAT", &"threshold": 35}).is_success(), "normal player policy remains mutable after event creation") + var changed_policy: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(changed_policy) and _runtime.create_snapshot() == changed_policy, "immutable template validation preserves lawful policy changes") + _reset_runtime(92_005) + _create_staff_and_facility(&"guard.event.legacy", &"objective.event.legacy") + _expect(_submit(&"create_defense_raid", {&"raid_id": &"raid.legacy.manual", &"definition": _threat_definition(&"objective.event.legacy")}).is_success(), "real manual raid exists before legacy provenance migration") + var legacy: Dictionary = _runtime.create_snapshot() + legacy[&"systems"][&"runtime"][&"runtime_schema_version"] = 12 + (legacy[&"systems"][&"runtime"][&"defense"][&"raids"] as Array)[0].erase(&"event_source") + var clean: Dictionary = legacy[&"systems"][&"runtime"][&"facility_cleanliness"].duplicate(true) + _expect(_runtime.restore_snapshot(legacy) and _runtime.facility_cleanliness.create_snapshot() == clean, "version-12 save keeps its required cleaning state when migrating to event schema 13") + _expect(not clean["facilities"].is_empty() and _runtime.defense_service.get_raid(&"raid.legacy.manual")[&"event_source"].is_empty(), "legacy migration preserves a nonempty cleanliness participant and old manual raid") + var migrated: Dictionary = _runtime.create_snapshot() + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "legacy12 re-emits current13 without losing required12 data") + var missing_clean: Dictionary = legacy.duplicate(true) + missing_clean[&"systems"][&"runtime"].erase(&"facility_cleanliness") + _expect(not _runtime.restore_snapshot(missing_clean) and _runtime.create_snapshot() == migrated, "legacy12 missing mandatory cleanliness is rejected atomically") + var malformed_clean: Dictionary = legacy.duplicate(true) + malformed_clean[&"systems"][&"runtime"][&"facility_cleanliness"]["schema_version"] = 99 + _expect(not _runtime.restore_snapshot(malformed_clean) and _runtime.create_snapshot() == migrated, "legacy12 malformed mandatory cleanliness is rejected atomically") + var future: Dictionary = _runtime.create_snapshot() + var current: Dictionary = future.duplicate(true) + future[&"systems"][&"runtime"][&"runtime_schema_version"] = 14 + _expect(not _runtime.restore_snapshot(future) and _runtime.create_snapshot() == current, "unknown future runtime schema remains rejected") + + +func _test_choice_replay_and_real_cost_commit_failure() -> void: + for commit_failure: bool in [false, true]: + _reset_runtime(92_006 if not commit_failure else 92_007) + _create_staff_and_facility(&"guard.event.choice", &"objective.event.choice") + var instance_id := &"event.instance.choice.fixture" + var threat := _event_raid_action(&"objective.event.choice", "THIEF") + var actions: Array[Dictionary] = [threat.create_snapshot()] + if commit_failure: + # This disclosed action spends the actual reserved escrow before the + # real commit. There is no mocked executor, cost callback or clock. + var consume := EventAction.new().configure(&"fixture.consume.escrow", EventAction.Type.ADD_LEDGER_ENTRY, { + &"from_account": StringName("EVENT_ESCROW:%s" % instance_id), + &"to_account": &"OPERATING_EXPENSE", &"amount": 7, + }) + actions.append(consume.create_snapshot()) + var registered: CommandResult = _runtime.submit_command(&"register_event_definition", {&"definition": { + &"event_id": &"event.fixture.choice", &"category": &"DEFENSE", &"foreground": false, + &"transaction_policy": &"ROLLBACK_ALL", &"choices": [{&"choice_id": &"fixture.choice", + &"text_key": &"fixture.choice.text", &"cost": {&"gold": 7}, &"actions": actions}], + }}) + _expect(registered.is_success() and _enqueue_threat_event(&"event.fixture.choice", instance_id, &"choice.cause.fixture").is_success(), "disclosed choice threat registers and enters the actual event queue") + _advance(1) + var instance: EventInstance = _runtime.event_service.get_instance(instance_id) + _expect(instance.state == EventInstance.State.WAITING_CHOICE, "ordinary event phase waits for an actual choice before creating its raid") + var before: Dictionary = _runtime.defense_service.create_snapshot() + var cash_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var expense_before: int = _runtime.economy_service.get_balance(&"OPERATING_EXPENSE") + var payload := {&"event_instance_id": instance_id, &"choice_instance_id": instance.choice_instance_id, &"choice_id": &"fixture.choice"} + var first := _submit(&"submit_event_choice", payload) + var raid_id := _event_raid_id(instance_id, threat.action_id) + if commit_failure: + _expect(not first.is_success() and _runtime.defense_service.create_snapshot() == before, "real cost commit failure rolls back the newly-created raid and provenance") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before and _runtime.economy_service.get_balance(&"OPERATING_EXPENSE") == expense_before, "failed commit rolls back its drain action and releases the actual escrow once") + _expect(_count_domain_events(&"DEFENSE_RAID_CREATED", raid_id) == 0 and _runtime.economy_service.get_balance(StringName("EVENT_ESCROW:%s" % instance_id)) == 0, "failed choice leaves no committed threat notification or stranded escrow") + else: + _expect(first.is_success() and _runtime.defense_service.get_raid(raid_id)[&"state"] == DefenseService.RaidState.PREPARING, "actual successful choice creates one preparing incident") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash_before - 7 and _runtime.economy_service.get_balance(&"OPERATING_EXPENSE") == expense_before + 7, "successful choice commits its disclosed fixture cost once") + var defense_after: Dictionary = _runtime.defense_service.create_snapshot() + var economy_after: Dictionary = _runtime.economy_service.create_snapshot() + var events_after: Dictionary = _runtime.event_service.create_snapshot() + # Successful choice commands legitimately resume the real clock. Pause + # through its public API to submit and observe the retry immediately. + _clock.set_paused(true) + var replay := _submit(&"submit_event_choice", payload) + _expect(replay.is_replay and replay.is_success() == first.is_success() and _runtime.defense_service.create_snapshot() == defense_after and _runtime.economy_service.create_snapshot() == economy_after and _runtime.event_service.create_snapshot() == events_after, "different command key replays the same real choice result without threat creation or payment retry") + _expect(_runtime.economy_service.validate_ledger()[&"ok"] and _runtime.event_service.validate_invariants()[&"ok"], "configured threat choice preserves actual Event and double-entry invariants") + + +func _threat_definition(objective: StringName) -> Dictionary: + return {&"entry_points": [&"fixture.event.entrance"], &"objective_ids": [objective], + &"round_minutes": 5, &"max_duration_minutes": 120, + &"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0, + &"terrain_bonus": 0, &"command_bonus": 0, &"surprise_penalty": 0, &"multiple_entry_penalty": 0, + &"waves": [{&"threat_strength": 600, &"max_rounds": 3, &"retreat_threshold": 0}]} + + +func _event_raid_action(objective: StringName, kind: String) -> EventAction: + return EventAction.new().configure(&"configured.threat", EventAction.Type.CREATE_DEFENSE_RAID, {&"template_id": &"fixture.template.explicit", &"source_kind": kind, &"definition": _threat_definition(objective)}) + + +func _register_threat_event(event_id: StringName, actions: Array) -> CommandResult: + var snapshots: Array[Dictionary] = [] + for action: EventAction in actions: snapshots.append(action.create_snapshot()) + return _runtime.submit_command(&"register_event_definition", {&"definition": {&"event_id": event_id, &"category": &"DEFENSE", &"foreground": false, &"transaction_policy": &"ROLLBACK_ALL", &"automatic_actions": snapshots}}) + + +func _enqueue_threat_event(event_id: StringName, instance_id: StringName, source_id: StringName) -> CommandResult: + return _runtime.submit_command(&"enqueue_event", {&"event_id": event_id, &"actor_ids": [], &"options": {&"event_instance_id": instance_id, &"context": {&"threat_source_id": source_id}}}) + + +func _event_raid_id(instance_id: StringName, action_id: StringName) -> StringName: + return StringName("event_defense_raid." + JSON.stringify([String(instance_id), String(action_id)], "", true, true).sha256_text()) + + +func _test_serialized_action_enum_rejection() -> void: + _reset_runtime(92_008) + _create_staff_and_facility(&"guard.event.enum", &"objective.event.enum") + var action := _event_raid_action(&"objective.event.enum", "THIEF") + var bad_types: Array = [999, -1, "FUTURE_UNKNOWN_ACTION", 1.5, true, null] + for index: int in range(bad_types.size()): + var raw: Dictionary = action.create_snapshot() + raw[&"type"] = bad_types[index] + var event_id := StringName("event.enum.invalid.%d" % index) + var defense_before: Dictionary = _runtime.defense_service.create_snapshot() + var result: CommandResult = _runtime.submit_command(&"register_event_definition", {&"definition": {&"event_id": event_id, &"category": &"DEFENSE", &"automatic_actions": [raw]}}) + _expect(not result.is_success() and _runtime.event_service.get_definition(event_id) == null and _runtime.defense_service.create_snapshot() == defense_before, "actual event registration rejects an unknown serialized action type without dropping or coercing it: %d" % index) + _expect(_register_threat_event(&"event.fixture.enum", [action]).is_success() and _enqueue_threat_event(&"event.fixture.enum", &"event.instance.enum", &"cause.fixture.enum").is_success(), "valid queued event exists for corrupt enum restore tests") + var queued: Dictionary = _runtime.create_snapshot() + for raw_type: Variant in bad_types: + var corrupt: Dictionary = queued.duplicate(true) + for raw_definition: Dictionary in corrupt[&"systems"][&"runtime"][&"event_definitions"]: + if String(raw_definition[&"event_id"]) == "event.fixture.enum": + (raw_definition[&"automatic_actions"] as Array)[0][&"type"] = raw_type + _expect(not _runtime.restore_snapshot(corrupt) and _runtime.create_snapshot() == queued, "queued complete threat template rejects a corrupt action enum atomically") + var integer_enum: Dictionary = queued.duplicate(true) + for raw_definition: Dictionary in integer_enum[&"systems"][&"runtime"][&"event_definitions"]: + if String(raw_definition[&"event_id"]) == "event.fixture.enum": + (raw_definition[&"automatic_actions"] as Array)[0][&"type"] = EventAction.Type.CREATE_DEFENSE_RAID + _expect(_runtime.restore_snapshot(integer_enum) and _runtime.create_snapshot() == queued, "known serialized integer enum remains compatible and canonicalizes to its name") + + +func _test_failure_policy_and_expiry() -> void: + for policy: String in ["SKIP_FAILED", "BRANCH_FAILURE", "EXPIRED"]: + _reset_runtime(92_020) + _create_staff_and_facility(&"guard.event.policy", &"objective.event.policy") + _expect(_runtime.staff_service.create_task(&"fixture.work", 20, _clock.absolute_minute, {}, &"task.event.policy.duplicate").is_success(), "actual duplicate task exists for policy failure") + var primary := _event_raid_action(&"objective.event.policy", "THIEF") + var duplicate := EventAction.new().configure(&"fail.policy", EventAction.Type.CREATE_TASK, {&"task_id": &"task.event.policy.duplicate", &"task_type": &"fixture.work"}) + var fallback := _event_raid_action(&"objective.event.policy", "AUTHORITY_ESCALATION") + fallback.action_id = &"fallback.threat" + var definition := {&"event_id": &"event.fixture.policy", &"category": &"DEFENSE", &"foreground": false, &"automatic_actions": [primary.create_snapshot(), duplicate.create_snapshot()]} + if policy == "EXPIRED": + definition[&"transaction_policy"] = &"ROLLBACK_ALL" + definition[&"fallback_actions"] = [fallback.create_snapshot()] + else: + definition[&"transaction_policy"] = policy + definition[&"failure_actions"] = [fallback.create_snapshot()] + _expect(_runtime.submit_command(&"register_event_definition", {&"definition": definition}).is_success(), "real Event registers policy " + policy) + var options := {&"event_instance_id": &"event.instance.policy", &"context": {&"threat_source_id": &"cause.policy.fixture"}} + if policy == "EXPIRED": options[&"expires_at"] = _clock.absolute_minute + _expect(_runtime.submit_command(&"enqueue_event", {&"event_id": &"event.fixture.policy", &"actor_ids": [], &"options": options}).is_success(), "real Event queue accepts policy cause " + policy) + _advance(1) + var primary_id := _event_raid_id(&"event.instance.policy", primary.action_id) + var fallback_id := _event_raid_id(&"event.instance.policy", fallback.action_id) + var expected_id := primary_id if policy == "SKIP_FAILED" else fallback_id + var missing_id := fallback_id if policy == "SKIP_FAILED" else primary_id + _expect(_runtime.defense_service.get_raid_ids() == [expected_id] and _runtime.defense_service.get_raid(missing_id).is_empty(), "policy commits only the intended threat and discards rolled-back primary: " + policy) + _expect(_count_domain_events(&"DEFENSE_RAID_CREATED", expected_id) == 1 and _count_domain_events(&"DEFENSE_RAID_CREATED", missing_id) == 0, "policy leaves exactly one committed incident notification: " + policy) + var instance: EventInstance = _runtime.event_service.get_instance(&"event.instance.policy") + _expect(instance.state == (EventInstance.State.EXPIRED if policy == "EXPIRED" else EventInstance.State.COMPLETED), "policy preserves its real Event terminal state: " + policy) + var saved: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(saved) and _runtime.create_snapshot() == saved, "non-primary threat provenance restores exactly: " + policy) + + +func _test_event_rollback_preserves_failed_round_guard() -> void: + _reset_runtime(92_021) + _create_staff_and_facility(&"guard.event.failure", &"objective.event.failure") + _create_staff_and_facility(&"guard.event.uncommitted", &"objective.event.uncommitted") + var prior_definition := _threat_definition(&"objective.event.failure") + prior_definition[&"waves"][0][&"max_rounds"] = 1 + _expect(_submit(&"create_defense_raid", {&"raid_id": &"raid.event.prior", &"definition": prior_definition}).is_success(), "real existing incident precedes Event rollback") + _expect(_register_defender(&"raid.event.prior", &"guard.event.failure", 90).is_success() and _register_objective(&"raid.event.prior", &"objective.event.failure", 20).is_success() and _submit(&"start_defense_raid", {&"raid_id": &"raid.event.prior"}).is_success(), "prior real defense has an automatic due round") + var failure := {&"calls": 0} + var normal_outcome := Callable(_runtime, "_apply_defense_outcome_boundary") + var injected := func(payload: Dictionary, phase: StringName, token: Variant) -> Dictionary: + var report: Dictionary = normal_outcome.call(payload, phase, token) + if phase == &"apply": + failure[&"calls"] += 1 + if bool(report.get(&"ok", false)): + report[&"ok"] = false + report[&"error"] = "Disclosed real post-apply failure before Event rollback." + return report + _runtime.defense_service.configure_callbacks(Callable(_runtime, "_roll_defense_round"), Callable(_runtime, "_apply_defense_skill_boundary"), injected, Callable(_runtime, "_apply_defense_rng_boundary")) + _clock.set_paused(false) + _clock.advance_minutes(5) + _expect(_clock.paused and failure[&"calls"] == 1 and _runtime.defense_service.get_raid(&"raid.event.prior")[&"current_round"] == 0, "real automatic round fails atomically and pauses once") + _expect(_runtime.staff_service.create_task(&"fixture.work", 20, _clock.absolute_minute, {}, &"task.event.guard.duplicate").is_success(), "Event later apply-time failure uses real duplicate task") + var threat := _event_raid_action(&"objective.event.uncommitted", "THIEF") + var duplicate := EventAction.new().configure(&"fail.guard", EventAction.Type.CREATE_TASK, {&"task_id": &"task.event.guard.duplicate", &"task_type": &"fixture.work"}) + _expect(_register_threat_event(&"event.fixture.guard", [threat, duplicate]).is_success() and _enqueue_threat_event(&"event.fixture.guard", &"event.instance.guard", &"cause.guard.fixture").is_success(), "real failed-minute Event registers and queues") + var defense_before: Dictionary = _runtime.defense_service.create_snapshot() + var rng_before: Dictionary = _runtime.get_rng_stream(&"defense").snapshot() + var failed_event: CommandResult = _runtime.event_service.start_next_event(_clock.absolute_minute) + _expect(not failed_event.is_success() and _runtime.defense_service.create_snapshot() == defense_before and _runtime.get_rng_stream(&"defense").snapshot() == rng_before, "Event rollback restores existing Defense and RNG after newly-created incident") + _expect(_count_domain_events(&"DEFENSE_AUTOMATIC_ROUND_FAILED", &"raid.event.prior") == 1 and _count_domain_events(&"DEFENSE_RAID_CREATED", _event_raid_id(&"event.instance.guard", threat.action_id)) == 0, "Event rollback keeps prior failure diagnostic while removing uncommitted creation") + _runtime._advance_defense(_clock.absolute_minute) + _expect(failure[&"calls"] == 1 and _runtime.defense_service.get_raid(&"raid.event.prior")[&"current_round"] == 0, "Event rollback cannot clear the real same-minute automatic attempt guard") + _runtime._configure_defense_boundaries() + _clock.set_paused(false) + _clock.advance_minutes(1) + _clock.set_paused(true) + _expect(_runtime.defense_service.get_raid(&"raid.event.prior")[&"current_round"] == 1, "next real minute resumes prior incident after its failed attempt") diff --git a/tests/s5/test_runtime_event_defense.gd.uid b/tests/s5/test_runtime_event_defense.gd.uid new file mode 100644 index 0000000..b61f19a --- /dev/null +++ b/tests/s5/test_runtime_event_defense.gd.uid @@ -0,0 +1 @@ +uid://dwqqi0hae5h52 diff --git a/tests/s6/test_performance_budget.gd b/tests/s6/test_performance_budget.gd index bf6ea34..cbd42cb 100644 --- a/tests/s6/test_performance_budget.gd +++ b/tests/s6/test_performance_budget.gd @@ -35,10 +35,10 @@ func _run() -> void: _expect(exceeded == [&"custom"], "budget violation emits its exact metric once") profiler.record_simulation_tick(3_000, 1) - profiler.record_simulation_tick(11_000, 4) + profiler.record_simulation_tick(11_000, 3) _expect(profiler.get_report(&"simulation_x1")[&"limit_us"] == 4_000, "x1 uses the MVP average budget") - _expect(profiler.get_report(&"simulation_x4")[&"limit_us"] == 12_000, "x4 uses the MVP P95 budget") - _expect(not profiler.get_report(&"simulation_x4")[&"budget_exceeded"], "x4 sample within budget passes") + _expect(profiler.get_report(&"simulation_x3")[&"limit_us"] == 12_000, "x3 uses the MVP P95 budget") + _expect(not profiler.get_report(&"simulation_x3")[&"budget_exceeded"], "x3 sample within budget passes") profiler.record_sample(&"path_query", 2_001) _expect(profiler.get_report(&"path_query")[&"budget_exceeded"], "path P95 enforces two milliseconds") @@ -63,10 +63,10 @@ func _run() -> void: ) _expect(not clock.get_performance_reports().is_empty(), "Kernel exposes read-only performance reports") clock.reset(1, 6, 0) - clock.set_speed(4) + clock.set_speed(3) var accelerated_ticks = clock.process_real_delta(clock.REAL_SECONDS_PER_GAME_MINUTE) - _expect(accelerated_ticks == 4, "x4 process frame advances the expected four game-minute ticks") - _expect(clock.performance_budget.get_report(&"simulation_x4")[&"lifetime_sample_count"] == 1, "x4 performance measures the aggregate frame once rather than four isolated ticks") + _expect(accelerated_ticks == 3, "x3 process frame advances the expected three game-minute ticks") + _expect(clock.performance_budget.get_report(&"simulation_x3")[&"lifetime_sample_count"] == 1, "x3 performance measures the aggregate frame once rather than three isolated ticks") _expect(clock.performance_budget.get_report(&"main_thread_stall")[&"lifetime_sample_count"] == 1, "active simulation frames feed the main-thread stall budget") clock.free() diff --git a/tests/s6/test_runtime_save_lifecycle.gd b/tests/s6/test_runtime_save_lifecycle.gd index 14aed1e..3a08b35 100644 --- a/tests/s6/test_runtime_save_lifecycle.gd +++ b/tests/s6/test_runtime_save_lifecycle.gd @@ -171,7 +171,7 @@ func _test_exact_round_trip_recovery_pause_and_performance() -> void: _reset_runtime(60_005, 3, 7, 15) var path := TEST_PREFIX + "_round_trip" SaveService.delete_save(path) - _clock.set_speed(4) + _clock.set_speed(3) var expected_snapshot = _runtime.create_snapshot() var expected_digest := SimulationDigest.compute(expected_snapshot) var first_save = _runtime.save_to_path(path, {&"display_name": "Runtime Exact"}) @@ -353,7 +353,7 @@ func _test_all_domain_invalid_candidates_preserve_complete_live_state() -> void: EventDefinition.ContentRating.GENERAL, [&"local_atomic_filter"] ) - _clock.set_speed(4) + _clock.set_speed(3) _clock.set_paused(true) _expect( _runtime.queue_manual_checkpoint(TEST_PREFIX + "_never_written")[&"ok"], diff --git a/tests/staff/test_dormitory_dimension_migration.gd b/tests/staff/test_dormitory_dimension_migration.gd new file mode 100644 index 0000000..6fb60f7 --- /dev/null +++ b/tests/staff/test_dormitory_dimension_migration.gd @@ -0,0 +1,65 @@ +extends SceneTree +const Routes=preload("res://scripts/construction/facility_resize_routes.gd") +var checks:=0 +var failures:=0 +var runtime: Variant +var clock: Variant +var state: Variant +func expect(ok: bool, message: String) -> void: + checks+=1 + if not ok: failures+=1;push_error(message) +func _initialize() -> void: call_deferred("run") +func build(id: StringName, origin: Vector3i) -> bool: + var result: CommandResult=runtime.submit_command(&"build_facility",{&"definition_id":id,&"origin":origin}) + expect(result.is_success(),"build legacy fixture %s: %s" % [id,result.message]) + if not result.is_success(): return false + clock.advance_minutes(runtime.definition_registry.get_facility(id).build_minutes) + return true +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();runtime.initialize(clock,state,42505);clock.set_paused(true);runtime.enable_production_operations() + var paths: Array[String]=FacilityCatalog.FACILITY_PATHS+FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS+FacilityCatalog.RESCUE_FACILITY_PATHS+FacilityCatalog.ORIGINAL_FACILITY_PATHS + paths.erase("res://data/facilities/expansion/staff_dormitory.tres");paths.append("res://tests/fixtures/legacy_staff_dormitory.tres") + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"],"register explicit legacy dormitory fixture") + if not build(&"warehouse",Vector3i(-6,0,1)) or not build(&"staff_dormitory",Vector3i(-6,2,1)) or not build(&"service_stairs",Vector3i(-7,1,1)) or not build(&"service_stairs",Vector3i(-7,2,1)) or not build(&"service_stairs",Vector3i(-7,3,1)): finish();return + var id: StringName=&"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"staff_dormitory": id=room.instance_id + expect(runtime.submit_command(&"register_staff",{&"staff_id":&"legacy_sleeper",&"role_id":&"porter",&"options":{&"current_cell":Vector3i(-7,3,1),&"health":20,&"fatigue":20,&"state":StaffRuntime.State.OFF_DUTY}}).is_success(),"old resting worker starts on a valid upper landing") + paths.erase("res://tests/fixtures/legacy_staff_dormitory.tres");paths.append("res://data/facilities/expansion/staff_dormitory.tres") + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"],"switch to canonical two-by-one definition") + var before: Dictionary=runtime.create_snapshot() + expect(runtime.get_staff_rest_report(&"legacy_sleeper")[&"resting"],"legacy worker rests beside the original upper row") + runtime.path_service.set_cell_temporary_block(Vector3i(-7,2,1),true) + var blocked: Dictionary=runtime.create_snapshot() + var rejected: Dictionary=runtime.prepare_facility_dimension_snapshot(id) + expect(not rejected[&"ok"] and rejected.get(&"routes",{}).get(&"errors",[]).any(func(error: Variant): return error is Dictionary and error[&"code"]==&"STAFF_ACCESS_LOST"), "blocked lower entrance rejects loss of off-duty rest access") + expect(runtime.create_snapshot()==blocked and runtime.restore_snapshot(before),"failed route candidate preserves source then valid fixture restores") + var task_result: CommandResult=runtime.staff_service.create_task(&"maintenance",10,clock.absolute_minute,{&"target_entity_id":id,&"target_cell":Vector3i(-7,3,1)},&"legacy_dorm_task") + expect(task_result.is_success() and runtime.restore_snapshot(runtime.create_snapshot()),"pending old-entry task is a valid Runtime source") + var task_source: Dictionary=runtime.create_snapshot() + rejected=runtime.prepare_facility_dimension_snapshot(id) + expect(not rejected[&"ok"] and rejected.get(&"routes",{}).get(&"errors",[]).any(func(error: Variant): return error is Dictionary and error[&"code"]==&"TASK_DESTINATION_CHANGED"),"old-entry task requires coordinated retargeting rather than silently surviving resize") + expect(runtime.create_snapshot()==task_source and runtime.restore_snapshot(before),"task rejection preserves source state") + task_result=runtime.staff_service.create_task(&"maintenance",10,clock.absolute_minute,{&"target_entity_id":id,&"target_cell":Vector3i(-7,2,1)},&"retained_dorm_task") + expect(runtime.submit_command(&"register_staff",{&"staff_id":&"dorm_maintainer",&"role_id":&"porter",&"options":{&"current_cell":Vector3i(-7,1,1),&"state":StaffRuntime.State.AVAILABLE}}).is_success(),"register worker for retained task") + expect(runtime.staff_service.assign_task_to_staff(&"retained_dorm_task",&"dorm_maintainer",clock.absolute_minute).is_success(),"assign retained task through normal Staff service") + var valid_task: Dictionary=runtime.staff_service.get_task(&"retained_dorm_task").create_snapshot() + var with_task: Dictionary=runtime.prepare_facility_dimension_snapshot(id) + expect(task_result.is_success() and with_task[&"ok"],"task at retained entrance permits migration") + if with_task[&"ok"]: + expect(runtime.restore_snapshot(with_task[&"snapshot"]) and runtime.staff_service.get_task(&"retained_dorm_task").create_snapshot()==valid_task,"accepted task retains exact ownership progress and destination") + expect(runtime.restore_snapshot(before),"restore original geometry before successful rest migration replay") + var prepared: Dictionary=runtime.prepare_facility_dimension_snapshot(id) + expect(prepared[&"ok"] and prepared.get(&"routes",{}).get(&"checked_routes",0)==2,"migration validates off-duty rest access independently of work assignment") + expect(runtime.create_snapshot()==before,"preparing candidate preserves original worker and all live state") + if not prepared[&"ok"]: print(prepared);finish();return + expect(runtime.restore_snapshot(prepared[&"snapshot"]),"full candidate with resting worker restores") + var worker: StaffRuntime=runtime.staff_service.get_staff(&"legacy_sleeper") + expect(worker.current_cell==Vector3i(-7,3,1) and not runtime.get_staff_rest_report(worker.staff_id)[&"resting"],"migration preserves position and requires real movement to resized dormitory") + clock.advance_minutes(2) + expect(worker.current_cell==Vector3i(-7,2,1) and runtime.get_staff_rest_report(worker.staff_id)[&"resting"],"rest driver physically descends to new access after migration") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"post-migration rest travel remains saveable") + finish() +func finish() -> void: + runtime.free();clock.free();state.free();print("Dormitory dimension migration: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/staff/test_dormitory_dimension_migration.gd.uid b/tests/staff/test_dormitory_dimension_migration.gd.uid new file mode 100644 index 0000000..2bacefc --- /dev/null +++ b/tests/staff/test_dormitory_dimension_migration.gd.uid @@ -0,0 +1 @@ +uid://chcs0l4hqkjkv diff --git a/tests/staff/test_dormitory_shift_cycle.gd b/tests/staff/test_dormitory_shift_cycle.gd new file mode 100644 index 0000000..091925c --- /dev/null +++ b/tests/staff/test_dormitory_shift_cycle.gd @@ -0,0 +1,54 @@ +extends SceneTree +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, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func command(kind: StringName, payload: Dictionary) -> bool: + var result: CommandResult = runtime.submit_command(kind, payload) + expect(result.is_success(), "%s: %s" % [kind, result.message]) + return result.is_success() +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();runtime.initialize(clock,state,42505);clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "enable production operations") + for row: Array in [[&"warehouse",Vector3i(-6,0,1)],[&"staff_dormitory",Vector3i(-6,2,1)],[&"service_stairs",Vector3i(-7,1,1)],[&"service_stairs",Vector3i(-7,2,1)]]: + if not command(&"build_facility",{&"definition_id":row[0],&"origin":row[1]}): finish();return + clock.advance_minutes(runtime.definition_registry.get_facility(row[0]).build_minutes) + var warehouse_id: StringName = &"" + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": warehouse_id = facility.instance_id + if not command(&"register_staff",{&"staff_id":&"dorm_night_worker",&"role_id":&"porter",&"options":{&"current_cell":Vector3i(5,0,0),&"role_tags":[&"porter"]}}): finish();return + if not command(&"set_staff_work_assignment",{&"staff_id":&"dorm_night_worker",&"facility_id":warehouse_id,&"template_id":&"NIGHT"}): finish();return + clock.advance_minutes(1110-clock.absolute_minute) + var worker: StaffRuntime = runtime.staff_service.get_staff(&"dorm_night_worker") + var shift: ShiftInstance = runtime.staff_service.get_shift(worker.shift_id) + expect(worker.state == StaffRuntime.State.AVAILABLE and shift != null and shift.actual_started_at >= 1080, "night worker reaches warehouse for normal shift") + clock.advance_minutes(1799-clock.absolute_minute) + expect(worker.state == StaffRuntime.State.AVAILABLE and worker.assigned_facility_id == warehouse_id, "worker remains on assigned shift until its final minute") + clock.advance_minutes(1) + expect(worker.state == StaffRuntime.State.OFF_DUTY, "06:00 naturally ends night shift") + var before_rest: Dictionary = runtime.create_snapshot() + clock.advance_minutes(60) + var rest_report: Dictionary = runtime.get_staff_rest_report(worker.staff_id) + expect(rest_report.get(&"resting",false) and rest_report.get(&"label","") == "宿舎で休憩中" and worker.current_cell == Vector3i(-7,2,1), "shift-ended worker physically reaches dormitory through service stairs") + var after_rest: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(before_rest), "restore shift-end movement checkpoint") + clock.advance_minutes(60) + expect(runtime.create_snapshot() == after_rest, "shift-end commute and rest replay exactly") + clock.advance_minutes(2550-clock.absolute_minute) + worker=runtime.staff_service.get_staff(&"dorm_night_worker") + shift=runtime.staff_service.get_shift(worker.shift_id) + expect(worker.state == StaffRuntime.State.AVAILABLE and worker.assigned_facility_id == warehouse_id and shift != null and shift.start_at == 2520, "next repeating night shift returns rested worker to workplace") + expect(not runtime.get_staff_rest_report(worker.staff_id).get(&"resting",false), "working employee is no longer reported as resting") + expect(runtime.staff_service.validate_invariants()[&"ok"], "full shift-rest-shift preserves staff invariants") + finish() +func finish() -> void: + runtime.free();clock.free();state.free() + print("Dormitory shift cycle: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/staff/test_dormitory_shift_cycle.gd.uid b/tests/staff/test_dormitory_shift_cycle.gd.uid new file mode 100644 index 0000000..fd91745 --- /dev/null +++ b/tests/staff/test_dormitory_shift_cycle.gd.uid @@ -0,0 +1 @@ +uid://nmcyjwou0ic4 diff --git a/tests/staff/test_rescue_elevator.gd b/tests/staff/test_rescue_elevator.gd new file mode 100644 index 0000000..c7295d4 --- /dev/null +++ b/tests/staff/test_rescue_elevator.gd @@ -0,0 +1,87 @@ +extends SceneTree +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, text: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(text) + else: print("PASS: ", text) +func run() -> void: + clock = load("res://autoload/game_clock.gd").new() + state = load("res://autoload/game_state.gd").new() + runtime = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1,6,0); state.reset(); runtime.initialize(clock,state,712); clock.set_paused(true) + for spec: Array in [[&"infirmary",Vector3i(-6,0,1)],[&"service_elevator",Vector3i(0,0,1)]]: + expect(runtime.submit_command(&"build_facility",{&"definition_id":spec[0],&"origin":spec[1]},&"player").is_success(), "ordinary construction: " + String(spec[0])) + clock.advance_minutes(400) + for id: StringName in [&"patient",&"medic"]: + expect(runtime.submit_command(&"register_staff",{&"staff_id":id,&"role_id":&"medic",&"options":{&"current_cell":Vector3i(0,1,1) if id == &"patient" else Vector3i(5,0,1),&"health":0 if id == &"patient" else 100,&"state":StaffRuntime.State.AVAILABLE,&"role_tags":[&"medic"],&"permitted_task_tags":[&"rescue"]}},&"player").is_success(), "register " + String(id)) + var checkpoint := {} + for tick: int in 130: + clock.advance_minutes(1) + var patient_trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:patient") + if patient_trip.get(&"state",&"") == PathService.ELEVATOR_REQUEST_BOARDED: + checkpoint = runtime.create_snapshot(); break + expect(not checkpoint.is_empty(), "rescuer reaches the upper floor and boards with the patient") + if checkpoint.is_empty(): + print(runtime.get_staff_rescue_report(),runtime.elevators.get_report()) + finish(); return + var medic_trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:medic") + expect(medic_trip.get(&"companion_id") == &"staff:patient", "real medic trip links to patient seat") + expect(runtime.restore_snapshot(checkpoint), "boarded rescue snapshot has valid crosslinks") + var slot := "user://tests/rescue_elevator_%d" % OS.get_process_id() + expect(runtime.save_to_path(slot)[&"ok"] and runtime.load_from_path(slot)[&"ok"], "boarded pair survives real disk save and load") + SaveService.delete_save(slot) + checkpoint = runtime.create_snapshot() + clock.advance_minutes(100) + expect(runtime.staff_service.get_staff(&"patient").health == 100, "upper-floor patient arrives and completes treatment") + var finished: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(checkpoint), "rewind paired ride") + clock.advance_minutes(100) + expect(runtime.create_snapshot() == finished, "paired elevator rescue replays identical state and events") + expect(runtime.restore_snapshot(checkpoint), "restore for emergency") + var shaft_id: StringName = runtime.elevators.get_report()[&"shafts"][0][&"shaft_id"] + expect(runtime.submit_command(&"set_elevator_operation",{&"shaft_id":shaft_id,&"operator_open":false},&"player").is_success(), "player can stop the occupied elevator safely") + var patient: StaffRuntime = runtime.staff_service.get_staff(&"patient") + var medic: StaffRuntime = runtime.staff_service.get_staff(&"medic") + expect(patient.current_cell == medic.current_cell and patient.health == 0 and runtime.elevators.get_report()[&"trips"].is_empty(), "emergency unloads both at the same floor without healing") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "emergency pair release remains saveable") + expect(runtime.restore_snapshot(checkpoint), "restore boarded rescue pair for tower-wide depletion") + state.set_resource(&"mana", 0) + clock.advance_minutes(1) + patient = runtime.staff_service.get_staff(&"patient"); medic = runtime.staff_service.get_staff(&"medic") + expect(patient.current_cell == medic.current_cell and patient.health == 0 and runtime.elevators.get_report()[&"trips"].is_empty(), "reserve depletion lands both rescue passengers together without healing") + var landing := patient.current_cell + expect(landing in runtime.elevators.get_report()[&"shafts"][0][&"stops"], "depleted rescue pair uses an actual elevator landing") + clock.advance_minutes(20) + expect(runtime.staff_service.get_staff(&"patient").health == 0 and runtime.staff_service.get_staff(&"patient").current_cell == landing, "stopped clinic cannot heal or teleport the landed patient") + var outage: Dictionary = runtime.create_snapshot() + expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.restore_snapshot(outage) and runtime.create_snapshot() == outage, "depleted rescue ownership survives exact complete save restoration") + state.set_resource(&"mana", 100) + clock.advance_minutes(100) + expect(runtime.staff_service.get_staff(&"patient").health == 100, "restored power resumes rescue and treatment for the same patient") + expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.restore_snapshot(runtime.create_snapshot()), "recovered rescue has no orphan ownership and remains saveable") + expect(runtime.restore_snapshot(checkpoint), "restore for clinic outage midride") + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id == &"infirmary": runtime.facility_service.set_operational_status(facility.instance_id,{&"operator_open":false},clock.absolute_minute) + clock.advance_minutes(1) + expect(runtime.elevators.get_report()[&"trips"].size() == 2, "clinic outage holds onboard pair until a landing") + clock.advance_minutes(40) + patient = runtime.staff_service.get_staff(&"patient"); medic = runtime.staff_service.get_staff(&"medic") + expect(patient.health == 0 and patient.current_cell.y == 0 and runtime.elevators.get_report()[&"trips"].is_empty(), "clinic outage does not teleport the pair back to pickup floor") + expect(runtime.staff_service.validate_invariants()[&"ok"] and runtime.restore_snapshot(runtime.create_snapshot()), "interrupted elevator rescue preserves ownership and saves") + expect(runtime.restore_snapshot(checkpoint), "restore for rescuer collapse midride") + runtime.staff_service.get_staff(&"medic").health = 0 + clock.advance_minutes(1) + expect(runtime.elevators.get_report()[&"trips"].size() == 2, "rescuer collapse never ejects either passenger between floors") + clock.advance_minutes(40) + patient = runtime.staff_service.get_staff(&"patient"); medic = runtime.staff_service.get_staff(&"medic") + expect(patient.current_cell == medic.current_cell and patient.current_cell.y == 0 and patient.health == 0 and medic.health == 0, "two incapacitated passengers reach a real landing together") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "both incapacitated passengers remain saveable") + finish() +func finish() -> void: + runtime.free(); clock.free(); state.free() + print("Elevator rescue: %d checks, %d failures" % [checks, failures]); quit(failures) diff --git a/tests/staff/test_rescue_elevator.gd.uid b/tests/staff/test_rescue_elevator.gd.uid new file mode 100644 index 0000000..0a28c01 --- /dev/null +++ b/tests/staff/test_rescue_elevator.gd.uid @@ -0,0 +1 @@ +uid://delft8b4w0alb diff --git a/tests/staff/test_shift_rush.gd b/tests/staff/test_shift_rush.gd new file mode 100644 index 0000000..37139c9 --- /dev/null +++ b/tests/staff/test_shift_rush.gd @@ -0,0 +1,55 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: call_deferred("run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1;push_error(message) + else: print("PASS: ",message) +func run() -> void: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,719);clock.set_paused(true) + var origins: Array[Vector3i] = [Vector3i(-10,0,1),Vector3i(-6,0,1),Vector3i(2,0,1),Vector3i(6,0,1)] + for origin: Vector3i in origins: + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"infirmary",&"origin":origin},&"player").is_success(),"build real workplace for shift rush") + clock.advance_minutes(80) + var rooms: Array = runtime.facility_service.get_instances() + for i: int in 8: + var id := StringName("day_%02d" % i) + expect(runtime.submit_command(&"register_staff",{&"staff_id":id,&"role_id":&"medic",&"options":{&"current_cell":Vector3i(0,0,1),&"role_tags":[&"medic"]}},&"player").is_success(),"register daytime employee") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":id,&"facility_id":rooms[i/2].instance_id,&"template_id":&"DAY"},&"player").is_success(),"assign daytime employee") + clock.advance_minutes(1079-clock.absolute_minute) + for i: int in 8: + var id := StringName("night_%02d" % i) + expect(runtime.submit_command(&"register_staff",{&"staff_id":id,&"role_id":&"medic",&"options":{&"current_cell":Vector3i(0,0,1),&"role_tags":[&"medic"]}},&"player").is_success(),"register nighttime employee") + expect(runtime.submit_command(&"set_staff_work_assignment",{&"staff_id":id,&"facility_id":rooms[i/2].instance_id,&"template_id":&"NIGHT"},&"player").is_success(),"assign nighttime employee") + clock.advance_minutes(1) + var off_duty := 0 + var commuting := 0 + var slowed := 0 + for i: int in 8: + if runtime.staff_service.get_staff(StringName("day_%02d" % i)).state == StaffRuntime.State.OFF_DUTY: off_duty += 1 + var credit: Dictionary = runtime._movement_credits.get(StringName("staff:night_%02d" % i),{}) + if int(credit.get(&"credit",0)) == 500: slowed += 1 + if runtime.staff_service.get_staff(StringName("night_%02d" % i)).state == StaffRuntime.State.COMMUTING: commuting += 1 + expect(off_duty == 8 and commuting > 0,"18:00 removes all eight day workers while night staff still physically commute") + expect(slowed > 0,"crowded shift-change corridor applies the original 50 percent movement speed") + var midpoint: Dictionary = runtime.create_snapshot() + clock.advance_minutes(60) + var working := 0 + var delayed := 0 + for i: int in 8: + var worker: StaffRuntime = runtime.staff_service.get_staff(StringName("night_%02d" % i)) + if worker.state == StaffRuntime.State.AVAILABLE: working += 1 + var shift: ShiftInstance = runtime.staff_service.get_shift(worker.shift_id) + if shift != null and shift.actual_started_at > 1080: delayed += 1 + expect(working == 8 and delayed > 0,"night workers activate only after real arrivals, including rush delays") + var end: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(midpoint),"16-employee shift-rush midpoint restores") + clock.advance_minutes(60) + expect(runtime.create_snapshot() == end,"16-employee shift change replays exact positions, attendance and history") + expect(runtime.staff_service.validate_invariants()[&"ok"],"shift rush preserves staff ownership") + runtime.free();clock.free();state.free() + print("Shift rush: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/staff/test_shift_rush.gd.uid b/tests/staff/test_shift_rush.gd.uid new file mode 100644 index 0000000..f319d27 --- /dev/null +++ b/tests/staff/test_shift_rush.gd.uid @@ -0,0 +1 @@ +uid://dukb72njvfdj7 diff --git a/tests/staff/test_staff_dormitory.gd b/tests/staff/test_staff_dormitory.gd new file mode 100644 index 0000000..c67567d --- /dev/null +++ b/tests/staff/test_staff_dormitory.gd @@ -0,0 +1,56 @@ +extends SceneTree +var checks := 0 +var failures := 0 +var runtime: Variant +var clock: Variant +var state: Variant +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func build(id: StringName, origin: Vector3i) -> bool: + var result: CommandResult=runtime.submit_command(&"build_facility",{&"definition_id":id,&"origin":origin}) + expect(result.is_success(),"normal build %s: %s" % [id,result.message]) + if not result.is_success(): return false + clock.advance_minutes(runtime.definition_registry.get_facility(id).build_minutes) + return true +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();runtime.initialize(clock,state,42504);clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"],"production operations enable physical staff rest") + var definition: FacilityDefinition=runtime.definition_registry.get_facility(&"staff_dormitory") + expect(definition!=null and definition.footprint==Vector2i(2,1) and definition.layer==1 and definition.customer_capacity==0,"normal dormitory definition has original geometry and excludes customer use") + if not build(&"warehouse",Vector3i(-6,0,1)) or not build(&"staff_dormitory",Vector3i(-6,2,1)) or not build(&"service_stairs",Vector3i(-7,1,1)) or not build(&"service_stairs",Vector3i(-7,2,1)): finish();return + var registered: CommandResult=runtime.submit_command(&"register_staff",{&"staff_id":&"dorm_sleeper",&"role_id":&"porter",&"options":{&"health":20,&"fatigue":20,&"current_cell":Vector3i(5,0,0),&"state":StaffRuntime.State.OFF_DUTY}}) + expect(registered.is_success(),"register off-duty worker on front corridor") + var worker: StaffRuntime=runtime.staff_service.get_staff(&"dorm_sleeper") + var room_id: StringName=&"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"staff_dormitory": room_id=room.instance_id + expect(runtime.path_service.find_path(Vector3i(5,0,0),Vector3i(-7,2,1)).is_empty(),"guest-access routing cannot enter dormitory via staff door and service stairs") + var snapshot: Dictionary=runtime.create_snapshot() + clock.advance_minutes(60) + expect(runtime.get_staff_rest_report(worker.staff_id).get(&"resting",false) and worker.current_cell==Vector3i(-7,2,1),"off-duty worker physically crosses staff door and stairs to dormitory") + expect(worker.health>20 and worker.fatigue<20,"staff recover only after reaching dormitory") + expect(runtime.get_staff_rest_report(worker.staff_id)[&"label"]=="宿舎で休憩中","status identifies dormitory instead of waiting room") + var arrived: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(snapshot),"restore pre-rest movement") + clock.advance_minutes(60) + expect(runtime.create_snapshot()==arrived,"staff movement and recovery replay exactly") + worker=runtime.staff_service.get_staff(&"dorm_sleeper") + var health:=worker.health + state.set_resource(&"mana",0);clock.advance_minutes(30) + expect(worker.health==health and not runtime.get_staff_rest_report(worker.staff_id).get(&"resting",false),"unpowered dormitory stops recovery") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"stopped dormitory and staff position restore") + worker=runtime.staff_service.get_staff(&"dorm_sleeper") + state.set_resource(&"mana",100);clock.advance_minutes(40) + expect(worker.health>health,"power recovery resumes rest") + runtime.facility_service.set_operational_status(room_id,{&"operator_open":false},clock.absolute_minute) + health=worker.health;clock.advance_minutes(30) + expect(worker.health==health,"closed dormitory cannot restore health") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"closed dormitory remains saveable") + finish() +func finish() -> void: + runtime.free();clock.free();state.free() + print("Staff dormitory: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/staff/test_staff_dormitory.gd.uid b/tests/staff/test_staff_dormitory.gd.uid new file mode 100644 index 0000000..6adaad1 --- /dev/null +++ b/tests/staff/test_staff_dormitory.gd.uid @@ -0,0 +1 @@ +uid://c571oqltvsnjr diff --git a/tests/staff/test_staff_rescue.gd b/tests/staff/test_staff_rescue.gd new file mode 100644 index 0000000..bec3c45 --- /dev/null +++ b/tests/staff/test_staff_rescue.gd @@ -0,0 +1,154 @@ +extends SceneTree +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, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) + else: print(" ✓ ",message) +func _run() -> void: + _test_paths() + _test_incapacitated_shift() + _test_task_compatibility() + clock = load("res://autoload/game_clock.gd").new() + state = load("res://autoload/game_state.gd").new() + runtime = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1,6,0); state.reset() + expect(runtime.initialize(clock,state,711)[&"valid"], "rescue runtime initializes") + clock.set_paused(true) + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"infirmary", &"origin": Vector3i(-6,0,1)}, &"player").is_success(), "medical facility builds through public command") + clock.advance_minutes(80) + expect(runtime.facility_service.get_instances().size() == 1, "medical facility construction completes") + var clinic: FacilityInstance = runtime.facility_service.get_instances()[0] + for id in [&"patient", &"porter"]: + var options := {&"current_cell": Vector3i(3,0,1) if id == &"patient" else Vector3i(5,0,1), &"health": 0 if id == &"patient" else 100, &"state": StaffRuntime.State.AVAILABLE, &"role_tags": [&"medic"], &"permitted_task_tags": [&"rescue"]} + expect(runtime.submit_command(&"register_staff", {&"staff_id": id, &"role_id": &"porter", &"options": options}, &"player").is_success(), "rescue actor registers: " + String(id)) + var patient: StaffRuntime = runtime.staff_service.get_staff(&"patient") + expect(patient.state == StaffRuntime.State.INCAPACITATED, "zero health means incapacitated") + runtime.facility_service.set_operational_status(clinic.instance_id, {&"operator_open": false}, clock.absolute_minute) + clock.advance_minutes(3) + expect(patient.current_cell == Vector3i(3,0,1) and runtime.staff_service.get_tasks().is_empty(), "closed clinic leaves patient in place without a rescue task") + runtime.facility_service.set_operational_status(clinic.instance_id, {&"operator_open": true}, clock.absolute_minute) + var carrying := false + for minute in range(30): + clock.advance_minutes(1) + for task: StaffTask in runtime.staff_service.get_tasks(): + if task.task_type == &"rescue_transport" and task.state == StaffTask.State.MOVING: + carrying = true + if carrying: break + expect(carrying, "medic reaches patient before transport begins") + var carrier: StaffRuntime = runtime.staff_service.get_staff(&"porter") + expect(patient.current_cell == carrier.current_cell and patient.health == 0, "pickup does not heal or teleport patient to clinic") + expect(runtime.staff_service.get_tasks().size() == 1, "one patient has one active rescue") + var midpoint: Dictionary = runtime.create_snapshot() + expect(runtime.save_to_path("user://saves/rescue_regression")[&"ok"], "mid-transport state writes to an isolated save package") + clock.advance_minutes(5) + expect(runtime.load_from_path("user://saves/rescue_regression")[&"ok"], "mid-transport disk save loads") + expect(runtime.latest_digest == SimulationDigest.compute(midpoint), "disk load restores the exact persisted rescue digest") + SaveService.delete_save("user://saves/rescue_regression") + # Manual save/load publish lifecycle events; compare replay from the loaded state. + midpoint = runtime.create_snapshot() + patient = runtime.staff_service.get_staff(&"patient") + clock.advance_minutes(70) + expect(patient.health == 100 and patient.state == StaffRuntime.State.OFF_DUTY, "transport and treatment restore patient at medical facility") + expect(patient.current_cell.x < 0 and patient.current_cell.z == 1, "patient actually travelled through the backyard") + var finished: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(midpoint), "mid-transport memory snapshot restores") + clock.advance_minutes(70) + expect(runtime.create_snapshot() == finished, "snapshot replay reproduces exact typed state and events") + expect(runtime.restore_snapshot(midpoint), "carrier failure fixture restores") + patient = runtime.staff_service.get_staff(&"patient") + carrier = runtime.staff_service.get_staff(&"porter") + var stranded_at := patient.current_cell + carrier.health = 0 + clock.advance_minutes(4) + expect(patient.health == 0 and patient.current_cell == stranded_at, "collapsed medic cannot move or heal their patient") + expect(runtime.staff_service.validate_invariants()[&"ok"], "carrier collapse preserves task ownership") + expect(runtime.restore_snapshot(midpoint), "interruption fixture restores") + patient = runtime.staff_service.get_staff(&"patient") + var stopped_at := patient.current_cell + runtime.facility_service.set_operational_status(clinic.instance_id, {&"operator_open": false}, clock.absolute_minute) + clock.advance_minutes(4) + expect(patient.health == 0 and patient.current_cell == stopped_at, "clinic outage cancels transport without moving or healing patient") + expect(runtime.staff_service.validate_invariants()[&"ok"], "interruption preserves staff/task invariants") + runtime.facility_service.set_operational_status(clinic.instance_id, {&"operator_open": true}, clock.absolute_minute) + clock.advance_minutes(80) + expect(patient.health == 100, "clinic reopening allows a new rescue from the last position") + runtime.facility_service.set_operational_status(clinic.instance_id, {&"operator_open": false}, clock.absolute_minute) + runtime.progression_service.update_metrics({&"reputation": 10}) + expect(runtime.submit_command(&"register_staff", {&"staff_id": &"visible_patient", &"role_id": &"porter", &"options": {&"health": 0, &"current_cell": Vector3i.ZERO}}, &"player").is_success(), "front-layer collapse fixture registers") + expect(runtime.submit_command(&"spawn_guest", {&"guest_id": &"witness", &"guest_type_id": &"noble_human", &"gold": 10}, &"player").is_success(), "witness enters the same front cell") + clock.advance_minutes(1) + expect(runtime.progression_service.metrics[&"reputation"] == 9, "visible collapse decreases reputation") + clock.advance_minutes(3) + expect(runtime.progression_service.metrics[&"reputation"] == 9, "one collapse cannot repeatedly reduce reputation") + expect(not runtime._authority_line_of_sight(Vector3i(0,0,1), Vector3i.ZERO, {}), "back-layer guest cannot witness a front-layer collapse") + expect(runtime.get_staff_rescue_report()[&"waiting"] == 1, "rescue report exposes the patient waiting for an operational clinic") + runtime.free();clock.free();state.free() + _test_work_collapse() + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]);quit(failures) +func _test_paths() -> void: + var path := PathService.new() + path.rebuild_graph([Vector3i(0,0,0),Vector3i(1,0,0),Vector3i(0,0,1),Vector3i(1,0,1)]) + expect(path.find_backyard_path(Vector3i(0,0,0),Vector3i(1,0,0)).is_empty(), "front corridor alone cannot be used for rescue") + path.configure_connector(&"door_a",Vector3i(0,0,0),Vector3i(0,0,1),100,true,&"staff") + path.configure_connector(&"door_b",Vector3i(1,0,1),Vector3i(1,0,0),100,true,&"staff") + var route := path.find_backyard_path(Vector3i(0,0,0),Vector3i(1,0,0)) + expect(route.size() == 4 and route[1].z == 1 and route[2].z == 1, "rescue uses real doors and backyard even with a shorter front path") + var lift := PathService.new() + lift.rebuild_graph([Vector3i(0,0,1), Vector3i(0,1,1)]) + lift.set_vertical_walk_requires_connector(true) + lift.configure_elevator(&"infra.connector:rescue",Vector3i(0,0,1),Vector3i(0,1,1),{&"cabin_capacity": 1}) + expect(lift.find_backyard_path(Vector3i(0,0,1),Vector3i(0,1,1)).is_empty(), "one-person lift cannot transport a rescuer and patient") + lift.configure_elevator(&"infra.connector:rescue",Vector3i(0,0,1),Vector3i(0,1,1),{&"cabin_capacity": 2}) + expect(lift.find_backyard_path(Vector3i(0,0,1),Vector3i(0,1,1)).is_empty(), "paired rescue does not use a lift without atomic group boarding") + lift.configure_elevator(&"transport.pair",Vector3i(0,0,1),Vector3i(0,1,1),{&"cabin_capacity": 1}) + expect(lift.find_backyard_path(Vector3i(0,0,1),Vector3i(0,1,1),true).is_empty(), "managed one-seat cabin cannot serve rescue") + lift.configure_elevator(&"transport.pair",Vector3i(0,0,1),Vector3i(0,1,1),{&"cabin_capacity": 2}) + expect(lift.find_backyard_path(Vector3i(0,0,1),Vector3i(0,1,1),true).size() == 2, "managed two-seat BACK cabin serves rescue") + var front := PathService.new() + front.rebuild_graph([Vector3i(0,0,0),Vector3i(0,1,0),Vector3i(0,1,1)]) + front.set_vertical_walk_requires_connector(true) + front.configure_elevator(&"transport.front",Vector3i(0,0,0),Vector3i(0,1,0),{&"cabin_capacity": 4}) + front.configure_connector(&"upper_door",Vector3i(0,1,0),Vector3i(0,1,1),100,true,&"staff") + expect(front.find_backyard_path(Vector3i(0,0,0),Vector3i(0,1,1),true).is_empty(), "rescue cannot substitute a FRONT passenger lift for the BACK route") + lift.configure_connector(&"rescue_stairs",Vector3i(0,0,1),Vector3i(0,1,1),100,true,&"staff") + expect(lift.find_backyard_path(Vector3i(0,0,1),Vector3i(0,1,1)).size() == 2, "real backyard stairs carry the rescue pair") + path.set_connector_enabled(&"door_a",false) + path.set_connector_enabled(&"door_b",false) + expect(path.find_backyard_path(Vector3i(0,0,0),Vector3i(1,0,0)).is_empty(), "blocked backyard entry cannot be bypassed") + +func _test_incapacitated_shift() -> void: + var service := StaffService.new() + var member := StaffRuntime.new().configure(&"worker", &"porter", {&"state": StaffRuntime.State.AVAILABLE}) + service.register_staff(member) + service.create_flex_shift(&"worker",0,0,1) + member.health = 0 + member.transition_to(StaffRuntime.State.INCAPACITATED,&"STAFF_COLLAPSED",0) + service.advance_one_minute(2) + expect(member.state == StaffRuntime.State.INCAPACITATED, "shift ending cannot wake an incapacitated staff member") + +func _test_work_collapse() -> void: + clock = load("res://autoload/game_clock.gd").new() + state = load("res://autoload/game_state.gd").new() + runtime = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,812);clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "work-health fixture enables production") + expect(runtime.submit_command(&"register_staff", {&"staff_id": &"tired", &"role_id": &"porter", &"options": {&"health": 1, &"state": StaffRuntime.State.AVAILABLE, &"current_cell": Vector3i(0,0,1)}}, &"player").is_success(), "working staff starts with one health") + runtime.staff_service.create_task(&"work",50,360,{&"target_cell": Vector3i(0,0,1), &"estimated_minutes": 90}) + clock.advance_minutes(30) + var tired: StaffRuntime = runtime.staff_service.get_staff(&"tired") + expect(tired.health == 0 and tired.state == StaffRuntime.State.INCAPACITATED and tired.current_task_id.is_empty(), "real work drains health and cancels the collapsed worker's task") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "work-collapse state and cancelled task save together") + runtime.free();clock.free();state.free() + +func _test_task_compatibility() -> void: + var task := StaffTask.new().configure(&"legacy", &"work", 1, 0, {}) + var saved := task.create_snapshot() + saved.erase("rescue_facility_id") + expect(StaffTask.from_snapshot(saved) != null, "legacy task without rescue metadata loads") + saved["rescue_facility_id"] = 12 + expect(StaffTask.from_snapshot(saved) == null, "malformed rescue metadata is rejected") diff --git a/tests/staff/test_staff_rescue.gd.uid b/tests/staff/test_staff_rescue.gd.uid new file mode 100644 index 0000000..d6567d4 --- /dev/null +++ b/tests/staff/test_staff_rescue.gd.uid @@ -0,0 +1 @@ +uid://b745cjukv2trj diff --git a/tests/staff/test_staff_rest.gd b/tests/staff/test_staff_rest.gd new file mode 100644 index 0000000..11a9c5a --- /dev/null +++ b/tests/staff/test_staff_rest.gd @@ -0,0 +1,50 @@ +extends SceneTree +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, text: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(text) + else: print("PASS: ",text) +func command(type: StringName, payload: Dictionary) -> bool: + var r: CommandResult = runtime.submit_command(type,payload,&"player") + expect(r.is_success(), "%s: %s" % [type,r.message]);return r.is_success() +func run() -> void: + clock = load("res://autoload/game_clock.gd").new();state = load("res://autoload/game_state.gd").new();runtime = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,714);clock.set_paused(true) + expect(runtime.enable_production_operations()[&"ok"], "normal operations enable staff rest") + command(&"register_staff",{&"staff_id":&"resting",&"role_id":&"porter",&"options":{&"health":20,&"fatigue":20,&"current_cell":Vector3i(5,0,0),&"state":StaffRuntime.State.OFF_DUTY}}) + clock.advance_minutes(30) + var worker: StaffRuntime = runtime.staff_service.get_staff(&"resting") + expect(worker.health == 20 and worker.current_cell == Vector3i(5,0,0), "off-duty worker cannot heal in the public corridor without a rest room") + expect(runtime.get_staff_rest_report(&"resting")[&"label"].contains("休憩先なし"), "missing rest room has an actionable status") + runtime.path_service.set_connector_enabled(&"starter.staff_door",false) + command(&"build_facility",{&"definition_id":&"staff_waiting_room",&"origin":Vector3i(-6,0,1)}) + clock.advance_minutes(120) + expect(worker.current_cell == Vector3i(5,0,0) and worker.health == 20, "rest room behind a blocked door cannot teleport or heal the worker") + command(&"build_facility",{&"definition_id":&"staff_door",&"origin":Vector3i(0,0,1)}) + clock.advance_minutes(30) + var midpoint: Dictionary = runtime.create_snapshot() + clock.advance_minutes(60) + expect(runtime.get_staff_rest_report(&"resting").get(&"resting",false) and worker.current_cell.z == 1, "off-duty worker physically reaches the back-layer rest room") + expect(worker.health > 20 and worker.fatigue < 20, "health and fatigue recover only after arrival") + var final_state: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(midpoint), "rest movement snapshot restores") + clock.advance_minutes(60) + expect(runtime.create_snapshot() == final_state, "rest movement and recovery replay identically") + worker = runtime.staff_service.get_staff(&"resting") + var room_id: StringName = runtime.get_staff_rest_report(&"resting")[&"facility_id"] + runtime.facility_service.set_operational_status(room_id,{&"operator_open":false},clock.absolute_minute) + var health := worker.health + clock.advance_minutes(30) + expect(worker.health == health and not runtime.get_staff_rest_report(&"resting").get(&"resting",false), "closed waiting room cannot recover health") + runtime.facility_service.set_operational_status(room_id,{&"operator_open":true},clock.absolute_minute) + worker.health = 0 + clock.advance_minutes(30) + expect(worker.health == 0 and worker.state == StaffRuntime.State.INCAPACITATED, "rest cannot replace rescue for an incapacitated worker") + expect(runtime.restore_snapshot(runtime.create_snapshot()), "closed-room and collapse state remain saveable") + runtime.free();clock.free();state.free() + print("Staff rest: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/staff/test_staff_rest.gd.uid b/tests/staff/test_staff_rest.gd.uid new file mode 100644 index 0000000..f0c56e2 --- /dev/null +++ b/tests/staff/test_staff_rest.gd.uid @@ -0,0 +1 @@ +uid://cmd8yjfi614y6 diff --git a/tests/staff/test_task_work_clock.gd b/tests/staff/test_task_work_clock.gd new file mode 100644 index 0000000..77399e6 --- /dev/null +++ b/tests/staff/test_task_work_clock.gd @@ -0,0 +1,31 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant=load("res://autoload/simulation_runtime.gd").new() + var clock: Variant=load("res://autoload/game_clock.gd").new() + var state: Variant=load("res://autoload/game_state.gd").new() + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42640);clock.set_paused(true) + expect(runtime.submit_command(&"register_staff",{&"staff_id":&"worker", &"role_id":&"porter", &"options":{&"current_cell":Vector3i(-8,0,0), &"state":StaffRuntime.State.AVAILABLE}}).is_success(), "register worker") + expect(runtime.submit_command(&"create_staff_task",{&"task_id":&"timed_work", &"task_type":&"inspection", &"priority":50, &"options":{&"target_cell":Vector3i(-8,0,0), &"estimated_minutes":8}}).is_success(), "create eight-minute task") + var task: StaffTask=runtime.staff_service.get_task(&"timed_work") + for minute in 10: + clock.advance_one_minute() + if task.state==StaffTask.State.WORKING: break + expect(task.state==StaffTask.State.WORKING and task.progress_minutes==0, "arrival begins work without consuming a minute") + var start: Dictionary=runtime.create_snapshot() + for elapsed in range(1,8): + clock.advance_one_minute() + expect(task.state==StaffTask.State.WORKING and task.progress_minutes==elapsed, "one tick advances exactly one work minute: %d" % elapsed) + clock.advance_one_minute() + expect(task.state==StaffTask.State.COMPLETED and task.progress_minutes==8, "completes at exactly eight elapsed work minutes") + var finished: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(start), "restore active work") + clock.advance_minutes(8) + expect(runtime.create_snapshot()==finished, "restored work reproduces exact completion state") + runtime.free();clock.free();state.free() + print("Staff task clock: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/staff/test_task_work_clock.gd.uid b/tests/staff/test_task_work_clock.gd.uid new file mode 100644 index 0000000..5df284b --- /dev/null +++ b/tests/staff/test_task_work_clock.gd.uid @@ -0,0 +1 @@ +uid://b22ii8yoikb78 diff --git a/tests/supply/test_normal_supply_chain.gd b/tests/supply/test_normal_supply_chain.gd index a47880d..c1f2dcc 100644 --- a/tests/supply/test_normal_supply_chain.gd +++ b/tests/supply/test_normal_supply_chain.gd @@ -85,8 +85,10 @@ func _run() -> void: _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()} + 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/business-management" if OS.get_environment("THE_TREE_BUSINESS_ACCEPTANCE") == "1" else "res://artifacts/supply-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) @@ -190,6 +192,10 @@ func _start_preview() -> void: 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(): diff --git a/tests/supply/test_supply_logistics_bridge.gd b/tests/supply/test_supply_logistics_bridge.gd index 6212f59..7f72f0f 100644 --- a/tests/supply/test_supply_logistics_bridge.gd +++ b/tests/supply/test_supply_logistics_bridge.gd @@ -74,6 +74,8 @@ func _run() -> void: _expect(_crosslinks(), "loaded Request, Task, reservations and cargo agree") _loaded = _runtime.create_snapshot() _expect(_runtime.restore_snapshot(_loaded), "mid-delivery Runtime snapshot restores") + _test_reserve_outage() + _test_kitchen_reserve_outage() _test_same_floor_access() _test_ownership_and_replay() _test_capacity_wait() @@ -358,3 +360,45 @@ func _expect(condition: bool, message: String) -> void: func _finish() -> void: print("%s: %d checks / %d failures" % ["PASS" if _failures == 0 else "FAIL", _checks, _failures]) quit(_failures) + +func _test_reserve_outage() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore physically loaded cargo for reserve outage") + _state.set_resource(&"mana", 0) + _clock.advance_minutes(30) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0, "outage retains loaded cargo without delivering into stopped facility") + _expect(_crosslinks(), "outage preserves Request Task inventory ownership") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "cargo waiting through outage restores") + _state.set_resource(&"mana", 100) + _wait_for(LogisticsRequest.State.COMPLETED, 60) + _expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_cargo()) == 0 and _quantity(_inventory(_destination)) == 8, "recovered power delivers cargo exactly once") + _expect(_crosslinks(), "recovered delivery ownership reconciles") + _expect(_runtime.restore_snapshot(_loaded), "rewind outage drill for remaining logistics scenarios") + + +func _test_kitchen_reserve_outage() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore real supply for cooking outage") + _wait_for(LogisticsRequest.State.COMPLETED, 60) + _expect(_command(&"hire_staff_candidate", {&"candidate_id": &"staff_chika"}), "hire kitchen employee") + _expect(_command(&"set_staff_work_assignment", {&"staff_id": &"staff_chika", &"facility_id": _destination, &"template_id": &"DAY"}), "assign kitchen employee through production command") + _expect(_command(&"set_kitchen_production", {&"facility_id": _destination, &"enabled": true, &"target_stock": 4}), "enable one real production batch") + for minute in range(60): + _clock.advance_minutes(1) + if int(_runtime.kitchen_production.get_report(_destination)[&"progress_minutes"]) >= 3: break + var before: Dictionary = _runtime.kitchen_production.get_report(_destination) + _expect(int(before[&"progress_minutes"]) == 3 and not before[&"active_batch"].is_empty(), "physical employee starts and advances actual cooking") + if before[&"active_batch"].is_empty(): + _runtime.restore_snapshot(_loaded) + return + _state.set_resource(&"mana", 0) + _clock.advance_minutes(30) + var stopped: Dictionary = _runtime.kitchen_production.get_report(_destination) + _expect(stopped[&"state"] == &"NO_POWER" and stopped[&"active_batch"] == before[&"active_batch"], "thirty stopped minutes preserve batch identity reservations and progress") + _expect(_quantity(_inventory(_destination)) == 8 and _runtime.inventory_service.total_quantity(_inventory(_destination), SupplyCatalog.BASIC_MEAL) == 0, "outage neither consumes input nor creates meals") + _expect(_runtime.kitchen_production.validate_crosslinks(), "stopped kitchen reservations remain owned") + var saved: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(saved) and _runtime.create_snapshot() == saved, "active cooking outage round trips through complete Runtime save") + _state.set_resource(&"mana", 100) + _clock.advance_minutes(30) + _expect(_quantity(_inventory(_destination)) == 4 and _runtime.inventory_service.total_quantity(_inventory(_destination), SupplyCatalog.BASIC_MEAL) == 4, "recovery consumes exactly four raw food and produces exactly four meals") + _expect(_runtime.kitchen_production.get_report(_destination)[&"state"] == &"TARGET_REACHED" and _runtime.kitchen_production.validate_crosslinks(), "recovered batch releases reservations and stops at target") + _expect(_runtime.restore_snapshot(_loaded), "rewind cooking outage scenario") diff --git a/tests/supply/test_supply_save_migration.gd b/tests/supply/test_supply_save_migration.gd index 105064c..ca9792d 100644 --- a/tests/supply/test_supply_save_migration.gd +++ b/tests/supply/test_supply_save_migration.gd @@ -19,7 +19,7 @@ func _run() -> void: source[&"clock"].advance_minutes(3) var canonical: Dictionary = source_runtime.create_snapshot() var canonical_data: Dictionary = canonical[&"systems"][&"runtime"] - _expect(canonical_data[&"runtime_schema_version"] == 9, "current Runtime emits schema v9") + _expect(canonical_data[&"runtime_schema_version"] == 13, "current Runtime emits schema v13") _expect(canonical_data[&"kitchen_production"][&"current_minute"] == source[&"clock"].absolute_minute, "production clock follows Runtime clock without existing kitchens") var legacy := canonical.duplicate(true) var legacy_data: Dictionary = legacy[&"systems"][&"runtime"] @@ -27,6 +27,7 @@ func _run() -> void: legacy_data.erase(&"kitchen_production") legacy_data.erase(&"elevators") legacy_data.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(legacy_data) legacy_data[&"path"][&"schema_version"] = 3 legacy_data[&"path"].erase(&"vertical_walk_blockers") legacy_data[&"path"].erase(&"vertical_walk_requires_connector") @@ -34,11 +35,13 @@ func _run() -> void: _expect(legacy_data.has(&"facility_infrastructure") and legacy_data.has(&"route_projection"), "v6 fixture retains infrastructure introduced in v6") _expect(first_runtime.restore_snapshot(legacy), "genuine v6 state restores into v9") var migrated: Dictionary = first_runtime.create_snapshot() - _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 9, "v6 migration re-emits current schema") + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "v6 migration re-emits current schema") var kitchen: Dictionary = migrated[&"systems"][&"runtime"][&"kitchen_production"] _expect(kitchen[&"plans"].is_empty() and kitchen[&"active_batches"].is_empty(), "legacy migration does not infer kitchen production plans or batches") _expect(kitchen[&"current_minute"] == source[&"clock"].absolute_minute, "legacy production clock binds to the restored Runtime minute") var expected := canonical.duplicate(true) + expected[&"systems"][&"runtime"][&"facility_upkeep"] = preload("res://tests/fixtures/pre_upkeep_runtime.gd").migrated_accrual(source[&"clock"].absolute_minute) + expected[&"systems"][&"runtime"][&"weather"] = preload("res://tests/fixtures/pre_upkeep_runtime.gd").migrated_weather(source[&"clock"].absolute_minute) expected[&"systems"][&"runtime"][&"path"][&"graph_revision"] += 1 _expect(migrated == expected, "v6 migration preserves all state except the one-time strict topology revision") _expect(second_runtime.restore_snapshot(legacy) and second_runtime.create_snapshot() == migrated, "v6 migration is deterministic across Runtime instances") @@ -67,6 +70,7 @@ func _run() -> void: orphaned[&"systems"][&"runtime"].erase(&"kitchen_production") orphaned[&"systems"][&"runtime"].erase(&"elevators") orphaned[&"systems"][&"runtime"].erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(orphaned[&"systems"][&"runtime"]) orphaned[&"systems"][&"runtime"][&"path"][&"schema_version"] = 3 orphaned[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_blockers") orphaned[&"systems"][&"runtime"][&"path"].erase(&"vertical_walk_requires_connector") diff --git a/tests/transport/test_elevator_service.gd b/tests/transport/test_elevator_service.gd index 7ddfba4..4d1c06b 100644 --- a/tests/transport/test_elevator_service.gd +++ b/tests/transport/test_elevator_service.gd @@ -15,6 +15,7 @@ func _initialize() -> void: func _run() -> void: var clock := root.get_node_or_null("GameClock") if clock != null: clock.set_paused(true) + _test_paired_boarding() _test_grouping() _test_capacity_and_time() _test_access_and_shared_cabin() @@ -256,3 +257,48 @@ func _expect(condition: bool, label: String) -> void: else: _failures += 1 push_error("FAIL: %s" % label) + + +func _test_paired_boarding() -> void: + _setup(1) + for i: int in range(7): _request(StringName("a%d" % i), 0, 3, 1) + _expect(_elevators.request_pair(&"medic", &"patient", Vector3i(0,0,1), Vector3i(0,3,1), 0, [&"staff"])[&"ok"], "rescue pair reserves two real seats") + var before := _elevators.create_snapshot() + _expect(_elevators.request_pair(&"medic", &"patient", Vector3i(0,0,1), Vector3i(0,3,1), 0, [&"staff"])[&"replay"] and _elevators.create_snapshot() == before, "pair retry is idempotent") + _tick() + _expect(_car()[&"passenger_count"] == 7 and _car()[&"waiting_count"] == 2, "one remaining seat never splits the rescue pair") + _expect(_elevators.get_actor_trip(&"medic")[&"state"] == _elevators.get_actor_trip(&"patient")[&"state"], "pair retains the same queued state") + var state := _elevators.create_snapshot() + var paths := _path.create_snapshot() + var arrivals := _tick(150) + var paired: Array[Dictionary] = [] + for arrival: Dictionary in arrivals: + if arrival[&"actor_id"] in [&"medic", &"patient"]: paired.append(arrival) + _expect(paired.size() == 2 and paired[0][&"cell"] == paired[1][&"cell"], "both rescue passengers arrive at the same landing") + _expect(_path.restore_snapshot(paths) and _elevators.restore_snapshot(state), "queued pair and seat ownership restore") + _minute = state[&"current_minute"] + _expect(_tick(150) == arrivals, "queued pair replays exact arrivals") + _setup(1) + _elevators.request_pair(&"medic", &"patient", Vector3i(0,0,1), Vector3i(0,3,1), 0, [&"staff"]) + _expect(_elevators.cancel_actor(&"patient") and _elevators.get_report()[&"trips"].is_empty() and _path.get_active_elevator_request(&"medic").is_empty(), "queued cancellation releases both passengers") + _elevators.request_pair(&"medic", &"patient", Vector3i(0,0,1), Vector3i(0,3,1), 0, [&"staff"]) + _tick(10) + state = _elevators.create_snapshot(); paths = _path.create_snapshot() + _expect(not _elevators.cancel_actor(&"patient") and _elevators.create_snapshot() == state, "onboard pair cannot teleport out through cancellation") + var corrupt := state.duplicate(true) + corrupt[&"trips"][0][&"companion_id"] = &"missing" + _expect(not _elevators.restore_snapshot(corrupt) and _elevators.create_snapshot() == state, "orphan companion save is rejected atomically") + _expect(_elevators.restore_snapshot(state), "boarded pair restores") + var released := _elevators.set_powered(_config[&"shaft_id"], false, _minute) + _expect(released.size() == 2 and released[0][&"cell"] == released[1][&"cell"] and released[0][&"emergency"] and released[1][&"emergency"], "power loss unloads the pair together") + _expect(_elevators.restore_snapshot(_elevators.create_snapshot()), "emergency release leaves a valid snapshot") + _setup(1) + _request(&"patient", 0, 2, 1) + state = _elevators.create_snapshot(); paths = _path.create_snapshot() + _expect(not _elevators.request_pair(&"medic", &"patient", Vector3i(0,0,1), Vector3i(0,3,1), 0, [&"staff"])[&"ok"] and _elevators.create_snapshot() == state and _path.create_snapshot() == paths, "conflicting pair reservation leaves all ownership unchanged") + + _setup(1) + for i: int in range(ElevatorService.QUEUE_CAPACITY - 1): _request(StringName("crowd_%d" % i), 0, 3, 1) + state = _elevators.create_snapshot(); paths = _path.create_snapshot() + _expect(_car()[&"waiting_count"] == ElevatorService.QUEUE_CAPACITY - 1, "queue fixture has exactly one free request slot") + _expect(not _elevators.request_pair(&"medic", &"patient", Vector3i(0,0,1), Vector3i(0,3,1), 0, [&"staff"])[&"ok"] and _elevators.create_snapshot() == state and _path.create_snapshot() == paths, "second-member queue rejection rolls back first member and request sequence") diff --git a/tests/transport/test_runtime_stairs.gd b/tests/transport/test_runtime_stairs.gd index e9708fe..f4b7997 100644 --- a/tests/transport/test_runtime_stairs.gd +++ b/tests/transport/test_runtime_stairs.gd @@ -16,7 +16,7 @@ func _run() -> void: _state = load("res://autoload/game_state.gd").new() _reset() _expect(_runtime.path_service.get_vertical_walk_requires_connector(), "new Runtime requires explicit vertical transport") - _expect(_runtime.definition_registry.facility_count() == 18, "Runtime registers baseline16 and two staircase definitions") + _expect(_runtime.definition_registry.facility_count() == 23, "Runtime registers baseline16, stairs, infirmary and premium room/dormitory and chapel") var before: Dictionary = _runtime.create_snapshot() var invalid := _command(&"build_facility", {&"definition_id": &"guest_stairs", &"origin": Vector3i(4, 3, 0)}) _expect(not invalid.is_success() and invalid.details.get(&"reason") == &"STAIR_LANDING_MISSING", "stairs without a lower landing reject before any construction") @@ -94,6 +94,7 @@ func _test_legacy() -> void: var data: Dictionary = legacy[&"systems"][&"runtime"] data[&"runtime_schema_version"] = 8 data.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) data[&"path"][&"schema_version"] = 4 data[&"path"].erase(&"vertical_walk_requires_connector") data[&"path"].erase(&"transit_actor_ids") @@ -103,6 +104,17 @@ func _test_legacy() -> void: var route: Array = _runtime.path_service.find_path(Vector3i(4, 0, 0), Vector3i(4, 2, 0)) _expect(route.size() == 3 and _runtime.path_service.calculate_path_cost(route) == 200, "old corridor stairs retain their original speed and reachability") var canonical: Dictionary = _runtime.create_snapshot() + var preview := WorldPreview.new() + preview.size=Vector2(1280,720) + preview.set_runtime(_runtime) + preview.layer_mode=WorldPreview.LayerMode.BACK + preview.reset_view() + var ghost_ids: Array = [] + for item: Dictionary in preview._other_layer_rooms(): + if item.get(&"legacy_stair",false): ghost_ids.append(item[&"id"]) + _expect(ghost_ids.size()==2 and ghost_ids.has(records[0][&"id"]) and ghost_ids.has(records[1][&"id"]), "opposite-layer view renders each actually migrated legacy connector once") + preview.free() + _expect(_runtime.create_snapshot()==canonical,"migrated legacy visualization does not alter canonical save or routes") _expect(_runtime.restore_snapshot(canonical) and _runtime.create_snapshot() == canonical, "migrated save is canonical and migration does not repeat") var malformed := canonical.duplicate(true) malformed[&"systems"][&"runtime"][&"legacy_stairs"][&"stairs"].pop_back() diff --git a/tests/transport/test_runtime_transport.gd b/tests/transport/test_runtime_transport.gd index aa8c444..12f4c5c 100644 --- a/tests/transport/test_runtime_transport.gd +++ b/tests/transport/test_runtime_transport.gd @@ -93,12 +93,56 @@ func _verify_mid_ride() -> bool: _expect(bool(_runtime.save_to_path(save_path).get(&"ok", false)), "boarded save writes isolated disk slot") _expect(bool(_runtime.load_from_path(save_path).get(&"ok", false)), "boarded disk slot reloads") var snapshot: Dictionary = _runtime.create_snapshot() - _expect(snapshot[&"systems"][&"runtime"][&"runtime_schema_version"] == 9, "runtime schema9 records elevator state and explicit vertical routing") + _expect(snapshot[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "runtime schema13 records elevator state and explicit vertical routing") _expect(_runtime.restore_snapshot(snapshot), "mid-ride Runtime snapshot restores actor/cabin/Path ownership") var shaft: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] var busy := _cmd(&"demolish_facility", {&"instance_id": shaft[&"module_ids"][1]}) _expect(not busy.is_success() and busy.details.get(&"reason") == &"ELEVATOR_BUSY", "occupied shaft demolition rejected before spending or topology changes") _expect(_runtime.restore_snapshot(snapshot), "snapshot resets rejected command metadata") + # Diagnose total reserve depletion while a natural passenger is aboard. + var riders: Array[StringName] = [] + for trip: Dictionary in _runtime.elevators.get_report()[&"trips"]: + if trip[&"state"] == PathService.ELEVATOR_REQUEST_BOARDED: + riders.append(trip[&"actor_id"]) + _expect(not riders.is_empty(), "depletion drill starts with actual boarded passenger") + _state.set_resource(&"mana", 0) + _clock.advance_minutes(1) + var outage: Dictionary = _runtime.elevators.get_report() + _expect(not outage[&"shafts"][0][&"powered"] and outage[&"trips"].is_empty(), "reserve outage stops cabin and releases trip ownership") + for actor_id: StringName in riders: + _expect(_runtime.path_service.get_active_elevator_request(actor_id).is_empty(), "outage releases Path passenger request") + var safe_arrivals := 0 + for event: SimulationDomainEvent in _runtime.domain_events.recent_events(): + if event.event_type == &"ELEVATOR_ARRIVED" and event.absolute_minute == _clock.absolute_minute and event.payload.get(&"actor_id", &"") == actor_id and bool(event.payload.get(&"emergency", false)): + if event.payload.get(&"cell") in shaft[&"stops"]: + safe_arrivals += 1 + _expect(safe_arrivals == 1, "boarded passenger alights exactly once at a valid shaft stop") + var outage_snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(outage_snapshot), "emergency alighting and empty reserve survive Runtime save") + _state.set_resource(&"mana", 100) + _clock.advance_minutes(1) + _expect(_runtime.elevators.get_report()[&"shafts"][0][&"powered"], "cabin supply returns when reserve recovers") + _expect(_runtime.restore_snapshot(snapshot), "rewind depletion drill to original passenger state") + for cell: Vector3i in shaft[&"stops"]: + _runtime.path_service.configure_cell_traffic(cell, 4, 0, true) + _state.set_resource(&"mana", 0) + _clock.advance_minutes(1) + for actor_id: StringName in riders: + _expect(_runtime.elevators.get_actor_trip(actor_id).get(&"state") == PathService.ELEVATOR_REQUEST_BOARDED, "no safe landing retains boarded passenger instead of teleporting") + var held_shaft: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] + _clock.advance_minutes(20) + var held_after: Dictionary = _runtime.elevators.get_report()[&"shafts"][0] + _expect(held_after[&"remaining_minutes"] == held_shaft[&"remaining_minutes"], "powerless held cabin does not advance physical journey") + var held_snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(held_snapshot) and _runtime.create_snapshot() == held_snapshot, "no-landing outage preserves full Runtime save and passenger ownership") + for cell: Vector3i in shaft[&"stops"]: + _runtime.path_service.configure_cell_traffic(cell) + _state.set_resource(&"mana", 100) + _clock.advance_minutes(45) + for actor_id: StringName in riders: + _expect(_runtime.elevators.get_actor_trip(actor_id).is_empty() and _runtime.path_service.get_active_elevator_request(actor_id).is_empty(), "landing repair and power restoration release held trip ownership") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "recovered held cabin remains saveable") + _expect(_runtime.restore_snapshot(snapshot), "restore original ride after no-landing drill") _clock.advance_minutes(45) var expected: Dictionary = _runtime.create_snapshot() _expect(_runtime.restore_snapshot(snapshot), "rewind boarded save for deterministic replay") diff --git a/tests/transport/test_transport_density_migration.gd b/tests/transport/test_transport_density_migration.gd index 8b34f98..65ebec2 100644 --- a/tests/transport/test_transport_density_migration.gd +++ b/tests/transport/test_transport_density_migration.gd @@ -68,7 +68,7 @@ func _run() -> void: for guest: GuestRuntime in _runtime.guest_service.get_guests(): _expect(guest.current_cell == Vector3i.ZERO and _runtime.path_service.get_actor_projected_cell(StringName("guest:" + String(guest.guest_id))) == Vector3i.ZERO, "migration preserves actual passenger's logical boarding anchor") var canonical: Dictionary = _runtime.create_snapshot() - _expect(canonical[&"systems"][&"runtime"][&"runtime_schema_version"] == 9 and canonical[&"systems"][&"runtime"][&"path"][&"schema_version"] == 5, "resave emits current Runtime9 and Path5 only") + _expect(canonical[&"systems"][&"runtime"][&"runtime_schema_version"] == 13 and canonical[&"systems"][&"runtime"][&"path"][&"schema_version"] == 5, "resave emits current Runtime13 and Path5 only") _expect(_runtime.restore_snapshot(canonical) and _runtime.create_snapshot() == canonical, "canonical migrated save reloads exactly without applying density or stairs twice") forged = canonical.duplicate(true) forged[&"systems"][&"runtime"][&"path"][&"transit_actor_ids"].pop_back() @@ -112,6 +112,7 @@ func _as_v8_path4(current: Dictionary) -> Dictionary: var data: Dictionary = result[&"systems"][&"runtime"] data[&"runtime_schema_version"] = 8 data.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) var path: Dictionary = data[&"path"] path[&"schema_version"] = 4 path.erase(&"vertical_walk_requires_connector") diff --git a/tests/transport/test_transport_save.gd b/tests/transport/test_transport_save.gd index b3b1f20..cea1297 100644 --- a/tests/transport/test_transport_save.gd +++ b/tests/transport/test_transport_save.gd @@ -15,12 +15,13 @@ func _run() -> void: var clock: Variant = fixture[&"clock"] clock.advance_minutes(3) var current: Dictionary = runtime.create_snapshot() - _expect(current[&"systems"][&"runtime"][&"runtime_schema_version"] == 9, "Runtime emits schema9 with elevator and explicit vertical-route aggregates") + _expect(current[&"systems"][&"runtime"][&"runtime_schema_version"] == 13, "Runtime emits schema13 with elevator and explicit vertical-route aggregates") var legacy := current.duplicate(true) var data: Dictionary = legacy[&"systems"][&"runtime"] data[&"runtime_schema_version"] = 7 data.erase(&"elevators") data.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) data[&"path"][&"schema_version"] = 3 data[&"path"].erase(&"vertical_walk_blockers") data[&"path"].erase(&"vertical_walk_requires_connector") @@ -28,6 +29,8 @@ func _run() -> void: _expect(runtime.restore_snapshot(legacy), "genuine v7 without lifts migrates from Path3 into Runtime9") var migrated: Dictionary = runtime.create_snapshot() var expected := current.duplicate(true) + expected[&"systems"][&"runtime"][&"facility_upkeep"] = preload("res://tests/fixtures/pre_upkeep_runtime.gd").migrated_accrual(clock.absolute_minute) + expected[&"systems"][&"runtime"][&"weather"] = preload("res://tests/fixtures/pre_upkeep_runtime.gd").migrated_weather(clock.absolute_minute) # Closing the old implicit-WALK topology bumps only the Path graph revision. expected[&"systems"][&"runtime"][&"path"][&"graph_revision"] += 1 _expect(migrated == expected, "empty-lift migration preserves all state except the explicit topology revision") @@ -113,6 +116,7 @@ func _test_attendance_migration() -> void: data[&"runtime_schema_version"] = 7 data.erase(&"elevators") data.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) data[&"path"][&"schema_version"] = 3 data[&"path"].erase(&"vertical_walk_blockers") data[&"path"].erase(&"vertical_walk_requires_connector") diff --git a/tests/ui/capture_pixel_tower.gd b/tests/ui/capture_pixel_tower.gd index 42002e1..b2ce80f 100644 --- a/tests/ui/capture_pixel_tower.gd +++ b/tests/ui/capture_pixel_tower.gd @@ -72,6 +72,10 @@ func _run() -> void: # main enables its production save policy in _ready; immediately turn it off # for this disposable scenario and make the manual save/load controls inert. _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + var load_menu := main.find_child("SaveLoadMenu", true, false) as MenuButton + if load_menu != null: + load_menu.disabled = true + load_menu.tooltip_text = "この見学モードは保存・読込を行いません。" for button: Node in main.find_children("*", "Button", true, false): var save_control := button as Button for connection: Dictionary in save_control.pressed.get_connections(): diff --git a/tests/ui/fixtures/counted_dashboard.gd b/tests/ui/fixtures/counted_dashboard.gd new file mode 100644 index 0000000..258e366 --- /dev/null +++ b/tests/ui/fixtures/counted_dashboard.gd @@ -0,0 +1,5 @@ +extends "res://scenes/main/main.gd" +var dashboard_refresh_count := 0 +func _refresh_dashboard() -> void: + dashboard_refresh_count += 1 + super._refresh_dashboard() diff --git a/tests/ui/fixtures/counted_dashboard.gd.uid b/tests/ui/fixtures/counted_dashboard.gd.uid new file mode 100644 index 0000000..b8077f6 --- /dev/null +++ b/tests/ui/fixtures/counted_dashboard.gd.uid @@ -0,0 +1 @@ +uid://erbvbvnrtc84 diff --git a/tests/ui/fixtures/opening_restart_flow.gd b/tests/ui/fixtures/opening_restart_flow.gd new file mode 100644 index 0000000..9dfc968 --- /dev/null +++ b/tests/ui/fixtures/opening_restart_flow.gd @@ -0,0 +1,119 @@ +extends "res://tests/ui/test_first_operating_day.gd" +## Two-process natural opening/three-close debug flow. The Python runner supplies +## an isolated slot and evidence directory; never use ordinary player saves. +var _handoff_done := false + +func _run() -> void: + if OS.get_environment("THE_TREE_OPENING_RESTART_SLOT").is_empty(): + push_error("Restart runner must supply its isolated save slot") + quit(1) + return + if "--read" not in OS.get_cmdline_user_args(): + await super._run() + return + _clock = root.get_node("GameClock") + _runtime = root.get_node("SimulationRuntime") + var state: Variant = 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() + _main.set("manual_save_path", OS.get_environment("THE_TREE_OPENING_RESTART_SLOT")) + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + _guide = _main.find_child("InnOpeningGuide", true, false) + _ledger = _main.find_child("OperatingLedgerPanel", true, false) + _load_handoff() + await _continue_to_third_close() + var expected: Variant = JSON.parse_string(FileAccess.get_file_as_string(_evidence_path("expected.json"))) + _expect(expected is Dictionary, "previous process retained expected continuation evidence") + if expected is Dictionary: + _expect(_gameplay_digest() == expected.get("gameplay_digest"), "fresh process reproduces full gameplay snapshot through third close") + _expect(JSON.stringify(_runtime.operations_coordinator.get_day_summaries()) == expected.get("closes_json"), "all three financial reports exactly match uninterrupted continuation") + await _capture_restart("restarted-third-close") + if _failures == 0: + SaveService.delete_save(OS.get_environment("THE_TREE_OPENING_RESTART_SLOT")) + super._finish() + +func _finish() -> void: + if _failures > 0 or _handoff_done: + super._finish() + return + _handoff_done = true + _main.set("manual_save_path", OS.get_environment("THE_TREE_OPENING_RESTART_SLOT")) + var selected_id: StringName = _runtime.facility_service.get_instances()[0].instance_id + _main.call("_on_facility_selected", selected_id) + var selection_file := FileAccess.open(_evidence_path("selection.json"), FileAccess.WRITE) + selection_file.store_string(JSON.stringify({"id": String(selected_id)})) + selection_file.close() + var found_save := false + for node: Node in _main.find_children("*", "Button", true, false): + if node.text == "保存": + node.pressed.emit() + found_save = true + break + _expect(found_save, "save through the normal dashboard button") + _load_handoff() + await _capture_restart("saved-second-morning") + await _continue_to_third_close() + var file := FileAccess.open(_evidence_path("expected.json"), FileAccess.WRITE) + _expect(file != null, "continuation evidence can be retained") + if file != null: + file.store_string(JSON.stringify({"gameplay_digest": _gameplay_digest(), "closes_json": JSON.stringify(_runtime.operations_coordinator.get_day_summaries())}, "\t") + "\n") + file.close() + await _capture_restart("uninterrupted-third-close") + super._finish() + +func _load_handoff() -> void: + var menu := _main.find_child("SaveLoadMenu", true, false) as MenuButton + _expect(menu != null and not menu.disabled, "normal load menu is available") + if menu != null and not menu.disabled: + menu.get_popup().id_pressed.emit(0) + _expect(_clock.absolute_minute == 1920, "disk load restores the second morning at08:00") + _expect(_guide.get_progress().get(&"stage") == &"complete", "restarted UI recognizes completed opening") + var selection: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(_evidence_path("selection.json"))) + _expect(String(_main.get("_selected_entity_id")) == selection["id"], "disk load restores the facility selected when saving") + _expect(_main.get("_selected_entity_kind") == &"facility", "disk load restores the selection type") + _expect(String(_main.get("world_preview").selected_entity_id) == selection["id"], "disk load restores the selected facility highlight") + +func _continue_to_third_close() -> void: + await _advance(4320 - _clock.absolute_minute) + _expect(_clock.absolute_minute == 4320, "natural operation reaches third midnight") + var summaries: Array = _runtime.operations_coordinator.get_day_summaries() + _expect(summaries.size() == 3, "three calendar closes exist exactly once") + for summary: Dictionary in summaries: + _expect(summary.get(&"balanced", false), "every operating close reconciles with the ledger") + var report: Dictionary = _runtime.get_operations_report() + _expect(not report.get(&"runtime_failure_latched", true), "no simulation failure after multi-day continuation") + _expect(_ledger.get_report().get(&"selected_close") == summaries.back(), "dashboard presents latest third-day report") + var detail: String = _ledger.get("_facility_label").text + _expect(not detail.contains("facility_"), "completed infrastructure has human-readable ledger names") + for definition_id: StringName in [&"guest_stairs", &"guest_corridor"]: + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id) + _expect(detail.contains(definition.display_name), "ledger identifies charged infrastructure: %s" % definition.display_name) + +func _gameplay_digest() -> String: + var snapshot: Dictionary = _runtime.create_snapshot() + # SAVE_LOADED contains measured elapsed time, which differs by process. + # Preserve ID counters, RNG streams and every other gameplay subsystem. + snapshot[&"systems"][&"runtime"].erase(&"domain_events") + var phase := "read" if "--read" in OS.get_cmdline_user_args() else "write" + var evidence := FileAccess.open(_evidence_path(phase + "-gameplay.json"), FileAccess.WRITE) + if evidence != null: + evidence.store_string(JSON.stringify(snapshot, "\t") + "\n") + evidence.close() + return SimulationDigest.compute(snapshot) + +func _evidence_path(name: String) -> String: + return OS.get_environment("THE_TREE_OPENING_RESTART_OUTPUT").path_join(name) + +func _capture_restart(name: String) -> void: + if DisplayServer.get_name() == "headless": return + _main.call("_refresh_dashboard") + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(_evidence_path(name + ".png")) == OK, "capture restart flow: " + name) diff --git a/tests/ui/fixtures/opening_restart_flow.gd.uid b/tests/ui/fixtures/opening_restart_flow.gd.uid new file mode 100644 index 0000000..f85b3e3 --- /dev/null +++ b/tests/ui/fixtures/opening_restart_flow.gd.uid @@ -0,0 +1 @@ +uid://b0wbovftoj0yu diff --git a/tests/ui/test_actor_selection.gd b/tests/ui/test_actor_selection.gd new file mode 100644 index 0000000..bd8f66e --- /dev/null +++ b/tests/ui/test_actor_selection.gd @@ -0,0 +1,184 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _main: Control +var _staff: StaffManagementPanel +var _guest: GuestObservationPanel +var _path := "user://tests/actor_selection_%d/slot" % OS.get_process_id() +var _slots: Array[String] = [] + +func _initialize() -> void: + call_deferred("_run") + +func _expect(ok: bool, message: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(message) + +func _mount() -> void: + _main = load("res://scenes/main/main.tscn").instantiate() + _main.set("manual_save_path", _path) + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + _staff = _main.find_child("StaffManagementPanel", true, false) + _guest = null + +func _choose(staff_id: StringName, guest_id: StringName, opened := true) -> void: + _main.call("_activate_management_mode", &"staff") + _staff.refresh() + _staff.roster_buttons[staff_id].pressed.emit() + _main.get("guest_count_label").pressed.emit() + _guest = _main.get("guest_observer").find_child("GuestObservationPanel", true, false) + for i: int in _guest.guests.item_count: + if _guest.guests.get_item_metadata(i) == guest_id: + _guest.guests.select(i) + _guest.guests.item_selected.emit(i) + if not opened: + _main.call("_set_staff_management_panel_open", false) + _main.get("guest_observer").hide() + +func _check_selected(staff_id: StringName, guest_id: StringName, opened: bool, context: String) -> void: + var observer: AcceptDialog = _main.get("guest_observer") + _expect(observer != null, context + " creates a real guest inspector when required") + if observer == null: + return + _guest = observer.find_child("GuestObservationPanel", true, false) + _expect(_staff.selected_staff_id == staff_id, context + " restores saved staff instead of first roster entry") + _expect(_guest.selected_guest_id == guest_id and _guest.guests.get_item_metadata(_guest.guests.selected) == guest_id, context + " restores saved guest and dropdown") + _expect(_staff.visible == opened and _main.get("guest_observer").visible == opened, context + " restores inspector visibility") + var amount: int = _runtime.guest_service.get_guest(guest_id).wallet_gold + _expect("%dG" % amount in _guest.details.text, context + " guest details show the actual restored wallet") + var name := "バルド" if staff_id == &"staff_bardo" else "アカネ" + _expect(_runtime.staff_service.has_staff(staff_id) and name in _staff.staff_detail_label.text, context + " staff detail name matches the restored employee") + _expect(_guest.bars[&"hunger"].value == _runtime.guest_service.get_guest(guest_id).needs[&"hunger"], context + " need bar matches the independently saved guest") + +func _gameplay_snapshot() -> Dictionary: + var snapshot: Dictionary = _runtime.create_snapshot() + # Save/load observations contain measured duration and intentionally differ. + snapshot[&"systems"][&"runtime"].erase(&"domain_events") + return snapshot + +func _gameplay_matches(saved: Dictionary, context: String, observation_count := 0) -> bool: + var expected := saved.duplicate(true) + # Runtime reports every load attempt using one new diagnostic event ID. + # Assert that exact increment; retain every other allocator and subsystem. + var counters: Dictionary = expected[&"systems"][&"runtime"][&"id_generator"][&"counters"] + counters["domain_event"] = int(counters.get("domain_event", 0)) + observation_count + var actual := _gameplay_snapshot() + var equal := SimulationDigest.compute(expected) == SimulationDigest.compute(actual) + if not equal: + var file := FileAccess.open("res://artifacts/actor-selection-2026-09-30/" + context + "-gameplay-diff.json", FileAccess.WRITE) + file.store_string(JSON.stringify({"expected": expected, "actual": actual}, "\t")) + return equal + +func _write_fixture(suffix: String, selection: Variant) -> String: + var path := _path + suffix + _slots.append(path) + var metadata := {} if selection == null else {&"interface_selection": selection} + _expect(SaveService.save_snapshot(path, _runtime.create_snapshot(), metadata).get(&"ok", false), "write gameplay-valid optional presentation fixture " + suffix) + return path + +func _run() -> void: + root.gui_embed_subwindows = true + root.size = Vector2i(1440, 810) + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _clock.reset(1, 6, 0) + root.get_node("GameState").reset() + _runtime.initialize(_clock, root.get_node("GameState"), 42532) + _clock.set_paused(true) + await _mount() + _main.get("staff_candidate_toggle_button").pressed.emit() + for id: StringName in [&"staff_akane", &"staff_bardo"]: + _main.get("staff_candidate_buttons")[id].pressed.emit() + _expect(_runtime.staff_service.has_staff(&"staff_akane") and _runtime.staff_service.has_staff(&"staff_bardo"), "ordinary candidate buttons hire both real employees") + # Controlled arrival fixtures isolate observer persistence, not natural arrivals. + var definition: GuestDefinition = _runtime.guest_service.get_definitions()[0] + for row: Array in [[&"selection_guest_a", 510, 17], [&"selection_guest_b", 910, 83]]: + _expect(_runtime.submit_command(&"spawn_guest", {&"guest_id": row[0], &"guest_type_id": definition.guest_type_id, &"gold": row[1], &"options": {&"planned_exit_at": _clock.absolute_minute + 1000, &"needs": {&"hunger": row[2]}}}).is_success(), "spawn an actual saved visitor " + String(row[0])) + _choose(&"staff_bardo", &"selection_guest_b") + var captured_before: Dictionary = _runtime.create_snapshot() + _main.call("_on_checkpoint_presentation_requested", {}) + _expect(captured_before == _runtime.create_snapshot(), "collecting actor presentation does not mutate any Runtime subsystem") + _main.call("_save_manual") + var before := _gameplay_snapshot() + _slots.append(_path) + var package := SaveService.load_snapshot(_path) + _expect(package.get(&"ok", false), "normal manual save creates a gameplay package") + var selection: Dictionary = package.get(&"manifest", {}).get("interface_selection", {}) + _expect(selection.get("staff_id") == "staff_bardo" and selection.get("guest_id") == "selection_guest_b", "normal save captures both independently chosen actor IDs") + _choose(&"staff_akane", &"selection_guest_a", false) + _main.call("_load_manual", true) + _expect(_main.get("build_status_label").text == "手動セーブを読み込みました。", "normal strict load accepts the complete actor gameplay package") + _check_selected(&"staff_bardo", &"selection_guest_b", true, "manual strict load") + _expect(_gameplay_matches(before, "manual"), "actor restoration does not change loaded gameplay") + var capture := OS.get_environment("THE_TREE_ACTOR_SELECTION_CAPTURE") + if not capture.is_empty(): + for frame: int in 5: + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(capture) == OK, "capture loaded guest inspector over saved staff panel") + _main.get("guest_observer").hide() + _main.call("_set_staff_management_panel_open", false) + var hidden_path := _path + "_hidden" + _slots.append(hidden_path) + _expect(_runtime.save_to_path(hidden_path).get(&"ok", false), "direct Runtime save collects hidden inspector selections") + _main.queue_free() + await process_frame + await _mount() + _expect(_main.get("guest_observer") == null, "new main has no pre-existing guest popup") + _expect(_runtime.load_from_path(hidden_path).get(&"ok", false), "new main accepts hidden actor selection package") + _check_selected(&"staff_bardo", &"selection_guest_b", false, "fresh main hidden load") + _choose(&"staff_bardo", &"selection_guest_b") + var intact := _gameplay_snapshot() + _expect(not _runtime.load_from_path(_path + "_missing").get(&"ok", true), "missing package is rejected") + _check_selected(&"staff_bardo", &"selection_guest_b", true, "failed load") + _expect(_gameplay_matches(intact, "failed", 1), "failed load preserves gameplay") + var generations := _path + "_generations" + _slots.append(generations) + for pair: Array in [[&"staff_bardo", &"selection_guest_b"], [&"staff_akane", &"selection_guest_a"]]: + _choose(pair[0], pair[1]) + _expect(_runtime.queue_checkpoint(generations, {}, true).get(&"ok", false), "queue actual rotating checkpoint") + _clock.advance_minutes(1) + _expect(_runtime.load_recovery_candidate(generations, &"autosave", 1).get(&"ok", false), "load older actor checkpoint generation") + _check_selected(&"staff_bardo", &"selection_guest_b", true, "older recovery candidate") + _expect(_runtime.load_recovery_candidate(generations, &"autosave", 0).get(&"ok", false), "load newest actor checkpoint generation") + _check_selected(&"staff_akane", &"selection_guest_a", true, "newest recovery candidate") + # Invalid primary map metadata must not discard valid actor presentation. + var independent := {&"version": 1, &"kind": [], &"id": {}, &"staff_id": "staff_bardo", &"staff_open": true, &"guest_id": "selection_guest_b", &"guest_open": true} + var independent_path := _write_fixture("_independent", independent) + _expect(_runtime.load_from_path(independent_path).get(&"ok", false), "invalid map presentation does not invalidate gameplay") + _check_selected(&"staff_bardo", &"selection_guest_b", true, "independent actor metadata") + for entry: Array in [ + ["_missing_ids", {&"version": 1, &"staff_id": "nonexistent_staff", &"staff_open": true, &"guest_id": "nonexistent_guest", &"guest_open": true}], + ["_wrong_types", {&"version": 1, &"staff_id": [], &"staff_open": true, &"guest_id": 17, &"guest_open": true}], + ["_long_ids", {&"version": 1, &"staff_id": "a".repeat(257), &"staff_open": true, &"guest_id": "b".repeat(257), &"guest_open": true}], + ["_wrong_version", {&"version": 9, &"staff_id": "staff_bardo", &"staff_open": true, &"guest_id": "selection_guest_b", &"guest_open": true}], + ["_legacy", {&"version": 1, &"kind": "", &"id": ""}], + ["_none", null], + ]: + _choose(&"staff_bardo", &"selection_guest_b") + var path := _write_fixture(entry[0], entry[1]) + var expected := _gameplay_snapshot() + _expect(_runtime.load_from_path(path).get(&"ok", false), "optional invalid or old metadata still loads " + entry[0]) + _expect(not _staff.visible and not _main.get("guest_observer").visible, "invalid or absent actor metadata does not reopen inspectors " + entry[0]) + _expect(_staff.selected_staff_id != &"nonexistent_staff" and _guest.selected_guest_id != &"nonexistent_guest", "missing actor IDs never become active selections " + entry[0]) + _expect(_gameplay_matches(expected, entry[0], 1), "presentation fallback preserves gameplay " + entry[0]) + var wrong_flags := {&"version": 1, &"staff_id": "staff_bardo", &"staff_open": "true", &"guest_id": "selection_guest_b", &"guest_open": 1} + _expect(_runtime.load_from_path(_write_fixture("_flags", wrong_flags)).get(&"ok", false), "wrong visibility types do not block valid actor selection") + _check_selected(&"staff_bardo", &"selection_guest_b", false, "strict visibility flags") + _finish() + +func _finish() -> void: + if is_instance_valid(_main): + _main.queue_free() + for path: String in _slots: + SaveService.delete_save(path) + print("Actor selection: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures else 0) diff --git a/tests/ui/test_actor_selection.gd.uid b/tests/ui/test_actor_selection.gd.uid new file mode 100644 index 0000000..dff4b2c --- /dev/null +++ b/tests/ui/test_actor_selection.gd.uid @@ -0,0 +1 @@ +uid://bwvp6pp1gmkb5 diff --git a/tests/ui/test_anri_recruitment.gd b/tests/ui/test_anri_recruitment.gd new file mode 100644 index 0000000..899d3a7 --- /dev/null +++ b/tests/ui/test_anri_recruitment.gd @@ -0,0 +1,105 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant = 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,42601); clock.set_paused(true) + root.size = Vector2i(1280,720) + var main: Control = load("res://scenes/main/main.tscn").instantiate() + var path := "user://tests/anri-recruitment-%d" % Time.get_ticks_usec() + main.manual_save_path = path + root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame; await process_frame + var before: Dictionary = runtime.create_snapshot() + var economy_before: Dictionary = runtime.economy_service.create_snapshot() + main.staff_candidate_toggle_button.pressed.emit() + var button: Button = main.staff_candidate_buttons.get(&"heroine_anri") + expect(button != null and button.text == "無料で加入" and button.is_visible_in_tree(), "normal staff screen offers Anri free recruitment") + clock.set_paused(false) + button.pressed.emit() + clock.set_paused(true) + expect(button.disabled and not runtime.staff_service.has_staff(&"heroine_anri"), "running game queues recruitment without premature joining") + button.pressed.emit() + expect(runtime.command_queue.create_snapshot()["pending"].size() == 1, "repeated UI action does not duplicate recruitment") + main.call("_save_manual") + expect(SaveService.load_snapshot(path)[&"ok"], "pending Anri recruitment saves through normal UI") + expect(runtime.restore_snapshot(before), "restore control before loading pending recruitment") + main.call("_load_manual") + clock.advance_one_minute() + var anri: StaffRuntime = runtime.staff_service.get_staff(&"heroine_anri") + expect(anri != null and anri.role_id == &"innkeeper" and anri.employment_kind == StaffRuntime.EmploymentKind.UNIQUE, "restored request creates canonical unique innkeeper") + expect(runtime.economy_service.create_snapshot() == economy_before and anri.daily_wage == 0, "joining costs no cash and creates no daily wage") + expect(button.disabled and button.text == "加入済み", "joined unique cannot be recruited twice in UI") + main.staff_management_toggle_button.pressed.emit() + var panel: StaffManagementPanel = main.find_child("StaffManagementPanel",true,false) + panel.select_staff(&"heroine_anri") + expect(panel.staff_detail_label.text.contains("アンリ") and panel.staff_detail_label.text.contains("女将") and panel.staff_detail_label.text.contains("日給 G0"), "staff screen uses unique name role and zero upkeep") + main.call("_save_manual") + var joined: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(before), "restore pre-join control") + main.call("_load_manual") + expect(runtime.staff_service.has_staff(&"heroine_anri") and runtime.get_hired_staff_candidate_ids().has(&"heroine_anri"), "normal load restores unique identity and recruitment history") + expect(not runtime.staff_service.retire_staff(&"heroine_anri").is_success(), "recruited Anri cannot retire") + var invalid := joined.duplicate(true) + for row: Dictionary in invalid[&"systems"][&"runtime"][&"staff"][&"staff"]: + if row[&"staff_id"] == "heroine_anri": row[&"employment_kind"] = "FREE_HIRE" + var intact: Dictionary = runtime.create_snapshot() + expect(not runtime.restore_snapshot(invalid) and runtime.create_snapshot() == intact, "save cannot downgrade Anri to removable generic employment") + # Place the recruited manager through the normal staff controls. + for build: Array in [[&"reception",Vector3i(-6,0,0)],[&"public_bath",Vector3i(1,0,0)]]: + main.call("_select_build_definition", build[0]) + main.call("_on_map_cell_clicked", build[1]) + clock.advance_minutes(runtime.definition_registry.get_facility(build[0]).build_minutes) + var reception: StringName = &"" + var bath: StringName = &"" + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id==&"reception": reception=facility.instance_id + if facility.definition_id==&"public_bath": bath=facility.instance_id + expect(not reception.is_empty() and not bath.is_empty(), "normal UI builds manager workplace and same-floor service") + panel.refresh(); panel.select_staff(&"heroine_anri") + for i in panel.facility_option.item_count: + if panel.facility_option.get_item_metadata(i)==reception: + panel.facility_option.select(i); panel.facility_option.item_selected.emit(i) + panel.apply_button.pressed.emit() + expect(runtime.staff_service.get_work_assignment(&"heroine_anri").get(&"facility_id")==reception, "normal staff controls place unique innkeeper") + var reception_instance: FacilityInstance=runtime.facility_service.get_instance(reception) + expect(reception_instance.active_staff_ids.is_empty(), "Anri does not meet the receptionist staffing requirement") + expect(not main.opening_guide._has_reception_assignment(reception_instance), "opening guide does not mistake innkeeper placement for receptionist coverage") + var manager_only: Dictionary=runtime.create_snapshot() + main.call("_hire_staff_candidate", &"staff_akane") + panel.refresh();panel.select_staff(&"staff_akane") + for i in panel.facility_option.item_count: + if panel.facility_option.get_item_metadata(i)==reception: + panel.facility_option.select(i);panel.facility_option.item_selected.emit(i) + panel.apply_button.pressed.emit() + expect(main.opening_guide._has_reception_assignment(reception_instance), "actual receptionist placement satisfies opening guide") + expect(runtime.restore_snapshot(manager_only), "restore manager-only workplace after receptionist comparison") + expect(not main.opening_guide._has_reception_assignment(runtime.facility_service.get_instance(reception)), "loading manager-only placement restores unsatisfied receptionist guidance") + main.navigation_buttons[&"business"].pressed.emit() + var business: BusinessManagementPanel = main.find_child("BusinessManagementPanel",true,false) + business.refresh() + for i in business.facility_option.item_count: + if business.facility_option.get_item_metadata(i)==bath: + business.facility_option.select(i); business.facility_option.item_selected.emit(i) + expect("24G" in business.quote_label.text and "アンリ配置フロア" in business.effect_label.text, "normal business screen explains current20percent floor price") + for i in business.price_option.item_count: + if business.price_option.get_item_metadata(i)==&"expensive": + business.price_option.select(i); business.price_option.item_selected.emit(i) + business.refresh() + expect("設定後 36G" in business.quote_label.text, "draft150percent policy combines with Anri120percent consistently") + if OS.get_environment("THE_TREE_ANRI_CAPTURE") == "1": + if not panel.visible: main.staff_management_toggle_button.pressed.emit() + panel.select_staff(&"heroine_anri") + expect(panel.is_visible_in_tree(), "recruited staff panel visible for rendered inspection") + await process_frame; await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/anri-floor-assigned.png")) == OK, "capture recruited Anri") + SaveService.delete_save(path) + main.queue_free(); await process_frame + print("Anri recruitment UI: %d checks, %d failures" % [checks,failures]); quit(failures) diff --git a/tests/ui/test_anri_recruitment.gd.uid b/tests/ui/test_anri_recruitment.gd.uid new file mode 100644 index 0000000..cc93631 --- /dev/null +++ b/tests/ui/test_anri_recruitment.gd.uid @@ -0,0 +1 @@ +uid://cvc6rmlmj0ak4 diff --git a/tests/ui/test_approved_free_unique_arrears_ui.gd b/tests/ui/test_approved_free_unique_arrears_ui.gd new file mode 100644 index 0000000..c4cd17f --- /dev/null +++ b/tests/ui/test_approved_free_unique_arrears_ui.gd @@ -0,0 +1,164 @@ +extends SceneTree +## Candidate only, not compiled/executed. Normal construction, staff, save and +## load controls are exercised; the sole money fixture is an explicitly labelled +## ledger spend at 23:59 using existing valid accounts/category. No tuning edits. +## Invoke -- --read in a second process with the SAME unique test save slot. +var checks := 0 +var failures := 0 +var main: Control +var runtime: Variant +var clock: Variant +var slot := "" + +func _initialize() -> void: + call_deferred("run") + +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error("FREE_UNIQUE_UI: " + label) + +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() + 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 unique_hire_result() -> CommandResult: + for saved: Dictionary in runtime.command_queue.create_snapshot()["processed"]: + var details: Dictionary = saved.get("details",{}) + if StringName(details.get(&"staff_id",&"")) == &"heroine_anri": + return CommandResult.from_dictionary(saved) + return null + +func verify_joined() -> void: + var anri: StaffRuntime = runtime.staff_service.get_staff(&"heroine_anri") + expect(anri != null and anri.employment_kind == StaffRuntime.EmploymentKind.UNIQUE and anri.role_id == &"innkeeper","normal read has canonical unique innkeeper") + if anri == null: + return + expect(runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) == 0 and runtime.upkeep_settlement.get_arrears_total() > 0,"joining preserves zero funds and actual unpaid upkeep") + expect(anri.daily_wage == 0 and runtime.staff_service.get_daily_payroll_total() == 0,"unique upkeep remains zero") + main.call("_set_staff_candidate_panel_open",true) + var button: Button = main.staff_candidate_buttons.get(&"heroine_anri") + expect(button != null and button.disabled and button.text == "加入済み","loaded normal UI cannot join unique twice") + main.staff_management_toggle_button.pressed.emit() + var panel := main.find_child("StaffManagementPanel",true,false) as StaffManagementPanel + expect(panel != null,"normal hired staff panel present") + if panel != null: + panel.select_staff(&"heroine_anri") + expect(panel.staff_detail_label.text.contains("アンリ") and panel.staff_detail_label.text.contains("日給 G0"),"normal management shows named character and no upkeep") + var completed := unique_hire_result() + expect(completed != null and completed.is_success() and completed.details.get(&"employment_kind") == &"UNIQUE","disk-persisted completed UI command has actual UNIQUE mode") + var events := 0 + for event: SimulationDomainEvent in runtime.domain_events.recent_events(): + if event.event_type == &"STAFF_CANDIDATE_HIRED" and &"heroine_anri" in event.entity_ids: + events += 1 + expect(event.payload.get(&"employment_kind") == &"UNIQUE","disk-persisted joining event has corrected mode") + expect(events == 1,"UI load contains one successful joining event") + var before: Dictionary = runtime.create_snapshot() + expect(not runtime.staff_service.retire_staff(&"heroine_anri").is_success() and not runtime.staff_service.remove_staff(&"heroine_anri",&"SOLD").is_success() and runtime.create_snapshot() == before,"loaded unique remains unsellable and non-retirable") + var corrupt := before.duplicate(true) + for row: Dictionary in corrupt[&"systems"][&"runtime"][&"staff"][&"staff"]: + if row[&"staff_id"] == "heroine_anri": + row[&"employment_kind"] = "FREE_HIRE" + expect(not runtime.restore_snapshot(corrupt) and runtime.create_snapshot() == before,"save corruption cannot downgrade unique protection") + +func run() -> void: + var explicit_slot: bool = OS.has_environment("THE_TREE_UNIQUE_FREE_UI_SLOT") + slot = OS.get_environment("THE_TREE_UNIQUE_FREE_UI_SLOT") + if not explicit_slot: + slot = "user://tests/approved_free_unique_%d_%d/manual" % [OS.get_process_id(), Time.get_ticks_usec()] + if DirAccess.dir_exists_absolute(ProjectSettings.globalize_path(slot)): + push_error("Generated test slot already exists; refuse overwrite.") + quit(90) + return + var relative_slot := slot.trim_prefix("user://tests/") + var valid_slot: bool = slot.begins_with("user://tests/") and not relative_slot.is_empty() and not relative_slot.ends_with("/") and not relative_slot.contains("\\") + for segment: String in relative_slot.split("/", true): + valid_slot = valid_slot and segment not in ["", ".", ".."] + if not valid_slot: + push_error("Explicit test slot is invalid; no production fallback.") + quit(90) + return + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + clock.reset(1,6,0) + state.reset() + expect(runtime.initialize(clock,state,424242)[&"valid"],"canonical real Runtime initializes") + clock.set_paused(true) + root.size = Vector2i(1440,900) + main = load("res://scenes/main/main.tscn").instantiate() + main.manual_save_path = slot + root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame + await process_frame + if "--read" in OS.get_cmdline_user_args(): + load_menu() + expect(clock.absolute_minute == 1441,"second-process normal load restores exact joined minute") + verify_joined() + else: + # Actual build palette and map handler; no direct completed-room insertion. + main.call("_select_build_definition",&"reception") + main.call("_on_map_cell_clicked",Vector3i(-6,0,0)) + expect(runtime.construction_service.get_jobs().size() == 1,"normal UI submits reception construction") + clock.advance_minutes(runtime.definition_registry.get_facility(&"reception").build_minutes) + clock.advance_minutes(1439-clock.absolute_minute) + var cash: int = runtime.economy_service.get_balance(runtime.PLAYER_CASH_ACCOUNT) + # Explicit boundary fixture only; not a natural-spending/balance assertion. + expect(runtime.economy_service.transfer(runtime.PLAYER_CASH_ACCOUNT,&"CONSTRUCTION_EXPENSE",cash,clock.absolute_minute,&"CONSTRUCTION",&"",&"acceptance.free_unique.ui_cash_fixture").is_success(),"controlled ledger spend before real upkeep close") + clock.advance_one_minute() + expect(clock.absolute_minute == 1440 and not runtime._operations_should_block_tick(),"ordinary first unpaid close completes without terminal/pause/failure") + expect(runtime.upkeep_settlement.get_arrears_total() > 0,"real ownership upkeep supplies test debt") + var economy_before: Dictionary = runtime.economy_service.create_snapshot() + var arrears_before: int = runtime.upkeep_settlement.get_arrears_total() + main.staff_candidate_toggle_button.pressed.emit() + var paid: Button = main.staff_candidate_buttons.get(&"staff_tessa") + expect(paid != null and not paid.disabled,"ordinary paid candidate still present") + if paid != null: + paid.pressed.emit() + expect(not runtime.staff_service.has_staff(&"staff_tessa") and runtime.economy_service.create_snapshot() == economy_before,"normal paid hire still denied without ledger changes") + var button: Button = main.staff_candidate_buttons.get(&"heroine_anri") + expect(button != null and button.is_visible_in_tree() and not button.disabled and button.text == "無料で加入","normal staff pane offers already-approved free Anri despite unrelated debt") + if button == null: + main.queue_free() + quit(failures) + return + var before: Dictionary = runtime.create_snapshot() + clock.set_paused(false) + button.pressed.emit() + clock.set_paused(true) + expect(not runtime.staff_service.has_staff(&"heroine_anri") and button.disabled and runtime.command_queue.pending_count() == 1,"running UI queues exactly one free joining request") + button.pressed.emit() + expect(runtime.command_queue.pending_count() == 1,"repeated UI press cannot enqueue twice") + save_button() + expect(SaveService.load_snapshot(slot)[&"ok"],"actual UI stores pending free request") + expect(runtime.restore_snapshot(before),"restore control before normal pending load") + load_menu() + expect(runtime.command_queue.pending_count() == 1 and not runtime.staff_service.has_staff(&"heroine_anri"),"normal disk load preserves pending request without premature joining") + clock.advance_one_minute() + expect(runtime.economy_service.create_snapshot() == economy_before and runtime.upkeep_settlement.get_arrears_total() == arrears_before,"executing restored free request does not spend or erase debt") + verify_joined() + save_button() # intentionally replace this explicit disposable test slot + expect(SaveService.load_snapshot(slot)[&"ok"],"joined state saved for independent process read") + var capture := OS.get_environment("THE_TREE_UNIQUE_FREE_UI_CAPTURE") + if not capture.is_empty() and DisplayServer.get_name() != "headless": + expect(capture.is_absolute_path() and not FileAccess.file_exists(capture),"new absolute rendered evidence path required") + if capture.is_absolute_path() and not FileAccess.file_exists(capture): + await process_frame + await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(capture) == OK,"rendered named character and unpaid HUD captured") + main.queue_free() + await process_frame + print("APPROVED_FREE_UNIQUE_UI phase=%s checks=%d failures=%d" % ["read" if "--read" in OS.get_cmdline_user_args() else "write",checks,failures]) + quit(failures) diff --git a/tests/ui/test_approved_free_unique_arrears_ui.gd.uid b/tests/ui/test_approved_free_unique_arrears_ui.gd.uid new file mode 100644 index 0000000..5af9ecb --- /dev/null +++ b/tests/ui/test_approved_free_unique_arrears_ui.gd.uid @@ -0,0 +1 @@ +uid://bnpq58ue07874 diff --git a/tests/ui/test_business_management.gd b/tests/ui/test_business_management.gd index a9274a6..da7a650 100644 --- a/tests/ui/test_business_management.gd +++ b/tests/ui/test_business_management.gd @@ -14,6 +14,22 @@ var _warehouse: StringName = &"" var _save_path := "user://tests/business_ui_%d/queued_policy" % OS.get_process_id() +func _test_layer_palette() -> void: + _main.call("_set_layer", 0) + var before: Color = _panel.facility_status_label.get_theme_color("font_color") + var selected: bool = _panel.business_open_button.button_pressed + _main.call("_set_layer", 1) + _panel.refresh() + _expect(_panel.facility_status_label.get_theme_color("font_color") == Color("d8eeea"), "BACK panel text survives refresh") + _expect(_panel.business_open_button.get_theme_color("font_hover_color") == Color("d8eeea") and _panel.business_open_button.get_theme_color("font_disabled_color") == Color("8da4a4"), "BACK toggle remains readable for hover and disabled states") + _panel.call("_set_result", "条件未達", false) + _expect(_panel.result_label.get_theme_color("font_color") == Color("ffb498"), "BACK error retains warning colour") + _main.call("_set_layer", 0) + _expect(_panel.facility_status_label.get_theme_color("font_color") == before and _panel.result_label.get_theme_color("font_color") == Color("a7402e"), "FRONT restores panel and warning colours") + _expect(_panel.business_open_button.button_pressed == selected, "palette does not toggle policy") + _panel.call("_set_result", "", true) + + func _initialize() -> void: call_deferred("_run") @@ -42,6 +58,7 @@ func _run() -> void: _expect(_panel.visible and _panel.apply_button.disabled and _panel.facility_option.disabled, "business nav opens a safe empty state before completed construction") _expect("完成" in _panel.facility_status_label.text, "empty state explains completed facility prerequisite") + _test_layer_palette() for request: Array in [ [&"reception", Vector3i(-8, 0, 0)], [&"standard_room", Vector3i(-8, 2, 0)], @@ -68,13 +85,30 @@ func _run() -> void: return _main.call("_activate_management_mode", &"business") _panel.refresh() + _test_related_facility_disk_save() _test_prices_and_drafts() _test_open_close() _test_saved_pending_edit() + _test_customer_counts() await _test_navigation_and_layout() _finish() +func _test_related_facility_disk_save() -> void: + _clock.set_paused(true) + var linked: CommandResult = _runtime.submit_command(&"set_facility_conditions", {&"facility_id": _rooms[0], &"conditions": {&"related_facility_id": _rooms[1]}}) + _expect(linked.is_success(), "built room accepts related facility through Runtime") + var snapshot: Dictionary = _runtime.create_snapshot() + var saved: Dictionary = _runtime.save_to_path(_save_path) + _expect(bool(saved.get(&"ok", false)), "related facility saves to real disk slot") + _runtime.submit_command(&"set_facility_conditions", {&"facility_id": _rooms[0], &"conditions": {&"related_facility_id": &""}}) + var loaded: Dictionary = _runtime.load_from_path(_save_path) + _expect(bool(loaded.get(&"ok", false)), "related facility loads through Runtime crosslink validation") + _expect(_runtime.facility_service.get_instance(_rooms[0]).related_facility_id == _rooms[1], "restored room retains the selected dependency") + _expect(_runtime.facility_service.create_snapshot() == snapshot[&"systems"][&"runtime"][&"facilities"], "facility state is exact across disk roundtrip") + _runtime.submit_command(&"set_facility_conditions", {&"facility_id": _rooms[0], &"conditions": {&"related_facility_id": &""}}) + + func _test_prices_and_drafts() -> void: _choose(_panel.facility_option, _rooms[0]) _expect(_panel.price_option.item_count == 5 and not _panel.price_option.disabled, "five price modes are available for paid guest rooms") @@ -124,8 +158,9 @@ func _test_open_close() -> void: _expect(not bool(_row(_rooms[1]).get(&"business_open", true)) and "停止" in _panel.facility_status_label.text, "close reaches actual facility operator state and status text") _expect("受付済みの料金は変わりません" in _panel.explanation_label.text - and "開始済みのサービス" in _panel.explanation_label.text and "給与は発生" in _panel.explanation_label.text, - "UI explains fixed accepted prices, service completion, and continued wages during closure") + and "開始済みのサービス" in _panel.explanation_label.text + and "休業中も給与と施設維持費は発生" in _panel.explanation_label.text, + "UI explains fixed accepted prices, service completion, and continued wages plus upkeep during closure") _panel.business_open_button.button_pressed = true _panel.apply_button.pressed.emit() _expect(bool(_row(_rooms[1]).get(&"business_open", false)), "admission can be reopened through the same controls") @@ -204,7 +239,8 @@ func _test_navigation_and_layout() -> void: _expect(control.get_global_rect().position.x >= side.get_global_rect().position.x and control.get_global_rect().end.x <= side.get_global_rect().end.x, "%s fits inside narrow sidebar" % control.name) if OS.get_environment("THE_TREE_BUSINESS_UI_CAPTURE") == "1": - var capture_dir := ProjectSettings.globalize_path("res://artifacts/business-management") + var requested_capture := OS.get_environment("THE_TREE_BUSINESS_CAPTURE_DIR") + var capture_dir := ProjectSettings.globalize_path(requested_capture if not requested_capture.is_empty() else "res://artifacts/business-management") DirAccess.make_dir_recursive_absolute(capture_dir) await RenderingServer.frame_post_draw root.get_texture().get_image().save_png(capture_dir.path_join("business-panel.png")) @@ -213,6 +249,10 @@ func _test_navigation_and_layout() -> void: await process_frame await RenderingServer.frame_post_draw root.get_texture().get_image().save_png(capture_dir.path_join("warehouse-policy.png")) + if OS.get_environment("THE_TREE_PANEL_SKIN_CAPTURE") == "1": + _main.call("_set_layer", 1) + await process_frame;await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/business-skin-back.png")) == OK, "capture BACK management skin") _main.call("_unhandled_key_input", _key(KEY_ESCAPE)) _expect(not _panel.visible and not bool(_main.get("_management_panel_open")), "Escape closes business inspector and restores full map") @@ -257,3 +297,30 @@ func _finish() -> void: _main.queue_free() print("Business management UI: %d checks, %d failures" % [_checks, _failures]) quit(1 if _failures > 0 else 0) + + +func _test_customer_counts() -> void: + # Controlled multi-person read-model fixture; no simulated time advances. + var saved: Dictionary = _runtime.facility_service.create_snapshot() + var room: FacilityInstance = _runtime.facility_service.get_instance(_rooms[1]) + room.active_services.clear() + room.customer_capacity = 3 + var group := ServiceInstance.new().configure(&"group", &"group_request", room.instance_id, _clock.absolute_minute, 60, [&"group_a", &"group_b"], []) + room.active_services[&"group"] = group + var request := ServiceRequest.new().configure(&"waiting_group", room.instance_id, [&"waiting_a", &"waiting_b", &"waiting_c"], &"default", _clock.absolute_minute) + _expect(_runtime.facility_service.enqueue_request(request).is_success(), "queue a three-person request for the occupancy read model") + var row := _row(room.instance_id) + _expect(row[&"active_count"] == 1 and row[&"active_customer_count"] == 2, "one grouped service reports two users separately from service count") + _expect(row[&"queued_count"] == 1 and row[&"queued_customer_count"] == 3, "one queued request reports three waiting people") + _panel.refresh() + _choose(_panel.facility_option, room.instance_id) + _expect("待ち 3人 / 利用中 2人" in _panel.facility_status_label.text and "上限 3人" in _panel.facility_status_label.text, "business screen shows people and explicit capacity") + room.customer_capacity = -1 + _panel.refresh() + _expect("上限 未設定" in _panel.facility_status_label.text, "unset customer limit is never replaced by generic capacity") + room.active_services.clear() + room.customer_capacity = 0 + _panel.refresh() + _expect("利用中 0人" in _panel.facility_status_label.text and "上限 0人" in _panel.facility_status_label.text, "zero limit remains distinct from an unspecified limit") + _expect(_runtime.facility_service.restore_snapshot(saved), "discard controlled occupancy fixture without changing later gameplay tests") + _panel.refresh() diff --git a/tests/ui/test_chapel_gameplay.gd b/tests/ui/test_chapel_gameplay.gd new file mode 100644 index 0000000..513b6dd --- /dev/null +++ b/tests/ui/test_chapel_gameplay.gd @@ -0,0 +1,85 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok:bool,label:String)->void: + checks+=1 + if not ok:failures+=1;push_error(label) +func _initialize()->void:call_deferred("run") +func cleric_id() -> StringName: return &"staff_uma" +func donation_price() -> int: return 20 +func recruit_cleric(main: Control, _runtime: Variant) -> void: + main.staff_candidate_buttons[cleric_id()].pressed.emit() +func verify_effect(_runtime: Variant, _chapel: FacilityInstance) -> void: pass +func verify_after_start(_runtime: Variant, _chapel: FacilityInstance) -> void: pass +func verify_completion(_runtime: Variant, _chapel: FacilityInstance, _service_id: StringName) -> void: pass +func run()->void: + var runtime:Variant=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,42607);clock.set_paused(true) + var original:Dictionary=runtime.create_snapshot() + var legacy:=original.duplicate(true) + legacy[&"systems"][&"runtime"][&"catalog_manifest"][&"manifest_version"]=1 + legacy[&"systems"][&"runtime"][&"catalog_manifest"][&"staff_candidate_ids"].erase("staff_uma") + expect(runtime.restore_snapshot(legacy),"exact old twenty-candidate manifest loads without losing progress") + var malformed:=legacy.duplicate(true) + malformed[&"systems"][&"runtime"][&"catalog_manifest"][&"staff_candidate_ids"].erase("staff_akane") + var intact:Dictionary=runtime.create_snapshot() + expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot()==intact,"arbitrary incomplete legacy catalog is rejected atomically") + malformed=legacy.duplicate(true) + malformed[&"systems"][&"runtime"][&"staff"][&"staff"]=[42] + expect(not runtime.restore_snapshot(malformed) and runtime.create_snapshot()==intact,"legacy malformed staff rows are rejected without a script type error") + var main:Control=load("res://scenes/main/main.tscn").instantiate();root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame;await process_frame + expect(main.MVP_FACILITY_IDS.has(&"chapel"),"chapel is exposed in normal construction palette") + main.call("_select_build_definition",&"chapel");main.call("_on_map_cell_clicked",Vector3i(-6,0,0));clock.advance_minutes(180) + var chapel:FacilityInstance + for f:FacilityInstance in runtime.facility_service.get_instances(): + if f.definition_id==&"chapel":chapel=f + expect(chapel!=null,"normal construction UI creates chapel") + if chapel==null:main.queue_free();quit(failures);return + expect(chapel.state==FacilityInstance.State.NO_STAFF,"chapel waits for cleric") + recruit_cleric(main, runtime) + expect(runtime.staff_service.has_staff(cleric_id()),"normal hire control recruits cleric") + var panel:StaffManagementPanel=main.find_child("StaffManagementPanel",true,false) + panel.refresh();panel.select_staff(cleric_id()) + expect(panel.staff_detail_label.text.contains("聖職者"),"staff UI labels new role") + for i in panel.facility_option.item_count: + if panel.facility_option.get_item_metadata(i)==chapel.instance_id: + panel.facility_option.select(i);panel.facility_option.item_selected.emit(i) + panel.apply_button.pressed.emit();clock.advance_minutes(20) + expect(chapel.active_staff_ids.has(cleric_id()) and chapel.state==FacilityInstance.State.OPEN,"assigned cleric walks to workplace and opens chapel") + verify_effect(runtime, chapel) + expect(runtime.submit_command(&"spawn_guest",{&"guest_id":&"worship_walker",&"guest_type_id":&"traveler",&"gold":100,&"options":{&"current_cell":Vector3i(-7,0,0),&"state":GuestRuntime.State.DECIDING,&"planned_exit_at":clock.absolute_minute+300,&"needs":{&"sleep":0,&"hunger":0,&"hygiene":0,&"medical":0,&"leisure":100,&"intimacy":0}}}).is_success(),"controlled arrival without forced destination or service") + var service_id:StringName=&"" + for minute in 30: + clock.advance_one_minute() + var g:GuestRuntime=runtime.guest_service.get_guest(&"worship_walker") + if g!=null and not g.current_service_id.is_empty():service_id=g.current_service_id;break + expect(not service_id.is_empty(),"guest autonomously selects, reaches and begins worship") + if not service_id.is_empty(): + verify_after_start(runtime, chapel) + var midpoint:Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(midpoint),"normal cleric, visitor and worship save together") + for minute in 61: + clock.advance_one_minute() + if runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED:break + var completed := int(runtime.facility_service.get_instance(chapel.instance_id).lifetime_statistics.get(&"services_completed", 0)) + expect(runtime.facility_service.get_service(service_id).state==ServiceInstance.State.COMPLETED and completed>0 and state.get_resource(&"faith")==completed and runtime.progression_service.metrics[&"faith_lifetime"]==completed,"each completed worship contributes exactly one Faith, including other autonomous visitors") + expect(runtime.economy_service.get_balance(&"GUEST_WALLET:worship_walker")==100-donation_price(),"actual worshipper pays quoted donation exactly once") + verify_completion(runtime, chapel, service_id) + expect(runtime.restore_snapshot(runtime.create_snapshot()),"completed chapel gameplay remains saveable") + main.navigation_buttons[&"business"].pressed.emit() + var business:BusinessManagementPanel=main.find_child("BusinessManagementPanel",true,false) + business.refresh() + for i in business.facility_option.item_count: + if business.facility_option.get_item_metadata(i)==chapel.instance_id: + business.facility_option.select(i);business.facility_option.item_selected.emit(i) + expect(business.quote_label.text.contains("%dG" % donation_price()) and business.effect_label.text.contains("信仰+1"),"business UI explains donation price and per-completion faith") + if OS.get_environment("THE_TREE_CHAPEL_CAPTURE")=="1": + root.size=Vector2i(1440,900) + await process_frame;await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path(OS.get_environment("THE_TREE_CHAPEL_CAPTURE_PATH") if not OS.get_environment("THE_TREE_CHAPEL_CAPTURE_PATH").is_empty() else "res://artifacts/facility-dimensions-2026-09-29/chapel-gameplay.png"))==OK,"capture native chapel business screen") + main.queue_free();await process_frame + print("Chapel gameplay: %d checks, %d failures"%[checks,failures]);quit(failures) diff --git a/tests/ui/test_chapel_gameplay.gd.uid b/tests/ui/test_chapel_gameplay.gd.uid new file mode 100644 index 0000000..a17415b --- /dev/null +++ b/tests/ui/test_chapel_gameplay.gd.uid @@ -0,0 +1 @@ +uid://d1bcyrfalhjc8 diff --git a/tests/ui/test_cleaning_gameplay.gd b/tests/ui/test_cleaning_gameplay.gd new file mode 100644 index 0000000..3b650ae --- /dev/null +++ b/tests/ui/test_cleaning_gameplay.gd @@ -0,0 +1,129 @@ +extends SceneTree +var checks:=0 +var failures:=0 +var runtime: Variant +var clock: Variant +func expect(ok: bool,label: String)->void: + checks+=1 + if not ok:failures+=1;push_error(label) +func _initialize()->void:call_deferred("run") +func active_job(id: StringName)->StaffTask: + for task: StaffTask in runtime.staff_service.get_tasks(): + if task.task_type==&"facility_clean" and task.target_entity_id==id and not task.is_terminal():return task + return null +func cleaner_id() -> StringName: return &"staff_eri" +func work_ticks() -> int: return 8 +func hire_cleaner(main: Control) -> void: + main.staff_candidate_buttons[cleaner_id()].pressed.emit() +func run()->void: + runtime=root.get_node("SimulationRuntime");clock=root.get_node("GameClock") + var state: Variant=root.get_node("GameState") + clock.reset(1,6,0);state.reset() + expect(runtime.initialize(clock,state,42631)[&"valid"],"initialize normal cleaning catalog") + clock.set_paused(true) + var main: Control=load("res://scenes/main/main.tscn").instantiate();root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame;await process_frame + expect(main.MVP_FACILITY_IDS.has(&"cleaning_room"),"normal palette contains cleaning room") + for build: Array in [[&"cleaning_room",Vector3i(-6,0,1)],[&"reception",Vector3i(1,0,0)]]: + main.call("_select_build_definition",build[0]);main.call("_on_map_cell_clicked",build[1]);clock.advance_minutes(runtime.definition_registry.get_facility(build[0]).build_minutes) + var room: FacilityInstance + var reception: FacilityInstance + for facility: FacilityInstance in runtime.facility_service.get_instances(): + if facility.definition_id==&"cleaning_room":room=facility + if facility.definition_id==&"reception":reception=facility + expect(room!=null and reception!=null,"normal UI builds both completed facilities") + if room==null or reception==null:main.queue_free();await process_frame;quit(failures);return + var preview: WorldPreview=main.world_preview + var room_id:=room.instance_id + var id:=reception.instance_id + expect(room.customer_capacity==0 and room.state==FacilityInstance.State.NO_STAFF,"cleaning room excludes customers and waits for cleaner") + var panel: StaffManagementPanel=main.find_child("StaffManagementPanel",true,false) + for assignment: Array in [[cleaner_id(),room_id],[&"staff_akane",id]]: + if assignment[0]==cleaner_id(): hire_cleaner(main) + else: main.staff_candidate_buttons[assignment[0]].pressed.emit() + panel.refresh();panel.select_staff(assignment[0]) + for i in panel.facility_option.item_count: + if panel.facility_option.get_item_metadata(i)==assignment[1]:panel.facility_option.select(i);panel.facility_option.item_selected.emit(i) + panel.apply_button.pressed.emit() + clock.advance_minutes(30) + expect(room.operation_state==FacilityInstance.OperationState.OPERATING and reception.operation_state==FacilityInstance.OperationState.OPERATING,"actual cleaner and receptionist commute to assigned workplaces") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"staffed cleaning room save/load") + expect(preview._staff_work_animation(runtime.staff_service.get_staff(cleaner_id()))==&"idle","cleaner at staffed base does not mime cleaning") + var count:=0 + for batch in 4: + var size:=8 if batch<3 else 1 + for index in size: + var guest:=StringName("cleaning_payer_%d"%count) + expect(runtime.submit_command(&"register_guest_wallet",{&"guest_id":guest,&"gold":100}).is_success(),"fund controlled visit") + expect(runtime.submit_command(&"create_service_request",{&"request_id":StringName("cleaning_visit_%d"%count),&"facility_id":id,&"guest_ids":[guest],&"service_type":&"check_in"}).is_success(),"request real reception service") + count+=1 + for minute in 25: + clock.advance_one_minute() + if int(runtime.facility_cleanliness.get_facility(id)["current"])==100-count:break + expect(runtime.facility_cleanliness.get_facility(id)["current"]==75,"25 completed defined visits reach cleaning threshold") + var task:=active_job(id) + expect(task!=null,"threshold schedules an actual staff task") + if task==null:main.queue_free();await process_frame;quit(failures);return + var task_id:=task.task_id + var walking_seen:=false + for minute in 80: + clock.advance_one_minute();task=runtime.staff_service.get_task(task_id) + if task.state==StaffTask.State.MOVING: + walking_seen=true + expect(preview._staff_work_animation(runtime.staff_service.get_staff(cleaner_id()))!=&"clean","moving cleaner does not mop before arrival") + expect(runtime.facility_cleanliness.get_facility(id)["current"]==75,"walking does not clean") + if task.state==StaffTask.State.WORKING:break + expect(walking_seen and task.state==StaffTask.State.WORKING,"assigned cleaner physically reaches work site") + var worker: StaffRuntime=runtime.staff_service.get_staff(cleaner_id()) + expect(worker.current_cell==task.target_cell and task.progress_minutes==0,"arrival starts work with zero elapsed minutes") + expect(runtime.facility_service.get_instance(room_id).operation_state==FacilityInstance.OperationState.OPERATING,"deployed cleaner continues staffing cleaning room") + if work_ticks() < 8: + expect(runtime.cleaning_bridge.status(id).contains("3倍速・残り3分"),"cleaning UI describes real remaining time at triple rate") + var working: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(working),"cleaner away from room and working task restore together") + expect(preview._staff_work_animation(runtime.staff_service.get_staff(cleaner_id()))==&"clean","restored on-site active task selects mop animation") + expect(not runtime.submit_command(&"progress_staff_task",{&"task_id":task_id,&"minutes":8}).is_success(),"manual progress cannot bypass actual work time") + # Save this command receipt too, so both replay paths have identical command history. + working=runtime.create_snapshot() + clock.advance_minutes(work_ticks()-1) + expect(runtime.facility_cleanliness.get_facility(id)["current"]==75 and runtime.staff_service.get_task(task_id).progress_minutes==(work_ticks()-1)*FacilityCleaningBridge.work_units_per_minute(runtime.staff_service.get_staff(cleaner_id())),"work before final tick is insufficient") + clock.advance_one_minute() + expect(runtime.facility_cleanliness.get_facility(id)["current"]==100 and runtime.staff_service.get_task(task_id).state==StaffTask.State.COMPLETED,"final tick completes one25-point recovery") + expect(preview._staff_work_animation(runtime.staff_service.get_staff(cleaner_id()))!=&"clean","completed job stops mop animation") + var finished: Dictionary=runtime.create_snapshot() + expect(runtime.restore_snapshot(working),"rewind actual cleaning work") + clock.advance_minutes(work_ticks()) + expect(runtime.create_snapshot()==finished,"cleaning replay matches all Runtime state") + # Stop an in-progress job through normal business controls. No partial recovery. + expect(runtime.restore_snapshot(working),"rewind for closed cleaning room") + clock.advance_minutes(work_ticks()-1) + expect(runtime.submit_command(&"set_facility_policy",{&"facility_id":room_id,&"patch":{&"business_open":false}}).is_success(),"close cleaning room through business policy") + clock.advance_minutes(10) + expect(runtime.facility_cleanliness.get_facility(id)["current"]<=75 and runtime.staff_service.get_task(task_id).state==StaffTask.State.CANCELLED,"closed source cancels partial work without recovery") + expect(preview._staff_work_animation(runtime.staff_service.get_staff(cleaner_id()))!=&"clean","closed base cancels visible cleaning") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"cancelled work plus waiting replacement job restore") + expect(runtime.restore_snapshot(working),"rewind for power interruption") + clock.advance_minutes(work_ticks()-1) + state.set_resource(&"mana",0);clock.advance_minutes(10) + expect(runtime.facility_cleanliness.get_facility(id)["current"]<=75 and runtime.staff_service.get_task(task_id).state==StaffTask.State.CANCELLED,"power loss stops cleaning without recovery") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"unpowered cleaning checkpoint restores") + expect(runtime.restore_snapshot(finished),"completed cleaning receipt restores") + clock.advance_minutes(5) + var cleaning_receipts:=0 + for receipt: Dictionary in runtime.facility_cleanliness.create_snapshot()["receipts"]: + if receipt["kind"]=="clean" and StringName(receipt["source_id"])==task_id:cleaning_receipts+=1 + expect(cleaning_receipts==1 and active_job(id)==null,"completion is not repeated; later normal guest visits may soil again") + main.navigation_buttons[&"business"].pressed.emit() + var business: BusinessManagementPanel=main.find_child("BusinessManagementPanel",true,false);business.refresh() + for i in business.facility_option.item_count: + if business.facility_option.get_item_metadata(i)==id:business.facility_option.select(i);business.facility_option.item_selected.emit(i) + expect(business.facility_status_label.text.contains("清潔度 %d / 100" % runtime.facility_cleanliness.get_facility(id)["current"]),"normal business UI shows actual cleanliness") + business.select_facility(room_id) + expect(business.business_open_button.text=="清掃業務を有効にする" and business.facility_status_label.text.contains("顧客利用なし") and not business.effect_label.text.contains("受付"),"cleaning room controls describe staff work rather than customer reception") + if OS.get_environment("THE_TREE_CLEANING_CAPTURE")=="1": + root.size=Vector2i(1280,720) + await process_frame;await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/cleanliness-runtime-2026-09-29/cleaning-gameplay.png"))==OK,"capture actual cleaning UI") + main.queue_free();await process_frame + print("Cleaning gameplay: %d checks, %d failures"%[checks,failures]);quit(failures) diff --git a/tests/ui/test_cleaning_gameplay.gd.uid b/tests/ui/test_cleaning_gameplay.gd.uid new file mode 100644 index 0000000..7158f2f --- /dev/null +++ b/tests/ui/test_cleaning_gameplay.gd.uid @@ -0,0 +1 @@ +uid://dwknbl8i0a7vt diff --git a/tests/ui/test_dashboard_coalescing.gd b/tests/ui/test_dashboard_coalescing.gd new file mode 100644 index 0000000..ca4b430 --- /dev/null +++ b/tests/ui/test_dashboard_coalescing.gd @@ -0,0 +1,40 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok: bool,label: String) -> void: + checks+=1 + if not ok: failures+=1;push_error(label) +func _initialize() -> void:call_deferred("run") +func run() -> void: + var runtime: Variant=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,42605);clock.set_paused(true) + var main: Control=load("res://scenes/main/main.tscn").instantiate() + main.set_script(load("res://tests/ui/fixtures/counted_dashboard.gd")) + root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame;await process_frame + var before: Dictionary=runtime.create_snapshot() + var initial: int=main.dashboard_refresh_count + clock.advance_minutes(3) + expect(clock.absolute_minute==363,"three simulation minutes still advance") + expect(main.dashboard_refresh_count==initial,"tick notifications do not rebuild dashboard synchronously") + expect(main._dashboard_refresh_pending,"batch schedules a dashboard refresh") + var after: Dictionary=runtime.create_snapshot() + await process_frame;await process_frame + expect(main.dashboard_refresh_count==initial+1,"three publications produce one completed display refresh") + expect(not main._dashboard_refresh_pending,"display refresh clears pending flag") + expect(runtime.create_snapshot()==after,"deferred display refresh cannot mutate simulation state") + main._flush_dashboard_refresh() + expect(main.dashboard_refresh_count==initial+1,"empty flush does not rebuild dashboard") + clock.advance_one_minute() + await process_frame;await process_frame + expect(main.dashboard_refresh_count==initial+2,"next frame gets a fresh display refresh") + expect(runtime.restore_snapshot(before),"restore initial simulation checkpoint") + await process_frame;await process_frame + clock.advance_minutes(3) + expect(runtime.create_snapshot()==after,"coalesced display preserves deterministic simulation replay") + main.queue_free() + await process_frame;await process_frame + print("Dashboard coalescing: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/ui/test_dashboard_coalescing.gd.uid b/tests/ui/test_dashboard_coalescing.gd.uid new file mode 100644 index 0000000..6e00b91 --- /dev/null +++ b/tests/ui/test_dashboard_coalescing.gd.uid @@ -0,0 +1 @@ +uid://djxfmyj5xengi diff --git a/tests/ui/test_defense_management.gd b/tests/ui/test_defense_management.gd new file mode 100644 index 0000000..39e334a --- /dev/null +++ b/tests/ui/test_defense_management.gd @@ -0,0 +1,323 @@ +extends SceneTree + +## Command-backed UI acceptance. Incidents are disclosed threat fixtures; +## combat progression uses the ordinary game clock. +var _checks := 0 +var _failures := 0 +var _main: Control +var _runtime: Variant +var _clock: Variant +var _panel: DefenseManagementPanel +var _facility_id: StringName = &"" +var _path := "user://tests/defense_management_%d/slot" % OS.get_process_id() +var _slots: Array[String] = [] + + +func _initialize() -> void: + call_deferred("_run") + + +func _expect(ok: bool, message: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(message) + + +func _run() -> void: + root.gui_embed_subwindows = true + root.size = Vector2i(1440, 810) + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _clock.reset(1, 6, 0) + root.get_node("GameState").reset() + _runtime.initialize(_clock, root.get_node("GameState"), 42540) + _clock.set_paused(true) + _main = load("res://scenes/main/main.tscn").instantiate() + _main.set("manual_save_path", _path) + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + _main.call("_set_management_panel_open", true) + _main.find_child("DefenseManagementButton", true, false).pressed.emit() + _panel = _main.get("defense_manager").find_child("DefenseManagementPanel", true, false) + _expect(_main.get("defense_manager").visible and _panel.selected_raid_id.is_empty(), "ordinary management button opens empty defense screen") + _expect("ありません" in _panel.summary_label.text and _panel.start_button.disabled, "no incident has no actionable start") + # Paid normal construction and hiring establish real target/defender links. + _main.get("defense_manager").hide() + _main.call("_select_build_definition", &"reception") + _main.call("_on_map_cell_clicked", Vector3i(-8, 0, 0)) + _main.call("_clear_build_selection") + _clock.advance_minutes(120) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"reception": _facility_id = facility.instance_id + _expect(not _facility_id.is_empty(), "normal paid reception construction provides a real target") + _main.get("staff_candidate_toggle_button").pressed.emit() + _main.get("staff_candidate_buttons")[&"staff_klaus"].pressed.emit() + _expect(_runtime.staff_service.has_staff(&"staff_klaus"), "ordinary recruitment hires guard with existing combat data") + if _facility_id.is_empty() or not _runtime.staff_service.has_staff(&"staff_klaus"): + _finish() + return + _expect(_create_incident(&"ui.defense.fixture").is_success(), "disclosed threat fixture creates an existing preparing incident") + _main.find_child("DefenseManagementButton", true, false).pressed.emit() + _expect(_panel.selected_raid_id == &"ui.defense.fixture" and "準備中" in _panel.summary_label.text, "real popup observes Runtime incident publication") + _expect(_panel.find_child("DefenseCancelPreparation", true, false) == null and &"cancel_defense_raid" not in DefenseManagementPanel.COMMANDS, "preparation has no free incident-cancellation button or allowed UI command") + var preparing_before: Dictionary = _runtime.create_snapshot() + _panel.call("_submit", &"cancel_defense_raid") + _panel.retreat_button.pressed.emit() + _expect(not _panel.confirmation.visible and (_panel.get("_confirmation_context") as Dictionary).is_empty(), "preparation cannot open a withdrawal confirmation even through a stale hidden button signal") + _expect(preparing_before == _runtime.create_snapshot(), "forbidden preparation cancellation and withdrawal do not enqueue commands or mutate any Runtime state") + var before: Dictionary = _runtime.create_snapshot() + var report: Dictionary = _runtime.get_defense_management_report(&"ui.defense.fixture") + _expect(before == _runtime.create_snapshot(), "management report does not mutate gameplay or RNG") + report[&"selected"][&"objective_ids"].clear() + report[&"staff"][0][&"combat"] = 999 + _expect(before == _runtime.create_snapshot(), "compact projections are detached from Defense and Staff") + _expect(not _panel.register_staff_button.disabled and _panel.start_button.disabled, "unregistered objective explains and prevents start") + _panel.register_staff_button.pressed.emit() + var raid: Dictionary = _runtime.defense_service.get_raid(&"ui.defense.fixture") + var guard: StaffRuntime = _runtime.staff_service.get_staff(&"staff_klaus") + _expect(raid[&"combatants"].has(&"staff_klaus"), "real participation button registers defender through Runtime command") + _expect(raid[&"combatants"][&"staff_klaus"][&"combat"] == guard.skills[&"combat"] and guard.state == StaffRuntime.State.UNAVAILABLE, "defender uses actual stored skill and becomes unavailable for work") + _panel.register_objective_button.pressed.emit() + raid = _runtime.defense_service.get_raid(&"ui.defense.fixture") + _expect(raid[&"assets"].has(_facility_id) and not _panel.start_button.disabled, "real target button registers existing facility and enables start") + _expect(raid[&"assets"][_facility_id][&"integrity"] == _runtime.facility_service.get_instance(_facility_id).durability, "objective reflects current facility durability") + # Policy edits survive publication until explicitly applied, and do not + # change current policy before the player presses the apply button. + _panel.policy_option.select(2) + _panel.policy_option.item_selected.emit(2) + _panel.threshold_spin.value = 7 + _panel.refresh() + _expect(_panel.policy_option.selected == 2 and _panel.threshold_spin.value == 7, "uncommitted retreat draft survives UI refresh") + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"retreat_policy"] == &"HOLD", "editing a draft alone does not alter defense") + _panel.policy_button.pressed.emit() + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"retreat_policy"] == &"THRESHOLD" and _runtime.defense_service.get_raid(&"ui.defense.fixture")[&"defender_retreat_threshold"] == 7, "apply button updates existing retreat policy contract") + _main.call("_save_manual") + _slots.append(_path) + var package := SaveService.load_snapshot(_path) + _expect(package.get(&"ok", false) and package.get(&"manifest", {}).get("interface_selection", {}).get("defense_id") == "ui.defense.fixture" and package.get(&"manifest", {}).get("interface_selection", {}).get("defense_open") == true, "normal save captures selected incident and open popup") + # Change a real policy after saving so the following read proves restoration. + _panel.policy_option.select(0) + _panel.policy_option.item_selected.emit(0) + _panel.policy_button.pressed.emit() + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"retreat_policy"] == &"HOLD", "ordinary policy button changes live preparation after the saved checkpoint") + _main.get("defense_manager").hide() + _main.call("_load_manual", true) + _expect(_main.get("defense_manager").visible and _panel.selected_raid_id == &"ui.defense.fixture", "manual load restores actual saved incident and visibility") + var restored_preparing: Dictionary = _runtime.defense_service.get_raid(&"ui.defense.fixture") + _expect(restored_preparing[&"state"] == DefenseService.RaidState.PREPARING and _runtime.staff_service.get_staff(&"staff_klaus").state == StaffRuntime.State.UNAVAILABLE, "saved preparation remains a live threat and keeps its actual defender locked") + _expect(restored_preparing[&"retreat_policy"] == &"THRESHOLD" and restored_preparing[&"defender_retreat_threshold"] == 7, "manual load restores the saved policy instead of the later live edit") + _clock.set_paused(false) + _panel.start_button.pressed.emit() + _panel.start_button.pressed.emit() + var queued := 0 + for command: Dictionary in _runtime.command_queue.create_snapshot().get("pending", []): + if command.get("command_type", "") == "start_defense_raid": queued += 1 + _expect(queued == 1 and _panel.start_button.disabled, "playing simulation accepts a single pending start despite repeated button activation") + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.PREPARING, "accepted running command has not mutated the incident before its tick") + _clock.advance_minutes(1) + _clock.set_paused(true) + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.ACTIVE and _panel.retreat_button.visible, "start button enters actual battle and exposes withdrawal") + # Stale start invocation is rejected by Runtime and never restarts combat. + var active: Dictionary = _runtime.defense_service.create_snapshot() + _panel.start_button.pressed.emit() + _expect(active == _runtime.defense_service.create_snapshot(), "invalid repeated start preserves active battle state") + # Withdrawal is available only after the existing incident starts. Backing + # out changes presentation alone and retains the threat and defender locks. + var active_before_return: Dictionary = _runtime.create_snapshot() + _panel.retreat_button.pressed.emit() + _expect(_panel.confirmation.visible and _panel.confirmation.title == "撤退の確認", "active battle opens only a withdrawal confirmation") + _panel.confirmation.canceled.emit() + _panel.confirmation.hide() + _expect((_panel.get("_confirmation_context") as Dictionary).is_empty() and active_before_return == _runtime.create_snapshot(), "returning from active withdrawal clears confirmation without changing gameplay or queued commands") + # Save the ACTIVE incident, then replace its queue through an actual load. + _main.call("_save_manual") + var active_package := SaveService.load_snapshot(_path) + _expect(active_package.get(&"ok", false), "normal save writes the active battle before stale-context testing") + _panel.retreat_button.pressed.emit() + var context: Dictionary = _panel.get("_confirmation_context").duplicate(true) + _expect(_panel.confirmation.visible and not context.is_empty(), "active withdrawal captures the current command queue before load") + _main.call("_load_manual", true) + _expect(_main.get("build_status_label").text == "手動セーブを読み込みました。" and _main.get("defense_manager").visible and _panel.selected_raid_id == &"ui.defense.fixture", "successful strict read restores active incident selection and popup") + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.ACTIVE, "active save restores actual battle state") + _expect(not _panel.confirmation.visible and (_panel.get("_confirmation_context") as Dictionary).is_empty(), "successful active load dismisses and invalidates the old withdrawal confirmation") + _expect(_runtime.command_queue.get_instance_id() != int(context.get(&"queue_instance_id", 0)), "successful load uses a different command queue than the pending confirmation") + var loaded_before_replay: Dictionary = _runtime.create_snapshot() + _panel.set("_confirmation_context", context) + _panel.confirmation.confirmed.emit() + _expect(loaded_before_replay == _runtime.create_snapshot(), "replayed old-queue withdrawal context leaves every loaded Runtime subsystem and command unchanged") + _expect("対象が変わりました" in _panel.result_label.text and _runtime.staff_service.get_staff(&"staff_klaus").state == StaffRuntime.State.UNAVAILABLE, "stale withdrawal reports rejection and leaves the loaded defender assigned") + # The disclosed fixture creates only the threat. Combat now progresses from + # ordinary game minutes without a round button or a synthetic player command. + var interval := int(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"round_minutes"]) + _clock.advance_minutes(interval - 1) + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"current_round"] == 0, "ordinary battle waits for its persisted round interval") + _clock.advance_minutes(1) + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"current_round"] == 1, "ordinary clock automatically resolves the first attack") + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.BETWEEN_WAVES and _panel.next_wave_button.visible, "service wave outcome exposes ordinary next-wave control") + _panel.next_wave_button.pressed.emit() + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.ACTIVE, "player next-wave button advances existing incident through Runtime") + _panel.retreat_button.pressed.emit() + _panel.confirmation.confirmed.emit() + _panel.confirmation.hide() + _expect(_runtime.defense_service.get_raid(&"ui.defense.fixture")[&"state"] == DefenseService.RaidState.DEFENDERS_RETREATED, "confirmed withdrawal terminates battle through Runtime") + _expect(_runtime.staff_service.get_staff(&"staff_klaus").state == StaffRuntime.State.FLEEING and _panel.policy_button.disabled, "retreat reconciles staff and terminal UI disables commands") + # Failed gameplay loads retain current selection. Optional bad presentation + # must not invalidate a valid gameplay load or reopen this inspector. + var selected := _panel.selected_raid_id + _expect(not _runtime.load_from_path(_path + "_absent").get(&"ok", true), "missing package load fails explicitly") + _expect(_panel.selected_raid_id == selected and _main.get("defense_manager").visible, "failed load preserves visible defense selection") + for entry: Array in [["types", []], ["missing", "missing.raid"], ["long", "x".repeat(257)]]: + var slot: String = _path + "_" + entry[0] + _slots.append(slot) + _expect(SaveService.save_snapshot(slot, _runtime.create_snapshot(), {&"interface_selection": {&"version": 1, &"defense_id": entry[1], &"defense_open": true}}).get(&"ok", false), "write malformed optional UI metadata fixture " + entry[0]) + _expect(_runtime.load_from_path(slot).get(&"ok", false), "valid gameplay still loads with optional invalid UI metadata " + entry[0]) + _expect(not _main.get("defense_manager").visible, "invalid incident selection does not reopen popup " + entry[0]) + _main.find_child("DefenseManagementButton", true, false).pressed.emit() + await _test_incident_presentation() + var capture := OS.get_environment("THE_TREE_DEFENSE_MANAGEMENT_CAPTURE") + if not capture.is_empty(): + for frame: int in 4: await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(capture) == OK, "capture actual defense popup after source-label and saved-selection checks") + _finish() + + +func _create_incident(id: StringName) -> CommandResult: + return _runtime.submit_command(&"create_defense_raid", {&"raid_id": id, &"definition": { + &"entry_points": [&"fixture.gate"], &"objective_ids": [_facility_id], + &"waves": [ + {&"wave_id": &"fixture.wave.one", &"threat_strength": 5, &"max_rounds": 1}, + {&"wave_id": &"fixture.wave.two", &"threat_strength": 5, &"max_rounds": 1}, + ], + }}, &"test.fixture") + + +func _finish() -> void: + if is_instance_valid(_main): _main.queue_free() + for path: String in _slots: + SaveService.delete_save(path) + if _failures == 0: print("PASS: %d checks" % _checks) + else: push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +## Display acceptance uses explicit disclosed causes through the actual Event +## queue. These cases do not claim normal threat generation or approve balance. +func _test_incident_presentation() -> void: + var expected := {"THIEF": "盗賊の襲撃", "BANKRUPTCY_RIOT": "客の暴動", "BANK_DELINQUENCY": "銀行による取り立て", "AUTHORITY_ESCALATION": "監査による摘発"} + var event_ids: Dictionary = {} + for kind: String in expected: + var event_id := StringName("ui.presentation.event." + kind) + var instance_id := StringName("ui.presentation.instance." + kind) + var action_id := &"ui.presentation.threat" + var action := EventAction.new().configure(action_id, EventAction.Type.CREATE_DEFENSE_RAID, { + &"template_id": &"ui.presentation.disclosed.template", &"source_kind": kind, + &"definition": _presentation_raid_definition(), + }) + var registered: CommandResult = _runtime.submit_command(&"register_event_definition", {&"definition": { + &"event_id": event_id, &"category": &"DEFENSE", &"foreground": false, + &"transaction_policy": &"ROLLBACK_ALL", &"automatic_actions": [action.create_snapshot()], + }}, &"test.fixture") + _expect(registered.is_success(), "disclosed presentation source registers " + kind) + var queued: CommandResult = _runtime.submit_command(&"enqueue_event", { + &"event_id": event_id, &"actor_ids": [], &"options": { + &"event_instance_id": instance_id, + &"context": {&"threat_source_id": StringName("ui.presentation.cause." + kind)}, + }, + }, &"test.fixture") + _expect(queued.is_success(), "disclosed presentation cause enters the real event queue " + kind) + _clock.advance_minutes(1) + var raid_id := StringName("event_defense_raid." + JSON.stringify([String(instance_id), String(action_id)], "", true, true).sha256_text()) + event_ids[kind] = raid_id + var raid: Dictionary = _runtime.defense_service.get_raid(raid_id) + _expect(not raid.is_empty() and raid.get(&"event_source", {}).get("source_kind", "") == kind, "ordinary event processing supplies explicit source provenance " + kind) + # The two manual incidents deliberately resemble a source enum. They have + # no event provenance and must remain generic defense records. + var manual_one := &"THIEF" + var manual_two := &"ui.presentation.manual.thief.source" + for id: StringName in [manual_one, manual_two]: + var definition := _presentation_raid_definition() + definition[&"source_id"] = &"THIEF" + var created: CommandResult = _runtime.submit_command(&"create_defense_raid", {&"raid_id": id, &"definition": definition}, &"test.fixture") + _expect(created.is_success(), "manual same-minute presentation incident is disclosed and created") + _main.find_child("DefenseManagementButton", true, false).pressed.emit() + _panel.refresh() + var before: Dictionary = _runtime.create_snapshot() + var report: Dictionary = _runtime.get_defense_management_report(event_ids["THIEF"]) + var selected: Dictionary = report.get(&"selected", {}) + _expect(selected.get(&"source_kind", "") == "THIEF" and typeof(selected.get(&"created_at")) == TYPE_INT, "compact management report exposes only explicit cause and persisted creation time") + selected[&"source_kind"] = "BANK_DELINQUENCY" + selected[&"created_at"] = -1 + _expect(_runtime.create_snapshot() == before, "display projection fields remain owned and cannot mutate gameplay, source or RNG") + for kind: String in expected: + var raid_id := StringName(event_ids[kind]) + _select_presentation_option(raid_id) + var index := _panel.raid_option.selected + _expect(_panel.selected_raid_id == raid_id and _panel.raid_option.get_item_metadata(index) == raid_id, "cause label never replaces internal selection metadata " + kind) + _expect(expected[kind] in _panel.raid_option.get_item_text(index) and "日目" in _panel.raid_option.get_item_text(index), "option shows readable cause and occurrence time " + kind) + _expect(expected[kind] in _panel.summary_label.text and String(raid_id) not in _panel.summary_label.text and "event_defense_raid." not in _panel.raid_option.get_item_text(index), "summary and option keep the SHA out of user text " + kind) + var again: Dictionary = _runtime.create_snapshot() + _panel.refresh() + _expect(_runtime.create_snapshot() == again and _panel.selected_raid_id == raid_id, "repeated label refresh preserves every gameplay system and selected record " + kind) + var manual_labels: Array[String] = [] + for id: StringName in [manual_one, manual_two]: + _select_presentation_option(id) + manual_labels.append(_panel.raid_option.get_item_text(_panel.raid_option.selected)) + _expect(_panel.selected_raid_id == id and "防衛戦" in _panel.summary_label.text and "盗賊" not in _panel.summary_label.text, "manual ID and source_id never imply a thief cause") + _expect(manual_labels[0] != manual_labels[1] and "(1)" in manual_labels[0] and "(2)" in manual_labels[1], "same cause/time manual incidents remain separately readable") + _expect(_panel.call("_raid_base_label", {&"raid_id": &"THIEF", &"source_id": &"THIEF"}) == "防衛戦 · 時刻不明", "legacy projection without cause/time never fabricates a source or midnight timestamp") + _expect("防衛戦" in String(_panel.call("_raid_base_label", {&"source_kind": "FUTURE_KIND", &"created_at": 0})), "unknown cause text receives a neutral label") + var saved_id := StringName(event_ids["THIEF"]) + _select_presentation_option(saved_id) + var saved_text := _panel.summary_label.text + _panel.policy_option.select(2) + _panel.policy_option.item_selected.emit(2) + _panel.threshold_spin.value = 17 + _panel.policy_button.pressed.emit() + _expect(_runtime.defense_service.get_raid(saved_id)[&"retreat_policy"] == &"THRESHOLD" and _runtime.defense_service.get_raid(saved_id)[&"defender_retreat_threshold"] == 17, "readable event selection sends the ordinary policy command to its actual internal raid") + _expect(_runtime.defense_service.get_raid(event_ids["BANK_DELINQUENCY"])[&"retreat_policy"] == &"HOLD", "selected policy action never affects another similarly displayed incident") + _main.call("_save_manual") + var package := SaveService.load_snapshot(_path) + _expect(package.get(&"ok", false) and package.get(&"manifest", {}).get("interface_selection", {}).get("defense_id") == String(saved_id), "normal UI save retains the full internal event raid ID") + _select_presentation_option(manual_two) + _main.get("defense_manager").hide() + _main.call("_load_manual", true) + _expect(_main.get("build_status_label").text == "手動セーブを読み込みました。" and _main.get("defense_manager").visible, "strict normal load restores the saved readable defense panel") + _expect(_panel.selected_raid_id == saved_id and _panel.summary_label.text == saved_text, "cause, creation time, state and exact selected event raid survive normal save/load") + _expect(_runtime.defense_service.get_raid(saved_id)[&"event_source"]["source_kind"] == "THIEF", "display labels never overwrite restored event provenance") + _expect(_runtime.defense_service.get_raid(saved_id)[&"retreat_policy"] == &"THRESHOLD" and _panel.threshold_spin.value == 17, "saved user command and policy controls restore on the same readable incident") + var restored_labels: Array[String] = [] + for id: StringName in [manual_one, manual_two]: + _select_presentation_option(id) + restored_labels.append(_panel.raid_option.get_item_text(_panel.raid_option.selected)) + _expect(restored_labels == manual_labels, "duplicate occurrence labels retain the same order after restoration") + _select_presentation_option(saved_id) + var title_before_failed_load := _panel.summary_label.text + _expect(not _runtime.load_from_path(_path + "_presentation_absent").get(&"ok", true), "failed load remains explicit during source-label acceptance") + _expect(_panel.selected_raid_id == saved_id and _panel.summary_label.text == title_before_failed_load, "failed read preserves readable selection and title") + for frame: int in 4: await process_frame + _expect(not _panel.raid_option.fit_to_longest_item and _panel.raid_option.clip_text, "raid selector cannot force window width from an internal or future long label") + _expect(_main.get("defense_manager").size.x <= 520 and _panel.raid_option.get_global_rect().size.x <= _main.get("defense_manager").size.x, "actual 520px defense dialog and selector stay within their intended width") + _expect(_panel.raid_option.get_item_tooltip(_panel.raid_option.selected) == _panel.raid_option.get_item_text(_panel.raid_option.selected), "clipped selector offers the full human-readable text instead of a technical ID") + + +func _select_presentation_option(raid_id: StringName) -> void: + for index: int in _panel.raid_option.item_count: + if _panel.raid_option.get_item_metadata(index) == raid_id: + _panel.raid_option.select(index) + _panel.raid_option.item_selected.emit(index) + return + _expect(false, "requested presentation raid exists in actual option metadata") + + +func _presentation_raid_definition() -> Dictionary: + return { + &"entry_points": [&"ui.presentation.disclosed.gate"], &"objective_ids": [_facility_id], + &"round_minutes": 5, &"max_duration_minutes": 120, + &"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0, + &"terrain_bonus": 0, &"command_bonus": 0, &"surprise_penalty": 0, &"multiple_entry_penalty": 0, + &"waves": [{&"wave_id": &"ui.presentation.disclosed.wave", &"threat_strength": 5, &"max_rounds": 1, &"retreat_threshold": 0}], + } diff --git a/tests/ui/test_defense_management.gd.uid b/tests/ui/test_defense_management.gd.uid new file mode 100644 index 0000000..e6db29a --- /dev/null +++ b/tests/ui/test_defense_management.gd.uid @@ -0,0 +1 @@ +uid://dlgto3iitns6f diff --git a/tests/ui/test_dimension_migration_ui.gd b/tests/ui/test_dimension_migration_ui.gd new file mode 100644 index 0000000..9080859 --- /dev/null +++ b/tests/ui/test_dimension_migration_ui.gd @@ -0,0 +1,54 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok: bool, message: String) -> void: + checks+=1 + if not ok: failures+=1;push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant=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,42507);clock.set_paused(true) + var paths: Array[String]=FacilityCatalog.FACILITY_PATHS+FacilityCatalog.INFRASTRUCTURE_FACILITY_PATHS+FacilityCatalog.RESCUE_FACILITY_PATHS+FacilityCatalog.ORIGINAL_FACILITY_PATHS + paths.erase("res://data/facilities/expansion/premium_room.tres");paths.append("res://tests/fixtures/legacy_premium_room.tres") + expect(runtime.definition_registry.rebuild_from_paths(paths)[&"valid"],"load legacy fixture definition") + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"reception",&"origin":Vector3i(-6,0,0)}).is_success(),"build fixture reception") + clock.advance_minutes(120) + expect(runtime.submit_command(&"build_facility",{&"definition_id":&"premium_room",&"origin":Vector3i(-6,2,0)}).is_success(),"build old-size room") + clock.advance_minutes(150) + paths.erase("res://tests/fixtures/legacy_premium_room.tres");paths.append("res://data/facilities/expansion/premium_room.tres");runtime.definition_registry.rebuild_from_paths(paths) + var source: Dictionary=runtime.create_snapshot() + var path := "user://saves/migration_ui_%d" % Time.get_ticks_usec() + expect(SaveService.save_snapshot(path,source)[&"ok"],"persist disposable old-size game") + var main: Control=load("res://scenes/main/main.tscn").instantiate() + main.set("manual_save_path",path);root.size=Vector2i(1440,810);root.add_child(main) + await process_frame;await process_frame + var live: Dictionary=runtime.create_snapshot() + var menu: MenuButton=main.find_child("SaveLoadMenu",true,false) + expect(menu!=null and menu.get_popup().get_item_count()==2,"load menu exposes ordinary and dimension-migration actions") + menu.get_popup().id_pressed.emit(1) + var dialog: ConfirmationDialog=main.get("dimension_migration_dialog") + expect(dialog!=null and dialog.visible and "上級客室" in dialog.dialog_text and "4×1" in dialog.dialog_text and "未保存" in dialog.dialog_text,"review shows concrete change and unsaved-game consequence") + expect(runtime.create_snapshot()==live and SaveService.load_snapshot(path)[&"snapshot"]==source,"review leaves live and disk unchanged") + if OS.get_environment("THE_TREE_MIGRATION_CAPTURE")=="1": + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/migration-dialog.png"))==OK,"capture migration review UI") + dialog.canceled.emit();dialog.hide() + expect(String(main.get("_dimension_migration_digest")).is_empty(),"cancel clears pending migration token") + menu.get_popup().id_pressed.emit(1) + var changed: Dictionary=source.duplicate(true);changed[&"clock"][&"speed"]=2 + SaveService.save_snapshot(path,changed) + dialog.confirmed.emit();dialog.hide() + expect(runtime.create_snapshot()==live and SaveService.load_snapshot(path)[&"snapshot"]==changed,"changed save rejects confirmation without loading or overwriting") + SaveService.save_snapshot(path,source);menu.get_popup().id_pressed.emit(1);dialog.confirmed.emit();dialog.hide() + var found:=false + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id==&"premium_room": found=room.occupied_cells.size()==4 + expect(found and "修正して読み込みました" in (main.get("build_status_label") as Label).text,"confirmation migrates and loads corrected room through UI") + expect(SaveService.load_snapshot(path+SaveService.MIGRATION_BACKUP_SUFFIX)[&"snapshot"]==source,"UI migration retains exact original backup") + menu.get_popup().id_pressed.emit(1) + expect("旧寸法の施設はありません" in (main.get("build_status_label") as Label).text,"already-current save reports no migration needed") + main.queue_free();await process_frame + SaveService.delete_save(path);SaveService.delete_save(path+SaveService.MIGRATION_BACKUP_SUFFIX) + print("Dimension migration UI: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/ui/test_dimension_migration_ui.gd.uid b/tests/ui/test_dimension_migration_ui.gd.uid new file mode 100644 index 0000000..f4aa6cc --- /dev/null +++ b/tests/ui/test_dimension_migration_ui.gd.uid @@ -0,0 +1 @@ +uid://bnt710u2gxgi6 diff --git a/tests/ui/test_first_operating_day.gd b/tests/ui/test_first_operating_day.gd index aeab4f6..e0072b9 100644 --- a/tests/ui/test_first_operating_day.gd +++ b/tests/ui/test_first_operating_day.gd @@ -63,6 +63,20 @@ func _run() -> void: if facility.definition_id == &"reception": reception_id = facility.instance_id _expect(not reception_id.is_empty(), "normal construction created the reception") + var before_manager: Dictionary=_runtime.create_snapshot() + _main.call("_hire_staff_candidate", &"heroine_anri") + var manager_panel := _main.find_child("StaffManagementPanel", true, false) as StaffManagementPanel + manager_panel.refresh();manager_panel.select_staff(&"heroine_anri") + for index: int in manager_panel.facility_option.item_count: + if StringName(manager_panel.facility_option.get_item_metadata(index))==reception_id: + manager_panel.facility_option.select(index) + manager_panel.facility_option.item_selected.emit(index) + manager_panel.apply_button.pressed.emit() + _expect(_runtime.staff_service.get_work_assignment(&"heroine_anri").get(&"facility_id")==reception_id, "normal manager placement established for opening guidance boundary") + _guide.refresh() + _expect(_guide.get_progress().get(&"stage")==&"staff", "manager-only reception keeps actual opening guide on staff recruitment") + _expect(_runtime.get_floor_price_multiplier(0)==12000, "manager passive remains enabled while receptionist guidance is unmet") + _expect(_runtime.restore_snapshot(before_manager), "restore ordinary opening scenario after manager-only boundary") _main.call("_hire_staff_candidate", &"staff_akane") var staff_members: Array = _runtime.staff_service.get_staff_members() _expect(staff_members.size() == 1, "normal hire button employs one receptionist") @@ -88,12 +102,27 @@ func _run() -> void: _main.call("_refresh_dashboard") await _capture("staffed") # The night is simulated naturally. No service request or guest is inserted. - await _advance(1440 - _clock.absolute_minute) - _expect(int(_ledger.get_report()[&"business_day"]) == 1, "midnight remains part of the first 06:00-based operating day") - _expect(int(_ledger.get_report()[&"construction_cash_out"]) > 0, "building cash outflow remains visible at midnight") - await _advance(1800 - _clock.absolute_minute) + await _advance(1439 - _clock.absolute_minute) + _expect(int(_ledger.get_report()[&"business_day"]) == 1, "23:59 remains part of the first calendar day") + _expect(int(_ledger.get_report()[&"construction_cash_out"]) > 0, "building cash outflow remains visible before midnight") + await _advance(1) + _expect(int(_ledger.get_report()[&"business_day"]) == 2, "midnight rolls the cash-flow period") + _expect(int(_ledger.get_report()[&"construction_cash_out"]) == 0, "prior-day construction is excluded from new-day cash flow") var summaries: Array = _runtime.operations_coordinator.get_day_summaries() - _expect(summaries.size() == 1, "next 06:00 creates one authoritative operating-day close") + _expect(summaries.size() == 1, "midnight creates one authoritative operating-day close") + var full_operations: Dictionary = _runtime.get_operations_report() + var compact_operations: Dictionary = _runtime.get_operations_dashboard_report() + for key: StringName in [&"enabled", &"arrears_total", &"tick_blocked", &"runtime_failure_latched", &"personal_action_points", &"deferred_runtime_minute"]: + _expect(compact_operations[key] == full_operations[key], "compact HUD matches live %s" % key) + var compact_coordinator: Dictionary = compact_operations[&"coordinator"] + var full_coordinator: Dictionary = full_operations[&"coordinator"] + for key: StringName in [&"terminal_state", &"consecutive_insolvency_count", &"legacy_history_last_day"]: + _expect(compact_coordinator[key] == full_coordinator[key], "compact HUD matches coordinator %s" % key) + _expect(compact_coordinator[&"day_summaries"].back()[&"net_profit"] == summaries.back()[&"net_profit"], "compact HUD preserves latest operating profit") + compact_coordinator[&"day_summaries"].back()[&"net_profit"] = -999999 + _expect(_runtime.operations_coordinator.get_day_summaries() == summaries, "editing HUD projection cannot mutate authoritative day close") + + _expect(_ledger.is_visible_in_tree(), "daily settlement automatically presents the ledger report") var lodging_sales := 0 for entry: LedgerEntry in _runtime.economy_service.get_entries(): var facility: FacilityInstance = _runtime.facility_service.get_instance(entry.source_entity_id) @@ -105,7 +134,7 @@ func _run() -> void: _expect(int(first[&"category_totals"][&"wages"]) > 0, "the close includes real employee wages") _expect(bool(first[&"balanced"]), "daily close reconciles the ledger") _expect(_ledger.get_report()[&"selected_close"] == first, "ledger panel shows the authoritative close without reclassification") - _expect(int(_ledger.get_report()[&"business_day"]) == 2, "cash-flow period rolls at 06:00 rather than midnight") + _expect(int(_ledger.get_report()[&"business_day"]) == 2, "cash-flow period rolls at midnight") _expect(_guide.get_progress().get(&"stage") == &"complete", "first lodging and following close complete opening guidance") var digest: String = _runtime.latest_digest _guide.refresh() @@ -113,6 +142,8 @@ func _run() -> void: _expect(_runtime.latest_digest == digest, "guide and ledger refresh never mutate simulation") _main.call("_activate_management_mode", &"ledger") await _capture("first-close") + await _advance(1800 - _clock.absolute_minute) + _expect(_runtime.operations_coordinator.get_day_summaries().size() == 1, "06:00 morning cannot duplicate the midnight close") # A dedicated temp slot avoids every normal player save path. var save_path := "user://tests/first_operating_day_%d/restore-check" % OS.get_process_id() var saved: Dictionary = _runtime.save_to_path(save_path, {&"display_name": "First operating day regression"}) @@ -124,6 +155,18 @@ func _run() -> void: # to String. Compare the JSON-shaped public report, preserving every value. _expect(JSON.stringify(_ledger.get_report()[&"selected_close"]) == JSON.stringify(close_before), "restored close preserves every financial value") _expect(_guide.get_progress().get(&"stage") == &"complete", "restored state preserves guide progress: %s" % _guide.get_progress()) + # Continue the loaded second morning, then replay the same complete state. + # This checks active work after load, not only a static financial report. + var loaded_checkpoint: Dictionary = _runtime.create_snapshot() + await _advance(120) + var continued_digest := SimulationDigest.compute(_runtime.create_snapshot()) + _expect(_clock.absolute_minute == 1920, "disk-loaded inn continues through the second morning") + _expect(_runtime.restore_snapshot(loaded_checkpoint), "second-morning checkpoint restores before replay") + await _advance(120) + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == continued_digest, + "second-morning guests, staff, ledger and events replay exactly after load") + _expect(_guide.get_progress().get(&"stage") == &"complete", "continued second morning preserves completed guidance") + await _capture("second-morning") var evidence := {"lodging_sales": lodging_sales, "report": _ledger.get_report(), "staff_count": _runtime.staff_service.get_staff_members().size()} print("FIRST_OPERATING_DAY_REPORT ", JSON.stringify(evidence)) var evidence_path := ProjectSettings.globalize_path("res://artifacts/first-operating-day/operating-report.json") @@ -132,9 +175,30 @@ func _run() -> void: if evidence_file != null: evidence_file.store_string(JSON.stringify(evidence, "\t") + "\n") SaveService.delete_save(save_path) + _test_new_report_selection() _finish() +func _test_new_report_selection() -> void: + # Projection-only fixture: test navigation between two closed reports without + # fabricating transactions in the real inn or changing its saved history. + var original: OperationsCoordinator = _runtime.operations_coordinator + var reports := OperationsCoordinator.new() + reports.advance_to(2880) + _runtime.operations_coordinator = reports + _ledger._selected_day = 1 + _ledger.refresh() + _expect(int(_ledger.get_report()[&"selected_close"][&"day"]) == 1, "player can inspect an earlier close") + _main.set("_last_presented_close_minute", 1440) + var projection := {&"systems": {&"runtime": {&"operations": {&"coordinator": reports.create_snapshot()}}}} + _main.call("_on_snapshot_published", projection, "") + _expect(int(_ledger.get_report()[&"selected_close"][&"day"]) == 2, "new close selects the latest report even after an earlier day was selected") + _ledger._selected_day = 1 + _main.call("_on_snapshot_published", projection, "") + _expect(int(_ledger.get_report()[&"selected_close"][&"day"]) == 1, "ordinary refresh preserves the player's historical selection") + _runtime.operations_coordinator = original + + func _build(definition_id: StringName, origin: Vector3i) -> bool: var before: int = _runtime.construction_service.get_jobs().size() _main.call("_select_build_definition", definition_id) diff --git a/tests/ui/test_guest_decision_log.gd b/tests/ui/test_guest_decision_log.gd new file mode 100644 index 0000000..3b5b5ec --- /dev/null +++ b/tests/ui/test_guest_decision_log.gd @@ -0,0 +1,91 @@ +extends SceneTree +const Demo := preload("res://scripts/ui/pixel_tower_demo.gd") +var checks := 0 +var failures := 0 +func expect(ok: bool, label: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(label) +func _initialize() -> void: call_deferred("run") +func run() -> void: + root.gui_embed_subwindows = true + root.size = Vector2i(1280,720) + var runtime: Variant = 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,424242);clock.set_paused(true) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + var demo := Demo.new() + expect(await demo.prepare(runtime,clock,self),"prepare normal construction and autonomous visitors") + var guest: GuestRuntime + var record: Dictionary + for minute in 120: + for candidate: GuestRuntime in runtime.guest_service.get_guests(): + var found: Dictionary = runtime.get_guest_decision_record(candidate.guest_id) + if found.get(&"action", &"") == &"MOVE_TO_FACILITY" and candidate.target_facility_id == StringName(found.get(&"facility_id", &"")): + guest=candidate;record=found;break + if guest != null: break + clock.advance_one_minute() + expect(guest != null,"autonomous decision produces matching recorded destination") + if guest == null: quit(failures);return + expect(record.has(&"needs") and record.has(&"travel_minutes") and record.has(&"estimated_total_gold"),"decision evidence includes actual scoring inputs") + var id := guest.guest_id + var original := record.duplicate(true) + record[&"needs"].clear() + expect(runtime.get_guest_decision_record(id)==original,"returned log does not expose saved event data") + var saved: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(saved),"Runtime restores decision event with moving visitors") + expect(runtime.get_guest_decision_record(id)==original,"saved decision inputs survive restoration exactly") + var main: Control = load("res://scenes/main/main.tscn").instantiate();root.add_child(main) + await process_frame + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + main.guest_count_label.pressed.emit();await process_frame + var panel: GuestObservationPanel = main.guest_observer.find_child("GuestObservationPanel", true, false) + panel.selected_guest_id=id;panel.refresh() + expect(panel.decision_label.text.contains("判断時の最大欲求") and panel.decision_label.text.contains("見込料金"),"normal HUD observer exposes actual decision history") + expect(main.guest_observer.size.y <= root.size.y and main.guest_observer.get_ok_button().is_visible_in_tree(), "observer fits viewport with visible close action") + var displayed := panel.decision_label.text + var restored_guest: GuestRuntime = runtime.guest_service.get_guest(id) + restored_guest.needs[&"hunger"]=0;restored_guest.needs[&"sleep"]=100 + panel.refresh() + expect(panel.decision_label.text==displayed,"current needs cannot rewrite past reasoning") + expect(panel._decision_text({}).contains("記録なし"),"legacy or expired history is explicitly unavailable") + var before: Dictionary=runtime.create_snapshot() + panel.refresh();panel.refresh() + expect(runtime.create_snapshot()==before,"opening and refreshing history is read-only") + if OS.get_environment("THE_TREE_DECISION_CAPTURE")!="": + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(OS.get_environment("THE_TREE_DECISION_CAPTURE"))==OK,"capture rendered decision UI") + expect(not runtime.submit_command(&"decide_guest", {&"guest_id": &"missing"}).is_success(), "failed decision is rejected") + expect(runtime.get_guest_decision_record(&"missing").is_empty(), "failure cannot fabricate a decision record") + expect(runtime.submit_command(&"spawn_guest", {&"guest_id": &"decision_leaver", &"guest_type_id": &"traveler", &"gold": 100, &"options": {&"state": GuestRuntime.State.DECIDING, &"planned_exit_at": clock.absolute_minute + 100}}).is_success(), "spawn controlled boundary visitor") + expect(runtime.submit_command(&"decide_guest", {&"guest_id": &"decision_leaver", &"context": {&"exit_threshold": 2147483647}}).is_success(), "no candidate exceeds departure threshold") + var leaving: Dictionary = runtime.get_guest_decision_record(&"decision_leaver") + expect(leaving.get(&"action", &"") == &"LEAVE" and leaving.get(&"facility_id", &"") == &"", "departure is recorded without a fictitious destination") + expect(panel._decision_text(leaving).contains("退館を選びました"), "departure reason is visible") + expect(runtime.submit_command(&"spawn_guest", {&"guest_id": &"decision_price", &"guest_type_id": &"traveler", &"gold": 100, &"options": {&"state": GuestRuntime.State.DECIDING, &"planned_exit_at": clock.absolute_minute + 100}}).is_success(), "spawn price alias fixture") + expect(runtime.submit_command(&"decide_guest", {&"guest_id": &"decision_price", &"candidates": [{&"facility_id": original[&"facility_id"], &"price_gold": 37, &"expected_quality": 100, &"respect_survival_reserve": false, &"need_relief": {&"sleep": 80}, &"travel_minutes": 5}], &"context": {&"exit_threshold": -10000}}).is_success(), "decision accepts supported price alias") + expect(runtime.get_guest_decision_record(&"decision_price").get(&"estimated_total_gold", -1) == 37, "history preserves effective quoted price rather than default zero") + expect(runtime.submit_command(&"spawn_guest", {&"guest_id": &"decision_rejected", &"guest_type_id": &"traveler", &"gold": 100, &"options": {&"state": GuestRuntime.State.DECIDING, &"planned_exit_at": clock.absolute_minute + 100}}).is_success(), "spawn rejection fixture") + var rejected_candidates: Array = [] + for index in 9: rejected_candidates.append({&"facility_id": StringName("closed_%02d" % index), &"is_open": false}) + rejected_candidates.append({&"facility_id": &"no_route", &"reachable": false}) + rejected_candidates.append({&"facility_id": &"too_expensive", &"price_gold": 10000}) + rejected_candidates.append({&"facility_id": &"missing_food", &"inputs_available": false}) + expect(runtime.submit_command(&"decide_guest", {&"guest_id": &"decision_rejected", &"candidates": rejected_candidates}).is_success(), "actual filtering rejects twelve candidates") + var rejected: Dictionary = runtime.get_guest_decision_record(&"decision_rejected") + expect(rejected.get(&"rejection_counts", {}) == {"FACILITY_CLOSED": 9, "UNREACHABLE": 1, "CANNOT_PAY": 1, "MISSING_INPUTS": 1}, "reason counts match actual closed unreachable and unaffordable candidates") + expect(rejected.get(&"rejection_count", 0) == 12 and rejected.get(&"rejection_examples", []).size() == 8, "bounded examples retain full total") + panel.selected_guest_id = &"decision_rejected";panel.refresh() + expect(panel.decision_label.text.contains("閉店・稼働停止 9施設") and panel.decision_label.text.contains("経路なし 1施設") and panel.decision_label.text.contains("ほか4施設"), "normal observer renders translated actual causes and explicit truncation") + var event_saved: Dictionary = runtime.domain_events.create_snapshot() + expect(runtime.domain_events.restore_snapshot(event_saved), "saved event stream with rejection evidence restores") + expect(runtime.get_guest_decision_record(&"decision_rejected") == rejected, "all recorded reasons survive event restoration") + expect(panel._rejection_text({}).contains("この記録にはありません"), "old decision records do not fabricate zero rejections") + if OS.get_environment("THE_TREE_REJECTION_CAPTURE") != "": + var scroll: ScrollContainer = main.guest_observer.get_node("GuestObservationScroll") + await process_frame;await process_frame + scroll.scroll_vertical = 250 + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(OS.get_environment("THE_TREE_REJECTION_CAPTURE")) == OK, "capture scrolled rejection details") + main.queue_free();await process_frame + print("Guest decision log: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/ui/test_guest_decision_log.gd.uid b/tests/ui/test_guest_decision_log.gd.uid new file mode 100644 index 0000000..ce84cd2 --- /dev/null +++ b/tests/ui/test_guest_decision_log.gd.uid @@ -0,0 +1 @@ +uid://cfactoxtr4j7o diff --git a/tests/ui/test_guest_observation.gd b/tests/ui/test_guest_observation.gd new file mode 100644 index 0000000..19100bf --- /dev/null +++ b/tests/ui/test_guest_observation.gd @@ -0,0 +1,68 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: call_deferred("run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func run() -> void: + root.gui_embed_subwindows = true + root.size = Vector2i(1280,720) + var runtime: Variant = root.get_node("SimulationRuntime") + var clock: Variant = root.get_node("GameClock") + clock.reset(1,6,0) + root.get_node("GameState").reset() + runtime.initialize(clock,root.get_node("GameState"),424242) + clock.set_paused(true) + runtime.configure_save_policy({&"enabled": false}) + var main: Control = load("res://scenes/main/main.tscn").instantiate() + root.add_child(main) + await process_frame + runtime.configure_save_policy({&"enabled": false}) + for initial: GuestRuntime in runtime.guest_service.get_guests(): + initial.state = GuestRuntime.State.LEAVING + runtime.guest_service.despawn_guest(initial.guest_id,clock.absolute_minute) + main.guest_count_label.pressed.emit() + await process_frame + var panel: GuestObservationPanel = main.guest_observer.find_child("GuestObservationPanel", true, false) + expect(main.guest_observer.visible and panel.guests.disabled, "HUD opens an empty observer without inventing visitors") + var service: GuestService = runtime.guest_service + var definition: GuestDefinition = service.get_definitions()[0] + var guest := service.spawn_guest(&"observer_guest", definition.guest_type_id, clock.absolute_minute, {&"wallet_gold": 500, &"needs": {&"hunger": 90, &"sleep": 70}, &"planned_exit_at": clock.absolute_minute+1000}) + panel.refresh() + expect(panel.selected_guest_id == guest.guest_id and not panel.guests.disabled, "new real visitor appears in the observer") + expect(panel.bars[&"hunger"].value == 90 and "食欲" in panel.priority_label.text, "bars and highest need match actual state") + var before := SimulationDigest.compute(runtime.create_snapshot()) + for frame: int in 3: panel.refresh() + expect(before == SimulationDigest.compute(runtime.create_snapshot()), "observing never changes the simulation") + guest.needs[&"sleep"] = 100 + panel.refresh() + expect(panel.bars[&"sleep"].value == 100 and "睡眠欲" in panel.priority_label.text, "observer refreshes after needs change") + for state: int in GuestRuntime.State.values(): + guest.state = state + panel.refresh() + expect(not panel.details.text.is_empty(), "every real state has a label") + guest.state = GuestRuntime.State.DECIDING + var saved := service.create_snapshot() + service.begin_leaving(guest.guest_id,clock.absolute_minute+1,&"TEST") + service.despawn_guest(guest.guest_id,clock.absolute_minute+2) + panel.refresh() + expect(panel.guests.disabled and panel.selected_guest_id.is_empty(), "despawn removes stale selection") + expect(service.restore_snapshot(saved), "guest snapshot restores") + panel.refresh() + expect(panel.selected_guest_id == &"observer_guest" and panel.bars[&"sleep"].value == 100, "read model follows restored service objects") + main.guest_observer.hide() + main.guest_count_label.pressed.emit() + await process_frame + expect(main.guest_observer.visible and clock.paused, "reopening preserves pause state") + main._refresh_dashboard() + if OS.get_environment("THE_TREE_GUEST_CAPTURE") == "1": + await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("res://artifacts/guest-ai-2026-09-29/observer.png") + main.queue_free() + await process_frame + print("Guest observer: %d checks, %d failures" % [checks, failures]) + quit(failures) diff --git a/tests/ui/test_guest_observation.gd.uid b/tests/ui/test_guest_observation.gd.uid new file mode 100644 index 0000000..0a67475 --- /dev/null +++ b/tests/ui/test_guest_observation.gd.uid @@ -0,0 +1 @@ +uid://5lpeq4dfpkqh diff --git a/tests/ui/test_layer_presentation.gd b/tests/ui/test_layer_presentation.gd new file mode 100644 index 0000000..dde5a81 --- /dev/null +++ b/tests/ui/test_layer_presentation.gd @@ -0,0 +1,93 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1;push_error(message) +func _initialize() -> void: call_deferred("run") +func run() -> void: + var runtime: Variant=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,42509);clock.set_paused(true) + var ids := {} + for spec in [[&"reception",Vector3i(-6,0,0)],[&"warehouse",Vector3i(-6,0,1)],[&"staff_waiting_room",Vector3i(-1,0,1)]]: + var result: CommandResult=runtime.submit_command(&"build_facility",{&"definition_id":spec[0],&"origin":spec[1]}) + expect(result.is_success(),"build layer fixture %s" % spec[0]) + clock.advance_minutes(runtime.definition_registry.get_facility(spec[0]).build_minutes) + for room: Dictionary in runtime.construction_service.get_facilities(): ids[room[&"definition_id"]]=room[&"instance_id"] + var before: Dictionary=runtime.create_snapshot() + root.size=Vector2i(1280,720) + var preview:=WorldPreview.new() + preview.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + preview.set_runtime(runtime) + preview.set_visual_theme(load("res://assets/themes/japanese_fantasy/visual-theme-v1.tres")) + root.add_child(preview);preview.set_process(false) + await process_frame;await process_frame + preview.set_zoom(2) + preview.pan_offset += preview.size*0.5-preview.cell_to_canvas(Vector3i(-3,1,0)) + var point:=preview.cell_to_canvas(Vector3i(-5,0,0)) + expect(preview._other_layer_rooms().size()==2,"front view includes both visible backyard footprints") + expect(preview.facility_at_canvas(point)==ids[&"reception"],"overlapping backyard ghost does not replace front hit target") + expect(preview.facility_at_canvas(preview.cell_to_canvas(Vector3i(-1,0,0))).is_empty(),"backyard-only ghost cannot be selected from front") + await capture("front") + preview.layer_mode=WorldPreview.LayerMode.BACK + var lens: ColorRect=preview.get_node("LayerLens") + expect(lens.visible and lens.mouse_filter==Control.MOUSE_FILTER_IGNORE,"switch starts noninteractive lens overlay") + expect(preview._other_layer_rooms().size()==1,"back view shows front footprint as wireframe") + expect(preview.facility_at_canvas(point)==ids[&"warehouse"],"switch changes hit target to actual backyard occupant") + expect(preview.cell_to_canvas(Vector3i(-5,0,1))==point,"layer switch preserves zoom and coordinate alignment") + var actor_time: float=preview.get("_animation_time") + preview._process(0.2) + expect(lens.visible and preview.get("_animation_time")==actor_time,"paused game animates lens without animating actors") + await capture("ripple") + preview._process(1.0) + expect(not lens.visible,"transition disables screen-sampling overlay after completion") + await capture("back") + for i in 16: + preview.layer_mode=1-preview.layer_mode + preview._process(0.02) + expect(preview.get_node("LayerLens")==lens,"rapid toggles reuse a single lens instead of accumulating overlays") + preview._process(1) + expect(not lens.visible,"rapid toggles settle into the final layer") + expect(runtime.create_snapshot()==before,"layer visualization and animation leave full simulation snapshot unchanged") + for spec in [[&"premium_room",Vector3i(-6,2,0)],[&"staff_dormitory",Vector3i(-6,2,1)]]: + expect(runtime.submit_command(&"build_facility",{&"definition_id":spec[0],&"origin":spec[1]}).is_success(),"start opposite-layer construction %s" % spec[0]) + clock.advance_minutes(5) + var construction_snapshot: Dictionary=runtime.create_snapshot() + preview.layer_mode=WorldPreview.LayerMode.FRONT;preview._process(1) + var back_job := {} + for item: Dictionary in preview._other_layer_rooms(): + if item.get(&"building",false): back_job=item + expect(not back_job.is_empty() and back_job[&"definition_id"]==&"staff_dormitory" and back_job[&"progress"]>0,"front view exposes real backyard construction progress") + if back_job.is_empty(): quit(1);return + var back_cells: Array[Vector3i]=runtime.construction_service.get_job(back_job[&"id"]).reserved_cells + expect(back_job[&"rect"]==preview._cell_bounds_rect(back_cells),"ghost construction uses exact reserved geometry") + expect(preview.facility_at_canvas(preview.cell_to_canvas(Vector3i(-6,2,0))).is_empty(),"opposite construction does not become a completed selectable facility") + await capture("front-construction") + preview.layer_mode=WorldPreview.LayerMode.BACK;preview._process(1) + var front_job := {} + for item: Dictionary in preview._other_layer_rooms(): + if item.get(&"building",false):front_job=item + expect(not front_job.is_empty() and front_job[&"definition_id"]==&"premium_room","back view exposes front construction") + await capture("back-construction") + expect(runtime.create_snapshot()==construction_snapshot,"construction overlays leave simulation unchanged") + expect(runtime.restore_snapshot(construction_snapshot),"partially built opposite layers restore") + preview.layer_mode=WorldPreview.LayerMode.FRONT;preview._process(1) + expect(preview._other_layer_rooms().any(func(item: Dictionary) -> bool: return item==back_job),"restored construction presentation matches exact prior state") + expect(runtime.submit_command(&"cancel_construction",{&"job_id":back_job[&"id"]}).is_success(),"cancel opposite construction normally") + expect(not preview._other_layer_rooms().any(func(item: Dictionary) -> bool: return item[&"id"]==back_job[&"id"]),"cancelled opposite construction vanishes") + clock.advance_minutes(150) + preview.layer_mode=WorldPreview.LayerMode.BACK;preview._process(1) + var completed_premium:=false + var still_building:=false + for item: Dictionary in preview._other_layer_rooms(): + if item[&"definition_id"]==&"premium_room": + completed_premium=true;still_building=bool(item.get(&"building",false)) + expect(completed_premium and not still_building,"completion replaces job hatch with finished facility outline") + preview.queue_free();await process_frame + print("Layer presentation: %d checks, %d failures" % [checks,failures]);quit(failures) +func capture(suffix: String) -> void: + if OS.get_environment("THE_TREE_LAYER_CAPTURE")!="1":return + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/layer-%s.png" % suffix))==OK,"capture %s layer presentation" % suffix) diff --git a/tests/ui/test_layer_presentation.gd.uid b/tests/ui/test_layer_presentation.gd.uid new file mode 100644 index 0000000..aeddf5f --- /dev/null +++ b/tests/ui/test_layer_presentation.gd.uid @@ -0,0 +1 @@ +uid://7pjvl52ebeqi diff --git a/tests/ui/test_layer_skin.gd b/tests/ui/test_layer_skin.gd new file mode 100644 index 0000000..e91b368 --- /dev/null +++ b/tests/ui/test_layer_skin.gd @@ -0,0 +1,70 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: call_deferred("run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1;push_error(message) +func run() -> void: + var runtime: Variant=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,42511);clock.set_paused(true) + root.size=Vector2i(1440,810) + var main: Control=load("res://scenes/main/main.tscn").instantiate() + root.add_child(main) + main.call("_set_management_panel_open",true) + await process_frame;await process_frame + var side: PanelContainer=main.find_child("SidePanel",true,false) + var guide: PanelContainer = main.opening_guide + var guide_front: Color = guide.get_theme_stylebox("panel").bg_color + var guide_progress: Dictionary = guide.get_progress() + var front: Color=side.get_theme_stylebox("panel").bg_color + var detail: Label=main.get("selected_detail_label") + var front_text:=detail.get_theme_color("font_color") + var position: Vector2=main.world_preview.cell_to_canvas(Vector3i.ZERO) + await capture("front") + main.back_button.pressed.emit() + await process_frame;await process_frame + expect(state.active_layer==1 and main.world_preview.layer_mode==1,"normal back button switches layer") + expect(side.get_theme_stylebox("panel").bg_color==Color("202e34") and detail.get_theme_color("font_color")==Color("d8eeea"),"back panel and text adopt monitor palette") + var bottom: Control=main.find_child("BottomBar",true,false) + expect(bottom.position.y+bottom.size.y<=main.size.y+1,"back skin preserves toolbar bounds") + expect(main.world_preview.cell_to_canvas(Vector3i(0,0,1))==position,"skin does not change map coordinate mapping") + main.world_preview._process(1.0) + expect(guide.get_theme_stylebox("panel").bg_color == Color("172229"), "opening guide follows BACK skin") + expect(guide.get_progress() == guide_progress, "skin does not change opening instructions or action") + var expanded_height := guide.size.y + guide.get("_heading").pressed.emit() + await process_frame;await process_frame + expect(not guide.get("_body").visible and guide.size.y < expanded_height, "paused guide collapses immediately") + guide.get("_heading").pressed.emit() + await process_frame;await process_frame + expect(guide.get("_body").visible and guide.get("_action").visible, "guide reopens with normal action") + await capture("back") + for i in 4: + main.front_button.pressed.emit();main.back_button.pressed.emit() + main.front_button.pressed.emit() + await process_frame;await process_frame + expect(side.get_theme_stylebox("panel").bg_color==front and detail.get_theme_color("font_color")==front_text,"repeated toggles restore exact front palette") + expect(guide.get_theme_stylebox("panel").bg_color == guide_front, "guide restores FRONT skin") + expect(clock.paused and clock.absolute_minute==360,"skin changes never advance simulation") + main.queue_free();await process_frame + state.active_layer=1 + main=load("res://scenes/main/main.tscn").instantiate() + root.add_child(main) + await process_frame;await process_frame + side=main.find_child("SidePanel",true,false) + bottom=main.find_child("BottomBar",true,false) + expect(side.get_theme_stylebox("panel").bg_color==Color("202e34") and main.opening_guide.get_theme_stylebox("panel").bg_color == Color("172229"),"new screen honors existing BACK selection including guide") + expect(bottom.position.y+bottom.size.y<=main.size.y+1,"BACK startup preserves toolbar bounds") + expect(main.rank_dialog.get_label().get_theme_color("font_color") == Color("d8eeea") and main.rank_dialog.theme.get_stylebox("panel", "AcceptDialog").bg_color == Color("202e34"), "rank dialog initializes in saved BACK skin") + expect(main.operating_ledger_panel.get("_cash_label").get_theme_color("font_color") == Color("d8eeea"), "ledger initialized before scene attachment honors saved BACK layer") + main.front_button.pressed.emit() + expect(side.get_theme_stylebox("panel").bg_color==front,"BACK startup still restores FRONT palette") + main.queue_free();await process_frame + print("Layer skin: %d checks, %d failures" % [checks,failures]);quit(failures) +func capture(label: String) -> void: + if DisplayServer.get_name()=="headless":return + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/guide-skin-%s.png" % label))==OK,"capture "+label) diff --git a/tests/ui/test_layer_skin.gd.uid b/tests/ui/test_layer_skin.gd.uid new file mode 100644 index 0000000..80902c2 --- /dev/null +++ b/tests/ui/test_layer_skin.gd.uid @@ -0,0 +1 @@ +uid://lv72d8fmyhdl diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd index 92fe70c..a42f0ad 100644 --- a/tests/ui/test_main_dashboard.gd +++ b/tests/ui/test_main_dashboard.gd @@ -35,6 +35,40 @@ func _run() -> void: await process_frame await process_frame _preview = _main.find_child("WorldPreview", true, false) as WorldPreview + # A deferred tick must retain its retry UI through the compact projection. + var previous_deferred: int = _runtime.get("_operations_deferred_runtime_minute") + _runtime.set("_operations_deferred_runtime_minute", _clock.absolute_minute) + _main.call("_update_pause_and_speed") + _expect((_main.get("pause_button") as Button).text == "運営再試行", "compact projection preserves deferred tick retry button") + _expect((_main.get("phase_label") as Label).text == "要確認", "compact projection preserves deferred tick warning") + _runtime.set("_operations_deferred_runtime_minute", previous_deferred) + _main.call("_update_pause_and_speed") + _expect((_main.get("pause_button") as Button).text == "再開", "cleared deferred tick restores paused resume button") + var before_weather: Dictionary = _runtime.create_snapshot() + _expect(_runtime.weather.schedule(&"storm", _clock.absolute_minute + 1)[&"ok"], "schedule storm for visible weather boundary") + _clock.advance_minutes(1) + _main.call("_refresh_dashboard") + _expect((_main.get("weather_label") as Label).text == "嵐", "HUD shows actual Runtime storm") + if OS.get_environment("THE_TREE_WEATHER_CAPTURE") == "1": + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/weather-2026-09-29/storm-hud.png")) == OK, "capture rendered storm HUD") + _expect(_runtime.restore_snapshot(before_weather), "restore weather before UI drill") + _main.call("_refresh_dashboard") + _expect((_main.get("weather_label") as Label).text == "晴れ", "HUD follows restored weather state") + var mana_label := (_main.get("resource_labels") as Dictionary)[&"mana"] as Label + var original_mana_color := mana_label.get_theme_color("font_color") + var original_mana: int = _state.get_resource(&"mana") + _state.set_resource(&"mana", 0) + _expect("停止" in mana_label.text and "0" in mana_label.text, "resource signal visibly identifies zero-reserve outage") + _expect("残量と配電" in mana_label.tooltip_text and "休止は解除されません" in mana_label.tooltip_text, "mana HUD explains recovery without promising suspension reset") + if OS.get_environment("THE_TREE_MANA_CAPTURE") == "1": + await process_frame + await RenderingServer.frame_post_draw + var capture := ProjectSettings.globalize_path("res://artifacts/mana-2026-09-29/outage-hud.png") + _expect(root.get_texture().get_image().save_png(capture) == OK, "capture rendered outage HUD") + _state.set_resource(&"mana", original_mana) + _expect(not "停止" in mana_label.text and mana_label.get_theme_color("font_color") == original_mana_color, "resource recovery clears outage label and alert color") _expect(_preview != null, "dashboard creates the live world preview") var visual_theme := _main.get("visual_theme") as VisualThemeProfile @@ -49,7 +83,7 @@ func _run() -> void: "world preview consumes the scene theme instead of hard-coded asset paths" ) _expect( - visual_theme.facility_icons.size() == 18 + visual_theme.facility_icons.size() == 23 and visual_theme.navigation_icons.size() == 6 and visual_theme.resource_icons.size() == 4 and visual_theme.control_icons.size() == 6, @@ -88,16 +122,36 @@ func _run() -> void: _expect(bool(operations.get(&"enabled", false)), "dashboard enables the production Operations loop") var summary := _main.get("dashboard_summary_label") as Label _expect( - summary != null and "行動点" in summary.text and "未払給与" in summary.text, + summary != null and "行動点" in summary.text and "未払経費" in summary.text, "dashboard exposes action points and payroll arrears" ) + await _test_defense_notice() var palette := _main.find_child("BuildPalette", true, false) as GridContainer - _expect(palette.get_child_count() == 18, "all sixteen MVP facilities and two stairs appear in the build palette") + _expect(palette.get_child_count() == 23, "all23 facilities including the cleaning room appear in the build palette") var iconized_build_buttons := 0 for build_button_value: Variant in (_main.get("build_buttons") as Dictionary).values(): if (build_button_value as Button).icon != null: iconized_build_buttons += 1 - _expect(iconized_build_buttons == 18, "all facility catalog buttons consume theme-provided icons") + _expect(iconized_build_buttons == 23, "all facility catalog buttons consume theme-provided icons") + var premium_button: Button = (_main.get("build_buttons") as Dictionary)[&"premium_room"] + _main.call("_set_build_palette_open", true) + premium_button.button_pressed = true + _expect(_main.get("_selected_definition_id") == &"premium_room" and _main.get("_selected_definition").footprint == Vector2i(4,1), "premium palette button selects original-size placement definition") + if OS.get_environment("THE_TREE_PREMIUM_CAPTURE") == "1": + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/premium-palette.png")) == OK, "capture premium room palette") + premium_button.button_pressed = false + var dorm_button: Button = (_main.get("build_buttons") as Dictionary)[&"staff_dormitory"] + dorm_button.button_pressed = true + _expect(_main.get("_selected_definition_id") == &"staff_dormitory" and _main.get("_selected_definition").footprint == Vector2i(2,1) and _preview.layer_mode == WorldPreview.LayerMode.BACK, "dormitory palette selects original footprint and back layer") + if OS.get_environment("THE_TREE_DORMITORY_CAPTURE") == "1": + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/dormitory-palette.png")) == OK, "capture dormitory palette") + dorm_button.button_pressed = false + _main.call("_set_layer", 0) + _main.call("_set_build_palette_open", false) var palette_scroll := _main.find_child("BuildPaletteScroll", true, false) as ScrollContainer _expect( palette_scroll != null and palette_scroll.is_ancestor_of(palette), @@ -124,7 +178,7 @@ func _run() -> void: _expect(alert_icon != null and alert_icon.texture != null, "top HUD consumes the theme warning icon") var selected_icon := _main.find_child("SelectedEntityIcon", true, false) as TextureRect _expect(selected_icon != null, "facility inspector reserves a theme-driven identity icon") - var guest_count := _main.get("guest_count_label") as Label + var guest_count := _main.get("guest_count_label") as Button var lodging_count := _main.get("lodging_count_label") as Label var alert_count := _main.get("alert_count_label") as Label _expect( @@ -187,9 +241,9 @@ func _test_staff_candidate_hiring_ui() -> void: var toggle := _main.find_child("StaffCandidateToggleButton", true, false) as Button var buttons := _main.get("staff_candidate_buttons") as Dictionary _expect(panel != null and not panel.visible, "staff candidate screen starts collapsed") - _expect(list != null and list.get_child_count() == 20, "all twenty validated Staff candidates have UI cards") - _expect(toggle != null and "20" in toggle.text, "collapsed hiring control exposes the available candidate count") - _expect(buttons.size() == 20 and buttons.has(&"staff_akane"), "every candidate has a stable hire control") + _expect(list != null and list.get_child_count() == 22, "twenty-one general candidates plus approved Anri recruitment have UI cards") + _expect(toggle != null and "22" in toggle.text, "collapsed hiring control exposes the available candidate count") + _expect(buttons.size() == 22 and buttons.has(&"staff_akane") and buttons.has(&"heroine_anri"), "every candidate has a stable hire control") var akane_detail := _main.find_child("CandidateDetail_staff_akane", true, false) as Label _expect( akane_detail != null @@ -264,6 +318,21 @@ func _test_excavation_rank_gate_and_tool_exclusion() -> void: _expect(reception_button != null and not reception_button.button_pressed, "exclusive excavation mode clears the palette toggle") _main.call("_unhandled_key_input", _key_event(KEY_ESCAPE)) _expect(not bool(_main.get("_excavation_mode")) and not _preview.excavation_mode, "Escape exits excavation mode") + var pan_button := _main.find_child("PanModeButton", true, false) as Button + _expect(pan_button != null, "camera movement has a visible text button") + _main.call("_select_build_definition", &"reception") + pan_button.pressed.emit() + _expect(_preview.pan_mode and pan_button.button_pressed and _main.get("_selected_definition") == null, + "pan button exits construction so a camera drag cannot place a facility") + _main.call("_select_build_definition", &"reception") + _expect(not _preview.pan_mode and not pan_button.button_pressed, "choosing construction exits pan mode") + pan_button.pressed.emit() + _main.call("_unhandled_key_input", _key_event(KEY_E)) + _expect(not _preview.pan_mode and _preview.excavation_mode, "excavation exits pan mode") + pan_button.pressed.emit() + _expect(_preview.pan_mode and not _preview.excavation_mode, "pan mode exits excavation") + _main.call("_unhandled_key_input", _key_event(KEY_ESCAPE)) + _expect(not _preview.pan_mode and not pan_button.button_pressed, "Escape exits pan mode and resets its button") func _test_coordinate_round_trip() -> void: @@ -274,7 +343,7 @@ func _test_coordinate_round_trip() -> void: _preview.layer_mode = WorldPreview.LayerMode.BACK var back_cell := Vector3i(4, 0, 1) _expect(_preview.canvas_to_cell(_preview.cell_to_canvas(back_cell)) == back_cell, "BACK layer coordinate round trip preserves the layer") - _expect(_preview.canvas_to_cell(Vector2(-10, -10)) == null, "points outside the map do not produce build cells") + _expect(_preview.canvas_to_cell(_preview.get_map_rect().position-Vector2(10, 10)) == null, "points outside the map do not produce build cells") func _test_build_payload_and_hit_test() -> void: @@ -417,14 +486,54 @@ func _promote_rank_two() -> bool: func _test_manual_save_and_load() -> void: SaveService.delete_save(_manual_save_path) + var facilities: Array = _runtime.facility_service.get_instances() + _expect(not facilities.is_empty(), "selection save fixture has a completed facility") + if facilities.is_empty(): + return + var selected_id: StringName = facilities[0].instance_id + _main.call("_on_facility_selected", selected_id) + _main.call("_set_layer", 1) _main.call("_save_manual") var status := _main.get("build_status_label") as Label _expect(status != null and "保存しました" in status.text, "manual save button writes the runtime snapshot") var expected_gold: int = _state.get_resource(&"gold") _state.set_resource(&"gold", expected_gold + 7) + _main.set("_selected_entity_id", &"") + _main.set("_selected_entity_kind", &"") + _main.call("_set_layer", 0) _main.call("_load_manual") _expect(_state.get_resource(&"gold") == expected_gold, "manual load button restores the saved simulation") _expect(status != null and "読み込みました" in status.text, "manual load reports success in Japanese") + _expect(_main.get("_selected_entity_id") == selected_id and _main.get("_selected_entity_kind") == &"facility", "manual load restores saved facility selection") + _expect(_preview.selected_entity_id == selected_id, "loaded selection restores map highlight") + _expect(_preview.layer_mode == 1 and _state.active_layer == 1, "load synchronizes saved layer and visible layer") + var loaded: Dictionary = SaveService.load_snapshot(_manual_save_path) + _expect(loaded[&"manifest"].get("interface_selection", {}).get("id", "") == String(selected_id), "selection persisted in the actual save package") + # Missing or malformed optional UI metadata cannot break an otherwise valid save. + for selection: Variant in [null, {"version": 99}, {"version": 1, "kind": [], "id": String(selected_id)}, {"version": 1, "kind": "facility", "id": "missing"}, {"version": 1, "kind": "unknown", "id": String(selected_id)}]: + var saved: Dictionary = _runtime.save_to_path(_manual_save_path, {"interface_selection": selection}) + _expect(saved.get(&"ok", false), "write fixture with optional presentation metadata") + _main.call("_load_manual", true) + _expect("読み込みました" in status.text and _main.get("_selected_entity_id") == &"", "invalid presentation selection is ignored without breaking gameplay load") + var old_saved: Dictionary = SaveService.save_snapshot(_manual_save_path, _runtime.create_snapshot(), {}) + _expect(old_saved.get(&"ok", false), "save without UI metadata remains supported") + _main.call("_load_manual", true) + _expect(_main.get("_selected_entity_id") == &"", "legacy save loads without inventing a selection") + # Failed loading must preserve the currently selected facility. + _main.call("_on_facility_selected", selected_id) + _main.set("manual_save_path", _manual_save_path + "_missing") + _main.call("_load_manual", true) + _expect(_main.get("_selected_entity_id") == selected_id, "failed load preserves current selection") + _main.set("manual_save_path", _manual_save_path) + _main.call("_save_manual") + _main.set("_selected_entity_id", &"") + _main.set("_selected_entity_kind", &"") + _main.call("_save_manual") + var corrupt := FileAccess.open(_manual_save_path.path_join(SaveService.WORLD_FILE), FileAccess.WRITE) + corrupt.store_string("invalid test package") + corrupt.close() + _main.call("_load_manual") + _expect("読み込みました" in status.text and _main.get("_selected_entity_id") == selected_id, "backup recovery uses the selected candidate's UI metadata") func _test_event_choice_ui() -> void: @@ -467,6 +576,43 @@ func _test_event_choice_ui() -> void: _clock.set_paused(true) +func _test_defense_notice() -> void: + var before: Dictionary = _runtime.create_snapshot() + var notice: Label = _main.get("alert_count_label") + var summary: Label = _main.get("dashboard_summary_label") + _expect(not "襲撃" in notice.text, "empty defense keeps compact normal HUD") + var created: CommandResult = _runtime.submit_command(&"create_defense_raid", { + &"raid_id": &"ui.hud.defense", &"definition": { + &"entry_points": [&"gate"], &"objective_ids": [&"core"], + &"waves": [{&"wave_id": &"wave.one", &"threat_strength": 15}], + }}, &"defense_controller") + _expect(created.is_success(), "HUD fixture creates preparing raid through Runtime command") + await process_frame + await process_frame + _expect("襲撃 1" in notice.text and "準備中 1" in summary.text, "Runtime publication shows preparing threat in HUD and management") + _expect("配属変更できません" in notice.tooltip_text and "戦闘中 0" in notice.tooltip_text, "HUD distinguishes preparation and explains employee lock") + if OS.get_environment("THE_TREE_DEFENSE_CAPTURE") == "1": + _main.call("_set_management_panel_open", true) + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/defense-hud-2026-09-29/preparing.png")) == OK, "capture rendered defense notice") + _main.call("_set_management_panel_open", false) + var saved: Dictionary = _runtime.create_snapshot() + var cancelled: CommandResult = _runtime.submit_command(&"cancel_defense_raid", {&"raid_id": &"ui.hud.defense"}, &"defense_controller") + _expect(cancelled.is_success(), "HUD fixture cancels incomplete preparation") + await process_frame + await process_frame + _expect(not "襲撃" in notice.text and "準備中 0 / 戦闘中 0 / 次波待ち 0" in summary.text and "ありません" in notice.tooltip_text, "terminal history clears HUD warning and stale tooltip") + _expect(_runtime.restore_snapshot(saved), "restore preparing raid snapshot") + await process_frame + await process_frame + _expect("襲撃 1" in notice.text and "準備中 1" in summary.text, "HUD follows restored preparing raid without a new raid signal") + _expect(_runtime.restore_snapshot(before), "restore pre-raid gameplay") + await process_frame + await process_frame + _expect(not "襲撃" in notice.text, "restoring empty defense clears warning") + + func _expect(condition: bool, message: String) -> void: _checks += 1 if condition: diff --git a/tests/ui/test_opening_assignment.gd b/tests/ui/test_opening_assignment.gd new file mode 100644 index 0000000..5c532af --- /dev/null +++ b/tests/ui/test_opening_assignment.gd @@ -0,0 +1,67 @@ +extends SceneTree +const Guide = preload("res://scripts/ui/inn_opening_guide.gd") +class Registry extends RefCounted: + var definition := FacilityDefinition.new() + func get_facility(_id): return definition +class Staff extends RefCounted: + var members: Array[StaffRuntime] = [] + var shifts: Array[ShiftInstance] = [] + var plans := {} + var reads := 0 + func get_staff_members(): return members + func get_work_assignment(id): return plans.get(id, {}).duplicate(true) + func get_shifts(): + reads += 1 + return shifts.duplicate() +class Runtime extends RefCounted: + var definition_registry := Registry.new() + var staff_service := Staff.new() +class Clock extends RefCounted: + var absolute_minute := 60 +var checks := 0 +var failures := 0 +func _initialize(): call_deferred("run") +func expect(value: bool, label: String): + checks += 1 + if not value: + failures += 1 + push_error(label) +func run(): + var runtime := Runtime.new() + var clock := Clock.new() + var reception := FacilityInstance.new() + reception.instance_id = &"desk" + runtime.definition_registry.definition.required_roles = [&"receptionist"] + var guide := Guide.new();guide._runtime=runtime;guide._clock=clock + expect(not guide._has_reception_assignment(reception), "no employees cannot open reception") + var staff := StaffRuntime.new();staff.staff_id=&"worker";staff.role_id=&"manager" + runtime.staff_service.members.append(staff);reception.active_staff_ids.append(staff.staff_id) + expect(not guide._has_reception_assignment(reception), "active manager is not a receptionist") + staff.role_id=&"receptionist" + expect(guide._has_reception_assignment(reception), "active receptionist qualifies") + reception.active_staff_ids.clear() + runtime.staff_service.plans[staff.staff_id]={&"facility_id":&"desk"} + expect(guide._has_reception_assignment(reception), "off-duty repeating assignment qualifies") + var shift := ShiftInstance.new();shift.staff_id=staff.staff_id;shift.end_at=61;shift.assigned_facility_id=&"desk" + runtime.staff_service.shifts.append(shift) + runtime.staff_service.plans[staff.staff_id]={&"facility_id":&"elsewhere"} + expect(not guide._has_reception_assignment(reception), "current reassignment overrides old shifts") + runtime.staff_service.plans.clear() + expect(guide._has_reception_assignment(reception), "legacy future shift qualifies without repeating plan") + shift.end_at=60 + expect(not guide._has_reception_assignment(reception), "shift ending now is expired") + shift.end_at=61;shift.cancelled=true + expect(not guide._has_reception_assignment(reception), "cancelled future shift cannot qualify") + shift.cancelled=false;shift.assigned_facility_id=&"elsewhere" + expect(not guide._has_reception_assignment(reception), "other facility shift cannot qualify") + shift.assigned_facility_id=&"desk";shift.staff_id=&"missing" + expect(not guide._has_reception_assignment(reception), "shift for absent employee cannot qualify") + for n in 49: + var extra := StaffRuntime.new();extra.staff_id=StringName("extra%d"%n);extra.role_id=&"receptionist";runtime.staff_service.members.append(extra) + runtime.staff_service.reads=0 + expect(not guide._has_reception_assignment(reception), "unassigned team remains incomplete") + expect(runtime.staff_service.reads==1, "historical shifts enumerated once for whole team") + shift.staff_id=&"extra48" + expect(guide._has_reception_assignment(reception), "last employee legacy assignment remains discoverable") + guide.free() + print("Opening assignment: %d checks, %d failures"%[checks,failures]);quit(failures) diff --git a/tests/ui/test_opening_assignment.gd.uid b/tests/ui/test_opening_assignment.gd.uid new file mode 100644 index 0000000..650f609 --- /dev/null +++ b/tests/ui/test_opening_assignment.gd.uid @@ -0,0 +1 @@ +uid://12qlnwu4ldpw diff --git a/tests/ui/test_original_build_pointer.gd b/tests/ui/test_original_build_pointer.gd new file mode 100644 index 0000000..93a4472 --- /dev/null +++ b/tests/ui/test_original_build_pointer.gd @@ -0,0 +1,166 @@ +extends SceneTree + +# Exercise viewport GUI hit testing; do not emit button signals or call map handlers. +var checks := 0 +var failures := 0 +var main: Control +var runtime: Variant +var clock: Variant +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _initialize() -> void: call_deferred("run") +func click_at(point: Vector2) -> void: + var motion := InputEventMouseMotion.new() + motion.position = point + motion.global_position = point + root.push_input(motion, true) + for pressed: bool in [true, false]: + var event := InputEventMouseButton.new() + event.position = point + event.global_position = point + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0 + root.push_input(event, true) + await process_frame +func run() -> void: + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,42508);clock.set_paused(true) + root.size = Vector2i(1440,810) + main = load("res://scenes/main/main.tscn").instantiate() + main.set("manual_save_path", "user://tests/build_pointer_%d" % Time.get_ticks_usec()) + root.add_child(main) + runtime.configure_save_policy({&"enabled":false, &"daily_checkpoint_enabled":false}) + await process_frame;await process_frame + await click_at((main.get("palette_toggle_button") as Button).get_global_rect().get_center()) + expect(main.get("palette_scroll").visible, "viewport click opens construction palette") + for item: Dictionary in [ + {&"id": &"reception", &"cell": Vector3i(-6,0,0), &"cells":8}, + {&"id": &"premium_room", &"cell": Vector3i(-6,2,0), &"cells":4}, + {&"id": &"guest_stairs", &"cell": Vector3i(-7,1,0), &"cells":1}, + {&"id": &"guest_stairs", &"cell": Vector3i(-7,2,0), &"cells":1}, + ]: + var button: Button = main.get("build_buttons")[item[&"id"]] + var scroll: ScrollContainer = main.get("palette_scroll") + for attempt in 8: + if scroll.get_global_rect().has_point(button.get_global_rect().get_center()): break + var bar := scroll.get_h_scroll_bar() + await click_at(bar.global_position + Vector2(bar.size.x-3,bar.size.y/2)) + # Clicking an already selected toggle would deselect the tool. + if not button.button_pressed: await click_at(button.get_global_rect().get_center()) + expect(main.get("_selected_definition_id") == item[&"id"], "viewport click selects %s" % item[&"id"]) + var preview: WorldPreview = main.get("world_preview") + var point := preview.global_position + preview.cell_to_canvas(item[&"cell"]) + expect(preview.get_global_rect().has_point(point), "placement target is visible") + var before: int = runtime.construction_service.get_jobs().size() + await click_at(point) + expect(runtime.construction_service.get_jobs().size() == before+1, "viewport placement creates exactly one %s job" % item[&"id"]) + if runtime.construction_service.get_jobs().size() != before+1: + print("Placement diagnostic: ", main.get("build_status_label").text) + finish();return + if item[&"id"] == &"premium_room": + expect((main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide).get_progress()[&"action"] == &"resume", "guide recognizes premium construction in progress") + clock.advance_minutes(runtime.definition_registry.get_facility(item[&"id"]).build_minutes) + var found := false + for room: Dictionary in runtime.construction_service.get_facilities(): + if room[&"definition_id"] == item[&"id"] and room[&"origin"] == item[&"cell"]: found = room[&"occupied_cells"].size() == item[&"cells"] + expect(found, "clicked construction completes with original geometry") + expect((main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide).get_progress()[&"stage"] == &"staff", "premium room advances opening guide to staff instead of requesting a standard room") + await operate_day() + if OS.get_environment("THE_TREE_POINTER_CAPTURE") == "1": + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/original-pointer.png")) == OK, "capture viewport-built room") + finish() +func finish() -> void: + SaveService.delete_save(main.get("manual_save_path")) + main.queue_free() + print("Original build viewport input: %d checks, %d failures" % [checks,failures]) + quit(failures) + +func press_key(code: Key, target: Window = root) -> void: + for pressed: bool in [true,false]: + var event := InputEventKey.new() + event.keycode = code + event.physical_keycode = code + event.window_id = target.get_window_id() + event.pressed = pressed + Input.parse_input_event(event) + await process_frame +func operate_day() -> void: + await press_key(KEY_ESCAPE) + await press_key(KEY_H) + var hire: Button = main.get("staff_candidate_buttons")[&"staff_akane"] + await click_at(hire.get_global_rect().get_center()) + var staff: Array = runtime.staff_service.get_staff_members() + expect(staff.size() == 1, "pointer hires ordinary receptionist from candidates") + if staff.is_empty(): return + await click_at((main.get("staff_management_toggle_button") as Button).get_global_rect().get_center()) + var panel: StaffManagementPanel = main.get("staff_management_panel") + await click_at((panel.roster_buttons[staff[0].staff_id] as Button).get_global_rect().get_center()) + var reception_id: StringName = &"" + var room_id: StringName = &"" + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id == &"reception": reception_id = room.instance_id + if room.definition_id == &"premium_room": room_id = room.instance_id + var option_index := -1 + for i in panel.facility_option.item_count: + if StringName(panel.facility_option.get_item_metadata(i)) == reception_id: option_index = i + expect(option_index >= 0, "reception appears in assignment options") + if option_index < 0: return + await click_at(panel.facility_option.get_global_rect().get_center()) + var popup := panel.facility_option.get_popup() + await press_key(KEY_HOME, popup) + for attempt in panel.facility_option.item_count + 1: + if popup.get_focused_item() == option_index: break + await press_key(KEY_DOWN, popup) + await press_key(KEY_ENTER, popup) + await click_at(panel.apply_button.get_global_rect().get_center()) + clock.advance_minutes(2) + expect(runtime.staff_service.get_staff(staff[0].staff_id).assigned_facility_id == reception_id, "pointer/dropdown keyboard assigns receptionist") + print("ASSIGNMENT_UI selected=",panel.facility_option.selected," expected=",option_index," result=",panel.result_label.text," popup=",popup.visible) + if runtime.staff_service.get_staff(staff[0].staff_id).assigned_facility_id != reception_id: return + await press_key(KEY_ESCAPE) + expect(runtime.get_facility_access_report(room_id)[&"reachable"], "clicked stairs connect the premium room") + while clock.absolute_minute < 1440: + var before: int = clock.absolute_minute + clock.advance_minutes(mini(30,1440-clock.absolute_minute)) + if clock.absolute_minute == before: + expect(false, "ordinary clock did not advance: %s" % runtime.get_operations_report());return + if clock.absolute_minute % 120 == 52: print("PREMIUM_DAY minute=",clock.absolute_minute) + await process_frame + var sales := 0 + for entry: LedgerEntry in runtime.economy_service.get_entries(): + if entry.category == &"SERVICE_SALE" and entry.source_entity_id == room_id: sales += entry.amount + expect(sales > 0, "natural arrivals complete paid premium lodging before first midnight") + expect(runtime.operations_coordinator.get_day_summaries().size() == 1 and runtime.economy_service.validate_ledger()[&"ok"], "natural premium day closes with balanced ledger") + expect((main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide).get_progress()[&"stage"] == &"complete", "premium revenue and midnight close complete the opening guide") + print("PREMIUM_POINTER_DAY sales=", sales, " minute=", clock.absolute_minute) + # Close the daily ledger before clicking the ordinary save control. + await press_key(KEY_ESCAPE) + for button: Node in main.find_children("*", "Button", true, false): + if button.text == "保存": + await click_at(button.get_global_rect().get_center()) + break + var saved: Dictionary = SaveService.load_snapshot(main.get("manual_save_path")) + expect(saved[&"ok"], "pointer saves normal premium-day progress") + if saved[&"ok"]: + clock.advance_minutes(2) + await click_at((main.find_child("SaveLoadMenu",true,false) as MenuButton).get_global_rect().get_center()) + var load_popup := (main.find_child("SaveLoadMenu",true,false) as MenuButton).get_popup() + await press_key(KEY_HOME,load_popup) + await press_key(KEY_ENTER,load_popup) + expect(clock.absolute_minute == 1440, "load-menu input restores saved day boundary") + expect((main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide).get_progress()[&"stage"] == &"complete", "load preserves completed premium opening guidance") + var loaded: Dictionary = runtime.create_snapshot() + for section: StringName in [&"facilities", &"economy", &"guests", &"staff", &"construction"]: + expect(loaded[&"systems"][&"runtime"][section] == saved[&"snapshot"][&"systems"][&"runtime"][section], "load-menu input restores %s state" % section) + clock.advance_minutes(30) + var continued: Dictionary = runtime.create_snapshot() + expect(runtime.restore_snapshot(loaded), "loaded next-day state can be replayed") + clock.advance_minutes(30) + expect(runtime.create_snapshot() == continued, "loaded inn continues thirty minutes with deterministic guest/staff/economy replay") diff --git a/tests/ui/test_original_build_pointer.gd.uid b/tests/ui/test_original_build_pointer.gd.uid new file mode 100644 index 0000000..3d32e92 --- /dev/null +++ b/tests/ui/test_original_build_pointer.gd.uid @@ -0,0 +1 @@ +uid://bq3jlsfqgcw4e diff --git a/tests/ui/test_original_facility_controls.gd b/tests/ui/test_original_facility_controls.gd new file mode 100644 index 0000000..01bebdc --- /dev/null +++ b/tests/ui/test_original_facility_controls.gd @@ -0,0 +1,73 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: + call_deferred("_run") +func expect(value: bool, label: String) -> void: + checks += 1 + if not value: + failures += 1 + push_error(label) +func _run() -> void: + var clock: Variant = root.get_node("GameClock") + var runtime: Variant = root.get_node("SimulationRuntime") + var state: Variant = root.get_node("GameState") + clock.reset() + state.reset() + runtime.initialize(clock, state, 927) + clock.set_paused(true) + var legacy_snapshot: Dictionary = runtime.create_snapshot() + legacy_snapshot[&"clock"][&"speed"] = 4 + expect(runtime.restore_snapshot(legacy_snapshot) and clock.speed == 3, "full Runtime migrates legacy x4 saves") + var main: Control = load("res://scenes/main/main.tscn").instantiate() + main.set("manual_save_path", "user://tests/original_controls_%d" % OS.get_process_id()) + root.add_child(main) + await process_frame + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + for id: StringName in [&"a", &"b"]: + runtime.facility_service.create_instance({&"instance_id": id, &"definition_id": &"standard_room"}) + main.set("_selected_entity_id", &"a") + main.set("_selected_entity_kind", &"facility") + main.call("_refresh_selected_entity") + var picker := main.get("related_facility_picker") as OptionButton + expect(picker.visible and picker.item_count == 2, "inspector exposes one related facility") + picker.item_selected.emit(1) + expect(runtime.facility_service.get_instance(&"a").related_facility_id == &"b", "UI submits real paused command") + runtime.facility_service.set_operational_status(&"b", {&"power_available": false}) + main.call("_refresh_selected_entity") + expect("未成立" in (main.get("selected_detail_label") as Label).text, "inspector displays unavailable dependency") + expect("未設定" in (main.get("selected_detail_label") as Label).text, "unknown customer capacity is not fabricated") + var buttons: Dictionary = main.get("speed_buttons") + expect(buttons.has(3) and not buttons.has(4), "original maximum speed has a real UI button") + (buttons[3] as Button).pressed.emit() + expect(clock.speed == 3, "x3 button controls clock") + clock.set_paused(true) + # Test the transactional Authority closure boundary with actual discovered evidence. + var evidence := AuthorityEvidence.new().configure(&"closure", &"audit", 60, {&"facility_id": &"a", &"created_at": 360}) + expect(runtime.authority_service.register_evidence(evidence).is_success(), "closure evidence registered") + expect(runtime.authority_service.attempt_discover_evidence(&"closure", &"inspector", Vector3i.ZERO, 360).is_success(), "inspection discovers reached evidence") + var payload := {&"resolution_id": &"test", &"inspection_id": &"inspection", &"absolute_minute": 360, &"fine_amount": 0, &"result": &"TEMPORARY_CLOSURE", &"evidence_ids": ["closure"]} + var before: Dictionary = runtime.facility_service.create_snapshot() + var events_before: Dictionary = runtime.domain_events.create_snapshot() + var ids_before: Dictionary = runtime.id_generator.create_snapshot() + var validation: Dictionary = runtime.call("_apply_authority_resolution_boundary", payload, &"validate", null) + expect(validation[&"ok"], "zero-fine closure validates") + var applied: Dictionary = runtime.call("_apply_authority_resolution_boundary", payload, &"apply", validation[&"token"]) + expect(applied[&"ok"] and runtime.facility_service.get_instance(&"a").operation_state == FacilityInstance.OperationState.SUSPENDED, "zero-fine inspection still suspends target") + expect(runtime.facility_service.get_instance(&"b").suspension_reason.is_empty(), "unrelated facility is not suspended") + var rolled_back: Dictionary = runtime.call("_apply_authority_resolution_boundary", payload, &"rollback", validation[&"token"]) + expect(rolled_back[&"ok"] and runtime.facility_service.create_snapshot() == before, "rollback restores facility and financial state") + expect(runtime.domain_events.create_snapshot() == events_before and runtime.id_generator.create_snapshot() == ids_before, "rollback restores emitted events and stable IDs") + if DisplayServer.get_name() != "headless": + root.size = Vector2i(1440, 900) + main.call("_set_management_panel_open", true) + main.call("_refresh_selected_entity") + for frame in 6: + await process_frame + await RenderingServer.frame_post_draw + var capture_path := "res://artifacts/original-2026-09-27/facility-controls.png" + expect(root.get_texture().get_image().save_png(capture_path) == OK, "real inspector capture saved") + main.queue_free() + await process_frame + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/ui/test_original_facility_controls.gd.uid b/tests/ui/test_original_facility_controls.gd.uid new file mode 100644 index 0000000..388b5de --- /dev/null +++ b/tests/ui/test_original_facility_controls.gd.uid @@ -0,0 +1 @@ +uid://cg5t3a3ly3213 diff --git a/tests/ui/test_pixel_tower.gd b/tests/ui/test_pixel_tower.gd index 364335a..16e7e81 100644 --- a/tests/ui/test_pixel_tower.gd +++ b/tests/ui/test_pixel_tower.gd @@ -234,6 +234,7 @@ func _test_animation_pause_and_simulation_isolation() -> void: _preview.call("_gui_input", press) var drag := InputEventMouseMotion.new() drag.relative = Vector2(43, 27) + drag.button_mask = MOUSE_BUTTON_MASK_MIDDLE _preview.call("_gui_input", drag) press.pressed = false _preview.call("_gui_input", press) @@ -242,6 +243,25 @@ func _test_animation_pause_and_simulation_isolation() -> void: "middle-button drag translates the visible room by the pointer movement") _click(moved_point) _expect(not _last_facility.is_empty(), "a room remains selectable immediately after dragging") + _preview.set_pan_mode(true) + _click(moved_point) + _expect(_last_facility.is_empty() and _last_cell == null, + "left drag in pan mode does not select or issue a placement click") + drag.button_mask = MOUSE_BUTTON_MASK_LEFT + _preview.call("_gui_input", drag) + _expect(_preview.cell_to_canvas(cell).distance_to(moved_point + drag.relative) <= 2.0, + "explicit pan mode moves the room with the left mouse button") + var pan_before := _preview.pan_offset + drag.button_mask = 0 + _preview.call("_gui_input", drag) + _expect(_preview.pan_offset == pan_before and not bool(_preview.get("_dragging")), + "returning after releasing outside the viewport does not leave a stuck drag") + _click(_preview.cell_to_canvas(cell)) + _preview.notification(Control.NOTIFICATION_APPLICATION_FOCUS_OUT) + _expect(not bool(_preview.get("_dragging")), "application focus loss cancels the camera drag") + _preview.set_pan_mode(false) + _click(_preview.cell_to_canvas(cell)) + _expect(not _last_facility.is_empty(), "leaving pan mode restores normal facility selection") _preview.reset_view() _expect(int(_clock.absolute_minute) == before_minute, "animation frames and camera input do not advance simulation minutes") diff --git a/tests/ui/test_rank_pointer.gd b/tests/ui/test_rank_pointer.gd new file mode 100644 index 0000000..85349e2 --- /dev/null +++ b/tests/ui/test_rank_pointer.gd @@ -0,0 +1,91 @@ +extends SceneTree +# Viewport input routing, including the embedded modal; not OS input automation. +var checks := 0 +var failures := 0 +func _initialize() -> void: call_deferred("run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func click(control: Control) -> void: + var viewport := control.get_viewport() + var point := control.get_global_rect().get_center() + if viewport is Window and viewport != root and viewport.is_embedded(): + point += Vector2(viewport.position) + viewport = root + var motion := InputEventMouseMotion.new() + motion.position = point + motion.global_position = point + viewport.push_input(motion, true) + for pressed: bool in [true, false]: + var event := InputEventMouseButton.new() + event.position = point + event.global_position = point + event.button_index = MOUSE_BUTTON_LEFT + event.pressed = pressed + event.button_mask = MOUSE_BUTTON_MASK_LEFT if pressed else 0 + viewport.push_input(event, true) + await process_frame +func key(code: Key, window: Window) -> void: + for pressed: bool in [true, false]: + var event := InputEventKey.new() + event.keycode = code + event.physical_keycode = code + event.window_id = window.get_window_id() + event.pressed = pressed + Input.parse_input_event(event) + await process_frame +func run() -> void: + var runtime: Variant = 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,42511); clock.set_paused(true) + root.size = Vector2i(1280,720) + var main: Control = load("res://scenes/main/main.tscn").instantiate() + var path := "user://tests/rank_pointer_%d" % Time.get_ticks_usec() + main.set("manual_save_path",path) + root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame; await process_frame + var button: Button = main.get("rank_button") + var dialog: ConfirmationDialog = main.get("rank_dialog") + print("RANK_RECT ",button.get_global_rect()," root ",root.size) + await click(button) + print("HOVER ",root.gui_get_hovered_control()) + expect(dialog.visible,"pointer opens rank conditions") + await click(dialog.get_ok_button()) + expect(dialog.visible and runtime.progression_service.current_rank == 1,"disabled promotion cannot activate by pointer") + await key(KEY_ESCAPE,dialog) + expect(not dialog.visible,"Escape closes rank modal") + # Controlled fixture isolates interaction from natural campaign progression. + expect(runtime.submit_command(&"update_progression_metrics",{&"metrics":{&"reputation":25}}).is_success(),"prepare reputation boundary") + expect(runtime.submit_command(&"change_resources",{&"deltas":{&"gold":5000}}).is_success(),"prepare money boundary") + await click(button) + await click(dialog.get_cancel_button()) + expect(not dialog.visible and runtime.progression_service.current_rank == 1,"cancel does not promote eligible game") + await click(button) + await click(dialog.get_ok_button()) + expect(not dialog.visible and runtime.progression_service.current_rank == 2,"pointer confirmation promotes and closes modal") + expect(button.text == "世界樹ランク 2","promotion updates visible HUD") + for node: Node in main.find_children("*","Button",true,false): + if node.text == "保存": + await click(node) + break + expect(SaveService.load_snapshot(path)[&"ok"],"pointer saves promoted game") + clock.advance_one_minute() + var menu: MenuButton = main.find_child("SaveLoadMenu",true,false) + await click(menu) + await key(KEY_HOME,menu.get_popup()) + await key(KEY_ENTER,menu.get_popup()) + expect(clock.absolute_minute == 360 and runtime.progression_service.current_rank == 2,"load menu keyboard restores saved promoted game") + await click(button) + expect(dialog.visible and "ランク3への条件" in dialog.dialog_text,"restored rank opens next requirements") + await key(KEY_ESCAPE,dialog) + if OS.get_environment("THE_TREE_RANK_CAPTURE") == "1": + await process_frame; await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/rank-pointer.png")) == OK,"capture promoted HUD") + SaveService.delete_save(path) + main.queue_free(); await process_frame + print("Rank viewport input: %d checks, %d failures" % [checks,failures]) + quit(failures) diff --git a/tests/ui/test_rank_pointer.gd.uid b/tests/ui/test_rank_pointer.gd.uid new file mode 100644 index 0000000..8ef956d --- /dev/null +++ b/tests/ui/test_rank_pointer.gd.uid @@ -0,0 +1 @@ +uid://cpy8opqu4mjhu diff --git a/tests/ui/test_room_presentation.gd b/tests/ui/test_room_presentation.gd new file mode 100644 index 0000000..6df037a --- /dev/null +++ b/tests/ui/test_room_presentation.gd @@ -0,0 +1,153 @@ +extends SceneTree + +const Presentation := preload("res://scripts/ui/room_presentation.gd") +var failures := 0 +var checks := 0 + +class Store extends RefCounted: + var rooms: Array = [] + var requests: Dictionary = {} + func get_instances() -> Array: return rooms + func get_instance(id: StringName) -> Variant: + for room: Variant in rooms: + if room.instance_id == id: return room + return null + func get_request(id: StringName) -> Variant: return requests.get(id) + +class Guests extends RefCounted: + var people: Dictionary = {} + func get_guest(id: StringName) -> Variant: return people.get(id) + +class Fixture extends RefCounted: + var facility_service := Store.new() + var guest_service := Guests.new() + var world_service := WorldService.new() + var elevators := ElevatorService.new() + +func _initialize() -> void: + call_deferred("run") + +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) + +func run() -> void: + root.get_node("GameClock").set_paused(true) + var room := FacilityInstance.new().configure(&"room", &"cafeteria", {&"capacity": 99, &"customer_capacity": 6}) + var service := ServiceInstance.new() + service.guest_ids.assign([&"f", &"a", &"c", &"e", &"b", &"d"]) + room.active_services[&"service"] = service + var data := Presentation.describe(room) + expect(data[&"count"] == 6 and data[&"limit"] == 6, "people capacity is independent of storage/service capacity 99") + expect(data[&"people"][0] == &"a" and "満員" in Presentation.label(data, 3) and "他3人" in Presentation.label(data, 3), "stable slots and honest overflow count") + var before := room.create_snapshot() + for id: StringName in [&"standard_room", &"cafeteria", &"reception"]: + for extent: Vector2 in [Vector2(16,24), Vector2(32,48), Vector2(96,48)]: + var rect := Rect2(Vector2(4,8), extent) + var points := Presentation.slots(rect, 1, id) + for i: int in points.size(): + var body := Rect2(points[i]-Vector2(8,18), Vector2(16,18)) + expect(rect.encloses(body), "person stays inside narrow/large room") + for j: int in range(i): + expect(not body.intersects(Rect2(points[j]-Vector2(8,18), Vector2(16,18))), "no overlapping or repeated use slots") + for scale: float in [0.5,1.0,2.0,4.0]: + var chapel_rect := Rect2(Vector2(16,32),Vector2(64,72)*scale) + var chapel_slots := Presentation.slots(chapel_rect,scale,&"chapel") + expect(chapel_slots.size()==8,"chapel reserves exactly eight visible visitor positions") + for i in chapel_slots.size(): + var body := Rect2(chapel_slots[i]-Vector2(8,18)*scale,Vector2(16,18)*scale) + expect(chapel_rect.encloses(body) and body.position.y>=chapel_rect.position.y+30*scale,"chapel visitors remain inside room and below altar") + for j in range(i): + expect(not body.intersects(Rect2(chapel_slots[j]-Vector2(8,18)*scale,Vector2(16,18)*scale)),"chapel visitor bodies do not overlap at zoom levels") + var rotated_chapel := Rect2(Vector2.ZERO,Vector2(48,96)) + var rotated_slots := Presentation.slots(rotated_chapel,1,&"chapel") + expect(rotated_slots.size()==8,"rotated chapel still accommodates eight visible visitors") + for i in rotated_slots.size(): + var body := Rect2(rotated_slots[i]-Vector2(8,18),Vector2(16,18)) + expect(rotated_chapel.encloses(body) and body.position.y>=30,"rotated visitor stays below altar and inside room") + for j in range(i): + expect(not body.intersects(Rect2(rotated_slots[j]-Vector2(8,18),Vector2(16,18))),"rotated chapel visitors do not overlap") + expect(room.create_snapshot() == before, "presentation leaves authoritative room data untouched") + room.set_operational_conditions({&"operator_open": false}, 0) + expect(Presentation.label(Presentation.describe(room), 3) == "未稼働", "closed room is inactive") + room.set_operational_conditions({&"suspension_reason": &"RAIDED"}, 0) + room.set_operational_conditions({&"operator_open": true}, 0) + expect(Presentation.label(Presentation.describe(room), 3) == "休止・摘発", "reopening cannot clear raid suspension") + var fixture := Fixture.new() + fixture.facility_service.rooms = [room] + fixture.world_service.reset({&"min_x": -3, &"max_x": 3, &"min_y": 0, &"max_y": 3}) + for x: int in range(-3, 4): fixture.world_service.set_walkable(Vector3i(x,0,0), true) + for i: int in 9: + var id := StringName("guest_%02d" % i) + var guest := GuestRuntime.new() + guest.guest_id = id + guest.current_cell = Vector3i.ZERO + guest.state = GuestRuntime.State.QUEUING + fixture.guest_service.people[id] = guest + var request := ServiceRequest.new() + request.guest_ids.assign([id]) + fixture.facility_service.requests[id] = request + room.queue_entries.append(id) + var preview := WorldPreview.new() + preview.size = Vector2(1100,600) + preview.set_runtime(fixture) + preview._prepare_room_presentations() + preview._prepare_queues() + var positions: Dictionary = preview._queue_positions.duplicate() + expect(positions.size() == 7 and preview._queue_hidden.size() == 2, "bounded walkable lane uses seven distinct positions and summarizes overflow") + var distinct: Dictionary = {} + for at: Vector2 in positions.values(): distinct[at] = true + expect(distinct.size() == positions.size(), "waiting people never repeat the same visible position") + preview._prepare_queues() + expect(positions == preview._queue_positions, "repeated frame preserves queue order and positions") + for i: int in 9: + fixture.guest_service.people[StringName("guest_%02d" % i)].current_cell = Vector3i(-1 if i < 4 else 1,0,0) + preview._prepare_queues() + distinct.clear() + for at: Vector2 in preview._queue_positions.values(): distinct[at] = true + expect(distinct.size() == preview._queue_positions.size(), "neighboring entrances share a corridor without overlapping their queues") + expect(preview._queue_positions.size()+preview._queue_hidden.size() == 9, "neighboring queues retain every actual waiting guest") + for guest: Variant in fixture.guest_service.people.values(): guest.current_cell = Vector3i.ZERO + preview.layer_mode = WorldPreview.LayerMode.BACK + preview._prepare_queues() + expect(preview._queue_positions.is_empty() and preview._queue_hidden.is_empty(), "layer switch removes front queues") + preview.layer_mode = WorldPreview.LayerMode.FRONT + room.queue_entries.clear() + var path := PathService.new() + var walkable: Array[Vector3i] = fixture.world_service.get_walkable_cells() + walkable.append(Vector3i(0,1,0)) + path.rebuild_graph(walkable) + fixture.elevators.configure(path) + var config := {&"shaft_id": &"transport.shaft.0.0.0.2", &"module_ids": [&"module"], &"stops": [Vector3i.ZERO,Vector3i(0,1,0)], &"width": 2, &"capacity": 4, &"travel_minutes_per_floor": 10, &"access_id": &""} + expect(fixture.elevators.synchronize([config],0)[&"ok"], "real elevator fixture initializes") + for i: int in 9: + expect(fixture.elevators.request_trip(StringName("guest/guest_%02d" % i),Vector3i.ZERO,Vector3i(0,1,0),0)[&"ok"], "real trip enters landing queue") + preview._prepare_queues() + expect(preview._queue_positions.size()+preview._queue_hidden.size() == 9, "landing represents every queued rider") + expect(not preview._queue_positions.values().has(preview.cell_to_canvas(Vector3i.ZERO)), "waiting people stay outside the car footprint") + fixture.elevators.advance_one_minute(1) + preview._prepare_queues() + var report: Dictionary = fixture.elevators.get_report() + if report[&"shafts"].is_empty(): + preview.free() + quit(1) + return + var car: Dictionary = report[&"shafts"][0] + expect(car[&"boarded"].size() == 4, "real elevator boards up to its capacity") + for actor: StringName in car[&"boarded"]: + expect(not preview._queue_positions.has(String(actor)) and not preview._queue_hidden.has(String(actor)), "boarded person is absent from landing display") + var snapshot := fixture.elevators.create_snapshot() + var queue_before: Dictionary = preview._queue_positions.duplicate() + expect(fixture.elevators.restore_snapshot(snapshot), "elevator snapshot restores") + preview._prepare_queues() + expect(queue_before == preview._queue_positions, "restored elevator preserves visible landing positions") + var initial_zoom := preview.zoom_level + preview.focus_operating_area() + expect(preview.zoom_level > initial_zoom and preview.canvas_to_cell(preview.cell_to_canvas(Vector3i.ZERO)) == Vector3i.ZERO, "initial framing enlarges the opening area without changing hit testing") + preview.reset_view() + expect(preview.zoom_level == 1.0 and preview.pan_offset == Vector2.ZERO, "Home overview remains available after opening focus") + preview.free() + print("PASS: %d room presentation checks; failures=%d" % [checks, failures]) + quit(failures) diff --git a/tests/ui/test_room_presentation.gd.uid b/tests/ui/test_room_presentation.gd.uid new file mode 100644 index 0000000..90db2ca --- /dev/null +++ b/tests/ui/test_room_presentation.gd.uid @@ -0,0 +1 @@ +uid://6bjsv1fuy0ol diff --git a/tests/ui/test_selection_autosave.gd b/tests/ui/test_selection_autosave.gd new file mode 100644 index 0000000..fea88c7 --- /dev/null +++ b/tests/ui/test_selection_autosave.gd @@ -0,0 +1,50 @@ +extends "res://tests/ui/test_first_operating_day.gd" +var _testing_autosave := false + +func _finish() -> void: + if _failures > 0 or _testing_autosave: + super._finish() + return + _testing_autosave = true + var path := "user://tests/selection_autosave_%d/slot" % OS.get_process_id() + var queued_path := path + "_queued" + SaveService.delete_save(path) + SaveService.delete_save(queued_path) + var facilities: Array = _runtime.facility_service.get_instances() + _expect(facilities.size() >= 2, "real opening provides two selection targets") + if facilities.size() < 2: + super._finish() + return + var first_id: StringName = facilities[0].instance_id + var second_id: StringName = facilities[1].instance_id + _main.call("_on_facility_selected", first_id) + _runtime.configure_save_policy({&"enabled": true, &"daily_checkpoint_enabled": true, &"autosave_path": path, &"autosave_generations": 3}) + await _advance(3240 - _clock.absolute_minute) + var generation := SaveService.load_snapshot(SaveService.get_autosave_path(path, 0)) + _expect(generation.get(&"ok", false), "06:00 checkpoint writes an actual autosave package") + _expect(generation.get(&"manifest", {}).get("interface_selection", {}).get("id", "") == String(first_id), "daily autosave captures the selected facility") + _main.call("_on_facility_selected", second_id) + var loaded: Dictionary = _runtime.load_recovery_candidate(path, &"autosave", 0) + _expect(loaded.get(&"ok", false), "explicit autosave candidate restores successfully") + _expect(_main.get("_selected_entity_id") == first_id, "direct autosave load restores selection through the common notification") + _expect(_main.get("world_preview").selected_entity_id == first_id, "direct load restores map highlight") + if OS.get_environment("THE_TREE_AUTOSAVE_CAPTURE") != "": + for frame: int in 5: + await process_frame + await RenderingServer.frame_post_draw + var screenshot := root.get_texture().get_image() + _expect(screenshot != null and screenshot.save_png(OS.get_environment("THE_TREE_AUTOSAVE_CAPTURE")) == OK, "capture actual autosave recovery selection") + _expect(_runtime.queue_manual_checkpoint(queued_path).get(&"ok", false), "queue a checkpoint while first target is selected") + _main.call("_on_facility_selected", second_id) + await _advance(1) + var queued := SaveService.load_snapshot(queued_path) + _expect(queued.get(&"manifest", {}).get("interface_selection", {}).get("id", "") == String(second_id), "queued checkpoint uses selection at actual save time") + _main.call("_on_facility_selected", first_id) + _expect(_runtime.load_from_path(queued_path).get(&"ok", false), "direct strict load succeeds") + _expect(_main.get("_selected_entity_id") == second_id, "direct strict load restores its own selection") + _expect(not _runtime.load_from_path(path + "_missing").get(&"ok", true), "missing direct load fails") + _expect(_main.get("_selected_entity_id") == second_id, "failed direct load preserves selection") + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + SaveService.delete_save(path) + SaveService.delete_save(queued_path) + super._finish() diff --git a/tests/ui/test_selection_autosave.gd.uid b/tests/ui/test_selection_autosave.gd.uid new file mode 100644 index 0000000..e157c82 --- /dev/null +++ b/tests/ui/test_selection_autosave.gd.uid @@ -0,0 +1 @@ +uid://bcv2y1ne4ik7b diff --git a/tests/ui/test_selection_lifecycle.gd b/tests/ui/test_selection_lifecycle.gd new file mode 100644 index 0000000..7469bcd --- /dev/null +++ b/tests/ui/test_selection_lifecycle.gd @@ -0,0 +1,150 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _main: Control +var _path := "user://tests/selection_lifecycle_%d/slot" % OS.get_process_id() + +func _initialize() -> void: + call_deferred("_run") + +func _expect(ok: bool, message: String) -> void: + _checks += 1 + if not ok: + _failures += 1 + push_error(message) + +func _mount() -> void: + _main = load("res://scenes/main/main.tscn").instantiate() + _main.set("manual_save_path", _path) + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + +func _build(id: StringName, cell: Vector3i) -> StringName: + _main.call("_set_layer", cell.z) + _main.call("_select_build_definition", id) + var before: int = _runtime.construction_service.get_jobs().size() + _main.call("_on_map_cell_clicked", cell) + var jobs: Array = _runtime.construction_service.get_jobs() + _expect(jobs.size() == before + 1, "normal palette creates %s construction" % id) + return jobs.back().job_id if jobs.size() == before + 1 else &"" + +func _round_trip(kind: StringName, id: StringName, heading: String) -> void: + _main.call("_save_manual") + var package := SaveService.load_snapshot(_path) + _expect(package.get(&"ok", false), "%s selection has a valid package" % kind) + _main.set("_selected_entity_id", &"") + _main.set("_selected_entity_kind", &"") + _main.call("_load_manual", true) + _expect(_main.get("_selected_entity_id") == id and _main.get("_selected_entity_kind") == kind, "%s selection restores its actual target" % kind) + _expect(heading in _main.get("selected_heading_label").text, "%s inspector restores the target details" % kind) + var expected_highlight := &"" if kind == &"excavation" else id + _expect(_main.get("world_preview").selected_entity_id == expected_highlight, "%s map selection uses the appropriate highlight" % kind) + +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, 42530) + _clock.set_paused(true) + root.size = Vector2i(1440, 810) + await _mount() + var reception_job := _build(&"reception", Vector3i(-8, 0, 0)) + if reception_job.is_empty(): + _finish() + return + _main.call("_clear_build_selection") + _main.call("_on_construction_selected", reception_job) + _round_trip(&"construction", reception_job, "工事") + _clock.advance_minutes(120) + var reception_id: StringName = _runtime.construction_service.get_facilities()[0][&"instance_id"] + _main.call("_on_facility_selected", reception_id) + _round_trip(&"facility", reception_id, "受付") + var stair_job := _build(&"guest_stairs", Vector3i(0, 1, 0)) + _clock.advance_minutes(60) + _main.call("_clear_build_selection") + var stair_id := &"" + for row: Dictionary in _runtime.construction_service.get_facilities(): + if row[&"definition_id"] == &"guest_stairs": + stair_id = row[&"instance_id"] + _expect(not stair_job.is_empty() and not stair_id.is_empty(), "paid stair construction finishes without a FacilityService instance") + if not stair_id.is_empty(): + _main.call("_on_facility_selected", stair_id) + _round_trip(&"facility", stair_id, "客用階段") + # Rank is an explicit boundary fixture; this suite does not claim natural rank progression. + _runtime.submit_command(&"update_progression_metrics", {&"metrics": {&"reputation": 25}}, &"player", &"", 100, -1, &"selection.rank.reputation") + _runtime.submit_command(&"change_resources", {&"deltas": {&"gold": 10000}}, &"player", &"", 100, -1, &"selection.rank.capital") + _expect(_runtime.submit_command(&"set_world_tree_rank", {&"rank": 2}, &"player", &"", 100, -1, &"selection.rank.promote").is_success(), "boundary fixture unlocks excavation") + _main.call("_set_excavation_mode", true) + _main.call("_on_map_cell_clicked", Vector3i(0, -1, 0)) + var excavations: Array = _runtime.excavation_service.get_jobs() + _expect(not excavations.is_empty(), "normal excavation UI creates a saved job") + if not excavations.is_empty(): + _round_trip(&"excavation", excavations.back()[&"job_id"], "地下掘削") + _main.call("_on_facility_selected", reception_id) + _expect(_runtime.save_to_path(_path).get(&"ok", false), "save selection through Runtime without explicit metadata") + _main.queue_free() + await process_frame + _expect(_runtime.get_signal_connection_list(&"checkpoint_presentation_requested").is_empty(), "destroyed UI leaves no presentation callback") + _expect(_runtime.save_to_path(_path + "_headless").get(&"ok", false), "Runtime still saves with no main scene") + var headless := SaveService.load_snapshot(_path + "_headless") + _expect(not headless.get(&"manifest", {}).has("interface_selection"), "UI-free save has no stale presentation") + await _mount() + _expect(_runtime.get_signal_connection_list(&"checkpoint_presentation_requested").size() == 1, "new main scene has exactly one presentation callback") + _expect(_runtime.load_from_path(_path).get(&"ok", false) and _main.get("_selected_entity_id") == reception_id, "new main scene receives direct-load selection") + # Rotation must preserve each candidate's own selection, rather than the newest UI state. + for selected: StringName in [reception_id, stair_id]: + _main.call("_on_facility_selected", selected) + _expect(_runtime.queue_checkpoint(_path + "_generations", {}, true).get(&"ok", false), "queue an autosave generation") + _clock.advance_minutes(1) + _expect(_runtime.load_recovery_candidate(_path + "_generations", &"autosave", 1).get(&"ok", false) and _main.get("_selected_entity_id") == reception_id, "older generation restores its reception selection") + _expect(_runtime.load_recovery_candidate(_path + "_generations", &"autosave", 0).get(&"ok", false) and _main.get("_selected_entity_id") == stair_id, "newest generation restores its staircase selection") + await _test_legacy() + _finish() + +func _test_legacy() -> void: + # Materialize a genuine pre-policy world connection through the normal migration contract. + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 42531) + _clock.set_paused(true) + _runtime.world_service.set_walkable(Vector3i(0, 1, 0), true) + _expect(_runtime.rebuild_path_graph_from_world(), "old-world boundary fixture has two adjacent floors") + var old: Dictionary = _runtime.create_snapshot() + var data: Dictionary = old[&"systems"][&"runtime"] + data[&"runtime_schema_version"] = 7 + data.erase(&"elevators") + data.erase(&"legacy_stairs") + preload("res://tests/fixtures/pre_upkeep_runtime.gd").strip_empty_upkeep(data) + data[&"path"][&"schema_version"] = 3 + for field: StringName in [&"vertical_walk_blockers", &"vertical_walk_requires_connector", &"transit_actor_ids"]: + data[&"path"].erase(field) + _expect(_runtime.restore_snapshot(old), "genuine old world migrates into owned legacy stairs") + _main.get("world_preview").set_runtime(_runtime) + var records: Array = _main.get("world_preview").get_legacy_stair_records() + _expect(not records.is_empty(), "migrated legacy connection is selectable") + if not records.is_empty(): + var id: StringName = records[0][&"id"] + _main.call("_on_legacy_stair_selected", id) + _round_trip(&"legacy_stairs", id, "既設の階段") + var capture := OS.get_environment("THE_TREE_SELECTION_LIFECYCLE_CAPTURE") + if not capture.is_empty(): + for frame: int in 5: + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(capture) == OK, "capture migrated staircase selection after actual load") + +func _finish() -> void: + if is_instance_valid(_main): + _main.queue_free() + for suffix: String in ["", "_headless", "_generations"]: + SaveService.delete_save(_path + suffix) + print("Selection lifecycle: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures else 0) diff --git a/tests/ui/test_selection_lifecycle.gd.uid b/tests/ui/test_selection_lifecycle.gd.uid new file mode 100644 index 0000000..b8a480d --- /dev/null +++ b/tests/ui/test_selection_lifecycle.gd.uid @@ -0,0 +1 @@ +uid://cwsl5k2mr1e4f diff --git a/tests/ui/test_staff_management.gd b/tests/ui/test_staff_management.gd index 31935b2..982d6f7 100644 --- a/tests/ui/test_staff_management.gd +++ b/tests/ui/test_staff_management.gd @@ -13,6 +13,29 @@ var _kitchen: StringName = &"" var _save_path := "" +func _test_staff_layer_skin() -> void: + _main.call("_set_layer", 0) + var front_color := _panel.staff_detail_label.get_theme_color("font_color") + _panel.select_staff(&"staff_akane") + var before: Dictionary = _runtime.staff_service.get_work_assignment(&"staff_akane") + _main.call("_set_layer", 1) + _panel.refresh() + _expect(_panel.staff_detail_label.get_theme_color("font_color") == Color("d8eeea") and _panel.summary_label.get_theme_color("font_color") == Color("d8eeea"), "staff text stays readable in BACK after refresh") + _panel.call("_set_result", "配属できません", false) + _expect(_panel.result_label.get_theme_color("font_color") == Color("ffb498"), "BACK rejection retains a readable warning colour") + _main.call("_set_layer", 0) + _expect(_panel.staff_detail_label.get_theme_color("font_color") == front_color and _panel.result_label.get_theme_color("font_color") == Color("a7402e"), "FRONT restores staff text and rejection colours") + _main.call("_set_layer", 1) + _panel.call("_set_result", "配属を確認しました", true) + _expect(_panel.result_label.get_theme_color("font_color") == Color("b5e7ba"), "BACK success retains distinct readable colour") + _expect(_runtime.staff_service.get_work_assignment(&"staff_akane") == before, "skin changes do not submit an assignment") + if OS.get_environment("THE_TREE_STAFF_SKIN_CAPTURE") == "1": + await process_frame;await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/staff-skin-back.png")) == OK, "capture staff BACK skin") + _main.call("_set_layer", 0) + _panel.call("_set_result", "", true) + + func _initialize() -> void: call_deferred("_run") @@ -64,6 +87,14 @@ func _run() -> void: "hired-staff tab replaces the candidates without hiding the management panel") _expect(_panel.roster_buttons.size() == 2 and "未配属 2人" in _panel.summary_label.text, "roster shows both real employees and unassigned count") + await _test_staff_layer_skin() + await _test_drag_assignment() + await _test_drag_rejection_boundaries() + await _test_drag_state_change() + await _test_drag_across_load() + await _test_drag_target_demolition() + await _test_drag_defense_lock() + await _test_drag_defense_lock(false) _panel.select_staff(&"staff_akane") _choose_facility(_receptions[0]) _panel.apply_button.pressed.emit() @@ -159,12 +190,15 @@ func _test_guide_respects_current_assignments() -> void: _panel.apply_button.pressed.emit() _build_guide_part(&"standard_room", Vector3i(-8, 2, 0)) _build_guide_part(&"standard_room", Vector3i(-6, 2, 0)) - _build_guide_part(&"guest_corridor", Vector3i(-4, 1, 0)) + _build_guide_part(&"guest_stairs", Vector3i(-4, 1, 0)) _clock.advance_minutes(60) - _build_guide_part(&"guest_corridor", Vector3i(-4, 2, 0)) + var incomplete_guide := _main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide + _expect(incomplete_guide.get_progress().get(&"stage") == &"access", + "incomplete stairs cannot advance guidance to staffing") + _build_guide_part(&"guest_stairs", Vector3i(-4, 2, 0)) _clock.advance_minutes(10) for x: int in range(-8, -3): - _build_guide_part(&"guest_corridor", Vector3i(x, 3, 0)) + _build_guide_part(&"guest_stairs" if x == -4 else &"guest_corridor", Vector3i(x, 3, 0)) _clock.advance_minutes(10) var accessible_room := false for facility: FacilityInstance in _runtime.facility_service.get_instances(): @@ -289,3 +323,280 @@ func _finish() -> void: _main.queue_free() print("Staff management UI: %d checks, %d failures" % [_checks, _failures]) quit(1 if _failures > 0 else 0) + + +func _test_drag_assignment() -> void: + var before: Dictionary = _runtime.create_snapshot() + var preview: WorldPreview = _main.get("world_preview") + _main.call("_set_layer", 0) + _main.call("_clear_build_selection") + await process_frame + var target: Vector3i + for room: Dictionary in _runtime.construction_service.get_facilities(): + if room[&"instance_id"] == _receptions[0]: target = room[&"origin"] + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0], "viewport drag assigns selected employee to completed reception") + _expect("変更しました" in _panel.result_label.text, "drag reports authoritative assignment success") + _expect(_runtime.restore_snapshot(before), "restore drag fixture before invalid target") + _panel.refresh() + var unchanged: Dictionary = _runtime.create_snapshot() + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(Vector3i(-4,4,0)), Vector3i(-4,4,0)) + _expect(_runtime.create_snapshot() == unchanged, "empty-map drop preserves all simulation state") + _expect("完成した配属先施設" in _panel.result_label.text, "invalid drop explains target requirement") + _expect(not preview._can_drop_data(Vector2.ZERO,{&"kind":&"unrelated"}), "map refuses unrelated drag payload") + _panel.refresh() + var schedule_before := _assignment(&"staff_akane").duplicate(true) + _main.call("_set_layer", 1) + await process_frame + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(Vector3i(2,0,1)), Vector3i(2,0,1)) + _expect(_assignment(&"staff_akane") == schedule_before, "wrong-role drag preserves staff schedule") + _expect("役割" in _panel.result_label.text, "wrong-role drag displays authoritative refusal") + _main.call("_set_layer", 0) + _panel.select_staff(&"staff_akane") + _panel.shift_option.select(1) + _panel.shift_option.item_selected.emit(1) + await process_frame + _clock.set_paused(false) + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _clock.set_paused(true) + _expect(_panel.apply_button.disabled and (_runtime.command_queue.create_snapshot()["pending"] as Array).size() == 1, "night drag queues exactly one assignment") + var drag_path := "user://tests/staff-drag-%d" % Time.get_ticks_usec() + _expect(_runtime.save_to_path(drag_path).get(&"ok", false), "save pending night drag") + _expect(_runtime.load_from_path(drag_path).get(&"ok", false), "restore pending night drag") + _panel.refresh() + _expect(_panel.apply_button.disabled, "loaded drag remains pending") + _clock.advance_one_minute() + _expect(_assignment(&"staff_akane").get(&"template_id") == &"NIGHT" and _assignment(&"staff_akane").get(&"facility_id") == _receptions[0], "restored drag applies selected night shift to exact facility") + _expect((_panel.get("_pending_commands") as Dictionary).is_empty(), "restored drag clears pending feedback after execution") + if OS.get_environment("THE_TREE_STAFF_DRAG_CAPTURE") == "1": + await process_frame; await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/staff-drag-night.png")) == OK, "capture restored night drag assignment") + SaveService.delete_save(drag_path) + _expect(_runtime.restore_snapshot(before), "restore drag boundary fixture for existing regression") + _panel.refresh() + + + +func _drag_staff_to(target: Vector2, cell: Variant = null, before_release := Callable()) -> void: + var source: Button = _panel.roster_buttons[&"staff_akane"] + var start := source.get_global_rect().get_center() + var motion := InputEventMouseMotion.new() + motion.position=start; motion.global_position=start + motion.window_id = root.get_window_id() + _send_drag_input(motion) + var down := InputEventMouseButton.new() + down.position=start; down.global_position=start; down.button_index=MOUSE_BUTTON_LEFT + down.pressed=true; down.button_mask=MOUSE_BUTTON_MASK_LEFT + down.window_id = root.get_window_id() + _send_drag_input(down) + await process_frame + for point: Vector2 in [start+Vector2(18,0),target]: + var move := InputEventMouseMotion.new() + if point == target and cell is Vector3i: + var map: WorldPreview = _main.get("world_preview") + point = map.global_position + map.cell_to_canvas(cell) + target = point + move.position=point; move.global_position=point; move.relative=point-start + move.button_mask=MOUSE_BUTTON_MASK_LEFT + move.window_id = root.get_window_id() + _send_drag_input(move) + await process_frame + if before_release.is_valid(): + _expect(root.gui_is_dragging(), "state changes only after the viewport has started dragging") + before_release.call() + var up := InputEventMouseButton.new() + up.position=target; up.global_position=target; up.button_index=MOUSE_BUTTON_LEFT; up.pressed=false + up.window_id = root.get_window_id() + _send_drag_input(up) + await process_frame + + +func _send_drag_input(event: InputEventMouse) -> void: + if DisplayServer.get_name() == "headless": + event.position *= Vector2(root.size) / root.get_visible_rect().size + event.global_position = event.position + Input.parse_input_event(event) + else: + if event is InputEventMouseButton and not event.pressed: + Input.warp_mouse(event.position * Vector2(root.size) / root.get_visible_rect().size) + root.push_input(event, true) + + +func _test_drag_rejection_boundaries() -> void: + # Controlled boundary fixtures; the action under test uses viewport input. + var before: Dictionary = _runtime.create_snapshot() + var preview: WorldPreview = _main.get("world_preview") + var target: Vector3i = _runtime.facility_service.get_instance(_receptions[0]).origin + _main.call("_set_layer", 0) + _panel.select_staff(&"staff_akane") + _panel.shift_option.select(0) + _panel.shift_option.item_selected.emit(0) + var role: StringName = _runtime.staff_service.get_staff(&"staff_akane").role_id + var maximum: int = _runtime.definition_registry.get_facility(&"reception").staff_max + for i in maximum: + var id := StringName("drag_capacity_%d" % i) + _expect(_runtime.staff_service.create_staff(id, role).is_success() and _runtime.staff_service.set_work_assignment(id, _receptions[0], &"DAY", _clock.absolute_minute).is_success(), "fill actual reception shift slots for capacity boundary") + var staff_before: Dictionary = _runtime.staff_service.create_snapshot() + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_runtime.staff_service.create_snapshot() == staff_before and "人員上限" in _panel.result_label.text, "full-facility drag preserves all staff and explains capacity limit") + await _capture_drag_rejection("capacity") + _expect(_runtime.restore_snapshot(before), "restore after capacity drag fixture") + _panel.refresh() + _panel.select_staff(&"staff_akane") + _runtime.staff_service.get_staff(&"staff_akane").current_cell = Vector3i(999,999,0) + staff_before = _runtime.staff_service.create_snapshot() + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_runtime.staff_service.create_snapshot() == staff_before and "通路と従業員ドア" in _panel.result_label.text, "unreachable drag preserves staff and explains route failure") + await _capture_drag_rejection("route") + _expect(_runtime.restore_snapshot(before), "restore after inaccessible route fixture") + _panel.refresh() + _panel.select_staff(&"staff_akane") + # Inject only the lock boundary, not a complete narrative event. + _runtime.event_service._actor_locks[&"staff_akane"] = &"drag_lock_fixture" + _panel.refresh() + staff_before = _runtime.staff_service.create_snapshot() + var queue_before: Dictionary = _runtime.command_queue.create_snapshot() + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_runtime.staff_service.create_snapshot() == staff_before and _runtime.command_queue.create_snapshot() == queue_before, "locked employee cannot start an assignment drag or submit a command") + _expect("変更できません" in _panel.result_label.text, "locked drag explains why assignment is unavailable") + await _capture_drag_rejection("locked") + _runtime.event_service._actor_locks.erase(&"staff_akane") + _expect(_runtime.restore_snapshot(before), "restore after locked employee fixture") + _panel.refresh() + + +func _capture_drag_rejection(kind: String) -> void: + if OS.get_environment("THE_TREE_DRAG_REJECTION_CAPTURE") != "1": return + await process_frame + await RenderingServer.frame_post_draw + var path := "res://artifacts/facility-dimensions-2026-09-29/staff-drag-rejection-%s.png" % kind + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path(path)) == OK, "capture drag rejection %s" % kind) + + +func _test_drag_state_change() -> void: + var before: Dictionary = _runtime.create_snapshot() + var preview: WorldPreview = _main.get("world_preview") + var target: Vector3i = _runtime.facility_service.get_instance(_receptions[0]).origin + var feedback: Array[CommandResult] = [] + var listener := func(result: CommandResult) -> void: feedback.append(result) + _runtime.command_completed.connect(listener) + for reason: StringName in [&"EVENT_LOCKED", &"STAFF_BUSY"]: + _panel.select_staff(&"staff_akane") + var assignment := _assignment(&"staff_akane").duplicate(true) + feedback.clear() + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target, func() -> void: + if reason == &"EVENT_LOCKED": + _runtime.event_service._actor_locks[&"staff_akane"] = &"drag_race_fixture" + else: + _runtime.staff_service.get_staff(&"staff_akane").current_task_id = &"drag_race_task" + ) + _expect(not feedback.is_empty() and not feedback.back().is_success() and feedback.back().details.get(&"reason") == reason, "drop revalidates latest %s state through real command" % reason) + _expect(_assignment(&"staff_akane") == assignment and _panel.apply_button.disabled, "rejected race preserves assignment and reflects unavailable state") + var explanation := "イベント参加中" if reason == &"EVENT_LOCKED" else "作業中" + _expect(explanation in _panel.staff_detail_label.text and explanation in _panel.apply_button.tooltip_text and explanation in _panel.result_label.text and (_panel.get("_pending_commands") as Dictionary).is_empty(), "race rejection shows precise reason and clears pending UI") + _runtime.event_service._actor_locks.erase(&"staff_akane") + _runtime.staff_service.get_staff(&"staff_akane").current_task_id = &"" + _expect(_runtime.restore_snapshot(before), "restore state after drag race") + _panel.refresh() + _runtime.command_completed.disconnect(listener) + + +func _test_drag_across_load() -> void: + var before: Dictionary = _runtime.create_snapshot() + var path := "user://tests/staff-drag-load-%d" % Time.get_ticks_usec() + _expect(_runtime.save_to_path(path).get(&"ok", false), "save before live drag reload") + var preview: WorldPreview = _main.get("world_preview") + var target: Vector3i = _runtime.facility_service.get_instance(_receptions[0]).origin + var loaded_staff: Array[Dictionary] = [] + var loaded_queue: Array[Dictionary] = [] + _panel.select_staff(&"staff_akane") + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target, func() -> void: + _expect(_runtime.load_from_path(path).get(&"ok", false), "load real save while pointer is dragging") + loaded_staff.append(_runtime.staff_service.create_snapshot()) + loaded_queue.append(_runtime.command_queue.create_snapshot()) + ) + _expect(_runtime.staff_service.create_snapshot() == loaded_staff[0], "pre-load drag cannot alter restored staff or assignment") + _expect(_runtime.command_queue.create_snapshot() == loaded_queue[0], "pre-load drag cannot enqueue a command in restored world") + _expect("読み込み" in _panel.result_label.text, "stale drag explains that load requires a new drag") + if OS.get_environment("THE_TREE_DRAG_LOAD_CAPTURE") == "1": + await process_frame + await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/staff-drag-load-2026-09-29/rejected.png")) == OK, "capture load cancellation feedback") + _panel.select_staff(&"staff_akane") + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0], "fresh drag after load still assigns normally") + SaveService.delete_save(path) + _expect(_runtime.restore_snapshot(before), "restore fixture after live drag reload") + _panel.refresh() + _panel.select_staff(&"staff_akane") + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target, func() -> void: + _expect(not _runtime.restore_snapshot({}), "invalid snapshot is refused during drag") + ) + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0], "failed restore preserves current world and live drag") + _expect(_runtime.restore_snapshot(before), "restore fixture after refused load") + _panel.refresh() + + +func _test_drag_target_demolition() -> void: + var before: Dictionary = _runtime.create_snapshot() + var preview: WorldPreview = _main.get("world_preview") + var target: Vector3i = _runtime.facility_service.get_instance(_receptions[0]).origin + var staff_before: Dictionary = _runtime.staff_service.create_snapshot() + _panel.select_staff(&"staff_akane") + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target, func() -> void: + var removed: CommandResult = _runtime.submit_command(&"demolish_facility", {&"instance_id":_receptions[0]}, &"player", &"", 30, -1, &"ui.drag.target.demolition") + _expect(removed.is_success(), "normal demolition command succeeds while dragging toward unstaffed facility") + ) + _expect(not _runtime.facility_service.has_instance(_receptions[0]), "drop cannot resurrect demolished facility") + _expect(_runtime.staff_service.create_snapshot() == staff_before, "demolition during drag preserves staff state and schedules") + _expect("完成した配属先施設" in _panel.result_label.text or "見つかりません" in _panel.result_label.text, "removed drop target has actionable rejection feedback") + _expect((_panel.get("_pending_commands") as Dictionary).is_empty(), "removed target leaves no stuck assignment UI") + _expect(_runtime.restore_snapshot(before), "restore after live demolition boundary") + _panel.refresh() + + +func _test_drag_defense_lock(register_objective := true) -> void: + var before: Dictionary = _runtime.create_snapshot() + var preview: WorldPreview = _main.get("world_preview") + var target: Vector3i = _runtime.facility_service.get_instance(_receptions[0]).origin + var assignment := _assignment(&"staff_akane").duplicate(true) + _panel.select_staff(&"staff_akane") + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target, func() -> void: + var created: CommandResult = _runtime.submit_command(&"create_defense_raid", { + &"raid_id": &"ui.drag.defense", &"definition": { + &"source_id": &"runtime_test", &"entry_points": [&"gate.north"], + &"objective_ids": [_receptions[0]], &"round_minutes": 5, + &"max_duration_minutes": 180, &"retreat_policy": &"HOLD", &"defender_retreat_threshold": 0, + &"waves": [{&"wave_id": &"wave.one", &"threat_strength": 15, + &"retreat_threshold": 0, &"max_rounds": 1, &"entry_points": [&"gate.north"]}], + }}, &"defense_controller") + _expect(created.is_success(), "create real defense raid during assignment drag") + if register_objective: + var asset: CommandResult = _runtime.submit_command(&"register_defense_asset", { + &"raid_id": &"ui.drag.defense", &"asset_id": _receptions[0], &"kind": &"objective", + &"max_integrity": 10000, &"defense": 40, &"options": {}}, &"defense_controller") + _expect(asset.is_success(), "register real facility as defense objective") + var registered: CommandResult = _runtime.submit_command(&"register_defense_combatant", { + &"raid_id": &"ui.drag.defense", &"actor_id": &"staff_akane", &"side": &"DEFENDER", + &"combat": 90, &"max_health": 100, &"options": {}}, &"defense_controller") + _expect(registered.is_success(), "register dragged employee as defender") + ) + _expect(_assignment(&"staff_akane") == assignment and "防衛参加中" in _panel.result_label.text, "defense registration during drag rejects assignment with precise feedback") + _expect(_panel.apply_button.disabled and "防衛参加中" in _panel.staff_detail_label.text and "防衛参加中" in _panel.apply_button.tooltip_text, "locked editor explains defense restriction before retry") + var path := "user://tests/staff-defense-lock-%d" % Time.get_ticks_usec() + _expect(_runtime.save_to_path(path).get(&"ok", false), "save registered defense lock") + _expect(_runtime.load_from_path(path).get(&"ok", false), "restore registered defense lock") + _panel.refresh() + _panel.select_staff(&"staff_akane") + var queue_before: Dictionary = _runtime.command_queue.create_snapshot() + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_runtime.command_queue.create_snapshot() == queue_before and "防衛参加中" in _panel.result_label.text, "restored defender cannot begin new drag and sees the reason") + var cancelled: CommandResult = _runtime.submit_command(&"cancel_defense_raid", {&"raid_id": &"ui.drag.defense"}, &"defense_controller") + _expect(cancelled.is_success(), "cancel raid through normal command: %s %s" % [cancelled.message, cancelled.details]) + _panel.refresh() + _expect(not _panel.apply_button.disabled and not "防衛参加中" in _panel.staff_detail_label.text, "terminal raid releases editor lock and stale explanation") + await _drag_staff_to(preview.global_position + preview.cell_to_canvas(target), target) + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0], "new drag after defense cancellation assigns normally") + SaveService.delete_save(path) + _expect(_runtime.restore_snapshot(before), "restore fixture after defense boundary") + _panel.refresh() diff --git a/tests/ui/test_staff_rescue.gd b/tests/ui/test_staff_rescue.gd new file mode 100644 index 0000000..d721803 --- /dev/null +++ b/tests/ui/test_staff_rescue.gd @@ -0,0 +1,87 @@ +extends SceneTree +var checks := 0 +var failures := 0 +var runtime: Variant +var clock: Variant +var main: Control +var elevator_mode := OS.get_environment("THE_TREE_RESCUE_ELEVATOR") == "1" +func _initialize() -> void: call_deferred("run") +func expect(ok: bool, text: String) -> void: + checks += 1 + if not ok: failures += 1; push_error(text) +func command(kind: StringName, payload: Dictionary) -> bool: + var result: CommandResult = runtime.submit_command(kind,payload,&"player") + expect(result.is_success(), "normal command succeeds: %s (%s)" % [kind,result.message]) + return result.is_success() +func run() -> void: + root.size = Vector2i(1440,810) + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + clock.reset(1,6,0) + root.get_node("GameState").reset() + runtime.initialize(clock,root.get_node("GameState"),711) + clock.set_paused(true) + main = load("res://scenes/main/main.tscn").instantiate() + root.add_child(main) + await process_frame + runtime.configure_save_policy({&"enabled": false}) + main._set_layer(1) + expect(main.build_buttons.has(&"infirmary"), "infirmary is selectable in the real build palette") + main.build_buttons[&"infirmary"].button_pressed = true + main._on_map_cell_clicked(Vector3i(-6,0,1)) + expect(runtime.construction_service.get_jobs().size() == 1, "map click starts infirmary construction") + if not command(&"build_facility",{&"definition_id":&"staff_door",&"origin":Vector3i(-1,0,1) if elevator_mode else Vector3i(0,0,1)}): quit(1); return + if elevator_mode: + if not command(&"build_facility",{&"definition_id":&"service_elevator",&"origin":Vector3i(0,0,1)}): quit(1); return + clock.advance_minutes(400 if elevator_mode else 180) + var clinic: FacilityInstance + for room: FacilityInstance in runtime.facility_service.get_instances(): + if room.definition_id == &"infirmary": clinic = room + expect(clinic != null and clinic.operation_state == FacilityInstance.OperationState.OPERATING, "paid infirmary completes and operates") + if clinic == null: quit(1); return + if not command(&"hire_staff_candidate",{&"candidate_id":&"staff_tessa"}): quit(1); return + if not command(&"set_staff_work_assignment",{&"staff_id":&"staff_tessa",&"facility_id":clinic.instance_id,&"template_id":&"DAY"}): quit(1); return + # Injury is the only fixture injection; the rescuer is hired and commutes normally. + if not command(&"register_staff",{&"staff_id":&"injured_worker",&"role_id":&"porter",&"options":{&"health":0,&"current_cell":Vector3i(0,1,1) if elevator_mode else Vector3i(3,0,1)}}): quit(1); return + main._activate_management_mode(&"staff") + main.staff_management_panel.select_staff(&"injured_worker") + main.world_preview.focus_operating_area() + var carried := false + var treated := false + var saw_cabin := false + for minute: int in 150: + clock.advance_minutes(1) + main.staff_management_panel.refresh() + var report: Dictionary = runtime.get_staff_rescue_report() + if elevator_mode and not saw_cabin: + var trip: Dictionary = runtime.elevators.get_actor_trip(&"staff:injured_worker") + for shaft: Dictionary in runtime.elevators.get_report()[&"shafts"]: + if trip.get(&"state", &"") == PathService.ELEVATOR_REQUEST_BOARDED and shaft[&"phase"] == &"TRAVEL": + saw_cabin = true + expect(shaft[&"passenger_count"] == 2, "normally hired medic and patient occupy two cabin seats") + await capture("paired-cabin") + if not carried and report[&"transporting"] > 0 and runtime.staff_service.get_staff(&"injured_worker").current_cell.x <= -1: + carried = true + expect("搬送中" in main.staff_management_panel.staff_detail_label.text, "staff UI shows actual transport status") + await capture("transport") + if not treated and report[&"treating"] > 0: + treated = true + expect("治療中" in main.staff_management_panel.staff_detail_label.text, "staff UI shows actual treatment status") + await capture("treatment") + if carried and treated and runtime.staff_service.get_staff(&"injured_worker").health > 0: break + if minute%10 == 0: await process_frame + if elevator_mode: expect(saw_cabin, "normal medical assignment uses the elevator for rescue") + expect(carried and treated, "a normally hired medical worker picks up and treats the patient") + expect(runtime.staff_service.get_staff(&"injured_worker").health == 100, "treatment completes through normal Runtime ticks") + expect(runtime.economy_service.get_balance(&"PLAYER_CASH") < 5000, "medical building and hiring are actually paid") + expect(runtime.staff_service.validate_invariants()[&"ok"], "normal rescue preserves ownership") + main.queue_free() + await process_frame + print("Rescue UI: %d checks, %d failures" % [checks,failures]) + quit(failures) +func capture(label: String) -> void: + if OS.get_environment("THE_TREE_RESCUE_CAPTURE") != "1": return + main.world_preview.queue_redraw() + await process_frame + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png(("res://artifacts/remaining-2026-09-29/%s.png" if elevator_mode else "res://artifacts/rescue-2026-09-29/%s.png") % label) diff --git a/tests/ui/test_staff_rescue.gd.uid b/tests/ui/test_staff_rescue.gd.uid new file mode 100644 index 0000000..97e4621 --- /dev/null +++ b/tests/ui/test_staff_rescue.gd.uid @@ -0,0 +1 @@ +uid://cxlhwwgj5vv38 diff --git a/tests/ui/test_staff_rest.gd b/tests/ui/test_staff_rest.gd new file mode 100644 index 0000000..2842b2c --- /dev/null +++ b/tests/ui/test_staff_rest.gd @@ -0,0 +1,39 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: call_deferred("run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: failures += 1;push_error(message) +func run() -> void: + root.size = Vector2i(1440,810) + var clock: Variant = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + var runtime: Variant = root.get_node("SimulationRuntime") + clock.reset(1,6,0);state.reset();runtime.initialize(clock,state,718);clock.set_paused(true) + var main: Control = load("res://scenes/main/main.tscn").instantiate();root.add_child(main);await process_frame + runtime.configure_save_policy({&"enabled":false}) + main._set_layer(1) + main.build_buttons[&"staff_waiting_room"].button_pressed = true + main._on_map_cell_clicked(Vector3i(-6,0,1)) + expect(runtime.construction_service.get_jobs().size() == 1,"waiting room builds through the real map palette") + clock.advance_minutes(120) + # Fatigue/health fixture, normal public registration and authoritative movement. + expect(runtime.submit_command(&"register_staff",{&"staff_id":&"resting",&"role_id":&"porter",&"options":{&"health":20,&"fatigue":20,&"state":StaffRuntime.State.OFF_DUTY,&"current_cell":Vector3i(5,0,0)}},&"player").is_success(),"tired off-duty staff registers") + main._activate_management_mode(&"staff");main.staff_management_panel.select_staff(&"resting") + main.staff_management_panel.refresh() + expect(main.staff_management_panel.staff_detail_label.text.contains("休憩先へ移動中"),"panel exposes actual journey to rest") + clock.advance_minutes(60);main.staff_management_panel.refresh();main.world_preview.focus_operating_area() + await process_frame + expect(main.staff_management_panel.staff_detail_label.text.contains("待機室で休憩中"),"arrival updates the panel to resting") + expect(runtime.staff_service.get_staff(&"resting").health > 20,"resting health recovers in normal operations") + main.world_preview._prepare_room_presentations() + expect(main.world_preview._resting_staff.has(&"resting"),"room presentation uses actual resting staff ownership") + var room_id: StringName = main.world_preview._resting_staff.get(&"resting",&"") + expect(main.world_preview._room_presentations.get(room_id,{}).get(&"count",0) == 1,"rest room count is a real person, not cosmetic occupancy") + if OS.get_environment("THE_TREE_REST_CAPTURE") == "1": + main.world_preview.queue_redraw();await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png("res://artifacts/remaining-2026-09-29/staff-rest.png") == OK,"resting staff screenshot saved") + expect(runtime.restore_snapshot(runtime.create_snapshot()),"visible rest state remains saveable") + main.queue_free();await process_frame + print("Staff rest UI: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/ui/test_staff_rest.gd.uid b/tests/ui/test_staff_rest.gd.uid new file mode 100644 index 0000000..d6fe931 --- /dev/null +++ b/tests/ui/test_staff_rest.gd.uid @@ -0,0 +1 @@ +uid://deoqe6b11a40o diff --git a/tests/ui/test_stair_transport_details.gd b/tests/ui/test_stair_transport_details.gd index 1e3adfd..9f1158a 100644 --- a/tests/ui/test_stair_transport_details.gd +++ b/tests/ui/test_stair_transport_details.gd @@ -116,14 +116,27 @@ func _test_legacy_stair_selection() -> void: var rect: Rect2 = _preview.call("_legacy_stair_rect", Vector3i(0,1,0)) var stair_id := _preview.legacy_stair_at_canvas(rect.get_center()) _expect(stair_id == &"legacy.stairs:0:0:0", "legacy stairs are hit-tested on the upper landing") + var inactive_front: Array = _preview._other_layer_rooms() + _expect(inactive_front.size()==1 and inactive_front[0][&"id"]==&"legacy.stairs:1:0:0" and inactive_front[0][&"rect"]==rect, "opposite legacy stairs appear once at their real compact footprint without construction service") + _expect(bool(inactive_front[0].get(&"legacy_stair",false)), "legacy ghost uses compact stair art rather than a completed facility room") _expect(_preview.legacy_stair_at_canvas(_preview.get_cell_rect(Vector3i(0,1,0)).position+Vector2.ONE).is_empty(), "small legacy stair target preserves the rest of an existing room's click area") _expect(_preview.legacy_stair_at_canvas(_preview.cell_to_canvas(Vector3i.ZERO)).is_empty(), "lower floor cannot select the stair glyph drawn upstairs") _click(rect.get_center()) _expect(_main.get("_selected_entity_kind") == &"legacy_stairs" and _main.get("_selected_entity_id") == stair_id, "legacy glyph click opens its dedicated selection without clearing its ID") _expect("既設の階段" == _main.get("selected_heading_label").text and "1F ↔ 2F" in _main.get("selected_detail_label").text, "legacy inspector describes the preserved connection") _preview.layer_mode = WorldPreview.LayerMode.BACK + var inactive_back: Array = _preview._other_layer_rooms() + _expect(inactive_back.size()==1 and inactive_back[0][&"id"]==&"legacy.stairs:0:0:0", "back view exposes front legacy stair without duplicating selected stair") _click(rect.get_center()) _expect(_main.get("_selected_entity_id") == &"legacy.stairs:1:0:0" and "客・従業員用" in _main.get("selected_detail_label").text and "BACK" in _main.get("selected_detail_label").text, "legacy BACK stairs preserve the old public access instead of falsely claiming staff-only access") + if OS.get_environment("THE_TREE_LAYER_LEGACY_CAPTURE") == "1": + _preview.set_zoom(3.0) + _preview.pan_offset += _preview.size*0.5-_preview.cell_to_canvas(Vector3i(0,1,0)) + for layer: int in [0,1]: + _preview.layer_mode=layer + _preview._process(1.0) + await process_frame;await process_frame;await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/layer-legacy-%d.png" % layer))==OK,"capture opposite legacy stairs") var records := _preview.get_legacy_stair_records() records[0][&"id"] = &"changed.view.copy" _expect(not str(fixture.legacy_stairs.create_snapshot()).contains("changed.view.copy"), "visual records cannot mutate saved stair ownership") diff --git a/tests/ui/test_supply_management.gd b/tests/ui/test_supply_management.gd index c05b990..5395f4a 100644 --- a/tests/ui/test_supply_management.gd +++ b/tests/ui/test_supply_management.gd @@ -14,6 +14,22 @@ var _item: StringName = &"" var _save_path := "user://tests/supply_ui_%d/queued_purchase" % OS.get_process_id() +func _test_layer_palette() -> void: + _main.call("_set_layer", 0) + var before: Color = _panel.warehouse_stock_label.get_theme_color("font_color") + var selected: bool = _panel.production_enabled.button_pressed + _main.call("_set_layer", 1) + _panel.refresh() + _expect(_panel.warehouse_stock_label.get_theme_color("font_color") == Color("d8eeea"), "BACK panel text survives refresh") + _expect(_panel.production_enabled.get_theme_color("font_hover_color") == Color("d8eeea") and _panel.production_enabled.get_theme_color("font_disabled_color") == Color("8da4a4"), "BACK toggle remains readable for hover and disabled states") + _panel.call("_set_result", "条件未達", false) + _expect(_panel.result_label.get_theme_color("font_color") == Color("ffb498"), "BACK error retains warning colour") + _main.call("_set_layer", 0) + _expect(_panel.warehouse_stock_label.get_theme_color("font_color") == before and _panel.result_label.get_theme_color("font_color") == Color("a7402e"), "FRONT restores panel and warning colours") + _expect(_panel.production_enabled.button_pressed == selected, "palette does not toggle policy") + _panel.call("_set_result", "", true) + + func _initialize() -> void: call_deferred("_run") @@ -41,6 +57,7 @@ func _run() -> void: (_main.get("navigation_buttons")[&"product"] as Button).pressed.emit() _expect(_panel.visible and _panel.purchase_button.disabled, "product opens a safe empty state before any warehouse exists") _expect("倉庫" in _panel.warehouse_stock_label.text, "empty purchase page explains the warehouse prerequisite") + _test_layer_palette() for request: Array in [ [&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(0, 0, 1)], @@ -195,6 +212,12 @@ func _test_navigation_and_layout() -> void: captured.save_png("/tmp/the-tree-supply-%s.png" % tab) if tab == &"purchase": captured.save_png("/tmp/the-tree-supply-management.png") + if OS.get_environment("THE_TREE_PANEL_SKIN_CAPTURE") == "1": + _main.call("_set_layer", 1) + _panel.select_tab(&"kitchen") + _choose(_panel.kitchen_option, _kitchens[0]) + await process_frame;await RenderingServer.frame_post_draw + _expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/supply-skin-back.png")) == OK, "capture BACK management skin") _main.call("_unhandled_key_input", _key(KEY_ESCAPE)) _expect(not _panel.visible and not bool(_main.get("_management_panel_open")), "Escape closes supply management and restores the map") diff --git a/tests/ui/test_upkeep_closed_ui.gd b/tests/ui/test_upkeep_closed_ui.gd new file mode 100644 index 0000000..de0fd70 --- /dev/null +++ b/tests/ui/test_upkeep_closed_ui.gd @@ -0,0 +1,135 @@ +extends SceneTree +## Runs the real UI command handlers, a normal clock and disk save. Invoke again +## with -- --read and the same THE_TREE_UPKEEP_UI_SAVE for fresh-process restore. +var checks := 0 +var failures := 0 +var main: Control +var runtime: Variant +var clock: Variant +func _initialize() -> void: + call_deferred("_run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _run() -> void: + runtime = root.get_node("SimulationRuntime") + clock = root.get_node("GameClock") + var state: Variant = root.get_node("GameState") + clock.reset(1, 6, 0) + state.reset() + runtime.initialize(clock, state, 424242) + clock.set_paused(true) + root.size = Vector2i(1440, 900) + main = load("res://scenes/main/main.tscn").instantiate() + var path := OS.get_environment("THE_TREE_UPKEEP_UI_SAVE") + if path.is_empty(): + path = "user://tests/upkeep_closed_%d/manual" % OS.get_process_id() + main.set("manual_save_path", path) + root.add_child(main) + await process_frame + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + if "--read" in OS.get_cmdline_user_args(): + _press_text("読込") + expect(clock.absolute_minute == 1440, "fresh-process UI load restores midnight") + else: + (main.get("build_buttons")[&"reception"] as Button).button_pressed = true + main.call("_on_map_cell_clicked", Vector3i(-6, 0, 0)) + expect(runtime.construction_service.get_jobs().size() == 1, "palette and map handler create one construction job") + clock.advance_minutes(120) + var rooms: Array = runtime.facility_service.get_instances() + expect(rooms.size() == 1, "normal clock finishes selected room") + if rooms.is_empty(): + _finish() + return + var panel: BusinessManagementPanel = main.get("business_management_panel") + panel.selected_facility_id = rooms[0].instance_id + panel.refresh() + panel.business_open_button.button_pressed = false + panel.apply_button.pressed.emit() + expect(not rooms[0].operator_open, "real business controls close room while paused") + expect("維持費" in panel.explanation_label.text, "business controls explain continued upkeep") + clock.advance_minutes(959) + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 0, "23:59 is not settled early") + _press_text("保存") + var before: Dictionary = runtime.create_snapshot() + _press_text("読込") + # Save/load deliberately emit distinct audit events and measured durations. + # Compare authoritative clock, ownership and money rather than those events. + var after: Dictionary = runtime.create_snapshot() + expect(after[&"clock"] == before[&"clock"], "UI disk load restores exact pre-midnight clock") + for field: StringName in [&"economy", &"facilities", &"facility_upkeep", &"upkeep_settlement"]: + expect(after[&"systems"][&"runtime"][field] == before[&"systems"][&"runtime"][field], "UI disk load preserves " + String(field)) + clock.advance_minutes(1) + _verify_closed() + await _test_ledger_skin() + if "--read" not in OS.get_cmdline_user_args(): + _press_text("保存") + await _capture() + _finish() +func _test_ledger_skin() -> void: + main.call("_set_layer", 0) + main.call("_set_ledger_panel_open", true) + var ledger: OperatingLedgerPanel = main.get("operating_ledger_panel") + var report := ledger.get_report() + var cash_label: Label = ledger.get("_cash_label") + var closed_label: Label = ledger.get("_closed_label") + var before_text := closed_label.text + var before_color := cash_label.get_theme_color("font_color") + var economy: Dictionary = runtime.economy_service.create_snapshot() + main.call("_set_layer", 1) + ledger.refresh() + expect(cash_label.get_theme_color("font_color") == Color("d8eeea") and closed_label.get_theme_color("font_color") == Color("d8eeea"), "BACK ledger cash and close remain readable after refresh") + expect(ledger.get_report() == report and closed_label.text == before_text, "skin preserves selected close and every displayed amount") + expect(runtime.economy_service.create_snapshot() == economy, "ledger skin does not post transactions") + if DisplayServer.get_name() != "headless": + await process_frame;await RenderingServer.frame_post_draw + var phase := "read" if "--read" in OS.get_cmdline_user_args() else "write" + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/ledger-skin-back-%s.png" % phase)) == OK, "capture ledger BACK skin") + main.call("_set_layer", 0) + expect(cash_label.get_theme_color("font_color") == before_color and ledger.get_report() == report, "FRONT restoration retains ledger data") + +func _verify_closed() -> void: + var rooms: Array = runtime.facility_service.get_instances() + expect(rooms.size() == 1, "saved room remains present") + if rooms.is_empty(): + return + var room: FacilityInstance = rooms[0] + expect(not room.operator_open, "closed policy survives midnight and restore") + # 08:00 completion to 00:00 = 960 minutes at the production 3G/day. + expect(runtime.economy_service.get_balance(FacilityUpkeepSettlement.EXPENSE) == 2, "closed room incurs 3 * 960 / 1440 = 2G") + expect(runtime.upkeep_settlement.get_facility_balance(room.instance_id)[&"fraction_units"] == 0, "whole-gold settlement leaves no fractional remainder") + expect(runtime.upkeep_settlement.get_arrears_total() == 0, "upkeep is paid with no arrears") + main.get("world_preview").facility_selected.emit(room.instance_id) + expect("3G / 日" in (main.get("selected_detail_label") as Label).text, "inspector uses production daily rate") + var midnight: Dictionary = runtime.create_snapshot() + var count: int = runtime.upkeep_settlement.get_settled_period_count() + clock.advance_minutes(1) + expect(runtime.upkeep_settlement.get_settled_period_count() == count, "next minute never repeats midnight settlement") + # Restore exact midnight for the independent read phase. + expect(runtime.restore_snapshot(midnight), "restore verified midnight for disk handoff") +func _press_text(text: String) -> void: + if text == "読込": + var menu := main.find_child("SaveLoadMenu", true, false) as MenuButton + expect(menu != null and not menu.disabled, "normal load menu is available") + if menu != null and not menu.disabled: + menu.get_popup().id_pressed.emit(0) + return + for button: Node in main.find_children("*", "Button", true, false): + if button.text == text: + button.pressed.emit() + return + expect(false, "UI button found: " + text) +func _capture() -> void: + if DisplayServer.get_name() == "headless": + return + for frame in 6: + await process_frame + await RenderingServer.frame_post_draw + var suffix := "read" if "--read" in OS.get_cmdline_user_args() else "write" + expect(root.get_texture().get_image().save_png("res://artifacts/upkeep-2026-09-29/closed-ui-%s.png" % suffix) == OK, "native rendered evidence saved") +func _finish() -> void: + main.queue_free() + print("%s: %d checks (phase %s)" % ["PASS" if failures == 0 else "FAIL", checks, "read" if "--read" in OS.get_cmdline_user_args() else "write"]) + quit(failures) diff --git a/tests/ui/test_upkeep_closed_ui.gd.uid b/tests/ui/test_upkeep_closed_ui.gd.uid new file mode 100644 index 0000000..e750704 --- /dev/null +++ b/tests/ui/test_upkeep_closed_ui.gd.uid @@ -0,0 +1 @@ +uid://c5u745h6rgnis diff --git a/tests/ui/test_upkeep_inspector.gd b/tests/ui/test_upkeep_inspector.gd new file mode 100644 index 0000000..4bd963d --- /dev/null +++ b/tests/ui/test_upkeep_inspector.gd @@ -0,0 +1,54 @@ +extends SceneTree +var checks := 0 +var failures := 0 +func _initialize() -> void: + call_deferred("_run") +func expect(ok: bool, message: String) -> void: + checks += 1 + if not ok: + failures += 1 + push_error(message) +func _run() -> void: + var runtime: Variant = 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, 424242) + clock.set_paused(true) + root.size = Vector2i(1440, 900) + var main: Control = load("res://scenes/main/main.tscn").instantiate() + main.set("manual_save_path", "user://tests/upkeep_inspector_%d" % OS.get_process_id()) + root.add_child(main) + await process_frame + runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + var definition: FacilityDefinition = runtime.definition_registry.get_facility(&"guest_corridor") + var old_rate := definition.maintenance_gold + definition.maintenance_gold = 1440 + expect(runtime.submit_command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(4, 1, 0)}).is_success(), "public construction accepts infrastructure") + clock.advance_minutes(10) + var facilities: Array = runtime.construction_service.get_facilities() + expect(facilities.size() == 1, "one completed corridor") + if not facilities.is_empty(): + var id: StringName = facilities[0][&"instance_id"] + clock.advance_minutes(3) + main.get("world_preview").facility_selected.emit(id) + var report: Dictionary = runtime.get_facility_upkeep_report(id) + expect(report[&"owned"] and report[&"daily_rate"] == 1440 and report[&"unsettled_units"] == 4320, "inspector projection preserves exact ownership accrual") + var text: String = main.get("selected_detail_label").text + expect("設備完成" in text and "1440G / 日" in text and "約3.00G" in text and "未払い 0G" in text, "infrastructure inspector displays daily pending and unpaid amounts") + expect("閉店・休止中" in text and "同期しています" not in text, "completed infrastructure explains ownership instead of a permanent sync placeholder") + var before: Dictionary = runtime.create_snapshot() + main.call("_refresh_selected_entity") + expect(runtime.create_snapshot() == before, "refresh is read only") + if DisplayServer.get_name() != "headless": + for frame in 6: + await process_frame + await RenderingServer.frame_post_draw + DirAccess.make_dir_recursive_absolute("res://artifacts/upkeep-2026-09-29") + expect(root.get_texture().get_image().save_png("res://artifacts/upkeep-2026-09-29/upkeep-inspector.png") == OK, "rendered inspector captured") + definition.maintenance_gold = old_rate + main.queue_free() + await process_frame + print("%s: %d checks" % ["PASS" if failures == 0 else "FAIL", checks]) + quit(failures) diff --git a/tests/ui/test_upkeep_inspector.gd.uid b/tests/ui/test_upkeep_inspector.gd.uid new file mode 100644 index 0000000..38f7657 --- /dev/null +++ b/tests/ui/test_upkeep_inspector.gd.uid @@ -0,0 +1 @@ +uid://bxmabisl8kpou diff --git a/tests/ui/test_world_tree_rank.gd b/tests/ui/test_world_tree_rank.gd new file mode 100644 index 0000000..1e06de2 --- /dev/null +++ b/tests/ui/test_world_tree_rank.gd @@ -0,0 +1,89 @@ +extends SceneTree +var checks:=0 +var failures:=0 +func expect(ok: bool, message: String) -> void: + checks+=1 + if not ok: failures+=1;push_error(message) +func _initialize() -> void:call_deferred("run") +func run() -> void: + var runtime: Variant=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,42510);clock.set_paused(true) + root.size=Vector2i(1280,720) + var main: Control=load("res://scenes/main/main.tscn").instantiate() + var save_path:="user://tests/rank_ui_%d" % Time.get_ticks_usec() + main.set("manual_save_path",save_path);root.add_child(main) + runtime.configure_save_policy({&"enabled":false,&"daily_checkpoint_enabled":false}) + await process_frame;await process_frame + var button: Button=main.get("rank_button") + var dialog: ConfirmationDialog=main.get("rank_dialog") + expect(button.text=="世界樹ランク 1" and button.is_visible_in_tree(),"rank appears in normal HUD") + var command_results: Array[CommandResult]=[] + runtime.command_completed.connect(func(result: CommandResult) -> void: command_results.append(result)) + var original: Dictionary=runtime.create_snapshot() + button.pressed.emit() + expect(dialog.visible and dialog.get_ok_button().disabled,"unmet rank conditions cannot be confirmed") + expect("5000 / 10000" in dialog.dialog_text and "評判" in dialog.dialog_text,"rank conditions show current facts and deficits") + expect(runtime.create_snapshot()==original,"viewing live eligibility does not mutate progression or simulation") + var front_text := dialog.get_label().get_theme_color("font_color") + var front_panel: Color = dialog.theme.get_stylebox("panel", "AcceptDialog").bg_color + var conditions := dialog.dialog_text + main.call("_set_layer", 1) + dialog.refresh() + expect(dialog.get_label().get_theme_color("font_color") == Color("d8eeea") and dialog.theme.get_stylebox("panel", "AcceptDialog").bg_color == Color("202e34"), "rank dialog uses readable BACK palette") + expect(dialog.get_ok_button().get_theme_color("font_disabled_color") == Color("8da4a4"), "disabled promotion remains legible in BACK") + expect(dialog.dialog_text == conditions and dialog.get_ok_button().disabled, "skin change preserves conditions and disabled gate") + if OS.get_environment("THE_TREE_RANK_SKIN_CAPTURE") == "1": + await process_frame; await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/rank-skin-back.png")) == OK, "capture BACK rank conditions") + main.call("_set_layer", 0) + expect(dialog.get_label().get_theme_color("font_color") == front_text and dialog.theme.get_stylebox("panel", "AcceptDialog").bg_color == front_panel, "rank dialog restores FRONT palette") + expect(runtime.create_snapshot() == original, "round trip skin change preserves complete simulation snapshot") + if OS.get_environment("THE_TREE_RANK_CAPTURE")=="1": + await process_frame;await RenderingServer.frame_post_draw + expect(root.get_texture().get_image().save_png(ProjectSettings.globalize_path("res://artifacts/facility-dimensions-2026-09-29/rank-conditions.png"))==OK,"capture rank HUD and unmet conditions") + dialog.hide() + # Controlled boundary inputs verify UI wiring, not natural campaign completion. + expect(runtime.submit_command(&"update_progression_metrics",{&"metrics":{&"reputation":25}}).is_success(),"fund reputation boundary fixture") + expect(runtime.submit_command(&"change_resources",{&"deltas":{&"gold":5000}}).is_success(),"fund current cash boundary through ledger command") + button.pressed.emit() + expect(not dialog.get_ok_button().disabled and "10000 / 10000" in dialog.dialog_text,"eligibility uses live cash before another tick") + var eligible_snapshot: Dictionary=runtime.create_snapshot() + # Explicit recovery-failure injection tests the pre-existing operations gate. + runtime.operations_coordinator._enter_external_quarantine(&"tick",clock.absolute_minute,&"test_phase",&"TEST_FAILURE") + dialog.refresh() + expect(dialog.get_ok_button().disabled and "営業状態の復旧" in dialog.dialog_text,"otherwise eligible rank is blocked and explained during operations quarantine") + expect(runtime.restore_snapshot(eligible_snapshot),"recover valid pre-fault game") + dialog.refresh() + runtime.submit_command(&"change_resources",{&"deltas":{&"gold":-1}}) + dialog.confirmed.emit();dialog.hide() + expect(runtime.progression_service.current_rank==1 and "昇格できません" in main.get("build_status_label").text,"confirmation revalidates changed conditions instead of trusting prior preview") + expect(not command_results.is_empty() and command_results.back().details.get(&"reason",&"")==&"REQUIREMENTS_NOT_MET","changed-cash rejection is eligibility failure, not a malformed UI request") + runtime.submit_command(&"change_resources",{&"deltas":{&"gold":1}}) + button.pressed.emit() + clock.set_paused(false) + dialog.confirmed.emit();dialog.hide() + clock.set_paused(true) + expect(runtime.progression_service.current_rank==1 and int(dialog.get("_pending_id"))!=0,"running-game confirmation queues promotion without pretending it already happened") + main.call("_save_manual") + expect(SaveService.load_snapshot(save_path)[&"ok"],"pending rank request saves before execution") + expect(runtime.restore_snapshot(original),"restore control game while promotion is pending") + main.call("_load_manual") + button.pressed.emit() + expect(int(dialog.get("_pending_id"))!=0 and dialog.get_ok_button().disabled,"loaded pending promotion stays visible and cannot be submitted twice") + dialog.hide() + clock.advance_one_minute() + expect(runtime.progression_service.current_rank==2 and state.world_tree_rank==2,"queued UI promotion commits real rank") + expect(runtime.world_service.get_bounds()==ProgressionService.RANK_BOUNDS[2],"promotion expands actual construction bounds") + expect(button.text=="世界樹ランク 2" and int(dialog.get("_pending_id"))==0,"completed command updates HUD and clears pending UI state") + main.call("_save_manual") + expect(SaveService.load_snapshot(save_path)[&"ok"],"promoted game saves through normal UI") + expect(runtime.restore_snapshot(original),"restore pre-promotion control state") + main.call("_refresh_dashboard") + expect(button.text=="世界樹ランク 1","HUD follows restored rank") + main.call("_load_manual") + expect(button.text=="世界樹ランク 2" and runtime.world_service.get_bounds()==ProgressionService.RANK_BOUNDS[2],"normal load restores promoted HUD and map bounds") + SaveService.delete_save(save_path) + main.queue_free();await process_frame + print("World tree rank UI: %d checks, %d failures" % [checks,failures]);quit(failures) diff --git a/tests/ui/test_world_tree_rank.gd.uid b/tests/ui/test_world_tree_rank.gd.uid new file mode 100644 index 0000000..c4d209c --- /dev/null +++ b/tests/ui/test_world_tree_rank.gd.uid @@ -0,0 +1 @@ +uid://beivol4d4b5al diff --git a/tests/world/test_path_transit_density.gd b/tests/world/test_path_transit_density.gd index c05db7d..36ecb7c 100644 --- a/tests/world/test_path_transit_density.gd +++ b/tests/world/test_path_transit_density.gd @@ -32,14 +32,14 @@ func _populate(path: PathService) -> void: func _test_occupancy_lifecycle() -> void: var path := _path() _populate(path) - _expect(path.get_current_density(ORIGIN) == 3 and is_equal_approx(path.get_speed_multiplier(ORIGIN), 1.0 / 3.0), "all three floor actors initially contribute to congestion") + _expect(path.get_current_density(ORIGIN) == 3 and is_equal_approx(path.get_speed_multiplier(ORIGIN), 0.5), "all three floor actors contribute to the specified half-speed congestion") _queue(path, &"guest:a", &"ride.a") _expect(path.get_current_density(ORIGIN) == 3 and path.get_transit_actor_ids().is_empty(), "queued passengers still occupy the floor") var before := path.create_snapshot() var graph_before := path.graph_revision var boarded := path.dequeue_elevator_boarding(&"lift", 0, 11, 1) _expect(boarded.size() == 1 and path.get_transit_actor_ids() == [&"guest:a"], "boarding derives transit occupancy from the existing Path owner") - _expect(path.get_current_density(ORIGIN) == 2 and is_equal_approx(path.get_speed_multiplier(ORIGIN), 0.5), "a boarded passenger no longer slows the remaining floor actors") + _expect(path.get_current_density(ORIGIN) == 2 and is_equal_approx(path.get_speed_multiplier(ORIGIN), 0.5), "boarding removes the passenger from density; the two remaining actors still move at half speed") _expect(path.get_actor_projected_cell(&"guest:a") == ORIGIN and path.create_snapshot()[&"actor_progress"] == before[&"actor_progress"], "boarding retains logical origin and progress records") _expect(path.graph_revision == graph_before and path.traffic_revision > before[&"traffic_revision"], "boarding changes traffic costs without changing structural topology") var stable := path.create_snapshot() diff --git a/tools/build_original_asset_inventory.py b/tools/build_original_asset_inventory.py new file mode 100644 index 0000000..2c8b9e5 --- /dev/null +++ b/tools/build_original_asset_inventory.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Evidence inventory for P22; file presence never certifies production readiness.""" +import hashlib +import json +from pathlib import Path +import re +import struct + +ROOT = Path(__file__).resolve().parents[1] +OUT = ROOT / 'docs/plans/original-completion-2026-09-29/execution/assets' +OUT.mkdir(parents=True, exist_ok=True) +def digest(p): + return hashlib.sha256(p.read_bytes()).hexdigest() +def save(name, data): + (OUT/name).write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n') + +references = {} +for folder in ('scripts', 'scenes', 'autoload', 'data', 'assets'): + for source in (ROOT/folder).rglob('*'): + if source.suffix not in ('.gd', '.tres', '.tscn') or not source.is_file(): + continue + for path in set(re.findall(r'res://([^"\s]+\.(?:png|svg|jpg|jpeg|webp|ogg|wav|mp3|ttf|otf))', source.read_text())): + references.setdefault(path, []).append(str(source.relative_to(ROOT))) +media = [] +for p in sorted((ROOT/'assets').rglob('*')): + if not p.is_file() or p.suffix.lower() not in ('.png','.svg','.jpg','.jpeg','.webp','.ogg','.wav','.mp3','.ttf','.otf'): + continue + path = str(p.relative_to(ROOT)) + row = {'path':path, 'bytes':p.stat().st_size, 'sha256':digest(p), 'literal_references':references.get(path, []), 'acceptance':'not_reviewed', 'provenance':'unverified'} + if p.suffix.lower() == '.png': + header = p.read_bytes()[:24] + if header[:8] == b'\x89PNG\r\n\x1a\n': + row['width'],row['height'] = struct.unpack('>II',header[16:24]) + if path.startswith('assets/mockups/'): + row['purpose']='reference_mockup_not_shipping_acceptance' + elif path.startswith('assets/kenney/'): + pack = p.relative_to(ROOT/'assets/kenney').parts[0] + license_path = ROOT/'assets/kenney'/pack/'License.txt' + row.update(provenance='Kenney', creator='Kenney', license=str(license_path.relative_to(ROOT)), license_present=license_path.exists()) + if license_path.exists(): + row['license_sha256']=digest(license_path) + row['license_declares_cc0']='CC0' in license_path.read_text() + else: + row['purpose']='existing_theme_or_icon_requires_source_and_quality_review' + media.append(row) +save('media_inventory.json', media) + +facility_mapping = ROOT/'docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json' +facilities = json.loads(facility_mapping.read_text()) +renderer = ROOT/'scripts/ui/pixel_tower_art.gd' +text = renderer.read_text() +slots = [] +for index,f in enumerate(facilities,1): + key = f['definition_id'] or f'original_facility_{index:02}' + for level in range(1,4): + for layer in ('front','back'): + for surface in ('exterior','interior'): + slots.append({'slot_id':f'{key}.lv{level}.{layer}.{surface}', 'requirement':['B-13.3',f"B-{f['section']}"], 'facility_name':f['name'], 'definition_id':f['definition_id'], 'level':level, 'layer':layer, 'surface':surface, 'original_placement':f['original_layer_zone'], 'creator':'unassigned', 'status':'unverified_required_slot', 'approved_asset':None, 'existing_candidate':str(renderer.relative_to(ROOT)) if f['definition_id'] and ('&"'+f['definition_id']+'"') in text else None, 'note':'Code token is only a search lead; it does not prove a dedicated level/layer/surface variant. These are usage slots, not a committed image count.'}) +save('facility_usage_slots.json',slots) + +people = [('anri','アンリ'),('fine','フィーネ'),('tomari','トマリ'),('amelia','アメリア'),('liz','リズ'),('mimi','ミミ'),('satsuki','サツキ')] +character_slots = [] +for key,name in people: + for kind in ('portrait_base','portrait_expression','portrait_costume','portrait_damage','portrait_dirt','sd_idle','sd_walk','sd_work','sd_collapsed','sd_carry','sd_rescue','sd_rest'): + character_slots.append({'slot_id':f'heroine.{key}.{kind}', 'character':name, 'requirement':['B-6.2','B-13.1' if kind.startswith('portrait') else 'B-13.2'], 'kind':kind, 'required_variant_count':1 if kind=='portrait_base' else None, 'creator':'unassigned', 'status':'unverified_required_slot', 'approved_asset':None, 'candidate':'scripts/ui/pixel_people.gd' if kind.startswith('sd_') else None, 'note':'Generic procedural poses do not prove character-specific appearance or role-specific animation.'}) +save('character_usage_slots.json',character_slots) + +other = [ + ('background.front','B-13.3','scenes/main/world_preview.gd'),('background.back','B-13.3','scenes/main/world_preview.gd'), + ('ui.front_skin','B-13.4','assets/themes/japanese_fantasy/visual-theme-v1.tres'),('ui.back_skin','B-13.4','assets/themes/japanese_fantasy/visual-theme-v1.tres'), + ('ui.emotion_graph','B-13.4',None),('ui.camera_overlay','B-13.4',None), + ('effect.shield','B-13.2',None),('effect.smoke','B-13.2',None),('effect.mana_light','B-13.2',None), + ('audio.front_bgm','B-13.5',None),('audio.back_arrangement','B-13.5',None),('audio.front_ambience','B-13.5',None),('audio.back_ambience','B-13.5',None),('audio.ui','B-13.5',None),('audio.character_voice','B-13.5',None), + ('other_characters','B-13.1',None),('restricted_content_unproduced','B-13.1',None)] +save('other_usage_slots.json',[{'slot_id':k,'requirement':(['B-12.3','B-13.1','B-13.2','B-13.5'] if k=='restricted_content_unproduced' else [req]),'candidate':candidate,'status':'not_produced_outside_current_content_scope' if k=='restricted_content_unproduced' else 'unverified_required_slot','creator':'unassigned','required_variant_count':None,'approved_asset':None} for k,req,candidate in other]) +summary = {'facility_mapping':str(facility_mapping.relative_to(ROOT)), 'facility_mapping_sha256':digest(facility_mapping), 'baseline_sha256':digest(ROOT/'docs/traceability/original/BASELINE.txt'), 'media_files':len(media), 'literal_referenced_media_files':sum(bool(x['literal_references']) for x in media), 'audio_files':sum(Path(x['path']).suffix in ('.wav','.ogg','.mp3') for x in media), 'facility_count':len(facilities), 'facility_usage_slots':len(slots), 'named_heroines':len(people), 'character_usage_slot_groups':len(character_slots), 'other_slot_groups':len(other), 'approved_assets':0, 'missing_literal_references':[p for p in references if not (ROOT/p).exists()], 'scope':'Inventory and candidate mappings only. No asset is promoted to complete by file existence, procedural fallback, or a passing gameplay test. Variant counts and production ownership remain unresolved.'} +save('inventory_summary.json',summary) +print(json.dumps(summary,ensure_ascii=False)) diff --git a/tools/capture_facility_audit.gd b/tools/capture_facility_audit.gd new file mode 100644 index 0000000..f268503 --- /dev/null +++ b/tools/capture_facility_audit.gd @@ -0,0 +1,23 @@ +extends SceneTree +func _initialize() -> void: call_deferred("run") +func run() -> void: + root.get_node("GameClock").set_paused(true) + var runtime: Variant = root.get_node("SimulationRuntime") + var original: Array = JSON.parse_string(FileAccess.get_file_as_string("res://docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json")) + var palette: Array = load("res://scenes/main/main.gd").MVP_FACILITY_IDS + var rows: Array = [] + for raw: Dictionary in original: + var id := StringName(raw["definition_id"] if raw["definition_id"] != null else "") + var definition: FacilityDefinition = FacilityCatalog.by_id(id) if not id.is_empty() else null + var row := {"section":raw["section"], "name":raw["name"], "definition_id":String(id), "original_size":raw["original_size"], "original_layer_zone":raw["original_layer_zone"], "runtime_registered":runtime.definition_registry.has_facility(id), "palette_listed":id in palette, "definition":null} + if definition != null: + row["definition"] = {"path":definition.resource_path, "display_name":definition.display_name, "footprint":[definition.footprint.x,definition.footprint.y], "layer":FacilityDefinition.ViewLayer.keys()[definition.layer], "zone":FacilityDefinition.MapZone.keys()[definition.zone], "customer_capacity":definition.customer_capacity, "capacity":definition.capacity, "staff_min":definition.staff_min,"staff_max":definition.staff_max, "required_roles":definition.required_roles, "service_attribute":String(definition.service_attribute), "maintenance_gold":definition.maintenance_gold, "faith_per_service":definition.faith_per_service} + rows.append(row) + var output := OS.get_environment("THE_TREE_FACILITY_AUDIT_PATH") + if output.is_empty(): + push_error("THE_TREE_FACILITY_AUDIT_PATH is required"); quit(2); return + var file := FileAccess.open(output,FileAccess.WRITE) + if file == null: + push_error("Cannot write audit output"); quit(2); return + file.store_string(JSON.stringify({"normal_registry_ids":runtime.definition_registry.get_facility_ids(),"palette_ids":palette,"original_facilities":rows},"\t")+"\n");file.close() + print("Exported facility audit rows (not acceptance PASS): ", rows.size());quit(0) diff --git a/tools/capture_facility_audit.gd.uid b/tools/capture_facility_audit.gd.uid new file mode 100644 index 0000000..083e09d --- /dev/null +++ b/tools/capture_facility_audit.gd.uid @@ -0,0 +1 @@ +uid://b5uppfknx478a diff --git a/tools/run_all_tests.py b/tools/run_all_tests.py new file mode 100644 index 0000000..4c89057 --- /dev/null +++ b/tools/run_all_tests.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Run every source test, retain logs and continue past failures. + +The exported-package test needs a built PCK and runs via make test-exported-opening. +""" +import argparse +import json +from pathlib import Path +import subprocess +import time + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--output', type=Path, default=Path('artifacts/all-tests')) + parser.add_argument('--godot', default='godot') + parser.add_argument('--timeout', type=int, default=1200) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + paths = [root / 'tests/run_tests.gd'] + sorted((root / 'tests').rglob('test*.gd')) + results = [] + for path in paths: + relative = path.relative_to(root) + if 'export' in relative.parts or relative.as_posix() == 'tests/s0/test_kernel_and_save.gd': + # The RefCounted kernel module is already invoked by run_tests.gd. + continue + start = time.monotonic() + name = str(relative).replace('/', '__') + '.log' + # Stream to disk so long-running scenarios expose progress immediately. + with (output / name).open('w') as log: + try: + run = subprocess.run( + [args.godot, '--headless', '--path', str(root), '--script', 'res://' + str(relative)], + cwd=root, stdout=log, stderr=subprocess.STDOUT, timeout=args.timeout, + ) + code = run.returncode + except subprocess.TimeoutExpired: + code = 124 + content = (output / name).read_text() + failed = code != 0 or 'SCRIPT ERROR:' in content or '\nERROR:' in content + results.append(dict(test=str(relative), exit_code=code, failed=failed, + seconds=round(time.monotonic() - start, 2), log=name)) + (output / 'all-tests.json').write_text(json.dumps(results, indent=2) + '\n') + print(f'{"FAIL" if failed else "PASS"} {relative}', flush=True) + failed = sum(result['failed'] for result in results) + print(f'{len(results)} tests, {failed} failures; logs: {output}') + return 1 if failed else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/run_facility_audit.py b/tools/run_facility_audit.py new file mode 100644 index 0000000..7fa11e5 --- /dev/null +++ b/tools/run_facility_audit.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Capture current registration evidence; successful export is not acceptance.""" +import argparse +from collections import Counter +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess + +ROOT = Path(__file__).resolve().parents[1] +MAPPING = 'docs/plans/original-completion-2026-09-29/execution/original_facility_mapping.json' +BASELINE = 'docs/plans/original-completion-2026-09-29/execution/baseline-fetched-2026-09-29.txt' + +def manifest(): + paths = [ROOT / MAPPING, ROOT / BASELINE, ROOT / 'tools/capture_facility_audit.gd', Path(__file__)] + for directory in ('autoload', 'scripts', 'scenes', 'data'): + paths.extend(p for p in (ROOT / directory).rglob('*') if p.suffix in ('.gd', '.tres', '.tscn', '.json')) + return {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(set(paths))} + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--godot', default='godot') + parser.add_argument('--output', required=True) + args = parser.parse_args() + out = Path(args.output).resolve() + out.mkdir(parents=True, exist_ok=True) + def save(name, value): + (out / name).write_text(json.dumps(value, ensure_ascii=False, indent=2) + '\n') + before = manifest() + save('source-start.json', before) + env = dict(os.environ, THE_TREE_FACILITY_AUDIT_PATH=str(out / 'runtime-facilities.json')) + with (out / 'export.log').open('w') as log: + result = subprocess.run([args.godot, '--headless', '--path', str(ROOT), '--script', 'res://tools/capture_facility_audit.gd'], env=env, stdout=log, stderr=subprocess.STDOUT, timeout=120) + after = manifest() + changes = [p for p in sorted(before.keys() | after.keys()) if before.get(p) != after.get(p)] + save('source-end.json', after) + save('source-change.json', changes) + if result.returncode or changes: + raise SystemExit('Audit capture failed or source changed; inspect logs.') + data = json.loads((out / 'runtime-facilities.json').read_text()) + mapping = json.loads((ROOT / MAPPING).read_text()) + rows = data['original_facilities'] + if len(rows) != 30 or len({r['name'] for r in rows}) != 30 or [r['name'] for r in rows] != [r['name'] for r in mapping]: + raise SystemExit('Original mapping coverage is invalid.') + counts = Counter() + mismatches = [] + for row in rows: + definition = row['definition'] + row['registration_status'] = ('normal' if row['runtime_registered'] and row['palette_listed'] else 'definition_only' if definition else 'missing') + counts[row['registration_status']] += 1 + row['full_acceptance'] = 'unproven' + if definition: + expected = [int(x) for x in row['original_size'].split('x')] + layer = re.match(r'FRONT|BACK', row['original_layer_zone']).group() + if definition['footprint'] != expected or definition['layer'] != layer: + mismatches.append(row['name']) + extra = sorted(set(data['normal_registry_ids']) - {r['definition_id'] for r in rows}) + unspecified = [r['name'] for r in rows if r['definition'] and r['definition']['customer_capacity'] < 0] + summary = dict(original_count=30, registration_counts=dict(counts), normal_catalog_count=len(data['normal_registry_ids']), non_original_registry_ids=extra, + dimension_or_layer_mismatches=mismatches, customer_limit_unset=unspecified, + all_original_acceptance=False, scope='Registration and loaded definition values only. Not operation, save migration, assets, Windows or whole-spec acceptance.') + save('summary.json', summary) + save('classified-facilities.json', rows) + labels = {'normal': '通常登録・パレット掲載', 'definition_only': '定義のみ', 'missing': '対応定義なし'} + lines = ['# 原本30施設・現行登録監査', '', + f"通常登録 {counts['normal']}、定義のみ {counts['definition_only']}、対応定義なし {counts['missing']}。通常登録全{len(data['normal_registry_ids'])}件のうち原本30施設以外は{len(extra)}件。", + '', '登録状態と読込済み定義の監査。実営業・保存移行・完成資産・Windows・全要件の受入完了は証明しない。', '', + f'対応定義の寸法・層の不一致: {len(mismatches)}件。人数専用上限が未設定: {len(unspecified)}件。capacityから人数値を転記していない。高層・背面・騒音・サービス条件は別途受入が必要。', '', + '| 原本 | 施設 | ID | 登録 | 寸法 / 層 / ゾーン | 顧客人数上限 |', '|---|---|---|---|---|---|'] + for r in rows: + d = r['definition'] + geometry = f"{d['footprint'][0]}×{d['footprint'][1]} / {d['layer']} / {d['zone']}" if d else '未定義' + limit = str(d['customer_capacity']) if d and d['customer_capacity'] >= 0 else '未設定' + lines.append(f"| {r['section']} | {r['name']} | {r['definition_id'] or '—'} | {labels[r['registration_status']]} | {geometry} | {limit} |") + lines += ['', '## 未接続施設', '', '対応定義なし: ' + '、'.join(r['name'] for r in rows if r['registration_status']=='missing') + '。', '', + '定義のみ: ' + '、'.join(r['name'] for r in rows if r['registration_status']=='definition_only') + '。', '', + '## 次の受入', '', '未定義施設は寸法・層だけでなく経済値、人数、業務、通常操作、保存を接続する。登録済み施設は未設定人数とサービス属性、営業時間・停止・経路・客/職員行動を個別検証する。施設追加のたびにこの監査を再実行する。', '', + '再現: `python3 tools/run_facility_audit.py --godot /opt/homebrew/bin/godot --output artifacts/facility-audit-current-2026-09-29`。原本SHA・入力ソースSHA・取得中の変更なしは同ディレクトリのsource-start/end/change.jsonを参照。', ''] + (out / 'REPORT.md').write_text('\n'.join(lines)) + print(json.dumps(summary, ensure_ascii=False)) + +if __name__ == '__main__': + main() diff --git a/tools/run_opening_restart_flow.py b/tools/run_opening_restart_flow.py new file mode 100644 index 0000000..6311f48 --- /dev/null +++ b/tools/run_opening_restart_flow.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Debug a naturally opened inn through three closes and an actual process restart.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import time +import uuid + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--godot', default='godot') + parser.add_argument('--rendered', action='store_true') + parser.add_argument('--output', type=Path, default=Path('artifacts/opening-restart-flow')) + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + output = args.output.resolve() + output.mkdir(parents=True, exist_ok=True) + env = dict(os.environ, THE_TREE_OPENING_RESTART_SLOT=f'user://tests/opening_restart_{uuid.uuid4().hex}/manual', THE_TREE_OPENING_RESTART_OUTPUT=str(output)) + env.pop('THE_TREE_OPENING_CAPTURE', None) + command = [args.godot] + ([] if args.rendered else ['--headless']) + ['--path', str(root), '--script', 'res://tests/ui/fixtures/opening_restart_flow.gd'] + source_paths = [p for directory in ('autoload', 'scripts', 'scenes', 'data', 'tests') for p in (root/directory).rglob('*') if p.suffix in ('.gd', '.tres', '.tscn', '.json')] + hashes = {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() for p in sorted(source_paths)} + (output/'source-start.json').write_text(json.dumps(hashes, indent=2)+'\n') + results = [] + for phase in ('write', 'read'): + started = time.monotonic() + log_path = output/f'{phase}.log' + with log_path.open('w') as log: + try: + code = subprocess.run(command + (['--', '--read'] if phase == 'read' else []), cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=1200).returncode + except subprocess.TimeoutExpired: + code = 124 + content = log_path.read_text() + passed = code == 0 and '0 failures' in content and 'ERROR:' not in content + results.append(dict(phase=phase, exit_code=code, passed=passed, seconds=round(time.monotonic()-started, 2), log=log_path.name)) + (output/'result.json').write_text(json.dumps(results, indent=2)+'\n') + print(json.dumps(results[-1]), flush=True) + if not passed: + return 1 + changed = [p for p,h in hashes.items() if not (root/p).exists() or hashlib.sha256((root/p).read_bytes()).hexdigest()!=h] + (output/'source-change.json').write_text(json.dumps(changed, indent=2)+'\n') + return 1 if changed else 0 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tools/run_upkeep_ui_flow.py b/tools/run_upkeep_ui_flow.py new file mode 100644 index 0000000..4facac7 --- /dev/null +++ b/tools/run_upkeep_ui_flow.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Verify closed-facility upkeep across two independent Godot processes.""" +import argparse +import json +import os +from pathlib import Path +import subprocess +import time +import uuid + +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--godot', default='godot') +parser.add_argument('--rendered', action='store_true') +parser.add_argument('--output', type=Path, default=Path('artifacts/upkeep-ui-flow')) +args = parser.parse_args() +root = Path(__file__).resolve().parents[1] +output = args.output.resolve() +output.mkdir(parents=True, exist_ok=True) +env = os.environ.copy() +env['THE_TREE_UPKEEP_UI_SAVE'] = f'user://tests/upkeep_ui_{uuid.uuid4().hex}/manual' +base = [args.godot] + ([] if args.rendered else ['--headless']) + ['--path', str(root), '--script', 'res://tests/ui/test_upkeep_closed_ui.gd'] +results = [] +for phase in ('write', 'read'): + start = time.monotonic() + log_path = output / f'{phase}.log' + with log_path.open('w') as log: + try: + code = subprocess.run(base + (['--', '--read'] if phase == 'read' else []), cwd=root, env=env, stdout=log, stderr=subprocess.STDOUT, timeout=600).returncode + except subprocess.TimeoutExpired: + code = 124 + content = log_path.read_text() + passed = code == 0 and 'PASS:' in content and 'ERROR:' not in content + results.append({'phase':phase, 'exit_code':code, 'passed':passed, 'seconds':round(time.monotonic()-start,2), 'log':str(log_path)}) + (output/'result.json').write_text(json.dumps(results, indent=2)+'\n') + print(json.dumps(results[-1]), flush=True) + if not passed: + raise SystemExit(1)