diff --git a/Makefile b/Makefile index c066c2f..cc5a024 100644 --- a/Makefile +++ b/Makefile @@ -13,6 +13,7 @@ test: $(MAKE) test-s1 $(MAKE) test-s2 $(MAKE) test-s3 + $(MAKE) test-supply $(MAKE) test-s4 $(MAKE) test-s5 $(MAKE) test-s6 @@ -89,6 +90,7 @@ test-ui: $(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 + $(GODOT) --headless --path . --script res://tests/ui/test_supply_management.gd $(MAKE) test-opening test-perf: @@ -122,3 +124,18 @@ test-opening: capture-opening: THE_TREE_OPENING_CAPTURE=1 $(GODOT) --path . --script res://tests/ui/test_first_operating_day.gd + +# Paid purchases, kitchen batches, and porter deliveries; isolated test saves. +.PHONY: test-supply capture-supply preview-supply +test-supply: + $(GODOT) --headless --path . --script res://tests/s2/test_kitchen_production.gd + $(GODOT) --headless --path . --script res://tests/supply/test_supply_save_migration.gd + $(GODOT) --headless --path . --script res://tests/supply/test_supply_logistics_bridge.gd + $(GODOT) --headless --path . --script res://tests/supply/test_supply_management_service.gd + $(GODOT) --headless --path . --script res://tests/supply/test_normal_supply_chain.gd + +capture-supply: + THE_TREE_SUPPLY_CAPTURE=1 THE_TREE_SUPPLY_CAPTURE_ONLY=1 $(GODOT) --path . --script res://tests/supply/test_normal_supply_chain.gd + +preview-supply: + THE_TREE_SUPPLY_PREVIEW=1 $(GODOT) --path . --script res://tests/supply/test_normal_supply_chain.gd diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index e5904fb..7be5a55 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -15,7 +15,8 @@ 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 := 6 +const RUNTIME_SNAPSHOT_VERSION := 7 +const INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION := 6 const LEGACY_RUNTIME_SNAPSHOT_VERSION := 1 const DEFENSE_RUNTIME_SNAPSHOT_VERSION := 2 const PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION := 3 @@ -86,6 +87,9 @@ var operating_resource_state: LedgerBackedResourceState var inventory_service := InventoryService.new() var logistics_dispatcher := LogisticsDispatcher.new() var logistics_requests: Dictionary = {} +var kitchen_production := KitchenProductionService.new() +var supply_management := SupplyManagementService.new() +var supply_logistics := SupplyLogisticsBridge.new() var facility_service := FacilityService.new() var guest_service := GuestService.new() var staff_service := StaffService.new() @@ -227,6 +231,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S latest_save_operation_result.clear() latest_restore_safety_report.clear() _initialize_rng_streams() + supply_logistics = SupplyLogisticsBridge.new() + supply_logistics.configure(self) _executors.clear() register_command_executor(&"change_resources", Callable(self, "_execute_change_resources")) register_command_executor(&"spend_resources", Callable(self, "_execute_spend_resources")) @@ -240,6 +246,8 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S register_command_executor(&"update_progression_metrics", Callable(self, "_execute_update_progression_metrics")) register_command_executor(&"complete_progression_quest", Callable(self, "_execute_complete_progression_quest")) register_command_executor(&"acknowledge_operations_pause", Callable(self, "_execute_acknowledge_operations_pause")) + register_command_executor(&"purchase_supplies", Callable(self, "_execute_purchase_supplies")) + register_command_executor(&"set_kitchen_production", Callable(self, "_execute_set_kitchen_production")) register_command_executor(&"add_inventory_stack", Callable(self, "_execute_add_inventory_stack")) register_command_executor(&"create_logistics_request", Callable(self, "_execute_create_logistics_request")) register_command_executor(&"dispatch_logistics", Callable(self, "_execute_dispatch_logistics")) @@ -452,6 +460,7 @@ func create_snapshot() -> Dictionary: &"mana": mana_network_service.create_snapshot(), &"economy": economy_service.create_snapshot(), &"inventory": inventory_service.create_snapshot(), + &"kitchen_production": kitchen_production.create_snapshot(), &"logistics_dispatcher": logistics_dispatcher.create_snapshot(), &"logistics_requests": _serialize_logistics_requests(), &"facilities": facility_service.create_snapshot(), @@ -515,13 +524,14 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo PATH_TRAFFIC_RUNTIME_SNAPSHOT_VERSION, CONTENT_RUNTIME_SNAPSHOT_VERSION, OPERATIONS_RUNTIME_SNAPSHOT_VERSION, + INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION, RUNTIME_SNAPSHOT_VERSION, ]: return false if runtime_schema_version < OPERATIONS_RUNTIME_SNAPSHOT_VERSION \ and not _legacy_operations_history_is_absent(runtime_data): return false - if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION \ + if runtime_schema_version < INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION \ and not _legacy_infrastructure_history_is_absent(runtime_data): return false if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION: @@ -537,11 +547,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo &"operations", &"relationships", &"event_definitions", &"events", &"authority", &"defense", &"construction", ] - if runtime_schema_version == RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION: runtime_current_keys.append_array([ &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", ]) + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + runtime_current_keys.append(&"kitchen_production") 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 \ @@ -562,13 +574,17 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo if runtime_schema_version >= OPERATIONS_RUNTIME_SNAPSHOT_VERSION \ and not runtime_data.get(&"operations", null) is Dictionary: return false - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION: for infrastructure_key: StringName in [ &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", ]: if not runtime_data.get(infrastructure_key, null) is Dictionary: return false + if runtime_schema_version < RUNTIME_SNAPSHOT_VERSION and runtime_data.has(&"kitchen_production"): + return false + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION and not runtime_data.get(&"kitchen_production", 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)): @@ -642,7 +658,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo var restored_spatial := SpatialSubslotService.new() var restored_route_adapter := RouteSubslotPathAdapter.new() var restored_infrastructure_projector := FacilityInfrastructureProjector.new() - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION: # Configure against an empty Spatial source before any managed namespace is # present. Then restore the source, rebuild the base+ROUTE topology, and # finally restore Path traffic/actors and the two metadata owners. @@ -959,7 +975,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo var restored_excavation := ExcavationService.new( restored_world, restored_construction_resource ) - if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if runtime_schema_version >= INFRASTRUCTURE_RUNTIME_SNAPSHOT_VERSION: if not restored_excavation.restore_snapshot(runtime_data[&"excavation"]): return false else: @@ -1002,6 +1018,15 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo restored_infrastructure_projector ): return false + var restored_kitchen := KitchenProductionService.new() + restored_kitchen.configure(restored_inventory, restored_facilities, definition_registry, restored_runtime_domain_minute) + if runtime_schema_version >= RUNTIME_SNAPSHOT_VERSION: + if not restored_kitchen.restore_snapshot(runtime_data[&"kitchen_production"]): + return false + if restored_kitchen.current_minute != restored_runtime_domain_minute or not restored_kitchen.validate_crosslinks(): + return false + if not SupplyLogisticsBridge.validate_crosslinks(restored_logistics, restored_staff, restored_inventory, restored_facilities): + 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"] @@ -1044,11 +1069,13 @@ func _restore_snapshot_internal(snapshot: Dictionary, recovery_mode: bool) -> bo mana_network_service = restored_mana economy_service = restored_economy inventory_service = restored_inventory + kitchen_production = restored_kitchen logistics_dispatcher = restored_dispatcher logistics_requests = restored_logistics facility_service = restored_facilities guest_service = restored_guests staff_service = restored_staff + supply_logistics.configure(self) mvp_content_catalog = restored_catalog mvp_content_report = restored_catalog_report.duplicate(true) progression_service = restored_progression @@ -1652,6 +1679,7 @@ func _build_operations_external_state() -> Dictionary: &"next_command_id": _next_command_id, &"economy": economy_service.create_snapshot(), &"inventory": inventory_service.create_snapshot(), + &"kitchen_production": kitchen_production.create_snapshot(), &"facilities": facility_service.create_snapshot(), &"guests": guest_service.create_snapshot(), &"staff": staff_service.create_snapshot(), @@ -1687,12 +1715,13 @@ func _operations_external_committed_minute() -> int: func _runtime_aggregate_committed_minute() -> int: if facility_service == null or guest_service == null or inventory_service == null \ - or staff_service == null: + or staff_service == null or kitchen_production == null: return -1 var minute := facility_service.current_minute if minute < 0 or guest_service.current_minute != minute \ or inventory_service.current_minute != minute \ - or staff_service.current_minute != minute: + or staff_service.current_minute != minute \ + or kitchen_production.current_minute != minute: return -1 return minute @@ -1703,7 +1732,7 @@ func _restore_operations_external_state(token: Variant) -> bool: return false var saved: Dictionary = token var required_dictionary_keys: Array[StringName] = [ - &"game_state", &"command_queue", &"economy", &"inventory", &"facilities", &"guests", + &"game_state", &"command_queue", &"economy", &"inventory", &"kitchen_production", &"facilities", &"guests", &"staff", &"relationships", &"events", &"authority", &"world", &"path", &"excavation", &"spatial_subslots", &"route_projection", &"facility_infrastructure", @@ -1743,6 +1772,7 @@ func _restore_operations_external_state(token: Variant) -> bool: var economy_ok := economy_service.restore_snapshot(saved[&"economy"]) var inventory_ok := inventory_service.restore_snapshot(saved[&"inventory"]) var facilities_ok := facility_service.restore_snapshot(saved[&"facilities"]) + 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"]) var relationships_ok := relationship_service.restore_snapshot(saved[&"relationships"]) @@ -1791,7 +1821,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 \ + var participants_ok := 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 \ @@ -1845,6 +1875,7 @@ func _operations_external_state_matches(saved: Dictionary) -> bool: or _next_command_id != int(saved[&"next_command_id"]) \ 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(guest_service.create_snapshot(), saved[&"guests"]) \ or not _strict_variants_equal(staff_service.create_snapshot(), saved[&"staff"]) \ @@ -4679,6 +4710,8 @@ func _initialize_s2_operations() -> void: facility_service.current_minute = _clock.absolute_minute _configure_facility_boundaries() _connect_facility_signals() + kitchen_production = KitchenProductionService.new() + kitchen_production.configure(inventory_service, facility_service, definition_registry, _clock.absolute_minute) func _initialize_s3_actors() -> void: guest_service = GuestService.new() @@ -5354,7 +5387,11 @@ func _execute_dispatch_logistics(command: SimulationCommand) -> CommandResult: var carriers: Variant = command.payload.get(&"carriers", command.payload.get("carriers", null)) if not carriers is Dictionary: return CommandResult.failed(CommandResult.Code.INVALID_PAYLOAD, command.command_id, "carriers must be a Dictionary.") - var assignments := logistics_dispatcher.dispatch(_sorted_logistics_requests(), carriers, _runtime_event_minute()) + var manual_requests: Array[LogisticsRequest] = [] + for request: LogisticsRequest in _sorted_logistics_requests(): + if not String(request.request_id).begins_with(SupplyLogisticsBridge.REQUEST_PREFIX): + manual_requests.append(request) + var assignments := logistics_dispatcher.dispatch(manual_requests, carriers, _runtime_event_minute()) return CommandResult.succeeded(command.command_id, { &"assignments": assignments, &"report": logistics_dispatcher.last_report.duplicate(true), @@ -5362,6 +5399,8 @@ func _execute_dispatch_logistics(command: SimulationCommand) -> CommandResult: func _execute_advance_logistics_request(command: SimulationCommand) -> CommandResult: var request_id := StringName(command.payload.get(&"request_id", command.payload.get("request_id", ""))) + if command.command_id > 0 and String(request_id).begins_with(SupplyLogisticsBridge.REQUEST_PREFIX): + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Supply deliveries advance only after the porter arrives.", {&"reason": &"SUPPLY_DRIVER_REQUIRED"}) if not logistics_requests.has(request_id): return CommandResult.failed(CommandResult.Code.NOT_FOUND, command.command_id, "Logistics request was not found.") var request: LogisticsRequest = logistics_requests[request_id] @@ -6844,6 +6883,9 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: "A Facility assigned to a non-terminal Defense Raid cannot be demolished.", {&"raid_id": defense_owner} ) + var supply_guard: Dictionary = supply_management.validate_demolition(self, instance_id) + if not bool(supply_guard.get(&"ok", false)): + return _staff_assignment_failure(command.command_id, StringName(supply_guard.get(&"reason", &"SUPPLY_IN_USE")), "Facility still owns supply stock, a production plan, or an active delivery.") for assignment: Dictionary in staff_service.get_work_assignments(): if StringName(assignment[&"facility_id"]) == instance_id: return _staff_assignment_failure(command.command_id, &"STAFF_ASSIGNED", "Unassign scheduled Staff before demolishing this facility.") @@ -6905,6 +6947,8 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: return demolished var result := construction_service.demolish_facility(instance_id) result.command_id = command.command_id + if result.is_success(): + kitchen_production.remove_plan(instance_id) return result @@ -7527,11 +7571,14 @@ 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) + supply_logistics.tick(absolute_minute) func _advance_facilities(absolute_minute: int) -> void: if _operations_tick_blocked: return facility_service.advance_one_minute(absolute_minute) + kitchen_production.advance_one_minute(absolute_minute) func _advance_guest_vitals(absolute_minute: int) -> void: if _operations_tick_blocked: @@ -7763,7 +7810,8 @@ func _advance_runtime_path_traffic(absolute_minute: int) -> void: 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: + and task.assigned_staff_id == staff.staff_id \ + and supply_logistics.can_move_task(task.task_id): _advance_staff_task_travel(task, actor_id, absolute_minute) else: _record_expected_actor_wait(actor_id, staff.current_cell, absolute_minute) @@ -8056,13 +8104,14 @@ func _recover_stuck_actor( elif kind == &"staff": var staff := staff_service.get_staff(entity_id) if staff != null and not staff.current_task_id.is_empty(): - var cancelled := staff_service.cancel_task( - staff.current_task_id, - &"PATH_STUCK", - absolute_minute - ) - action_ok = cancelled.is_success() - action = &"STAFF_TASK_CANCELLED" if action_ok else &"STAFF_TASK_CANCEL_FAILED" + if supply_logistics.owns_task(staff.current_task_id): + var recovery: Dictionary = supply_logistics.handle_path_stuck(staff.current_task_id, absolute_minute) + action_ok = bool(recovery.get(&"ok", false)) + action = StringName(recovery.get(&"action", &"SUPPLY_RECOVERY_FAILED")) + else: + var cancelled := staff_service.cancel_task(staff.current_task_id, &"PATH_STUCK", absolute_minute) + action_ok = cancelled.is_success() + action = &"STAFF_TASK_CANCELLED" if action_ok else &"STAFF_TASK_CANCEL_FAILED" if not action_ok: path_service.restore_snapshot(path_before) guest_service.restore_snapshot(guests_before) @@ -11112,3 +11161,19 @@ func _publish_snapshot(_absolute_minute: int) -> void: latest_snapshot = create_snapshot() latest_digest = SimulationDigest.compute(latest_snapshot) snapshot_published.emit(latest_snapshot, latest_digest) + + +func _execute_purchase_supplies(command: SimulationCommand) -> CommandResult: + return supply_management.purchase(self, command) + + +func _execute_set_kitchen_production(command: SimulationCommand) -> CommandResult: + return supply_management.set_production(self, command) + + +func get_supply_management_report() -> Dictionary: + return supply_management.get_report(self) + + +func preview_supply_purchase(warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary: + return supply_management.preview_purchase(self, warehouse_id, item_id, quantity) diff --git a/data/facilities/cafeteria.tres b/data/facilities/cafeteria.tres index 195f4b0..abba935 100644 --- a/data/facilities/cafeteria.tres +++ b/data/facilities/cafeteria.tres @@ -16,7 +16,7 @@ weight_per_cell = 2 maintenance_mana = 2 staff_min = 2 staff_max = 4 -required_roles = Array[StringName]([&"cook", &"server"]) +required_roles = Array[StringName]([&"cook", &"server", &"attendant"]) primary_skill = &"cooking" capacity = 12 queue_capacity = 24 diff --git a/data/facilities/warehouse.tres b/data/facilities/warehouse.tres index 15ccefb..4c158fa 100644 --- a/data/facilities/warehouse.tres +++ b/data/facilities/warehouse.tres @@ -16,7 +16,7 @@ weight_per_cell = 2 maintenance_mana = 0 staff_min = 1 staff_max = 3 -required_roles = Array[StringName]([&"logistics"]) +required_roles = Array[StringName]([&"porter"]) primary_skill = &"operation" capacity = 100 queue_capacity = 4 diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 61bb147..80c5782 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -238,3 +238,12 @@ - 保存互換: StaffService Snapshotをversion 4とし、継続計画を保存する。旧version 3の保存は計画なしで読み込み、既存の人物・単日Shift・給与履歴を保持する。旧保存の最終勤務先を推測して全員へ繰返し予定を付与しない。読み込み後に通常UIで配属を指定した人物から、新しい継続計画を使う。 - 影響: 翌日出勤、再配属、夜勤の日付跨ぎ、未配属、作業中拒否、途中Save復元を受入へ含める。施設別給与は保存した実勤務区間を根拠とし、現在の計画だけから過去分を再分類しない。 - 今回の範囲外: 施設価格、受付の直接収入、宿泊料金の決済・売上記帳タイミングは改定しない。開業ガイドと帳簿は現在のサービス・台帳・06:00決算を読み取って表示する。これらと仕様正本の照合・変更はPLAY-04/PLAY-05で別に判断する。 + +## D-033 P1の厨房供給は既存レシピを維持して通常操作へ接続する + +- 状態: 採用(2026-09-09。供給・製造のP1調整値。3営業日を含む通常プレイの受入は別途検証する) +- 判断: 既存の `ingredient/raw_food` 4個から `ingredient/basic_meal` 4個を15稼働分で作るレシピを維持する。厨房の継続生産計画は客や客財布を要求せず、入力予約、製造進捗、完成時消費と出力を独立して扱う。無人、停電、到達不能、休業時は進捗と予約を保持し、材料の期限切れはバッチ失敗として予約を解放する。出力容量が足りなければ材料を消費せず待つ。 +- 調整値: 食材は1個3G・重量1・期限1,440分、基本料理は重量1・完成後期限720分。保管容量は倉庫100、厨房40、食堂24の重量とし、施設の接客定員とは分ける。厨房への食材補給目標は8個、完成料理の目標は既定8個・上限16個(4個単位)とする。これらは `SupplyCatalog` に明記した暫定値であり、価格バランスの合格を意味しない。 +- 人員データ: 倉庫の必要役割を既存雇用候補の `porter` に合わせる。食堂は既存の `cook`/`server` を維持して `attendant` を追加し、接客係Iris/Junoも配属できるようにする。候補に存在しない役割だけを要求して厨房と食堂の同時営業を阻む状態を解消する。 +- 保存と撤去: 生産計画、進行中バッチ、予約ID、進捗と時刻を保存し、復元済みInventoryの所有者・品目・数量・依頼者・用途・期限と照合する。有効な生産計画または進行中バッチがある厨房は解体対象にできない。停止済みかつバッチがない計画は解体成功後に削除する。 +- 仕様差: 正本の「料理ポイント+食器在庫」、食器・清掃用消耗品の購入と消費、廃棄物回収の通常操作は後続の仕様調整へ残す。今回の基本料理を料理ポイントへ暗黙に読み替えない。食堂の現行15分/35Gと正本45分/25G、受付の直接収入、宿泊決済時刻、倉庫のRank解禁条件も、この供給接続では改定しない。 diff --git a/docs/DEVELOPMENT_BACKLOG.md b/docs/DEVELOPMENT_BACKLOG.md index a6b2e3f..e829ac6 100644 --- a/docs/DEVELOPMENT_BACKLOG.md +++ b/docs/DEVELOPMENT_BACKLOG.md @@ -1,6 +1,6 @@ # 制作課題台帳 -更新日: 2026-09-09。承認済み [全体制作計画](DEVELOPMENT_PLAN.md) の実行台帳。基準はドット断面版コミット `423fd6dd2e75da926e38b35ac8c435e462b168d9`、制作ブランチは `codex/first-operating-loop`。P1の通常開業ガイド・継続配属API/パネル・日次帳簿を接続し、通常初期予算での最小宿を単日受入・専用保存復元で確認した。全自動テストの構成ターゲットは成功終了。補給・厨房・物流を含む3営業日は未合格。部品の接続、単日成立、段階全体の完成を区別する。 +更新日: 2026-09-09。承認済み [全体制作計画](DEVELOPMENT_PLAN.md) の実行台帳。基準はドット断面版コミット `423fd6dd2e75da926e38b35ac8c435e462b168d9`、制作ブランチは `codex/first-operating-loop`。P1の通常開業ガイド・継続配属API/パネル・日次帳簿を接続し、通常初期予算での最小宿を単日受入・専用保存復元で確認した。全自動テストの構成ターゲットは成功終了。その後、有料仕入れ・厨房の自律生産・実配送を通常の商品UIへ接続した。3営業日の最終受入は進行中。部品の接続、単日成立、段階全体の完成を区別する。 ## 状態の読み方 @@ -33,10 +33,10 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` |---|---|---| | PLAY-01 | **最小ループ実装済み・単日確認**。初期Gold 5,000/資材50、通常UIの建築経路で受付1・一般客室2・通路7、アカネ雇用・配属、自然来客の宿泊320G、翌06:00決算まで確認。`test_first_operating_day.gd` | 受入は見学Fixture・無料在庫・客挿入なし。**3営業日未検証/バランス調整残**。受付売上6,020G等の数値を正本と照合し、補給・厨房・配送込みの序盤へ広げる | | PLAY-02 | **通常UI接続済み・個別API103項目/単日確認**。雇用済み一覧、配属・再配属・未配属、DAY/NIGHT継続計画、変更不可・経路・定員等の理由、予約Commandと保存復元。StaffService v4、D-032 | 実通勤と到着後の稼働はMOVE-07へ分離。現在は経路存在を検証して論理配属し、人物位置は変えない。長期の複数施設・人員不足対応は3営業日受入で検証 | -| PLAY-03 | `inventory_service.gd` のStack・予約・期限、`logistics_dispatcher.gd`、Runtimeの配送Command、`test_inventory_logistics.gd` | **画面へ未接続/未検証**。発注費を支払う仕入れから倉庫→厨房→接客施設までを通常操作へ接続。欠品、容量超過、期限切れ、配送失敗を同じ経営ループで再現する | -| PLAY-04 | 厨房・食堂DefinitionとFacility Service/Inventory Callbackが存在。見学は食堂へ `ingredient/basic_meal` を32個支給する | **仕様との相違/画面へ未接続**。食材消費と仕様の料理ポイント/食器を照合する。客の注文に依存しない厨房生産の入口と材料→料理の扱いを決定し、実配送を含む通常運営へ接続する。見学支給は完成の根拠にしない | +| PLAY-03 | **通常UI接続済み**。完成倉庫への有料購入、容量・期限・予約、実porter TaskとPathによる倉庫→厨房→食堂。物流81項目、管理処理84項目、商品UI37項目の回帰 | 仕入れは倉庫への即納。外部業者の納期、継続自動購入、複数品目、正本の食器・消耗品は後続。経路断・満杯・腐敗・担当不能での保持/再試行/返送を検証 | +| PLAY-04 | **自律生産接続済み・140項目確認**。明示設定した厨房で原料4→料理4、15稼働分、目標在庫、入力予約・出力容量・期限・中断復帰を扱う。見学用の無料在庫に依存しない | **仕様差をD-033で明記**。今回の原料/基本料理を維持し、料理ポイント+食器、食堂価格・所要時間との統一は残す | | PLAY-05 | **日次帳簿UI接続済み・単日確認**。06:00営業日、入出金と建築、確定売上・運営費・給与・利益・前日差・未払・施設別損益を表示。翌朝給与120G、未払0、照合一致 | **操作未接続/仕様差・調整残**。価格・営業/休業Policy UI、受付の直接収入、宿泊の決済・記帳時点、維持費分類を正本と照合。受付売上6,020G・全利益6,040Gは製品バランス合格ではない | -| PLAY-06 | **基礎実装済み・単日Save復元確認**。手動1枠と世代保存に加え、単日受入の専用 `user://tests/first_operating_day_/restore-check` から配属・決算・開業達成を復元 | **未検証**。通常3日運営の建設中・配送中・行列中を保存し、再起動後の一致を確認。手動10枠UIは別途制作 | +| PLAY-06 | **Runtime v7と配送途中Save復元を確認**。厨房計画・バッチ、Request・Task・carrier・貨物・予約を照合。旧v6移行20項目、商品UIの待機購入も再読込後に一度だけ確定 | 建設中・行列中・配送中を組み合わせた再起動の人手試遊、手動10枠UIと製品の保存導線は残る | 確認済みの単日経路: 通常初期予算→建設→アカネの日勤配属→自然来客宿泊→翌06:00給与・決算→専用保存復元。`make test-opening` で再現し、`make capture-opening` は `artifacts/first-operating-day/{opening,staffed,first-close}.png` を出力する。建築は現金出金、営業成績は確定損益として分離した。最終UIはPixel238・Dashboard121・Staff44・開業35項目が合格し、既存全自動テストの構成ターゲットも成功終了した。詳細は [P1単日検証記録](qa/P1_FIRST_OPERATING_DAY.md) を参照する。 @@ -62,7 +62,7 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` | LIFE-02 | **試作実装済み/専用素材未制作**。Kenney静止画の足スライス・上下動・左右反転。衣服の継ぎ目修正済み | 専用歩行・作業フレームを共通人物で確認し、縮小、拡大、混雑、職種識別を満たしてから展開 | | LIFE-03 | **一部実装済み**。`pixel_tower_art.gd` の施設別家具、昼夜背景、世界樹の枝葉。現在の対象はMVP16施設 | 種類、営業、混雑、故障を家具と状態で識別できること。計画の施設数はMVP16種へ統一済み | | LIFE-04 | **素材一部導入/音未接続**。Kenney Tiny Town/Tiny Dungeonの公式素材とCC0原文保存 | UI音・環境音をKenney優先で選定し、音量設定と頻発音の重複抑制を確認 | -| UX-01 | **一部実装済み・通常UI接続**。配置・掘削Preview、停止理由、待機・人員、配属・勤務変更、日次帳簿 | 在庫・欠品・接続、価格や営業方針などの対処を通常画面から完結させる | +| UX-01 | **一部実装済み・通常UI接続**。配置・掘削Preview、停止理由、待機・人員、継続配属、日次帳簿、有料仕入れ・厨房設定・配送理由 | 価格・営業方針の設定と、初見プレイヤーが各理由から対処できる導線を仕上げる | | UX-02 | **最小ガイド実装済み・単日確認**。実状態に応じる6段階、通常操作へのボタン、折り畳み、達成履歴・Save復元 | Day 1〜5解禁との整合、スキップ、複数失敗からの回復、初見30分で改善まで到達する試遊は残る | | UX-03 | **一部実装済み/未接続**。ホイール、中ボタン、Home、キーボード開閉、Pixel・カメラ回帰 | 中ボタンなし・トラックパッド代替操作、日本語可読性、拡大時のドット、色以外の状態判別を実入力で確認 | @@ -100,5 +100,5 @@ P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、` 1. 通常UIの最小宿・翌朝決算・専用保存復元は単日確認済み。新しい配属、ガイド、帳簿を含む全体回帰の結果を確認して次の検証済みコミットを記録する。 2. MOVE-01〜03の階間経路と基本昇降機、MOVE-07の実通勤・到着後稼働を統合する。現在の論理配属で人物移動を完成扱いにしない。 -3. PLAY-03〜05の発注・厨房生産・実配送・会計仕様差を解消し、通常3営業日を継続する。建設中・配送中・行列中の保存再開を含める。 -4. 通常開始と保存なし見学の両方で表示・操作を確認し、最小宿の成功をP1全体完成やWindows製品合格としない。 +3. PLAY-03/04の有料発注・厨房生産・実配送は接続済み。PLAY-05の価格・営業Policy・会計仕様を揃え、欠品・人員・経路の改善比較と初見試遊へ進む。 +4. 通常開始と保存なし見学の両方で表示・操作を確認し、最小宿の成功をP1全体完成やWindows製品合格としない。供給機能の最新検証は [供給検証記録](qa/P1_SUPPLY_CHAIN.md) を参照。 diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index fcd6a99..4326cb6 100644 --- a/docs/DEVELOPMENT_PLAN.md +++ b/docs/DEVELOPMENT_PLAN.md @@ -2,7 +2,7 @@ 作成日: 2026-09-09 基準: The Tower SPを参考にしたドット絵の断面経営ゲーム。ローカル制作版を正本とする。 -状態: 2026-09-09 ユーザー承認済み。P0の基準点整備を完了し、P1の通常営業の最小ループを実装・単日確認済み。補給・厨房・物流を含む3営業日は未合格。 +状態: 2026-09-09 ユーザー承認済み。P0の基準点整備を完了し、P1の通常営業の最小ループを実装・単日確認済み。有料仕入れ・厨房・実配送を通常UIへ接続し、部品・UI・配送途中保存の検証は合格。3営業日の最終受入を進行中。 ## 1. 目指す体験と計画の前提 @@ -30,6 +30,8 @@ [IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md)、[PLAYTEST_GUIDE.md](PLAYTEST_GUIDE.md)、[UI_OPERATION_SPECIFICATION.md](UI_OPERATION_SPECIFICATION.md) はP0で現在のドット断面と開閉式UIへ同期した。現在の外観・操作は [PIXEL_TOWER_VISUALS.md](PIXEL_TOWER_VISUALS.md)、復旧方法は [RECOVERY_GUIDE.md](RECOVERY_GUIDE.md)、実装・未接続・仕様差・検証残は [DEVELOPMENT_BACKLOG.md](DEVELOPMENT_BACKLOG.md) に記録する。P1の最小営業は単日受入・専用保存復元を確認した。初期Gold 5,000/資材50から受付1・客室2・通路7・アカネ1人で、自然来客の宿泊売上320G、翌06:00の給与120G、未払0、帳簿照合一致。受付売上6,020G・全体利益6,040Gは価格・会計仕様の調整対象であり、バランス合格や補給・厨房・配送込みの3営業日完成ではない。最終の既存全自動テスト構成ターゲットも成功終了。詳細は [P1単日検証記録](qa/P1_FIRST_OPERATING_DAY.md) を参照。 +追加進捗: 商品の3タブ、有料原料購入、厨房の自主生産、porterの実歩行による搬入・搬出、期限/容量/障害時の貨物保全を接続した。Runtime v7で厨房計画と配送途中の保存を扱い、旧v6も移行する。仕様差と検証範囲は [供給検証記録](qa/P1_SUPPLY_CHAIN.md) を参照。 + ## 3. 段階別の制作計画 | 段階 | 目的・主な成果 | 依存 | 完成条件 | diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 22e771e..b570b88 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -145,7 +145,7 @@ - 掘削モードでは費用、入口接続、支保、Rank/運営gate、進捗を表示し、FRONT/BACK同時解放Commandへ接続する。 - 雇用候補20人の氏名、役割、勤務帯、契約金、日給、4技能を表示。原子的な雇用、未払/破綻gate、二重クリック防止、予約実行結果、保存したCommand履歴との整合を扱う。その後のP1追加で、雇用済み人物の配属・再配属、DAY/NIGHTの継続勤務計画、未配属、反映予定と変更不可理由を通常UIへ接続した。配属先までの経路を検証するが、実通勤して到着してから稼働する経路は未統合。 - 本番運営の明示起動、個人AP、未払給与、前日損益、要確認/破綻、失敗境界の再試行表示、foreground Eventの日本語Choice、下部の手動Save/Loadを接続する。P1追加で6段階の「開業の手引き」と、営業日選択、現金入出金、建築出金、確定損益、給与・未払、施設別損益、照合を示す帳簿を接続した。建築出金と営業利益を分け、内部のcost_of_goodsは雇用契約金等を含むため「運営費」と表示する。 -- 商品・探索・投資等の管理カテゴリは入口または準備中の案内に留まる。すべてのカテゴリを製品機能の完成として数えない。 +- 商品は有料仕入れ・厨房設定・実配送の3タブへ接続済み。探索・投資等の管理カテゴリは入口または準備中の案内に留まる。すべてのカテゴリを製品機能の完成として数えない。 - 従来の大きな生成背景、施設アイコンによるマップ表現、人物Bust Marker、装飾Skinは現マップ表示に使用しない。小型の建築・管理・資源・操作アイコンは `VisualThemeProfile` の既存参照を残す。 - 基準解像度1440×810、HiDPI、`canvas_items`/`expand`/fractional stretchは維持。Pixelの元画像にはMipmapを生成しない。旧UIの解像度別Layout試験と現在のPixel・カメラ試験は、実描画Frame性能とは別に評価する。 @@ -163,7 +163,16 @@ - 受付売上6,020G、全体営業利益6,040Gという現結果はバランス合格ではない。受付の会計上の役割、価格、宿泊料金の決済・売上記帳タイミングは仕様との差を調整する必要がある。 - 配属は繰返し計画をStaffService v4へ保存し、過去の勤務区間を保持してShiftを分割する。旧v3は計画なしで互換復元する。設計判断はD-032を参照。 - `make test-opening` で単日受入、`make capture-opening` で `artifacts/first-operating-day/{opening,staffed,first-close}.png` を生成する。自動受入は通常UI処理を呼び出すもので、初見プレイヤーの試遊合格とは区別する。 -- P1全体は未合格。補給、厨房の自律生産、倉庫からの実配送、価格・営業方針、欠品からの回復を含む3営業日運営は残る。配属は経路存在の事前検証と論理確定であり、人物位置を変えず、職場到着後の稼働はMOVEへ追加する。 +- P1全体は未合格。供給経路は下記へ追加した。価格・営業方針、正本との会計統一、初見プレイヤーの3営業日試遊は残る。配属は経路存在の事前検証と論理確定であり、人物位置を変えず、職場到着後の稼働はMOVEへ追加する。 + +### P1追加:仕入れから食堂まで + +- 商品パネルから完成倉庫へ調理用食材を1個3Gで有料購入し、現物と台帳費用を一括確定する。支払い失敗時は両方を戻す。 +- KitchenProductionService は客の注文なしで原料4→料理4を15稼働分で生産。自動ON/OFF、目標4/8/12/16、予約・期限・容量・職員・魔力・接続の待機を扱う。 +- SupplyLogisticsBridge は勤務中のporterへ実Taskを割り当て、荷元への歩行→積込→納品先への歩行→納品を実行する。満杯・経路断・担当不能では貨物を保持し、部分腐敗時は残った品を実際に荷元へ返す。 +- 配送中の保存、無効参照の拒否、厨房counterの再使用防止を含むRuntime v7を追加。旧v6以下は生産計画なしとして復元し、既存の物流Commandは維持する。供給配送の到着確認は手動advanceで迂回できない。 +- 倉庫の担当職種を実catalogのporterへ補正し、食堂はattendantも配属可能にした。設定値・正本の料理ポイント/食器等との差はD-033へ記録。 +- 部品・UI・通常運営の検証範囲は [供給検証記録](qa/P1_SUPPLY_CHAIN.md)。`make test-supply`、`make capture-supply` で再現する。 ## 統合済みの運営縦断 diff --git a/docs/PLAYTEST_GUIDE.md b/docs/PLAYTEST_GUIDE.md index 40568ab..13c1212 100644 --- a/docs/PLAYTEST_GUIDE.md +++ b/docs/PLAYTEST_GUIDE.md @@ -13,6 +13,9 @@ | 建設済みの宿を見学 | `make preview-pixel` | 受付、食堂、厨房、客室、通路、実際の客・従業員を用意。保存なし | | 時間差の画像を出力 | `make capture-pixel` | `artifacts/pixel-tower/` にPNG 2枚と運営状態JSONを生成 | | 通常開業の単日受入 | `make test-opening` | 見学Fixtureなしで通常UIのCommand経路を使い、初売上・翌06:00・保存復元を検証 | +| 仕入れ・厨房・配送をすぐ試遊 | `make preview-supply` | 初期予算から通常建設・実購入した宿を12:20まで進め、停止して開く。Spaceで再開。保存なし | +| 有料仕入れ・厨房・配送の検証 | `make test-supply` | 通常予算から供給を接続し、配送途中保存と3営業日の運営を検証 | +| 稼働中の商品パネルを撮影 | `make capture-supply` | 通常建設・実購入で用意した初日の仕入れ/厨房/配送をPNG出力 | | 通常開業の画面を出力 | `make capture-opening` | 同じ単日受入を描画し、開始・配属後・決算のPNGを生成 | 見学は実施設7件と通路、従業員4人をRuntimeコマンドで組み立て、食堂へ食材32個を用意した再現場面です。保存・読込・自動保存は無効で、通常の初期状態には反映しません。準備中は少し待ってから操作します。PNGの生成には描画可能なデスクトップ環境が必要です。 @@ -31,6 +34,7 @@ | 地下掘削モード | 下部の「掘削」、または `E` | | 雇用候補を開閉 | 左の「従業員」から「候補を見る」、または `H` | | 雇用済み人物の配属・勤務変更 | 左の「従業員」→「雇用済み」→人物・配属先・日勤/夜勤を選び「配属・勤務帯を変更」 | +| 食材の仕入れ・厨房の自動生産・配送状況 | 左の「商品」→「仕入れ」「厨房」「配送」 | | 日次決算を見る | 左の「帳簿」。確定した営業日を選択可能 | | 開業ガイドを折り畳む | マップ左上の「開業の手引き」の見出しをクリック | | FRONT/BACK切替 | 下部の「客用・表層」「裏方・裏層」、または `Tab` | @@ -120,3 +124,16 @@ Windows書き出しコマンドの存在はWindows実機合格を意味しませ 今回の到達点は、通常UIでの最小宿の単日成立です。仕入れ→倉庫→厨房生産→実配送→消費、価格・営業方針の操作、欠品や人員不足からの回復を含む3営業日はまだ完了していません。昇降機の運行・乗降と、配属先への実通勤も未統合です。 全Actorの14日実Tick、初見試遊、Windows実機での100客+30従業員描画、保存枠・設定・専用アニメーション・音・本文も別途必要です。現在の単日受入や画像出力から製品完成を判定せず、[課題台帳](DEVELOPMENT_BACKLOG.md) の完成条件で進めます。 + +## 食材を仕入れて料理を提供する + +初期の資材50で倉庫・厨房・食堂も建てる場合、客室はまず1室にします。受付1・客室1・食堂1・倉庫1・厨房1・職員ドア1で資材46を使用します。2室の開業例にそのまま追加すると資材が1不足します。 + +1. FRONTに受付・食堂、BACKに倉庫・厨房を建て、地表の職員ドアでFRONT/BACKをつなぎます。具体的な配置と職員5人の組合せは [供給検証記録](qa/P1_SUPPLY_CHAIN.md) にあります。 +2. 完工後、調理人を厨房、接客係2人を食堂、運搬係を倉庫へ配属します。倉庫の仕入れ自体は職員なしでも可能ですが、運ぶには勤務中の運搬係と通行可能な経路が必要です。 +3. 左の「商品」→「仕入れ」で倉庫と食材数量を選びます。まず40個なら120Gです。代金と残金を確認し「購入」を押します。 +4. 「厨房」で厨房を選び、自動生産を有効にし、目標を8個にして保存します。4個の食材を使う15分の調理が始まり、食堂の在庫が少なくなると運搬係が納品します。 +5. 「配送」で担当者・荷元・納品先・待機理由を確認します。材料不足なら仕入れ、人員不足なら勤務・配属、経路不足なら職員ドアと通路を確認します。積み込んだ荷物は、経路が修復されるまで担当者が保持します。 +6. 食材は24時間、料理は12時間で期限切れになるため、翌営業日にも必要量を仕入れます。仕入れ費用・給与・食堂売上は「帳簿」に反映されます。 + +厨房の自動生産を停止すると追加の原料補給と調理が止まります。予約済みの配送と、すでに作った料理の食堂への出庫は続きます。在庫・運搬中の荷物・有効な生産計画が残る施設は解体できません。 diff --git a/docs/UI_OPERATION_SPECIFICATION.md b/docs/UI_OPERATION_SPECIFICATION.md index d2bf776..b7ef7aa 100644 --- a/docs/UI_OPERATION_SPECIFICATION.md +++ b/docs/UI_OPERATION_SPECIFICATION.md @@ -110,3 +110,13 @@ Runtimeでは00:00の暦日更新と、06:00の前営業日締め・朝運用を 旧V3モック `assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png` と旧画像制作仕様書は過去の資料として保存する。現在の画面を旧モックの装飾や常設パネルへ戻す基準にはしない。画像内の数値・施設寸法ではなく、正式DefinitionとWorldServiceの占有を正とする。 確認手順は [PLAYTEST_GUIDE.md](PLAYTEST_GUIDE.md)、実装と未接続の区分は [DEVELOPMENT_BACKLOG.md](DEVELOPMENT_BACKLOG.md) を参照する。 + +## 10. 商品・厨房・配送 + +左の「商品」は右パネルを開き、仕入れ/厨房/配送の3タブを切り替える。完成した倉庫・厨房を選び、仕入れは合計代金と購入後残金、厨房は現物在庫・生産状態・目標4/8/12/16を表示する。入力途中の数量と厨房別の設定は表示更新やタブ変更で失わない。 + +仕入れ・設定は正式Commandを1件だけ送り、待機中の同じ操作を無効にする。処理結果は固定位置の日本語ラベルに表示し、失敗時は入力を保持する。Save復元時もCommand Queueを参照し、二重購入を防ぐ。 + +配送は施設名、品目数量、担当者、状態、待機理由を表示する。現在の配送を優先し、完了履歴は直近10件まで表示する。食堂未建築でも厨房への配送は表示する。帳簿・従業員等と同時に常設せず、Escで閉じて断面へ戻る。 + +実装: `scripts/ui/supply_management_panel.gd`。検証: `tests/ui/test_supply_management.gd`。詳細と画面は [供給検証記録](qa/P1_SUPPLY_CHAIN.md) を参照。 diff --git a/docs/qa/P1_SUPPLY_CHAIN.md b/docs/qa/P1_SUPPLY_CHAIN.md new file mode 100644 index 0000000..a728435 --- /dev/null +++ b/docs/qa/P1_SUPPLY_CHAIN.md @@ -0,0 +1,57 @@ +# P1 有料仕入れ・厨房・実配送 + +更新日: 2026-09-09。承認済み計画 PLAY-03/04/06 の供給経路を統合した記録。 + +## 接続した挙動 + +- 左の「商品」から「仕入れ」「厨房」「配送」を操作する。既存のKenney素材・クリーム色の管理パネルと断面表示を維持する。 +- 完成倉庫に調理用食材を1個3Gで購入する。確定前に代金・購入後残金・保管上限を確認し、実在庫追加と台帳への費用記帳を同時に確定する。 +- 厨房の自動生産を有効にすると、倉庫から原料を補給し、勤務中の調理人が4個の原料から15分で4個の基本料理を作る。来客の注文を生産開始の条件にしない。 +- 運搬係の勤務・Task・Pathを使って、荷元へ歩く→積み込む→納品先へ歩く→納品する。運搬中の人物は既存の荷物表示と連動する。 +- 食堂の実サービスが納品された料理を消費し、売上を計上する。厨房停止後も、すでに作った料理の出庫は続けられる。 +- 食材は24時間、料理は12時間で期限切れになる。予約中の期限切れ、容量不足、経路断、人員不在は実在庫を保持した待機・再試行・廃棄へ接続する。積載中の経路断では荷物を持つ担当者を解除しない。 +- Runtime保存形式はv7。厨房計画・進行中バッチを保存し、物流・人物・予約・貨物の参照を照合する。v6以前は厨房計画なしとして移行する。 + +供給値の採用理由と正本との差は [D-033](../DESIGN_DECISIONS.md) を参照する。 + +## 再現方法 + +```sh +make test-supply +make test-ui +make capture-supply +make preview-supply +``` + +`test_normal_supply_chain.gd` は通常Mainを起動し、初期5,000G・資材50から建設、採用、配属、購入の正式Commandを使う。人工の客・完成施設・在庫・無料資源を挿入しない。画面の購入・設定・Save復元は別の `test_supply_management.gd` で実Button/入力を操作する。 + +供給テストの配置はFRONTに受付 `(-8,0)`、食堂 `(2,0)`、客室 `(-8,2)`。BACKに倉庫 `(-8,0)`、厨房 `(2,0)`、職員ドア `(0,0)`。客室への通路はFRONT `(-4,1)` → `(-4,2)` → `(-5,2)`・`(-6,2)`。完成に必要な実時間を進めてから配属する。 + +アカネを受付、ジーナを倉庫、チカを厨房、イリスとユノを食堂へ日勤配属する。原料40個を購入し、厨房の目標在庫を8個に設定する。次の2営業日の06:00にも40個ずつ購入する。採用時の契約金と日々の給与も通常会計へ含める。 + +保存先はテスト専用 `user://tests/normal_supply_/cargo`、その他の各テストもプロセス別の専用保存を使う。通常の手動保存や自動保存へ書き込まない。 + +`preview-supply` は初日の12:20で停止した試遊を開く。Spaceで再開し、商品・勤務・建設を試せる。手動保存・読込と自動保存は無効。 + +画像は `artifacts/supply-chain/{purchase,kitchen,delivery}.png`、3日運営の数値は `artifacts/supply-chain/normal-play-report.json`。`capture-supply` は初日の稼働場面を撮影して終了するため、3日間の受入そのものは `test-supply` の結果を見る。 + +## 検証結果 + +| 対象 | 確認結果 | +|---|---| +| 厨房の原料・生産・中断・期限・容量・復元 | 140項目 PASS | +| Runtime v7/旧v6の移行・偽装旧保存拒否 | 20項目 PASS | +| 実物流・途中保存・障害復旧・外部Command境界 | 81項目 PASS | +| 有料購入・決済失敗・停止後出庫・返送容量・複数倉庫 | 84項目 PASS | +| 商品UI・下書き・重複防止・待機購入Save復元 | 37項目 PASS、1280×720も実描画確認 | +| 通常初期予算→実供給→食堂売上→配送途中保存 | 初日の35項目 PASS、1440×810の3タブを目視確認 | +| 通常3営業日の締め・再仕入れ・最終保存 | 最終受入を実行中 | + +既存回帰は構成ターゲットを分割して実行した。`test-s0/s1/s2/s3/s6/mvp` の38本・3,389項目が成功終了し、`check`・`test-s4`・`test-s5`(旧保存fixture補正後のDefense再確認を含む)・`test-acceptance`・`test-ui`・`test-perf` も完了した。性能のsmokeは部品を直接合成する検証で、通常シーン全体の描画性能を示さない。 + +## 残る課題 + +- 正本の料理ポイント・食器・施設解禁との統一、仕入れ設定の継続自動発注、価格・サービス会計の調整は残る。 +- 建設済みの倉庫へ仕入れ品を直接受け取る方式で、外部業者の車両・納期はまだ扱わない。 +- 職員ドアを使う実物流は接続したが、一般職員の出勤・職場到着後稼働、昇降機の実乗降はP2の別項目である。 +- この検証だけで初見30分の遊びやすさ、経済バランス、Windows実機性能、P1全体の完成を判定しない。 diff --git a/scenes/main/main.gd b/scenes/main/main.gd index b29492f..8491110 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -3,6 +3,7 @@ extends Control 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 WorldPreviewScript := preload("res://scenes/main/world_preview.gd") const DefaultVisualTheme := preload("res://assets/themes/japanese_fantasy/visual-theme-v1.tres") const MANUAL_SAVE_PATH := "user://saves/manual_ui" @@ -128,6 +129,8 @@ var build_buttons: Dictionary = {} var navigation_buttons: Dictionary = {} var opening_guide: Control var operating_ledger_panel: VBoxContainer +var supply_management_panel: VBoxContainer +var _supply_panel_open := false var operations_controls: VBoxContainer var _ledger_panel_open := false var management_panel: Control @@ -531,6 +534,12 @@ func _create_side_panel() -> Control: root_column.add_child(operating_ledger_panel) operating_ledger_panel.setup(_runtime, GameClock) operating_ledger_panel.visible = false + supply_management_panel = SupplyManagementPanelScript.new() + supply_management_panel.configure(_runtime, _apply_button_style) + supply_management_panel.status_requested.connect(_on_staff_management_status) + supply_management_panel.action_requested.connect(_activate_management_mode) + supply_management_panel.visible = false + root_column.add_child(supply_management_panel) return panel @@ -630,10 +639,12 @@ func _set_management_panel_open(open: bool) -> void: _set_staff_candidate_panel_open(false) _set_staff_management_panel_open(false) _set_ledger_panel_open(false) + _set_supply_panel_open(false) func _activate_management_mode(mode: StringName) -> void: _set_management_panel_open(mode != &"facility") + _set_supply_panel_open(mode == &"product") _set_ledger_panel_open(mode == &"ledger") for raw_mode: Variant in navigation_buttons: (navigation_buttons[raw_mode] as Button).set_pressed_no_signal(StringName(raw_mode) == mode) @@ -645,7 +656,7 @@ func _activate_management_mode(mode: StringName) -> void: _set_staff_candidate_panel_open(false) _set_status("建築する施設を下のパレットから選んでください。", true, true) &"product": - _set_status("商品管理は一覧画面の実装準備中です。", true, true) + _set_status("倉庫への仕入れ、厨房の自動生産、施設への配送を確認できます。", true, true) &"explore": _set_status("探索管理は一覧画面の実装準備中です。", true, true) &"investment": @@ -656,19 +667,37 @@ func _activate_management_mode(mode: StringName) -> void: func _set_ledger_panel_open(open: bool) -> void: _ledger_panel_open = open + if open: + _set_supply_panel_open(false) if operating_ledger_panel != null: operating_ledger_panel.visible = open if open: operating_ledger_panel.refresh() if operations_controls != null: - operations_controls.visible = not open + operations_controls.visible = not open and not _supply_panel_open if dashboard_summary_label != null: - dashboard_summary_label.visible = not open + dashboard_summary_label.visible = not open and not _supply_panel_open if open: _set_staff_candidate_panel_open(false) _set_staff_management_panel_open(false) +func _set_supply_panel_open(open: bool) -> void: + _supply_panel_open = open + if open: + _set_ledger_panel_open(false) + _set_staff_candidate_panel_open(false) + _set_staff_management_panel_open(false) + if supply_management_panel != null: + supply_management_panel.visible = open + if open: + supply_management_panel.refresh() + if operations_controls != null: + operations_controls.visible = not open and not _ledger_panel_open + if dashboard_summary_label != null: + dashboard_summary_label.visible = not open and not _ledger_panel_open + + func _on_opening_action(action: StringName) -> void: match action: &"build_reception", &"build_room", &"build_corridor": @@ -821,6 +850,7 @@ func _set_staff_management_panel_open(open: bool) -> void: staff_management_toggle_button.set_pressed_no_signal(open) if open: _set_ledger_panel_open(false) + _set_supply_panel_open(false) _set_staff_candidate_panel_open(false) _set_management_panel_open(true) staff_management_panel.refresh() @@ -842,6 +872,7 @@ func _set_staff_candidate_panel_open(open: bool) -> void: _refresh_staff_candidates() if open: _set_ledger_panel_open(false) + _set_supply_panel_open(false) _set_staff_management_panel_open(false) _set_management_panel_open(true) _set_status("雇用候補を選んでください。契約金と日給は確定前に表示されます。", true, true) @@ -1001,6 +1032,9 @@ func _clear_build_selection(announce := true) -> void: func _clear_active_tool() -> void: + if _supply_panel_open: + _set_management_panel_open(false) + return if _staff_management_panel_open: _set_staff_management_panel_open(false) return @@ -1193,6 +1227,7 @@ func _excavation_idempotency_key(cell: Vector3i) -> StringName: func _on_facility_selected(instance_id: StringName) -> void: _set_ledger_panel_open(false) + _set_supply_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = instance_id @@ -1202,6 +1237,7 @@ func _on_facility_selected(instance_id: StringName) -> void: func _on_construction_selected(job_id: StringName) -> void: _set_ledger_panel_open(false) + _set_supply_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = job_id @@ -1301,6 +1337,7 @@ func _refresh_foreground_event(event_service: Variant) -> void: return foreground_event_panel.visible = true _set_ledger_panel_open(false) + _set_supply_panel_open(false) _set_management_panel_open(true) var actors: Array[String] = [] for actor_value: Variant in foreground.actor_ids: @@ -1695,6 +1732,8 @@ func _load_manual() -> void: _synchronize_ui_command_state() if staff_management_panel != null: staff_management_panel.refresh() + if supply_management_panel != null: + supply_management_panel.refresh() _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" diff --git a/scripts/inventory/inventory_service.gd b/scripts/inventory/inventory_service.gd index b3ab5c9..8831816 100644 --- a/scripts/inventory/inventory_service.gd +++ b/scripts/inventory/inventory_service.gd @@ -711,7 +711,11 @@ func get_stack(stack_id: StringName) -> Dictionary: func get_stacks(owner_inventory_id: StringName = &"") -> Array[Dictionary]: var result: Array[Dictionary] = [] - var owners := _sorted_names(_inventories.keys()) if owner_inventory_id == &"" else [owner_inventory_id] + var owners: Array[StringName] = [] + if owner_inventory_id == &"": + owners = _sorted_names(_inventories.keys()) + else: + owners.append(owner_inventory_id) for owner: StringName in owners: if not _inventories.has(owner): continue diff --git a/scripts/staff/staff_service.gd b/scripts/staff/staff_service.gd index 47235e2..50e6dbe 100644 --- a/scripts/staff/staff_service.gd +++ b/scripts/staff/staff_service.gd @@ -48,6 +48,7 @@ var _dispatcher := TaskDispatcherType.new() var _item_reserver := Callable() var _item_releaser := Callable() var _travel_estimator := Callable() +var _external_task_driver := Callable() func configure_callbacks( @@ -62,6 +63,49 @@ func configure_callbacks( return self +## External operations own their work-completion transaction while retaining +## ordinary Staff movement, shifts, and bidirectional Task ownership. +func configure_external_task_driver(driver: Callable = Callable()) -> void: + _external_task_driver = driver + + +func is_externally_driven_task(task_id: StringName) -> bool: + return _external_task_driver.is_valid() and bool(_external_task_driver.call(task_id)) + + +func assign_task_to_staff(task_id: StringName, staff_id: StringName, absolute_minute: int) -> CommandResult: + var task := get_task(task_id) + var staff := get_staff(staff_id) + if task == null or staff == null or not task.is_dispatchable(): + return _conflict("Task or employee is unavailable for assignment.") + if not task.required_items.is_empty() or not task.reservation_ids.is_empty(): + return _conflict("Explicit assignment requires externally reserved operation inputs.") + if not staff.is_available_for_dispatch() or not staff.meets_task_requirements(task): + return _conflict("Employee is busy or does not meet the Task requirements.") + if not task.reserve(staff_id, [], absolute_minute): + return _conflict("Task could not reserve its employee.") + if not staff.reserve_task(task_id, absolute_minute): + task.reopen(&"ASSIGNMENT_ROLLED_BACK", absolute_minute, true) + return _conflict("Employee could not reserve its Task.") + task_assigned.emit(task_id, staff_id) + return _success({&"task_id": task_id, &"staff_id": staff_id}) + + +func retarget_external_task(task_id: StringName, target_cell: Vector3i, absolute_minute: int) -> CommandResult: + var task := get_task(task_id) + if task == null or not is_externally_driven_task(task_id) or task.is_terminal(): + return _conflict("Only an active externally driven Task can be retargeted.") + var staff := get_staff(task.assigned_staff_id) + if staff == null or staff.current_task_id != task_id: + return _conflict("Task and employee ownership differ.") + task.target_cell = target_cell + task.state = StaffTaskType.State.MOVING + task.progress_minutes = 0 + task.state_changed_at = absolute_minute + staff.transition_to(StaffRuntimeType.State.MOVING_TO_TASK, &"TASK_RETARGETED", absolute_minute) + return _success({&"task_id": task_id}) + + func register_staff(value: Variant) -> CommandResult: if value == null or not value is StaffRuntime: return _invalid("Staff runtime is missing or has an invalid type.") @@ -678,8 +722,11 @@ func begin_task(task_id: StringName, absolute_minute: int = -1) -> CommandResult func progress_task( task_id: StringName, minutes: int = 1, - absolute_minute: int = -1 + absolute_minute: int = -1, + externally_driven := false ) -> CommandResult: + if is_externally_driven_task(task_id) and not externally_driven: + return _conflict("Task completion belongs to its external operation.") var task := get_task(task_id) if task == null: return _not_found("Task", task_id) @@ -755,6 +802,8 @@ func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]: &"action": &"BEGAN_WORK", }) StaffTaskType.State.WORKING: + if is_externally_driven_task(task_id): + continue var progress_result := progress_task(task.task_id, 1, absolute_minute) if progress_result.is_success(): actions.append({ @@ -773,8 +822,11 @@ func _advance_assigned_tasks(absolute_minute: int) -> Array[Dictionary]: func cancel_task( task_id: StringName, reason: StringName = &"CANCELLED", - absolute_minute: int = -1 + absolute_minute: int = -1, + externally_driven := false ) -> CommandResult: + if is_externally_driven_task(task_id) and not externally_driven: + return _conflict("External operation must release its cargo before cancellation.") var task := get_task(task_id) if task == null: return _not_found("Task", task_id) @@ -801,6 +853,8 @@ func remove_staff( var staff := get_staff(staff_id) if staff == null: return _not_found("Staff", staff_id) + 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 if not staff.current_task_id.is_empty(): var task := get_task(staff.current_task_id) @@ -842,6 +896,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 is_externally_driven_task(staff.current_task_id): + return _conflict("Finish the employee external operation before retirement.") staff.is_retiring = true return remove_staff(staff_id, &"RETIRED", true, absolute_minute) diff --git a/scripts/supply/kitchen_production_service.gd b/scripts/supply/kitchen_production_service.gd new file mode 100644 index 0000000..f524109 --- /dev/null +++ b/scripts/supply/kitchen_production_service.gd @@ -0,0 +1,416 @@ +class_name KitchenProductionService +extends RefCounted + +const Catalog = preload("res://scripts/supply/supply_catalog.gd") +const SNAPSHOT_VERSION := 1 + +var current_minute := 0 +var last_error := "" +var last_restore_error := "" + +var _inventory: InventoryService +var _facilities: FacilityService +var _registry: DefinitionRegistry +var _plans: Dictionary = {} +var _active_batches: Dictionary = {} +var _next_batch_sequence := 1 + + +func configure( + inventory: InventoryService, + facilities: FacilityService, + registry: DefinitionRegistry, + minute: int +) -> void: + _inventory = inventory + _facilities = facilities + _registry = registry + current_minute = maxi(minute, 0) + + +func set_plan(facility_id: StringName, enabled: bool, target_stock: int = Catalog.KITCHEN_TARGET_DEFAULT) -> Dictionary: + if _kitchen(facility_id) == null: + return _failure(&"INVALID_FACILITY", "Production plans require an existing kitchen.") + if not _valid_target(target_stock): + return _failure(&"INVALID_TARGET", "Target must be 4, 8, 12, or 16 meals.") + _plans[facility_id] = { + &"facility_id": String(facility_id), &"enabled": enabled, &"target_stock": target_stock, + } + last_error = "" + return {&"ok": true, &"plan": get_plan(facility_id)} + + +func get_plan(facility_id: StringName) -> Dictionary: + if _plans.has(facility_id): + return (_plans[facility_id] as Dictionary).duplicate(true) + if _kitchen(facility_id) == null: + return {} + return {&"facility_id": String(facility_id), &"enabled": false, &"target_stock": Catalog.KITCHEN_TARGET_DEFAULT} + + +func get_plans() -> Array[Dictionary]: + var result: Array[Dictionary] = [] + for facility_id: StringName in _sorted_ids(_plans): + result.append(get_plan(facility_id)) + return result + + +## Also works after a successful facility removal, when the instance no longer +## exists. Runtime checks enabled plans and batches before authorizing removal. +func remove_plan(facility_id: StringName) -> Dictionary: + if _active_batches.has(facility_id): + return {&"ok": false, &"reason": &"ACTIVE_BATCH"} + if _plans.has(facility_id) and bool(_plans[facility_id][&"enabled"]): + return {&"ok": false, &"reason": &"PLAN_ENABLED"} + var existed := _plans.erase(facility_id) + return {&"ok": true, &"reason": &"REMOVED" if existed else &"NO_PLAN"} + + +func get_report(facility_id: StringName) -> Dictionary: + var facility := _kitchen(facility_id) + if facility == null: + return {} + var plan := get_plan(facility_id) + var batch: Dictionary = _active_batches.get(facility_id, {}) + var state := _production_state(facility, plan, batch, current_minute) + return { + &"facility_id": String(facility_id), &"inventory_id": String(facility.local_inventory_id), + &"enabled": plan[&"enabled"], &"target_stock": plan[&"target_stock"], + &"state": state, &"status": state, + &"raw_stock": _inventory.available_quantity(facility.local_inventory_id, Catalog.RAW_FOOD, current_minute), + &"meal_stock": _usable_quantity(facility.local_inventory_id, Catalog.BASIC_MEAL, current_minute), + &"progress_minutes": int(batch.get(&"progress_minutes", 0)), + &"duration_minutes": Catalog.PRODUCTION_MINUTES, + &"active_batch": batch.duplicate(true), + } + + +## Runtime calls this after projecting current staffing/power/access. A batch +## started at t first earns work at t+1; interruptions retain earned work. +func advance_one_minute(minute: int) -> Dictionary: + if not _configured(): + return _failure(&"NOT_CONFIGURED", "Inventory, facilities, and registry must be configured.") + if minute == current_minute: + return {&"ok": true, &"replay": true, &"events": []} + if minute != current_minute + 1: + return _failure(&"INVALID_MINUTE", "Production must advance one minute at a time.") + current_minute = minute + var events: Array[Dictionary] = [] + for facility_id: StringName in _sorted_ids(_plans): + var facility := _kitchen(facility_id) + var batch: Dictionary = _active_batches.get(facility_id, {}) + if facility == null: + if not batch.is_empty(): + _release_batch(batch) + _active_batches.erase(facility_id) + events.append(_event(&"BATCH_FAILED", facility_id, batch, &"FACILITY_REMOVED")) + _plans.erase(facility_id) + continue + # Expiry also closes reservations while a plan or facility is paused. + if not batch.is_empty() and not _batch_links_valid(batch, minute): + _release_batch(batch) + _active_batches.erase(facility_id) + events.append(_event(&"BATCH_FAILED", facility_id, batch, &"INPUT_UNAVAILABLE")) + continue + var plan: Dictionary = _plans[facility_id] + var state := _production_state(facility, plan, batch, minute) + if batch.is_empty(): + if state == &"READY": + var result := _start_batch(facility, minute) + if bool(result[&"ok"]): + events.append(_event(&"BATCH_STARTED", facility_id, _active_batches[facility_id])) + continue + if state != &"PRODUCING": + continue + batch[&"progress_minutes"] = mini(int(batch[&"progress_minutes"]) + 1, Catalog.PRODUCTION_MINUTES) + if int(batch[&"progress_minutes"]) == Catalog.PRODUCTION_MINUTES: + var result := _complete_batch(batch, minute) + if bool(result[&"ok"]): + _active_batches.erase(facility_id) + events.append(_event(&"BATCH_COMPLETED", facility_id, batch)) + else: + # Atomic inventory failure keeps the reservation and full progress. + events.append(_event(&"BATCH_WAITING", facility_id, batch, result.get(&"code", &"INVENTORY_ERROR"))) + last_error = "" + return {&"ok": true, &"events": events} + + +func create_snapshot() -> Dictionary: + var batches: Array[Dictionary] = [] + for facility_id: StringName in _sorted_ids(_active_batches): + batches.append((_active_batches[facility_id] as Dictionary).duplicate(true)) + return { + &"version": SNAPSHOT_VERSION, &"current_minute": current_minute, + &"next_batch_sequence": _next_batch_sequence, + &"plans": get_plans(), &"active_batches": batches, + } + + +## Restore is transactional and read-only with respect to inventory. Inventory +## and FacilityService must already contain their restored state. +func restore_snapshot(snapshot: Dictionary) -> bool: + last_restore_error = "" + if not _configured() or not _keys_match(snapshot, [&"version", &"current_minute", &"next_batch_sequence", &"plans", &"active_batches"]): + return _restore_failure("Missing dependencies or invalid snapshot fields.") + if not _integer(snapshot[&"version"], SNAPSHOT_VERSION, SNAPSHOT_VERSION) \ + or not _integer(snapshot[&"current_minute"], 0) \ + or not _integer(snapshot[&"next_batch_sequence"], 1) \ + or not snapshot[&"plans"] is Array or not snapshot[&"active_batches"] is Array: + return _restore_failure("Invalid snapshot version, clock, sequence, or collections.") + var candidate_plans: Dictionary = {} + for value: Variant in snapshot[&"plans"]: + if not value is Dictionary or not _keys_match(value, [&"facility_id", &"enabled", &"target_stock"]): + return _restore_failure("Invalid production plan fields.") + var plan: Dictionary = value + if not _text(plan[&"facility_id"]) or not plan[&"enabled"] is bool \ + or not _integer(plan[&"target_stock"], 4, Catalog.KITCHEN_TARGET_MAX) \ + or not _valid_target(int(plan[&"target_stock"])): + return _restore_failure("Invalid production plan values.") + var facility_id := StringName(plan[&"facility_id"]) + if candidate_plans.has(facility_id) or _kitchen(facility_id) == null: + return _restore_failure("Duplicate plan or unknown kitchen.") + candidate_plans[facility_id] = plan.duplicate(true) + var candidate_batches: Dictionary = {} + var seen_batches: Dictionary = {} + for value: Variant in snapshot[&"active_batches"]: + if not value is Dictionary or not _valid_batch_shape(value, int(snapshot[&"current_minute"]), int(snapshot[&"next_batch_sequence"])): + return _restore_failure("Invalid active batch.") + var batch: Dictionary = value + var facility_id := StringName(batch[&"facility_id"]) + if not candidate_plans.has(facility_id) or candidate_batches.has(facility_id) or seen_batches.has(batch[&"batch_id"]): + return _restore_failure("Duplicate batch or missing production plan.") + seen_batches[batch[&"batch_id"]] = true + candidate_batches[facility_id] = batch.duplicate(true) + if not _crosslinks_valid(candidate_batches, int(snapshot[&"current_minute"]), int(snapshot[&"next_batch_sequence"])): + return _restore_failure("Kitchen input reservations do not match the restored inventory.") + _plans = candidate_plans + _active_batches = candidate_batches + _next_batch_sequence = int(snapshot[&"next_batch_sequence"]) + current_minute = int(snapshot[&"current_minute"]) + last_error = "" + return true + + +func validate_crosslinks() -> bool: + return _configured() and _crosslinks_valid(_active_batches, current_minute, _next_batch_sequence) + + +func _start_batch(facility: FacilityInstance, minute: int) -> Dictionary: + var batch_id := StringName("kitchen.batch:%08d" % _next_batch_sequence) + var reserved := _inventory.reserve_item( + facility.local_inventory_id, Catalog.RAW_FOOD, Catalog.BATCH_INPUT, + batch_id, Catalog.PRODUCTION_PURPOSE, minute + ) + if not bool(reserved[&"ok"]): + return reserved + _next_batch_sequence += 1 + var reservation_ids: Array[String] = [] + for reservation_id: StringName in reserved[&"reservation_ids"]: + reservation_ids.append(String(reservation_id)) + var definition := _registry.get_facility(facility.definition_id) + _active_batches[facility.instance_id] = { + &"batch_id": String(batch_id), &"facility_id": String(facility.instance_id), + &"inventory_id": String(facility.local_inventory_id), &"started_at": minute, + &"progress_minutes": 0, &"quality": clampi(definition.base_quality, 0, 100), + &"reservation_ids": reservation_ids, + } + return {&"ok": true} + + +func _complete_batch(batch: Dictionary, minute: int) -> Dictionary: + if not _has_output_space(batch): + return {&"ok": false, &"code": &"OUTPUT_FULL"} + var before := _inventory.create_snapshot() + var consumed := _inventory.consume_reservations(_reservation_ids(batch), &"", minute) + if not bool(consumed[&"ok"]): + return consumed + var item := Catalog.get_item(Catalog.BASIC_MEAL) + var produced := _inventory.add_stack( + StringName(batch[&"inventory_id"]), Catalog.BASIC_MEAL, Catalog.BATCH_OUTPUT, + int(batch[&"quality"]), minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"]) + ) + if not bool(produced[&"ok"]): + _inventory.restore_snapshot(before) + return produced + + +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.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" + if not facility.operator_open or not facility.is_within_opening_hours(minute): return &"CLOSED" + if not batch.is_empty(): + if not _batch_links_valid(batch, minute): return &"INPUT_UNAVAILABLE" + if int(batch[&"progress_minutes"]) >= Catalog.PRODUCTION_MINUTES and not _has_output_space(batch): + return &"OUTPUT_FULL" + return &"PRODUCING" + if _usable_quantity(facility.local_inventory_id, Catalog.BASIC_MEAL, minute) + Catalog.BATCH_OUTPUT > int(plan[&"target_stock"]): + return &"TARGET_REACHED" + if _inventory.available_quantity(facility.local_inventory_id, Catalog.RAW_FOOD, minute) < Catalog.BATCH_INPUT: + return &"NO_INPUT" + return &"READY" + + +func _has_output_space(batch: Dictionary) -> bool: + var consumed_weight := 0 + for reservation_id: StringName in _reservation_ids(batch): + var reservation := _inventory.get_reservation(reservation_id) + if reservation == null: return false + var stack := _inventory.get_stack(reservation.stack_id) + if stack.is_empty(): return false + consumed_weight += reservation.quantity * int(stack[&"unit_weight"]) + var after_weight := Catalog.inventory_weight(_inventory, StringName(batch[&"inventory_id"])) - consumed_weight + Catalog.BATCH_OUTPUT + return after_weight <= Catalog.storage_capacity(&"kitchen") + + +func _batch_links_valid(batch: Dictionary, minute: int) -> bool: + var facility := _kitchen(StringName(batch[&"facility_id"])) + if facility == null or facility.local_inventory_id != StringName(batch[&"inventory_id"]): return false + var quantity := 0 + var allocated_by_stack: Dictionary = {} + for reservation_id: StringName in _reservation_ids(batch): + var reservation := _inventory.get_reservation(reservation_id) + if reservation == null or not reservation.is_active() or reservation.is_expired(minute) or reservation.quantity <= 0: return false + if reservation.owner_inventory_id != facility.local_inventory_id \ + or reservation.item_id != Catalog.RAW_FOOD or reservation.requester_id != StringName(batch[&"batch_id"]) \ + or reservation.purpose != Catalog.PRODUCTION_PURPOSE or reservation.created_at != int(batch[&"started_at"]): return false + var stack := _inventory.get_stack(reservation.stack_id) + if stack.is_empty() or StringName(stack[&"owner_inventory_id"]) != facility.local_inventory_id \ + or StringName(stack[&"item_id"]) != Catalog.RAW_FOOD \ + or int(stack[&"reserved_quantity"]) < reservation.quantity or int(stack[&"quantity"]) < reservation.quantity: return false + if int(stack[&"expires_at"]) >= 0 and int(stack[&"expires_at"]) <= minute: return false + allocated_by_stack[reservation.stack_id] = int(allocated_by_stack.get(reservation.stack_id, 0)) + reservation.quantity + if int(allocated_by_stack[reservation.stack_id]) > int(stack[&"reserved_quantity"]): return false + quantity += reservation.quantity + return quantity == Catalog.BATCH_INPUT + + +func _crosslinks_valid(batches: Dictionary, minute: int, next_sequence: int) -> bool: + var linked: Dictionary = {} + for batch: Dictionary in batches.values(): + if not _batch_links_valid(batch, minute): return false + for reservation_id: StringName in _reservation_ids(batch): + if linked.has(reservation_id): return false + linked[reservation_id] = true + # Inventory retains terminal reservations. Their requester IDs are durable + # evidence that a batch number was already used, even after completion, + # failure, or removal of the kitchen's plan. + for reservation: InventoryReservation in _inventory.get_reservations(): + if reservation.purpose != Catalog.PRODUCTION_PURPOSE: + continue + var sequence := _batch_sequence(reservation.requester_id) + if sequence < 1 or sequence >= next_sequence: + return false + if reservation.is_active() and not linked.has(reservation.reservation_id): + return false + return true + + +func _release_batch(batch: Dictionary) -> void: + for reservation_id: StringName in _reservation_ids(batch): + var reservation := _inventory.get_reservation(reservation_id) + # Only release this batch's own holds if external state was corrupted. + if reservation != null and reservation.requester_id == StringName(batch[&"batch_id"]) \ + and reservation.purpose == Catalog.PRODUCTION_PURPOSE: + _inventory.release_reservation(reservation_id, &"", current_minute) + + +func _kitchen(facility_id: StringName) -> FacilityInstance: + if not _configured(): return null + var facility := _facilities.get_instance(facility_id) + if facility == null or facility.definition_id != &"kitchen" or facility.local_inventory_id == &"": return null + if _registry.get_facility(facility.definition_id) == null: return null + return facility + + +func _configured() -> bool: + return _inventory != null and _facilities != null and _registry != null + + +func _usable_quantity(inventory_id: StringName, item_id: StringName, minute: int) -> int: + var quantity := 0 + for stack: Dictionary in _inventory.get_stacks(inventory_id): + if StringName(stack[&"item_id"]) == item_id and (int(stack[&"expires_at"]) < 0 or int(stack[&"expires_at"]) > minute): + quantity += int(stack[&"quantity"]) + return quantity + + +func _valid_batch_shape(batch: Dictionary, minute: int, next_sequence: int) -> bool: + if not _keys_match(batch, [&"batch_id", &"facility_id", &"inventory_id", &"started_at", &"progress_minutes", &"quality", &"reservation_ids"]): return false + for key: StringName in [&"batch_id", &"facility_id", &"inventory_id"]: + if not _text(batch[key]): return false + if not _integer(batch[&"started_at"], 0, minute) \ + or not _integer(batch[&"progress_minutes"], 0, Catalog.PRODUCTION_MINUTES) \ + or int(batch[&"progress_minutes"]) > minute - int(batch[&"started_at"]) \ + or not _integer(batch[&"quality"], 0, 100) or not batch[&"reservation_ids"] is Array: + return false + var sequence := _batch_sequence(batch[&"batch_id"]) + if sequence < 1 or sequence >= next_sequence: return false + var seen: Dictionary = {} + for reservation_id: Variant in batch[&"reservation_ids"]: + if not _text(reservation_id) or seen.has(StringName(reservation_id)): return false + seen[StringName(reservation_id)] = true + return not seen.is_empty() + + +func _batch_sequence(value: Variant) -> int: + if not _text(value): return -1 + var batch_id := String(value) + var sequence_text := batch_id.trim_prefix("kitchen.batch:") + if not sequence_text.is_valid_int(): return -1 + var sequence := sequence_text.to_int() + if sequence < 1 or batch_id != "kitchen.batch:%08d" % sequence: return -1 + return sequence + + +func _reservation_ids(batch: Dictionary) -> Array[StringName]: + var ids: Array[StringName] = [] + for value: Variant in batch[&"reservation_ids"]: + ids.append(StringName(value)) + return ids + + +func _valid_target(target: int) -> bool: + return target >= Catalog.BATCH_OUTPUT and target <= Catalog.KITCHEN_TARGET_MAX and target % Catalog.BATCH_OUTPUT == 0 + + +func _integer(value: Variant, minimum: int, maximum: int = 9223372036854775807) -> bool: + return typeof(value) == TYPE_INT and value >= minimum and value <= maximum + + +func _text(value: Variant) -> bool: + return typeof(value) in [TYPE_STRING, TYPE_STRING_NAME] and not String(value).strip_edges().is_empty() + + +func _keys_match(value: Dictionary, expected: Array) -> bool: + if value.size() != expected.size(): return false + for key: StringName in expected: + if not value.has(key): return false + return true + + +func _sorted_ids(values: Dictionary) -> Array[StringName]: + var ids: Array[StringName] = [] + for value: Variant in values.keys(): ids.append(StringName(value)) + ids.sort() + return ids + + +func _event(kind: StringName, facility_id: StringName, batch: Dictionary, reason: StringName = &"") -> Dictionary: + return { + &"type": kind, &"facility_id": String(facility_id), &"batch_id": batch[&"batch_id"], + &"minute": current_minute, &"reason": reason, + &"output_quantity": Catalog.BATCH_OUTPUT if kind == &"BATCH_COMPLETED" else 0, + } + + +func _failure(code: StringName, message: String) -> Dictionary: + last_error = message + return {&"ok": false, &"code": code, &"message": message, &"events": []} + + +func _restore_failure(message: String) -> bool: + last_restore_error = message + return false diff --git a/scripts/supply/kitchen_production_service.gd.uid b/scripts/supply/kitchen_production_service.gd.uid new file mode 100644 index 0000000..7d850a4 --- /dev/null +++ b/scripts/supply/kitchen_production_service.gd.uid @@ -0,0 +1 @@ +uid://brvl7twad1pru diff --git a/scripts/supply/supply_catalog.gd b/scripts/supply/supply_catalog.gd new file mode 100644 index 0000000..c18e6d3 --- /dev/null +++ b/scripts/supply/supply_catalog.gd @@ -0,0 +1,61 @@ +class_name SupplyCatalog +extends RefCounted + +## Explicit P1 tuning values. The existing raw_food -> basic_meal IDs and +## recipe remain compatible with facility data; cooking points/tableware are +## a later specification change, not silently introduced by this catalogue. +const RAW_FOOD := &"ingredient/raw_food" +const BASIC_MEAL := &"ingredient/basic_meal" +const RAW_TARGET := 8 +const KITCHEN_TARGET_DEFAULT := 8 +const KITCHEN_TARGET_MAX := 16 +const BATCH_INPUT := 4 +const BATCH_OUTPUT := 4 +const PRODUCTION_MINUTES := 15 +const PRODUCTION_PURPOSE := &"kitchen_production" + + +static func get_item(item_id: StringName) -> Dictionary: + match item_id: + RAW_FOOD: + return { + &"item_id": RAW_FOOD, &"display_name": "調理用食材", + &"unit_price": 3, &"unit_weight": 1, &"shelf_life_minutes": 1440, + &"purchasable": true, + } + BASIC_MEAL: + return { + &"item_id": BASIC_MEAL, &"display_name": "基本料理", + &"unit_price": 0, &"unit_weight": 1, &"shelf_life_minutes": 720, + &"purchasable": false, + } + return {} + + +static func get_purchase_items() -> Array[Dictionary]: + return [get_item(RAW_FOOD)] + + +static func storage_capacity(definition_id: StringName) -> int: + match definition_id: + &"warehouse": return 100 + &"kitchen": return 40 + &"cafeteria": return 24 + return 0 + + +static func get_recipe() -> Dictionary: + return { + &"input_item_id": RAW_FOOD, &"input_quantity": BATCH_INPUT, + &"output_item_id": BASIC_MEAL, &"output_quantity": BATCH_OUTPUT, + &"duration_minutes": PRODUCTION_MINUTES, &"minimum_staff": 1, + } + + +## All physical stacks, including reserved/expired goods, occupy storage until +## the inventory owner actually removes them. +static func inventory_weight(inventory: InventoryService, inventory_id: StringName) -> int: + var total := 0 + for stack: Dictionary in inventory.get_stacks(inventory_id): + total += int(stack[&"quantity"]) * int(stack[&"unit_weight"]) + return total diff --git a/scripts/supply/supply_catalog.gd.uid b/scripts/supply/supply_catalog.gd.uid new file mode 100644 index 0000000..233a0e6 --- /dev/null +++ b/scripts/supply/supply_catalog.gd.uid @@ -0,0 +1 @@ +uid://uqlj4hkioxck diff --git a/scripts/supply/supply_logistics_bridge.gd b/scripts/supply/supply_logistics_bridge.gd new file mode 100644 index 0000000..b719668 --- /dev/null +++ b/scripts/supply/supply_logistics_bridge.gd @@ -0,0 +1,560 @@ +class_name SupplyLogisticsBridge +extends RefCounted + +## Execution state lives in LogisticsRequest, StaffTask, and InventoryService. +## This adapter owns no additional save payload and never moves an actor itself. +const REQUEST_PREFIX := "supply." +const PICKUP_TYPE := &"supply_pickup" +const DELIVERY_TYPE := &"supply_delivery" +const RETURN_TYPE := &"supply_return" +const RETRY_MINUTES := 5 +const RETURNING := "RETURNING_EXPIRED_CARGO" + +var _runtime: Variant +var last_report: Dictionary = {} + + +func configure(runtime: Variant) -> SupplyLogisticsBridge: + _runtime = runtime + _runtime.staff_service.configure_external_task_driver(Callable(self, "owns_task")) + return self + + +func owns_task(task_id: StringName) -> bool: + if _runtime == null: + return false + var task: StaffTask = _runtime.staff_service.get_task(task_id) + return task != null and task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] \ + and String(task.target_entity_id).begins_with(REQUEST_PREFIX) + + +func can_move_task(task_id: StringName) -> bool: + if not owns_task(task_id): + return true + var task: StaffTask = _runtime.staff_service.get_task(task_id) + var staff: StaffRuntime = _runtime.staff_service.get_staff(task.assigned_staff_id) + if staff == null or not staff.can_take_shift(): + return false + # Partial spoilage is a return obligation. Do not keep walking to the + # customer while the bridge is still searching for a safe return route. + return task.task_type != DELIVERY_TYPE or not is_returning(task.target_entity_id) + + +func tick(absolute_minute: int) -> Dictionary: + if _runtime == null: + return {&"ok": false, &"reason": &"NOT_CONFIGURED"} + var actions: Array[Dictionary] = [] + for request_id: StringName in _request_ids(_runtime.logistics_requests): + var request: LogisticsRequest = _runtime.logistics_requests[request_id] + if request.is_terminal(): + continue + if request.state == LogisticsRequest.State.BLOCKED: + if absolute_minute >= request.state_changed_at + RETRY_MINUTES: + _rearm(request, absolute_minute) + continue + if request.state == LogisticsRequest.State.RESERVED: + if not _reserved_stock_valid(request): + _block_before_pickup(request, "SOURCE_STOCK_EXPIRED", absolute_minute) + else: + _dispatch(request, absolute_minute) + continue + var task := _active_task(_runtime.staff_service, request.request_id) + var staff: StaffRuntime = _runtime.staff_service.get_staff(request.assigned_carrier_id) + if task == null or staff == null or staff.current_task_id != task.task_id: + request.state_reason = "CARRIER_TASK_MISSING" + continue + if request.state == LogisticsRequest.State.DELIVERING and _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)).is_empty(): + _cancel_empty_cargo(request, staff, task, "CARGO_EXPIRED", absolute_minute) + continue + if not staff.can_take_shift(): + if request.state in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP]: + _block_before_pickup(request, "CARRIER_UNAVAILABLE", absolute_minute) + else: + request.state_reason = "CARRIER_UNAVAILABLE" + continue + if request.state in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP]: + if not _reserved_stock_valid(request): + _block_before_pickup(request, "SOURCE_STOCK_EXPIRED", absolute_minute) + elif _arrived_for_work(staff, task, absolute_minute): + if _pickup(request, staff, task, absolute_minute): + actions.append({&"request_id": request_id, &"action": &"PICKED_UP"}) + elif request.state == LogisticsRequest.State.DELIVERING: + var cargo := _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)) + if cargo.is_empty(): + _cancel_empty_cargo(request, staff, task, "CARGO_EXPIRED", absolute_minute) + continue + if not _quantities_cover(cargo, request.item_requirements) and task.task_type != RETURN_TYPE: + var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id) + var route := _access_path(staff.current_cell, source) + if not bool(route.get(&"reachable", false)): + request.state_reason = "EXPIRED_CARGO_RETURN_BLOCKED" + continue + if not _runtime.staff_service.retarget_external_task(task.task_id, route[&"cell"], absolute_minute).is_success(): + continue + task.task_type = RETURN_TYPE + request.state_reason = RETURNING + continue + if _arrived_for_work(staff, task, absolute_minute): + if _deliver(request, staff, task, absolute_minute): + actions.append({&"request_id": request_id, &"action": &"DELIVERED"}) + last_report = {&"ok": true, &"actions": actions} + return last_report.duplicate(true) + + +func handle_path_stuck(task_id: StringName, absolute_minute: int) -> Dictionary: + if not owns_task(task_id): + return {&"ok": false, &"action": &"NOT_SUPPLY_TASK"} + var task: StaffTask = _runtime.staff_service.get_task(task_id) + var request: LogisticsRequest = _runtime.logistics_requests.get(task.target_entity_id) + if request == null: + return {&"ok": false, &"action": &"REQUEST_MISSING"} + if request.state == LogisticsRequest.State.DELIVERING: + # Loaded cargo stays with its actor, even when no safe return route exists. + # The normal Path loop retries after topology is repaired. + request.retry_count += 1 + if task.task_type != RETURN_TYPE: + request.state_reason = "ROUTE_BLOCKED" + task.state_reason = &"ROUTE_BLOCKED" + return {&"ok": true, &"action": &"SUPPLY_CARGO_HELD"} + return {&"ok": _block_before_pickup(request, "ROUTE_BLOCKED", absolute_minute), &"action": &"SUPPLY_PICKUP_RETRY"} + + +func get_report() -> Dictionary: + var rows: Array[Dictionary] = [] + if _runtime == null: + return {&"requests": rows, &"deliveries": rows.duplicate(true)} + for request_id: StringName in _request_ids(_runtime.logistics_requests): + var request: LogisticsRequest = _runtime.logistics_requests[request_id] + var task := _active_task(_runtime.staff_service, request_id) + var source := _facility_for_inventory(_runtime.facility_service, request.source_inventory_id) + var destination := _facility_for_inventory(_runtime.facility_service, request.destination_inventory_id) + var worker: StaffRuntime = _runtime.staff_service.get_staff(request.assigned_carrier_id) + rows.append({ + &"source_facility_id": source.instance_id if source != null else &"", + &"destination_facility_id": destination.instance_id if destination != null else &"", + &"carrier_name": String(_runtime.mvp_content_catalog.get_entry(&"staff_candidates", worker.staff_id).get(&"display_name", worker.staff_id)) if worker != null else "", + &"reason_codes": [] if request.state_reason.is_empty() else [StringName(request.state_reason)], + &"request_id": request_id, &"state": LogisticsRequest.State.keys()[request.state], + &"reason": request.state_reason, &"carrier_id": request.assigned_carrier_id, + &"source_inventory_id": request.source_inventory_id, + &"destination_inventory_id": request.destination_inventory_id, + &"item_requirements": request.item_requirements.duplicate(true), &"weight": request.total_weight, + &"retry_count": request.retry_count, &"task_id": task.task_id if task != null else &"", + }) + var active: Array[Dictionary] = [] + var recent: Array[Dictionary] = [] + for row: Dictionary in rows: + var request: LogisticsRequest = _runtime.logistics_requests[row[&"request_id"]] + if request.is_terminal(): recent.append(row) + else: active.append(row) + recent.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + var left: LogisticsRequest = _runtime.logistics_requests[a[&"request_id"]] + var right: LogisticsRequest = _runtime.logistics_requests[b[&"request_id"]] + if left.state_changed_at != right.state_changed_at: return left.state_changed_at > right.state_changed_at + return String(left.request_id) > String(right.request_id)) + active.append_array(recent.slice(0, 10)) + return {&"requests": rows, &"deliveries": active} + + +func is_returning(request_id: StringName) -> bool: + if _runtime == null: + return false + var request: LogisticsRequest = _runtime.logistics_requests.get(request_id) + if request == null or request.state != LogisticsRequest.State.DELIVERING: + return false + var task := _active_task(_runtime.staff_service, request_id) + if task != null and task.task_type == RETURN_TYPE: + return true + return not _quantities_cover(_cargo_quantities(_runtime.inventory_service, _cargo_id(request.assigned_carrier_id)), request.item_requirements) + + +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: + request.state_reason = "FACILITY_UNAVAILABLE" + return + if not _has_storage_space(destination, request.total_weight): + request.state_reason = "DESTINATION_FULL" + return + var carriers: Dictionary = {} + var source_cells: Dictionary = {} + for staff: StaffRuntime in _runtime.staff_service.get_staff_members(): + var shift: ShiftInstance = _runtime.staff_service.get_shift(staff.shift_id) + if staff.role_id != &"porter" or not staff.is_available_for_dispatch() \ + or shift == null or not shift.contains_minute(minute) \ + or _runtime.logistics_dispatcher.get_reserved_load(staff.staff_id) > 0 \ + or not _cargo_quantities(_runtime.inventory_service, _cargo_id(staff.staff_id)).is_empty(): + continue + var access := _access_path(staff.current_cell, source) + if not bool(access.get(&"reachable", false)): + continue + var onward := _access_path(access[&"cell"], destination) + if not bool(onward.get(&"reachable", false)): + continue + carriers[staff.staff_id] = {&"capacity": mini(20, 10 + int(staff.skills.get(&"operation", 0)) / 10), + &"current_load": 0, &"path_minutes": int(access[&"steps"]), &"position": staff.current_cell, &"role": &"logistics"} + source_cells[staff.staff_id] = access[&"cell"] + if carriers.is_empty(): + request.state_reason = "NO_AVAILABLE_PORTER_OR_ROUTE" + return + var before := _capture(request) + var assigned: Array = _runtime.logistics_dispatcher.dispatch([request], carriers, minute) + if assigned.is_empty(): + request.state_reason = "CARRIER_CAPACITY_EXCEEDED" + return + var staff_id := request.assigned_carrier_id + var task := _create_task(request, PICKUP_TYPE, source_cells[staff_id], minute) + if task == null or not _runtime.staff_service.assign_task_to_staff(task.task_id, staff_id, minute).is_success(): + _rollback(request, before) + request.state_reason = "TASK_ASSIGNMENT_FAILED" + return + request.state_reason = "" + + +func _pickup(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, minute: int) -> bool: + 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 + 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): + 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)): + return _block_before_pickup(request, "NO_DELIVERY_ROUTE", minute) and false + var before := _capture(request) + if request.state == LogisticsRequest.State.ASSIGNED and not _advance_request(request, minute): + _rollback(request, before) + return false + if not _advance_request(request, minute): + _rollback(request, before) + return _block_before_pickup(request, "PICKUP_FAILED", minute) and false + if not _runtime.staff_service.progress_task(task.task_id, 1, minute, true).is_success(): + _rollback(request, before) + return false + staff.carried_inventory_id = _cargo_id(staff.staff_id) + var delivery := _create_task(request, DELIVERY_TYPE, route[&"cell"], minute) + if delivery == null or not _runtime.staff_service.assign_task_to_staff(delivery.task_id, staff.staff_id, minute).is_success(): + _rollback(request, before) + return false + request.state_reason = "" + return true + + +func _deliver(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, minute: int) -> bool: + var returning := task.task_type == RETURN_TYPE + var destination_id := request.source_inventory_id if returning else request.destination_inventory_id + var destination := _facility_for_inventory(_runtime.facility_service, destination_id) + if destination == null or not _cell_touches_facility(staff.current_cell, destination): + 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 not returning: + request.state_reason = "DESTINATION_FULL" + return false + var before := _capture(request) + if returning: + var transfers: Array[Dictionary] = [] + for item_id: StringName in _sorted_keys(quantities): + transfers.append({&"source_inventory_id": _cargo_id(staff.staff_id), &"destination_inventory_id": destination_id, + &"item_id": item_id, &"quantity": int(quantities[item_id]), &"operation_id": StringName("supply.return.%s.%s" % [request.request_id, item_id])}) + if not _runtime.inventory_service.transfer_available_batch(transfers, minute)[&"ok"] \ + or not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, staff.staff_id, request.total_weight): + _rollback(request, before) + return false + request.cancel("CARGO_EXPIRED_RETURNED", minute) + else: + if not _advance_request(request, minute): + _rollback(request, before) + request.state_reason = "DELIVERY_RETRY" + return false + if not _runtime.staff_service.progress_task(task.task_id, 1, minute, true).is_success(): + _rollback(request, before) + return false + staff.carried_inventory_id = &"" + return true + + +func _block_before_pickup(request: LogisticsRequest, reason: String, minute: int) -> bool: + var before := _capture(request) + var task := _active_task(_runtime.staff_service, request.request_id) + var carrier_id := request.assigned_carrier_id + var worker: StaffRuntime = _runtime.staff_service.get_staff(carrier_id) + var unavailable_state := worker.state if worker != null and not worker.can_take_shift() else -1 + for reservation_id: StringName in request.reservation_ids: + var reservation: InventoryReservation = _runtime.inventory_service.get_reservation(reservation_id) + if reservation != null and reservation.is_active() \ + and not _runtime.inventory_service.release_reservation(reservation_id, &"", minute)[&"ok"]: + _rollback(request, before) + return false + if not carrier_id.is_empty() and not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, carrier_id, request.total_weight): + _rollback(request, before) + return false + if task != null and not _runtime.staff_service.cancel_task(task.task_id, StringName(reason), minute, true).is_success(): + _rollback(request, before) + return false + if unavailable_state >= 0: + worker.transition_to(unavailable_state, StringName(reason), minute) + request.mark_blocked(reason, minute) + return true + + +func _rearm(request: LogisticsRequest, minute: int) -> void: + if request.deadline >= 0 and request.deadline <= minute: + request.cancel("DEADLINE_EXPIRED", minute) + return + var before := _capture(request) + var ids: Array[StringName] = [] + var stacks: Array[StringName] = [] + var weight := 0 + for item_id: StringName in _sorted_keys(request.item_requirements): + var result: Dictionary = _runtime.inventory_service.reserve_item(request.source_inventory_id, item_id, + int(request.item_requirements[item_id]), request.request_id, &"logistics", minute, request.deadline, + StringName("supply.retry.%s.%d.%s" % [request.request_id, minute, item_id])) + if not bool(result.get(&"ok", false)): + _rollback(request, before) + request.state_changed_at = minute + request.state_reason = "WAITING_FOR_SOURCE_STOCK" + return + for id: StringName in result[&"reservation_ids"]: + ids.append(id) + var reservation: InventoryReservation = _runtime.inventory_service.get_reservation(id) + stacks.append(reservation.stack_id) + weight += reservation.quantity * int(_runtime.inventory_service.get_stack(reservation.stack_id)[&"unit_weight"]) + if weight != request.total_weight or not request.mark_reserved(ids, stacks, minute): + _rollback(request, before) + request.state_changed_at = minute + request.state_reason = "SOURCE_WEIGHT_CHANGED" + + +func _cancel_empty_cargo(request: LogisticsRequest, staff: StaffRuntime, task: StaffTask, reason: String, minute: int) -> void: + var before := _capture(request) + var unavailable_state := staff.state if not staff.can_take_shift() else -1 + if not _runtime.logistics_dispatcher.release_assignment_for_request(request.request_id, staff.staff_id, request.total_weight) \ + or not _runtime.staff_service.cancel_task(task.task_id, StringName(reason), minute, true).is_success(): + _rollback(request, before) + return + staff.carried_inventory_id = &"" + if unavailable_state >= 0: + staff.transition_to(unavailable_state, StringName(reason), minute) + request.cancel(reason, minute) + + +func _create_task(request: LogisticsRequest, type: StringName, cell: Vector3i, minute: int) -> StaffTask: + var leg := "pickup" if type == PICKUP_TYPE else "delivery" + var task_id := StringName("supply.%s.%s.%d" % [leg, request.request_id, request.retry_count]) + var result: CommandResult = _runtime.staff_service.create_task(type, request.priority, minute, { + &"target_entity_id": request.request_id, &"target_cell": cell, &"required_role_tags": [&"porter"], + &"task_tags": [&"logistics"], &"estimated_minutes": 1, &"interrupt_level": 0, + }, task_id) + return _runtime.staff_service.get_task(task_id) if result.is_success() else null + + +func _advance_request(request: LogisticsRequest, minute: int) -> bool: + var command := SimulationCommand.new() + command.command_id = 0 + command.command_type = &"advance_logistics_request" + command.payload = {&"request_id": request.request_id} + command.issued_tick = minute + command.execute_tick = minute + return _runtime._execute_advance_logistics_request(command).is_success() + + +func _capture(request: LogisticsRequest) -> Dictionary: + return {&"request": request.create_snapshot(), &"staff": _runtime.staff_service.create_snapshot(), + &"inventory": _runtime.inventory_service.create_snapshot(), &"dispatcher": _runtime.logistics_dispatcher.create_snapshot(), + &"events": _runtime.domain_events.create_snapshot(), &"ids": _runtime.id_generator.create_snapshot()} + + +func _rollback(request: LogisticsRequest, before: Dictionary) -> void: + _runtime.staff_service.restore_snapshot(before[&"staff"]) + _runtime.inventory_service.restore_snapshot(before[&"inventory"]) + _runtime.logistics_dispatcher.restore_snapshot(before[&"dispatcher"]) + _runtime.domain_events.restore_snapshot(before[&"events"]) + _runtime.id_generator.restore_snapshot(before[&"ids"]) + var restored := LogisticsRequest.from_snapshot(before[&"request"]) + # Keep the Request identity while callers finish handling this transition. + for property: Dictionary in restored.get_property_list(): + var name := StringName(property[&"name"]) + if name in [&"RefCounted", &"script"] or not (int(property[&"usage"]) & PROPERTY_USAGE_SCRIPT_VARIABLE): + continue + request.set(name, restored.get(name)) + _runtime.logistics_requests[request.request_id] = request + + +func _reserved_stock_valid(request: LogisticsRequest) -> bool: + return _reservation_quantities_valid(request, _runtime.inventory_service, true) + + +func _has_storage_space(facility: FacilityInstance, incoming_weight: int) -> bool: + var capacity := SupplyCatalog.storage_capacity(facility.definition_id) + return capacity > 0 and SupplyCatalog.inventory_weight(_runtime.inventory_service, facility.local_inventory_id) + incoming_weight <= capacity + + +func find_facility_access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: + return _access_path(start, facility) + + +func _access_path(start: Vector3i, facility: FacilityInstance) -> Dictionary: + if facility == null: + return {&"reachable": false} + var cells: Dictionary = {} + for cell: Vector3i in facility.occupied_cells: + for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + if _runtime.path_service.has_cell(cell + delta): + cells[cell + delta] = true + if cells.is_empty() and _runtime.path_service.has_cell(facility.origin): + cells[facility.origin] = true + var ordered: Array = cells.keys() + ordered.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) + var best: Array[Vector3i] = [] + for cell: Vector3i in ordered: + var path: Array[Vector3i] = _runtime.path_service.find_path(start, cell, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]) + if not path.is_empty() and (best.is_empty() or path.size() < best.size()): + best = path + return {&"reachable": not best.is_empty(), &"cell": best[-1] if not best.is_empty() else facility.origin, &"steps": maxi(0, best.size() - 1)} + + +static func _arrived_for_work(staff: StaffRuntime, task: StaffTask, minute: int) -> bool: + return task.state == StaffTask.State.WORKING and task.state_changed_at < minute and staff.current_cell == task.target_cell + + +static func _cargo_id(staff_id: StringName) -> StringName: + return StringName("cargo:%s" % staff_id) + + +static func _request_ids(requests: Dictionary) -> Array[StringName]: + var ids: Array[StringName] = [] + for id: StringName in _sorted_keys(requests): + if String(id).begins_with(REQUEST_PREFIX): + ids.append(id) + return ids + + +static func _sorted_keys(source: Dictionary) -> Array[StringName]: + var ids: Array[StringName] = [] + for key: Variant in source: + ids.append(StringName(key)) + ids.sort_custom(func(a: StringName, b: StringName) -> bool: return String(a) < String(b)) + return ids + + +static func _active_task(staff: StaffService, request_id: StringName) -> StaffTask: + for task: StaffTask in staff.get_tasks(): + if task.target_entity_id == request_id and task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] and not task.is_terminal(): + return task + return null + + +static func _facility_for_inventory(facilities: FacilityService, inventory_id: StringName) -> FacilityInstance: + for facility: FacilityInstance in facilities.get_instances(): + if facility.local_inventory_id == inventory_id: + return facility + return null + + +static func _cargo_quantities(inventory: InventoryService, cargo_id: StringName) -> Dictionary: + var quantities: Dictionary = {} + for stack: Dictionary in inventory.get_stacks(cargo_id): + var item_id := StringName(stack[&"item_id"]) + quantities[item_id] = int(quantities.get(item_id, 0)) + int(stack[&"quantity"]) + return quantities + + +static func _quantities_cover(actual: Dictionary, requested: Dictionary) -> bool: + for item_id: StringName in _sorted_keys(requested): + if int(actual.get(item_id, 0)) < int(requested[item_id]): + return false + return true + + +static func _reservation_quantities_valid(request: LogisticsRequest, inventory: InventoryService, active: bool) -> bool: + var totals: Dictionary = {} + var weight := 0 + var seen: Dictionary = {} + if request.reservation_ids.is_empty(): + return false + for id: StringName in request.reservation_ids: + if seen.has(id): + return false + seen[id] = true + var reservation := inventory.get_reservation(id) + if reservation == null or reservation.requester_id != request.request_id \ + or reservation.owner_inventory_id != request.source_inventory_id: + return false + if active and (not reservation.is_active() or reservation.is_expired(inventory.current_minute) \ + or inventory.is_stack_expired(reservation.stack_id, inventory.current_minute)): + return false + if not active and reservation.state != InventoryReservation.State.CONSUMED: + return false + totals[reservation.item_id] = int(totals.get(reservation.item_id, 0)) + reservation.quantity + if active: + var stack := inventory.get_stack(reservation.stack_id) + if stack.is_empty(): + return false + weight += reservation.quantity * int(stack[&"unit_weight"]) + return totals == request.item_requirements and (not active or weight == request.total_weight) + + +static func validate_crosslinks(requests: Dictionary, staff: StaffService, inventory: InventoryService, facilities: FacilityService) -> bool: + var carrier_owners: Dictionary = {} + var active_task_ids: Dictionary = {} + for id: StringName in _request_ids(requests): + var request: LogisticsRequest = requests[id] + var source := _facility_for_inventory(facilities, request.source_inventory_id) + var destination := _facility_for_inventory(facilities, request.destination_inventory_id) + if not String(request.source_inventory_id).begins_with("facility.inventory:") or not String(request.destination_inventory_id).begins_with("facility.inventory:"): + return false + if not request.is_terminal() and (source == null or destination == null): + return false + var task := _active_task(staff, id) + if request.state in [LogisticsRequest.State.RESERVED, LogisticsRequest.State.BLOCKED] or request.is_terminal(): + if task != null: + return false + if request.state == LogisticsRequest.State.RESERVED and not _reservation_quantities_valid(request, inventory, true): + return false + continue + if request.state not in [LogisticsRequest.State.ASSIGNED, LogisticsRequest.State.PICKING_UP, LogisticsRequest.State.DELIVERING]: + return false + var worker := staff.get_staff(request.assigned_carrier_id) + if worker == null or worker.role_id != &"porter" or carrier_owners.has(worker.staff_id) \ + or task == null or task.assigned_staff_id != worker.staff_id or worker.current_task_id != task.task_id \ + or task.state not in [StaffTask.State.RESERVED, StaffTask.State.MOVING, StaffTask.State.WORKING] \ + or task.interrupt_level != 0 or not task.required_items.is_empty() or not task.reservation_ids.is_empty(): + return false + carrier_owners[worker.staff_id] = id + active_task_ids[task.task_id] = true + var loaded := request.state == LogisticsRequest.State.DELIVERING + var endpoint := source if not loaded or task.task_type == RETURN_TYPE else destination + if not _cell_touches_facility(task.target_cell, endpoint): + return false + if (loaded and task.task_type not in [DELIVERY_TYPE, RETURN_TYPE]) or (not loaded and task.task_type != PICKUP_TYPE) \ + or not String(task.task_id).begins_with("supply.%s.%s." % ["delivery" if loaded else "pickup", id]): + return false + if not _reservation_quantities_valid(request, inventory, not loaded): + return false + var cargo := _cargo_quantities(inventory, _cargo_id(worker.staff_id)) + if loaded: + if worker.carried_inventory_id != _cargo_id(worker.staff_id) or cargo.is_empty(): + return false + for item_id: StringName in _sorted_keys(cargo): + if int(cargo[item_id]) > int(request.item_requirements.get(item_id, 0)): + return false + elif not cargo.is_empty() or not worker.carried_inventory_id.is_empty(): + return false + for task: StaffTask in staff.get_tasks(): + if task.task_type in [PICKUP_TYPE, DELIVERY_TYPE, RETURN_TYPE] and not task.is_terminal() \ + and (not requests.has(task.target_entity_id) or not active_task_ids.has(task.task_id)): + return false + return true + + +static func _cell_touches_facility(cell: Vector3i, facility: FacilityInstance) -> bool: + for occupied: Vector3i in facility.occupied_cells: + if cell.z == occupied.z and absi(cell.x - occupied.x) + absi(cell.y - occupied.y) <= 1: + return true + return cell == facility.origin diff --git a/scripts/supply/supply_logistics_bridge.gd.uid b/scripts/supply/supply_logistics_bridge.gd.uid new file mode 100644 index 0000000..40c4ff4 --- /dev/null +++ b/scripts/supply/supply_logistics_bridge.gd.uid @@ -0,0 +1 @@ +uid://due4h10y45t4w diff --git a/scripts/supply/supply_management_service.gd b/scripts/supply/supply_management_service.gd new file mode 100644 index 0000000..75c8fae --- /dev/null +++ b/scripts/supply/supply_management_service.gd @@ -0,0 +1,253 @@ +class_name SupplyManagementService +extends RefCounted + +## Player commands and deterministic replenishment. Vendor purchases arrive in +## the selected warehouse; all transfers inside the tree use real porter tasks. + +func preview_purchase(runtime: Variant, warehouse_id: StringName, item_id: StringName, quantity: int) -> Dictionary: + var cash: int = runtime.economy_service.get_balance(&"PLAYER_CASH") + var item := SupplyCatalog.get_item(item_id) + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id) + var quote := {&"ok": false, &"reason": &"", &"total_cost": 0, &"cash_after": cash, &"quantity_after": 0, &"max_quantity": 0} + if facility == null or facility.definition_id != &"warehouse": + quote[&"reason"] = &"NOT_WAREHOUSE" + return quote + if item.is_empty() or not bool(item.get(&"purchasable", false)): + quote[&"reason"] = &"ITEM_NOT_PURCHASABLE" + return quote + var price := int(item[&"unit_price"]) + var space: int = maxi(SupplyCatalog.storage_capacity(facility.definition_id) - SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id) - _incoming_weight(runtime, facility.local_inventory_id), 0) + quote[&"max_quantity"] = mini(space / int(item[&"unit_weight"]), maxi(cash, 0) / price) + quote[&"quantity_after"] = runtime.inventory_service.total_quantity(facility.local_inventory_id, item_id) + maxi(quantity, 0) + quote[&"total_cost"] = maxi(quantity, 0) * price + 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: + quote[&"reason"] = &"FACILITY_CLOSED" + elif int(quote[&"total_cost"]) > cash: + quote[&"reason"] = &"INSUFFICIENT_FUNDS" + elif quantity * int(item[&"unit_weight"]) > space: + quote[&"reason"] = &"STORAGE_FULL" + else: + quote[&"ok"] = true + return quote + + +func purchase(runtime: Variant, command: SimulationCommand) -> CommandResult: + var payload := command.payload + if not _text(payload.get(&"warehouse_id")) or not _text(payload.get(&"item_id")) or not _integer(payload.get(&"quantity")): + return _failure(command, &"INVALID_PAYLOAD") + var warehouse_id := StringName(payload[&"warehouse_id"]) + var item_id := StringName(payload[&"item_id"]) + var quantity := int(payload[&"quantity"]) + var quote := preview_purchase(runtime, warehouse_id, item_id, quantity) + if not bool(quote[&"ok"]): + return _failure(command, StringName(quote[&"reason"]), quote) + var facility: FacilityInstance = runtime.facility_service.get_instance(warehouse_id) + var item := SupplyCatalog.get_item(item_id) + var minute: int = runtime._runtime_event_minute() + var inventory_before: Dictionary = runtime.inventory_service.create_snapshot() + var economy_before: Dictionary = runtime.economy_service.create_snapshot() + var stocked: Dictionary = runtime.inventory_service.add_stack(facility.local_inventory_id, item_id, quantity, 100, minute + int(item[&"shelf_life_minutes"]), minute, int(item[&"unit_weight"])) + if not bool(stocked.get(&"ok", false)): + return _failure(command, &"INVENTORY_ERROR") + var paid: CommandResult = runtime.economy_service.transfer(&"PLAYER_CASH", runtime.OPERATING_EXPENSE_ACCOUNT, int(quote[&"total_cost"]), minute, &"OPERATING_EXPENSE", warehouse_id, StringName("supply.purchase.%d" % command.command_id), &"supply_purchase", &"", false) + if not paid.is_success(): + runtime.inventory_service.restore_snapshot(inventory_before) + runtime.economy_service.restore_snapshot(economy_before) + return _failure(command, &"PAYMENT_FAILED") + if not runtime.economy_service.commit_transaction_notification(StringName(paid.details[&"transaction_id"])): + runtime.inventory_service.restore_snapshot(inventory_before) + runtime.economy_service.restore_snapshot(economy_before) + return _failure(command, &"PAYMENT_FAILED") + quote.merge({&"warehouse_id": warehouse_id, &"item_id": item_id, &"quantity": quantity, &"transaction_id": paid.details[&"transaction_id"]}) + return CommandResult.succeeded(command.command_id, quote) + + +func set_production(runtime: Variant, command: SimulationCommand) -> CommandResult: + var payload := command.payload + if not _text(payload.get(&"facility_id")) or not payload.get(&"enabled") is bool or not _integer(payload.get(&"target_stock")): + return _failure(command, &"INVALID_PAYLOAD") + var facility_id := StringName(payload[&"facility_id"]) + var facility: FacilityInstance = runtime.facility_service.get_instance(facility_id) + if facility == null or facility.definition_id != &"kitchen": + return _failure(command, &"NOT_KITCHEN") + var result: Dictionary = runtime.kitchen_production.set_plan(facility_id, bool(payload[&"enabled"]), int(payload[&"target_stock"])) + if not bool(result.get(&"ok", false)): + return _failure(command, StringName(result.get(&"code", &"INVALID_TARGET")), result) + return CommandResult.succeeded(command.command_id, result) + + +func get_report(runtime: Variant) -> Dictionary: + var warehouses: Array[Dictionary] = [] + var kitchens: Array[Dictionary] = [] + var destinations: Array[Dictionary] = [] + for facility: FacilityInstance in _facilities(runtime): + var row := _facility_row(runtime, facility) + match facility.definition_id: + &"warehouse": + var quote := preview_purchase(runtime, facility.instance_id, SupplyCatalog.RAW_FOOD, 1) + row[&"can_receive"] = bool(quote[&"ok"]) + row[&"reason_codes"] = [] if bool(quote[&"ok"]) else [quote[&"reason"]] + warehouses.append(row) + &"kitchen": + row.merge(runtime.kitchen_production.get_report(facility.instance_id), true) + row.merge({&"target_min": 4, &"target_step": 4, &"target_max": SupplyCatalog.KITCHEN_TARGET_MAX, &"inputs": {SupplyCatalog.RAW_FOOD: 4}, &"outputs": {SupplyCatalog.BASIC_MEAL: 4}}, true) + row[&"reason_codes"] = [] if row[&"state"] in [&"PRODUCING", &"READY", &"TARGET_REACHED"] else [row[&"state"]] + kitchens.append(row) + &"cafeteria": + row[&"missing_items"] = {SupplyCatalog.BASIC_MEAL: maxi(12 - runtime.inventory_service.available_quantity(facility.local_inventory_id, SupplyCatalog.BASIC_MEAL), 0)} + row[&"reason_codes"] = facility.state_reasons.duplicate() + if runtime.inventory_service.available_quantity(facility.local_inventory_id, SupplyCatalog.BASIC_MEAL) <= 0: + row[&"reason_codes"].append(&"NO_INPUT") + destinations.append(row) + var bridge_report: Dictionary = runtime.supply_logistics.get_report() + return {&"cash": runtime.economy_service.get_balance(&"PLAYER_CASH"), &"catalog": SupplyCatalog.get_purchase_items(), &"warehouses": warehouses, &"kitchens": kitchens, &"destinations": destinations, &"deliveries": bridge_report.get(&"deliveries", [])} + + +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: + continue + var source_type: StringName + var item_id: StringName + var target := 0 + if destination.definition_id == &"kitchen" and bool(runtime.kitchen_production.get_plan(destination.instance_id).get(&"enabled", false)): + source_type = &"warehouse" + item_id = SupplyCatalog.RAW_FOOD + target = SupplyCatalog.RAW_TARGET + elif destination.definition_id == &"cafeteria": + source_type = &"kitchen" + item_id = SupplyCatalog.BASIC_MEAL + target = 12 + else: + continue + var deficit: int = target - runtime.inventory_service.total_quantity(destination.local_inventory_id, item_id) - _incoming_quantity(runtime, destination.local_inventory_id, item_id) + var space: int = SupplyCatalog.storage_capacity(destination.definition_id) - SupplyCatalog.inventory_weight(runtime.inventory_service, destination.local_inventory_id) - _incoming_weight(runtime, destination.local_inventory_id) + var needed: int = mini(mini(deficit, space), 4) + if needed <= 0: + continue + var sources: Array[Dictionary] = [] + for source: FacilityInstance in facilities: + if source.definition_id != source_type or not source.operator_open: + continue + if source_type == &"kitchen" and not _has_production_plan(runtime, source.instance_id): + continue + var available: int = runtime.inventory_service.available_quantity(source.local_inventory_id, item_id, minute) + if available <= 0: + continue + sources.append({&"facility": source, &"available": available, &"distance": _connection_distance(runtime, source, destination)}) + # Prefer a connected nearby supplier; IDs resolve equal routes stably. + # If none is connected, retain one visible request for route repair. + sources.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: + if a[&"distance"] != b[&"distance"]: return a[&"distance"] < b[&"distance"] + return String(a[&"facility"].instance_id) < String(b[&"facility"].instance_id)) + for source_row: Dictionary in sources: + var source: FacilityInstance = source_row[&"facility"] + var available := int(source_row[&"available"]) + var ids_before: Dictionary = runtime.id_generator.create_snapshot() + var request_id := StringName("supply.%s" % runtime.id_generator.next_id(&"logistics_request")) + var result: CommandResult = runtime._execute_create_logistics_request(SimulationCommand.new(0, minute, minute, 50, &"create_logistics_request", &"supply", destination.instance_id, {&"request_id": request_id, &"source_inventory_id": source.local_inventory_id, &"destination_inventory_id": destination.local_inventory_id, &"item_requirements": {item_id: mini(needed, available)}, &"priority": 50})) + if not result.is_success(): + runtime.id_generator.restore_snapshot(ids_before) + break + + +func validate_demolition(runtime: Variant, facility_id: StringName) -> Dictionary: + var facility: FacilityInstance = runtime.facility_service.get_instance(facility_id) + if facility == null: + return {&"ok": true} + if SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id) > 0: + return {&"ok": false, &"reason": &"STOCK_REMAINS"} + for request: LogisticsRequest in runtime.logistics_requests.values(): + if not request.is_terminal() and facility.local_inventory_id in [request.source_inventory_id, request.destination_inventory_id]: + return {&"ok": false, &"reason": &"DELIVERY_ACTIVE"} + if facility.definition_id == &"kitchen": + var report: Dictionary = runtime.kitchen_production.get_report(facility_id) + if bool(report.get(&"enabled", false)) or not report.get(&"active_batch", {}).is_empty(): + return {&"ok": false, &"reason": &"PRODUCTION_ACTIVE"} + return {&"ok": true} + + +func _facility_row(runtime: Variant, facility: FacilityInstance) -> Dictionary: + var definition: FacilityDefinition = runtime.definition_registry.get_facility(facility.definition_id) + return {&"facility_id": facility.instance_id, &"display_name": definition.display_name, &"origin": facility.origin, &"layer": facility.origin.z, &"used": SupplyCatalog.inventory_weight(runtime.inventory_service, facility.local_inventory_id), &"capacity": SupplyCatalog.storage_capacity(facility.definition_id), &"stock": _stock(runtime, facility.local_inventory_id)} + + +func _stock(runtime: Variant, inventory_id: StringName) -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + for item_id: StringName in [SupplyCatalog.RAW_FOOD, SupplyCatalog.BASIC_MEAL]: + var total: int = runtime.inventory_service.total_quantity(inventory_id, item_id) + var available: int = runtime.inventory_service.available_quantity(inventory_id, item_id) + rows.append({&"item_id": item_id, &"total": total, &"available": available, &"reserved": total - available, &"in_transit": _incoming_quantity(runtime, inventory_id, item_id)}) + return rows + + +func _incoming_quantity(runtime: Variant, inventory_id: StringName, item_id: StringName) -> int: + var quantity := 0 + for request: LogisticsRequest in runtime.logistics_requests.values(): + if request.is_terminal(): + continue + if runtime.supply_logistics.is_returning(request.request_id): + if request.source_inventory_id == inventory_id: + quantity += runtime.inventory_service.total_quantity(StringName("cargo:%s" % request.assigned_carrier_id), item_id) + elif request.destination_inventory_id == inventory_id: + quantity += int(request.item_requirements.get(item_id, 0)) + return quantity + + +func _incoming_weight(runtime: Variant, inventory_id: StringName) -> int: + var weight := 0 + for request: LogisticsRequest in runtime.logistics_requests.values(): + if request.is_terminal(): + continue + if runtime.supply_logistics.is_returning(request.request_id): + if request.source_inventory_id == inventory_id: + weight += SupplyCatalog.inventory_weight(runtime.inventory_service, StringName("cargo:%s" % request.assigned_carrier_id)) + elif request.destination_inventory_id == inventory_id: + weight += request.total_weight + return weight + + +func _facilities(runtime: Variant) -> Array[FacilityInstance]: + var rows: Array[FacilityInstance] = runtime.facility_service.get_instances() + rows.sort_custom(func(a: FacilityInstance, b: FacilityInstance) -> bool: return String(a.instance_id) < String(b.instance_id)) + return rows + + +func _failure(command: SimulationCommand, reason: StringName, details: Dictionary = {}) -> CommandResult: + var result := details.duplicate(true) + result[&"reason"] = reason + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command.command_id, "Supply command rejected: %s" % reason, result) + + +func _text(value: Variant) -> bool: + return (value is String or value is StringName) and not String(value).is_empty() + + +func _integer(value: Variant) -> bool: + return value is int or (value is float and is_finite(value) and value == floor(value) and absf(value) <= 1000000.0) + + +func _has_production_plan(runtime: Variant, facility_id: StringName) -> bool: + for plan: Dictionary in runtime.kitchen_production.get_plans(): + if StringName(plan[&"facility_id"]) == facility_id: + return true + return false + + +func _connection_distance(runtime: Variant, source: FacilityInstance, destination: FacilityInstance) -> int: + var distance := 1000000 + var seen: Dictionary = {} + for cell: Vector3i in source.occupied_cells: + for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + var start := cell + delta + if seen.has(start) or not runtime.path_service.has_cell(start): + continue + seen[start] = true + var route: Dictionary = runtime.supply_logistics.find_facility_access_path(start, destination) + if bool(route.get(&"reachable", false)): + distance = mini(distance, int(route[&"steps"])) + return distance diff --git a/scripts/supply/supply_management_service.gd.uid b/scripts/supply/supply_management_service.gd.uid new file mode 100644 index 0000000..340724c --- /dev/null +++ b/scripts/supply/supply_management_service.gd.uid @@ -0,0 +1 @@ +uid://b8pm8ddxxfcxx diff --git a/scripts/ui/supply_management_panel.gd b/scripts/ui/supply_management_panel.gd new file mode 100644 index 0000000..b587e74 --- /dev/null +++ b/scripts/ui/supply_management_panel.gd @@ -0,0 +1,569 @@ +class_name SupplyManagementPanel +extends VBoxContainer + +## 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) +signal action_requested(action: StringName) + +const KEY_PREFIX := "ui.supply." +const PURCHASE: StringName = &"purchase_supplies" +const PRODUCTION: StringName = &"set_kitchen_production" +const ITEM_NAMES := {&"ingredient/raw_food": "食材", &"ingredient/basic_meal": "完成料理"} +const STATE_NAMES := { + &"OPEN": "補給待ち", &"RESERVED": "運搬員待ち", &"ASSIGNED": "荷元へ移動", + &"PICKING_UP": "積込中", &"DELIVERING": "配送中", &"COMPLETED": "納品済み", + &"BLOCKED": "配送停止", &"CANCELLED": "取消済み", &"WORKING": "調理中", + &"PRODUCING": "調理中", &"IDLE": "待機中", &"DISABLED": "自動生産停止", + &"TARGET_REACHED": "目標在庫に到達", &"READY": "稼働可能", +} +const REASONS := { + &"INSUFFICIENT_FUNDS": "資金が不足しています。", &"INSUFFICIENT_CASH": "資金が不足しています。", + &"INVALID_QUANTITY": "購入数量を確認してください。", &"INVALID_ITEM": "この品目は購入できません。", + &"INVALID_PAYLOAD": "入力した設定を確認してください。", &"ITEM_NOT_PURCHASABLE": "この品目は購入できません。", + &"ITEM_NOT_FOUND": "品目が見つかりません。", &"WAREHOUSE_NOT_FOUND": "完成した倉庫を選んでください。", + &"FACILITY_NOT_FOUND": "施設が見つかりません。", &"NOT_WAREHOUSE": "受入先には倉庫を選んでください。", + &"WAREHOUSE_FULL": "倉庫の空きが不足しています。", &"CAPACITY_EXCEEDED": "保管容量を超えています。", + &"STORAGE_FULL": "倉庫の空きが不足しています。", &"FACILITY_CLOSED": "受入先の施設が閉鎖されています。", + &"NO_STOCK": "荷元の在庫が不足しています。", &"INSUFFICIENT_STOCK": "荷元の在庫が不足しています。", + &"NO_INPUTS": "調理用の食材が不足しています。", &"NO_STAFF": "必要な職員を配属してください。", + &"NO_INPUT": "必要な在庫が不足しています。", &"DISABLED": "自動生産は停止中です。", + &"INVENTORY_ERROR": "在庫への入庫に失敗しました。", &"PAYMENT_FAILED": "代金を支払えませんでした。", + &"NO_CARRIER": "勤務中の運搬員がいません。", &"NO_ELIGIBLE_CARRIER": "運搬できる職員がいません。", + &"NO_AVAILABLE_PORTER_OR_ROUTE": "勤務中の運搬員と配送経路を確認してください。", + &"FACILITY_UNAVAILABLE": "荷元・供給先の施設が利用できません。", + &"DESTINATION_FULL": "供給先の保管容量が不足しています。", + &"DESTINATION_FULL_OR_CLOSED": "供給先の空き容量と営業状態を確認してください。", + &"NO_DELIVERY_ROUTE": "供給先までの配送経路がありません。", + &"ROUTE_BLOCKED": "配送経路が通れません。通路とドアを確認してください。", + &"CARRIER_CAPACITY_EXCEEDED": "運搬員が持てる量を超えています。", + &"TASK_ASSIGNMENT_FAILED": "運搬作業を割り当てられません。", + &"CARRIER_TASK_MISSING": "運搬作業との接続を確認してください。", + &"CARRIER_UNAVAILABLE": "担当の運搬員が現在作業できません。", + &"SOURCE_STOCK_EXPIRED": "荷元の在庫が消費期限を過ぎました。", + &"WAITING_FOR_SOURCE_STOCK": "荷元への入荷を待っています。", + &"SOURCE_WEIGHT_CHANGED": "荷物の重量が変わったため再手配が必要です。", + &"PICKUP_FAILED": "積込できませんでした。再手配を待っています。", + &"DELIVERY_RETRY": "納品を再試行しています。", + &"RETURNING_EXPIRED_CARGO": "期限切れの荷物を荷元へ返送しています。", + &"EXPIRED_CARGO_RETURN_BLOCKED": "期限切れの荷物を返送できません。経路を確認してください。", + &"NO_ROUTE": "通路と従業員ドアの接続を確認してください。", &"NO_ACCESS": "施設への経路がありません。", + &"NO_POWER": "施設へ魔力が届いていません。", &"CLOSED": "施設が閉鎖されています。", + &"NOT_KITCHEN": "厨房を選んでください。", &"INVALID_TARGET": "目標在庫の上限を確認してください。", + &"BANKRUPT": "運営終了後は変更できません。", &"UNAVAILABLE": "現在は操作できません。", +} + +var summary_label: Label +var warehouse_option: OptionButton +var item_option: OptionButton +var quantity_spin: SpinBox +var purchase_quote_label: Label +var purchase_button: Button +var warehouse_stock_label: Label +var kitchen_option: OptionButton +var production_enabled: CheckButton +var target_spin: SpinBox +var production_button: Button +var kitchen_status_label: Label +var delivery_label: Label +var result_label: Label +var tab_buttons: Dictionary = {} +var selected_warehouse_id: StringName = &"" +var selected_item_id: StringName = &"" +var selected_kitchen_id: StringName = &"" +var selected_tab: StringName = &"purchase" + +var _runtime: Variant +var _style_button: Callable +var _report: Dictionary = {} +var _quote: Dictionary = {} +var _pages: Dictionary = {} +var _kitchen_drafts: Dictionary = {} +var _pending_commands: Dictionary = {} +var _queue_instance_id := 0 +var _sequence := 1 +var _refreshing := false +var _built := false + + +func configure(runtime: Variant, style_button := Callable()) -> void: + _runtime = runtime + _style_button = style_button + + +func _ready() -> void: + name = "SupplyManagementPanel" + size_flags_horizontal = Control.SIZE_EXPAND_FILL + add_theme_constant_override("separation", 5) + _build_interface() + _built = true + if _runtime != null: + if _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: + summary_label = _label("商品 · 仕入れと補給", "SupplySummary") + add_child(summary_label) + var tabs := HBoxContainer.new() + tabs.add_theme_constant_override("separation", 3) + add_child(tabs) + for entry: Array in [[&"purchase", "仕入れ"], [&"kitchen", "厨房"], [&"delivery", "配送"]]: + var button := _button(entry[1], "SupplyTab_%s" % entry[0], select_tab.bind(entry[0])) + button.toggle_mode = true + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + tab_buttons[entry[0]] = button + tabs.add_child(button) + var page := VBoxContainer.new() + page.name = "SupplyPage_%s" % entry[0] + page.add_theme_constant_override("separation", 5) + _pages[entry[0]] = page + add_child(page) + var purchase_page: VBoxContainer = _pages[&"purchase"] + purchase_page.add_child(_label("受入倉庫")) + warehouse_option = _option("SupplyWarehouseOption") + warehouse_option.item_selected.connect(func(index: int) -> void: + selected_warehouse_id = StringName(warehouse_option.get_item_metadata(index)) + refresh()) + purchase_page.add_child(warehouse_option) + warehouse_stock_label = _label("", "SupplyWarehouseStock") + purchase_page.add_child(warehouse_stock_label) + purchase_page.add_child(_label("購入する品目")) + item_option = _option("SupplyItemOption") + item_option.item_selected.connect(func(index: int) -> void: + selected_item_id = StringName(item_option.get_item_metadata(index)) + refresh()) + purchase_page.add_child(item_option) + quantity_spin = _spin("SupplyQuantity", 1, 1) + quantity_spin.value_changed.connect(_on_purchase_quantity_changed) + purchase_page.add_child(_label("数量")) + purchase_page.add_child(quantity_spin) + purchase_quote_label = _label("", "SupplyPurchaseQuote") + purchase_page.add_child(purchase_quote_label) + purchase_button = _button("購入", "PurchaseSuppliesButton", submit_purchase) + purchase_page.add_child(purchase_button) + var kitchen_page: VBoxContainer = _pages[&"kitchen"] + kitchen_page.add_child(_label("設定する厨房")) + kitchen_option = _option("SupplyKitchenOption") + kitchen_option.item_selected.connect(func(index: int) -> void: + selected_kitchen_id = StringName(kitchen_option.get_item_metadata(index)) + refresh()) + kitchen_page.add_child(kitchen_option) + production_enabled = CheckButton.new() + production_enabled.name = "SupplyProductionEnabled" + production_enabled.text = "自動生産" + 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")) + production_enabled.toggled.connect(_on_production_enabled_changed) + kitchen_page.add_child(production_enabled) + kitchen_page.add_child(_label("目標在庫 · 完成料理")) + target_spin = _spin("SupplyTargetStock", 0, 0) + target_spin.value_changed.connect(_on_target_changed) + kitchen_page.add_child(target_spin) + production_button = _button("生産設定を保存", "SaveKitchenProductionButton", submit_production) + kitchen_page.add_child(production_button) + kitchen_status_label = _label("", "SupplyKitchenStatus") + kitchen_page.add_child(kitchen_status_label) + var delivery_page: VBoxContainer = _pages[&"delivery"] + delivery_label = _label("", "SupplyDeliveryStatus") + delivery_page.add_child(delivery_label) + var actions := HBoxContainer.new() + for entry: Array in [[&"staff", "従業員を開く"], [&"facility", "施設をつくる"]]: + var button := _button(entry[1], "SupplyAction_%s" % entry[0], func() -> void: action_requested.emit(entry[0])) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + actions.add_child(button) + add_child(actions) + result_label = _label("", "SupplyResult") + add_child(result_label) + select_tab(&"purchase") + + +func select_tab(tab: StringName) -> void: + if not _pages.has(tab): + return + selected_tab = tab + for key: StringName in _pages: + (_pages[key] as Control).visible = key == tab + (tab_buttons[key] as Button).set_pressed_no_signal(key == tab) + refresh() + + +func get_report() -> Dictionary: + return _report.duplicate(true) + + +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() + _kitchen_drafts.clear() + for picker: OptionButton in [warehouse_option, item_option, kitchen_option]: + picker.get_popup().hide() + _synchronize_commands(queue) + _report = {} + if _runtime != null and _runtime.has_method(&"get_supply_management_report"): + var value: Variant = _runtime.call(&"get_supply_management_report") + if value is Dictionary: + _report = value + _refreshing = true + summary_label.text = "商品 · 手持ち %dG" % int(_report.get(&"cash", 0)) if not _report.is_empty() else "商品 · 補給情報はまだありません" + match selected_tab: + &"purchase": + _refresh_purchase() + &"kitchen": + _refresh_kitchen() + &"delivery": + _refresh_deliveries() + _refreshing = false + + +func _refresh_purchase() -> void: + selected_warehouse_id = _fill_option(warehouse_option, _rows(&"warehouses"), &"facility_id", selected_warehouse_id, "完成した倉庫がありません") + selected_item_id = _fill_option(item_option, _rows(&"catalog"), &"item_id", selected_item_id, "購入できる品目がありません") + var warehouse := _row(&"warehouses", &"facility_id", selected_warehouse_id) + var catalog := _row(&"catalog", &"item_id", selected_item_id) + warehouse_stock_label.text = "倉庫を完成させると、有料で食材を仕入れられます。" if warehouse.is_empty() else "保管 %d / %d\n%s" % [warehouse.get(&"used", 0), warehouse.get(&"capacity", 0), _stock_text(warehouse.get(&"stock", []))] + _quote = {} + if _runtime != null and _runtime.has_method(&"preview_supply_purchase") and not warehouse.is_empty() and not catalog.is_empty(): + var value: Variant = _runtime.call(&"preview_supply_purchase", selected_warehouse_id, selected_item_id, int(quantity_spin.value)) + if value is Dictionary: + _quote = value + # The upper bound comes from Runtime. Retain a rejected draft so the player + # can see why it cannot be bought instead of silently reducing its quantity. + quantity_spin.max_value = maxi(int(quantity_spin.value), maxi(1, int(_quote.get(&"max_quantity", 1)))) + var pending := _has_pending(PURCHASE) + quantity_spin.editable = not pending and not catalog.is_empty() and not warehouse.is_empty() + warehouse_option.disabled = pending or _rows(&"warehouses").is_empty() + item_option.disabled = pending or _rows(&"catalog").is_empty() + purchase_button.disabled = pending or not bool(_quote.get(&"ok", false)) + purchase_button.text = "購入を受理済み…" if pending else "購入" + if catalog.is_empty() or warehouse.is_empty(): + purchase_quote_label.text = "受入倉庫と品目を選んでください。" + return + purchase_quote_label.text = "単価 %dG / %s\n合計 %dG · 購入後 %dG" % [catalog.get(&"unit_price", 0), catalog.get(&"unit_name", "個"), _quote.get(&"total_cost", 0), _quote.get(&"cash_after", _report.get(&"cash", 0))] + if not bool(_quote.get(&"ok", false)): + purchase_quote_label.text += "\n" + _reason_text(_quote.get(&"reason", &"UNAVAILABLE")) + + +func _refresh_kitchen() -> void: + selected_kitchen_id = _fill_option(kitchen_option, _rows(&"kitchens"), &"facility_id", selected_kitchen_id, "完成した厨房がありません") + var row := _row(&"kitchens", &"facility_id", selected_kitchen_id) + var draft: Dictionary = _kitchen_drafts.get(selected_kitchen_id, row) + var pending := _has_pending(PRODUCTION, selected_kitchen_id) + kitchen_option.disabled = _rows(&"kitchens").is_empty() + production_enabled.disabled = pending or row.is_empty() + target_spin.editable = not pending and not row.is_empty() + var target_max := maxi(0, int(row.get(&"target_max", 0))) + target_spin.min_value = int(row.get(&"target_min", 4)) if not row.is_empty() else 0 + target_spin.step = int(row.get(&"target_step", 4)) if not row.is_empty() else 1 + target_spin.max_value = maxi(target_max, int(draft.get(&"target_stock", 0))) + target_spin.set_value_no_signal(int(draft.get(&"target_stock", 0))) + production_enabled.set_pressed_no_signal(bool(draft.get(&"enabled", false))) + production_button.disabled = pending or row.is_empty() or int(target_spin.value) > target_max + production_button.text = "設定を受理済み…" if pending else "生産設定を保存" + if row.is_empty(): + kitchen_status_label.text = "厨房をつくり、調理係を配属してください。" + return + kitchen_status_label.text = "設定上限 %d · %s\n材料 %s → 生産 %s\n%s" % [target_max, _state_text(row.get(&"state", &"IDLE")), _items_text(row.get(&"inputs", {})), _items_text(row.get(&"outputs", {})), _stock_text(row.get(&"stock", []))] + var reasons := _reasons_text(row.get(&"reason_codes", [])) + if not reasons.is_empty(): + kitchen_status_label.text += "\n" + reasons + var batch: Variant = row.get(&"active_batch", {}) + if batch is Dictionary and not batch.is_empty(): + if batch.has(&"remaining_minutes"): + kitchen_status_label.text += "\n調理完了まで %d分" % int(batch[&"remaining_minutes"]) + elif row.has(&"duration_minutes"): + kitchen_status_label.text += "\n調理 %d / %d分" % [row.get(&"progress_minutes", 0), row[&"duration_minutes"]] + kitchen_status_label.text += "\n食材は倉庫から補給し、完成料理は不足する食堂へ配送します。" + + +func _refresh_deliveries() -> void: + var lines: PackedStringArray = ["食堂の在庫"] + for row: Dictionary in _rows(&"destinations"): + var missing := _items_text(row.get(&"missing_items", {})) + lines.append("%s\n不足:%s" % [_facility_name(row), missing]) + var reasons := _reasons_text(row.get(&"reason_codes", [])) + if not reasons.is_empty(): + lines.append(reasons) + if _rows(&"destinations").is_empty(): + lines.append("食堂はまだありません。") + lines.append("\n配送状況") + for row: Dictionary in _rows(&"deliveries"): + var state := StringName(String(row.get(&"state", &"OPEN"))) + lines.append("%s → %s\n%s · %s" % [_facility_by_id(StringName(row.get(&"source_facility_id", &""))), _facility_by_id(StringName(row.get(&"destination_facility_id", &""))), _items_text(row.get(&"item_requirements", {})), _state_text(state)]) + if state not in [&"COMPLETED", &"CANCELLED"]: + lines.append("担当:%s" % (row.get(&"carrier_name", "未割当") if not String(row.get(&"carrier_name", "")).is_empty() else "未割当")) + var reasons := _reasons_text(row.get(&"reason_codes", [])) + if not reasons.is_empty(): + lines.append(reasons) + if _rows(&"deliveries").is_empty(): + lines.append("配送要求はまだありません。") + delivery_label.text = "\n".join(lines) + + +func submit_purchase() -> void: + if _has_pending(PURCHASE): + return + if _runtime == null or not _runtime.has_method(&"preview_supply_purchase"): + _set_result("仕入れサービスへ接続できません。", false) + return + var quote: Variant = _runtime.call(&"preview_supply_purchase", selected_warehouse_id, selected_item_id, int(quantity_spin.value)) + if not quote is Dictionary or not bool(quote.get(&"ok", false)): + _set_result(_reason_text(quote.get(&"reason", &"UNAVAILABLE")) if quote is Dictionary else "購入条件を確認できません。", false) + refresh() + return + _submit(PURCHASE, {&"warehouse_id": selected_warehouse_id, &"item_id": selected_item_id, &"quantity": int(quantity_spin.value)}) + + +func submit_production() -> void: + if selected_kitchen_id.is_empty() or _has_pending(PRODUCTION, selected_kitchen_id): + return + _submit(PRODUCTION, {&"facility_id": selected_kitchen_id, &"enabled": production_enabled.button_pressed, &"target_stock": int(target_spin.value)}) + + +func _submit(type: StringName, payload: Dictionary) -> void: + if _runtime == null or not _runtime.has_method(&"submit_command"): + _set_result("補給サービスへ接続できません。", false) + return + var context := {&"type": type, &"payload": payload.duplicate(true)} + var key := StringName("%s%08d" % [KEY_PREFIX, _sequence]) + _sequence += 1 + var result: Variant = _runtime.call(&"submit_command", type, payload, &"player", StringName(payload.get(&"facility_id", payload.get(&"warehouse_id", &""))), 75, -1, key) + if result == null or not result.has_method(&"is_success"): + _set_result("操作に応答がありません。", false) + return + var queued := false + var queue: Variant = _runtime.get(&"command_queue") + if queue != null: + 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_commands[int(result.command_id)] = context + _set_result("操作を受理しました。次の時間更新で反映します。", true) + else: + _show_result(result, context) + refresh() + + +func _show_result(result: Variant, context: Dictionary) -> void: + var details: Dictionary = result.details if result.details is Dictionary else {} + if not result.is_success(): + _set_result(_reason_text(details.get(&"reason", &"UNAVAILABLE")), false) + return + var payload: Dictionary = context[&"payload"] + if context[&"type"] == PRODUCTION: + var id := StringName(payload.get(&"facility_id", &"")) + _kitchen_drafts.erase(id) + _set_result("%s:自動生産%s、目標在庫 %dに変更しました。" % [_facility_by_id(id), "ON" if bool(details.get(&"enabled", payload.get(&"enabled", false))) else "OFF", details.get(&"target_stock", payload.get(&"target_stock", 0))], true) + else: + var cost_text := "(%dG)" % int(details[&"total_cost"]) if details.has(&"total_cost") else "" + _set_result("%sを%d仕入れました%s。" % [_item_name(StringName(payload.get(&"item_id", &""))), details.get(&"quantity", payload.get(&"quantity", 0)), cost_text], true) + + +func _synchronize_commands(queue: Variant) -> void: + _pending_commands.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", []): + var type := StringName(command.get("command_type", &"")) + if type in [PURCHASE, PRODUCTION]: + _pending_commands[int(command.get("command_id", 0))] = {&"type": type, &"payload": command.get("payload", {}).duplicate(true)} + + +func _has_pending(type: StringName, facility_id: StringName = &"") -> bool: + for context: Dictionary in _pending_commands.values(): + if context[&"type"] == type and (facility_id.is_empty() or StringName(context[&"payload"].get(&"facility_id", &"")) == facility_id): + return true + return false + + +func _on_command_completed(result: Variant) -> void: + if not _pending_commands.has(int(result.command_id)): + return + var context: Dictionary = _pending_commands[int(result.command_id)] + _pending_commands.erase(int(result.command_id)) + _show_result(result, context) + refresh() + + +func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: + if is_visible_in_tree(): + refresh() + + +func _on_purchase_quantity_changed(_value: float) -> void: + if not _refreshing: + refresh() + + +func _on_production_enabled_changed(_enabled: bool) -> void: + _store_kitchen_draft() + + +func _on_target_changed(_value: float) -> void: + _store_kitchen_draft() + + +func _store_kitchen_draft() -> void: + if _refreshing or selected_kitchen_id.is_empty(): + return + _kitchen_drafts[selected_kitchen_id] = {&"enabled": production_enabled.button_pressed, &"target_stock": int(target_spin.value)} + refresh() + + +func _fill_option(picker: OptionButton, rows: Array[Dictionary], id_key: StringName, selected: StringName, empty_text: String) -> StringName: + if picker.get_popup().visible: + return selected + picker.clear() + if rows.is_empty(): + picker.add_item(empty_text) + picker.set_item_metadata(0, &"") + return &"" + var index := 0 + for row: Dictionary in rows: + picker.add_item(_facility_name(row) if id_key == &"facility_id" else String(row.get(&"display_name", row.get(id_key, "")))) + var added := picker.item_count - 1 + picker.set_item_metadata(added, StringName(row.get(id_key, &""))) + if StringName(row.get(id_key, &"")) == selected: + index = added + picker.select(index) + return StringName(picker.get_item_metadata(index)) + + +func _rows(key: StringName) -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + var value: Variant = _report.get(key, []) + if value is Array: + for row: Variant in value: + if row is Dictionary: + rows.append(row) + return rows + + +func _row(key: StringName, id_key: StringName, id: StringName) -> Dictionary: + for row: Dictionary in _rows(key): + if StringName(row.get(id_key, &"")) == id: + return row + return {} + + +func _facility_name(row: Dictionary) -> String: + var label := String(row.get(&"display_name", row.get(&"facility_id", "施設"))) + var origin: Variant = row.get(&"origin", null) + if origin is Vector3i: + label += " (%d,%d・%s)" % [origin.x, origin.y, "裏" if int(row.get(&"layer", 0)) == 1 else "表"] + elif origin is Dictionary: + label += " (%d,%d・%s)" % [origin.get(&"x", 0), origin.get(&"y", 0), "裏" if int(row.get(&"layer", 0)) == 1 else "表"] + return label + + +func _facility_by_id(id: StringName) -> String: + for group: StringName in [&"warehouses", &"kitchens", &"destinations"]: + var row := _row(group, &"facility_id", id) + if not row.is_empty(): + return _facility_name(row) + return "施設未指定" if id.is_empty() else String(id) + + +func _item_name(id: StringName) -> String: + return String(_row(&"catalog", &"item_id", id).get(&"display_name", ITEM_NAMES.get(id, String(id)))) + + +func _items_text(value: Variant) -> String: + var parts: PackedStringArray = [] + if value is Dictionary: + for id: Variant in value: + parts.append("%s %s" % [_item_name(StringName(id)), value[id]]) + elif value is Array: + for row: Variant in value: + if row is Dictionary: + parts.append("%s %d" % [_item_name(StringName(row.get(&"item_id", &""))), row.get(&"quantity", row.get(&"missing", 0))]) + return " / ".join(parts) if not parts.is_empty() else "なし" + + +func _stock_text(value: Variant) -> String: + var lines: PackedStringArray = [] + if value is Array: + for row: Variant in value: + if row is Dictionary: + lines.append("%s %d(利用可 %d / 予約 %d / 配送中 %d)" % [_item_name(StringName(row.get(&"item_id", &""))), row.get(&"total", 0), row.get(&"available", 0), row.get(&"reserved", 0), row.get(&"in_transit", 0)]) + return "\n".join(lines) if not lines.is_empty() else "在庫なし" + + +func _state_text(value: Variant) -> String: + var state := StringName(String(value)) + return String(STATE_NAMES.get(state, REASONS.get(state, String(value)))) + + +func _reason_text(value: Variant) -> String: + var reason := StringName(String(value)) + return String(REASONS.get(reason, "状態を確認してください(%s)。" % reason)) + + +func _reasons_text(value: Variant) -> String: + var lines: PackedStringArray = [] + if value is Array: + for reason: Variant in value: + lines.append(_reason_text(reason)) + return "\n".join(lines) + + +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")) + status_requested.emit(message, success) + + +func _label(value: String, node_name := "") -> Label: + var label := Label.new() + if not node_name.is_empty(): + label.name = node_name + label.text = value + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + 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")) + return label + + +func _button(value: String, node_name: String, callback: Callable) -> Button: + var button := Button.new() + button.name = node_name + button.text = value + button.custom_minimum_size.y = 30 + button.add_theme_font_size_override("font_size", 13) + button.pressed.connect(callback) + if _style_button.is_valid(): + _style_button.call(button) + return button + + +func _option(node_name: String) -> OptionButton: + var picker := OptionButton.new() + picker.name = node_name + picker.fit_to_longest_item = false + picker.size_flags_horizontal = Control.SIZE_EXPAND_FILL + picker.custom_minimum_size.y = 30 + if _style_button.is_valid(): + _style_button.call(picker) + return picker + + +func _spin(node_name: String, minimum: int, value: int) -> SpinBox: + var spin := SpinBox.new() + spin.name = node_name + spin.min_value = minimum + spin.max_value = minimum + spin.step = 1 + spin.value = value + spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL + spin.custom_minimum_size.y = 30 + return spin diff --git a/scripts/ui/supply_management_panel.gd.uid b/scripts/ui/supply_management_panel.gd.uid new file mode 100644 index 0000000..bbd506d --- /dev/null +++ b/scripts/ui/supply_management_panel.gd.uid @@ -0,0 +1 @@ +uid://cu6exjai0evbv diff --git a/tests/mvp/test_runtime_content_progression.gd b/tests/mvp/test_runtime_content_progression.gd index 5ef28be..b525193 100644 --- a/tests/mvp/test_runtime_content_progression.gd +++ b/tests/mvp/test_runtime_content_progression.gd @@ -379,6 +379,7 @@ func _test_v3_migration_and_v4_tamper_guards() -> void: var v3: Dictionary = _runtime.create_snapshot() var v3_runtime: Dictionary = v3[&"systems"][&"runtime"] v3_runtime[&"runtime_schema_version"] = 3 + v3_runtime.erase(&"kitchen_production") v3_runtime.erase(&"operations") v3_runtime.erase(&"catalog_manifest") v3_runtime.erase(&"progression") @@ -395,10 +396,10 @@ 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"]) == 6, "v3 migration emits canonical Runtime schema v6") + _expect(int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 7, "v3 migration emits canonical Runtime schema v7") var digest := SimulationDigest.compute(migrated) - _expect(_runtime.restore_snapshot(migrated), "canonical v5 snapshot restores") - _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v5 restore is exact") + _expect(_runtime.restore_snapshot(migrated), "canonical v7 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "canonical v7 restore is exact") var progressed_legacy: Dictionary = v3.duplicate(true) progressed_legacy[&"game_state"][&"world_tree_rank"] = 2 diff --git a/tests/mvp/test_runtime_operations_acceptance.gd b/tests/mvp/test_runtime_operations_acceptance.gd index cd9403d..5ae1b8c 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 := 6 +const RUNTIME_SNAPSHOT_VERSION := 7 var _checks := 0 var _failures := 0 @@ -15,7 +15,7 @@ func _run() -> void: print("MVP: production Operations acceptance") _test_waiting_choice_and_command_exactly_once() _test_twenty_four_hour_actual_tick_smoke() - _test_full_partial_payroll_bankruptcy_and_v6_tamper() + _test_full_partial_payroll_bankruptcy_and_v7_tamper() _test_fourteen_day_boundary_snapshot_determinism() await process_frame if _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 v6" + "fixture emits Runtime snapshot schema v7" ) if enable_operations: var enabled: Dictionary = runtime.enable_production_operations() @@ -100,6 +100,7 @@ func _advance_operations_boundary_slice(fixture: Dictionary, amount: int) -> int func _align_operations_fixture_clocks(runtime: Variant, minute: int) -> void: runtime.inventory_service.current_minute = minute runtime.facility_service.current_minute = minute + runtime.kitchen_production.current_minute = minute runtime.guest_service.current_minute = minute runtime.staff_service.current_minute = minute runtime.authority_service.current_minute = minute @@ -207,7 +208,7 @@ func _test_waiting_choice_and_command_exactly_once() -> void: var command_snapshot: Dictionary = runtime.create_snapshot() _expect( runtime.restore_snapshot(command_snapshot), - "command history survives an exact v6 snapshot round trip" + "command history survives an exact v7 snapshot round trip" ) var restored_retry: CommandResult = runtime.submit_command( &"change_resources", @@ -247,12 +248,12 @@ func _test_twenty_four_hour_actual_tick_smoke() -> void: _expect( runtime.restore_snapshot(snapshot) and runtime.latest_digest == SimulationDigest.compute(snapshot), - "24-hour actual-tick v6 snapshot round trip is exact" + "24-hour actual-tick v7 snapshot round trip is exact" ) _free_fixture(fixture) -func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void: +func _test_full_partial_payroll_bankruptcy_and_v7_tamper() -> void: var fixture := _new_fixture(73_002, false) var runtime: Variant = fixture[&"runtime"] var clock: Variant = fixture[&"clock"] @@ -339,7 +340,7 @@ func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void: var terminal_digest := SimulationDigest.compute(terminal_snapshot) _expect( runtime.restore_snapshot(terminal_snapshot), - "BANKRUPT terminal v6 snapshot restores without clearing terminal state" + "BANKRUPT terminal v7 snapshot restores without clearing terminal state" ) clock.advance_minutes(180) _expect( @@ -352,32 +353,32 @@ func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void: "BANKRUPT cannot be acknowledged away" ) - _assert_v6_tamper_rejected( + _assert_v7_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"operations"][&"personal_action_points"] = 99, - "v6 rejects an out-of-range Operations action-point total" + "v7 rejects an out-of-range Operations action-point total" ) - _assert_v6_tamper_rejected( + _assert_v7_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"next_command_id"] = int( tampered[&"systems"][&"runtime"][&"next_command_id"] ) + 1, - "v6 rejects command high-water gaps" + "v7 rejects command high-water gaps" ) - _assert_v6_tamper_rejected( + _assert_v7_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: var accepted: Array = tampered[&"systems"][&"runtime"][&"command_queue"][&"accepted"] if not accepted.is_empty(): accepted[0][&"idempotency_key"] = &"tampered.accepted.key", - "v6 rejects accepted-command/idempotency cross-link tampering" + "v7 rejects accepted-command/idempotency cross-link tampering" ) - _assert_v6_tamper_rejected( + _assert_v7_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: @@ -385,26 +386,26 @@ func _test_full_partial_payroll_bankruptcy_and_v6_tamper() -> void: summaries[0][&"category_totals"][&"wages"] = int( summaries[0][&"category_totals"][&"wages"] ) + 1, - "v6 rejects DaySummary/payroll wage reconciliation tampering" + "v7 rejects DaySummary/payroll wage reconciliation tampering" ) - _assert_v6_tamper_rejected( + _assert_v7_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: - tampered[&"systems"][&"runtime"][&"unexpected_v6_field"] = true, - "v6 rejects unknown Runtime fields" + tampered[&"systems"][&"runtime"][&"unexpected_v7_field"] = true, + "v7 rejects unknown Runtime fields" ) - _assert_v6_tamper_rejected( + _assert_v7_tamper_rejected( runtime, terminal_snapshot, func(tampered: Dictionary) -> void: tampered[&"systems"][&"runtime"][&"operations"][&"coordinator"][&"unexpected_field"] = true, - "v6 rejects unknown Coordinator fields" + "v7 rejects unknown Coordinator fields" ) _free_fixture(fixture) -func _assert_v6_tamper_rejected( +func _assert_v7_tamper_rejected( runtime: Variant, canonical: Dictionary, mutator: Callable, @@ -453,7 +454,7 @@ func _test_fourteen_day_boundary_snapshot_determinism() -> void: var resumed_clock: Variant = resumed[&"clock"] var resumed_runtime: Variant = resumed[&"runtime"] var restored: bool = resumed_runtime.restore_snapshot(midpoint) - _expect(restored, "fresh Runtime restores the enabled v6 midpoint snapshot") + _expect(restored, "fresh Runtime restores the enabled v7 midpoint snapshot") var resumed_advanced := 0 if restored: resumed_advanced = _advance_operations_boundary_slice( diff --git a/tests/mvp/test_runtime_operations_integration.gd b/tests/mvp/test_runtime_operations_integration.gd index 1b4564b..993a5db 100644 --- a/tests/mvp/test_runtime_operations_integration.gd +++ b/tests/mvp/test_runtime_operations_integration.gd @@ -57,7 +57,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"]) == 6, "Runtime snapshot advances to schema v6") + _expect(int(disabled_runtime[&"runtime_schema_version"]) == 7, "Runtime snapshot advances to schema v7") _expect( disabled_runtime[&"operations"] == { &"wrapper_version": 1, @@ -360,6 +360,7 @@ func _test_daily_payroll_financials_and_crosslink_tamper() -> void: ) var downgraded: Dictionary = canonical.duplicate(true) downgraded[&"systems"][&"runtime"][&"runtime_schema_version"] = 4 + downgraded[&"systems"][&"runtime"].erase(&"kitchen_production") _expect( _rejects_atomically(downgraded), "relabelled enabled v5 cannot discard Operations history through v4 migration" diff --git a/tests/s1/test_runtime_infrastructure_integration.gd b/tests/s1/test_runtime_infrastructure_integration.gd index d8b1f37..a469d04 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 := 6 +const RUNTIME_SNAPSHOT_VERSION := 7 const INFRASTRUCTURE_SNAPSHOT_KEYS: Array[StringName] = [ &"excavation", &"spatial_subslots", @@ -61,7 +61,7 @@ func _run() -> void: _test_proxy_completion_projection_failure_is_atomic() _test_vertical_preflight_rejection() _test_demolition_projection_atomicity() - _test_v6_snapshot_strictness() + _test_current_snapshot_strictness() _test_v5_proxy_migration_determinism() _test_rank_expansion_preserves_route_union() _finish() @@ -112,12 +112,12 @@ func _test_runtime_surface(fixture: Dictionary) -> bool: ready = ready and service != null var snapshot: Dictionary = runtime.create_snapshot() var runtime_data: Dictionary = snapshot[&"systems"][&"runtime"] - var v6 := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION - _expect(v6, "Runtime emits infrastructure snapshot v6") - ready = ready and v6 + var current_version := int(runtime_data.get(&"runtime_schema_version", -1)) == RUNTIME_SNAPSHOT_VERSION + _expect(current_version, "Runtime emits schema v7 with infrastructure state") + ready = ready and current_version for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: var valid_dictionary := runtime_data.get(key, null) is Dictionary - _expect(valid_dictionary, "v6 stores strict %s state" % key) + _expect(valid_dictionary, "current Runtime stores strict %s state" % key) ready = ready and valid_dictionary if not ready: print(" PENDING: Runtime v6 infrastructure surface is not connected yet; behavioral cases skipped.") @@ -290,7 +290,7 @@ func _test_excavation_tick_pause_resume_and_completion() -> void: _expect( runtime.restore_snapshot(paused_snapshot) and runtime.create_snapshot() == paused_snapshot, - "paused excavation survives an exact Runtime v6 save/restore" + "paused excavation survives an exact current Runtime save/restore" ) _expect( runtime.excavation_service.get_job(excavation_job_id).get(&"state", &"") @@ -731,7 +731,7 @@ func _test_demolition_projection_atomicity() -> void: _free_fixture(fixture) -func _test_v6_snapshot_strictness() -> void: +func _test_current_snapshot_strictness() -> void: var fixture := _new_fixture(81_060) var runtime: Variant = fixture[&"runtime"] _expect(_promote_rank_two(fixture, &"infra.snapshot.rank"), "snapshot fixture reaches Rank 2") @@ -750,10 +750,10 @@ func _test_v6_snapshot_strictness() -> void: _expect(built[&"ok"], "snapshot fixture stores one completed projection") var snapshot: Dictionary = runtime.create_snapshot() var expected_digest := SimulationDigest.compute(snapshot) - _expect(runtime.restore_snapshot(snapshot), "valid Runtime v6 snapshot restores") + _expect(runtime.restore_snapshot(snapshot), "valid Runtime v7 snapshot restores") _expect( runtime.create_snapshot() == snapshot and runtime.latest_digest == expected_digest, - "Runtime v6 round trip is exact" + "Runtime v7 round trip is exact" ) _assert_runtime_restore_rejected( @@ -761,7 +761,7 @@ func _test_v6_snapshot_strictness() -> void: snapshot, func(candidate: Dictionary) -> void: candidate[&"systems"][&"runtime"][&"unknown_infrastructure"] = {}, - "unknown v6 Runtime field" + "unknown v7 Runtime field" ) for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: _assert_runtime_restore_rejected( @@ -837,6 +837,7 @@ func _test_v5_proxy_migration_determinism() -> void: var legacy_runtime: Dictionary = legacy[&"systems"][&"runtime"] var stripped_v6_events := _strip_v6_infrastructure_domain_history(legacy_runtime) legacy_runtime[&"runtime_schema_version"] = 5 + legacy_runtime.erase(&"kitchen_production") for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: legacy_runtime.erase(key) legacy_runtime[&"path"] = baseline[&"runtime"].path_service.create_snapshot() @@ -854,8 +855,8 @@ 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"]) == 6, - "v5 migration is immediately re-emitted as v6" + int(first_migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 7, + "v5 migration is immediately re-emitted as v7" ) _expect( first_runtime.facility_infrastructure_projector.has_projection(instance_id) @@ -1056,6 +1057,8 @@ func _strip_v6_infrastructure_domain_history(runtime_data: Dictionary) -> int: func _legacy_v5_infrastructure_shape_is_clean(runtime_data: Dictionary) -> bool: + if runtime_data.has(&"kitchen_production"): + return false for key: StringName in INFRASTRUCTURE_SNAPSHOT_KEYS: if runtime_data.has(key) or runtime_data.has(String(key)): return false diff --git a/tests/s2/test_kitchen_production.gd b/tests/s2/test_kitchen_production.gd new file mode 100644 index 0000000..c8b5a31 --- /dev/null +++ b/tests/s2/test_kitchen_production.gd @@ -0,0 +1,352 @@ +extends SceneTree + +const Catalog = preload("res://scripts/supply/supply_catalog.gd") +const KitchenService = preload("res://scripts/supply/kitchen_production_service.gd") + +class RejectingOutputInventory: + extends InventoryService + + var reject_output := true + + func add_stack(owner: StringName, item: StringName, quantity: int, quality: int = 100, + expires_at: int = -1, created_at: int = -1, unit_weight: int = 1, stack_id: StringName = &"") -> Dictionary: + if reject_output and item == &"ingredient/basic_meal": + return {&"ok": false, &"code": &"OUTPUT_REJECTED"} + return super.add_stack(owner, item, quantity, quality, expires_at, created_at, unit_weight, stack_id) + +var _checks := 0 +var _failures := 0 +var _registry: DefinitionRegistry + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P1: independent kitchen production and supply catalogue") + _registry = DefinitionRegistry.new() + _expect(_registry.rebuild()[&"valid"], "facility definitions load") + _test_catalog() + _test_batch_and_target() + _test_interruptions() + _test_expiry_and_retry() + _test_capacity() + _test_output_failure_rollback() + _test_snapshot_round_trip() + _test_snapshot_rejections() + _test_completed_batch_counter_guard() + _test_crosslinks_and_removal() + _test_plan_removal() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _fixture(inventory: InventoryService = null) -> Dictionary: + if inventory == null: + inventory = InventoryService.new() + var facilities := FacilityService.new() + var result := facilities.create_instance({ + &"instance_id": &"kitchen.one", &"definition_id": &"kitchen", + &"local_inventory_id": &"kitchen.stock", &"staff_min": 1, &"staff_max": 3, + &"active_staff_ids": [&"cook"], &"base_quality": 55, + }) + _expect(result.is_success(), "test kitchen registers") + var kitchen := KitchenService.new() + kitchen.configure(inventory, facilities, _registry, 0) + return {&"inventory": inventory, &"facilities": facilities, &"service": kitchen} + + +func _test_catalog() -> void: + var raw := Catalog.get_item(Catalog.RAW_FOOD) + _expect(raw[&"unit_price"] == 3 and raw[&"unit_weight"] == 1 and raw[&"shelf_life_minutes"] == 1440, "raw food has explicit P1 price, weight, and expiry") + _expect(raw[&"purchasable"], "raw food can be purchased") + var meal := Catalog.get_item(Catalog.BASIC_MEAL) + _expect(not meal[&"purchasable"] and meal[&"shelf_life_minutes"] == 720, "meals are produced and expire after 720 minutes") + raw[&"unit_price"] = 999 + _expect(Catalog.get_item(Catalog.RAW_FOOD)[&"unit_price"] == 3, "catalogue callers cannot mutate global prices") + _expect(Catalog.get_item(&"unknown").is_empty(), "unknown items have no implicit free purchase") + _expect(Catalog.get_purchase_items().size() == 1, "only raw food is offered for purchase") + _expect(Catalog.storage_capacity(&"warehouse") == 100 and Catalog.storage_capacity(&"kitchen") == 40 and Catalog.storage_capacity(&"cafeteria") == 24, "owner storage capacities are distinct from service capacities") + _expect(Catalog.storage_capacity(&"unknown") == 0, "unsupported facilities have no supply storage") + var recipe := Catalog.get_recipe() + _expect(recipe[&"input_quantity"] == 4 and recipe[&"output_quantity"] == 4 and recipe[&"duration_minutes"] == 15, "recipe preserves existing facility tuning") + + +func _test_batch_and_target() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + _expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"DISABLED", "kitchen waits for a player production plan") + _expect(not kitchen.set_plan(&"missing", true, 8)[&"ok"], "unknown facility plan is rejected") + for target: int in [0, 3, 5, 17]: + _expect(not kitchen.set_plan(&"kitchen.one", true, target)[&"ok"], "non-batch target %d is rejected" % target) + _expect(kitchen.set_plan(&"kitchen.one", true, 8)[&"ok"], "ordinary target enables production") + _expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"NO_INPUT", "missing inputs are visible") + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 12, 90, 2000, 0) + _expect(kitchen.advance_one_minute(1)[&"events"][0][&"type"] == &"BATCH_STARTED", "production starts without guest or wallet") + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 12 and inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 8, "start reserves without consuming") + _tick(kitchen, 14) + _expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 14, "14 operating minutes do not complete 15-minute recipe") + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 0, "recipe output is not available prematurely") + var finished := kitchen.advance_one_minute(16) + _expect(finished[&"events"][0][&"type"] == &"BATCH_COMPLETED", "15 operating minutes complete batch") + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 8 and inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "exact input consumption and output quantity") + for stack: Dictionary in inventory.get_stacks(&"kitchen.stock"): + if stack[&"item_id"] == Catalog.BASIC_MEAL: + _expect(stack[&"expires_at"] == 736 and stack[&"created_at"] == 16 and stack[&"quality"] == 55, "output captures completion time, recipe quality, and shelf life") + var snapshot := kitchen.create_snapshot() + _expect(kitchen.advance_one_minute(16)[&"replay"] and kitchen.create_snapshot() == snapshot, "duplicate minute cannot duplicate output") + _expect(not kitchen.advance_one_minute(18)[&"ok"] and kitchen.create_snapshot() == snapshot, "time jumps cannot invent operating time") + _tick(kitchen, 16) + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 8, "second batch reaches target") + _tick(kitchen, 3) + _expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"TARGET_REACHED" and inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 4, "target prevents speculative overproduction") + _expect(kitchen.validate_crosslinks(), "completed batches leave no orphan active holds") + _expect(inventory.validate_invariants()[&"ok"], "normal production preserves inventory invariants") + + +func _test_interruptions() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + var facilities: FacilityService = f[&"facilities"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 4) + _tick(kitchen, 6) + _expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 5, "partial progress exists before interruption") + var cases := [ + {&"off": {&"active_staff_ids": []}, &"on": {&"active_staff_ids": [&"cook"]}, &"state": &"NO_STAFF"}, + {&"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) + _tick(kitchen, 3) + var report := kitchen.get_report(&"kitchen.one") + _expect(report[&"state"] == item[&"state"] and report[&"progress_minutes"] == 5, "%s pauses without erasing work" % item[&"state"]) + _expect(inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 0, "interruption retains the batch's materials") + facilities.set_operational_status(&"kitchen.one", item[&"on"], kitchen.current_minute) + kitchen.set_plan(&"kitchen.one", false, 4) + _tick(kitchen, 2) + _expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 5, "player pause also retains progress") + 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") + + +func _test_expiry_and_retry() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 2, 100, 5, 0) + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 6, 100, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 4) + _tick(kitchen, 2) + _expect(kitchen.get_report(&"kitchen.one")[&"active_batch"][&"reservation_ids"].size() == 2, "FEFO can reserve a batch across two stacks") + kitchen.set_plan(&"kitchen.one", false, 4) + _tick(kitchen, 2) + var failed := kitchen.advance_one_minute(5) + _expect(failed[&"events"][0][&"type"] == &"BATCH_FAILED", "input expiry fails a batch even while player paused it") + _expect(inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD, 5) == 6, "failure releases the still-fresh part of a split reservation") + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 0, "expired food cannot become fresh meals") + _expect(kitchen.validate_crosslinks(), "expired batch has no orphan holds") + kitchen.set_plan(&"kitchen.one", true, 4) + _expect(kitchen.advance_one_minute(6)[&"events"][0][&"type"] == &"BATCH_STARTED", "fresh input can retry on the following minute") + _tick(kitchen, 15) + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "retry completes using fresh ingredients") + + +func _test_capacity() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0) + inventory.add_stack(&"kitchen.stock", &"other", 40) + kitchen.set_plan(&"kitchen.one", true, 4) + _tick(kitchen, 16) + _expect(kitchen.get_report(&"kitchen.one")[&"state"] == &"OUTPUT_FULL", "overfull storage waits after cooking") + _expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 15, "output wait retains completed work") + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 4 and inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 0, "full output never consumes input") + _tick(kitchen, 2) + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 4, "repeated output waits are non-destructive") + var removal := inventory.reserve_item(&"kitchen.stock", &"other", 4, &"test", &"remove") + inventory.consume_reservations(removal[&"reservation_ids"]) + _tick(kitchen, 1) + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4 and Catalog.inventory_weight(inventory, &"kitchen.stock") == 40, "a same-weight recipe can finish at exact storage capacity") + + +func _test_snapshot_round_trip() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 8, 90, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 8) + _tick(kitchen, 8) + var copy := _fixture() + var restored_inventory: InventoryService = copy[&"inventory"] + var restored: KitchenProductionService = copy[&"service"] + _expect(restored_inventory.restore_snapshot(inventory.create_snapshot()), "inventory round trip restores reserved input") + _expect(restored.restore_snapshot(kitchen.create_snapshot()), "production round trip restores against matching inventory") + _expect(restored.create_snapshot() == kitchen.create_snapshot() and restored.validate_crosslinks(), "plan and batch round trip exactly") + _tick(kitchen, 24) + _tick(restored, 24) + _expect(restored.create_snapshot() == kitchen.create_snapshot(), "restored production follows identical completion times") + _expect(restored_inventory.create_snapshot() == inventory.create_snapshot(), "restore cannot duplicate or lose ingredients and meals") + var public_report := restored.get_report(&"kitchen.one") + public_report[&"target_stock"] = 16 + var public_plans := restored.get_plans() + public_plans[0][&"enabled"] = false + _expect(restored.get_plan(&"kitchen.one")[&"enabled"] and restored.get_plan(&"kitchen.one")[&"target_stock"] == 8, "reports are defensive copies") + + +func _test_output_failure_rollback() -> void: + var inventory := RejectingOutputInventory.new() + var f := _fixture(inventory) + var kitchen: KitchenProductionService = f[&"service"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 4) + _tick(kitchen, 1) + var reserved_inventory := inventory.create_snapshot() + _tick(kitchen, 15) + _expect(inventory.create_snapshot() == reserved_inventory, "failed output insert rolls input consumption back atomically") + _expect(kitchen.get_report(&"kitchen.one")[&"progress_minutes"] == 15 and kitchen.validate_crosslinks(), "output failure keeps completed work and a valid input hold") + inventory.reject_output = false + _tick(kitchen, 1) + _expect(inventory.total_quantity(&"kitchen.stock", Catalog.RAW_FOOD) == 0 and inventory.total_quantity(&"kitchen.stock", Catalog.BASIC_MEAL) == 4, "recovered output inserts exactly one batch") + _expect(kitchen.validate_crosslinks() and inventory.validate_invariants()[&"ok"], "fault recovery preserves both service invariants") + + +func _test_snapshot_rejections() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 8) + _tick(kitchen, 4) + var original := kitchen.create_snapshot() + var inventory_before := inventory.create_snapshot() + var mutations: Array[Dictionary] = [] + for pair: Array in [[&"version", 2], [&"current_minute", -1], [&"current_minute", 4.0], [&"next_batch_sequence", 1], [&"plans", {}]]: + var malformed := original.duplicate(true) + malformed[pair[0]] = pair[1] + mutations.append(malformed) + for pair: Array in [[&"enabled", 1], [&"target_stock", 5], [&"target_stock", "8"], [&"facility_id", "missing"]]: + var malformed := original.duplicate(true) + malformed[&"plans"][0][pair[0]] = pair[1] + mutations.append(malformed) + for pair: Array in [[&"progress_minutes", 16], [&"progress_minutes", 4], [&"started_at", -1], [&"quality", 101], [&"batch_id", "kitchen.batch:00000000"], [&"inventory_id", "other"], [&"reservation_ids", []], [&"reservation_ids", ["missing"]]]: + var malformed := original.duplicate(true) + malformed[&"active_batches"][0][pair[0]] = pair[1] + mutations.append(malformed) + var duplicate_plan := original.duplicate(true) + duplicate_plan[&"plans"].append(duplicate_plan[&"plans"][0].duplicate(true)) + mutations.append(duplicate_plan) + var duplicate_batch := original.duplicate(true) + duplicate_batch[&"active_batches"].append(duplicate_batch[&"active_batches"][0].duplicate(true)) + mutations.append(duplicate_batch) + var duplicate_reservation := original.duplicate(true) + duplicate_reservation[&"active_batches"][0][&"reservation_ids"].append(duplicate_reservation[&"active_batches"][0][&"reservation_ids"][0]) + mutations.append(duplicate_reservation) + var missing_batch := original.duplicate(true) + missing_batch[&"active_batches"] = [] + mutations.append(missing_batch) + var unknown_field := original.duplicate(true) + unknown_field[&"arbitrary"] = true + mutations.append(unknown_field) + for i: int in range(mutations.size()): + _expect(not kitchen.restore_snapshot(mutations[i]) and not kitchen.last_restore_error.is_empty(), "malformed snapshot %d is rejected" % i) + _expect(kitchen.create_snapshot() == original and inventory.create_snapshot() == inventory_before, "snapshot rejection %d leaves both services intact" % i) + + +func _test_crosslinks_and_removal() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + var facilities: FacilityService = f[&"facilities"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 8, 100, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 8) + _tick(kitchen, 2) + var saved := kitchen.create_snapshot() + var id := StringName(saved[&"active_batches"][0][&"reservation_ids"][0]) + var reservation := inventory.get_reservation(id) + reservation.requester_id = &"other.batch" + _expect(not kitchen.validate_crosslinks() and not kitchen.restore_snapshot(saved), "a different requester cannot steal a production reservation") + reservation.requester_id = StringName(saved[&"active_batches"][0][&"batch_id"]) + reservation.quantity = 3 + _expect(not kitchen.validate_crosslinks(), "partial ingredient allocation is rejected") + reservation.quantity = 4 + var extra := inventory.reserve_item(&"kitchen.stock", Catalog.RAW_FOOD, 1, reservation.requester_id, Catalog.PRODUCTION_PURPOSE, 2) + _expect(not kitchen.validate_crosslinks() and not kitchen.restore_snapshot(saved), "orphan production reservations are rejected") + inventory.release_reservation(extra[&"reservation_ids"][0], &"", 2) + _expect(kitchen.validate_crosslinks(), "removing orphan restores valid links") + facilities.unregister_instance(&"kitchen.one") + var removed := kitchen.advance_one_minute(3) + _expect(removed[&"events"][0][&"reason"] == &"FACILITY_REMOVED", "removed kitchen fails its batch") + _expect(kitchen.get_plans().is_empty() and inventory.available_quantity(&"kitchen.stock", Catalog.RAW_FOOD, 3) == 8, "removal releases input holds and retires the plan") + _expect(kitchen.validate_crosslinks(), "facility removal leaves no active production orphan") + + +func _test_plan_removal() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + var facilities: FacilityService = f[&"facilities"] + _expect(kitchen.remove_plan(&"missing")[&"ok"], "removing an absent plan is idempotent") + kitchen.set_plan(&"kitchen.one", true, 4) + _expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"PLAN_ENABLED", "enabled plan cannot be forgotten") + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 4, 100, 1000, 0) + _tick(kitchen, 1) + kitchen.set_plan(&"kitchen.one", false, 4) + _expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"ACTIVE_BATCH", "paused batch prevents plan removal") + kitchen.set_plan(&"kitchen.one", true, 4) + _tick(kitchen, 15) + kitchen.set_plan(&"kitchen.one", false, 4) + facilities.unregister_instance(&"kitchen.one") + _expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"REMOVED", "disabled idle plan can be removed after facility demolition") + _expect(kitchen.get_plans().is_empty(), "removed plan is omitted from saved state") + _expect(kitchen.remove_plan(&"kitchen.one")[&"reason"] == &"NO_PLAN", "repeated removal does not recreate a plan") + + +func _test_completed_batch_counter_guard() -> void: + var f := _fixture() + var kitchen: KitchenProductionService = f[&"service"] + var inventory: InventoryService = f[&"inventory"] + inventory.add_stack(&"kitchen.stock", Catalog.RAW_FOOD, 8, 100, 1000, 0) + kitchen.set_plan(&"kitchen.one", true, 4) + _tick(kitchen, 16) + var canonical := kitchen.create_snapshot() + _expect(canonical[&"active_batches"].is_empty() and canonical[&"next_batch_sequence"] == 2, "completed batch retains its next sequence without an active batch") + var inventory_before := inventory.create_snapshot() + var reused := canonical.duplicate(true) + reused[&"next_batch_sequence"] = 1 + _expect(not kitchen.restore_snapshot(reused), "closed input reservations forbid rewinding the batch counter") + _expect(kitchen.create_snapshot() == canonical and inventory.create_snapshot() == inventory_before, "counter rejection leaves service and inventory unchanged") + kitchen.set_plan(&"kitchen.one", false, 4) + kitchen.remove_plan(&"kitchen.one") + var without_plan := kitchen.create_snapshot() + without_plan[&"next_batch_sequence"] = 1 + _expect(not kitchen.restore_snapshot(without_plan), "removed plans cannot erase durable batch identity evidence") + _expect(kitchen.validate_crosslinks(), "terminal reservations remain valid after plan removal") + kitchen.set_plan(&"kitchen.one", true, 8) + _tick(kitchen, 1) + _expect(kitchen.get_report(&"kitchen.one")[&"active_batch"][&"batch_id"] == "kitchen.batch:00000002", "the next actual batch uses a fresh identity") + + +func _tick(kitchen: KitchenProductionService, count: int) -> void: + for ignored: int in range(count): + var result := kitchen.advance_one_minute(kitchen.current_minute + 1) + if not result[&"ok"]: + _expect(false, "minute advance succeeds: %s" % result) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/s2/test_kitchen_production.gd.uid b/tests/s2/test_kitchen_production.gd.uid new file mode 100644 index 0000000..ae91bc4 --- /dev/null +++ b/tests/s2/test_kitchen_production.gd.uid @@ -0,0 +1 @@ +uid://dpmobtia1rvj5 diff --git a/tests/s3/test_runtime_path_traffic.gd b/tests/s3/test_runtime_path_traffic.gd index 19e8e7e..f474450 100644 --- a/tests/s3/test_runtime_path_traffic.gd +++ b/tests/s3/test_runtime_path_traffic.gd @@ -1,6 +1,7 @@ extends SceneTree const POST_V2_RUNTIME_KEYS := [ + &"kitchen_production", &"movement_credits", &"catalog_manifest", &"progression", @@ -48,7 +49,7 @@ func _run() -> void: _test_occupied_cell_graph_rebuild_is_rejected() _test_stuck_guest_and_staff_recovery() _test_event_recovery_path_transaction() - _test_v6_snapshot_crosslinks_and_legacy_v2() + _test_current_snapshot_crosslinks_and_legacy_v2() _test_dispatcher_request_ownership_crosslinks() _test_twenty_four_hour_projection_integrity() _cleanup() @@ -292,7 +293,7 @@ func _test_event_recovery_path_transaction() -> void: ) -func _test_v6_snapshot_crosslinks_and_legacy_v2() -> void: +func _test_current_snapshot_crosslinks_and_legacy_v2() -> void: _reset_runtime(8_106) var origin := Vector3i(-3, 0, 0) _runtime.path_service.configure_cell_traffic(origin, 1, 0, false) @@ -301,11 +302,11 @@ func _test_v6_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"]) == 6, "Runtime snapshot advertises schema v6") + _expect(int(runtime_data[&"runtime_schema_version"]) == 7, "Runtime snapshot advertises schema v7") _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 v6 snapshot restores") - _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v6 save/load is exact") + _expect(_runtime.restore_snapshot(snapshot), "valid mid-move v7 snapshot restores") + _expect(SimulationDigest.compute(_runtime.create_snapshot()) == digest, "mid-move v7 save/load is exact") var corruptions: Array[Dictionary] = [] var bad_actor := snapshot.duplicate(true) @@ -349,16 +350,16 @@ func _test_v6_snapshot_crosslinks_and_legacy_v2() -> void: traffic[&"temporary_block"] = true _expect( _is_true_runtime_v2_shape(legacy_data), - "legacy fixture removes every v3-v6 aggregate and dedicated history instead of relabelling v6" + "legacy fixture removes every v3-v7 aggregate and dedicated history instead of relabelling v7" ) _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"]) == 6 + int(migrated[&"systems"][&"runtime"][&"runtime_schema_version"]) == 7 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"]), - "legacy migration emits canonical v6 ownership while preserving blocked traffic state" + "legacy migration emits canonical v7 ownership while preserving blocked traffic state" ) diff --git a/tests/s5/test_runtime_defense_cycle.gd b/tests/s5/test_runtime_defense_cycle.gd index c1f7249..a73197e 100644 --- a/tests/s5/test_runtime_defense_cycle.gd +++ b/tests/s5/test_runtime_defense_cycle.gd @@ -793,6 +793,7 @@ func _test_snapshot_replay_corruption_and_legacy_migration() -> void: _reset_runtime(7_606, 9, 17) var legacy: Dictionary = _runtime.create_snapshot() legacy[&"systems"][&"runtime"].erase(&"runtime_schema_version") + legacy[&"systems"][&"runtime"].erase(&"kitchen_production") legacy[&"systems"][&"runtime"].erase(&"operations") legacy[&"systems"][&"runtime"].erase(&"defense") legacy[&"systems"][&"runtime"][&"rng_streams"].erase("defense") diff --git a/tests/supply/test_normal_supply_chain.gd b/tests/supply/test_normal_supply_chain.gd new file mode 100644 index 0000000..fd0f8b3 --- /dev/null +++ b/tests/supply/test_normal_supply_chain.gd @@ -0,0 +1,202 @@ +extends SceneTree + +## A real inn from the default 5,000 G / 50 materials. No inserted guests, +## inventory, employees, completed construction, or free resource commands. +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _ids: Dictionary = {} +var _saw_loaded := false +var _saw_movement := false +var _saw_batch := false +var _save_checked := false +var _last_porter_cell := Vector3i.ZERO +var _save_path := "user://tests/normal_supply_%d/cargo" % OS.get_process_id() +var _main: Control + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _clock.reset(1, 6, 0) + _state.reset() + _runtime.initialize(_clock, _state, 424242) + _clock.set_paused(true) + root.size = Vector2i(1440, 810) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + _main.set("manual_save_path", "user://tests/supply_normal_%d/manual" % OS.get_process_id()) + root.add_child(_main) + await process_frame + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect(_state.get_resource(&"gold") == 5000 and _state.get_resource(&"materials") == 50, "ordinary starting resources") + for row: Array in [[&"reception", Vector3i(-8, 0, 0)], [&"cafeteria", Vector3i(2, 0, 0)], [&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]: + if not _command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}): + _finish() + return + await _advance(180) + _command(&"build_facility", {&"definition_id": &"standard_room", &"origin": Vector3i(-8, 2, 0)}) + _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 1, 0)}) + await _advance(60) + _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(-4, 2, 0)}) + await _advance(10) + for x: int in [-5, -6]: + _command(&"build_facility", {&"definition_id": &"guest_corridor", &"origin": Vector3i(x, 2, 0)}) + await _advance(10) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + _ids[facility.definition_id] = facility.instance_id + for assignment: Array in [[&"staff_akane", &"reception"], [&"staff_gina", &"warehouse"], [&"staff_chika", &"kitchen"], [&"staff_iris", &"cafeteria"], [&"staff_juno", &"cafeteria"]]: + if not _command(&"hire_staff_candidate", {&"candidate_id": assignment[0]}) or not _command(&"set_staff_work_assignment", {&"staff_id": assignment[0], &"facility_id": _ids[assignment[1]], &"template_id": &"DAY"}): + _finish() + return + _expect(_state.get_resource(&"materials") == 4, "all buildings and the staff door fit within the starting material budget") + var warehouse: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"warehouse"]) + var cash_before: int = _state.get_resource(&"gold") + _command(&"purchase_supplies", {&"warehouse_id": warehouse.instance_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40}) + _expect(_state.get_resource(&"gold") == cash_before - 120 and _runtime.inventory_service.total_quantity(warehouse.local_inventory_id, SupplyCatalog.RAW_FOOD) == 40, "40 raw food costs exactly 120 G and arrives only in the warehouse") + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8}) + await _advance(120) + _expect(_saw_movement and _saw_loaded, "an employed porter walks the real route and carries purchased food") + _expect(_saw_batch, "the kitchen starts autonomous production without a guest request") + _expect(_save_checked, "a mid-delivery disk save restores ownership and replays exactly") + _expect(_completed_deliveries(SupplyCatalog.RAW_FOOD) > 0 and _completed_deliveries(SupplyCatalog.BASIC_MEAL) > 0, "both warehouse-to-kitchen and kitchen-to-cafeteria deliveries complete") + _expect(_sales(&"cafeteria") > 0, "natural guests pay for meals made from purchased food") + await _capture() + if OS.get_environment("THE_TREE_SUPPLY_PREVIEW") == "1": + _start_preview() + return + if OS.get_environment("THE_TREE_SUPPLY_CAPTURE_ONLY") == "1": + _finish() + return + for day: int in range(1, 4): + await _advance(360 + 1440 * day - _clock.absolute_minute) + var summaries: Array = _runtime.operations_coordinator.get_day_summaries() + _expect(summaries.size() == day and bool(summaries[-1].get(&"balanced", false)), "business day %d closes with a reconciled ledger" % day) + _expect(_state.get_resource(&"gold") >= 0, "day %d retains cash after real payroll and purchases" % day) + if day < 3: + _command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 40}) + _expect(_sales(&"standard_room") > 0, "the same inn earns lodging 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_path := ProjectSettings.globalize_path("res://artifacts/supply-chain/normal-play-report.json") + DirAccess.make_dir_recursive_absolute(evidence_path.get_base_dir()) + var file := FileAccess.open(evidence_path, FileAccess.WRITE) + if file != null: + file.store_string(JSON.stringify(evidence, "\t")) + print("NORMAL_SUPPLY_REPORT ", JSON.stringify({"cash": evidence["cash"], "meal_sales": evidence["meal_sales"], "raw_deliveries": evidence["raw_deliveries"], "meal_deliveries": evidence["meal_deliveries"]})) + _finish() + + +func _advance(minutes: int) -> void: + for index: int in maxi(minutes, 0): + var before: int = _clock.absolute_minute + _clock.advance_minutes(1) + if _clock.absolute_minute != before + 1: + _expect(false, "clock must progress: %s" % _runtime.get_operations_report()) + return + var porter: StaffRuntime = _runtime.staff_service.get_staff(&"staff_gina") + if porter != null: + _saw_movement = _saw_movement or porter.current_cell != _last_porter_cell + _last_porter_cell = porter.current_cell + if not porter.carried_inventory_id.is_empty(): + _saw_loaded = true + if not _save_checked: + _check_loaded_save() + if _ids.has(&"kitchen"): + _saw_batch = _saw_batch or not _runtime.kitchen_production.get_report(_ids[&"kitchen"]).get(&"active_batch", {}).is_empty() + if index % 120 == 119: + print("SUPPLY_PROGRESS ", _clock.formatted_time(), " meals=", _sales(&"cafeteria")) + await process_frame + + +func _check_loaded_save() -> void: + _save_checked = true + var saved: Dictionary = _runtime.save_to_path(_save_path) + _expect(bool(saved.get(&"ok", false)), "mid-delivery save writes to an isolated slot") + var before: Dictionary = _runtime.create_snapshot() + var loaded: Dictionary = _runtime.load_from_path(_save_path) + _expect(bool(loaded.get(&"ok", false)), "mid-delivery disk restore: %s" % loaded) + var normalized: Dictionary = _runtime.create_snapshot() + _clock.advance_minutes(5) + var expected: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(normalized), "mid-delivery checkpoint rewinds") + _clock.advance_minutes(5) + _expect(_runtime.create_snapshot() == expected, "mid-delivery replay preserves exact inventory, tasks, positions, and ledger") + _expect(_runtime.restore_snapshot(before), "resume the observed minute after replay verification") + + +func _completed_deliveries(item: StringName) -> int: + var count := 0 + for request: LogisticsRequest in _runtime.logistics_requests.values(): + if String(request.request_id).begins_with("supply.") and request.state == LogisticsRequest.State.COMPLETED and request.item_requirements.has(item): + count += 1 + return count + + +func _sales(definition_id: StringName) -> int: + var total := 0 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + if entry.category == &"SERVICE_SALE" and entry.source_entity_id == _ids.get(definition_id, &"missing"): + total += entry.amount + return total + + +func _command(type: StringName, payload: Dictionary) -> bool: + var result: CommandResult = _runtime.submit_command(type, payload) + _expect(result.is_success(), "%s %s: %s" % [type, payload, result.message]) + return result.is_success() + + +func _capture() -> void: + if OS.get_environment("THE_TREE_SUPPLY_CAPTURE") != "1": + return + await process_frame + _main.call("_activate_management_mode", &"product") + var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + for tab: StringName in [&"purchase", &"kitchen", &"delivery"]: + panel.select_tab(tab) + await process_frame + await RenderingServer.frame_post_draw + var path := ProjectSettings.globalize_path("res://artifacts/supply-chain/%s.png" % tab) + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + root.get_texture().get_image().save_png(path) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error("FAIL: " + message) + + +func _finish() -> void: + SaveService.delete_save(_save_path) + print("Normal supply chain: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) + + +func _start_preview() -> void: + if _failures > 0: + _finish() + return + SaveService.delete_save(_save_path) + root.title = "The Tree · 仕入れと厨房の試遊(保存なし)" + for node: Node in _main.find_children("*", "Button", true, false): + var button := node as Button + for connection: Dictionary in button.pressed.get_connections(): + var callback: Callable = connection[&"callable"] + if callback.get_method() in [&"_save_manual", &"_load_manual"]: + button.disabled = true + button.tooltip_text = "この試遊は保存しません。" + _main.call("_activate_management_mode", &"product") + var panel := _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + panel.select_tab(&"kitchen") + _main.call("_set_status", "通常予算から育てた宿の試遊です。Spaceで再開し、商品タブで仕入れ・調理・配送を確認できます(保存なし)。", true, true) + _clock.set_paused(true) + print("SUPPLY_PREVIEW_READY checks=%d failures=0" % _checks) diff --git a/tests/supply/test_normal_supply_chain.gd.uid b/tests/supply/test_normal_supply_chain.gd.uid new file mode 100644 index 0000000..9333241 --- /dev/null +++ b/tests/supply/test_normal_supply_chain.gd.uid @@ -0,0 +1 @@ +uid://bb4vjqk7okwwa diff --git a/tests/supply/test_supply_logistics_bridge.gd b/tests/supply/test_supply_logistics_bridge.gd new file mode 100644 index 0000000..9119d5f --- /dev/null +++ b/tests/supply/test_supply_logistics_bridge.gd @@ -0,0 +1,315 @@ +extends SceneTree + +## Real construction, hired porter and purchased stock; failure injection is +## limited to route closures, capacity pressure and expired cargo. +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _source: StringName +var _destination: StringName +var _loaded: Dictionary +var _reserved: Dictionary +const REQUEST := &"supply.bridge_test" +const PORTER := &"staff_gina" + + +class LateFailInventory extends InventoryService: + var fail_delivery := true + + func transfer_available(source_inventory_id: StringName, destination_inventory_id: StringName, + item_id: StringName, quantity: int, operation_id: StringName = &"", absolute_minute: int = -1) -> Dictionary: + var result := super.transfer_available(source_inventory_id, destination_inventory_id, item_id, quantity, operation_id, absolute_minute) + if fail_delivery and String(source_inventory_id).begins_with("cargo:") and result[&"ok"]: + return {&"ok": false, &"code": &"INJECTED_LATE_FAILURE"} + return result + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("Supply bridge: real movement, cargo conservation and recovery") + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 653322)[&"valid"], "Runtime initializes") + _clock.set_paused(true) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + for row: Array in [[&"warehouse", Vector3i(-8, 0, 1)], [&"kitchen", Vector3i(2, 0, 1)], [&"staff_door", Vector3i(0, 0, 1)]]: + _expect(_command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}), "build %s" % row[0]) + _clock.advance_minutes(180) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": _source = facility.instance_id + if facility.definition_id == &"kitchen": _destination = facility.instance_id + if _source.is_empty() or _destination.is_empty(): + _expect(false, "normal construction completes") + _finish() + return + _expect(_command(&"hire_staff_candidate", {&"candidate_id": PORTER}), "hire the catalogue porter") + _expect(_command(&"set_staff_work_assignment", {&"staff_id": PORTER, &"facility_id": _source, &"template_id": &"DAY"}), "assign porter to a repeating real shift") + _expect(_command(&"purchase_supplies", {&"warehouse_id": _source, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}), "purchase the source stock through the ledger") + _expect(_command(&"create_logistics_request", {&"request_id": REQUEST, &"source_inventory_id": _inventory(_source), &"destination_inventory_id": _inventory(_destination), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}}), "create the reserved supply request") + _expect(_quantity(_inventory(_destination)) == 0, "request creation does not deliver anything") + _test_manual_dispatch_boundary() + _reserved = _runtime.create_snapshot() + _clock.advance_minutes(1) + _expect(_request().assigned_carrier_id == PORTER and _worker().current_cell.distance_to(Vector3i.ZERO) <= 1.0, "dispatcher selects the actual employee without teleporting") + var start := _worker().current_cell + _wait_for(LogisticsRequest.State.DELIVERING, 40) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_cell != start, "porter walks to the source before pickup") + if _request().state != LogisticsRequest.State.DELIVERING: + _finish() + return + _expect(_quantity(_inventory(_source)) == 0 and _quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0, "pickup moves all eight units into owned cargo only") + _expect(_crosslinks(), "loaded Request, Task, reservations and cargo agree") + _loaded = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(_loaded), "mid-delivery Runtime snapshot restores") + _test_ownership_and_replay() + _test_capacity_wait() + _test_route_recovery() + _test_unavailable_and_expiry() + _test_restore_guards() + _test_pickup_retry() + _test_unavailable_before_pickup() + _test_partial_expiry_return() + _test_shift_boundary() + _test_late_delivery_failure() + _finish() + + +func _test_ownership_and_replay() -> void: + var owned := _owned_snapshot() + var manual: CommandResult = _runtime.submit_command(&"advance_logistics_request", {&"request_id": REQUEST}) + _expect(not manual.is_success() and manual.details.get(&"reason") == &"SUPPLY_DRIVER_REQUIRED", "external advance cannot bypass real arrival") + _expect(_owned_snapshot() == owned, "rejected manual advance leaves inventory, Task and carrier ownership unchanged") + _expect(_runtime.restore_snapshot(_loaded), "rewind the rejected command audit before deterministic replay") + var task_id := _worker().current_task_id + _expect(not _runtime.staff_service.cancel_task(task_id).is_success(), "ordinary cancellation cannot orphan loaded cargo") + _expect(not _runtime.staff_service.remove_staff(PORTER).is_success(), "loaded porter cannot be removed") + _expect(not _runtime.staff_service.retire_staff(PORTER).is_success() and not _worker().is_retiring, "retirement rejects atomically while hauling") + _expect(not _runtime.staff_service.progress_task(task_id, 1, _clock.absolute_minute + 2).is_success(), "generic Task progress cannot bypass delivery") + _wait_for(LogisticsRequest.State.COMPLETED, 40) + _expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8, "arrival transfers the purchased stock into kitchen storage") + _expect(_quantity(_cargo()) == 0 and _worker().current_task_id.is_empty() and _worker().carried_inventory_id.is_empty(), "completion clears cargo and employee ownership together") + _expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "completion releases exact dispatcher weight") + var completed: Dictionary = _runtime.create_snapshot() + var end_minute: int = _clock.absolute_minute + _expect(_runtime.restore_snapshot(_loaded), "rewind to a loaded employee") + _clock.advance_minutes(end_minute - _clock.absolute_minute) + _expect(_runtime.create_snapshot() == completed, "loaded save replay produces identical inventory, Tasks, positions and accounts") + + +func _test_manual_dispatch_boundary() -> void: + var owned := _owned_snapshot() + var manual: CommandResult = _runtime.submit_command(&"dispatch_logistics", {&"carriers": {&"synthetic_porter": {&"capacity": 100, &"current_load": 0, &"path_minutes": 0, &"role": &"logistics"}}}) + _expect(manual.is_success() and manual.details[&"assignments"].is_empty(), "external synthetic carrier cannot claim a managed supply request") + _expect(_owned_snapshot() == owned, "manual dispatch does not change reserved stock, Staff Task or request ownership") + + +func _owned_snapshot() -> Dictionary: + return {&"inventory": _runtime.inventory_service.create_snapshot(), &"staff": _runtime.staff_service.create_snapshot(), &"dispatcher": _runtime.logistics_dispatcher.create_snapshot(), &"request": _request().create_snapshot()} + + +func _test_capacity_wait() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore capacity scenario") + _runtime.inventory_service.add_stack(_inventory(_destination), SupplyCatalog.BASIC_MEAL, 40, 100, -1, _clock.absolute_minute) + _clock.advance_minutes(30) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _request().state_reason == "DESTINATION_FULL", "capacity becoming full after pickup pauses delivery") + _expect(_quantity(_cargo()) == 8 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 8, "full destination retains all cargo and weight ownership") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "waiting loaded cargo is restorable") + _runtime.inventory_service.transfer_available(_inventory(_destination), &"test.capacity_sink", SupplyCatalog.BASIC_MEAL, 40, &"", _clock.absolute_minute) + _wait_for(LogisticsRequest.State.COMPLETED, 10) + _expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "freeing storage resumes once without losing or duplicating stock") + + +func _test_route_recovery() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore route scenario") + var target := _task().target_cell + _runtime.path_service.set_cell_temporary_block(target, true) + _clock.advance_minutes(40) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _quantity(_cargo()) == 8, "blocked delivery route never releases carried stock") + _expect(_worker().current_task_id == _task().task_id and _crosslinks(), "stuck recovery keeps employee and Task linked") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "route-blocked cargo saves and restores") + _runtime.path_service.set_cell_temporary_block(target, false) + _wait_for(LogisticsRequest.State.COMPLETED, 40) + _expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8, "restoring the real route resumes delivery") + + +func _test_unavailable_and_expiry() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore unavailable scenario") + _worker().health = 0 + _worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute) + var cell := _worker().current_cell + _clock.advance_minutes(3) + _expect(_worker().current_cell == cell and _quantity(_cargo()) == 8, "incapacitated porter does not move or lose cargo") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "incapacitated cargo owner is restorable") + _worker().health = 100 + _worker().transition_to(StaffRuntime.State.MOVING_TO_TASK, &"TEST_RECOVERED", _clock.absolute_minute) + _wait_for(LogisticsRequest.State.COMPLETED, 40) + _expect(_request().state == LogisticsRequest.State.COMPLETED, "employee recovery resumes the retained delivery") + _expect(_runtime.restore_snapshot(_loaded), "restore spoilage scenario") + _worker().health = 0 + _worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute) + var inventory_snapshot: Dictionary = _runtime.inventory_service.create_snapshot() + for owner: Dictionary in inventory_snapshot[&"inventories"]: + if owner[&"owner_inventory_id"] == String(_cargo()): + for stack: Dictionary in owner[&"stacks"]: + stack[&"expires_at"] = _clock.absolute_minute + 1 + _expect(_runtime.inventory_service.restore_snapshot(inventory_snapshot), "inject a cargo expiry boundary") + _clock.advance_minutes(1) + _expect(_request().state == LogisticsRequest.State.CANCELLED and _quantity(_cargo()) == 0, "expired cargo enters inventory waste and closes the impossible delivery") + _expect(_worker().state == StaffRuntime.State.INCAPACITATED and _worker().current_task_id.is_empty(), "spoilage cleanup preserves incapacitation while releasing the Task") + _expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "spoilage releases weight without a ghost assignment") + + +func _test_restore_guards() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore crosslink scenario") + var stable: Dictionary = _runtime.create_snapshot() + var bad := stable.duplicate(true) + for task: Dictionary in bad[&"systems"][&"runtime"][&"staff"][&"tasks"]: + if task[&"task_type"] == String(SupplyLogisticsBridge.DELIVERY_TYPE): + task[&"target_cell"] = [99, 99, 1] + _expect(not _runtime.restore_snapshot(bad) and _runtime.create_snapshot() == stable, "restore rejects a remote delivery target atomically") + var worker := _worker() + worker.carried_inventory_id = &"cargo:somebody_else" + _expect(not _crosslinks(), "crosslink validator rejects stolen cargo ownership") + worker.carried_inventory_id = _cargo() + var task := _task() + var saved_type := task.task_type + task.task_type = SupplyLogisticsBridge.PICKUP_TYPE + _expect(not _crosslinks(), "crosslink validator rejects pickup Task paired with loaded request") + task.task_type = saved_type + _expect(_crosslinks(), "valid ownership survives rejected candidates") + + +func _test_pickup_retry() -> void: + _expect(_runtime.restore_snapshot(_reserved), "restore before pickup dispatch") + _clock.advance_minutes(1) + var old_task := _worker().current_task_id + var stopped: Dictionary = _runtime.supply_logistics.handle_path_stuck(old_task, _clock.absolute_minute) + _expect(stopped[&"ok"] and _request().state == LogisticsRequest.State.BLOCKED, "unloaded route failure blocks the request and cancels the approach") + _expect(_runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8 and _worker().current_task_id.is_empty(), "failed approach releases source reservation and employee together") + _expect(_runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "failed approach retains no dispatcher load") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "blocked unloaded request restores") + _wait_for(LogisticsRequest.State.DELIVERING, 40) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _worker().current_task_id != old_task, "retries reserve stock again and create a fresh attempt Task") + _wait_for(LogisticsRequest.State.COMPLETED, 40) + _expect(_quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "retry delivers the same stock exactly once") + + +func _test_unavailable_before_pickup() -> void: + _expect(_runtime.restore_snapshot(_reserved), "restore unloaded health failure") + _clock.advance_minutes(1) + _worker().health = 0 + _worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute) + _clock.advance_minutes(1) + _expect(_request().state == LogisticsRequest.State.BLOCKED and _worker().current_task_id.is_empty(), "incapacitation before pickup releases approach ownership") + _expect(_worker().state == StaffRuntime.State.INCAPACITATED and _runtime.inventory_service.available_quantity(_inventory(_source), SupplyCatalog.RAW_FOOD) == 8, "unloaded health failure preserves health state and releases reserved food") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()) and _crosslinks(), "unloaded health failure saves without expired reservation links") + + +func _test_partial_expiry_return() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore partial-spoilage scenario") + var inventory: Dictionary = _runtime.inventory_service.create_snapshot() + for owner: Dictionary in inventory[&"inventories"]: + if owner[&"owner_inventory_id"] == String(_cargo()): + owner[&"stacks"][0][&"quantity"] = 4 + _expect(_runtime.inventory_service.restore_snapshot(inventory), "split loaded cargo into two expiry lots") + _runtime.inventory_service.add_stack(_cargo(), SupplyCatalog.RAW_FOOD, 4, 100, _clock.absolute_minute + 1, _clock.absolute_minute) + _clock.advance_minutes(1) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _task().task_type == SupplyLogisticsBridge.RETURN_TYPE and _quantity(_cargo()) == 4, "partial expiry retargets the surviving cargo to its source") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "returning cargo saves its destination in the existing Task") + _worker().health = 0 + _worker().transition_to(StaffRuntime.State.INCAPACITATED, &"TEST", _clock.absolute_minute) + _clock.advance_minutes(1) + _expect(_crosslinks(), "unavailability reason cannot erase return destination") + _worker().health = 100 + _worker().transition_to(StaffRuntime.State.MOVING_TO_TASK, &"RECOVERED", _clock.absolute_minute) + _wait_for(LogisticsRequest.State.CANCELLED, 40) + _expect(_request().state_reason == "CARGO_EXPIRED_RETURNED" and _quantity(_inventory(_source)) == 4 and _quantity(_inventory(_destination)) == 0, "return unloads only unspoiled stock at the original warehouse") + _expect(_quantity(_cargo()) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 0 and _crosslinks(), "partial-expiry return releases all cargo ownership") + + +func _test_shift_boundary() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore end-of-shift scenario") + var target := _task().target_cell + _runtime.path_service.set_cell_temporary_block(target, true) + _clock.advance_minutes(1081 - _clock.absolute_minute) + _expect(_worker().is_overtime and not _worker().current_task_id.is_empty() and _quantity(_cargo()) == 8, "18:00 shift end retains loaded non-interruptible work") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "overtime delivery is restorable") + _runtime.path_service.set_cell_temporary_block(target, false) + _wait_for(LogisticsRequest.State.COMPLETED, 40) + _clock.advance_minutes(1) + _expect(_worker().state == StaffRuntime.State.OFF_DUTY and _worker().current_task_id.is_empty() and _quantity(_inventory(_destination)) == 8, "employee ends the shift after safe unloading") + + +func _test_late_delivery_failure() -> void: + _expect(_runtime.restore_snapshot(_loaded), "restore delivery transaction failure") + var fault := LateFailInventory.new() + _expect(fault.restore_snapshot(_runtime.inventory_service.create_snapshot()), "install failure after inventory transfer has already mutated") + _runtime.inventory_service = fault + _clock.advance_minutes(30) + _expect(_request().state == LogisticsRequest.State.DELIVERING and _request().state_reason == "DELIVERY_RETRY", "late inventory failure rolls the request back to owned delivery") + _expect(_quantity(_cargo()) == 8 and _quantity(_inventory(_destination)) == 0 and _runtime.logistics_dispatcher.get_reserved_load(PORTER) == 8, "failed delivery reverses all physical transfer and dispatcher changes") + _expect(_crosslinks() and not _worker().current_task_id.is_empty(), "failed delivery preserves the employee and Task pair") + fault.fail_delivery = false + _wait_for(LogisticsRequest.State.COMPLETED, 10) + _expect(_request().state == LogisticsRequest.State.COMPLETED and _quantity(_inventory(_destination)) == 8 and _quantity(_cargo()) == 0, "clearing transient failure retries the same operation exactly once") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "transaction retry leaves a restorable Runtime") + + +func _wait_for(state: int, minutes: int) -> void: + for index: int in minutes: + if _request().state == state: return + _clock.advance_minutes(1) + + +func _command(type: StringName, payload: Dictionary) -> bool: + var result: CommandResult = _runtime.submit_command(type, payload) + if not result.is_success(): print(result.message, result.details) + return result.is_success() + + +func _inventory(id: StringName) -> StringName: + return _runtime.facility_service.get_instance(id).local_inventory_id + + +func _request() -> LogisticsRequest: + return _runtime.logistics_requests[REQUEST] + + +func _worker() -> StaffRuntime: + return _runtime.staff_service.get_staff(PORTER) + + +func _task() -> StaffTask: + return _runtime.staff_service.get_task(_worker().current_task_id) + + +func _cargo() -> StringName: + return StringName("cargo:%s" % PORTER) + + +func _quantity(inventory_id: StringName) -> int: + return _runtime.inventory_service.total_quantity(inventory_id, SupplyCatalog.RAW_FOOD) + + +func _crosslinks() -> bool: + return SupplyLogisticsBridge.validate_crosslinks(_runtime.logistics_requests, _runtime.staff_service, _runtime.inventory_service, _runtime.facility_service) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) + + +func _finish() -> void: + print("%s: %d checks / %d failures" % ["PASS" if _failures == 0 else "FAIL", _checks, _failures]) + quit(_failures) diff --git a/tests/supply/test_supply_logistics_bridge.gd.uid b/tests/supply/test_supply_logistics_bridge.gd.uid new file mode 100644 index 0000000..14eb044 --- /dev/null +++ b/tests/supply/test_supply_logistics_bridge.gd.uid @@ -0,0 +1 @@ +uid://dbj5dk0uqj0ay diff --git a/tests/supply/test_supply_management_service.gd b/tests/supply/test_supply_management_service.gd new file mode 100644 index 0000000..74c1935 --- /dev/null +++ b/tests/supply/test_supply_management_service.gd @@ -0,0 +1,337 @@ +extends SceneTree + +## Runtime-level purchasing and replenishment. All physical stock is purchased +## and cooked normally; failure fixtures cover payment, closed routes and expiry. +class RejectingPaymentEconomy: + extends EconomyService + + func transfer(_from: StringName, _to: StringName, _amount: int, + _minute: int = -1, _category: StringName = DEFAULT_CATEGORY, + _source: StringName = &"", _key: StringName = &"", _memo: StringName = &"", + _related: StringName = &"", _notify := true) -> CommandResult: + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, 0, "Injected payment refusal") + + +const PORTER := &"staff_gina" +const COOK := &"staff_chika" +const RETURN_REQUEST := &"supply.management_return" +var _checks := 0 +var _failures := 0 +var _completed_groups := 0 +var _runtime: Variant +var _clock: Variant +var _state: Variant +var _ids: Dictionary = {} +var _built: Dictionary = {} + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _runtime = root.get_node("SimulationRuntime") + _clock = root.get_node("GameClock") + _state = root.get_node("GameState") + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 553221)[&"valid"], "Runtime initializes") + _clock.set_paused(true) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + for row: Array in [ + [&"warehouse", Vector3i(-8, 0, 1)], [ &"kitchen", Vector3i(2, 0, 1)], + [&"cafeteria", Vector3i(2, 0, 0)], [&"staff_door", Vector3i(0, 0, 1)], + ]: + _command(&"build_facility", {&"definition_id": row[0], &"origin": row[1]}) + _clock.advance_minutes(180) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + _ids[facility.definition_id] = facility.instance_id + var completed := 0 + for job: ConstructionJob in _runtime.construction_service.get_jobs(): + if job.state == ConstructionJob.State.COMPLETED: + completed += 1 + _expect(_ids.has(&"warehouse") and _ids.has(&"kitchen") and _ids.has(&"cafeteria") and completed == 4, + "normal starting budget completes three supply facilities and the staff door") + if not _ids.has(&"warehouse") or not _ids.has(&"kitchen") or not _ids.has(&"cafeteria"): + _finish() + return + _built = _runtime.create_snapshot() + _test_paid_purchase_and_rejection() + _test_disabled_kitchen_still_delivers() + _test_multiple_sources_choose_connected_nearest() + _test_returning_cargo_reserves_actual_space() + _expect(_completed_groups == 4, "all four regression scenarios reach their final assertions") + _finish() + + +func _test_paid_purchase_and_rejection() -> void: + var cash: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var quote: Dictionary = _runtime.preview_supply_purchase(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, 8) + _expect(bool(quote[&"ok"]), "unassigned completed warehouse accepts a paid purchase") + var result := _purchase(8) + _expect(result.is_success() and _quantity(&"warehouse", SupplyCatalog.RAW_FOOD) == 8, + "successful purchase creates exactly eight physical units") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == cash - int(quote[&"total_cost"]) + and _state.get_resource(&"gold") == cash - int(quote[&"total_cost"]), + "ledger and player cash both reflect the exact purchase price") + var entries: Array = _runtime.economy_service.get_transaction_entries(StringName(result.details.get(&"transaction_id", &""))) + _expect(entries.size() == 1 and entries[0].amount == int(quote[&"total_cost"]) + and entries[0].source_entity_id == _ids[&"warehouse"], "purchase records one expense attributed to its warehouse") + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 0}, &"INVALID_QUANTITY") + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 100}, &"STORAGE_FULL") + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1000}, &"INSUFFICIENT_FUNDS") + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.BASIC_MEAL, &"quantity": 1}, &"ITEM_NOT_PURCHASABLE") + _assert_purchase_rejected({&"warehouse_id": _ids[&"kitchen"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"NOT_WAREHOUSE") + _runtime.facility_service.set_operational_status(_ids[&"warehouse"], {&"operator_open": false}, _clock.absolute_minute) + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"FACILITY_CLOSED") + _runtime.facility_service.set_operational_status(_ids[&"warehouse"], {&"operator_open": true}, _clock.absolute_minute) + # Exercise the rollback after stock has been inserted, rather than only + # testing quote failures that reject before the first inventory write. + var original_economy: EconomyService = _runtime.economy_service + var rejecting := RejectingPaymentEconomy.new() + _expect(rejecting.restore_snapshot(original_economy.create_snapshot()), "payment-failure fixture retains all real balances") + _runtime.economy_service = rejecting + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 1}, &"PAYMENT_FAILED") + _runtime.economy_service = original_economy + _completed_groups += 1 + + +func _test_disabled_kitchen_still_delivers() -> void: + _expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for production") + _hire_and_assign(PORTER, &"warehouse") + _hire_and_assign(COOK, &"kitchen") + _expect(_purchase(8).is_success(), "purchase ingredients for real production") + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 4}) + for minute: int in 100: + if _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) >= 4: + break + _clock.advance_minutes(1) + _expect(_quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) == 4, "paid food is transported and cooked into one real batch") + if _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) != 4: + return + _expect(_meal_requests().is_empty(), "production finishes before the following logistics tick creates a meal request") + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": false, &"target_stock": 4}) + # Preserve the legacy compatibility boundary: a kitchen with no explicit + # production plan must not suddenly become an automatic source. + _expect(_runtime.kitchen_production.remove_plan(_ids[&"kitchen"])[&"ok"], "completed disabled kitchen can model a legacy unconfigured source") + _runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute) + _expect(_meal_requests().is_empty(), "unconfigured kitchen stock does not trigger unsolicited automatic delivery") + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": false, &"target_stock": 4}) + _clock.advance_minutes(1) + _expect(_meal_requests().size() == 1 and not _runtime.kitchen_production.get_plan(_ids[&"kitchen"])[&"enabled"], + "an explicitly disabled production plan still dispatches its completed meals") + _expect(_quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 0 + and _runtime.inventory_service.available_quantity(_inventory(&"kitchen"), SupplyCatalog.BASIC_MEAL) == 0, + "meal request reserves stock without instantly moving it to the dining room") + _runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute) + _runtime.supply_management.ensure_deliveries(_runtime, _clock.absolute_minute) + _expect(_meal_requests().size() == 1, "repeated replenishment cannot duplicate the already reserved meal shipment") + for minute: int in 100: + if _quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 4: + break + _clock.advance_minutes(1) + _expect(_quantity(&"cafeteria", SupplyCatalog.BASIC_MEAL) == 4 + and _quantity(&"kitchen", SupplyCatalog.BASIC_MEAL) == 0, + "real porter completes the outgoing shipment while kitchen production remains off") + _completed_groups += 1 + + +func _test_multiple_sources_choose_connected_nearest() -> void: + _expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for competing suppliers") + _command(&"build_facility", {&"definition_id": &"warehouse", &"origin": Vector3i(6, 0, 1)}) + _clock.advance_minutes(180) + var near_id: StringName = &"" + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse" and facility.instance_id != _ids[&"warehouse"]: + near_id = facility.instance_id + _expect(not near_id.is_empty(), "starting budget also completes a second, closer warehouse") + if near_id.is_empty(): + return + _expect(_purchase(8).is_success(), "older distant supplier holds eight paid units") + _command(&"purchase_supplies", {&"warehouse_id": near_id, &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 8}) + var near: FacilityInstance = _runtime.facility_service.get_instance(near_id) + var kitchen: FacilityInstance = _runtime.facility_service.get_instance(_ids[&"kitchen"]) + var near_route: Dictionary = _runtime.supply_logistics.find_facility_access_path(Vector3i(6, 0, 1), kitchen) + var far_route: Dictionary = _runtime.supply_logistics.find_facility_access_path(Vector3i(-5, 0, 1), kitchen) + _expect(bool(near_route[&"reachable"]) and bool(far_route[&"reachable"]) + and int(near_route[&"steps"]) < int(far_route[&"steps"]) + and String(near_id) > String(_ids[&"warehouse"]), + "both actual routes connect, and the shorter route belongs to the later warehouse ID") + var stocked: Dictionary = _runtime.create_snapshot() + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8}) + _clock.advance_minutes(1) + var requests := _raw_requests_for_kitchen() + _expect(requests.size() == 1 and int(requests[0].item_requirements[SupplyCatalog.RAW_FOOD]) == 4, + "two stocked suppliers create only one four-unit shipment in the first logistics tick") + _expect(not requests.is_empty() and requests[0].source_inventory_id == near.local_inventory_id, + "connected nearby supplier wins over the older, farther warehouse") + _expect(_runtime.inventory_service.available_quantity(near.local_inventory_id, SupplyCatalog.RAW_FOOD) == 4 + and _runtime.inventory_service.available_quantity(_inventory(&"warehouse"), SupplyCatalog.RAW_FOOD) == 8 + and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 4, + "exactly four units are reserved once and counted once as incoming") + _clock.advance_minutes(1) + requests = _raw_requests_for_kitchen() + _expect(requests.size() == 2 and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 8, + "next tick adds at most one batch to cover the remaining four-unit deficit") + _clock.advance_minutes(1) + _expect(_raw_requests_for_kitchen().size() == 2 + and _runtime.inventory_service.available_quantity(_inventory(&"warehouse"), SupplyCatalog.RAW_FOOD) == 8, + "incoming stock at target stops extra requests and leaves other supplier stock untouched") + # Geometric proximity must not beat an actual connection. Closing every + # access cell of the near warehouse leaves the far route into the kitchen. + _expect(_runtime.restore_snapshot(stocked), "rewind to the two unreserved suppliers") + near = _runtime.facility_service.get_instance(near_id) + kitchen = _runtime.facility_service.get_instance(_ids[&"kitchen"]) + for cell: Vector3i in near.occupied_cells: + for delta: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + if _runtime.path_service.has_cell(cell + delta): + _runtime.path_service.set_cell_temporary_block(cell + delta, true) + far_route = _runtime.supply_logistics.find_facility_access_path(Vector3i(-5, 0, 1), kitchen) + near_route = _runtime.supply_logistics.find_facility_access_path(Vector3i(6, 0, 1), kitchen) + _expect(bool(far_route[&"reachable"]) and not bool(near_route[&"reachable"]), + "route closure really disconnects the nearby supplier while preserving the far route") + _command(&"set_kitchen_production", {&"facility_id": _ids[&"kitchen"], &"enabled": true, &"target_stock": 8}) + _clock.advance_minutes(1) + requests = _raw_requests_for_kitchen() + _expect(requests.size() == 1 and requests[0].source_inventory_id == _inventory(&"warehouse") + and int(requests[0].item_requirements[SupplyCatalog.RAW_FOOD]) == 4, + "unreachable near stock cannot steal the single shipment from a connected supplier") + _completed_groups += 1 + + +func _test_returning_cargo_reserves_actual_space() -> void: + _expect(_runtime.restore_snapshot(_built), "reset to the paid buildings for returning cargo") + _hire_and_assign(PORTER, &"warehouse") + _expect(_purchase(2).is_success() and _purchase(6).is_success(), "two paid stacks provide an eight-unit shipment") + _command(&"create_logistics_request", { + &"request_id": RETURN_REQUEST, &"source_inventory_id": _inventory(&"warehouse"), + &"destination_inventory_id": _inventory(&"kitchen"), &"item_requirements": {SupplyCatalog.RAW_FOOD: 8}, + }) + var request: LogisticsRequest = _runtime.logistics_requests[RETURN_REQUEST] + for minute: int in 60: + if request.state == LogisticsRequest.State.DELIVERING: + break + _clock.advance_minutes(1) + var cargo_id := StringName("cargo:%s" % PORTER) + _expect(request.state == LogisticsRequest.State.DELIVERING + and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 8, + "actual pickup puts purchased goods in the employee's cargo") + if request.state != LogisticsRequest.State.DELIVERING: + return + var inventory_snapshot: Dictionary = _runtime.inventory_service.create_snapshot() + var injected := false + for inventory: Dictionary in inventory_snapshot[&"inventories"]: + if inventory[&"owner_inventory_id"] != String(cargo_id): + continue + for stack: Dictionary in inventory[&"stacks"]: + if int(stack[&"quantity"]) == 2: + stack[&"expires_at"] = _clock.absolute_minute + 1 + injected = true + _expect(injected, "the original two-unit paid stack remains identifiable inside real cargo") + _expect(_runtime.inventory_service.restore_snapshot(inventory_snapshot), "inject expiry of only the two-unit paid stack") + _clock.advance_minutes(1) + _expect(request.state == LogisticsRequest.State.DELIVERING and request.state_reason == "RETURNING_EXPIRED_CARGO" + and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 6, + "partial expiry starts a real return of the six surviving units") + if request.state != LogisticsRequest.State.DELIVERING or request.state_reason != "RETURNING_EXPIRED_CARGO": + return + for reason: String in ["RETURNING_EXPIRED_CARGO", "EXPIRED_CARGO_RETURN_BLOCKED", "CARRIER_UNAVAILABLE"]: + # Both bridge states retain the same real cargo. Capacity accounting + # must follow that cargo even if the return route becomes blocked. + request.state_reason = reason + var quote: Dictionary = _runtime.preview_supply_purchase(_ids[&"warehouse"], SupplyCatalog.RAW_FOOD, 94) + _expect(bool(quote[&"ok"]) and int(quote[&"max_quantity"]) == 94, + "%s reserves six actual returning units, not the original eight" % reason) + _expect(_incoming(&"warehouse", SupplyCatalog.RAW_FOOD) == 6 and _incoming(&"kitchen", SupplyCatalog.RAW_FOOD) == 0, + "%s counts returning goods only at the true receiving warehouse" % reason) + _assert_purchase_rejected({&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": 95}, &"STORAGE_FULL") + request.state_reason = "RETURNING_EXPIRED_CARGO" + _expect(_purchase(94).is_success(), "buyer may use the exact free space left after reserving the return") + for minute: int in 30: + if request.is_terminal(): + break + _clock.advance_minutes(1) + _expect(request.state == LogisticsRequest.State.CANCELLED and request.state_reason == "CARGO_EXPIRED_RETURNED", + "capacity-aware purchase leaves enough room for the real return to complete") + _expect(_quantity(&"warehouse", SupplyCatalog.RAW_FOOD) == 100 + and _runtime.inventory_service.total_quantity(cargo_id, SupplyCatalog.RAW_FOOD) == 0 + and _incoming(&"warehouse", SupplyCatalog.RAW_FOOD) == 0, + "return fills warehouse exactly once and clears incoming cargo") + _expect(_runtime.inventory_service.validate_invariants()[&"ok"] + and SupplyLogisticsBridge.validate_crosslinks(_runtime.logistics_requests, _runtime.staff_service, _runtime.inventory_service, _runtime.facility_service), + "completed return preserves inventory and carrier ownership invariants") + _completed_groups += 1 + + +func _assert_purchase_rejected(payload: Dictionary, reason: StringName) -> void: + var inventory_before: Dictionary = _runtime.inventory_service.create_snapshot() + var economy_before: Dictionary = _runtime.economy_service.create_snapshot() + var gold_before: int = _state.get_resource(&"gold") + var result: CommandResult = _runtime.submit_command(&"purchase_supplies", payload) + _expect(not result.is_success() and result.details.get(&"reason") == reason, "purchase rejects %s" % reason) + _expect(_runtime.inventory_service.create_snapshot() == inventory_before + and _runtime.economy_service.create_snapshot() == economy_before and _state.get_resource(&"gold") == gold_before, + "%s leaves full inventory, ledger and player cash unchanged" % reason) + + +func _command(type: StringName, payload: Dictionary) -> CommandResult: + var result: CommandResult = _runtime.submit_command(type, payload) + _expect(result.is_success(), "%s succeeds: %s" % [type, result.details]) + return result + + +func _purchase(quantity: int) -> CommandResult: + return _runtime.submit_command(&"purchase_supplies", {&"warehouse_id": _ids[&"warehouse"], &"item_id": SupplyCatalog.RAW_FOOD, &"quantity": quantity}) + + +func _hire_and_assign(staff_id: StringName, facility: StringName) -> void: + _command(&"hire_staff_candidate", {&"candidate_id": staff_id}) + _command(&"set_staff_work_assignment", {&"staff_id": staff_id, &"facility_id": _ids[facility], &"template_id": &"DAY"}) + + +func _inventory(definition: StringName) -> StringName: + return _runtime.facility_service.get_instance(_ids[definition]).local_inventory_id + + +func _quantity(definition: StringName, item: StringName) -> int: + return _runtime.inventory_service.total_quantity(_inventory(definition), item) + + +func _incoming(definition: StringName, item: StringName) -> int: + var report: Dictionary = _runtime.get_supply_management_report() + var group := &"warehouses" if definition == &"warehouse" else &"kitchens" + for row: Dictionary in report[group]: + if StringName(row[&"facility_id"]) != _ids[definition]: + continue + for stock: Dictionary in row[&"stock"]: + if StringName(stock[&"item_id"]) == item: + return int(stock[&"in_transit"]) + return -1 + + +func _meal_requests() -> Array[LogisticsRequest]: + var requests: Array[LogisticsRequest] = [] + for request: LogisticsRequest in _runtime.logistics_requests.values(): + if request.item_requirements.has(SupplyCatalog.BASIC_MEAL): + requests.append(request) + return requests + + +func _raw_requests_for_kitchen() -> Array[LogisticsRequest]: + var requests: Array[LogisticsRequest] = [] + for request: LogisticsRequest in _runtime.logistics_requests.values(): + if request.destination_inventory_id == _inventory(&"kitchen") and request.item_requirements.has(SupplyCatalog.RAW_FOOD): + requests.append(request) + return requests + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) + + +func _finish() -> void: + print("Supply management service: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) diff --git a/tests/supply/test_supply_management_service.gd.uid b/tests/supply/test_supply_management_service.gd.uid new file mode 100644 index 0000000..a657edf --- /dev/null +++ b/tests/supply/test_supply_management_service.gd.uid @@ -0,0 +1 @@ +uid://dmfdmeee0ev61 diff --git a/tests/supply/test_supply_save_migration.gd b/tests/supply/test_supply_save_migration.gd new file mode 100644 index 0000000..725b03a --- /dev/null +++ b/tests/supply/test_supply_save_migration.gd @@ -0,0 +1,92 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P1: Runtime v7 supply save migration") + var source := _fixture(7301) + var first := _fixture(7302) + var second := _fixture(7303) + var source_runtime: Variant = source[&"runtime"] + var first_runtime: Variant = first[&"runtime"] + var second_runtime: Variant = second[&"runtime"] + 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"] == 7, "current Runtime emits schema v7") + _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"] + legacy_data[&"runtime_schema_version"] = 6 + legacy_data.erase(&"kitchen_production") + _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 v7") + var migrated: Dictionary = first_runtime.create_snapshot() + _expect(migrated[&"systems"][&"runtime"][&"runtime_schema_version"] == 7, "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") + _expect(migrated == canonical, "v6 migration preserves non-supply state exactly") + _expect(second_runtime.restore_snapshot(legacy) and second_runtime.create_snapshot() == migrated, "v6 migration is deterministic across Runtime instances") + _expect(first_runtime.restore_snapshot(migrated) and first_runtime.create_snapshot() == migrated, "canonical v7 round trip is exact") + var missing_supply := canonical.duplicate(true) + missing_supply[&"systems"][&"runtime"].erase(&"kitchen_production") + _expect(_rejects_atomically(first_runtime, missing_supply), "v7 cannot omit its supply aggregate") + var relabelled := canonical.duplicate(true) + relabelled[&"systems"][&"runtime"][&"runtime_schema_version"] = 6 + _expect(_rejects_atomically(first_runtime, relabelled), "relabelled v7 with retained kitchen state cannot masquerade as v6") + var bad_clock := canonical.duplicate(true) + bad_clock[&"systems"][&"runtime"][&"kitchen_production"][&"current_minute"] -= 1 + _expect(_rejects_atomically(first_runtime, bad_clock), "production and Runtime clock disagreement is rejected") + var bad_version := canonical.duplicate(true) + bad_version[&"systems"][&"runtime"][&"kitchen_production"][&"version"] = 2 + _expect(_rejects_atomically(first_runtime, bad_version), "unknown nested production schema is rejected") + var stock: Dictionary = source_runtime.inventory_service.add_stack(&"supplier.legacy", SupplyCatalog.RAW_FOOD, 4) + _expect(stock[&"ok"], "orphan migration fixture has raw food") + var hold: Dictionary = source_runtime.inventory_service.reserve_item( + &"supplier.legacy", SupplyCatalog.RAW_FOOD, 4, + &"kitchen.batch:00000001", SupplyCatalog.PRODUCTION_PURPOSE, source[&"clock"].absolute_minute + ) + _expect(hold[&"ok"], "orphan fixture creates a production reservation") + var orphaned: Dictionary = source_runtime.create_snapshot() + orphaned[&"systems"][&"runtime"][&"runtime_schema_version"] = 6 + orphaned[&"systems"][&"runtime"].erase(&"kitchen_production") + _expect(_rejects_atomically(first_runtime, orphaned), "legacy label cannot hide input holds for a discarded production batch") + for fixture: Dictionary in [source, first, second]: + fixture[&"runtime"].free() + fixture[&"clock"].free() + fixture[&"state"].free() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d of %d checks failed" % [_failures, _checks]) + quit(_failures) + + +func _fixture(seed: int) -> Dictionary: + var clock: Variant = load("res://autoload/game_clock.gd").new() + var state: Variant = load("res://autoload/game_state.gd").new() + var runtime: Variant = load("res://autoload/simulation_runtime.gd").new() + clock.reset(1, 6, 0) + state.reset() + _expect(runtime.initialize(clock, state, seed)[&"valid"], "migration fixture initializes") + clock.set_paused(true) + return {&"clock": clock, &"state": state, &"runtime": runtime} + + +func _rejects_atomically(runtime: Variant, candidate: Dictionary) -> bool: + var before: Dictionary = runtime.create_snapshot() + return not runtime.restore_snapshot(candidate) and runtime.create_snapshot() == before + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) diff --git a/tests/supply/test_supply_save_migration.gd.uid b/tests/supply/test_supply_save_migration.gd.uid new file mode 100644 index 0000000..37bbb30 --- /dev/null +++ b/tests/supply/test_supply_save_migration.gd.uid @@ -0,0 +1 @@ +uid://t3p5aqdc4g5h diff --git a/tests/ui/test_supply_management.gd b/tests/ui/test_supply_management.gd new file mode 100644 index 0000000..c05b990 --- /dev/null +++ b/tests/ui/test_supply_management.gd @@ -0,0 +1,242 @@ +extends SceneTree + +## Normal build/purchase commands: UI drafts, money/stock reconciliation, queued +## purchases and an isolated save slot. No free inventory is inserted. +var _checks := 0 +var _failures := 0 +var _main: Control +var _panel: SupplyManagementPanel +var _runtime: Variant +var _clock: Variant +var _warehouse: StringName = &"" +var _kitchens: Array[StringName] = [] +var _item: StringName = &"" +var _save_path := "user://tests/supply_ui_%d/queued_purchase" % OS.get_process_id() + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + _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, 824242) + _clock.set_paused(true) + root.size = Vector2i(1280, 720) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + _main.set("manual_save_path", "user://tests/supply_ui_%d/manual" % OS.get_process_id()) + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + _panel = _main.find_child("SupplyManagementPanel", true, false) as SupplyManagementPanel + _expect(_panel != null, "normal scene includes supply management") + if _panel == null: + _finish() + return + (_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") + for request: Array in [ + [&"warehouse", Vector3i(-8, 0, 1)], + [&"kitchen", Vector3i(0, 0, 1)], + [&"kitchen", Vector3i(6, 0, 1)], + ]: + var before: int = _runtime.construction_service.get_jobs().size() + _main.call("_select_build_definition", request[0]) + _main.call("_on_map_cell_clicked", request[1]) + _expect(_runtime.construction_service.get_jobs().size() == before + 1, "normal UI builds %s" % request[0]) + _clock.advance_minutes(180) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"warehouse": + _warehouse = facility.instance_id + elif facility.definition_id == &"kitchen": + _kitchens.append(facility.instance_id) + _expect(not _warehouse.is_empty() and _kitchens.size() == 2, "paid construction produces a warehouse and two kitchens") + if _warehouse.is_empty() or _kitchens.size() != 2: + _finish() + return + _main.call("_activate_management_mode", &"product") + _panel.refresh() + _item = _panel.selected_item_id + _expect(not _item.is_empty(), "Runtime purchase catalog supplies a real item") + _test_purchase_and_failure() + _test_kitchen_drafts() + _test_saved_pending_purchase() + await _test_navigation_and_layout() + _finish() + + +func _test_purchase_and_failure() -> void: + _choose(_panel.warehouse_option, _warehouse) + _panel.quantity_spin.value = 4 + var quote: Dictionary = _runtime.preview_supply_purchase(_warehouse, _item, 4) + _expect(bool(quote.get(&"ok", false)), "Runtime quotes a paid purchase for the completed warehouse") + var balance_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var stock_before := _stock() + _panel.purchase_button.pressed.emit() + _expect(_stock() == stock_before + 4, "purchase button credits the warehouse's actual inventory") + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == balance_before - int(quote.get(&"total_cost", -1)), + "purchase pays exactly the authoritative quoted cost") + _expect("仕入れました" in _panel.result_label.text, "successful purchase is visible in Japanese") + # A deliberately oversized draft must fail without reducing cash or stock. + _panel.quantity_spin.allow_greater = true + _panel.quantity_spin.value = int(quote.get(&"max_quantity", 100)) + 100 + var invalid_quantity := int(_panel.quantity_spin.value) + var balance_after: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var stock_after := _stock() + _expect(_panel.purchase_button.disabled, "invalid quoted quantity disables purchase before submission") + _panel.submit_purchase() + _expect(_stock() == stock_after and _runtime.economy_service.get_balance(&"PLAYER_CASH") == balance_after, + "rejected purchase never partially spends money or inserts stock") + _expect(int(_panel.quantity_spin.value) == invalid_quantity and not _panel.result_label.text.is_empty(), + "rejected purchase preserves the quantity and an actionable result") + _panel.quantity_spin.allow_greater = false + _panel.quantity_spin.value = 2 + + +func _test_kitchen_drafts() -> void: + _panel.select_tab(&"kitchen") + _choose(_panel.kitchen_option, _kitchens[0]) + _panel.production_enabled.button_pressed = true + _panel.target_spin.value = 4 + _panel.select_tab(&"purchase") + _clock.advance_minutes(1) + _panel.select_tab(&"kitchen") + _expect(_panel.production_enabled.button_pressed and int(_panel.target_spin.value) == 4, + "time updates and tab changes preserve an unsaved kitchen draft") + _clock.set_paused(false) + _panel.production_button.pressed.emit() + _expect(_panel.production_button.disabled, "queued kitchen setting disables another submission for that kitchen") + _choose(_panel.kitchen_option, _kitchens[1]) + _panel.production_enabled.button_pressed = true + _panel.target_spin.value = 8 + _clock.advance_minutes(1) + _clock.set_paused(true) + _expect(_panel.selected_kitchen_id == _kitchens[1] and int(_panel.target_spin.value) == 8 + and _panel.production_enabled.button_pressed, "another kitchen's completion preserves the selected kitchen's draft") + var configured := _kitchen_row(_kitchens[0]) + _expect(bool(configured.get(&"enabled", false)) and int(configured.get(&"target_stock", -1)) == 4, + "saved kitchen settings reach the authoritative Runtime model") + _panel.production_button.pressed.emit() + var second := _kitchen_row(_kitchens[1]) + _expect(bool(second.get(&"enabled", false)) and int(second.get(&"target_stock", -1)) == 8, + "second kitchen applies its own saved target") + _expect("職員" in _panel.kitchen_status_label.text or "経路" in _panel.kitchen_status_label.text, + "unready kitchen explains its authoritative staffing or route blocker in Japanese") + _panel.production_enabled.button_pressed = false + _panel.production_button.pressed.emit() + _expect(not bool(_kitchen_row(_kitchens[1]).get(&"enabled", true)), "automatic production can be stopped through the same controls") + + +func _test_saved_pending_purchase() -> void: + _panel.select_tab(&"purchase") + _panel.quantity_spin.value = 2 + var stock_before := _stock() + var balance_before: int = _runtime.economy_service.get_balance(&"PLAYER_CASH") + var quote: Dictionary = _runtime.preview_supply_purchase(_warehouse, _item, 2) + _clock.set_paused(false) + _panel.purchase_button.pressed.emit() + var pending_before: int = _runtime.command_queue.create_snapshot().get("pending", []).size() + _panel.submit_purchase() + _expect(pending_before == 1 and _runtime.command_queue.create_snapshot().get("pending", []).size() == 1, + "repeated purchase cannot enqueue the same draft twice") + var saved: Dictionary = _runtime.save_to_path(_save_path) + _expect(bool(saved.get(&"ok", false)), "queued purchase saves to an isolated test slot") + _clock.advance_minutes(1) + var loaded: Dictionary = _runtime.load_from_path(_save_path) + _expect(bool(loaded.get(&"ok", false)), "pending purchase reloads successfully") + _panel.refresh() + _expect(_panel.purchase_button.disabled, "restored pending purchase cannot be resubmitted") + _clock.advance_minutes(1) + _clock.set_paused(true) + _panel.refresh() + _expect(_stock() == stock_before + 2 and _runtime.economy_service.get_balance(&"PLAYER_CASH") == balance_before - int(quote.get(&"total_cost", -1)), + "restored purchase credits and charges exactly once") + _expect(not _panel.purchase_button.disabled, "completed restored command releases purchase controls") + var digest: String = _runtime.latest_digest + _panel.refresh() + _panel.select_tab(&"delivery") + _expect(_runtime.latest_digest == digest, "displaying stock and deliveries never mutates simulation") + _expect(not (_panel.get_report().get(&"deliveries", []) as Array).is_empty(), + "real warehouse stock creates visible kitchen replenishment requests") + _expect("倉庫" in _panel.delivery_label.text and "厨房" in _panel.delivery_label.text, + "delivery page resolves source and destination to identifiable facilities") + _expect("運搬員" in _panel.delivery_label.text and "経路" in _panel.delivery_label.text, + "waiting delivery explains the actual porter or route shortage in Japanese") + + +func _test_navigation_and_layout() -> void: + _main.call("_activate_management_mode", &"ledger") + _expect(not _panel.visible and (_main.get("operating_ledger_panel") as Control).visible, "ledger replaces supply controls") + _main.call("_unhandled_key_input", _key(KEY_H)) + _expect(not _panel.visible and (_main.get("staff_candidate_panel") as Control).visible, "H returns to the hiring candidates") + _main.call("_activate_management_mode", &"product") + await process_frame + await process_frame + var side := _main.find_child("SidePanel", true, false) as Control + var guide := _main.find_child("InnOpeningGuide", true, false) as Control + _expect(not side.get_global_rect().intersects(guide.get_global_rect()), "1280x720 keeps supply inspector clear of opening guidance") + _expect(_panel.get_global_rect().position.x >= side.get_global_rect().position.x + and _panel.get_global_rect().end.x <= side.get_global_rect().end.x, + "supply tabs and inputs fit inside the existing sidebar width") + if OS.get_environment("THE_TREE_SUPPLY_UI_CAPTURE") == "1": + for tab: StringName in [&"purchase", &"kitchen", &"delivery"]: + _panel.select_tab(tab) + if tab == &"kitchen": + _choose(_panel.kitchen_option, _kitchens[0]) + await process_frame + await RenderingServer.frame_post_draw + var captured := root.get_texture().get_image() + captured.save_png("/tmp/the-tree-supply-%s.png" % tab) + if tab == &"purchase": + captured.save_png("/tmp/the-tree-supply-management.png") + _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") + + +func _stock() -> int: + var facility: FacilityInstance = _runtime.facility_service.get_instance(_warehouse) + return _runtime.inventory_service.total_quantity(facility.local_inventory_id, _item) + + +func _kitchen_row(id: StringName) -> Dictionary: + for row: Dictionary in _runtime.get_supply_management_report().get(&"kitchens", []): + if StringName(row.get(&"facility_id", &"")) == id: + return row + return {} + + +func _choose(picker: OptionButton, id: StringName) -> void: + for index: int in picker.item_count: + if StringName(picker.get_item_metadata(index)) == id: + picker.select(index) + picker.item_selected.emit(index) + return + _expect(false, "requested facility appears in the relevant supply dropdown: %s" % id) + + +func _key(code: Key) -> InputEventKey: + var event := InputEventKey.new() + event.keycode = code + event.pressed = true + return event + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(message) + + +func _finish() -> void: + SaveService.delete_save(_save_path) + if is_instance_valid(_main): + _main.queue_free() + print("Supply management UI: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) diff --git a/tests/ui/test_supply_management.gd.uid b/tests/ui/test_supply_management.gd.uid new file mode 100644 index 0000000..a068089 --- /dev/null +++ b/tests/ui/test_supply_management.gd.uid @@ -0,0 +1 @@ +uid://f077idu4qluv