diff --git a/Makefile b/Makefile index 256c5dd..c066c2f 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,7 @@ test-s2: $(GODOT) --headless --path . --script res://tests/s2/test_runtime_operations_cycle.gd test-s3: + $(GODOT) --headless --path . --script res://tests/s3/test_runtime_staff_assignment.gd $(GODOT) --headless --path . --script res://tests/s3/test_guest_service.gd $(GODOT) --headless --path . --script res://tests/s3/test_staff_service.gd $(GODOT) --headless --path . --script res://tests/s3/test_staff_payroll_operations.gd @@ -87,6 +88,8 @@ test-acceptance: 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 + $(MAKE) test-opening test-perf: $(GODOT) --headless --path . --script res://tests/performance/test_perf_mvp_001.gd @@ -112,3 +115,10 @@ capture-pixel: test-pixel-ui: $(GODOT) --headless --path . --script res://tests/ui/test_pixel_tower.gd + +.PHONY: test-opening capture-opening +test-opening: + $(GODOT) --headless --path . --script res://tests/ui/test_first_operating_day.gd + +capture-opening: + THE_TREE_OPENING_CAPTURE=1 $(GODOT) --path . --script res://tests/ui/test_first_operating_day.gd diff --git a/README.md b/README.md index f9d6d89..3d743c8 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ Godotが開いたら、右上の実行ボタンまたは `F5` でプロジェクトを起動できます。エディタを開かず直接実行する場合は `make run` を使用します。 +通常の新規ゲームは一時停止から始まります。左上の開業ガイドに沿って受付・客室・通路を建て、「従業員」で雇用・配属・毎日の勤務帯を決めます。「帳簿」で翌朝06:00の決算を確認できます。具体的な最小宿の建て方は [試遊ガイド](docs/PLAYTEST_GUIDE.md#通常uiで最初の宿を開業する) を参照してください。 + ## ドット絵の世界樹を見る 『The Tower SP』のように断面の施設と小さな人物の営みを眺める方向で、マップをドット絵主体へ変更しました。KenneyのTiny Dungeon・Tiny Townの16px素材と、専用の部屋・家具描画を組み合わせています。従来の大きな生成背景は現在のマップ描画では使いません。上部の情報と操作バーを小さくし、建築・運営パネルは必要なときに開きます。 @@ -24,6 +26,8 @@ Godotが開いたら、右上の実行ボタンまたは `F5` でプロジェク make test-acceptance # 24時間実Tickと14日運営境界の受入試験 make test-perf-full # 200客・50従業員・150施設の24時間完全負荷試験 make check # 読み込みとメイン画面の起動確認 + make test-opening # 通常UIから初売上・翌朝決算・保存再開を確認 + make capture-opening # 同じ開業手順の画面3枚と運営結果JSONを出力 make export-windows # Windows版を build/windows に生成 ## 設計方針 @@ -38,6 +42,8 @@ Godotが開いたら、右上の実行ボタンまたは `F5` でプロジェク ## 仕様書 - [全体制作計画](docs/DEVELOPMENT_PLAN.md) +- [制作課題台帳](docs/DEVELOPMENT_BACKLOG.md) +- [基準版の復旧ガイド](docs/RECOVERY_GUIDE.md) - [ドット絵表示と見学ガイド](docs/PIXEL_TOWER_VISUALS.md) - [画像アセット制作・差し替え仕様書](docs/ART_ASSET_PRODUCTION_SPECIFICATION.md) diff --git a/autoload/simulation_runtime.gd b/autoload/simulation_runtime.gd index f22dea1..e5904fb 100644 --- a/autoload/simulation_runtime.gd +++ b/autoload/simulation_runtime.gd @@ -256,6 +256,7 @@ func initialize(clock: SimulationKernel, state: Variant, seed := DEFAULT_WORLD_S register_command_executor(&"hire_staff_candidate", Callable(self, "_execute_hire_staff_candidate")) register_command_executor(&"create_staff_shift", Callable(self, "_execute_create_staff_shift")) register_command_executor(&"set_staff_schedule_override", Callable(self, "_execute_set_staff_schedule_override")) + register_command_executor(&"set_staff_work_assignment", Callable(self, "_execute_set_staff_work_assignment")) register_command_executor(&"create_staff_task", Callable(self, "_execute_create_staff_task")) register_command_executor(&"begin_staff_task", Callable(self, "_execute_begin_staff_task")) register_command_executor(&"progress_staff_task", Callable(self, "_execute_progress_staff_task")) @@ -5936,6 +5937,85 @@ func _rollback_staff_candidate_hire(snapshot: Dictionary) -> bool: _runtime_event_minute() ) +func _execute_set_staff_work_assignment(command: SimulationCommand) -> CommandResult: + for key: StringName in [&"staff_id", &"facility_id", &"template_id"]: + if typeof(command.payload.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]: + return _staff_assignment_failure(command.command_id, &"INVALID_PAYLOAD", "Staff, facility, and shift IDs must be strings.") + var staff_id := StringName(command.payload[&"staff_id"]) + var facility_id := StringName(command.payload[&"facility_id"]) + var template_id := StringName(command.payload[&"template_id"]) + var staff := staff_service.get_staff(staff_id) + if staff == null: + return _staff_assignment_failure(command.command_id, &"STAFF_NOT_FOUND", "Staff was not found.") + if template_id not in [&"DAY", &"NIGHT"]: + return _staff_assignment_failure(command.command_id, &"INVALID_TEMPLATE", "Choose DAY or NIGHT.") + if not _defense_lock_owner(staff_id, &"DEFENDER").is_empty(): + return _staff_assignment_failure(command.command_id, &"DEFENSE_LOCKED", "Staff is participating in a defense operation.") + if event_service.get_actor_locks().has(staff_id): + return _staff_assignment_failure(command.command_id, &"EVENT_LOCKED", "Staff is participating in an event.") + if not staff.current_task_id.is_empty(): + return _staff_assignment_failure(command.command_id, &"STAFF_BUSY", "Finish the current task before changing assignment.") + if not staff.can_take_shift() or staff.state not in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE]: + return _staff_assignment_failure(command.command_id, &"STAFF_UNAVAILABLE", "Staff cannot change assignment in the current state.") + if not facility_id.is_empty(): + var facility := facility_service.get_instance(facility_id) + if facility == null: + return _staff_assignment_failure(command.command_id, &"FACILITY_NOT_FOUND", "Facility was not found.") + var definition := definition_registry.get_facility(facility.definition_id) + if definition == null or definition.staff_max <= 0: + return _staff_assignment_failure(command.command_id, &"FACILITY_UNSTAFFED", "This facility does not accept Staff assignments.") + if not definition.required_roles.is_empty() and staff.role_id not in definition.required_roles: + return _staff_assignment_failure(command.command_id, &"ROLE_MISMATCH", "Staff role does not match this facility.") + if _staff_assignment_count(facility_id, template_id, staff_id) >= definition.staff_max: + return _staff_assignment_failure(command.command_id, &"FACILITY_FULL", "This shift has reached the facility Staff limit.") + if not _staff_can_reach_facility(staff, facility): + return _staff_assignment_failure(command.command_id, &"NO_ROUTE", "Staff cannot reach this facility through an accessible route.") + var result := staff_service.set_work_assignment(staff_id, facility_id, template_id, _runtime_event_minute()) + result.command_id = command.command_id + if result.is_success(): + _staffing_projection_enabled = true + _sync_facility_staffing(_runtime_event_minute()) + if result.details.get(&"reason", &"") == &"ASSIGNMENT_UPDATED": + _publish_actor_event(&"STAFF_WORK_ASSIGNMENT_CHANGED", &"staff_service", [staff_id], result.details) + return result + + +func _staff_assignment_failure(command_id: int, reason: StringName, message: String) -> CommandResult: + return CommandResult.failed(CommandResult.Code.VALIDATION_FAILED, command_id, message, {&"reason": reason}) + + +func _staff_assignment_count(facility_id: StringName, template_id: StringName, excluded_staff: StringName) -> int: + var counted: Dictionary = {} + for row: Dictionary in staff_service.get_work_assignments(): + var staff_id := StringName(row[&"staff_id"]) + if staff_id != excluded_staff and StringName(row[&"facility_id"]) == facility_id \ + and StringName(row[&"template_id"]) == template_id: + counted[staff_id] = true + var interval := StaffService._work_interval(template_id, _runtime_event_minute()) + for shift: ShiftInstance in staff_service.get_shifts(): + if shift.staff_id != excluded_staff and not shift.cancelled \ + and shift.assigned_facility_id == facility_id \ + and shift.end_at > maxi(interval.x, _runtime_event_minute()) and shift.start_at < interval.y: + counted[shift.staff_id] = true + return counted.size() + + +func _staff_can_reach_facility(staff: StaffRuntime, facility: FacilityInstance) -> bool: + var occupied: Array[Vector3i] = facility.occupied_cells.duplicate() + if occupied.is_empty(): + occupied.append(facility.origin) + var candidates: Dictionary = {} + for cell: Vector3i in occupied: + for offset: Vector3i in [Vector3i.ZERO, Vector3i.LEFT, Vector3i.RIGHT, Vector3i.UP, Vector3i.DOWN]: + var candidate := cell + offset + if path_service.has_cell(candidate): + candidates[candidate] = true + for candidate: Vector3i in candidates: + if not path_service.find_path(staff.current_cell, candidate, [WorldService.LAYER_FRONT, WorldService.LAYER_BACK], [&"staff"]).is_empty(): + return true + return false + + func _execute_set_staff_schedule_override(command: SimulationCommand) -> CommandResult: var staff_id := StringName(command.payload.get(&"staff_id", command.payload.get("staff_id", ""))) var start_day := int(command.payload.get(&"start_day", command.payload.get("start_day", -1))) @@ -6764,6 +6844,9 @@ func _execute_demolish_facility(command: SimulationCommand) -> CommandResult: "A Facility assigned to a non-terminal Defense Raid cannot be demolished.", {&"raid_id": defense_owner} ) + 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.") var operational_instance := facility_service.get_instance(instance_id) if operational_instance != null \ and (not operational_instance.queue_entries.is_empty() or not operational_instance.active_services.is_empty()): @@ -9187,6 +9270,18 @@ func _validate_runtime_crosslinks( or inventory.current_minute != absolute_minute \ or staff.current_minute != absolute_minute: return false + for assignment: Dictionary in staff.get_work_assignments(): + var facility_id := StringName(assignment[&"facility_id"]) + if facility_id.is_empty(): + continue + var assigned_facility := facilities.get_instance(facility_id) + if assigned_facility == null: + return false + var assigned_staff := staff.get_staff(StringName(assignment[&"staff_id"])) + var definition := definition_registry.get_facility(assigned_facility.definition_id) + if assigned_staff == null or definition == null or definition.staff_max <= 0 \ + or (not definition.required_roles.is_empty() and assigned_staff.role_id not in definition.required_roles): + return false var queued_links: Dictionary = {} var service_links: Dictionary = {} for facility: FacilityInstance in facilities.get_instances(): @@ -9607,6 +9702,54 @@ func get_mvp_staff_candidate_summaries() -> Array[Dictionary]: return summaries +## Defensive presentation snapshot for the Staff management panel. Planned +## assignment remains visible at night/off-duty even when the active link clears. +func get_staff_management_report() -> Dictionary: + var rows: Array[Dictionary] = [] + for staff: StaffRuntime in staff_service.get_staff_members(): + var entry := mvp_content_catalog.get_entry(&"staff_candidates", staff.staff_id) + var plan := staff_service.get_work_assignment(staff.staff_id) + var shift := staff_service.get_shift(staff.shift_id) + var template_id: StringName = &"DAY" + var effective_from := _runtime_event_minute() + var next_start_at := -1 + var assigned_facility_id := staff.assigned_facility_id + if not plan.is_empty(): + template_id = StringName(plan[&"template_id"]) + assigned_facility_id = StringName(plan[&"facility_id"]) + effective_from = int(plan[&"effective_from"]) + next_start_at = int(plan[&"next_start_at"]) + elif shift != null: + template_id = shift.template_id + effective_from = shift.start_at + else: + template_id = &"NIGHT" if StringName(entry.get(&"preferred_shift", &"day")) == &"night" else &"DAY" + rows.append({ + &"staff_id": staff.staff_id, &"display_name": String(entry.get(&"display_name", staff.staff_id)), + &"role_id": staff.role_id, &"state": staff.state, &"state_name": StaffRuntime.State.keys()[staff.state], + &"assigned_facility_id": assigned_facility_id, &"active_facility_id": staff.assigned_facility_id, + &"template_id": template_id, &"recurring": not plan.is_empty() and not assigned_facility_id.is_empty(), + &"effective_from": effective_from, &"next_start_at": next_start_at, + &"daily_wage": staff.daily_wage, &"fatigue": staff.fatigue, &"unpaid_wages": staff.unpaid_wages, + &"current_task_id": staff.current_task_id, + &"can_edit": staff.can_take_shift() and staff.current_task_id.is_empty() + and staff.state in [StaffRuntime.State.OFF_DUTY, StaffRuntime.State.COMMUTING, StaffRuntime.State.AVAILABLE] + and _defense_lock_owner(staff.staff_id, &"DEFENDER").is_empty() + and not event_service.get_actor_locks().has(staff.staff_id), + }) + var facilities: Array[Dictionary] = [] + for facility: FacilityInstance in facility_service.get_instances(): + var definition := definition_registry.get_facility(facility.definition_id) + if definition == null or definition.staff_max <= 0: + continue + facilities.append({ + &"facility_id": facility.instance_id, &"display_name": definition.display_name, + &"definition_id": facility.definition_id, &"layer": facility.origin.z, &"origin": facility.origin, + &"required_roles": definition.required_roles.duplicate(), &"staff_min": definition.staff_min, &"staff_max": definition.staff_max, + }) + return {&"staff": rows, &"facilities": facilities} + + func get_hired_staff_candidate_ids() -> Array[StringName]: return _sorted_name_keys(_hired_staff_candidate_ids) diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index a9f0c71..61bb147 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -227,3 +227,14 @@ - 判断: 暦日は00:00に更新する一方、宿の営業日は06:00から翌日05:59までとする。日次決算は翌06:00に行い、その後に昼番開始、個人AP回復、監査抽選、朝Event、Autosaveを処理する。 - 理由: 夜間の宿泊、酒場、地下営業を途中の00:00で分断せず、一続きの夜営業として同じDaySummaryへ含める。朝昼営業と夜営業の交代時に会計と運営判断をまとめる方が、宿の経営感覚と一致する。 - 影響: DaySummaryの期間は`Day N 06:00 <= minute < Day N+1 06:00`となる。05:59から06:00の境界では財務締めを最初に一度だけ確定し、失敗時は昼番開始以降を保留する。旧D-022は廃止する。 + +## D-032 通常UIの配属は継続計画と勤務実績を分けて保存する + +- 状態: 採用(2026-09-09。通常開業の単日受入・保存復元を確認。3営業日を含むP1全体は未合格) +- 判断: 通常UIから行う施設配属と勤務帯変更は、従業員ごとの `DAY`/`NIGHT` 継続計画 `work_assignments` として保存する。計画から以後の日次出勤に必要な具体的Shiftを決定的に生成し、毎朝プレイヤーが同じ配属をやり直す必要をなくす。未配属は施設IDを空にした明示計画として扱う。 +- 理由: 雇用時や単日Shiftの一度きりの指定だけでは翌営業日の出勤が途切れる。一方、再配属で既存Shiftの施設を直接書き換えると、過去の勤務時間と日次給与の施設別配賦まで新しい配属先へ移り、決算と保存再開の整合が崩れる。 +- 勤務履歴: 配属変更時に、進行中Shiftは変更時刻で区切って旧施設・既勤務区間を保持する。過去のShiftは書き換えない。未開始の旧予定は取消し、新しい計画の有効区間を別Shiftとして作る。日付をまたぐ夜勤も同じ区間規則を使い、実績時間を重複計上しない。 +- 操作境界: UIは `set_staff_work_assignment` Commandを送り、Runtimeで施設、役割、定員、到達経路、勤務変更可能状態を検証してから確定する。変更後の将来予定に旧単日Overrideが残って以前の配属へ戻ることを防ぐ。作業中など変更を受け付けられない場合は、理由を表示して状態を維持する。 +- 保存互換: StaffService Snapshotをversion 4とし、継続計画を保存する。旧version 3の保存は計画なしで読み込み、既存の人物・単日Shift・給与履歴を保持する。旧保存の最終勤務先を推測して全員へ繰返し予定を付与しない。読み込み後に通常UIで配属を指定した人物から、新しい継続計画を使う。 +- 影響: 翌日出勤、再配属、夜勤の日付跨ぎ、未配属、作業中拒否、途中Save復元を受入へ含める。施設別給与は保存した実勤務区間を根拠とし、現在の計画だけから過去分を再分類しない。 +- 今回の範囲外: 施設価格、受付の直接収入、宿泊料金の決済・売上記帳タイミングは改定しない。開業ガイドと帳簿は現在のサービス・台帳・06:00決算を読み取って表示する。これらと仕様正本の照合・変更はPLAY-04/PLAY-05で別に判断する。 diff --git a/docs/DEVELOPMENT_BACKLOG.md b/docs/DEVELOPMENT_BACKLOG.md new file mode 100644 index 0000000..a6b2e3f --- /dev/null +++ b/docs/DEVELOPMENT_BACKLOG.md @@ -0,0 +1,104 @@ +# 制作課題台帳 + +更新日: 2026-09-09。承認済み [全体制作計画](DEVELOPMENT_PLAN.md) の実行台帳。基準はドット断面版コミット `423fd6dd2e75da926e38b35ac8c435e462b168d9`、制作ブランチは `codex/first-operating-loop`。P1の通常開業ガイド・継続配属API/パネル・日次帳簿を接続し、通常初期予算での最小宿を単日受入・専用保存復元で確認した。全自動テストの構成ターゲットは成功終了。補給・厨房・物流を含む3営業日は未合格。部品の接続、単日成立、段階全体の完成を区別する。 + +## 状態の読み方 + +| 区分 | 意味 | +|---|---| +| 実装済み | 対象の部品または経路がコードにあり、下記の根拠を確認できる。段階全体の完成とは限らない | +| 画面へ未接続 | Service、Runtime、データに部品があるが、通常の画面操作から完結しない | +| 仕様との相違 | 正本・データ・実装・補助資料に差がある。採用方式を決めてからコードと仕様を揃える | +| 未検証 | 必要な通常プレイ、異常系、保存再開、実機、長期受入の結果をまだ記録していない | +| 未実装 | 計画で要求する機能・コンテンツの実行経路がない | +| 進行中 | 担当作業が動いているが、統合・受入前。実装済みへの読み替えをしない | + +仕様の正本は [GAME_SPECIFICATION.md](GAME_SPECIFICATION.md) と [SYSTEM_SPECIFICATION.md](SYSTEM_SPECIFICATION.md)。ルール変更時は [DESIGN_DECISIONS.md](DESIGN_DECISIONS.md) に理由と保存互換への影響を残す。 + +## P0:制作の基準点 + +| ID | 現在地と根拠 | 残り・完了判定 | +|---|---|---| +| BASE-01 | **基準保存済み**。ローカル制作版の初期コミット `423fd6d`、完全履歴bundle、Kenney原文ライセンスと取得元を保存。`git bundle verify`成功。詳細は [復旧ガイド](RECOVERY_GUIDE.md) | 別ディレクトリclone、Headless Editor読込、Pixel回帰238項目は成功。復元先の目視見学と元Drive版との全内容照合は未確認。Drive版は復旧候補として残す | +| BASE-02 | **資料更新済み/実機未検証**。[試遊ガイド](PLAYTEST_GUIDE.md)、[UI仕様](UI_OPERATION_SPECIFICATION.md)、[実装状況](IMPLEMENTATION_STATUS.md)を現Pixel表示へ同期。通常・見学・Capture・Test・Windows書き出しはMakefileにある | Windows Export Templateを用いた書き出し、クリーンなWindowsで起動・入力・保存を確認。コマンドがあることを実機合格としない | +| BASE-03 | **台帳作成済み**。この文書で部品の実装、画面未接続、仕様差、検証残を分類 | 以後は各機能の統合時に根拠・検証結果・残る差を更新する | + +P0の保存物はGit管理対象の制作ファイル。`.godot/`、`build/`、`artifacts/`、ゲームの `user://` 保存はbundleの外にある。 + +## P1:通常プレイの宿を経営 + +完成条件: 見学用の施設・食材支給やデバッグCommandに依存せず、通常画面だけで3営業日を運営し、欠品・人員・経路不足へ対処して保存再開できること。**段階全体は未合格。** + +| ID | 実装済みの根拠 | 未接続・仕様差・検証残と次の確認 | +|---|---|---| +| 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-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は別途制作 | + +確認済みの単日経路: 通常初期予算→建設→アカネの日勤配属→自然来客宿泊→翌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) を参照する。 + +## P2:上下移動と混雑 + +完成条件: 3階以上で実乗降し、同じ人数・需要のまま設備と動線の改善で待ち時間・離脱が減ること。**現在は運行未統合。** + +| ID | 実装済みの根拠 | 未接続・仕様差・検証残と次の確認 | +|---|---|---| +| MOVE-01 | `path_service.gd` の隣接経路、Connector、アクセス権、`facility_infrastructure_projector.gd` の通路・職員ドア投影 | **仕様との相違/未統合**。同一階の徒歩と設備による垂直移動を分け、通常隣接Edgeで階間を迂回する経路を抑制。旧通路・Saveからの移行を決める | +| MOVE-02 | PathにElevator状態、有限Queue、要求重複防止、停止時迂回、停滞検知の部品 | **未統合**。昇降路、停止階、車両、定員、待機要求を1方式の運行モデルへつなぐ。部品のQueue試験だけで昇降機完成としない | +| MOVE-03 | 昇降機Definitionがパレットにある。現在のRuntimeは安全なTopology未統合として建設を拒否 | **画面へ未接続/未検証**。建設・延伸・停止階変更・撤去・客用/業務用権限を運行へ統合する | +| MOVE-04 | `world_preview.gd` の徒歩補間と人物の小型描画 | **未実装**。待つ→乗る→昇降→降りるの運行状態を表示し、車内と徒歩の重複を防ぐ。見学の上階通路移動は乗降試験ではない | +| MOVE-05 | Path単体の停止・Queue・停滞検知とSnapshot試験 | **未検証**。統合した車両で満員、撤去、経路変更、車内Save復元を試験し、永久行列・二重乗車・閉じ込めを解消 | +| MOVE-06 | 既存の混雑密度/容量表示、[MVP受入](MVP_ACCEPTANCE_SCENARIOS.md)の比較条件 | **未検証**。同一Seed・需要・人数・施設数で改善前後を比較し、画面と移動時間P95・離脱で記録する | +| MOVE-07 | **計画と経路検証のみ接続済み**。通常UIの配属は継続計画を保存し、経路が存在するかを検証。人物のcurrent_cellは変更しない | **未統合**。勤務開始→実通勤→職場到着→人員稼働をつなぎ、移動中や不到達の人物を到着済み人員として扱わない。再配属・夜勤・Save復元を含める | + +## P3:営みと初見の操作 + +| ID | 現在地 | 次の完了判定 | +|---|---|---| +| LIFE-01 | **一部実装済み**。Runtime位置、サービス中位置の描画投影、待機・利用・作業Badge、停止 | 実状態に基づく歩行/待機/利用/睡眠/清掃/運搬/休憩の一覧と利用位置を確定する | +| 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-02 | **最小ガイド実装済み・単日確認**。実状態に応じる6段階、通常操作へのボタン、折り畳み、達成履歴・Save復元 | Day 1〜5解禁との整合、スキップ、複数失敗からの回復、初見30分で改善まで到達する試遊は残る | +| UX-03 | **一部実装済み/未接続**。ホイール、中ボタン、Home、キーボード開閉、Pixel・カメラ回帰 | 中ボタンなし・トラックパッド代替操作、日本語可読性、拡大時のドット、色以外の状態判別を実入力で確認 | + +## P4:The Tree固有の判断 + +| ID | 実装済みの根拠 | 残り | +|---|---|---| +| TREE-01 | Rank条件・非破壊拡張・Runtime統合、掘削gate・費用・支保、表裏同時開通 | **通常導線未検証**。Rank 1→2→3、地下解禁と対応施設を通常営業から通す | +| TREE-02 | BACK層、職員専用接続、物流・Task・混雑部品 | **未接続/未検証**。裏方改善で表の混雑と接客待ちが改善する同条件比較 | +| TREE-03 | Heat、予告・実Path監査、罰金台帳、日次処理 | **通常経営未検証**。地下の追加収益・対策費・監査を可視化し、合法/地下比較を行う | +| TREE-04 | `heroine_anri`/`rival_rai` の定義、関係Service、Event、配置リスクPreview部品 | **未接続**。実配属・同一Shift・接触から14日Event列を生成する | +| TREE-05 | 条件、閾値、Actor Lock、Choice、Route Lock確認、foreground UI | **未接続/未検証**。配置の事前警告→関係理由→重要分岐→復帰を実人物で一巡 | +| TREE-06 | FADE/REPLACE/DISABLEと厳密Save FilterのRuntime試験 | **画面へ未接続/進行未検証**。設定UIと代替本文を結び、変更後も14日が途切れないことを確認 | + +## P5:14日MVPと実機受入 + +| ID | 既存の根拠 | 未検証の完成条件 | +|---|---|---| +| MVP-01 | 14日Coordinator、Rank・人物Catalog、[受入シナリオ](MVP_ACCEPTANCE_SCENARIOS.md) | 開業→混雑→Day 7保存→BACK改善→地下→Day 14結果を通常操作で通す | +| MVP-02 | 24時間実Tick、14日運営境界を加速した連続/Day 7復元の一致 | 全Actor AI・Event・監査・実経路を含む20,160分を省略せず実行。00:00と06:00を個別に保存再開 | +| MVP-03 | 既存S14の混雑・BACK・合法/地下・関係の比較条件 | 移動P95 15%以上短縮、BACK物流中央値20%以上短縮等、正本のA/B条件を統合したゲームで満たす | +| MVP-04 | 論理負荷PERF-MVP-001、UI解像度・カメラの回帰 | Windows 11基準機を決め、100客+30従業員の実描画を1280×720・x1/x4で計測。平均55fps/1% low 45fps/入力100ms、保存・日次処理の境界を確認 | +| MVP-05 | 地上/地下とFRONT/BACKの説明、操作ガイド | 初見30分で軸と改善操作を理解し、14日完走後に改善判断を振り返れる試遊結果を残す | + +過去の全System合格記録、直前のUI・カメラ359項目、見学のPNGは、それぞれ対象が異なる。この台帳の作成では全System試験やWindows実機を再実行していない。 + +## P6・P7:製品版と配布 + +- **画面へ未接続/未実装**: Rank 4〜7の本編進行、拡張施設、研究・投資・探索、長編本文と結末。基盤やDefinitionの存在と、遊べる章の完成を分ける。 +- **仕様との相違**: 全体エンディングがゲーム仕様§3.1の3種と§3.6の4種で異なる。台本量を確定する前に整理する。 +- **未実装/未検証**: タイトル、ニューゲーム、続きから、設定、手動10枠UI、自動3世代の選択と復旧提示、クレジット、配布パッケージ。現在の手動1枠を製品仕様達成としない。 +- **未検証**: Windows初回起動、日本語・入力・DPI、低性能GPU、保存先と互換性。`make export-windows` とPresetはあるが、この台帳時点の対象PC合格記録はない。 + +## 直近の統合チェック + +1. 通常UIの最小宿・翌朝決算・専用保存復元は単日確認済み。新しい配属、ガイド、帳簿を含む全体回帰の結果を確認して次の検証済みコミットを記録する。 +2. MOVE-01〜03の階間経路と基本昇降機、MOVE-07の実通勤・到着後稼働を統合する。現在の論理配属で人物移動を完成扱いにしない。 +3. PLAY-03〜05の発注・厨房生産・実配送・会計仕様差を解消し、通常3営業日を継続する。建設中・配送中・行列中の保存再開を含める。 +4. 通常開始と保存なし見学の両方で表示・操作を確認し、最小宿の成功をP1全体完成やWindows製品合格としない。 diff --git a/docs/DEVELOPMENT_PLAN.md b/docs/DEVELOPMENT_PLAN.md index d63d254..fcd6a99 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の通常営業の最小ループから実施する。 +状態: 2026-09-09 ユーザー承認済み。P0の基準点整備を完了し、P1の通常営業の最小ループを実装・単日確認済み。補給・厨房・物流を含む3営業日は未合格。 ## 1. 目指す体験と計画の前提 @@ -22,13 +22,13 @@ | シミュレーション | 時間、建設、在庫・物流、経済、客AI、従業員、イベント、監査、保存の基盤 | 自律厨房生産・補充・配送と通常操作の接続。各Serviceの存在と日常運営の完成を区別 | | 縦移動 | Path側にConnector・Queue等の部品が存在 | 階間の通常隣接経路抑制、建設との接続、昇降・乗降、保存復元の統合 | | 見学版 | 実施設7件と通路、従業員4人、自然来客が動く | 見学は食材32個を用意した保存なしの再現場面。通常ゲームの完成とは区別 | -| 運営UI | 建設・掘削・雇用候補・施設情報・イベント選択・手動保存等 | 配属・勤務変更、仕入れ・在庫、価格・営業方針、比較しやすい損益、保存枠等 | +| 運営UI | 建設・掘削・雇用候補・継続配属/勤務変更・施設情報・開業の手引き・日次帳簿・イベント選択・手動保存。通常初期予算で最小宿の単日成立と保存復元を確認 | 実通勤・到着後の稼働、仕入れ・在庫、価格・営業方針、保存枠、3営業日の継続運営 | | 長期検証 | 24時間の統合検証、14日の運営境界比較等は実装記録あり | 全AIを含む20,160分の実Tick、各A/B比較、実描画を伴う負荷確認 | -| 制作管理 | ローカル版と元Drive版が存在 | 現状GitにHEADがなく制作ファイルが未追跡。作業基準点と復旧手順を確立 | +| 制作管理 | P0完了。ローカル正本を基準コミット `423fd6d` と完全履歴bundleへ保存。別ディレクトリ復元、Godot読込、Pixel回帰238項目を確認し、復旧手順と課題台帳を整備 | 今後の統合ごとに検証済み基準点とバックアップを更新する。元Drive版との全内容照合は未完了のため復旧候補として保持 | 直前の外観改修では起動確認とUI・カメラの359項目が通過した。この計画作成時に全システムテストを再実行したわけではなく、359項目の結果も14日MVPやWindows性能の合格を意味しない。 -[IMPLEMENTATION_STATUS.md](IMPLEMENTATION_STATUS.md) と [PLAYTEST_GUIDE.md](PLAYTEST_GUIDE.md) には旧画面の説明が残っている。現在の外観・操作は [PIXEL_TOWER_VISUALS.md](PIXEL_TOWER_VISUALS.md) を参照し、P0で資料を揃える。 +[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. 段階別の制作計画 @@ -72,6 +72,7 @@ - **MOVE-04** 待つ→乗る→昇降→降りるを描画する。乗客が徒歩と車内に同時表示されないよう、運行状態に結び付ける。 - **MOVE-05** 停止・満員・撤去・経路変更・保存途中の乗客を検証する。永久行列、重複乗車、閉じ込めを解消する。 - **MOVE-06** 同じ需要・施設数・乱数で、移動設備の改善前後を比較する。人物の数を減らさずに待ち時間が改善することを画面と数値で示す。 +- **MOVE-07** 継続配属計画から勤務先への実通勤を生成し、到着後に施設人員として稼働するよう接続する。現状は経路の存在を事前検証して論理配属するだけで、配属操作では位置を変えない。移動中・不到達を到着済み人員として扱わず、再配属・夜勤・保存復元を確認する。 この段階で、階数を増やすほど移動設計が重要になることを確かめる。移動が面白くなる前に、多数の施設や後半シナリオを量産しない。 @@ -79,7 +80,7 @@ - **LIFE-01** 歩く/待つ/利用する/眠る/清掃する/運ぶ/休憩するの意味を、実状態と動作一覧で定義する。向き、足元、利用位置、車内表示の基準を固定する。 - **LIFE-02** 共通人物で専用の歩行・作業フレームを先行制作し、縮小・拡大・混雑で確認してから職種・客層へ展開する。髪色や衣装だけでなく、仕事道具と行動で識別できるようにする。 -- **LIFE-03** MVP26施設を「外形と家具で種類が分かる」「営業・混雑・故障が分かる」という順で仕上げる。世界樹は成長・地下・昼夜による変化を加える。 +- **LIFE-03** MVP16施設を「外形と家具で種類が分かる」「営業・混雑・故障が分かる」という順で仕上げる。世界樹は成長・地下・昼夜による変化を加える。 - **LIFE-04** Kenneyを優先してUI音・環境音・汎用素材を選び、足りない固有部分を制作する。音量設定を用意し、頻繁な効果音が重ならないよう調整する。 - **UX-01** 建設可能範囲、費用、接続、必要人員、欠品の案内を揃える。配属・在庫・会計など、日常の判断を少ない操作で行えるようにする。 - **UX-02** 建築と経営の序盤ガイドを作る。既存のDay 1~5解禁表との整合をとり、説明の再閲覧、スキップ、失敗からの回復を用意する。 @@ -134,14 +135,14 @@ MVP後、試遊で残った課題と既存の製品仕様を照合して後半 | 仕様と仮データがずれたまま施設を増やす | P1で厨房・食堂の入出力と役割を揃え、安定した型から横展開する | | 見た目の完成と長期運営の完成を混同する | 各段階の完成条件、実Tick受入、初見試遊を別々に記録する | | ドット表現が増えると描画が重くなる | P3から可視範囲・更新頻度・動作数を計測し、P5でWindows実機の負荷条件を満たす | -| 施設・人物・文章の量産が先行して修正量が膨らむ | 基本運行1方式、MVP26施設、人物関係1組で先に形式と面白さを確かめる | +| 施設・人物・文章の量産が先行して修正量が膨らむ | 基本運行1方式、MVP16施設、人物関係1組で先に形式と面白さを確かめる | | 旧版を戻せず保存データも読めなくなる | P0の版管理、保存形式変更時の移行、更新前バックアップ、途中再開テスト | ## 7. 次回の着手順 -1. **BASE-01~03**:現行版の復元可能な基準点と、実装/画面/仕様の差分を確定する。 -2. **PLAY-01~02、PLAY-05の最小部分**:建てる→雇う・配属する→宿泊の売上→翌朝の給与・決算を、通常画面だけで一巡させる。 -3. **MOVE-01~03**:階間経路を整理し、基本昇降機1基で建設→待機→乗車→移動→降車を接続する。 +1. **BASE-01~03(完了)**:復元可能な基準点と課題台帳を維持し、今後の統合ごとに更新する。 +2. **PLAY-01~02、PLAY-05の最小部分(単日確認済み)**:建てる→雇う・継続配属→自然来客の宿泊→翌朝給与・決算→保存復元を確認。全体回帰と会計仕様差の調整を続ける。 +3. **MOVE-01~03、MOVE-07**:階間経路、基本昇降機の建設・運行、配属後の実通勤・到着後稼働を接続する。 4. **PLAY-03~04**:厨房生産・発注・実搬送を、確定した配属と会計へ接続する。ルール確定後の素材・UI部分は縦移動の制作と並行できる。 5. **MOVE-04~06、PLAY-05~06**:断面アニメーション、混雑改善、停止・撤去・保存再開、3営業日の継続運営を確認する。 6. **LIFE-01~04、UX-01~03**:専用動作と説明・音・日常操作を整え、初見30分の試遊へ渡す。 diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 36a2cb5..22e771e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -2,6 +2,10 @@ この文書は仕様書そのものではなく、現在のコードがどこまで到達しているかを記録する。ゲームルールの正本は `GAME_SPECIFICATION.md`、System境界と不変条件の正本は `SYSTEM_SPECIFICATION.md` とする。 +更新日: 2026-09-09。ドット断面版の基準コミットは `423fd6dd2e75da926e38b35ac8c435e462b168d9`。復旧方法は [RECOVERY_GUIDE.md](RECOVERY_GUIDE.md)、承認済み制作計画の課題と検証残は [DEVELOPMENT_BACKLOG.md](DEVELOPMENT_BACKLOG.md) を参照する。 + +以下のSystem別一覧は既存の実装・試験記録を引き継いでいる。直前のドット版変更では起動とUI・カメラの359項目、その後の配属APIでは103項目の個別確認記録がある。P1追加後に `make check` と `make test` の全構成ターゲットを2群に分けて実行し、全件が成功終了した。追加の配属API103項目、スタッフ画面44項目、通常開業35項目も合格。対象と制約は [P1単日検証記録](qa/P1_FIRST_OPERATING_DAY.md) に記録した。通常初期予算での最小宿は単日受入・保存復元を確認したが、14日MVP、Windows実機性能、通常操作だけによる3営業日は別の完成条件として残る。 + ## 実装・自動検証済み ### S0: 決定的Simulation基盤 @@ -127,28 +131,39 @@ - 32 Guest定義のRuntime登録、候補雇用時の契約費・日給・Shift・Pathの一括確定、重複雇用防止 - 累計Faith/累計客の実取引連動、Rank昇格とWorld/Path/GameStateの原子的確定、旧Runtime Rank 1保存の安全な移行 -### 操作画面 +### 操作画面:現在のドット断面版 -- V3モックを基準にした暖色の木・革・羊皮紙UI。上部の常設HUD、左の管理カテゴリ、中央の生きたMap、右の施設Inspector、下部の建築パレットへ領域を分離 -- 表層の世界樹宿と地下根域の製品向け背景アセットを導入。背景Texture、UI Palette、重ね描画Opacityを`VisualThemeProfile`へ分離し、Main SceneのTheme指定だけで一括交換可能。Theme欠落時は手続き描画へ安全にFallbackする -- 施設16種、管理カテゴリ6種、資源4種、表裏層・掘削・回転・保存などの操作アイコンを、実透明の4×4 Atlas Assetとして導入。UIは安定IDから`VisualThemeProfile`の割り当てを取得し、画像名やAtlas座標を直接参照しないため、後からTheme単位または個別割り当てで交換可能 -- 施設Iconを中央Mapの完成施設・建設中施設・配置Previewへも接続。占有CellとHit Testは従来どおりWorldServiceを正とし、Iconは占有範囲中央へ視覚的に重ねるだけなのでSimulationとSave互換性を維持する。選択中の施設/工事は右Inspectorにも同じTheme Iconを表示 -- 客8クラスと従業員8役割群の成人キャラクターMarkerを実透明4×4 Atlasとして導入。客種別/従業員役割の安定IDからTheme割り当てを解決し、派生客種別はクラス接頭辞へ、未知IDは既定MarkerへFallbackする -- 外枠・木・革・羊皮紙Panel、Button 4状態、選択/警告枠、待機Badge、工事中、客/従業員経路、FRONT/BACK境界装飾を4×4 UI Skin Atlasとして導入。主要Panel、全Button、Map経路、工事中Cell、待機Badgeを意味ID経由で接続し、Theme欠落時は従来のStyleBoxFlat/手続き描画へFallbackする -- 外部の画像生成AIで正式素材を再制作するための`ART_ASSET_PRODUCTION_SPECIFICATION.md`を追加。現在接続済みの全画像について推奨解像度、Alpha、Atlas順序、生成指示、差し替え手順、納品判定を固定した -- 1440×810を基準解像度として、`canvas_items`/`expand`/fractional stretchとHiDPIを有効化。背景、施設、共通操作、人物AtlasにはMipmapを生成し、720p・1080p・1440p・4Kで同一UI構造を維持する -- 上部HUDへ営業日、時刻、時間帯、来客、宿泊予定、所持金、Mana、資材、Faith、Heat、一時停止、x1/x2/x4を常設 -- 実World Cell、占有、工事、施設、Guest、StaffのFRONT/BACK別描画 -- PathServiceの実密度/容量から緑・黄・赤の混雑経路を描画し、FacilityServiceの実待機列をMap上の数字Badgeとして表示 -- 施設選択と状態、reason、待機列、Service、配置人員の表示 -- 仕様表のMVP施設16種を選べる画面下部の横スクロール建設パレット -- 回転、配置Preview、重なり等の失敗理由、建設Command -- 掘削モード、正式な費用・入口接続・支保Preview、Rank/運営gate、進捗表示、FRONT/BACK同時解放Command -- 時間、一時停止、x1/x2/x4、Event、Heat、手動Save/Load -- 本番運営の明示起動、個人AP、未払給与、前日損益、要確認/破綻状態、失敗境界の再試行表示 -- 検証済み雇用候補20人の一覧。氏名、役割、勤務帯、契約金、日給、4技能を表示し、原子的な雇用Command、未払/破綻gate、二重クリック防止、即時/予約実行結果、Save済みCommand履歴からの再試行連番復元へ追従する -- foreground Eventの日本語Choiceボタン、安定した選択Command、解決後の停止解除と選択欄消去 -- 1280×720、1440×810、1920×1080、2560×1440、3840×2160で、左Navigation/中央Map/右Inspector/下部Paletteの位置関係を含むLayout自動検証 +- The Tower SPを参考にした世界樹の断面を中央へ広く表示する。空、幹、枝葉、床、部屋を手続き的なドット描画で構成し、受付のカウンター、客室のベッド、食堂の卓、厨房などを施設IDに応じて描き分ける。 +- Kenney Tiny Dungeon/Tiny Townの16×16px素材を人物、小物、庭木へ接続。Packed Atlas、Nearest、Mipmap無効を使用し、公式配布元・CC0原文・取得ZIPのSHA-256を `assets/kenney/` に保存する。 +- 客と従業員はRuntimeに存在する人物を表示する。移動の補間、左右反転、足のずらしと上下動、待機・利用・作業の状態表示を行い、一時停止中はアニメーションも停止する。専用の食事・清掃・運搬フレームは未制作。 +- サービス中の客は施設内の利用位置へ描画上だけ投影する。シミュレーションの権威セル、経路、占有、保存形式は維持する。 +- 濃緑、クリーム、木色のコンパクトUI。上部HUD、左の細い管理カテゴリ、中央マップ、下部操作バーを常設し、右の運営詳細と下部の建築パレットは必要なときに開く。起動時は両パネルを閉じる。 +- 上部HUDに営業日、時刻、時間帯、来客、宿泊予定、所持金、Mana、資材、Faith、Heat、停止、x1/x2/x4を表示する。 +- 実World Cell、占有、工事、施設、Guest、StaffをFRONT/BACK別に描く。ホイールでカーソル基準ズーム、中ボタンドラッグでパン、Homeで全体表示、Gで格子と実混雑表示を切り替える。 +- PathServiceの密度/容量を緑・黄・赤の経路に、FacilityServiceの待機人数を小さなBadgeに表示する。施設クリックで右パネルを開き、状態、reason、待機列、Service、配置人員を確認する。 +- MVP施設16種を選べる下部の横スクロール建築パレット、回転、配置Preview、失敗理由、建設Commandを接続。客用/業務用昇降機は安全な階間Topologyが未統合のため、現在は建設を拒否する。 +- 掘削モードでは費用、入口接続、支保、Rank/運営gate、進捗を表示し、FRONT/BACK同時解放Commandへ接続する。 +- 雇用候補20人の氏名、役割、勤務帯、契約金、日給、4技能を表示。原子的な雇用、未払/破綻gate、二重クリック防止、予約実行結果、保存したCommand履歴との整合を扱う。その後のP1追加で、雇用済み人物の配属・再配属、DAY/NIGHTの継続勤務計画、未配属、反映予定と変更不可理由を通常UIへ接続した。配属先までの経路を検証するが、実通勤して到着してから稼働する経路は未統合。 +- 本番運営の明示起動、個人AP、未払給与、前日損益、要確認/破綻、失敗境界の再試行表示、foreground Eventの日本語Choice、下部の手動Save/Loadを接続する。P1追加で6段階の「開業の手引き」と、営業日選択、現金入出金、建築出金、確定損益、給与・未払、施設別損益、照合を示す帳簿を接続した。建築出金と営業利益を分け、内部のcost_of_goodsは雇用契約金等を含むため「運営費」と表示する。 +- 商品・探索・投資等の管理カテゴリは入口または準備中の案内に留まる。すべてのカテゴリを製品機能の完成として数えない。 +- 従来の大きな生成背景、施設アイコンによるマップ表現、人物Bust Marker、装飾Skinは現マップ表示に使用しない。小型の建築・管理・資源・操作アイコンは `VisualThemeProfile` の既存参照を残す。 +- 基準解像度1440×810、HiDPI、`canvas_items`/`expand`/fractional stretchは維持。Pixelの元画像にはMipmapを生成しない。旧UIの解像度別Layout試験と現在のPixel・カメラ試験は、実描画Frame性能とは別に評価する。 + +### 見学・制作基準 + +- `make preview-pixel` は実施設7件、通路、従業員4人、自然来客を組み立てる専用見学。食堂へ食材32個を明示的に支給し、保存・読込・自動保存を無効にする。通常ゲームの初期状態や完成した経営ループの代用ではない。 +- `make capture-pixel` は時間差PNG 2枚と状態JSONを `artifacts/pixel-tower/` に再生成する。生成物はGit管理対象外。 +- ローカル制作版をGitへ記録し、基準コミットの完全履歴bundleを作成・検証済み。`.godot/`、`build/`、`artifacts/`、ゲームの `user://` 保存はbundleに含めない。 +- 旧V3モックと `ART_ASSET_PRODUCTION_SPECIFICATION.md` は過去のアート資産として保持する。現在の画面実装の基準は [PIXEL_TOWER_VISUALS.md](PIXEL_TOWER_VISUALS.md) と実描画であり、旧モックへの再一致を要求しない。 + +## P1:通常開業の最小ループを単日確認 + +- 通常の初期Gold 5,000・資材50から、UIのCommand経路で受付1件、一般客室2件、通路7セルを建築し、アカネを雇用・受付へ日勤配属する。見学Fixture、客やServiceの直接挿入、無料在庫支給は使用しない。 +- 自然来客による宿泊売上320G、翌06:00の給与120G、未払0G、帳簿照合一致を確認。専用 `user://tests/first_operating_day_/restore-check` から決算・配属・開業達成を復元する。 +- 受付売上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へ追加する。 ## 統合済みの運営縦断 @@ -168,7 +183,8 @@ - 配置と同一Shiftから関係Event候補を生成する接触判定と事前リスクPreview - 夕方混雑、FRONT/BACK比較、健全/地下収支を直接比較するScenario Test - 100客・30従業員の実描画Frame試験 -- 製品用アート、音響、物語本文、完全なチュートリアル、バランス調整 +- 通常開始からの建設・雇用・配属・厨房生産・仕入れ・配送・収支確認・保存再開による3営業日運営 +- 専用ドット動作、製品用アート、音響、物語本文、完全なチュートリアル、バランス調整 ## 実行方法 diff --git a/docs/PLAYTEST_GUIDE.md b/docs/PLAYTEST_GUIDE.md index 5bc7ed1..40568ab 100644 --- a/docs/PLAYTEST_GUIDE.md +++ b/docs/PLAYTEST_GUIDE.md @@ -1,58 +1,122 @@ # 試遊ガイド -このガイドは、現在のプロトタイプで画面操作とSimulation連動を短時間で確認するためのものです。 +更新日: 2026-09-09。現在のドット断面版で、画面操作とSimulationの連動を確認する手順です。復旧基準は `423fd6d`、その後のP1追加で通常初期予算から翌朝の決算・保存復元までを単日確認しました。通常ゲームと、在庫を用意した保存なしの見学を分けて確認します。 -## 起動 +## 起動する場所とモード -Godot 4.7系を用意し、プロジェクト直下で次を実行します。 +制作フォルダは `/Users/korizatou114/Projects/とりにく工房ゲーム制作部` です。Godot 4.7系を用意し、このフォルダで実行します。`godot`がPATHにない場合は、各コマンドへ `GODOT='/実際のGodot実行ファイル'` を指定できます。 - make editor +| 目的 | コマンド | 内容 | +|---|---|---| +| 通常ゲーム | `make run` | 通常の初期資源・建設・雇用・運営。手動保存と自動保存が有効 | +| エディター | `make editor` | 開いた後に実行ボタンまたはF5で起動 | +| 建設済みの宿を見学 | `make preview-pixel` | 受付、食堂、厨房、客室、通路、実際の客・従業員を用意。保存なし | +| 時間差の画像を出力 | `make capture-pixel` | `artifacts/pixel-tower/` にPNG 2枚と運営状態JSONを生成 | +| 通常開業の単日受入 | `make test-opening` | 見学Fixtureなしで通常UIのCommand経路を使い、初売上・翌06:00・保存復元を検証 | +| 通常開業の画面を出力 | `make capture-opening` | 同じ単日受入を描画し、開始・配属後・決算のPNGを生成 | -Godotが開いたら右上の実行ボタンを押します。エディターを介さず起動する場合は次を使います。 - - make run +見学は実施設7件と通路、従業員4人をRuntimeコマンドで組み立て、食堂へ食材32個を用意した再現場面です。保存・読込・自動保存は無効で、通常の初期状態には反映しません。準備中は少し待ってから操作します。PNGの生成には描画可能なデスクトップ環境が必要です。 ## 基本操作 | 操作 | マウス/キー | |---|---| -| 施設を選ぶ | 左側の建設パレット | -| 配置候補を確認・建設 | 地図上へ移動/左クリック | -| 施設の詳細を表示 | 建設選択を解除して施設を左クリック | -| 施設を回転 | `R` | -| 建設選択を解除 | `Esc` | -| 地下掘削モード | 画面下の「掘削」、または `E` | -| 雇用候補を開く/閉じる | 左側の「候補を見る」、または `H` | -| FRONT/BACK切替 | 画面下のボタン、または `Tab` | -| 一時停止/再開 | 画面下のボタン、または `Space` | -| 速度変更 | `1`、`2`、`4` | -| 手動保存/読込 | 画面右下の「保存」「読込」 | +| 建築パレットを開閉 | 下部の「建築」、または `B` | +| 施設を選ぶ | 開いた下部パレットの施設カード。横へスクロール可能 | +| 配置候補を確認・建設 | マップ上へカーソル移動/左クリック | +| 施設の詳細を表示 | 建築選択を解除して完成施設を左クリック。右パネルが開く | +| 運営・選択中施設の詳細を開閉 | 下部の「運営」、または `I` | +| 施設を回転 | 下部の「回転」、または `R` | +| 選択・モードを解除 | 下部の「解除」、または `Esc`。ツール未使用時はパネルも閉じる | +| 地下掘削モード | 下部の「掘削」、または `E` | +| 雇用候補を開閉 | 左の「従業員」から「候補を見る」、または `H` | +| 雇用済み人物の配属・勤務変更 | 左の「従業員」→「雇用済み」→人物・配属先・日勤/夜勤を選び「配属・勤務帯を変更」 | +| 日次決算を見る | 左の「帳簿」。確定した営業日を選択可能 | +| 開業ガイドを折り畳む | マップ左上の「開業の手引き」の見出しをクリック | +| FRONT/BACK切替 | 下部の「客用・表層」「裏方・裏層」、または `Tab` | +| 一時停止/再開 | 上部の「停止/再開」、または `Space` | +| 速度変更 | 上部のx1/x2/x4、または `1`、`2`、`4` | +| 拡大・縮小 | マップ上でホイール。カーソル付近を基準に拡縮 | +| 断面を移動 | マップ上で中ボタンドラッグ | +| 全体表示に戻す | `Home` | +| 格子・混雑表示 | `G` | +| 手動保存/読込 | 下部右側の「保存」「読込」。見学では無効 | -## 最初の確認手順 +地上/境界/地下は高さによるゾーンです。FRONT/BACKは同じ高さにある客用/裏方のレイヤーで、地下とBACKは別の軸です。起動時は建築パレットと運営詳細を閉じ、断面を広く表示します。 -1. 一時停止した状態で「受付」を選び、中央の境界階へ配置します。 -2. 「一般客室」を地上側、「大衆食堂」を空いているFRONTセルへ配置します。 -3. `4`で時間を進め、工事表示が完成施設へ変わることを確認します。 -4. `Esc`で建設を解除し、完成施設を選びます。営業状態、待機列、サービス数、配置人員が左側へ表示されます。 -5. `Tab`でBACKへ切り替え、「倉庫」を配置します。FRONTとBACKの占有が別管理であることを確認します。 -6. 「保存」を押し、建設や時間を進めてから「読込」を押します。保存時点へ戻れば成功です。 +## 最初に外観と動きを見る -`H`で雇用候補を開くと、20人の氏名、役割、勤務帯、契約金、日給、接客・運営・戦闘・裏社会の技能を比較できます。「雇用する」を押すと契約金が一度だけ支払われ、候補は雇用済み表示へ変わります。時間進行中に押した場合は次のCommand処理で確定し、処理中の二重送信は行いません。未払給与または破綻状態では新しい契約を拒否し、保存後に再起動しても過去の操作IDと衝突せず再試行できます。 +1. `make preview-pixel` で見学を起動します。受付カウンター、食堂の卓、客室のベッドと小さな人物を確認します。 +2. ホイールで拡大し、客の移動・待機・利用、従業員の移動を観察します。人物はRuntimeの実在Actorです。現在の歩行はKenneyの静止画を分割して動かす簡易表現です。 +3. `Space`で停止し、人物の動作が止まることを確認します。再開して再び動くことを確かめます。 +4. `G`で格子と混雑を表示し、施設をクリックして右側の営業状態・待機・人員と照合します。 +5. `Tab`でBACKの厨房を確認します。中ボタンドラッグとズームを試した後、`Home`で全体表示へ戻します。 -世界樹Rank 2へ到達すると「掘削」を選べます。地下の候補へカーソルを合わせると費用、資材、所要時間、入口接続、支保条件を確認でき、クリックするとFRONT/BACKを同時に掘削します。Rank不足や未払給与などで開始できない場合は、理由を画面へ表示します。 +この見学は収支・補充・配送の完成を保証する試験ではありません。昇降機の乗降は未実装で、見学の上下移動は既存の通路接続を使っています。 -建設パレットは上下にスクロールでき、MVP施設16種をすべて選べます。客用通路、業務用通路、従業員ドアは完成時に実際のROUTE/職員専用経路へ変換されます。客用/業務用昇降機は安全な階間経路が未実装のため、現時点では資源を消費せず建設を拒否します。 +## 通常UIで最初の宿を開業する -配置できない場合は、範囲外、ゾーン不一致、レイヤー不一致、重なり、支持不足、資源不足のいずれかが画面へ表示されます。 +`make run` の新規ゲームは初期Gold 5,000・資材50で停止した状態から開始します。`Space`で再開し、必要なら `1`/`4`で速度を選びます。速度の変更だけでは停止は解除されません。マップ左上の「開業の手引き」が受付→客室→接続→配属→宿泊売上→翌朝の決算を案内します。見出しで折り畳み、後から再び開けます。 -## 自動検証 +次は単日受入と同じ小さな宿です。座標は `(x, y)`、すべてFRONTです。画面では `y=0` が地表の1F、`y=2` が3F、`y=3` が4Fに当たります。`G`で格子を出し、空きセルを選ぶと右パネルで座標を確認できます。配置するたびに停止し、完工を待つと進めやすくなります。 -全Systemと操作画面の回帰試験: +1. `B`で「受付」を選び、`(-8, 0)`へ配置します。受付は4×2セルを占有します。`Space`で再開して120ゲーム分進め、完成したら再び停止します。 +2. 「一般客室」を `(-8, 2)` と `(-6, 2)` に1室ずつ配置します。完成した受付のすぐ上、左右に並ぶ2室です。同時に「客用通路」を `(-4, 1)` へ配置し、60ゲーム分進めます。 +3. 「客用通路」を `(-4, 2)` へ配置し、10ゲーム分進めます。続いて客室の上側をつなぐように `(-8, 3)`、`(-7, 3)`、`(-6, 3)`、`(-5, 3)`、`(-4, 3)` へ置き、さらに10ゲーム分進めます。通路は合計7セルです。 +4. `Esc`で建築を解除します。左の「従業員」から「候補を見る」または `H` で雇用候補を開き、受付係のアカネを雇用します。契約金と日給を確認してください。 +5. 「雇用済み」を開いてアカネを選び、配属先を建てた受付、勤務帯を「日勤 06:00〜18:00」にして「配属・勤務帯を変更」を押します。成功表示を確認し、時間進行中の予約Commandなら次の処理まで待ちます。勤務帯は翌日以降も繰り返す計画として保存されます。 +6. `Space`で再開し、必要なら `1`または`4`で速度を選んで、自然に到着した客の宿泊を観察します。現在の宿泊サービスは完了時に売上を記帳するため、到着直後に宿泊売上が出るとは限りません。客が来ない・利用されない場合は施設を選び、接続と人員の理由を確認します。 +7. 翌朝06:00まで進め、左の「帳簿」で第1営業日の確定値を選びます。売上、運営費(契約金等)、給与、営業利益、未払、施設別損益、照合結果を確認します。00:00はまだ同じ営業日です。 +8. 通常モードの下部「保存」で結果を確認し、その後に時間を進めて「読込」を押します。時刻、配属計画、決算、開業達成表示が保存時点へ戻ることを確かめます。手動UIは `user://saves/manual_ui` の1枠です。 - make test +この経路はUIの建築・雇用・配属処理から実行する自動受入で確認しています。受入の途中保存は通常スロットへ触れず、専用 `user://tests/first_operating_day_/restore-check` を使用して復元後に整理します。実ユーザーによる初見30分の試遊合格や、見学Fixtureを使わない3営業日の完成とは別の判定です。 -エディター読み込みとメイン画面の起動確認: +現在の配属は経路が存在することを事前検証してから論理的に確定します。配属操作だけで人物の位置は変えず、職場まで実際に通勤して到着後に人員として稼働する統合は今後のMOVE課題です。 - make check +## 単日確認の結果と読み方 -現在はSystem実装と操作可能な運営画面を検証する段階です。最終アート、音響、物語コンテンツ、全施設、完全なチュートリアル、製品版バランスは今後の制作範囲です。 +Seed `424242`、通常初期予算、受付1・一般客室2・通路7・アカネ1人の受入で、次を確認しました。客・サービスの直接挿入、無料の在庫追加、見学Fixtureは使っていません。 + +| 確認項目 | 結果 | +|---|---| +| 自然来客による宿泊売上 | 320G | +| 受付売上 | 6,020G | +| 翌06:00の給与 | 120G | +| 未払給与 | 0G | +| 全体の営業利益 | 6,040G | +| 帳簿照合 | 一致 | +| 専用テスト保存からの復元 | 決算値・配属・開業達成を復元 | + +受付売上が大きく、現在の数値は製品版の黒字基準や推奨バランスではありません。価格、受付の会計上の役割、宿泊料金の決済・記帳タイミングは仕様との差を調整する必要があります。建築出金は資金増減として表示し、営業利益から分けています。また、06:00の給与支払は新営業日の現金出金に、前営業日の給与費用は確定決算に表示されます。 + +`make capture-opening` の出力は `artifacts/first-operating-day/opening.png`、`staffed.png`、`first-close.png` です。開始画面、配属後、翌朝の帳簿を比較できます。 + +## 建築・掘削で確認すること + +パレットは横スクロールでMVP施設16種を表示します。客用通路・業務用通路・従業員ドアは、完成時に実ROUTEまたは職員専用接続へ変換されます。客用/業務用昇降機は安全な階間経路が未統合のため、基準版では資源を消費せず建設を拒否します。 + +Rank 2へ到達すると地下掘削を選べます。候補へカーソルを合わせ、費用・資材・所要時間・入口接続・支保条件を確認し、クリックするとFRONT/BACKを同時に掘削します。Rank不足、未払給与などで開始できない場合は理由を表示します。 + +範囲外、ゾーンやレイヤーの不一致、重なり、支持不足、資源不足などの失敗理由は下部の案内に出ます。実装済みのクリック配置を確認する手順であり、ドラッグ連続建設、人物追跡、解体UI、価格変更等の製品仕様は別途接続が必要です。 + +## 自動検証とWindows書き出し + +| 確認 | コマンド | 判定する範囲 | +|---|---|---| +| エディター読込・短時間起動 | `make check` | 構文、リソース、Main Sceneの起動 | +| ドット・カメラ回帰 | `make test-pixel-ui` | 実施設クリック、ズーム・パン、層、停止、描画とSimulationの分離 | +| 操作画面 | `make test-ui` | 上記、運営・従業員画面、通常開業の回帰 | +| 通常開業 | `make test-opening` | 初期予算、建築・雇用・配属、自然来客の宿泊、00:00/06:00、専用保存復元 | +| 通常開業の描画 | `make capture-opening` | 同じ受入と開始・配属後・決算のPNG | +| 全System | `make test` | S0〜S6、MVP、受入、UI、論理性能のHeadless試験 | +| 運営受入 | `make test-acceptance` | 24時間実Tickと14日運営境界。全Actorの14日実Tickとは区別 | +| 完全論理負荷 | `make test-perf-full` | 200客・50従業員・150施設の24時間Fixture。実描画fpsとは区別 | +| Windows出力 | `make export-windows` | 対応するGodot Export Templateを使い `build/windows/TheTree.exe` 等を生成 | + +Windows書き出しコマンドの存在はWindows実機合格を意味しません。起動・日本語・入力・保存・再開・描画性能を別途確認します。ドット版の仕様は [PIXEL_TOWER_VISUALS.md](PIXEL_TOWER_VISUALS.md)、基準点の復旧は [RECOVERY_GUIDE.md](RECOVERY_GUIDE.md) を参照してください。 + +## リリース前に残る範囲 + +今回の到達点は、通常UIでの最小宿の単日成立です。仕入れ→倉庫→厨房生産→実配送→消費、価格・営業方針の操作、欠品や人員不足からの回復を含む3営業日はまだ完了していません。昇降機の運行・乗降と、配属先への実通勤も未統合です。 + +全Actorの14日実Tick、初見試遊、Windows実機での100客+30従業員描画、保存枠・設定・専用アニメーション・音・本文も別途必要です。現在の単日受入や画像出力から製品完成を判定せず、[課題台帳](DEVELOPMENT_BACKLOG.md) の完成条件で進めます。 diff --git a/docs/RECOVERY_GUIDE.md b/docs/RECOVERY_GUIDE.md new file mode 100644 index 0000000..fb8a26c --- /dev/null +++ b/docs/RECOVERY_GUIDE.md @@ -0,0 +1,93 @@ +# ローカル制作版の復旧ガイド + +更新日: 2026-09-09。現行の制作フォルダや元Drive版を上書きせず、Gitの基準bundleから別ディレクトリへ復元する手順です。 + +## 保存した基準点 + +| 項目 | 値 | +|---|---| +| ローカル正本 | `/Users/korizatou114/Projects/とりにく工房ゲーム制作部` | +| 基準コミット | `423fd6dd2e75da926e38b35ac8c435e462b168d9` | +| コミット内容 | `Preserve approved pixel tower prototype as development baseline` | +| 制作ブランチ | `codex/first-operating-loop` | +| 復旧bundle | `/Users/korizatou114/Projects/the-tree-backups/the-tree-baseline-2026-09-09.bundle` | +| bundle SHA-256 | `6c33456fed426b5348f7f3a1b2ee5e0dfdf8f9d70bca9acc293ba62b7ae05cd0` | + +bundleはこの基準点までの完全なGit履歴を保持します。作成時の `main`、`codex/first-operating-loop`、`HEAD` は同じ基準コミットを指します。この後のP0資料更新とP1実装は、別途コミットして新しいbundleを作るまで、この基準bundleには含まれません。 + +含むものはGit管理されたソース、Godotプロジェクト、Definition、テスト、資料、元画像、Kenney原文ライセンス等です。`.godot/` のインポートキャッシュ、`build/` の配布物、`artifacts/` のキャプチャ、ゲームの `user://` 保存は含みません。Godot実行ファイルとWindows Export Templateも別途必要です。 + +元Drive版は内容照合が終わるまで復旧候補として残します。制作正本へ自動で同期したり、復旧のために元Drive版を削除したりしません。 + +## 1. bundleを確認する + +現在の制作フォルダで実行します。いずれも読み取り専用です。 + +```sh +cd '/Users/korizatou114/Projects/とりにく工房ゲーム制作部' +git bundle verify '/Users/korizatou114/Projects/the-tree-backups/the-tree-baseline-2026-09-09.bundle' +git bundle list-heads '/Users/korizatou114/Projects/the-tree-backups/the-tree-baseline-2026-09-09.bundle' +shasum -a 256 '/Users/korizatou114/Projects/the-tree-backups/the-tree-baseline-2026-09-09.bundle' +``` + +`verify`が成功し、完全履歴と表示され、SHA-256が上表と一致することを確認します。SHA-256が異なる場合は同じ基準bundleとして使わず、別の保存物か破損かを確認します。 + +## 2. 新しいフォルダへcloneする + +復元先は例として `TheTree-restored-2026-09-09` を使います。同名のフォルダがある場合は別名を選び、既存フォルダを消して空ける操作は行いません。 + +```sh +git clone --branch codex/first-operating-loop \ + '/Users/korizatou114/Projects/the-tree-backups/the-tree-baseline-2026-09-09.bundle' \ + '/Users/korizatou114/Projects/TheTree-restored-2026-09-09' +cd '/Users/korizatou114/Projects/TheTree-restored-2026-09-09' +git rev-parse HEAD +git status --short +``` + +HEADが `423fd6dd2e75da926e38b35ac8c435e462b168d9` と一致し、作業ツリーが空の差分であることを確認します。そのコピーで制作を続ける場合だけ、復旧用のブランチを作ります。 + +```sh +git switch -c codex/recovered-pixel-baseline +``` + +この手順で現在の制作フォルダの履歴や未コミット変更は動きません。元フォルダに対する `reset --hard`、`clean`、上書きコピーは必要ありません。 + +## 3. キャッシュを再生成して確認する + +復元先のフォルダで、Godot 4.7系を使って実行します。 + +```sh +godot --headless --editor --path . --quit +make test-pixel-ui +``` + +初回は元画像から `.godot/` を再生成するため時間がかかります。`godot` がPATHになければ、実際のGodot実行ファイルを用い、Makeには `GODOT='/実際のGodot実行ファイル'` を渡します。 + +描画可能なデスクトップで保存なしの見学を開きます。 + +```sh +make preview-pixel +``` + +受付、食堂、客室、実際の客と従業員が表示され、ズーム・パン・停止・レイヤー切替が動けば、表示の復元を確認できます。見学は手動保存・読込・自動保存を無効にしており、通常ゲームのセーブを更新しません。 + +通常ゲームは `make run`、全Systemの検証は `make test`、Windows配布物の再生成は対応するExport Templateを用いて `make export-windows` です。基準版は通常3営業日運営や昇降機運行が完成した版ではないため、未実装の項目を復旧失敗と判定しません。 + +## ゲームのセーブと復旧コードは別に扱う + +コードを別フォルダへcloneしても、同じプロジェクト名の通常起動は同じ `user://` 保存先を使います。ソースの復元だけでゲームのセーブ履歴は戻りません。通常の手動保存は `user://saves/manual_ui`、その他の世代保存とRecovery候補はSaveServiceが管理します。 + +プレイデータも保全する場合は、通常ゲームを閉じてからOS上の実際のユーザーデータディレクトリを別の場所へ丸ごとコピーします。復旧確認ではまず保存なし見学を使い、旧版から現行セーブへ上書き保存しないでください。通常ゲームへ戻すときは、読込可否と保存形式の移行を確認したうえで、Loadの正式・backup・autosave等の復旧候補を使用します。 + +## 検証記録 + +2026-09-09に次を確認しました。 + +- `git bundle verify` 成功。基準bundleは完全履歴を保持し、SHA-256は上表と一致。 +- bundleから `/tmp/the-tree-recovery-check.Ph7d3m/project` へ別ディレクトリclone成功。HEAD一致、clone直後のGit差分なし。 +- `project.godot`、`Makefile`、Main、見学シナリオ、Kenney両パックの原文ライセンスが存在。 +- Godot `4.7.2.stable.official.ed1daf0bf` で復元先のHeadless Editor読込・素材再インポート成功。 +- 復元先の `test_pixel_tower.gd` が **238項目合格**。カメラ、実施設クリック、停止、Simulation不変等の回帰。 + +この復旧確認では、全System試験、通常ゲームのセーブ上書き、Windows書き出し、復元先での目視見学は行っていません。`/tmp` の検証コピーは長期バックアップではなく、長期の復旧元は上表のbundleです。 diff --git a/docs/UI_OPERATION_SPECIFICATION.md b/docs/UI_OPERATION_SPECIFICATION.md index 781ee3c..d2bf776 100644 --- a/docs/UI_OPERATION_SPECIFICATION.md +++ b/docs/UI_OPERATION_SPECIFICATION.md @@ -1,96 +1,112 @@ # UI・操作仕様書 -## 1. 目的 +更新日: 2026-09-09。ドット断面版の復旧基準は `423fd6d`。この文書には、その後に追加した通常開業ガイド、継続配属、日次帳簿と単日確認を反映する。現在の接続状態と製品版へ向けた要求を分け、3営業日を含むP1全体は未合格として扱う。 -メイン画面を離れずに、宿の観察、問題発見、建築、改善までを完結できる経営シミュレーションUIとする。 +## 1. 目的と外観 -操作感は、施設配置が人流へ影響し、その結果を画面上の混雑、待機列、稼働率として観察できることを中心に置く。外観は、暖色の木、革、真鍮、羊皮紙を使った日本製ファンタジーSLG調とし、写実的な西洋ストラテジー表現は避ける。 +メイン画面を離れずに、宿の観察、問題発見、建築、改善までを完結できる経営シミュレーションUIとする。The Tower SPを参考に、世界樹の断面と小さなドット人物の営みを画面の中心へ置く。施設配置と動線の変更が、人の移動、混雑、待機、稼働として観察できることを重視する。 -## 2. 画面の固定領域 +現在は濃緑、クリーム、木色の平坦で小さな枠を使う。部屋、家具、床、世界樹はドット主体で描き、人物・小物・庭木にKenneyの16px素材を使う。太い装飾枠や大きな人物アイコンを常設して中央の断面を狭めない。 -| 領域 | 常時表示する情報 | 主な操作 | +## 2. 画面の領域と開閉 + +| 領域 | 現在の情報・操作 | 表示状態 | |---|---|---| -| 上部 | 営業日、時刻、時間帯、来客、宿泊、資金、主要資源、評判、警戒 | 停止、通常、2倍、4倍 | -| 左側 | 従業員、施設、商品、探索、投資、帳簿 | 管理モード切替 | -| 中央 | 世界樹の断面、施設、客、従業員、経路、待機列 | 選択、建築、追跡、移動、拡大縮小 | -| 右側 | 選択対象の状態、容量、待機、担当者、清潔、当日売上 | 詳細表示、営業切替、担当変更 | -| 下部 | 建築カテゴリ、施設カード、費用、占有サイズ | 建築、回転、解体、取消 | -| 右下 | 現在の目標、重要通知 | 目標詳細、通知位置へ移動 | +| 上部 | 営業日、時刻、時間帯、来客、宿泊予定、資金、Mana、資材、Faith、Heat、停止、x1/x2/x4 | 常設の小型HUD | +| 左側 | 従業員、施設、商品、探索、投資、帳簿の入口 | 常設の細いカテゴリ列。入口があるだけの準備中項目を含む | +| 中央 | 世界樹の断面、施設の室内、客、従業員、工事、待機、混雑、6段階の開業手引き | 手引きは左上の見出しで折り畳める。断面を広く使用 | +| 右側 | 運営概要、施設状態、雇用候補/雇用済み人物の配属、帳簿、要対応イベント | `I`、カテゴリ、施設選択で必要な内容を開く重ね表示。初期は閉じる | +| 下部 | 建築、FRONT/BACK、掘削、回転、解除、運営、保存、読込と状況案内 | 操作バーと案内を常設 | +| 下部の追加領域 | MVP施設16種のカード、建設費、資材 | `B`で開閉する横スクロール建築パレット。初期は閉じる | -中央マップの可視面積を優先し、通常の管理パネルは画面幅の35%を超えて中央を覆わない。 +中央の可視面積を優先する。通常の管理パネルは画面幅の35%以内を設計目安とし、現実装は右側約312pxを使用する。人物編成や帳簿など一覧性が必要な機能は、将来の専用パネルで扱う。目標と通知を独立した右下領域へ常設する製品案は未接続。 -## 3. 基本操作ループ +## 3. 観察と基本操作 -1. 中央マップで宿の営業を観察する。 -2. 待機人数、赤い経路、警告通知から問題を発見する。 -3. 施設または人物を選び、右側で原因を確認する。 -4. 左側の施設ボタンまたは下部の建築カードから対策を選ぶ。 -5. 半透明の建築予告をマップ上へ直接配置する。 -6. 営業を再開し、待機時間と経路色の変化を確認する。 +1. 中央の断面で営業を観察する。 +2. 待機人数や施設の状態表示を見て、必要なときだけ `G` で格子・混雑を表示する。 +3. 施設を選び、右パネルで状態と停止理由を確認する。 +4. `B`または左の施設カテゴリから建築カードを選ぶ。 +5. マップのPreviewと下部の失敗理由を確認して配置する。 +6. 営業を再開し、人流と待機人数の変化を確認する。 -施設の建築、選択、情報確認のために別画面へ移動させない。人物編成、商品詳細、帳簿など一覧性が必要な機能だけを専用画面または大型パネルにする。 +ホイールはカーソル基準のズーム、中ボタンドラッグはパン、Homeは全体表示へ戻す。TabはFRONT/BACK切替、Spaceは停止/再開、1・2・4は時間倍率。Hは雇用候補、Eは掘削、Rは回転、Escはツールとパネルの解除に使う。地上/地下とFRONT/BACKは異なる軸として説明する。 + +現在は施設・工事・セルの選択を接続している。客や従業員のクリック選択、再クリック追跡、通知から原因地点への移動、トラックパッドや中ボタンなし環境の代替パンは今後の要求であり、現操作として案内しない。 ## 4. 建築操作 -- 施設カード選択後、カーソル位置に半透明の完成予想を表示する。 -- 建築可能は緑、条件不足は黄、建築不可は赤で示す。 -- 建築不可の場合は、資金不足、接続不足、未掘削、占有衝突などの理由をカーソル付近へ一行表示する。 -- 同種の床、通路、壁、装飾はドラッグによる連続配置を許可する。 -- 右クリックまたはEscで建築を取り消す。 -- 回転後も建築カードの選択状態を維持する。 -- 解体前に対象、返金額、停止する経路を予告する。 -- 人物が利用中の施設は即時消去せず、退避完了後に解体する。 +現在の接続範囲: -## 5. 観察表示 +- カード選択後、カーソル位置に占有セルを示す半透明Previewを表示する。 +- 配置可能と不可を枠・色・失敗理由で区別する。範囲外、占有、資金、未掘削、接続・支持等の根拠はRuntimeの検証結果を用いる。 +- 左クリックで建設Commandを送る。回転後もカード選択を維持する。 +- Escまたは下部の解除でツールを取り消す。パレットは横スクロールする。 +- 掘削はRank/運営gate、費用、資材、入口、支保をPreviewし、FRONT/BACKを同時に解放する。 +- 客用/業務用昇降機は階間Topology未統合のため建設を拒否する。見えるカードを利用可能な運行設備として説明しない。 -通常表示では装飾を優先し、問題が起きた場所だけ小さな待機表示を出す。管理表示では次のオーバーレイを切り替えられる。 +今後の要求: -| 表示 | 色と内容 | -|---|---| -| 混雑 | 緑=快適、黄=やや混雑、赤=混雑または通行不能 | -| 客経路 | 客用通路と主要目的地 | -| 従業員経路 | 裏通路、搬送経路、勤務先までの移動 | -| 清潔 | 施設と通路の清潔度ヒートマップ | -| 騒音 | 発生源と距離減衰 | -| 治安・警戒 | 犯罪、目撃、監査リスク | -| 物流 | 倉庫、予約在庫、搬送中の物資 | +- 注意状態の黄色表示、カーソル付近の短い理由、右クリック取消、床・通路・装飾のドラッグ連続配置。 +- 解体前の返金額、停止する経路、利用中人物の退避を予告し、退避完了後に撤去するUI。 +- 必要人員、欠品、接続の案内を建設後の対処へつなげる。 -待機列は人数だけでなく推定待ち時間を保持する。通常は人数を表示し、選択または警告状態で推定時間を展開する。 +## 5. 観察表示と人物の動作 -## 6. 人物と施設の選択 +| 表示 | 現在の状態 | 次に接続する要求 | +|---|---|---| +| 室内・人物 | 施設別家具、Kenneyの小人物、セル間補間、左右反転、簡易歩行 | 専用の食事、睡眠、清掃、運搬、休憩動作と利用位置 | +| 混雑 | `G`で実密度/容量に基づく緑・黄・赤を表示 | 改善前後の待ち時間比較と目的地への案内 | +| 待機 | 実待機人数Badge、人物の小さな待機表示 | 推定待ち時間を保持し、選択または警告時に展開 | +| 客/従業員経路 | FRONT/BACK切替と共通の実混雑表示 | 人物ごとの経路、職員の搬送・勤務先の個別表示 | +| 清潔・騒音・治安・物流 | 専用オーバーレイ切替は未接続 | 清潔、距離減衰、目撃・監査、倉庫・予約・運搬の表示 | -- 客を選ぶと、現在の目的、行き先、経路、欲求、残り忍耐を表示する。 -- 従業員を選ぶと、勤務先、移動中か勤務中か、疲労、担当、次の休憩を表示する。 -- 施設を選ぶと、営業状態、容量、利用中、待機、担当者、清潔、在庫、当日売上を表示する。 -- 選択対象をもう一度押すと追跡を開始し、中央マップが対象へ緩やかに追従する。 -- 警告通知を押すと原因地点へ移動し、関係する施設または人物を選択する。 +人物はRuntimeの実Actorを表示する。客のサービス中位置は施設内へ描画上だけ投影し、権威セル、経路探索、占有、保存を変えない。停止中は動作も止め、移動は確定セルへ合わせる。隣接Atlasセルの別人物を歩行差分に使用しない。 + +昇降機については待つ→乗る→昇降→降りるを将来の運行状態へ接続する。乗客の徒歩表示と車内表示の重複を防ぐ。現段階の上下通路移動を昇降機完成の証拠にしない。 + +## 6. 人物と施設の詳細 + +施設の営業状態、複数停止理由、容量、利用中Service、待機、人員は右パネルへ接続済み。客や従業員の画面上のクリック選択と追跡は未接続。次を製品要求とする。 + +- 客: 目的、行き先、経路、欲求、残り忍耐。 +- 従業員: 勤務先、状態、疲労、担当、再配属と勤務帯変更は雇用済みパネルに接続済み。実通勤・到着、次の休憩の詳しい予告と人物クリック連動は追加が必要。 +- 施設: 営業/休業、価格・方針、清潔、在庫、当日売上・費用、担当変更。 + +雇用候補20人の比較と雇用Commandに加え、雇用済み一覧から人物、配属先、日勤06:00〜18:00/夜勤18:00〜翌06:00を選ぶ操作を接続した。「配属・勤務帯を変更」はRuntime Commandで確定し、未配属、反映予定、役割・定員・経路の失敗理由、作業中などの変更不可、処理中の重複防止を表示する。勤務帯は継続計画として保存する(D-032)。 + +この配属では人物の位置を変更せず、配属先への経路存在を事前に検証して論理的に確定する。勤務先へ実通勤し、到着後に施設人員へ加える統合は未完了である。契約、計画の確定、現場到着の3つを同じ意味で表示しない。 + +商品、探索、投資の準備中案内を完成した経営機能として説明しない。 ## 7. 時間進行と06:00決算 -- 時間操作は時計の近くへ置き、停止、通常、2倍、4倍を一操作で切り替える。 -- 重要な選択肢、経営破綻、監査結果など即時判断が必要な場合だけ自動停止する。 -- 06:00に前営業日を締め、夜営業の結果まで含めて決算する。 -- 決算は画面全体を長時間塞がず、まず収入、支出、利益、前日差、警告だけを短く表示する。 -- 詳細帳簿は任意で開く。閉じると朝営業が動いている中央マップへ戻る。 +時間操作は上部の時計の近くへ置き、停止・通常・2倍・4倍を一操作で切り替える。重要な選択肢では右パネルを開き、選択と解決後の停止解除を接続する。 -## 8. 視覚ルール +Runtimeでは00:00の暦日更新と、06:00の前営業日締め・朝運用を別の境界として扱う。帳簿は当営業日の現金入出金・うち建築・給与支払と、選んだ確定営業日の売上・運営費・給与・その他費用・利益・前日差・未払・照合・施設別損益を表示する。建築による資金減少を営業赤字と混同しない。06:00の給与現金支払が新営業日の出金、費用が前営業日の決算に属する点を案内する。 -- 上位情報は太い木枠または革タブ、詳細情報は明るい羊皮紙パネルに載せる。 -- 正常は緑、注意は黄、危険は赤、地下固有情報は紫を基本色とする。 -- 小さな数値へ筆文字を使わない。日本語本文と数値は太く読みやすい書体を使う。 -- アイコンには可能な限り日本語ラベルまたはツールチップを併記する。 -- 成人向け施設は露骨な図像を常設せず、紫の照明、幕、宝石、夜の装飾で区別する。 -- 固有作品のボタン、アイコン、キャラクター、画面寸法を複製しない。 -- 背景画像、UI配色、背景とGridの合成率は`VisualThemeProfile`から取得し、Simulation、Save、施設Definitionへ画像Pathを保存しない。 -- 施設、管理カテゴリ、資源、共通操作のIconも`VisualThemeProfile`から安定IDで取得する。Icon Atlasの画像Pathと切り出し位置はTheme資源だけが保持し、Simulation、Save、施設Definitionへ保存しない。 -- Map上の完成施設、建設中施設、配置Preview、および右Inspectorは同じ施設IDのIconを共有する。建設中は暖色半透明、配置可能Previewは緑系半透明、配置不可Previewは赤系半透明とし、占有Cellの状態色と選択枠を隠さない。 -- Map上の客は客クラス、従業員は役割群に対応する成人キャラクターMarkerをThemeから取得する。同一Cellでは決定的な位置ずらしで重なりを軽減し、未登録IDやTheme欠落時も従来の図形MarkerへFallbackする。 -- 主要Panel、Button状態、選択/警告枠、待機Badge、工事中、経路、層境界の外装は`ui_skin_textures`から意味IDで取得する。伸縮PanelとButtonはNine-patchとして描画し、画像がない場合は既存の色・枠・図形へFallbackする。 -- UIの設計基準は1440×810とし、HiDPIと`canvas_items`拡縮で720p、1080p、1440p、4Kへ対応する。横長画面の余白は中央Mapへ配分し、固定領域の最小可読サイズを維持する。Theme TextureはMipmapを生成し、縮小時のちらつきと輪郭崩れを抑える。 -- Theme Textureが未設定または読込不能な場合は、操作不能にせず手続き的な背景色とGridへFallbackする。 +開業の手引きは受付→客室→接続→配属→初宿泊売上→翌朝決算の6段階を実状態から示す。達成後は売上と決算の履歴を先に評価し、夜間の勤務外や配属変更だけで達成が消えないようにする。Save復元では新しいServiceから読み直す。手引きは状態を書き換えず、ボタンから通常の建築・従業員・時間・帳簿操作へ進める。 -## 9. モック基準 +通常初期予算での宿泊売上320G、翌06:00の給与120G・未払0・照合一致・専用保存復元を単日確認した。受付売上6,020Gと全利益6,040Gの現結果は調整対象で、価格や宿泊の決済時点を今回のUI追加で改定していない。厨房・補給・実配送を含む3営業日運営、初見試遊、製品バランスは残る。 -実装時の構成基準は `assets/mockups/the-tree-main-gameplay-visual-mock-v3-tower-style.png` とする。 +帳簿は表示中だけ更新し、日付のPopup操作中は選択肢を再構築しない。画面を閉じれば断面の観察へ戻る。 -画像内の文字、数値、施設サイズはレイアウト検証用であり、正式なゲームデータは各仕様書とデータ定義を正とする。 +## 8. 視覚と資源管理のルール + +- 濃緑、クリーム、木色を基本にし、正常は緑、注意は黄、危険は赤、地下固有情報は紫で示す。状態名や記号を添え、色だけに依存しない。 +- 日本語と小さな数値に筆文字を使わず、読みやすい書体を使用する。小アイコンには日本語ラベルまたはツールチップを併記する。 +- 成人向け施設は紫の照明、幕、夜の装飾などで区別する。固有作品のボタン、キャラクター、画面寸法を複製しない。 +- KenneyのPacked Atlasを `VisualThemeProfile.pixel_dungeon_tiles`/`pixel_town_tiles` から取得する。16pxセルと用途の切り出しは描画ヘルパー側で扱い、画像Path・Atlas座標・カメラ状態をSimulationやSaveへ保存しない。 +- KenneyのPixel素材はNearest、Mipmap無効。人物の小ささと床への接地を維持し、整数倍率を優先する。最小表示や大きな世界の一覧では縮小が起こるため、ズームで確認できるようにする。 +- 建築カード、管理カテゴリ、資源、操作の小アイコンは既存のTheme安定ID参照を維持する。マップの施設は大きな共通Iconではなく室内として描き、右Inspectorの小Iconとは役割を分ける。 +- 現マップは旧生成背景、人物Bust Marker、装飾Skinを使用しない。旧TextureのMipmap方針をPixel素材へ一括適用しない。 +- 基準解像度1440×810、HiDPI、`canvas_items`/`expand`/fractional stretchを使用し、横長画面の余白を中央へ配分する。解像度別のレイアウト確認と実描画fpsの検証を分ける。 +- Pixel Texture欠落時にも背景・家具・操作系は手続き描画を継続する。ただし現人物描画は素材がなければ省略されるため、「必ず代替人物Markerが出る」とは保証しない。 + +## 9. 現在の表示基準と確認資料 + +現在の基準は [PIXEL_TOWER_VISUALS.md](PIXEL_TOWER_VISUALS.md)、`make capture-pixel` の実描画・状態JSONと、`make capture-opening` の通常開業・配属後・決算PNGとする。`scenes/main/world_preview.gd`、`scripts/ui/pixel_tower_art.gd`、`scenes/main/main.gd` が主な表示実装である。 + +旧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) を参照する。 diff --git a/docs/qa/P1_FIRST_OPERATING_DAY.md b/docs/qa/P1_FIRST_OPERATING_DAY.md new file mode 100644 index 0000000..aa71de5 --- /dev/null +++ b/docs/qa/P1_FIRST_OPERATING_DAY.md @@ -0,0 +1,50 @@ +# P1 最小宿の単日検証 + +実施日: 2026-09-09。環境: macOS / Apple M4 / Godot 4.7.2。基準コミット `423fd6d` から `codex/first-operating-loop` で実装。 + +## 今回の到達点 + +通常開始の5,000G・資材50から、通常UIの建築・雇用・配属処理を使い、受付1、一般客室2、通路7、日勤のアカネ1人で翌朝06:00まで進めた。客やサービスの直接挿入、無料の食材、見学用の完成施設は使用していない。時間は通常の1分Tickを省略せず進めた。 + +自然到着客の宿泊売上320G、受付売上6,020G、建築出金1,340G、契約金等の運営費180G、給与120G、確定利益6,040G、手持ち9,700G、未払0G。台帳照合は一致。00:00は第1営業日のまま、06:00に第2営業日へ移行する。専用テスト枠に保存し、復元後の全決算値・開業達成表示を確認した。 + +受付の反復利用による収益は現行値の記録であり、経営バランスの完成判定ではない。価格・受付会計・宿泊決済仕様の調整を課題台帳に残す。 + +## 実行結果 + +以下の2群で `make check` と `make test` の全構成ターゲットを実行し、両方が終了コード0、エラー0で完了した。 + +```sh +make check test-s0 test-s1 test-s2 test-s4 test-s5 +make test-s3 test-s6 test-mvp test-acceptance test-ui test-perf +``` + +| 対象 | 結果 | +|---|---| +| 配属・再配属・継続勤務・旧保存互換 | 103 checks PASS | +| Pixel表示・カメラ・入力 | 238 checks PASS | +| 既存Dashboard | 121 checks PASS | +| 従業員画面・開業ガイドの解除/予約 | 44 checks PASS | +| 通常UIの建築から翌朝・保存再開 | 35 checks PASS | +| PERF-MVP-001 smoke | 24 checks PASS | + +その他のS0〜S6・MVP・既存受入もすべて成功。生ログはローカルの `artifacts/first-operating-day/core-regression.log` と `integration-regression.log` に保存した。 + +初回はテストの保存先に `res://` を指定したため拒否された。専用 `user://tests/first_operating_day_/restore-check` へ変更した。Coordinatorが保存時にStringNameをStringへ正規化するため、表示用決算の往復比較はJSON表現の全値で確認する。実際の保存・復元および既存の厳密Snapshot検証も通過している。通常の手動セーブ先へ触れないよう、既存Dashboard試験もPID付き専用枠へ分離した。 + +## 画面と再現 + +`make capture-opening` は実描画で開始・配属後・決算の3画面と運営JSONを生成する。今回の画面確認は1440×810、従業員画面の独立確認は1280×720で行った。 + +- `artifacts/first-operating-day/opening.png` +- `artifacts/first-operating-day/staffed.png` +- `artifacts/first-operating-day/first-close.png` +- `artifacts/first-operating-day/operating-report.json` + +生成物はGit管理の外にある。コード、Kenneyの取得元とライセンス、再現手順はGitに保存する。 + +## 残る判定 + +P1全体の補給・厨房・配送を含む3営業日は未合格。昇降機、配属先への実通勤と到着後の稼働、収支バランスは後続課題。配属APIは経路の存在を事前に検証するが、人物を移動させない。 + +既存14日受入の一部は日次境界を加速して検証する方式であり、全AIを含む20,160分の実Tick完走とは異なる。負荷試験は小規模な論理smokeで、実描画やWindows基準機の性能合格ではない。Windows書き出し・実機試遊・初見30分試遊は今回実行していない。 diff --git a/scenes/main/main.gd b/scenes/main/main.gd index 5f8386f..b29492f 100644 --- a/scenes/main/main.gd +++ b/scenes/main/main.gd @@ -1,5 +1,8 @@ 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 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" @@ -90,6 +93,7 @@ const STAFF_SKILL_NAMES := { } @export var visual_theme: VisualThemeProfile = DefaultVisualTheme +var manual_save_path := MANUAL_SAVE_PATH var time_label: Label var phase_label: Label @@ -109,6 +113,9 @@ var dashboard_summary_label: Label var foreground_event_panel: VBoxContainer var foreground_event_label: Label var foreground_event_choices: VBoxContainer +var staff_management_panel: VBoxContainer +var staff_management_toggle_button: Button +var _staff_management_panel_open := false var staff_candidate_panel: VBoxContainer var staff_candidate_summary_label: Label var staff_candidate_toggle_button: Button @@ -119,6 +126,10 @@ var selected_entity_icon: TextureRect var build_status_label: Label var build_buttons: Dictionary = {} var navigation_buttons: Dictionary = {} +var opening_guide: Control +var operating_ledger_panel: VBoxContainer +var operations_controls: VBoxContainer +var _ledger_panel_open := false var management_panel: Control var management_toggle_button: Button var palette_scroll: ScrollContainer @@ -145,6 +156,7 @@ var _operations_start_report: Dictionary = {} func _ready() -> void: _runtime = get_node_or_null("/root/SimulationRuntime") + _pause_fresh_opening() if _runtime != null and _runtime.has_method("enable_production_save_policy"): _runtime.enable_production_save_policy() if _runtime != null \ @@ -173,6 +185,24 @@ func _ready() -> void: ) +func _pause_fresh_opening() -> void: + if _runtime == null or GameClock.absolute_minute != 360: + return + if not (_runtime.get(&"latest_restore_safety_report") as Dictionary).is_empty(): + return + var operations: Dictionary = _runtime.call(&"get_operations_report") + if bool(operations.get(&"enabled", false)): + return + if not _call_array(_service(&"construction_service"), &"get_facilities").is_empty() \ + or not _call_array(_service(&"construction_service"), &"get_jobs").is_empty() \ + or not _call_array(_service(&"staff_service"), &"get_staff_members").is_empty(): + return + var queue: Variant = _runtime.get(&"command_queue") + if queue != null and not queue.get_accepted_command_metadata().is_empty(): + return + GameClock.set_paused(true) + + func _unhandled_key_input(event: InputEvent) -> void: if not event.pressed or event.echo: return @@ -252,6 +282,12 @@ func _build_interface() -> void: world_preview.size_flags_vertical = Control.SIZE_EXPAND_FILL world_preview.set_visual_theme(visual_theme) map_panel.add_child(world_preview) + opening_guide = OpeningGuideScript.new() + world_preview.add_child(opening_guide) + opening_guide.setup(_runtime, GameClock) + opening_guide.position = Vector2(8, 8) + opening_guide.size = Vector2(302, 0) + opening_guide.action_requested.connect(_on_opening_action) # The inspector overlays the map only when requested, leaving the full # cross-section available while watching the simulation. management_panel = _create_side_panel() @@ -396,6 +432,7 @@ func _create_side_panel() -> Control: column.size_flags_horizontal = Control.SIZE_EXPAND_FILL column.add_theme_constant_override("separation", 8) scroll.add_child(column) + var root_column := column var inspector_heading := HBoxContainer.new() var inspector_title := _section_heading("運営状況") @@ -424,12 +461,21 @@ func _create_side_panel() -> Control: foreground_event_choices.add_theme_constant_override("separation", 5) foreground_event_panel.add_child(foreground_event_choices) column.add_child(HSeparator.new()) + operations_controls = VBoxContainer.new() + operations_controls.name = "OperationsControls" + operations_controls.add_theme_constant_override("separation", 8) + column.add_child(operations_controls) + column = operations_controls var candidate_heading_row := HBoxContainer.new() - candidate_heading_row.add_child(_section_heading("雇用候補")) + candidate_heading_row.add_child(_section_heading("スタッフ")) var candidate_heading_spacer := Control.new() candidate_heading_spacer.size_flags_horizontal = Control.SIZE_EXPAND_FILL candidate_heading_row.add_child(candidate_heading_spacer) + staff_management_toggle_button = _button("雇用済み", _toggle_staff_management_panel, 62) + staff_management_toggle_button.name = "StaffManagementToggleButton" + staff_management_toggle_button.toggle_mode = true + candidate_heading_row.add_child(staff_management_toggle_button) staff_candidate_toggle_button = _button( "候補を見る H", _toggle_staff_candidate_panel, @@ -438,6 +484,11 @@ func _create_side_panel() -> Control: staff_candidate_toggle_button.name = "StaffCandidateToggleButton" candidate_heading_row.add_child(staff_candidate_toggle_button) column.add_child(candidate_heading_row) + staff_management_panel = StaffManagementPanelScript.new() + staff_management_panel.configure(_runtime, _apply_button_style) + staff_management_panel.status_requested.connect(_on_staff_management_status) + staff_management_panel.visible = false + column.add_child(staff_management_panel) staff_candidate_panel = VBoxContainer.new() staff_candidate_panel.name = "StaffCandidatePanel" staff_candidate_panel.visible = false @@ -476,6 +527,10 @@ func _create_side_panel() -> Control: selected_detail_label.add_theme_font_size_override("font_size", 13) selected_row.add_child(selected_detail_label) + operating_ledger_panel = OperatingLedgerPanelScript.new() + root_column.add_child(operating_ledger_panel) + operating_ledger_panel.setup(_runtime, GameClock) + operating_ledger_panel.visible = false return panel @@ -573,15 +628,18 @@ func _set_management_panel_open(open: bool) -> void: management_toggle_button.set_pressed_no_signal(open) if not open: _set_staff_candidate_panel_open(false) + _set_staff_management_panel_open(false) + _set_ledger_panel_open(false) func _activate_management_mode(mode: StringName) -> void: _set_management_panel_open(mode != &"facility") + _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) match mode: &"staff": - _set_staff_candidate_panel_open(true) + _set_staff_management_panel_open(true) &"facility": _set_build_palette_open(true) _set_staff_candidate_panel_open(false) @@ -593,7 +651,41 @@ func _activate_management_mode(mode: StringName) -> void: &"investment": _set_status("投資管理は一覧画面の実装準備中です。", true, true) &"ledger": - _set_status("帳簿は06:00決算と接続する予定です。", true, true) + _set_status("本日の資金増減と、06:00に確定した営業日の損益を表示します。", true, true) + + +func _set_ledger_panel_open(open: bool) -> void: + _ledger_panel_open = open + 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 + if dashboard_summary_label != null: + dashboard_summary_label.visible = not open + if open: + _set_staff_candidate_panel_open(false) + _set_staff_management_panel_open(false) + + +func _on_opening_action(action: StringName) -> void: + match action: + &"build_reception", &"build_room", &"build_corridor": + _set_ledger_panel_open(false) + _set_management_panel_open(false) + _set_build_palette_open(true) + var definitions := {&"build_reception": &"reception", &"build_room": &"standard_room", &"build_corridor": &"guest_corridor"} + _select_build_definition(definitions[action]) + &"staff": + _activate_management_mode(&"staff") + if _call_array(_service(&"staff_service"), &"get_staff_members").is_empty(): + _set_staff_candidate_panel_open(true) + &"resume": + if GameClock.paused: + _toggle_pause() + &"ledger": + _activate_management_mode(&"ledger") func _connect_signals() -> void: @@ -717,6 +809,28 @@ func _staff_candidate_text(summary: Dictionary) -> String: ] +func _toggle_staff_management_panel() -> void: + _set_staff_management_panel_open(not _staff_management_panel_open) + + +func _set_staff_management_panel_open(open: bool) -> void: + _staff_management_panel_open = open + if staff_management_panel != null: + staff_management_panel.visible = open + if staff_management_toggle_button != null: + staff_management_toggle_button.set_pressed_no_signal(open) + if open: + _set_ledger_panel_open(false) + _set_staff_candidate_panel_open(false) + _set_management_panel_open(true) + staff_management_panel.refresh() + _set_status("従業員を選び、配属先と毎日の勤務帯を変更できます。", true, true) + + +func _on_staff_management_status(message: String, success: bool) -> void: + _set_status(message, success) + + func _toggle_staff_candidate_panel() -> void: _set_staff_candidate_panel_open(not _staff_candidate_panel_open) @@ -727,6 +841,8 @@ func _set_staff_candidate_panel_open(open: bool) -> void: staff_candidate_panel.visible = open _refresh_staff_candidates() if open: + _set_ledger_panel_open(false) + _set_staff_management_panel_open(false) _set_management_panel_open(true) _set_status("雇用候補を選んでください。契約金と日給は確定前に表示されます。", true, true) @@ -885,6 +1001,9 @@ func _clear_build_selection(announce := true) -> void: func _clear_active_tool() -> void: + if _staff_management_panel_open: + _set_staff_management_panel_open(false) + return if _staff_candidate_panel_open: _set_staff_candidate_panel_open(false) return @@ -1073,6 +1192,7 @@ func _excavation_idempotency_key(cell: Vector3i) -> StringName: func _on_facility_selected(instance_id: StringName) -> void: + _set_ledger_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = instance_id @@ -1081,6 +1201,7 @@ func _on_facility_selected(instance_id: StringName) -> void: func _on_construction_selected(job_id: StringName) -> void: + _set_ledger_panel_open(false) _set_management_panel_open(true) _set_excavation_mode(false, false) _selected_entity_id = job_id @@ -1158,6 +1279,10 @@ func _refresh_dashboard() -> void: _refresh_staff_candidates() _refresh_selected_entity() _update_pause_and_speed() + if opening_guide != null: + opening_guide.refresh() + if operating_ledger_panel != null and _ledger_panel_open: + operating_ledger_panel.refresh() func _refresh_foreground_event(event_service: Variant) -> void: @@ -1175,6 +1300,7 @@ func _refresh_foreground_event(event_service: Variant) -> void: foreground_event_panel.visible = false return foreground_event_panel.visible = true + _set_ledger_panel_open(false) _set_management_panel_open(true) var actors: Array[String] = [] for actor_value: Variant in foreground.actor_ids: @@ -1539,11 +1665,11 @@ func _save_manual() -> void: if _runtime == null or not _runtime.has_method(&"save_to_path"): _set_status("保存サービスへ接続できません。", false) return - var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path("user://saves")) + var directory_error := DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(manual_save_path).get_base_dir()) if directory_error != OK and directory_error != ERR_ALREADY_EXISTS: _set_status("保存フォルダを作成できませんでした。", false) return - var result: Variant = _runtime.call(&"save_to_path", MANUAL_SAVE_PATH, {&"display_name": "手動保存"}) + var result: Variant = _runtime.call(&"save_to_path", manual_save_path, {&"display_name": "手動保存"}) if result is Dictionary and bool(result.get(&"ok", false)): _set_status("手動セーブを保存しました。", true) else: @@ -1558,15 +1684,17 @@ func _load_manual() -> void: _set_status("読込サービスへ接続できません。", false) return var result: Variant = ( - _runtime.call(&"load_with_recovery", MANUAL_SAVE_PATH) + _runtime.call(&"load_with_recovery", manual_save_path) if _runtime.has_method(&"load_with_recovery") - else _runtime.call(&"load_from_path", MANUAL_SAVE_PATH) + else _runtime.call(&"load_from_path", manual_save_path) ) if result is Dictionary and bool(result.get(&"ok", false)): _pending_build_commands.clear() _pending_excavation_commands.clear() _pending_hire_commands.clear() _synchronize_ui_command_state() + if staff_management_panel != null: + staff_management_panel.refresh() _selected_entity_id = &"" _selected_entity_kind = &"" world_preview.selected_entity_id = &"" diff --git a/scripts/staff/staff_service.gd b/scripts/staff/staff_service.gd index 74fedd4..47235e2 100644 --- a/scripts/staff/staff_service.gd +++ b/scripts/staff/staff_service.gd @@ -18,7 +18,8 @@ const CommandResultType = preload("res://scripts/core/command_result.gd") const LEGACY_SNAPSHOT_VERSION := 1 const PAYROLL_SNAPSHOT_VERSION := 2 -const SNAPSHOT_VERSION := 3 +const EMPLOYMENT_SNAPSHOT_VERSION := 3 +const SNAPSHOT_VERSION := 4 const HIGH_PRIORITY_THRESHOLD := 90 const DEFAULT_LATE_REASON := &"PERSONAL" const MAX_PAYROLL_DAY := 1_000_000 @@ -36,6 +37,7 @@ var _staff: Dictionary = {} var _shifts: Dictionary = {} var _tasks: Dictionary = {} var _schedule_overrides: Dictionary = {} +var _work_assignments: Dictionary = {} var _departed_payroll: Dictionary = {} var _payroll_settlements: Dictionary = {} var _payroll_days: Dictionary = {} @@ -375,6 +377,164 @@ func get_shifts() -> Array[ShiftInstance]: return result +## A work assignment is a repeating plan; concrete Shift records retain the +## facility and elapsed interval used by historical wage allocation. +func get_work_assignments() -> Array[Dictionary]: + var rows: Array[Dictionary] = [] + for staff_id: StringName in _sorted_keys(_work_assignments): + rows.append((_work_assignments[staff_id] as Dictionary).duplicate(true)) + return rows + + +func get_work_assignment(staff_id: StringName) -> Dictionary: + return (_work_assignments.get(staff_id, {}) as Dictionary).duplicate(true) + + +func set_work_assignment(staff_id: StringName, facility_id: StringName, template_id: StringName, absolute_minute := -1) -> CommandResult: + var minute := current_minute if absolute_minute < 0 else absolute_minute + if minute < current_minute: + return _invalid("Assignment time cannot move backwards.", {&"reason": &"TIME_REGRESSION"}) + var staff := get_staff(staff_id) + if staff == null: + return _invalid("Staff was not found.", {&"reason": &"STAFF_NOT_FOUND"}) + if template_id not in [&"DAY", &"NIGHT"]: + return _invalid("Choose DAY or NIGHT.", {&"reason": &"INVALID_TEMPLATE"}) + if not staff.current_task_id.is_empty(): + return _invalid("Finish the current task before changing assignment.", {&"reason": &"STAFF_BUSY"}) + if not staff.can_take_shift() or staff.state not in [ + StaffRuntimeType.State.OFF_DUTY, StaffRuntimeType.State.COMMUTING, StaffRuntimeType.State.AVAILABLE, + ]: + return _invalid("Staff cannot change assignment in the current state.", {&"reason": &"STAFF_UNAVAILABLE"}) + var previous := get_work_assignment(staff_id) + if not previous.is_empty() and StringName(previous[&"facility_id"]) == facility_id \ + and StringName(previous[&"template_id"]) == template_id: + return _success(_work_assignment_result(staff_id, &"ASSIGNMENT_UNCHANGED")) + + # Build every replacement before the first live write. Never repurpose the + # facility of elapsed work, including a NIGHT segment crossing midnight. + var replacements: Dictionary = {} + for shift: ShiftInstance in _shifts_for_staff(staff_id): + if shift.cancelled or shift.end_at <= minute: + continue + var replacement := ShiftInstanceType.from_snapshot(shift.create_snapshot()) + if replacement.start_at >= minute: + replacement.cancelled = true + else: + replacement.end_at = minute + replacement.end_minute_of_day = posmod(minute, MINUTES_PER_DAY) + replacement.worked_minutes = mini(replacement.worked_minutes, minute - replacement.start_at) + replacement.close(minute) + if not replacement.is_valid(): + return _invalid("Existing shift could not be split.", {&"reason": &"SHIFT_CONFLICT"}) + replacements[replacement.shift_instance_id] = replacement + var interval := _work_interval(template_id, minute) + var start_at := maxi(interval.x, minute) + var new_shift: ShiftInstance = null + if not facility_id.is_empty(): + new_shift = _make_work_shift(staff_id, facility_id, template_id, start_at, interval.y) + if _shifts.has(new_shift.shift_instance_id): + return _invalid("Generated shift ID conflicts.", {&"reason": &"SHIFT_CONFLICT"}) + for shift_id: StringName in _sorted_keys(replacements): + _shifts[shift_id] = replacements[shift_id] + # An explicit repeating plan supersedes future one-day overrides. Keeping + # them would silently move an employee back to an old facility later. + for override_id: StringName in _sorted_keys(_schedule_overrides): + var entry: Dictionary = _schedule_overrides[override_id] + if StringName(entry[&"staff_id"]) == staff_id \ + and int(entry[&"start_day"]) >= minute / MINUTES_PER_DAY: + _schedule_overrides.erase(override_id) + _work_assignments[staff_id] = { + &"staff_id": String(staff_id), &"facility_id": String(facility_id), + &"template_id": String(template_id), &"changed_at": minute, + &"effective_from": start_at if new_shift != null else minute, + &"next_start_at": interval.x + MINUTES_PER_DAY, + } + staff.shift_id = &"" + staff.assigned_facility_id = &"" + staff.is_overtime = false + staff.transition_to(StaffRuntimeType.State.OFF_DUTY, &"WORK_ASSIGNMENT_CHANGED", minute) + if new_shift != null: + _shifts[new_shift.shift_instance_id] = new_shift + _next_shift_sequence += 1 + # APPLY_COMMANDS precedes the Staff AI phase in a running tick. The AI + # records that new minute later; paused edits record the boundary here. + _update_shift_states(minute, staff_id, minute == current_minute) + if new_shift != null: + shift_registered.emit(new_shift) + return _success(_work_assignment_result(staff_id, &"ASSIGNMENT_UPDATED")) + + +func _work_assignment_result(staff_id: StringName, reason: StringName) -> Dictionary: + var row := get_work_assignment(staff_id) + row[&"reason"] = reason + row[&"shift_id"] = get_staff(staff_id).shift_id + row[&"unassigned"] = String(row[&"facility_id"]).is_empty() + return row + + +static func _work_interval(template_id: StringName, absolute_minute: int) -> Vector2i: + var day := absolute_minute / MINUTES_PER_DAY + var minute_of_day := posmod(absolute_minute, MINUTES_PER_DAY) + if template_id == &"DAY": + if minute_of_day >= ShiftInstanceType.DAY_END: + day += 1 + return Vector2i(day * MINUTES_PER_DAY + ShiftInstanceType.DAY_START, day * MINUTES_PER_DAY + ShiftInstanceType.DAY_END) + if minute_of_day < ShiftInstanceType.NIGHT_END: + day -= 1 + return Vector2i(day * MINUTES_PER_DAY + ShiftInstanceType.NIGHT_START, (day + 1) * MINUTES_PER_DAY + ShiftInstanceType.NIGHT_END) + + +func _make_work_shift(staff_id: StringName, facility_id: StringName, template_id: StringName, start_at: int, end_at: int) -> ShiftInstance: + return ShiftInstanceType.new().configure( + StringName("shift_%08d_d%05d" % [_next_shift_sequence, start_at / MINUTES_PER_DAY]), + staff_id, template_id, start_at / MINUTES_PER_DAY, + posmod(start_at, MINUTES_PER_DAY), posmod(end_at, MINUTES_PER_DAY), facility_id + ) + + +func _ensure_work_assignment_shifts(absolute_minute: int) -> void: + for staff_id: StringName in _sorted_keys(_work_assignments): + var row: Dictionary = _work_assignments[staff_id] + if String(row[&"facility_id"]).is_empty() \ + or absolute_minute < int(row[&"next_start_at"]) - ShiftInstanceType.COMMUTE_LEAD_MINUTES: + continue + var interval := _work_interval(StringName(row[&"template_id"]), absolute_minute) + # During a running shift the next occurrence still lies in the future. + if interval.x < int(row[&"next_start_at"]): + continue + var shift := _make_work_shift(staff_id, StringName(row[&"facility_id"]), StringName(row[&"template_id"]), maxi(interval.x, 0), interval.y) + _apply_override_to_shift(shift) + var overlaps := false + for existing: ShiftInstance in _shifts_for_staff(staff_id): + if _shifts_overlap(existing, shift): + overlaps = true + break + row[&"next_start_at"] = interval.x + MINUTES_PER_DAY + # Explicit one-day shifts win over the recurring template on that day. + if overlaps or _shifts.has(shift.shift_instance_id) or not shift.is_valid(): + continue + _shifts[shift.shift_instance_id] = shift + _next_shift_sequence += 1 + shift_registered.emit(shift) + + +static func _work_assignment_shape_is_valid(row: Dictionary) -> bool: + if row.size() != 6: + return false + for key: StringName in [&"staff_id", &"facility_id", &"template_id"]: + if typeof(row.get(key, null)) not in [TYPE_STRING, TYPE_STRING_NAME]: + return false + for key: StringName in [&"changed_at", &"effective_from", &"next_start_at"]: + if typeof(row.get(key, null)) != TYPE_INT or int(row[key]) < 0: + return false + if String(row[&"staff_id"]).is_empty() or StringName(row[&"template_id"]) not in [&"DAY", &"NIGHT"]: + return false + var standard_start := ShiftInstanceType.DAY_START if StringName(row[&"template_id"]) == &"DAY" else ShiftInstanceType.NIGHT_START + return int(row[&"effective_from"]) >= int(row[&"changed_at"]) \ + and int(row[&"effective_from"]) < int(row[&"next_start_at"]) \ + and posmod(int(row[&"next_start_at"]), MINUTES_PER_DAY) == standard_start + + func set_schedule_override( staff_id: StringName, start_day: int, @@ -470,6 +630,7 @@ func advance_one_minute( return {&"ok": false, &"reason": &"MINUTE_ALREADY_APPLIED"} current_minute = absolute_minute var repairs := audit_orphan_tasks(current_minute) + _ensure_work_assignment_shifts(current_minute) _update_shift_states(current_minute) var task_actions := _advance_assigned_tasks(current_minute) var high_priority := _has_high_priority_open_task() @@ -665,6 +826,7 @@ func remove_staff( continue removed_override_days.append(int(entry.get(&"start_day", -1))) _schedule_overrides.erase(override_id) + _work_assignments.erase(staff_id) _staff.erase(staff_id) staff_removed.emit(staff_id, reason) last_error = "" @@ -843,6 +1005,7 @@ func create_snapshot() -> Dictionary: &"shifts": shift_snapshots, &"tasks": task_snapshots, &"schedule_overrides": override_snapshots, + &"work_assignments": get_work_assignments(), &"departed_payroll": departed_payroll, &"payroll_settlements": payroll_settlements, &"dispatcher": _dispatcher.create_snapshot(), @@ -867,6 +1030,7 @@ func restore_snapshot_with_orphan_recovery(snapshot: Dictionary) -> bool: _shifts = candidate._shifts _tasks = candidate._tasks _schedule_overrides = candidate._schedule_overrides + _work_assignments = candidate._work_assignments _departed_payroll = candidate._departed_payroll _payroll_settlements = candidate._payroll_settlements _payroll_days = candidate._payroll_days @@ -896,7 +1060,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool if ( typeof(raw_version) != TYPE_INT or int(raw_version) not in [ - LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, SNAPSHOT_VERSION, + LEGACY_SNAPSHOT_VERSION, PAYROLL_SNAPSHOT_VERSION, EMPLOYMENT_SNAPSHOT_VERSION, SNAPSHOT_VERSION, ] ): last_error = "Unsupported StaffService snapshot version." @@ -939,7 +1103,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool raw_staff.has(&"employment_effective_minute") or raw_staff.has("employment_effective_minute") ) - if snapshot_version == SNAPSHOT_VERSION: + if snapshot_version >= EMPLOYMENT_SNAPSHOT_VERSION: if not has_row_version or not has_effective_minute: last_error = "Current Staff snapshot entry is missing employment-time schema fields." return false @@ -994,6 +1158,38 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool &"patch": (raw_override[&"patch"] as Dictionary).duplicate(true), } + var next_assignments: Dictionary = {} + if snapshot_version >= SNAPSHOT_VERSION: + if not snapshot.get(&"work_assignments", null) is Array: + last_error = "Staff work assignments must be an Array." + return false + for raw_assignment: Variant in snapshot[&"work_assignments"]: + if not raw_assignment is Dictionary or not _work_assignment_shape_is_valid(raw_assignment): + last_error = "Staff work assignment is malformed." + return false + var owner := StringName(raw_assignment[&"staff_id"]) + if not next_staff.has(owner) or next_assignments.has(owner) \ + or int(raw_assignment[&"changed_at"]) > int(snapshot.get(&"current_minute", -1)): + last_error = "Staff work assignment owner or time is invalid." + return false + var changed_at := int(raw_assignment[&"changed_at"]) + var assigned_facility := StringName(raw_assignment[&"facility_id"]) + var template := StringName(raw_assignment[&"template_id"]) + var expected_effective := changed_at if assigned_facility.is_empty() else maxi(changed_at, _work_interval(template, changed_at).x) + var next_start := int(raw_assignment[&"next_start_at"]) + var current_interval := _work_interval(template, int(snapshot.get(&"current_minute", 0))) + if int(raw_assignment[&"effective_from"]) != expected_effective \ + or (not assigned_facility.is_empty() and ( + next_start <= int(snapshot.get(&"current_minute", 0)) + or next_start not in [current_interval.x, current_interval.x + MINUTES_PER_DAY] + )): + last_error = "Staff work assignment cadence is invalid." + return false + next_assignments[owner] = raw_assignment.duplicate(true) + elif snapshot.has(&"work_assignments"): + last_error = "Legacy Staff snapshot contains work assignments from a later schema." + return false + var next_payroll: Dictionary = {} for raw_payroll: Variant in snapshot[&"departed_payroll"]: if not raw_payroll is Dictionary: @@ -1067,6 +1263,7 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool _shifts = next_shifts _tasks = next_tasks _schedule_overrides = next_overrides + _work_assignments = next_assignments _departed_payroll = next_payroll _payroll_settlements = next_settlements _payroll_days = next_payroll_days @@ -1097,8 +1294,10 @@ func _restore_snapshot_internal(snapshot: Dictionary, recover_orphan_tasks: bool return true -func _update_shift_states(absolute_minute: int) -> void: +func _update_shift_states(absolute_minute: int, only_staff: StringName = &"", record_duty := true) -> void: for staff_id: StringName in _sorted_keys(_staff): + if not only_staff.is_empty() and only_staff != staff_id: + continue var staff: StaffRuntime = _staff[staff_id] var shift := get_shift(staff.shift_id) if shift != null and shift.staff_id != staff.staff_id: @@ -1161,7 +1360,8 @@ func _update_shift_states(absolute_minute: int) -> void: ]: staff.transition_to(StaffRuntimeType.State.AVAILABLE, &"SHIFT_STARTED", absolute_minute) shift_state_changed.emit(staff.staff_id, shift.shift_instance_id, staff.state) - shift.record_duty_minute(absolute_minute) + if record_duty: + shift.record_duty_minute(absolute_minute) if absolute_minute >= shift.start_at + 15 and staff.state == StaffRuntimeType.State.COMMUTING: shift.mark_late(absolute_minute, DEFAULT_LATE_REASON) diff --git a/scripts/ui/inn_opening_guide.gd b/scripts/ui/inn_opening_guide.gd new file mode 100644 index 0000000..f90eded --- /dev/null +++ b/scripts/ui/inn_opening_guide.gd @@ -0,0 +1,152 @@ +class_name InnOpeningGuide +extends PanelContainer + +## Read-only guidance. Every action goes back through the normal dashboard. +signal action_requested(action: StringName) + +var _runtime: Variant +var _clock: Variant +var _heading: Button +var _body: VBoxContainer +var _detail: Label +var _action: Button +var _progress: Dictionary = {} +var _sale_economy_id := 0 +var _first_sale_minute := -1 + + +func setup(runtime: Variant, clock: Variant) -> void: + _runtime = runtime + _clock = clock + name = "InnOpeningGuide" + custom_minimum_size.x = 290 + var skin := StyleBoxFlat.new() + skin.bg_color = Color("30261be8") + skin.border_color = Color("b58a45") + skin.set_border_width_all(1) + skin.set_content_margin_all(8) + add_theme_stylebox_override("panel", skin) + var column := VBoxContainer.new() + column.add_theme_constant_override("separation", 5) + add_child(column) + _heading = Button.new() + _heading.alignment = HORIZONTAL_ALIGNMENT_LEFT + _heading.add_theme_font_size_override("font_size", 14) + _heading.pressed.connect(func() -> void: _body.visible = not _body.visible) + column.add_child(_heading) + _body = VBoxContainer.new() + column.add_child(_body) + _detail = Label.new() + _detail.custom_minimum_size.x = 270 + _detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _detail.add_theme_font_size_override("font_size", 13) + _detail.add_theme_color_override("font_color", Color("fff0cf")) + _body.add_child(_detail) + _action = Button.new() + _action.add_theme_font_size_override("font_size", 13) + _action.pressed.connect(func() -> void: action_requested.emit(StringName(_progress.get(&"action", &"")))) + _body.add_child(_action) + refresh() + + +func refresh() -> void: + if _runtime == null or _heading == null: + return + _progress = _read_progress() + _heading.text = "開業の手引き · %s" % _progress[&"title"] + _detail.text = _progress[&"detail"] + _action.text = _progress[&"button"] + _action.visible = not String(_progress[&"action"]).is_empty() + # A collapsed guide must shrink as soon as its body is hidden. + reset_size() + + +func get_progress() -> Dictionary: + return _read_progress().duplicate(true) if _runtime != null else {} + + +func _read_progress() -> Dictionary: + var first_sale := _first_lodging_sale() + if first_sale >= 0 and _runtime.operations_coordinator != null: + for summary: Dictionary in _runtime.operations_coordinator.get_day_summaries(): + if int(summary.get(&"closed_at", -1)) > first_sale: + return _step(&"complete", "初日の営業を達成", "宿泊売上と翌朝の決算を確認できました。帳簿を見ながら、客室と従業員を少しずつ増やしましょう。", &"ledger", "決算を見る") + var reception: FacilityInstance + var rooms: Array[FacilityInstance] = [] + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"reception": + if reception == null or _reception_score(facility) > _reception_score(reception): + reception = facility + elif facility.definition_id == &"standard_room": + rooms.append(facility) + if reception == null: + return _build_step(&"reception", &"reception", "1/6 受付をつくる", "表の地表階に受付を配置します。完成した受付の上に客室を増築できます。", &"build_reception") + if rooms.is_empty(): + return _build_step(&"room", &"standard_room", "2/6 客室をつくる", "受付が支えになる位置に一般客室を配置します。", &"build_room") + var accessible_room := false + for room: FacilityInstance in rooms: + accessible_room = accessible_room or room.access_available + if not reception.access_available or not accessible_room: + return _step(&"access", "3/6 道をつなぐ", "客室の出入口まで客用通路をつなぎます。施設を選択すると、通行不可など営業できない理由を確認できます。", &"build_corridor", "客用通路を選ぶ") + if not _has_reception_assignment(reception): + return _step(&"staff", "4/6 受付係を配属", "従業員から受付係を雇い、雇用済み一覧でこの受付と勤務帯を選び「配属・勤務帯を変更」を押します。", &"staff", "従業員を開く") + if first_sale < 0: + return _step(&"sale", "5/6 最初の宿泊売上", "時間を進めて、お客さまの到着と宿泊を見守りましょう。売上は現在のサービスが完了すると記帳されます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る") + return _step(&"close", "6/6 翌朝の決算へ", "最初の宿泊売上が入りました。06:00に営業日が締まり、給与・利益・未払いを帳簿で確認できます。", &"resume" if _clock.paused else &"ledger", "時間を進める" if _clock.paused else "帳簿を見る") + + +func _reception_score(facility: FacilityInstance) -> int: + return (2 if facility.access_available else 0) + (1 if _has_reception_assignment(facility) else 0) + + +func _has_reception_assignment(reception: FacilityInstance) -> bool: + var assigned := not reception.active_staff_ids.is_empty() + # A repeating plan remains valid off-duty. Historical or cancelled shifts + # cannot conceal a deliberate unassignment made before the first sale. + for staff: StaffRuntime in _runtime.staff_service.get_staff_members(): + var plan: Dictionary = _runtime.staff_service.get_work_assignment(staff.staff_id) + if not plan.is_empty(): + assigned = assigned or StringName(plan.get(&"facility_id", &"")) == reception.instance_id + continue + for shift: ShiftInstance in _runtime.staff_service.get_shifts(): + if shift.staff_id == staff.staff_id and not shift.cancelled and shift.end_at > _clock.absolute_minute and shift.assigned_facility_id == reception.instance_id: + assigned = true + return assigned + + +func _first_lodging_sale() -> int: + # Once found, an append-only ledger cannot revoke historical first revenue. + # Restore/reset replaces EconomyService, which invalidates this visual cache. + var economy_id: int = _runtime.economy_service.get_instance_id() + if economy_id != _sale_economy_id: + _sale_economy_id = economy_id + _first_sale_minute = -1 + if _first_sale_minute >= 0: + return _first_sale_minute + var room_ids: Dictionary = {} + for job: ConstructionJob in _runtime.construction_service.get_jobs(): + if job.definition_id == &"standard_room" and not job.facility_instance_id.is_empty(): + room_ids[job.facility_instance_id] = true + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"standard_room": + room_ids[facility.instance_id] = true + if room_ids.is_empty(): + return -1 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + if entry.category == &"SERVICE_SALE" and room_ids.has(entry.source_entity_id): + _first_sale_minute = entry.absolute_minute if _first_sale_minute < 0 else mini(_first_sale_minute, entry.absolute_minute) + return _first_sale_minute + + +func _build_step(stage: StringName, definition_id: StringName, title: String, detail: String, action: StringName) -> Dictionary: + for job: ConstructionJob in _runtime.construction_service.get_jobs(): + if job.definition_id == definition_id and job.state not in [ConstructionJob.State.COMPLETED, ConstructionJob.State.CANCELLED]: + return _step(stage, title, "工事中 %d%%。時間を進めると完成します。" % (job.progress / 100), &"resume" if _clock.paused else &"", "時間を進める") + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(definition_id) + if definition != null: + detail += "\n費用 %dG / 資材 %d" % [int(definition.construction_cost.get(&"gold", 0)), int(definition.construction_cost.get(&"materials", 0))] + return _step(stage, title, detail, action, "受付を選ぶ" if definition_id == &"reception" else "一般客室を選ぶ") + + +func _step(stage: StringName, title: String, detail: String, action: StringName, button: String) -> Dictionary: + return {&"stage": stage, &"title": title, &"detail": detail, &"action": action, &"button": button} diff --git a/scripts/ui/inn_opening_guide.gd.uid b/scripts/ui/inn_opening_guide.gd.uid new file mode 100644 index 0000000..3937eab --- /dev/null +++ b/scripts/ui/inn_opening_guide.gd.uid @@ -0,0 +1 @@ +uid://fr1l6mxlidk diff --git a/scripts/ui/operating_ledger_panel.gd b/scripts/ui/operating_ledger_panel.gd new file mode 100644 index 0000000..c6a124e --- /dev/null +++ b/scripts/ui/operating_ledger_panel.gd @@ -0,0 +1,129 @@ +class_name OperatingLedgerPanel +extends VBoxContainer + +## Projection only: authoritative close and ledger entries own all amounts. +var _runtime: Variant +var _clock: Variant +var _cash_label: Label +var _day_picker: OptionButton +var _closed_label: Label +var _facility_label: Label +var _selected_day := -1 +var _picker_days: Array[int] = [] + + +func setup(runtime: Variant, clock: Variant) -> void: + _runtime = runtime + _clock = clock + name = "OperatingLedgerPanel" + add_theme_constant_override("separation", 8) + var heading := Label.new() + heading.text = "帳簿 · 06:00締め" + heading.add_theme_color_override("font_color", Color("55371e")) + add_child(heading) + _cash_label = _label() + _day_picker = OptionButton.new() + _day_picker.name = "LedgerDayPicker" + _day_picker.item_selected.connect(func(index: int) -> void: + _selected_day = _day_picker.get_item_id(index) + refresh()) + add_child(_day_picker) + _closed_label = _label() + _facility_label = _label() + refresh() + + +func get_report() -> Dictionary: + if _runtime == null: + return {} + var minute: int = _clock.absolute_minute + var business_day := maxi(1, int(floor(float(minute - 360) / 1440.0)) + 1) + var start := (business_day - 1) * 1440 + 360 + var inflow := 0 + var outflow := 0 + var construction_outflow := 0 + var wage_outflow := 0 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + if entry.absolute_minute < start or entry.absolute_minute > minute or entry.category == &"INITIALIZATION": + continue + if entry.debit_account == &"PLAYER_CASH": + inflow += entry.amount + if entry.credit_account == &"PLAYER_CASH": + outflow += entry.amount + if entry.category == &"CONSTRUCTION": + construction_outflow += entry.amount + elif entry.category == &"WAGE": + wage_outflow += entry.amount + var summaries: Array[Dictionary] = [] + if _runtime.operations_coordinator != null: + summaries = _runtime.operations_coordinator.get_day_summaries() + var selected: Dictionary = {} + for summary: Dictionary in summaries: + if int(summary.get(&"day", -1)) == _selected_day: + selected = summary + if selected.is_empty() and not summaries.is_empty(): + selected = summaries.back() + return { + &"business_day": business_day, &"period_start": start, + &"cash": _runtime.economy_service.get_balance(&"PLAYER_CASH"), + &"cash_in": inflow, &"cash_out": outflow, &"cash_net": inflow - outflow, + &"construction_cash_out": construction_outflow, &"wage_cash_out": wage_outflow, + &"closed_days": summaries, &"selected_close": selected.duplicate(true), + } + + +func refresh() -> void: + if _runtime == null or _cash_label == null: + return + if _day_picker.get_popup().visible: + return + var report := get_report() + _cash_label.text = "第%d営業日 · 手持ち %dG\n本日の入金 +%dG / 出金 −%dG\nうち建築 %dG / 給与支払 %dG\n資金増減 %+dG(営業利益は下の決算)" % [report[&"business_day"], report[&"cash"], report[&"cash_in"], report[&"cash_out"], report[&"construction_cash_out"], report[&"wage_cash_out"], report[&"cash_net"]] + var summary: Dictionary = report[&"selected_close"] + if summary.is_empty(): + if not _day_picker.disabled or _day_picker.item_count == 0: + _day_picker.clear() + _picker_days.clear() + _day_picker.add_item("決算は翌朝06:00に確定します") + _day_picker.disabled = true + _closed_label.text = "建築後の手持ち資金だけで営業成績は決まりません。宿泊などの売上と日々の費用をここで確認できます。" + _facility_label.text = "" + return + _day_picker.disabled = false + var days: Array[int] = [] + for day: Dictionary in report[&"closed_days"]: + days.append(int(day[&"day"])) + if not _day_picker.get_popup().visible: + if days != _picker_days: + _day_picker.clear() + _picker_days = days + for day_number: int in days: + _day_picker.add_item("第%d営業日 · 確定" % day_number, day_number) + _day_picker.select(_picker_days.find(int(summary[&"day"]))) + var totals: Dictionary = summary.get(&"category_totals", {}) + var other := 0 + for key: StringName in [&"maintenance", &"utilities", &"taxes_and_interest", &"fines"]: + other += int(totals.get(key, 0)) + _closed_label.text = "売上 %dG / 返金 %dG\n運営費(契約金等) %dG\n給与 %dG / その他 %dG\n営業利益 %+dG\n前日比 %+dG / 未払給与 %dG\n帳簿照合:%s" % [totals.get(&"revenue", 0), totals.get(&"refunds", 0), totals.get(&"cost_of_goods", 0), totals.get(&"wages", 0), other, summary.get(&"net_profit", 0), summary.get(&"previous_day_delta", 0), summary.get(&"arrears_total", 0), "一致" if bool(summary.get(&"balanced", false)) else "差額あり"] + var lines: PackedStringArray = ["施設別 · 売上 / 費用 / 利益"] + var rows: Dictionary = summary.get(&"facility_profit_loss", {}) + var ids := rows.keys() + ids.sort() + for id: Variant in ids: + var facility: FacilityInstance = _runtime.facility_service.get_instance(StringName(id)) + var label := String(id) + if facility != null: + var definition: FacilityDefinition = _runtime.definition_registry.get_facility(facility.definition_id) + label = "%s (%d,%d)" % [definition.display_name if definition != null else facility.definition_id, facility.origin.x, facility.origin.y] + var row: Dictionary = rows[id] + lines.append("%s\n%dG / %dG / %+dG" % [label, row.get(&"revenue", 0), row.get(&"expenses", 0), row.get(&"net_profit", 0)]) + _facility_label.text = "\n".join(lines) if not rows.is_empty() else "施設別の記帳はまだありません。" + + +func _label() -> Label: + var label := Label.new() + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_color_override("font_color", Color("55371e")) + label.add_theme_font_size_override("font_size", 13) + add_child(label) + return label diff --git a/scripts/ui/operating_ledger_panel.gd.uid b/scripts/ui/operating_ledger_panel.gd.uid new file mode 100644 index 0000000..55e5346 --- /dev/null +++ b/scripts/ui/operating_ledger_panel.gd.uid @@ -0,0 +1 @@ +uid://dqh1r7py3s52i diff --git a/scripts/ui/staff_management_panel.gd b/scripts/ui/staff_management_panel.gd new file mode 100644 index 0000000..07519cc --- /dev/null +++ b/scripts/ui/staff_management_panel.gd @@ -0,0 +1,409 @@ +class_name StaffManagementPanel +extends VBoxContainer + +## Roster and assignment editing only. Runtime owns Shift, staffing, movement, +## and persistence; this panel never edits a Staff or Shift instance directly. +signal status_requested(message: String, success: bool) + +const ASSIGNMENT_COMMAND: StringName = &"set_staff_work_assignment" +const KEY_PREFIX := "ui.staff_assignment." +const ROLE_NAMES := { + &"attendant": "接客", &"bartender": "酒場", &"clerk": "会計", + &"cleaner": "清掃", &"cook": "調理", &"guard": "警備", + &"maintenance": "保守", &"masseur": "施術", &"medic": "医療", + &"porter": "物流", &"receptionist": "受付", +} +const STATE_NAMES := ["勤務外", "出勤中", "勤務中", "移動中", "作業中", "休憩中", "食事中", "睡眠中", "治療へ移動", "避難中", "行動不能", "ストライキ", "拘束中", "捕虜", "勤務不可"] + +var summary_label: Label +var roster_list: VBoxContainer +var roster_buttons: Dictionary = {} +var editor_panel: VBoxContainer +var staff_detail_label: Label +var facility_option: OptionButton +var shift_option: OptionButton +var apply_button: Button +var result_label: Label +var selected_staff_id: StringName = &"" + +var _runtime: Variant +var _style_button: Callable +var _staff_rows: Array[Dictionary] = [] +var _facility_rows: Array[Dictionary] = [] +var _draft_facility_id: StringName = &"" +var _draft_template_id: StringName = &"DAY" +var _draft_dirty := false +var _sequence := 1 +var _pending_commands: Dictionary = {} +var _queue_instance_id := 0 +var _built := false + + +func configure(runtime: Variant, style_button := Callable()) -> void: + _runtime = runtime + _style_button = style_button + + +func _ready() -> void: + name = "StaffManagementPanel" + 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("まだ従業員を雇っていません。候補から雇用してください。") + summary_label.name = "HiredStaffSummary" + add_child(summary_label) + var scroll := ScrollContainer.new() + scroll.name = "HiredStaffScroll" + scroll.custom_minimum_size.y = 120 + scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED + add_child(scroll) + roster_list = VBoxContainer.new() + roster_list.name = "HiredStaffList" + roster_list.size_flags_horizontal = Control.SIZE_EXPAND_FILL + roster_list.add_theme_constant_override("separation", 3) + scroll.add_child(roster_list) + editor_panel = VBoxContainer.new() + editor_panel.name = "StaffAssignmentEditor" + editor_panel.add_theme_constant_override("separation", 5) + add_child(editor_panel) + staff_detail_label = _label("") + staff_detail_label.name = "StaffAssignmentDetail" + editor_panel.add_child(staff_detail_label) + editor_panel.add_child(_label("配属先")) + facility_option = OptionButton.new() + facility_option.name = "StaffFacilityOption" + facility_option.fit_to_longest_item = false + facility_option.custom_minimum_size = Vector2(0, 30) + facility_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + facility_option.item_selected.connect(_on_facility_changed) + _style(facility_option) + editor_panel.add_child(facility_option) + editor_panel.add_child(_label("勤務帯")) + shift_option = OptionButton.new() + shift_option.name = "StaffShiftOption" + shift_option.fit_to_longest_item = false + shift_option.custom_minimum_size.y = 30 + shift_option.add_item("日勤 06:00〜18:00") + shift_option.set_item_metadata(0, &"DAY") + shift_option.add_item("夜勤 18:00〜翌06:00") + shift_option.set_item_metadata(1, &"NIGHT") + shift_option.item_selected.connect(_on_shift_changed) + _style(shift_option) + editor_panel.add_child(shift_option) + apply_button = Button.new() + apply_button.name = "ApplyStaffAssignment" + apply_button.text = "配属・勤務帯を変更" + apply_button.custom_minimum_size.y = 32 + apply_button.pressed.connect(submit_assignment) + _style(apply_button) + editor_panel.add_child(apply_button) + result_label = _label("") + result_label.name = "StaffAssignmentResult" + add_child(result_label) + + +func refresh() -> void: + if not _built or _runtime == null: + return + var queue: Variant = _runtime.get(&"command_queue") + if queue != null and int(queue.get_instance_id()) != _queue_instance_id: + _queue_instance_id = int(queue.get_instance_id()) + _draft_dirty = false + facility_option.get_popup().hide() + shift_option.get_popup().hide() + _synchronize_commands(queue) + _read_roster() + var live_ids: Array[StringName] = [] + var unassigned := 0 + for row: Dictionary in _staff_rows: + var staff_id := StringName(row.get(&"staff_id", &"")) + live_ids.append(staff_id) + if StringName(row.get(&"assigned_facility_id", &"")).is_empty(): + unassigned += 1 + var button := roster_buttons.get(staff_id) as Button + if button == null: + button = Button.new() + button.name = "HiredStaff_%s" % staff_id + button.custom_minimum_size.y = 32 + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.text_overrun_behavior = TextServer.OVERRUN_TRIM_ELLIPSIS + button.toggle_mode = true + button.pressed.connect(select_staff.bind(staff_id)) + _style(button) + roster_list.add_child(button) + roster_buttons[staff_id] = button + button.text = "%s %s%s" % [row.get(&"display_name", staff_id), _role_name(row), "・未配属" if StringName(row.get(&"assigned_facility_id", &"")).is_empty() else ""] + button.tooltip_text = "%s / %s" % [_facility_name(StringName(row.get(&"assigned_facility_id", &""))), _state_name(row)] + for raw_id: Variant in roster_buttons.keys(): + if not live_ids.has(StringName(raw_id)): + var stale := roster_buttons[raw_id] as Button + roster_list.remove_child(stale) + stale.queue_free() + roster_buttons.erase(raw_id) + if not live_ids.has(selected_staff_id): + selected_staff_id = live_ids[0] if not live_ids.is_empty() else &"" + _draft_dirty = false + for raw_id: Variant in roster_buttons: + (roster_buttons[raw_id] as Button).set_pressed_no_signal(StringName(raw_id) == selected_staff_id) + summary_label.text = "雇用済み %d人 / 未配属 %d人" % [_staff_rows.size(), unassigned] if not _staff_rows.is_empty() else "従業員はまだいません。「候補を見る」から雇用してください。" + var scroll := find_child("HiredStaffScroll", true, false) as ScrollContainer + scroll.visible = not _staff_rows.is_empty() + scroll.custom_minimum_size.y = mini(maxi(_staff_rows.size(), 1) * 35, 140) + editor_panel.visible = not selected_staff_id.is_empty() + if editor_panel.visible: + _refresh_editor() + + +func select_staff(staff_id: StringName) -> void: + selected_staff_id = staff_id + _draft_dirty = false + result_label.text = "" + refresh() + + +func _refresh_editor() -> void: + if facility_option.get_popup().visible or shift_option.get_popup().visible: + return + var row := _selected_row() + if row.is_empty(): + return + var current_facility := StringName(row.get(&"assigned_facility_id", &"")) + var template := StringName(String(row.get(&"template_id", "DAY")).to_upper()) + if not _draft_dirty: + _draft_facility_id = current_facility + _draft_template_id = &"NIGHT" if template == &"NIGHT" else &"DAY" + staff_detail_label.text = "%s(%s) 日給 G%d\n配属:%s / %s\n%s 疲労 %d / 未払 G%d" % [ + row.get(&"display_name", selected_staff_id), _role_name(row), int(row.get(&"daily_wage", 0)), + _facility_name(current_facility), "夜勤" if template == &"NIGHT" else "日勤" if template == &"DAY" else "自由勤務", + _state_name(row), int(row.get(&"fatigue", 0)), int(row.get(&"unpaid_wages", 0)), + ] + var effective_from := int(row.get(&"effective_from", -1)) + var now := int(get_node("/root/GameClock").get("absolute_minute")) + if effective_from > now: + staff_detail_label.text += "\n反映:%sから" % _minute_text(effective_from) + if row.has(&"recurring") and bool(row.get(&"recurring", false)): + staff_detail_label.text += "\nこの勤務帯を毎日くり返します。" + facility_option.clear() + facility_option.add_item("未配属(施設担当を外す)") + facility_option.set_item_metadata(0, &"") + var selected_index := 0 if _draft_facility_id.is_empty() else -1 + for facility: Dictionary in _facility_rows: + var facility_id := StringName(facility.get(&"facility_id", &"")) + facility_option.add_item(_facility_name(facility_id)) + var index := facility_option.item_count - 1 + facility_option.set_item_metadata(index, facility_id) + if facility_id == _draft_facility_id: + selected_index = index + if selected_index < 0: + facility_option.add_item("選択した施設は現在存在しません") + selected_index = facility_option.item_count - 1 + facility_option.set_item_metadata(selected_index, _draft_facility_id) + facility_option.set_item_disabled(selected_index, true) + facility_option.select(selected_index) + shift_option.select(1 if _draft_template_id == &"NIGHT" else 0) + var pending := _pending_commands.values().has(selected_staff_id) + facility_option.disabled = pending + shift_option.disabled = pending + var can_edit := bool(row.get(&"can_edit", true)) + apply_button.disabled = pending or not can_edit or facility_option.is_item_disabled(selected_index) + apply_button.tooltip_text = "現在の作業や拘束が終わってから変更できます。" if not can_edit else "配属先と毎日の勤務帯をまとめて変更します。" + if not can_edit: + staff_detail_label.text += "\n現在の作業や拘束が終わってから変更できます。" + apply_button.text = "変更を受理済み…" if pending else "配属・勤務帯を変更" + + +func _on_facility_changed(index: int) -> void: + _draft_facility_id = StringName(facility_option.get_item_metadata(index)) + _draft_dirty = true + + +func _on_shift_changed(index: int) -> void: + _draft_template_id = StringName(shift_option.get_item_metadata(index)) + _draft_dirty = true + + +func submit_assignment() -> void: + if _runtime == null or selected_staff_id.is_empty(): + return + if _pending_commands.values().has(selected_staff_id): + _set_result("この従業員の変更はすでに受理されています。", false) + return + var key := StringName("%s%08d" % [KEY_PREFIX, _sequence]) + _sequence += 1 + var result: Variant = _runtime.call(&"submit_command", ASSIGNMENT_COMMAND, { + &"staff_id": selected_staff_id, + &"facility_id": _draft_facility_id, + &"template_id": _draft_template_id, + }, &"player", selected_staff_id, 75, -1, key) + if result == null or not result.has_method(&"is_success"): + _set_result("配属変更に応答がありません。", false) + return + var pending := false + var queue: Variant = _runtime.get(&"command_queue") + if queue != null: + for command: Dictionary in queue.create_snapshot().get("pending", []): + if int(command.get("command_id", 0)) == int(result.command_id): + pending = true + break + if result.is_success() and pending: + _pending_commands[int(result.command_id)] = selected_staff_id + _set_result("変更を受理しました。次の時間更新で反映します。", true) + else: + _show_command_result(result) + refresh() + + +func _show_command_result(result: Variant, staff_id: StringName = &"") -> void: + var target_id := staff_id + if target_id.is_empty(): + target_id = StringName(result.details.get(&"staff_id", selected_staff_id)) + var employee_name := String(target_id) + for row: Dictionary in _staff_rows: + if StringName(row.get(&"staff_id", &"")) == target_id: + employee_name = String(row.get(&"display_name", target_id)) + break + if result.is_success(): + var details: Dictionary = result.details if result.details is Dictionary else {} + if target_id == selected_staff_id: + _draft_dirty = false + var facility_id := StringName(details.get(&"facility_id", _draft_facility_id)) + var template := StringName(details.get(&"template_id", _draft_template_id)) + var start := int(details.get(&"effective_from", -1)) + var when := "%sから" % _minute_text(start) if start >= 0 else "" + _set_result("%s:%s%s・毎日%sに変更しました。" % [employee_name, when, _facility_name(facility_id), "夜勤" if template == &"NIGHT" else "日勤"], true) + else: + _set_result("%s:%s" % [employee_name, _failure_message(result)], false) + + +func _failure_message(result: Variant) -> String: + var details: Dictionary = result.details if result.details is Dictionary else {} + var reason := StringName(details.get(&"reason", details.get("reason", &""))) + var reasons := { + &"STAFF_NOT_FOUND": "従業員が見つかりません。", + &"FACILITY_NOT_FOUND": "配属先が見つかりません。施設が完成しているか確認してください。", + &"ROLE_MISMATCH": "この施設には役割が合いません。必要な職種を確認してください。", + &"NO_ACCESS": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。", + &"NO_PATH": "従業員が配属先へ移動できません。通路と従業員ドアを確認してください。", + &"STAFF_BUSY": "作業中のため変更できません。作業完了後に再試行してください。", + &"CAPACITY_EXCEEDED": "配属先の人員上限を超えています。", + &"FACILITY_FULL": "配属先の人員上限を超えています。", + &"NO_ROUTE": "配属先へ移動できません。通路と従業員ドアを確認してください。", + &"INVALID_TEMPLATE": "日勤または夜勤を選択してください。", + &"STAFF_UNAVAILABLE": "この従業員は現在、勤務を変更できません。状態をご確認ください。", + &"DEFENSE_LOCKED": "防衛参加中は勤務を変更できません。", + &"EVENT_LOCKED": "イベント参加中は勤務を変更できません。", + &"FACILITY_UNSTAFFED": "この施設は人員を配属する施設ではありません。", + &"SHIFT_CONFLICT": "既存の勤務と時間が重なります。勤務予定を確認してください。", + &"BANKRUPT": "運営終了後は配属を変更できません。", + } + if reasons.has(reason): + return reasons[reason] + return "変更できませんでした:%s" % String(result.message) if not String(result.message).is_empty() else "変更できませんでした。配属先と勤務帯を確認してください。" + + +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", []): + if StringName(command.get("command_type", &"")) != ASSIGNMENT_COMMAND: + continue + var payload: Dictionary = command.get("payload", {}) + _pending_commands[int(command.get("command_id", 0))] = StringName(payload.get(&"staff_id", payload.get("staff_id", &""))) + + +func _on_snapshot_published(_snapshot: Dictionary, _digest: String) -> void: + if is_visible_in_tree(): + refresh() + + +func _on_command_completed(result: Variant) -> void: + if not _pending_commands.has(int(result.command_id)): + return + var staff_id := StringName(_pending_commands[int(result.command_id)]) + _pending_commands.erase(int(result.command_id)) + _show_command_result(result, staff_id) + refresh() + + +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 _read_roster() -> void: + _staff_rows.clear() + _facility_rows.clear() + if _runtime.has_method(&"get_staff_management_report"): + var report: Dictionary = _runtime.call(&"get_staff_management_report") + _staff_rows.assign(report.get(&"staff", [])) + _facility_rows.assign(report.get(&"facilities", [])) + return + _set_result("従業員データを読み込めません。", false) + + +func _selected_row() -> Dictionary: + for row: Dictionary in _staff_rows: + if StringName(row.get(&"staff_id", &"")) == selected_staff_id: + return row + return {} + + +func _facility_name(facility_id: StringName) -> String: + if facility_id.is_empty(): + return "未配属" + for facility: Dictionary in _facility_rows: + if StringName(facility.get(&"facility_id", &"")) == facility_id: + var suffix := String(facility_id).trim_prefix("facility_").lstrip("0") + var location := "" + var origin: Variant = facility.get(&"origin", null) + if origin is Vector3i: + var floor_name := "%dF" % (origin.y + 1) if origin.y >= 0 else "B%d" % -origin.y + location = " %s・%s" % [floor_name, "客用" if int(facility.get(&"layer", 0)) == 0 else "裏方"] + return "%s%s #%s" % [facility.get(&"display_name", facility_id), location, suffix if not suffix.is_empty() else facility_id] + return "撤去済みの施設" + + +func _role_name(row: Dictionary) -> String: + var role := StringName(row.get(&"role_id", &"")) + return String(ROLE_NAMES.get(role, String(role))) + + +func _state_name(row: Dictionary) -> String: + var value := int(row.get(&"state", 0)) + return STATE_NAMES[value] if value >= 0 and value < STATE_NAMES.size() else "状態を確認中" + + +func _minute_text(absolute_minute: int) -> String: + var day := absolute_minute / 1440 + 1 + var hour := posmod(absolute_minute, 1440) / 60 + var minute := posmod(absolute_minute, 60) + return "%d日目 %02d:%02d" % [day, hour, minute] + + +func _label(text_value: String) -> Label: + var label := Label.new() + label.text = text_value + label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + label.add_theme_font_size_override("font_size", 12) + label.add_theme_color_override("font_color", Color("394937")) + return label + + +func _style(button: Button) -> void: + button.add_theme_font_size_override("font_size", 12) + if _style_button.is_valid(): + _style_button.call(button) diff --git a/scripts/ui/staff_management_panel.gd.uid b/scripts/ui/staff_management_panel.gd.uid new file mode 100644 index 0000000..9a1f75d --- /dev/null +++ b/scripts/ui/staff_management_panel.gd.uid @@ -0,0 +1 @@ +uid://c337ritas4qat diff --git a/tests/s3/test_runtime_staff_assignment.gd b/tests/s3/test_runtime_staff_assignment.gd new file mode 100644 index 0000000..30e7c41 --- /dev/null +++ b/tests/s3/test_runtime_staff_assignment.gd @@ -0,0 +1,290 @@ +extends SceneTree + +var _checks := 0 +var _failures := 0 +var _clock: Variant +var _state: Variant +var _runtime: Variant + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + print("P1: Staff reassignment, repeating shifts, and save compatibility") + _test_service_shift_history_and_recurrence() + _test_night_boundary_and_unassignment() + _test_service_snapshot_guards() + _test_runtime_assignment_boundary() + _cleanup() + if _failures == 0: + print("PASS: %d checks" % _checks) + else: + push_error("FAIL: %d / %d checks" % [_failures, _checks]) + quit(_failures) + + +func _worker(service: StaffService, id: StringName = &"worker") -> StaffRuntime: + _expect(service.create_staff(id, &"receptionist", {&"daily_wage": 120}).is_success(), "test employee registers") + return service.get_staff(id) + + +func _test_service_shift_history_and_recurrence() -> void: + var service := StaffService.new() + var worker := _worker(service) + service.advance_one_minute(360) + _expect(service.set_work_assignment(worker.staff_id, &"west", &"DAY").is_success(), "first assignment starts a recurring DAY shift") + var first_id := worker.shift_id + for minute: int in range(361, 600): + service.advance_one_minute(minute) + service.advance_one_minute(600) + var total_before := service.get_shift(first_id).worked_minutes + var result := service.set_work_assignment(worker.staff_id, &"east", &"DAY") + _expect(result.is_success() and int(result.details[&"effective_from"]) == 600, "daytime reassignment takes effect now") + var first := service.get_shift(first_id) + var second := service.get_shift(worker.shift_id) + _expect(first.assigned_facility_id == &"west" and first.start_at == 360 and first.end_at == 600 and first.actual_ended_at == 600, "elapsed work retains its original facility and exact half-open interval") + _expect(second.assigned_facility_id == &"east" and second.start_at == 600 and second.end_at == 1080, "replacement starts exactly at the split boundary") + _expect(first.worked_minutes + second.worked_minutes == total_before, "reassignment neither duplicates nor loses the current duty minute") + _expect(worker.current_cell == Vector3i.ZERO, "assignment never teleports the employee") + var stable := service.create_snapshot() + result = service.set_work_assignment(worker.staff_id, &"east", &"DAY") + _expect(result.details[&"reason"] == &"ASSIGNMENT_UNCHANGED" and service.create_snapshot() == stable, "same plan replay is an exact no-op") + for minute: int in range(601, 1081): + service.advance_one_minute(minute) + _expect(worker.state == StaffRuntime.State.OFF_DUTY and worker.assigned_facility_id.is_empty(), "DAY ends at 18:00 and clears active facility") + _expect(first.worked_minutes + second.worked_minutes == 720, "all 720 daytime minutes remain attributed exactly once") + var historical := first.create_snapshot() + for minute: int in range(1081, 3241): + service.advance_one_minute(minute) + _expect(worker.state == StaffRuntime.State.AVAILABLE and worker.assigned_facility_id == &"east", "the same assignment works on the third day without another command") + _expect(service.get_shift(first_id).create_snapshot() == historical, "later daily shifts leave historical work unchanged") + _expect(service.validate_invariants()[&"ok"], "repeating shifts preserve the Staff graph") + var allocation := _wage_allocation(service, 0, 1440) + _expect(int(allocation[&"allocated_total"]) == 120 and int(allocation[&"unallocated_total"]) == 0, "split daily wages are allocated once with no lost amount") + _expect(allocation[&"allocated_by_facility"] == {&"west": 40, &"east": 80}, "historical wages follow the 240/480 minute split after later days run") + + +func _test_night_boundary_and_unassignment() -> void: + var service := StaffService.new() + var worker := _worker(service) + service.advance_one_minute(1410) + _expect(service.set_work_assignment(worker.staff_id, &"west", &"NIGHT").is_success(), "late-night assignment begins immediately") + var first_id := worker.shift_id + for minute: int in range(1411, 1451): + service.advance_one_minute(minute) + _expect(service.set_work_assignment(worker.staff_id, &"east", &"NIGHT").is_success(), "NIGHT reassignment works after midnight") + var first := service.get_shift(first_id) + var next := service.get_shift(worker.shift_id) + _expect(first.start_at == 1410 and first.end_at == 1450 and next.start_at == 1450 and next.end_at == 1800, "midnight split keeps one exact historical interval and the remaining night") + _expect(first.worked_minutes + next.worked_minutes == 41, "cross-midnight reassignment preserves recorded duty total") + _expect(_wage_allocation(service, 0, 1440)[&"allocated_by_facility"] == {&"west": 120}, "reassignment after midnight does not move the prior day's wage expense") + var restored := StaffService.new() + _expect(restored.restore_snapshot(service.create_snapshot()), "a split NIGHT snapshot restores") + for minute: int in range(1451, 2521): + service.advance_one_minute(minute) + restored.advance_one_minute(minute) + _expect(service.create_snapshot() == restored.create_snapshot(), "save replay reaches the next NIGHT deterministically") + _expect(worker.state == StaffRuntime.State.AVAILABLE and worker.assigned_facility_id == &"east", "next evening starts a new NIGHT automatically") + var day_result := service.set_work_assignment(worker.staff_id, &"east", &"DAY") + _expect(int(day_result.details[&"effective_from"]) == 3240 and worker.state == StaffRuntime.State.OFF_DUTY, "changing night to DAY waits for the next 06:00") + _expect(service.set_work_assignment(worker.staff_id, &"", &"DAY").is_success(), "empty facility explicitly unassigns") + service.advance_one_minute(4680) + _expect(worker.assigned_facility_id.is_empty() and worker.shift_id.is_empty(), "unassigned Staff never return to an old future shift") + _expect(service.get_daily_payroll_total() == 120, "unassigning does not silently dismiss Staff or remove their wage") + _expect(service.remove_staff(worker.staff_id).is_success() and service.get_work_assignments().is_empty(), "staff removal cleans the recurring plan") + + +func _wage_allocation(service: StaffService, start: int, end: int) -> Dictionary: + var allocator = load("res://autoload/simulation_runtime.gd").new() + allocator.staff_service = service + for id: StringName in [&"west", &"east"]: + allocator.facility_service.create_instance({&"instance_id": id, &"definition_id": &"reception"}) + var result: Dictionary = allocator._allocate_operations_wages_to_facilities(start, end, 120, [{&"staff_id": &"worker", &"new_wages": 120}], {}) + allocator.free() + return result + + +func _test_service_snapshot_guards() -> void: + var service := StaffService.new() + _worker(service) + service.advance_one_minute(400) + service.set_work_assignment(&"worker", &"west", &"DAY") + var current := service.create_snapshot() + var restored := StaffService.new() + _expect(restored.restore_snapshot(current) and restored.create_snapshot() == current, "current recurring-plan snapshot round trip is exact") + var legacy := current.duplicate(true) + legacy[&"version"] = 3 + legacy.erase(&"work_assignments") + _expect(restored.restore_snapshot(legacy), "pre-roster v3 snapshot migrates") + _expect(restored.get_work_assignments().is_empty() and restored.get_shifts().size() == service.get_shifts().size(), "legacy migration preserves concrete shifts without inventing a plan") + var pristine := restored.create_snapshot() + for change: Dictionary in [ + {&"staff_id": "missing"}, {&"template_id": "FLEX"}, + {&"effective_from": 1}, {&"next_start_at": 1}, {&"changed_at": 99999}, + {&"next_start_at": 1800 + 1440 * 10}, + {&"facility_id": 17}, {&"unexpected": true}, + ]: + var bad := current.duplicate(true) + for key: Variant in change: + bad[&"work_assignments"][0][key] = change[key] + _expect(not restored.restore_snapshot(bad) and restored.create_snapshot() == pristine, "malformed plan rejects atomically: %s" % change) + var downgrade := current.duplicate(true) + downgrade[&"version"] = 3 + _expect(not restored.restore_snapshot(downgrade), "outer-only downgrade cannot hide a roster") + + +func _test_runtime_assignment_boundary() -> void: + _clock = load("res://autoload/game_clock.gd").new() + _state = load("res://autoload/game_state.gd").new() + _runtime = load("res://autoload/simulation_runtime.gd").new() + _clock.reset(1, 6, 0) + _state.reset() + _expect(_runtime.initialize(_clock, _state, 91003)[&"valid"], "normal Runtime initializes") + _clock.set_paused(true) + for origin: Vector3i in [Vector3i(-8, 0, 0), Vector3i(2, 0, 0)]: + _expect(_runtime.submit_command(&"build_facility", {&"definition_id": &"reception", &"origin": origin}).is_success(), "reception is built through the normal command") + _clock.advance_minutes(120) + var buildings: Array = _runtime.facility_service.get_instances() + _expect(buildings.size() == 2, "both receptions complete") + if buildings.size() != 2: + return + var west: StringName = buildings[0].instance_id + var east: StringName = buildings[1].instance_id + for id: StringName in [&"staff_akane", &"staff_bardo", &"staff_chika"]: + _expect(_runtime.submit_command(&"hire_staff_candidate", {&"candidate_id": id}).is_success(), "catalog employee is hired: %s" % id) + var money_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) + _expect(_assign(&"staff_akane", west, &"DAY").is_success(), "new command assigns hired receptionist") + _expect(buildings[0].active_staff_ids == [&"staff_akane"] and buildings[0].state == FacilityInstance.State.OPEN, "paused assignment immediately opens the facility") + var old_cell: Vector3i = _runtime.staff_service.get_staff(&"staff_akane").current_cell + var before: Dictionary = _runtime.staff_service.create_snapshot() + for row: Array in [ + [&"absent", west, &"DAY", &"STAFF_NOT_FOUND"], + [&"staff_akane", &"absent", &"DAY", &"FACILITY_NOT_FOUND"], + [&"staff_chika", west, &"DAY", &"ROLE_MISMATCH"], + [&"staff_akane", west, &"FLEX", &"INVALID_TEMPLATE"], + ]: + var result = _assign(row[0], row[1], row[2]) + _expect(not result.is_success() and result.details.get(&"reason") == row[3], "invalid assignment explains: %s" % row[3]) + _expect(_runtime.staff_service.create_snapshot() == before, "rejected command leaves Staff and schedule unchanged") + _runtime.path_service.set_cell_temporary_block(old_cell, true) + var blocked = _assign(&"staff_akane", east, &"DAY") + _expect(not blocked.is_success() and blocked.details.get(&"reason") == &"NO_ROUTE", "inaccessible assignment is rejected") + _expect(_runtime.staff_service.create_snapshot() == before, "route failure preserves the old assignment") + _runtime.path_service.set_cell_temporary_block(old_cell, false) + _clock.advance_minutes(20) + _expect(_assign(&"staff_akane", east, &"DAY").is_success(), "employee can move to another compatible facility") + _expect(buildings[0].active_staff_ids.is_empty() and buildings[1].active_staff_ids == [&"staff_akane"], "reassignment removes the old service staffing and activates the new facility") + _expect(_runtime.staff_service.get_staff(&"staff_akane").current_cell == old_cell, "Runtime assignment leaves Path position intact") + _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == money_before, "assignment and shift changes charge no second signing fee") + var report: Dictionary = _runtime.get_staff_management_report() + _expect(report[&"staff"][0][&"recurring"] and report[&"staff"][0][&"assigned_facility_id"] == east, "management report exposes the committed recurring plan") + report[&"staff"][0][&"assigned_facility_id"] = &"tampered" + _expect(_runtime.staff_service.get_work_assignment(&"staff_akane")[&"facility_id"] == String(east), "management report is a defensive copy") + _expect(_assign(&"staff_bardo", east, &"NIGHT").is_success(), "another employee can cover the opposite shift") + var snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "Runtime save validates repeating assignments and split historical work") + _clock.advance_minutes(1440) + var replay_expected: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "Runtime rewinds to recurring-plan checkpoint") + _clock.advance_minutes(1440) + _expect(_runtime.create_snapshot() == replay_expected, "next-day scheduling and actor projection replay exactly after restore") + var saved_staff: Dictionary = _runtime.staff_service.create_snapshot() + var bad_snapshot: Dictionary = _runtime.create_snapshot().duplicate(true) + bad_snapshot[&"systems"][&"runtime"][&"staff"][&"work_assignments"][0][&"facility_id"] = "missing" + _expect(not _runtime.restore_snapshot(bad_snapshot), "Runtime rejects a plan targeting a missing facility") + _expect(_runtime.staff_service.create_snapshot() == saved_staff, "invalid Runtime roster restore is atomic") + var remove = _runtime.submit_command(&"demolish_facility", {&"instance_id": east}) + _expect(not remove.is_success() and remove.details.get(&"reason") == &"STAFF_ASSIGNED", "demolition requires releasing saved staff assignments") + var queued_at: int = _clock.absolute_minute + _clock.set_paused(false) + var queued = _assign(&"staff_akane", west, &"DAY") + _expect(queued.is_success(), "running game accepts the assignment into its command queue") + _clock.advance_one_minute() + _clock.set_paused(true) + var applied: Dictionary = _runtime.staff_service.get_work_assignment(&"staff_akane") + var applied_shift: ShiftInstance = _runtime.staff_service.get_shift(_runtime.staff_service.get_staff(&"staff_akane").shift_id) + _expect(int(applied[&"changed_at"]) == queued_at + 1 and int(applied[&"effective_from"]) == queued_at + 1, "queued assignment uses the execution minute, not the previous Staff tick") + _expect(applied_shift.worked_minutes == 1, "command and AI phases record the execution minute only once") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "queued assignment leaves a coherent save at the end of its tick") + _test_active_service_reassignment(west, east) + _test_busy_staff_and_capacity(west) + + +func _test_active_service_reassignment(west: StringName, east: StringName) -> void: + _expect(_assign(&"staff_akane", east, &"DAY").is_success(), "service test keeps receptionist on DAY") + var spawned = _runtime.submit_command(&"spawn_guest", { + &"guest_id": &"reassignment_guest", &"guest_type_id": &"traveler", &"gold": 100, + &"options": {&"state": GuestRuntime.State.DECIDING, &"current_cell": Vector3i.ZERO}, + }) + _expect(spawned.is_success(), "a real Guest and wallet enter the service test") + var request = _runtime.submit_command(&"create_service_request", { + &"facility_id": east, &"guest_ids": [&"reassignment_guest"], &"service_type": &"check_in", + &"options": {&"service_minutes": 30}, + }) + _expect(request.is_success(), "Guest enters reception through normal request ownership") + _clock.advance_one_minute() + var active: Array = _runtime.facility_service.get_active_services(east) + _expect(active.size() == 1, "reception begins the Guest service") + if active.size() != 1: + return + var service: ServiceInstance = active[0] + var balance_before: int = _runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) + _expect(_assign(&"staff_akane", west, &"DAY").is_success(), "reassignment during facility service is allowed") + _clock.advance_one_minute() + _expect(service.state == ServiceInstance.State.WAITING_FOR_STAFF, "existing service enters the normal staff-replacement grace period") + _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == balance_before, "waiting service posts no phantom revenue") + _expect(_assign(&"staff_akane", east, &"DAY").is_success(), "replacement can return during grace period") + _clock.advance_one_minute() + _expect(service.state == ServiceInstance.State.ACTIVE, "staff return resumes the same service instead of duplicating it") + _expect(_assign(&"staff_akane", &"", &"DAY").is_success(), "employee can be unassigned during service") + _clock.advance_minutes(6) + _expect(service.state == ServiceInstance.State.FAILED, "uncovered service fails at the existing five-minute grace deadline") + _expect(_runtime.economy_service.get_balance(_runtime.PLAYER_CASH_ACCOUNT) == balance_before, "failed uncovered service never posts completed-sale revenue") + _expect(_runtime.restore_snapshot(_runtime.create_snapshot()), "Guest, service, staff, and repeating plan save consistently after interruption") + + +func _test_busy_staff_and_capacity(west: StringName) -> void: + _expect(_assign(&"staff_akane", west, &"DAY").is_success(), "busy-task test starts with a working employee") + var task = _runtime.submit_command(&"create_staff_task", { + &"task_id": &"assignment_guard_task", &"task_type": &"reception", &"priority": 99, + &"options": {&"target_cell": Vector3i.ZERO, &"required_role_tags": [&"receptionist"], + &"task_tags": [&"reception"], &"estimated_minutes": 20}, + }) + _expect(task.is_success(), "staff task enters normal dispatcher") + _clock.advance_one_minute() + var before: Dictionary = _runtime.staff_service.create_snapshot() + var busy = _assign(&"staff_akane", &"", &"DAY") + _expect(not busy.is_success() and busy.details.get(&"reason") == &"STAFF_BUSY", "active task blocks a schedule edit with an explicit reason") + _expect(_runtime.staff_service.create_snapshot() == before, "busy rejection preserves Task ownership and progress") + _expect(_runtime.staff_service.cancel_task(&"assignment_guard_task", &"TEST_COMPLETE", _clock.absolute_minute).is_success(), "task releases its employee through the service boundary") + _expect(_assign(&"staff_bardo", west, &"DAY").is_success(), "second receptionist shares the day shift") + for id: StringName in [&"capacity_extra", &"capacity_overflow"]: + _expect(_runtime.submit_command(&"register_staff", {&"staff_id": id, &"role_id": &"receptionist"}).is_success(), "additional employee registers for the capacity boundary") + _expect(_assign(&"capacity_extra", west, &"DAY").is_success(), "third receptionist reaches the exact staffing limit") + before = _runtime.staff_service.create_snapshot() + var overflow = _assign(&"capacity_overflow", west, &"DAY") + _expect(not overflow.is_success() and overflow.details.get(&"reason") == &"FACILITY_FULL", "fourth simultaneous receptionist is rejected") + _expect(_runtime.staff_service.create_snapshot() == before, "capacity rejection leaves all assignments unchanged") + _expect(_assign(&"capacity_overflow", west, &"NIGHT").is_success(), "opposite shift has its own staffing capacity") + + +func _assign(staff_id: StringName, facility_id: StringName, template_id: StringName) -> CommandResult: + return _runtime.submit_command(&"set_staff_work_assignment", {&"staff_id": staff_id, &"facility_id": facility_id, &"template_id": template_id}) + + +func _cleanup() -> void: + for node: Variant in [_runtime, _clock, _state]: + if node != null: + node.free() + _runtime = null + _clock = null + _state = null + + +func _expect(condition: bool, description: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error(description) diff --git a/tests/s3/test_runtime_staff_assignment.gd.uid b/tests/s3/test_runtime_staff_assignment.gd.uid new file mode 100644 index 0000000..262b630 --- /dev/null +++ b/tests/s3/test_runtime_staff_assignment.gd.uid @@ -0,0 +1 @@ +uid://cedumilwe1o4f diff --git a/tests/s3/test_staff_payroll_operations.gd b/tests/s3/test_staff_payroll_operations.gd index 1eda946..89b9acd 100644 --- a/tests/s3/test_staff_payroll_operations.gd +++ b/tests/s3/test_staff_payroll_operations.gd @@ -208,6 +208,7 @@ func _test_snapshot_contract() -> void: var pre_effective_v2 := snapshot.duplicate(true) pre_effective_v2[&"version"] = 2 + pre_effective_v2.erase(&"work_assignments") for staff_snapshot: Dictionary in pre_effective_v2[&"staff"]: staff_snapshot.erase(&"snapshot_version") staff_snapshot.erase(&"employment_effective_minute") @@ -215,13 +216,14 @@ func _test_snapshot_contract() -> void: _expect(migrated_v2.restore_snapshot(pre_effective_v2), "pre-effective-minute v2 Staff snapshot migrates") _expect(migrated_v2.get_staff(&"a").employment_effective_minute == 0, "v2 legacy Staff employment starts at minute zero") _expect( - migrated_v2.create_snapshot()[&"version"] == 3 + migrated_v2.create_snapshot()[&"version"] == StaffServiceType.SNAPSHOT_VERSION and migrated_v2.create_snapshot()[&"staff"][0][&"snapshot_version"] == 1, "v2 legacy Staff row is re-emitted in the current service and row schemas" ) var legacy := snapshot.duplicate(true) legacy[&"version"] = 1 + legacy.erase(&"work_assignments") legacy.erase(&"last_advanced_minute") legacy.erase(&"payroll_settlements") for staff_snapshot: Dictionary in legacy[&"staff"]: diff --git a/tests/s3/test_staff_service.gd b/tests/s3/test_staff_service.gd index 7d6c571..9f08f07 100644 --- a/tests/s3/test_staff_service.gd +++ b/tests/s3/test_staff_service.gd @@ -608,6 +608,7 @@ func _test_payroll_snapshot_migration_and_tamper() -> void: legacy_source.remove_staff(&"departed_debt", &"LEFT", true, 0) var legacy: Dictionary = legacy_source.create_snapshot() legacy[&"version"] = 1 + legacy.erase(&"work_assignments") legacy.erase(&"last_advanced_minute") legacy.erase(&"payroll_settlements") for staff_snapshot: Dictionary in legacy[&"staff"]: @@ -621,7 +622,7 @@ func _test_payroll_snapshot_migration_and_tamper() -> void: "v1 migration preserves active and departed unpaid wages" ) var migrated_snapshot := migrated.create_snapshot() - _expect(migrated_snapshot[&"version"] == 3 and migrated_snapshot[&"payroll_settlements"].is_empty(), "v1 migration emits canonical v3 with empty history") + _expect(migrated_snapshot[&"version"] == StaffServiceType.SNAPSHOT_VERSION and migrated_snapshot[&"payroll_settlements"].is_empty(), "v1 migration emits canonical current schema with empty history") var service := StaffServiceType.new() service.register_staff(_staff(&"staff_a", 10, {&"daily_wage": 100, &"unpaid_wages": 20})) @@ -630,19 +631,20 @@ func _test_payroll_snapshot_migration_and_tamper() -> void: service.apply_daily_payroll_settlement(&"snapshot.day.2", 2, {&"staff_a": 50, &"staff_b": 100}) var snapshot: Dictionary = service.create_snapshot() var restored := StaffServiceType.new() - _expect(restored.restore_snapshot(snapshot), "canonical v3 payroll history restores") - _expect(restored.create_snapshot() == snapshot, "v3 payroll settlement history round trip is exact") + _expect(restored.restore_snapshot(snapshot), "canonical current schema payroll history restores") + _expect(restored.create_snapshot() == snapshot, "current payroll settlement history round trip is exact") var v2_snapshot: Dictionary = snapshot.duplicate(true) v2_snapshot[&"version"] = 2 + v2_snapshot.erase(&"work_assignments") for staff_snapshot: Dictionary in v2_snapshot[&"staff"]: staff_snapshot.erase(&"snapshot_version") staff_snapshot.erase(&"employment_effective_minute") var v2_migrated := StaffServiceType.new() _expect(v2_migrated.restore_snapshot(v2_snapshot), "pre-effective-minute v2 payroll history migrates") _expect( - v2_migrated.create_snapshot()[&"version"] == 3 + v2_migrated.create_snapshot()[&"version"] == StaffServiceType.SNAPSHOT_VERSION and v2_migrated.get_staff(&"staff_a").employment_effective_minute == 0, - "v2 migration emits canonical v3 Staff rows at effective minute zero" + "v2 migration emits canonical current schema Staff rows at effective minute zero" ) var before_tamper := restored.create_snapshot() diff --git a/tests/ui/test_first_operating_day.gd b/tests/ui/test_first_operating_day.gd new file mode 100644 index 0000000..d724b1c --- /dev/null +++ b/tests/ui/test_first_operating_day.gd @@ -0,0 +1,176 @@ +extends SceneTree + +## Normal UI commands, default funds, and natural arrivals only. No demo fixture. +## Optional THE_TREE_OPENING_CAPTURE=1 renders the new-game and close screens. +var _checks := 0 +var _failures := 0 +var _runtime: Variant +var _clock: Variant +var _main: Control +var _guide: InnOpeningGuide +var _ledger: OperatingLedgerPanel + + +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, 424242) + _clock.set_paused(true) + root.size = Vector2i(1440, 810) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + await process_frame + await process_frame + _guide = _main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide + _ledger = _main.find_child("OperatingLedgerPanel", true, false) as OperatingLedgerPanel + _expect(_guide != null and _ledger != null, "normal main scene mounts opening guide and real ledger") + if _guide == null or _ledger == null: + _finish() + return + _expect(_runtime.economy_service.get_balance(&"PLAYER_CASH") == 5000, "ordinary initial funds are unchanged") + _expect(_guide.get_progress().get(&"stage") == &"reception", "new game starts with reception guidance") + await _capture("opening") + if not _build(&"reception", Vector3i(-8, 0, 0)): + _finish() + return + _expect(_guide.get_progress().get(&"action") == &"resume", "guide explains construction time while paused") + await _advance(120) + _expect(_guide.get_progress().get(&"stage") == &"room", "completed reception advances room guidance") + _build(&"standard_room", Vector3i(-8, 2, 0)) + _build(&"standard_room", Vector3i(-6, 2, 0)) + _build(&"guest_corridor", Vector3i(-4, 1, 0)) + await _advance(60) + _build(&"guest_corridor", Vector3i(-4, 2, 0)) + await _advance(10) + for x: int in range(-8, -3): + _build(&"guest_corridor", Vector3i(x, 3, 0)) + await _advance(10) + var reception_id: StringName = &"" + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"reception": + reception_id = facility.instance_id + _expect(not reception_id.is_empty(), "normal construction created the reception") + _main.call("_hire_staff_candidate", &"staff_akane") + var staff_members: Array = _runtime.staff_service.get_staff_members() + _expect(staff_members.size() == 1, "normal hire button employs one receptionist") + var staff_panel := _main.find_child("StaffManagementPanel", true, false) as StaffManagementPanel + _expect(staff_panel != null, "staff assignment controls are part of the dashboard") + if staff_panel == null or staff_members.is_empty(): + _finish() + return + staff_panel.select_staff(staff_members[0].staff_id) + var assigned := false + for index: int in staff_panel.facility_option.item_count: + if StringName(staff_panel.facility_option.get_item_metadata(index)) == reception_id: + staff_panel.facility_option.select(index) + staff_panel.facility_option.item_selected.emit(index) + staff_panel.apply_button.pressed.emit() + assigned = true + break + _expect(assigned, "reception can be selected through the assignment dropdown") + await _advance(2) + _expect(_runtime.staff_service.get_staff(staff_members[0].staff_id).assigned_facility_id == reception_id, "UI applies the receptionist assignment to the simulation") + _expect(_guide.get_progress().get(&"stage") == &"sale", "guide recognizes accessible rooms and staffing") + _main.call("_clear_active_tool") + _main.call("_refresh_dashboard") + await _capture("staffed") + # The night is simulated naturally. No service request or guest is inserted. + await _advance(1440 - _clock.absolute_minute) + _expect(int(_ledger.get_report()[&"business_day"]) == 1, "midnight remains part of the first 06:00-based operating day") + _expect(int(_ledger.get_report()[&"construction_cash_out"]) > 0, "building cash outflow remains visible at midnight") + await _advance(1800 - _clock.absolute_minute) + var summaries: Array = _runtime.operations_coordinator.get_day_summaries() + _expect(summaries.size() == 1, "next 06:00 creates one authoritative operating-day close") + var lodging_sales := 0 + for entry: LedgerEntry in _runtime.economy_service.get_entries(): + var facility: FacilityInstance = _runtime.facility_service.get_instance(entry.source_entity_id) + if entry.category == &"SERVICE_SALE" and facility != null and facility.definition_id == &"standard_room": + lodging_sales += entry.amount + _expect(lodging_sales > 0, "naturally arriving guests complete paid lodging") + if not summaries.is_empty(): + var first: Dictionary = summaries[0] + _expect(int(first[&"category_totals"][&"wages"]) > 0, "the close includes real employee wages") + _expect(bool(first[&"balanced"]), "daily close reconciles the ledger") + _expect(_ledger.get_report()[&"selected_close"] == first, "ledger panel shows the authoritative close without reclassification") + _expect(int(_ledger.get_report()[&"business_day"]) == 2, "cash-flow period rolls at 06:00 rather than midnight") + _expect(_guide.get_progress().get(&"stage") == &"complete", "first lodging and following close complete opening guidance") + var digest: String = _runtime.latest_digest + _guide.refresh() + _ledger.refresh() + _expect(_runtime.latest_digest == digest, "guide and ledger refresh never mutate simulation") + _main.call("_activate_management_mode", &"ledger") + await _capture("first-close") + # A dedicated temp slot avoids every normal player save path. + var save_path := "user://tests/first_operating_day_%d/restore-check" % OS.get_process_id() + var saved: Dictionary = _runtime.save_to_path(save_path, {&"display_name": "First operating day regression"}) + _expect(bool(saved.get(&"ok", false)), "first operating day saves to a dedicated test slot: %s" % saved) + var close_before: Dictionary = _ledger.get_report()[&"selected_close"] + var restored: Dictionary = _runtime.load_from_path(save_path) + _expect(bool(restored.get(&"ok", false)), "first operating day can be restored: %s" % restored.get(&"code", &"UNKNOWN")) + # Coordinator snapshots intentionally normalize StringName keys and values + # to String. Compare the JSON-shaped public report, preserving every value. + _expect(JSON.stringify(_ledger.get_report()[&"selected_close"]) == JSON.stringify(close_before), "restored close preserves every financial value") + _expect(_guide.get_progress().get(&"stage") == &"complete", "restored state preserves guide progress: %s" % _guide.get_progress()) + var evidence := {"lodging_sales": lodging_sales, "report": _ledger.get_report(), "staff_count": _runtime.staff_service.get_staff_members().size()} + print("FIRST_OPERATING_DAY_REPORT ", JSON.stringify(evidence)) + var evidence_path := ProjectSettings.globalize_path("res://artifacts/first-operating-day/operating-report.json") + DirAccess.make_dir_recursive_absolute(evidence_path.get_base_dir()) + var evidence_file := FileAccess.open(evidence_path, FileAccess.WRITE) + if evidence_file != null: + evidence_file.store_string(JSON.stringify(evidence, "\t") + "\n") + SaveService.delete_save(save_path) + _finish() + + +func _build(definition_id: StringName, origin: Vector3i) -> bool: + var before: int = _runtime.construction_service.get_jobs().size() + _main.call("_select_build_definition", definition_id) + _main.call("_on_map_cell_clicked", origin) + var built: bool = _runtime.construction_service.get_jobs().size() == before + 1 + _expect(built, "UI starts %s at %s" % [definition_id, origin]) + return built + + +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, "normal operations must advance: %s" % _runtime.get_operations_report()) + return + if index % 120 == 119: + print("OPENING_PROGRESS ", _clock.formatted_time()) + await process_frame + + +func _capture(suffix: String) -> void: + if OS.get_environment("THE_TREE_OPENING_CAPTURE") != "1": + return + _main.call("_refresh_dashboard") + for frame: int in 5: + await process_frame + await RenderingServer.frame_post_draw + var image := root.get_texture().get_image() + var path := ProjectSettings.globalize_path("res://artifacts/first-operating-day/%s.png" % suffix) + DirAccess.make_dir_recursive_absolute(path.get_base_dir()) + _expect(image != null and image.save_png(path) == OK, "captures %s UI" % suffix) + + +func _expect(condition: bool, message: String) -> void: + _checks += 1 + if not condition: + _failures += 1 + push_error("FAIL: %s" % message) + + +func _finish() -> void: + print("First operating day: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) diff --git a/tests/ui/test_first_operating_day.gd.uid b/tests/ui/test_first_operating_day.gd.uid new file mode 100644 index 0000000..67b1ff9 --- /dev/null +++ b/tests/ui/test_first_operating_day.gd.uid @@ -0,0 +1 @@ +uid://b0lupcx1bx00j diff --git a/tests/ui/test_main_dashboard.gd b/tests/ui/test_main_dashboard.gd index a72e191..d175b17 100644 --- a/tests/ui/test_main_dashboard.gd +++ b/tests/ui/test_main_dashboard.gd @@ -1,7 +1,6 @@ extends SceneTree -const MANUAL_SAVE_PATH := "user://saves/manual_ui" - +var _manual_save_path := "user://tests/main_dashboard_%d/manual_ui" % OS.get_process_id() var _checks := 0 var _failures := 0 var _main: Control @@ -31,6 +30,7 @@ func _run() -> void: "project uses DPI-aware canvas scaling for high-resolution displays" ) _main = load("res://scenes/main/main.tscn").instantiate() as Control + _main.set("manual_save_path", _manual_save_path) root.add_child(_main) await process_frame await process_frame @@ -150,7 +150,7 @@ func _run() -> void: if is_instance_valid(_main): _main.queue_free() - SaveService.delete_save(MANUAL_SAVE_PATH) + SaveService.delete_save(_manual_save_path) if _failures == 0: print("PASS: %d checks" % _checks) else: @@ -416,7 +416,7 @@ func _promote_rank_two() -> bool: func _test_manual_save_and_load() -> void: - SaveService.delete_save(MANUAL_SAVE_PATH) + SaveService.delete_save(_manual_save_path) _main.call("_save_manual") var status := _main.get("build_status_label") as Label _expect(status != null and "保存しました" in status.text, "manual save button writes the runtime snapshot") diff --git a/tests/ui/test_staff_management.gd b/tests/ui/test_staff_management.gd new file mode 100644 index 0000000..0d92f1d --- /dev/null +++ b/tests/ui/test_staff_management.gd @@ -0,0 +1,287 @@ +extends SceneTree + +## Real Runtime commands through the staff panel controls, including queued +## assignment, draft preservation, a rejected role, and pending Save restoration. +var _checks := 0 +var _failures := 0 +var _main: Control +var _panel: StaffManagementPanel +var _runtime: Variant +var _clock: Variant +var _receptions: Array[StringName] = [] +var _kitchen: StringName = &"" +var _save_path := "" + + +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(false) + root.size = Vector2i(1280, 720) + _main = load("res://scenes/main/main.tscn").instantiate() as Control + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect(_clock.paused, "a truly new opening pauses before the first construction") + await process_frame + await process_frame + _panel = _main.find_child("StaffManagementPanel", true, false) as StaffManagementPanel + _expect(_panel != null, "normal UI includes a separate staff management panel") + if _panel == null: + _finish() + return + for request: Array in [ + [&"reception", Vector3i(-8, 0, 0)], + [&"reception", Vector3i(2, 0, 0)], + [&"kitchen", Vector3i(2, 0, 1)], + ]: + _main.call("_select_build_definition", request[0]) + _main.call("_on_map_cell_clicked", request[1]) + _clock.advance_minutes(180) + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + if facility.definition_id == &"reception": + _receptions.append(facility.instance_id) + elif facility.definition_id == &"kitchen": + _kitchen = facility.instance_id + _expect(_receptions.size() == 2 and not _kitchen.is_empty(), "three real facilities are ready for assignment tests") + if _receptions.size() != 2 or _kitchen.is_empty(): + _finish() + return + _main.call("_unhandled_key_input", _key(KEY_H)) + _expect((_main.get("staff_candidate_panel") as Control).visible, "H opens the unchanged hiring candidates") + for candidate: StringName in [&"staff_akane", &"staff_bardo"]: + var hire := (_main.get("staff_candidate_buttons") as Dictionary).get(candidate) as Button + hire.pressed.emit() + (_main.get("staff_management_toggle_button") as Button).pressed.emit() + _expect(_panel.visible and not (_main.get("staff_candidate_panel") as Control).visible, + "hired-staff tab replaces the candidates without hiding the management panel") + _expect(_panel.roster_buttons.size() == 2 and "未配属 2人" in _panel.summary_label.text, + "roster shows both real employees and unassigned count") + _panel.select_staff(&"staff_akane") + _choose_facility(_receptions[0]) + _panel.apply_button.pressed.emit() + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0], + "paused Apply assigns the receptionist to the chosen completed facility") + _expect("変更しました" in _panel.result_label.text and "毎日" in _panel.staff_detail_label.text, + "assignment success and recurring schedule are visible in Japanese") + _test_rejected_assignment() + _test_queued_assignment_preserves_other_draft() + _test_save_pending_assignment() + _test_guide_respects_current_assignments() + await _test_navigation_and_layout() + await _test_restored_opening_does_not_pause() + _finish() + + +func _test_rejected_assignment() -> void: + var before: Dictionary = _assignment(&"staff_akane").duplicate(true) + _choose_facility(_kitchen) + _panel.apply_button.pressed.emit() + _expect(_assignment(&"staff_akane") == before, "wrong-role assignment never mutates the existing reception schedule") + _expect("役割" in _panel.result_label.text and "役割" in (_main.get("build_status_label") as Label).text, + "rejected assignment shows its actionable Japanese reason in both panel and status") + _expect(_chosen_facility() == _kitchen, "a rejected assignment preserves the attempted facility for correction") + + +func _test_queued_assignment_preserves_other_draft() -> void: + _choose_facility(_receptions[1]) + _clock.set_paused(false) + _panel.apply_button.pressed.emit() + var queued: Array = _runtime.command_queue.create_snapshot().get("pending", []) + _expect(queued.size() == 1 and _panel.apply_button.disabled, "running-clock Apply queues one assignment and disables repeated submission") + _panel.submit_assignment() + _expect((_runtime.command_queue.create_snapshot().get("pending", []) as Array).size() == 1, + "repeated submit cannot create a second assignment for the same employee") + _panel.select_staff(&"staff_bardo") + _choose_facility(_receptions[0]) + _panel.shift_option.select(1) + _panel.shift_option.item_selected.emit(1) + _clock.advance_minutes(1) + _clock.set_paused(true) + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[1], "queued reassignment commits on the command tick") + _expect(_panel.selected_staff_id == &"staff_bardo" and _chosen_facility() == _receptions[0] + and _panel.shift_option.selected == 1, "another employee's pending completion preserves the active facility and night-shift draft") + _panel.apply_button.pressed.emit() + _expect(_assignment(&"staff_bardo").get(&"template_id") == &"NIGHT", "night selector creates an actual recurring night schedule") + _expect("18:00" in _panel.result_label.text and "反映" in _panel.staff_detail_label.text, + "future night shift clearly reports when it begins") + _panel.select_staff(&"staff_akane") + _choose_facility(&"") + _panel.apply_button.pressed.emit() + _expect(StringName(_assignment(&"staff_akane").get(&"facility_id", &"missing")).is_empty(), + "unassigned option removes the facility duty through Runtime") + + +func _test_save_pending_assignment() -> void: + _choose_facility(_receptions[0]) + _clock.set_paused(false) + _panel.apply_button.pressed.emit() + _save_path = "user://tests/staff-management-%d" % OS.get_process_id() + var saved: Dictionary = _runtime.save_to_path(_save_path) + _expect(bool(saved.get(&"ok", false)), "pending UI assignment saves to an isolated test slot") + _clock.advance_minutes(1) + var restored: Dictionary = _runtime.load_from_path(_save_path) + _expect(bool(restored.get(&"ok", false)), "pending UI assignment can be restored") + _panel.refresh() + _expect(_panel.apply_button.disabled and not (_panel.get("_pending_commands") as Dictionary).is_empty(), + "restored command metadata restores disabled pending controls") + var sequence_before := int(_panel.get("_sequence")) + _clock.advance_minutes(1) + _clock.set_paused(true) + _panel.refresh() + _expect(_assignment(&"staff_akane").get(&"facility_id") == _receptions[0] + and (_panel.get("_pending_commands") as Dictionary).is_empty(), + "restored assignment completes once and releases its UI pending state") + _choose_facility(_receptions[1]) + _panel.apply_button.pressed.emit() + _expect(int(_panel.get("_sequence")) > sequence_before + and _assignment(&"staff_akane").get(&"facility_id") == _receptions[1], + "post-load Apply uses a fresh command key and changes the real assignment") + + +func _test_guide_respects_current_assignments() -> void: + # Remove both saved plans so an unrelated receptionist cannot satisfy the + # guide while this employee's assignment is being changed. + for staff_id: StringName in [&"staff_akane", &"staff_bardo"]: + _panel.select_staff(staff_id) + _choose_facility(&"") + _panel.apply_button.pressed.emit() + _build_guide_part(&"standard_room", Vector3i(-8, 2, 0)) + _build_guide_part(&"standard_room", Vector3i(-6, 2, 0)) + _build_guide_part(&"guest_corridor", Vector3i(-4, 1, 0)) + _clock.advance_minutes(60) + _build_guide_part(&"guest_corridor", Vector3i(-4, 2, 0)) + _clock.advance_minutes(10) + for x: int in range(-8, -3): + _build_guide_part(&"guest_corridor", Vector3i(x, 3, 0)) + _clock.advance_minutes(10) + var accessible_room := false + for facility: FacilityInstance in _runtime.facility_service.get_instances(): + accessible_room = accessible_room or (facility.definition_id == &"standard_room" and facility.access_available) + var guide := _main.find_child("InnOpeningGuide", true, false) as InnOpeningGuide + _expect(accessible_room and guide.get_progress().get(&"stage") == &"staff", + "real accessible room still requires a reception assignment before first sale") + _main.call("_activate_management_mode", &"staff") + _panel.select_staff(&"staff_akane") + _choose_facility(_receptions[0]) + _panel.shift_option.select(0) + _panel.shift_option.item_selected.emit(0) + _panel.apply_button.pressed.emit() + _expect(guide.get_progress().get(&"stage") == &"sale", + "day assignment advances the accessible inn to waiting for its first sale") + _choose_facility(&"") + _panel.apply_button.pressed.emit() + var historical_reception_shift := false + for shift: ShiftInstance in _runtime.staff_service.get_shifts(): + if shift.staff_id == &"staff_akane" and shift.assigned_facility_id == _receptions[0]: + historical_reception_shift = historical_reception_shift or shift.cancelled or shift.end_at <= _clock.absolute_minute + _expect(historical_reception_shift and guide.get_progress().get(&"stage") == &"staff", + "unassigning reception returns guidance to staffing despite past or cancelled reception shifts") + _choose_facility(_receptions[0]) + _panel.shift_option.select(1) + _panel.shift_option.item_selected.emit(1) + _panel.apply_button.pressed.emit() + var night_plan: Dictionary = _assignment(&"staff_akane") + var reception: FacilityInstance = _runtime.facility_service.get_instance(_receptions[0]) + _expect(night_plan.get(&"template_id") == &"NIGHT" and int(night_plan.get(&"effective_from", -1)) > _clock.absolute_minute + and reception.active_staff_ids.is_empty(), "night assignment is genuinely future duty with nobody staffing reception during daytime") + _expect(guide.get_progress().get(&"stage") == &"sale", + "saved future night duty satisfies staffing guidance before its shift starts") + + +func _build_guide_part(definition_id: StringName, origin: Vector3i) -> void: + var before: int = _runtime.construction_service.get_jobs().size() + _main.call("_select_build_definition", definition_id) + _main.call("_on_map_cell_clicked", origin) + _expect(_runtime.construction_service.get_jobs().size() == before + 1, + "guide fixture builds %s through UI at %s" % [definition_id, origin]) + + +func _test_navigation_and_layout() -> void: + _main.call("_clear_active_tool") + _expect(not _panel.visible, "Escape behavior closes the hired staff pane") + _main.call("_activate_management_mode", &"ledger") + var ledger := _main.find_child("OperatingLedgerPanel", true, false) as Control + _expect(ledger.visible and not (_main.get("operations_controls") as Control).visible, + "ledger navigation displays the actual ledger while hiding assignment controls") + _main.call("_unhandled_key_input", _key(KEY_H)) + _expect(not ledger.visible and (_main.get("staff_candidate_panel") as Control).visible, + "H returns from the ledger to hiring candidates") + _main.call("_activate_management_mode", &"staff") + await process_frame + await process_frame + var guide := _main.find_child("InnOpeningGuide", true, false) as Control + var side := _main.find_child("SidePanel", true, false) as Control + _expect(not guide.get_global_rect().intersects(side.get_global_rect()), + "1280x720 keeps opening guidance and staff inspector apart") + _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, + "staff roster and assignment controls fit the compact sidebar width") + if OS.get_environment("THE_TREE_STAFF_UI_CAPTURE") == "1": + await RenderingServer.frame_post_draw + root.get_texture().get_image().save_png("/tmp/the-tree-staff-management.png") + + +func _test_restored_opening_does_not_pause() -> void: + _main.queue_free() + await process_frame + _clock.reset(1, 6, 0) + var state: Variant = root.get_node("GameState") + state.reset() + _runtime.initialize(_clock, state, 824242) + _clock.set_paused(false) + var snapshot: Dictionary = _runtime.create_snapshot() + _expect(_runtime.restore_snapshot(snapshot), "restored opening fixture accepts its empty initial save") + _main = load("res://scenes/main/main.tscn").instantiate() as Control + root.add_child(_main) + _runtime.configure_save_policy({&"enabled": false, &"daily_checkpoint_enabled": false}) + _expect(not _clock.paused and int(_clock.absolute_minute) == 360, + "mounting a restored empty opening preserves its saved running state and time") + _clock.set_paused(true) + + +func _assignment(staff_id: StringName) -> Dictionary: + return _runtime.staff_service.get_work_assignment(staff_id) + + +func _choose_facility(facility_id: StringName) -> void: + for index: int in _panel.facility_option.item_count: + if StringName(_panel.facility_option.get_item_metadata(index)) == facility_id: + _panel.facility_option.select(index) + _panel.facility_option.item_selected.emit(index) + return + _expect(false, "requested facility is available in the UI: %s" % facility_id) + + +func _chosen_facility() -> StringName: + return StringName(_panel.facility_option.get_item_metadata(_panel.facility_option.selected)) + + +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: + if not _save_path.is_empty(): + SaveService.delete_save(_save_path) + if is_instance_valid(_main): + _main.queue_free() + print("Staff management UI: %d checks, %d failures" % [_checks, _failures]) + quit(1 if _failures > 0 else 0) diff --git a/tests/ui/test_staff_management.gd.uid b/tests/ui/test_staff_management.gd.uid new file mode 100644 index 0000000..fea9625 --- /dev/null +++ b/tests/ui/test_staff_management.gd.uid @@ -0,0 +1 @@ +uid://b3t73d1gjiac7