using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Text; using UnityEditor; using UnityEditor.SceneManagement; using UnityEngine; // System.Diagnostics をまるごと using すると Debug が UnityEngine.Debug と // ぶつかるので、要る型だけ別名で入れる using Process = System.Diagnostics.Process; namespace ServiceSim.EditorTools { /// /// 段階B(依頼 v7。2026-10-23):18回まとめて回して、台帳に1行ずつ残す。 /// /// 回すもの: /// サイトの条件 6 つ(③ fam_p1・⑦ fam_p5 × ロボット 0・1・2 台)× 種 0・1・2 = 18 回。 /// 決まりは固定版のまま。条件ごとに .asset へ入れた値は、ExperimentRunner と SiteValues が /// 回し終わりに必ず元へ戻す(途中で止めても戻す)。 /// /// 回す順番: /// ③ロボット2台 種0 → ⑦ロボット2台 種0 → 残りを条件・種の順。 /// **最初の2回で失敗したら、残りは回さずに止める**。 /// /// 回しっぱなしにするための作り: /// ・1回ごとに Play を出入りする(前の回の人・ロボット・NavMesh を持ち越さない)。 /// Play をまたいで消えないよう、進みは EditorPrefs に置く /// ・1回の実時間の上限は ExperimentRunner.maxWallSecondsPerRun(40 分)。 /// 超えた回は書き出して stopped で台帳に残し、次へ進む /// ・回しているあいだは caffeinate で Mac のスリープを止める(終わったら戻す) /// ・途中で止めても、もう一度押せば **台帳を見て** まだ回していない回から続ける /// /// シミュレーションの動きは1行も変えない。入れたのは「まとめて回す仕組み」と /// 「台帳への書き出し」だけ。 /// public static class ServiceSimStageB { // 店の形の場所は ServiceSimSetup に1つだけ置く // (画面なしの実行ファイルも同じ所を見る。依頼 v12 の 2 章・2026-10-26) const string P1Path = ServiceSimSetup.P1LayoutPath; const string P5Path = ServiceSimSetup.P5LayoutPath; /// 1回の実時間の上限(秒)。依頼 v7 の 3 // 1回の実時間の上限。値は ExperimentRunner に1つだけ置く // (画面なしの実行ファイルと同じ値を使うため。依頼 v12 の 2 章・2026-10-26) const float MaxWallSecondsPerRun = ExperimentRunner.DefaultMaxWallSecondsPerRun; /// 最初の何回かが失敗したら、残りを回さずに止める const int FailFastRuns = 2; const string LogFileName = "run_all_log.txt"; /// /// まとめて回すときの名札(依頼 v11 の 2・3 B 3)。 /// 出力フォルダの名前に入り、台帳の「もう回したか」の照合もこの名札ごとに分ける。 /// stageB … 段階Bの18回(基準データを作る回) /// split_check … プロジェクトを分けたあとの「同じ結果が出るか」の確かめ /// 名札を分けてあるので、split_check を回しても段階Bの18回が「済」にならない /// const string LabelStageB = "stageB"; const string LabelSplitCheck = "split_check"; /// /// 同じ種でずれる元の切り分け(依頼 v11 の 5 章)。 /// 名札を分けてあるので、段階Bの18回とも split_check とも混ざらない /// const string LabelDetCheck = "det_check"; /// /// 道探しの枠(pathfindingIterationsPerFrame)が結果を動かすかの確かめ(2026-10-26)。 /// 段階Bの18回(⑦)で待ちが短く出たので、枠 100 と 5000 で同じ回を比べる /// const string LabelPathCheck = "path_check"; /// /// ロボットどうしのすれ違い(依頼 v13 の 依頼1)。 /// すれ違いの走り方を入れた回と切った回で名札を分けるので、 /// 出力フォルダ名も台帳の照合も混ざらない(段階Bの18回も「済」にならない) /// const string LabelPassOn = "pass_on"; const string LabelPassOff = "pass_off"; /// /// このプロジェクトの Unity Version Control のリポジトリ名に入っているはずの字。 /// cm status が返したリポジトリ名にこれが無ければ、 /// 1つ上のワークスペース(Robotics_UX_New)の版を見てしまっているので回さない(依頼 v11 の 2) /// const string ExpectRepoName = "ServiceSim"; /// EditorPrefs にためるログの区切り(1文字。行の中に出ない字を使う) const char LogSep = '\u001f'; // ---- Play をまたいで持つ進み(EditorPrefs)---- const string KeyActive = "ServiceSim.StageB.active"; const string KeyIndex = "ServiceSim.StageB.index"; const string KeyConds = "ServiceSim.StageB.conds"; // "2:0;5:0;..." const string KeyFolders = "ServiceSim.StageB.folders"; // ";" 区切り const string KeyUnityCs = "ServiceSim.StageB.unityCs"; const string KeyPending = "ServiceSim.StageB.pending"; const string KeyLog = "ServiceSim.StageB.log"; const string KeyRunStart = "ServiceSim.StageB.runStart"; const string KeyCaffeinate = "ServiceSim.StageB.caffeinate"; const string KeyBatchStart = "ServiceSim.StageB.batchStart"; const string KeyLabel = "ServiceSim.StageB.label"; // ---------------- メニュー ---------------- [MenuItem("ServiceSim/Stage B/Run All(18回)", false, 200)] public static void RunAll() { List condIdx; List seeds; BuildOrder(out condIdx, out seeds); StartBatch("段階B", LabelStageB, condIdx, seeds, true); } /// /// プロジェクトを分けたあとの「同じ結果が出るか」の確かめ(依頼 v11 の 3 B 3)。 /// /// 段階Bの18回のうち ③ ロボット1台・種1 だけを 2 回回す。 /// 同じ種で同じ数字が出るか(2回が同じか)と、 /// 基準データ v2 の同じ回(20261025_stageB_cs144_fam_p1_r1_s1)と同じかを見る。 /// /// 名札が split_check なので、出力フォルダは /// {日付}_split_check_cs{版}_fam_p1_r1_s1(2回めは末尾に _2) /// になり、段階Bの18回とは混ざらない。 /// 台帳の「もう回したか」も名札ごとなので、 /// この確かめのあとで Run All を押しても18回とも「まだ」のまま。 /// 回し方・記録・条件の中身は段階Bとまったく同じ /// [MenuItem("ServiceSim/Stage B/移せたかの確かめ(③1台・種1 を2回)", false, 203)] public static void RunSplitCheck() { // 条件 1 = ③(fam_p1)・ロボット 1 台(unity_conditions の並び)。種は 1 List condIdx = new List { 1, 1 }; List seeds = new List { 1, 1 }; StartBatch("移せたかの確かめ", LabelSplitCheck, condIdx, seeds, false); } /// /// 同じ種でずれる元の切り分け(依頼 v11 の 5 章)。③1台・種1 を2回回す。 /// /// どの試しになるかは ExperimentPlan の det_check の切り替えで決まります /// (試し1 = ExperimentPlan.pathIterationsPerFrame、試し2 = detCheckSyncPeoplePaths、 /// 試し4 = detCheckNoAvoidance)。ここは回すだけで、切り替えは触りません。 /// /// 出力フォルダは {日付}_det_check_cs{版}_fam_p1_r1_s1(2回めは _2)。 /// 名札が det_check なので、段階Bの18回とも split_check とも混ざりません。 /// 入れた切り替えは run_info.json の det_check に残ります /// [MenuItem("ServiceSim/Stage B/ずれの切り分け(③1台・種1 を2回)", false, 204)] public static void RunDetCheck() { List condIdx = new List { 1, 1 }; List seeds = new List { 1, 1 }; StartBatch("ずれの切り分け", LabelDetCheck, condIdx, seeds, false); } // ---------------- すれ違い(依頼 v13 の 依頼1)---------------- /// /// すれ違いの確かめ(依頼 v13 の 依頼1)。③(fam_p1)ロボット2台・スタッフ4・種1 を1回。 /// /// ロボット1台の条件ではすれ違いが起きないので2台で回す。 /// 押した方(入/切)に合わせて RobotSpec.passByEnabled を書いてから回すので、 /// 切り替えと名札が食い違わない。 /// 出力フォルダは {日付}_pass_on_cs{版}_fam_p1_r2_s1(切は pass_off)。 /// 入れた値は run_info.json の pass_by に残る /// [MenuItem("ServiceSim/Stage B/すれ違い(③2台・種1)/入で1回回す", false, 230)] public static void RunPassOn() { RunPassCheck(true); } [MenuItem("ServiceSim/Stage B/すれ違い(③2台・種1)/切で1回回す", false, 231)] public static void RunPassOff() { RunPassCheck(false); } [MenuItem("ServiceSim/Stage B/すれ違い(③2台・種1)/いまの設定を見る", false, 232)] public static void PassByShow() { RobotSpec spec = LoadRobotSpec(); if (spec != null) Debug.Log("[ServiceSim] すれ違い: " + PassByNote(spec)); } static void RunPassCheck(bool on) { RobotSpec spec = LoadRobotSpec(); if (spec == null) return; if (spec.passByEnabled != on) { spec.passByEnabled = on; EditorUtility.SetDirty(spec); AssetDatabase.SaveAssets(); } Debug.Log("[ServiceSim] すれ違いの走り方を " + (on ? "入" : "切") + " にしました\n " + PassByNote(spec)); // 条件 2 = ③(fam_p1)・ロボット 2 台(unity_conditions の並び)。種は 1 List condIdx = new List { 2 }; List seeds = new List { 1 }; StartBatch("すれ違いの確かめ(" + (on ? "入" : "切") + ")", on ? LabelPassOn : LabelPassOff, condIdx, seeds, false); } /// いまのすれ違いの設定を、人が読める1行にする static string PassByNote(RobotSpec spec) { if (spec == null) return "(RobotSpec を読めません)"; if (!spec.passByEnabled) return "切(cs:3 と同じ動き)"; return "入 / 見る距離 " + spec.passByDistanceM + " m / すれ違い中の速さ " + spec.passBySpeedMps + " m/秒 / 体どうしの間 " + spec.passBySeparationM + " m(右へ " + spec.PassByShiftM.ToString("0.##") + " m 寄る)"; } static RobotSpec LoadRobotSpec() { RobotSpec s = AssetDatabase.LoadAssetAtPath(ServiceSimSetup.RobotSpecPath); if (s == null) Debug.LogError("[ServiceSim] RobotSpec が見つかりません"); return s; } /// /// 道探しの枠が結果を動かすかの確かめ(2026-10-26)。 /// ⑦(fam_p5)ロボット0台・種0・1・2 を3回回す。 /// /// 段階Bの18回(cs:7)で、⑦が移す前(cs:144)より9回とも待ちが短く出た。 /// 昼の切り分けで入れた枠 5000 が、エディタを閉じるまで残っていた疑いがあるため、 /// 枠 100 と 5000 で同じ3回を回して並べる。ロボット0台でも出ているので、 /// ロボットではなく人(スタッフ)の動きを見る条件を選んでいる。 /// /// 枠は ExperimentPlan.pathIterationsPerFrame で決まる(切り替えメニューで変える)。 /// 入った値は run_info.json の path_iterations_per_frame に出るので、 /// あとから「どちらの枠で回したか」が必ず分かる。 /// 名札が path_check なので、段階Bの18回とは混ざらない /// [MenuItem("ServiceSim/Stage B/道探しの枠の確かめ(⑦0台・種0〜2)", false, 205)] public static void RunPathCheck() { // 条件 3 = ⑦(fam_p5)・ロボット 0 台(unity_conditions の並び) List condIdx = new List { 3, 3, 3 }; List seeds = new List { 0, 1, 2 }; StartBatch("道探しの枠の確かめ", LabelPathCheck, condIdx, seeds, false); } /// /// まとめて回し始める(Run All と、移せたかの確かめで共通)。 /// /// skipDone が true なら、同じ名札・同じ版・同じ条件・同じ種の行が台帳にある回を飛ばす。 /// 確かめは同じ条件を 2 回回すので false にする(飛ばさない)。 /// 回し方(1回ごとに Play を出入りする・実時間の上限・caffeinate)は同じ /// static void StartBatch(string title, string label, List condIdx, List seeds, bool skipDone) { ExperimentRunner runner; ExperimentPlan plan; if (!ServiceSimRunSupport.Prepare(out runner, out plan)) return; // --- 押す前の確かめ(合わなければ回し始めない)--- if (!CheckBeforeRun(plan, label, out string why)) { Debug.LogError("[ServiceSim] " + title + ":回し始めません\n" + why); return; } SimParams sp = LoadParams(); if (sp == null) return; if (sp.Conditions.Count != 6) { Debug.LogError("[ServiceSim] " + title + ":unity_conditions が 6 件ではありません(" + sp.Conditions.Count + " 件)。params を確かめてください"); return; } string ledger = ServiceSimLedger.PathOf(plan); string day = DateTime.Now.ToString("yyyyMMdd"); // --- 回し始めの版(照合キーに入れるので先に読む。依頼 v8 の 3)--- string cs, pendingNote; ReadChangeset(out cs, out pendingNote); // 照合は「店・台数・種・版」。出力フォルダ名(日付入り)では照らし合わせない。 // 名札ごとに分けるので、確かめの回が段階Bの18回を「済」にしない(依頼 v11) HashSet done = skipDone ? ServiceSimLedger.DoneKeys(ledger, label) : new HashSet(); List conds = new List(); List folders = new List(); int skipped = 0; for (int i = 0; i < condIdx.Count; i++) { SimParams.Condition c = sp.ConditionAt(condIdx[i]); if (c == null) { Debug.LogError("[ServiceSim] " + title + ":条件 " + condIdx[i] + " がありません"); return; } if (done.Contains(ServiceSimLedger.RunKey(c.store, c.robots, seeds[i], cs))) { skipped++; continue; } conds.Add(condIdx[i] + ":" + seeds[i]); folders.Add(FreeFolderName(plan, day, label, cs, c, seeds[i], folders)); } if (conds.Count == 0) { Debug.Log("[ServiceSim] " + title + ":この版(" + cs + ")の " + condIdx.Count + " 回はぜんぶ台帳にあります(" + skipped + " 回を飛ばしました)。" + "\n やり直すときは台帳の行を見て、エイイチに相談してください" + "\n 台帳: " + ledger); return; } EditorPrefs.SetBool(KeyActive, true); EditorPrefs.SetInt(KeyIndex, 0); EditorPrefs.SetString(KeyConds, string.Join(";", conds.ToArray())); EditorPrefs.SetString(KeyFolders, string.Join(";", folders.ToArray())); EditorPrefs.SetString(KeyUnityCs, cs); EditorPrefs.SetString(KeyPending, pendingNote); EditorPrefs.SetString(KeyLog, ""); EditorPrefs.SetString(KeyBatchStart, day); EditorPrefs.SetString(KeyLabel, label); StartCaffeinate(); Debug.Log("[ServiceSim] " + title + ":" + conds.Count + " / " + condIdx.Count + " 回を回します" + (skipped > 0 ? "(台帳にある " + skipped + " 回は飛ばします)" : "") + "\n 名札: " + label + "\n Unity の版: " + cs + (pendingNote.Length > 0 ? "(" + pendingNote + ")" : "") + "\n 台帳: " + ledger + "\n 1回の実時間の上限: " + (MaxWallSecondsPerRun / 60f).ToString("0") + " 分" + "\n 1回ごとに Play を出入りします。止めたいときは、Play を抜けたところで" + " ServiceSim/Stage B/いまの回で止める を押してください" + "\n ずれの切り分けの切り替え: " + DetCheckNote(plan) + "\n すれ違い(依頼 v13): " + PassByNote(LoadRobotSpec()) + "\n 置き場所: " + string.Join(" / ", folders.ToArray())); StartNext(); } [MenuItem("ServiceSim/Stage B/押す前の確かめ(回さない)", false, 201)] public static void CheckOnly() { ExperimentPlan plan = AssetDatabase.LoadAssetAtPath( ServiceSimSetup.ExperimentPlanPath); if (plan == null) { Debug.LogError("[ServiceSim] ExperimentPlan が見つかりません"); return; } string why; bool ok = CheckBeforeRun(plan, out why); string ledger = ServiceSimLedger.PathOf(plan); HashSet doneFolders = ServiceSimLedger.DoneFolders(ledger); StringBuilder sb = new StringBuilder(); sb.AppendLine("[ServiceSim] 段階B:押す前の確かめ " + (ok ? "→ 回せます" : "→ 回せません")); if (!ok) sb.AppendLine(why); sb.AppendLine(" 台帳: " + ledger); sb.AppendLine(" 台帳にある回(ぜんぶの版): " + doneFolders.Count + " 件"); sb.AppendLine(" 列(" + ServiceSimLedger.Columns.Length + " 列・台帳の版 " + ServiceSimLedger.SchemaVersion + "): " + ServiceSimLedger.HeaderLine); // --- 試算の版・店の形・レジの数(依頼 v10b の 3 B 2)--- SimParams sp = LoadParams(); if (sp == null) { sb.AppendLine(" 試算の値を読めませんでした(回せません)"); Debug.LogError(sb.ToString().TrimEnd()); return; } sb.AppendLine(" 試算の版 (meta.model): " + sp.Model + " / 店の形の版 (meta.layout_generation): " + sp.LayoutGeneration); sb.AppendLine(" 条件: " + sp.Conditions.Count + " 件 / レジ: " + RegistersNote(sp)); if (sp.Problems.Count > 0) { ok = false; sb.AppendLine(" 合わないもの: " + sp.Problems.Count + " 件"); for (int i = 0; i < sp.Problems.Count; i++) sb.AppendLine(" - " + sp.Problems[i]); } else sb.AppendLine(" 合わないもの: なし"); // --- 18回が「済」か「まだ」か(この版での照合。Run All と同じ見方)--- string cs, pendingNote; ReadChangeset(out cs, out pendingNote); // 照合は名札(stageB)ごと。移せたかの確かめ(split_check)の回は数えない(依頼 v11) HashSet doneKeys = ServiceSimLedger.DoneKeys(ledger, LabelStageB); List condIdx; List seeds; BuildOrder(out condIdx, out seeds); int todo = 0; StringBuilder list = new StringBuilder(); for (int i = 0; i < condIdx.Count; i++) { SimParams.Condition c = sp.ConditionAt(condIdx[i]); if (c == null) continue; bool had = doneKeys.Contains( ServiceSimLedger.RunKey(c.store, c.robots, seeds[i], cs)); if (!had) todo++; list.AppendLine(" " + (i + 1).ToString("00") + ". " + c.store + "・ロボット " + c.robots + " 台・種 " + seeds[i] + " … " + (had ? "済(台帳にあるので飛ばします)" : "まだ")); } sb.AppendLine(" Unity の版: " + cs + (pendingNote.Length > 0 ? "(" + pendingNote + ")" : "(保留なし)")); // 版が分からないまま回すと、台帳の unity_cs が「不明」になって // どの版で回したか追えなくなる(依頼 v11 の 2) if (cs.IndexOf("不明", StringComparison.Ordinal) >= 0) { ok = false; sb.AppendLine(" ・版が分かりません。このプロジェクトを Unity Version Control の" + "ワークスペースにして、check-in してから回してください"); } sb.AppendLine(" この版の18回(名札 " + LabelStageB + "): まだ " + todo + " 回 / 済 " + (condIdx.Count - todo) + " 回"); sb.Append(list.ToString().TrimEnd('\n')); sb.AppendLine(); sb.AppendLine(" 移せたかの確かめ(名札 " + LabelSplitCheck + ")は、この18回の「済/まだ」に入りません"); if (ok) Debug.Log(sb.ToString().TrimEnd()); else Debug.LogError(sb.ToString().TrimEnd()); } // ---------------- ずれの切り分けの切り替え(依頼 v11 の 5 章。試しだけ)---------------- /// いま入っている切り替えを、人が読める1行にする static string DetCheckNote(ExperimentPlan plan) { if (plan == null) return "(ExperimentPlan を読めません)"; // 道探しの枠は切り替えではなく「回すたびに入れる値」なので、いつでも出す(2026-10-26) string head = "道探しの枠 " + plan.pathIterationsPerFrame + " 回/コマ" + (plan.pathIterationsPerFrame == 100 ? "(既定)" : "(既定は 100)"); if (!plan.detCheckSyncPeoplePaths && !plan.detCheckNoAvoidance && !plan.detCheckFrameFingerprint) return head + " / ほかの切り替えはぜんぶ切"; return head + " / 人の道を同期 " + (plan.detCheckSyncPeoplePaths ? "入" : "切") + " / 局所回避を切る " + (plan.detCheckNoAvoidance ? "入" : "切") + " / 1コマごとの指紋 " + (plan.detCheckFrameFingerprint ? "入" : "切"); } static ExperimentPlan LoadPlan() { ExperimentPlan p = AssetDatabase.LoadAssetAtPath( ServiceSimSetup.ExperimentPlanPath); if (p == null) Debug.LogError("[ServiceSim] ExperimentPlan が見つかりません"); return p; } static void SetDetCheck(string title, int pathIterations, bool syncPaths, bool noAvoidance, bool fingerprint) { ExperimentPlan p = LoadPlan(); if (p == null) return; p.pathIterationsPerFrame = pathIterations > 0 ? pathIterations : 100; p.detCheckSyncPeoplePaths = syncPaths; p.detCheckNoAvoidance = noAvoidance; p.detCheckFrameFingerprint = fingerprint; EditorUtility.SetDirty(p); AssetDatabase.SaveAssets(); Debug.Log("[ServiceSim] ずれの切り分け:" + title + "\n " + DetCheckNote(p) + "\n このまま ServiceSim/Stage B/ずれの切り分け(③1台・種1 を2回)を押してください"); } [MenuItem("ServiceSim/Stage B/切り分けの設定/試し1:道探しの枠を 5000 にする", false, 220)] public static void DetCheck1() { SetDetCheck("試し1", 5000, false, false, true); } [MenuItem("ServiceSim/Stage B/切り分けの設定/試し2:人の道を同期で引く", false, 221)] public static void DetCheck2() { SetDetCheck("試し2", 5000, true, false, true); } [MenuItem("ServiceSim/Stage B/切り分けの設定/試し4:局所回避も切る(要るときだけ)", false, 222)] public static void DetCheck4() { SetDetCheck("試し4", 5000, true, true, true); } [MenuItem("ServiceSim/Stage B/切り分けの設定/ぜんぶ切る(枠 100・ほかは切)", false, 223)] public static void DetCheckOff() { SetDetCheck("ぜんぶ切る", 100, false, false, false); } [MenuItem("ServiceSim/Stage B/切り分けの設定/道探しの枠を 100 にする(既定)", false, 225)] public static void PathIter100() { SetPathIterations(100); } [MenuItem("ServiceSim/Stage B/切り分けの設定/道探しの枠を 5000 にする", false, 226)] public static void PathIter5000() { SetPathIterations(5000); } /// /// 道探しの枠だけを入れ直す(2026-10-26)。 /// ほかの切り替えは触らない。回すたびに ExperimentRunner がこの値を入れるので、 /// エディタの中に前の値が残らない /// static void SetPathIterations(int n) { ExperimentPlan p = LoadPlan(); if (p == null) return; p.pathIterationsPerFrame = n > 0 ? n : 100; EditorUtility.SetDirty(p); AssetDatabase.SaveAssets(); Debug.Log("[ServiceSim] 道探しの枠を " + p.pathIterationsPerFrame + " 回/コマ にしました(既定は 100)" + "\n " + DetCheckNote(p) + "\n 回すたびにこの値が入り、run_info.json の" + " path_iterations_per_frame に実際の値が出ます"); } [MenuItem("ServiceSim/Stage B/切り分けの設定/いまの設定を見る", false, 224)] public static void DetCheckShow() { ExperimentPlan p = LoadPlan(); if (p != null) Debug.Log("[ServiceSim] ずれの切り分けの切り替え: " + DetCheckNote(p)); } /// 6条件のレジの数を短く書く(ぜんぶ同じなら1つだけ) static string RegistersNote(SimParams sp) { if (sp.Conditions.Count == 0) return "(条件なし)"; int first = sp.Conditions[0].registers; bool same = true; for (int i = 1; i < sp.Conditions.Count; i++) if (sp.Conditions[i].registers != first) { same = false; break; } if (same) return first + " つ(6条件とも同じ)"; StringBuilder sb = new StringBuilder(); for (int i = 0; i < sp.Conditions.Count; i++) { if (i > 0) sb.Append(" / "); sb.Append(sp.Conditions[i].registers); } return "ばらばら: " + sb; } [MenuItem("ServiceSim/Stage B/いまの回で止める", false, 202)] public static void StopBatch() { if (!EditorPrefs.GetBool(KeyActive, false)) { Debug.Log("[ServiceSim] 段階B:いま回していません"); return; } Finish("エイイチが止めました"); } [MenuItem("ServiceSim/Stage B/台帳を開く", false, 210)] public static void OpenLedger() { ExperimentPlan plan = AssetDatabase.LoadAssetAtPath( ServiceSimSetup.ExperimentPlanPath); if (plan == null) return; string p = ServiceSimLedger.PathOf(plan); if (!File.Exists(p)) { Debug.LogWarning("[ServiceSim] まだありません: " + p); return; } EditorUtility.RevealInFinder(p); } // ---------------- 押す前の確かめ ---------------- static bool CheckBeforeRun(ExperimentPlan plan, out string why) { return CheckBeforeRun(plan, null, out why); } /// /// 押す前の確かめ。label に名札を渡すと、 /// 測る回(stageB / split_check)では「ずれの切り分けの切り替え」が /// 入ったままかどうかも見る(依頼 v11 の 5 章)。 /// 切り替えが入ったまま測る回を回すと、黙って動きが変わった数字が /// 台帳に入ってしまうので、そこで止める /// static bool CheckBeforeRun(ExperimentPlan plan, string label, out string why) { StringBuilder sb = new StringBuilder(); if (label != null && label != LabelDetCheck && plan != null && (plan.detCheckSyncPeoplePaths || plan.detCheckNoAvoidance)) sb.AppendLine(" ・ずれの切り分けの切り替えが入ったままです(" + DetCheckNote(plan) + ")。" + "測る回は回せません。ServiceSim/Stage B/切り分けの設定/" + "ぜんぶ切る を押してください"); // 1倍で回す切り替え(SimHud)が入っていると、その回は見る専用で // 記録を書き出さない。測る回では必ず止める(依頼 v13 の 依頼2・2026-10-26) SimHud hud = UnityEngine.Object.FindObjectOfType(); if (hud != null && hud.realTimeSpeed) sb.AppendLine(" ・SimHud の「1倍で回す」が入っています。" + "その回は見る専用で記録を書き出さないので、測る回は回せません。" + "シーンの SimHud で Real Time Speed を切ってください"); if (!Directory.Exists(plan.kioxiaVolumePath)) sb.AppendLine(" ・KIOXIA がつながっていません: " + plan.kioxiaVolumePath); if (!Directory.Exists(plan.kioxiaOutputRoot)) sb.AppendLine(" ・置き場所がありません: " + plan.kioxiaOutputRoot + "(MASTER の保存先です。新しく作らずに確かめてください)"); string ledger = ServiceSimLedger.PathOf(plan); string headerWhy; if (!ServiceSimLedger.CanAppend(ledger, out headerWhy)) sb.AppendLine(" ・" + headerWhy.Replace("\n", "\n ")); StoreBuilder builder = UnityEngine.Object.FindObjectOfType(); NavMeshBaker baker = UnityEngine.Object.FindObjectOfType(); if (builder == null || baker == null) sb.AppendLine(" ・StoreBuilder / NavMeshBaker が見つかりません。ServiceSim/Setup を実行してください"); why = sb.ToString().TrimEnd(); return why.Length == 0; } // ---------------- 18回の並び ---------------- /// /// ③ロボット2台 種0 → ⑦ロボット2台 種0 → 残りを条件・種の順。 /// 条件の番号は unity_conditions の並び(0..5) /// static void BuildOrder(out List conds, out List seeds) { conds = new List(); seeds = new List(); // 最初の2回(この2回で失敗したら残りを回さない) Add(conds, seeds, 2, 0); // ③ ロボット2台 種0 Add(conds, seeds, 5, 0); // ⑦ ロボット2台 種0 int[] allSeeds = { 0, 1, 2 }; for (int c = 0; c < 6; c++) for (int s = 0; s < allSeeds.Length; s++) { if (c == 2 && allSeeds[s] == 0) continue; // もう入れた if (c == 5 && allSeeds[s] == 0) continue; Add(conds, seeds, c, allSeeds[s]); } } static void Add(List conds, List seeds, int c, int s) { conds.Add(c); seeds.Add(s); } /// /// 出力フォルダの名前(依頼 v8 の 3・v11 の 2)。 /// {日付}_{名札}_cs{版}_{店}_r{台数}_s{種}。 /// 版が分かるようにしておき、照合は台帳のキー(店・台数・種・版)で行う。 /// /// 版の字は "ServiceSim/cs:1" の形で来るので(依頼 v11 の 2)、 /// フォルダ名に使えない "/" と ":" を外して "cs1" にする。 /// 前のプロジェクトの "cs:144" も同じく "cs144" になり、名前の形は変わらない /// static string FolderName(string day, string label, string cs, SimParams.Condition c, int seed) { string ver = (cs ?? "") .Replace(ServiceSimLedger.CsPrefix, "") // "ServiceSim/" を外す .Replace("cs:", "") .Replace(":", "").Replace("/", "") .Replace("(", "").Replace(")", ""); if (ver.Length == 0) ver = "unknown"; return day + "_" + label + "_cs" + ver + "_" + c.store + "_r" + c.robots + "_s" + seed; } /// /// 書き出す先が既にあるときは上書きせず、末尾に _2・_3 … を付けた /// 新しいフォルダ名を返す(依頼 v8 の 3)。 /// 同じ条件を2回回す確かめ(依頼 v11)では、2回めがこれで _2 になる /// static string FreeFolderName(ExperimentPlan plan, string day, string label, string cs, SimParams.Condition c, int seed, List taken) { string baseName = FolderName(day, label, cs, c, seed); string name = baseName; for (int n = 2; n < 100; n++) { // taken は、この並びでもう使うことに決めた名前(まだフォルダは無い)。 // 同じ条件を2回回すときに、2回めが同じ名前にならないようにする(依頼 v11) bool free = !Directory.Exists(Path.Combine(plan.kioxiaOutputRoot, name)) && (taken == null || !taken.Contains(name)); if (free) return name; name = baseName + "_" + n; } return name; } // ---------------- 1回ずつ回す ---------------- static void StartNext() { if (!EditorPrefs.GetBool(KeyActive, false)) return; string[] conds = Split(EditorPrefs.GetString(KeyConds, "")); string[] folders = Split(EditorPrefs.GetString(KeyFolders, "")); int index = EditorPrefs.GetInt(KeyIndex, 0); if (index >= conds.Length || index >= folders.Length) { Finish("ぜんぶ終わりました"); return; } ExperimentRunner runner; ExperimentPlan plan; if (!ServiceSimRunSupport.Prepare(out runner, out plan)) { Finish("Play の下ごしらえができませんでした"); return; } SimParams sp = LoadParams(); if (sp == null) { Finish("params を読めませんでした"); return; } if (runner.simParams == null) runner.simParams = sp; string[] parts = conds[index].Split(':'); int condIndex = int.Parse(parts[0], CultureInfo.InvariantCulture); int seed = int.Parse(parts[1], CultureInfo.InvariantCulture); SimParams.Condition cond = sp.ConditionAt(condIndex); if (cond == null) { Finish("条件 " + condIndex + " がありません"); return; } string assetPath = cond.store == "fam_p1" ? P1Path : cond.store == "fam_p5" ? P5Path : null; JsonStoreLayout layout = assetPath != null ? AssetDatabase.LoadAssetAtPath(assetPath) : null; if (layout == null) { Finish("店の形が見つかりません: " + cond.store); return; } StoreBuilder builder = UnityEngine.Object.FindObjectOfType(); NavMeshBaker baker = UnityEngine.Object.FindObjectOfType(); if (builder == null || baker == null) { Finish("StoreBuilder / NavMeshBaker がありません"); return; } builder.layout = layout; baker.layout = layout; EditorUtility.SetDirty(builder); EditorUtility.SetDirty(baker); ExperimentRunner.Job job = new ExperimentRunner.Job(); job.siteRun = true; job.conditionIndex = condIndex; job.storeKey = cond.store; job.robots = cond.robots; job.seed = seed; job.aisleWidthIndex = layout.aisleWidthIndex; job.crowd = CrowdLevel.Normal; job.applyDeliveryOffset = false; job.folder = Path.Combine(plan.kioxiaOutputRoot, folders[index]); runner.SetSingleJob(job); runner.timeMode = ExperimentRunner.TimeMode.FixedStep; runner.captureDeltaTimeOverride = 0f; runner.maxWallSecondsPerRun = MaxWallSecondsPerRun; // 回し始めの版と、保留中の変更があったかを run_info に残す(依頼 v7 の 2) runner.unityCs = EditorPrefs.GetString(KeyUnityCs, ""); runner.pendingNote = EditorPrefs.GetString(KeyPending, ""); runner.runLabel = "stageB"; runner.viewMode = false; runner.runOnPlay = true; EditorUtility.SetDirty(runner); EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); EditorPrefs.SetString(KeyRunStart, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture)); Debug.Log("[ServiceSim] " + BatchLabel() + " " + (index + 1) + "/" + conds.Length + " を回します" + "\n 条件 " + condIndex + ":" + cond.Id + "(" + cond.store + "・ロボット " + cond.robots + " 台)/ 種 " + seed + "\n 置き場所: " + job.folder); EditorApplication.EnterPlaymode(); } /// Play を抜けたら、その回を台帳に足して次へ [InitializeOnLoadMethod] static void Hook() { EditorApplication.playModeStateChanged -= OnPlayModeChanged; EditorApplication.playModeStateChanged += OnPlayModeChanged; } static void OnPlayModeChanged(PlayModeStateChange state) { if (state != PlayModeStateChange.EnteredEditMode) return; if (!EditorPrefs.GetBool(KeyActive, false)) return; // Play を抜けた直後にそのまま Play へ入れないので、1 コマ置く EditorApplication.delayCall += AfterRun; } static void AfterRun() { if (!EditorPrefs.GetBool(KeyActive, false)) return; ExperimentPlan plan = AssetDatabase.LoadAssetAtPath( ServiceSimSetup.ExperimentPlanPath); if (plan == null) { Finish("ExperimentPlan が見つかりません"); return; } string[] conds = Split(EditorPrefs.GetString(KeyConds, "")); string[] folders = Split(EditorPrefs.GetString(KeyFolders, "")); int index = EditorPrefs.GetInt(KeyIndex, 0); if (index >= folders.Length) { Finish("ぜんぶ終わりました"); return; } string folder = folders[index]; string runDir = Path.Combine(plan.kioxiaOutputRoot, folder); float wallS = 0f; long ticks; if (long.TryParse(EditorPrefs.GetString(KeyRunStart, "0"), NumberStyles.Integer, CultureInfo.InvariantCulture, out ticks) && ticks > 0) wallS = (float)(DateTime.UtcNow - new DateTime(ticks, DateTimeKind.Utc)).TotalSeconds; string status; string line = WriteLedgerRow(plan, conds[index], folder, runDir, wallS, out status); // ログに1行ためる string log = EditorPrefs.GetString(KeyLog, ""); EditorPrefs.SetString(KeyLog, log.Length > 0 ? log + LogSep + line : line); Debug.Log("[ServiceSim] " + BatchLabel() + " " + (index + 1) + "/" + conds.Length + " おわり: " + line); // 最初の2回で失敗したら、残りは回さない if (status == "failed" && index + 1 <= FailFastRuns) { Finish("最初の " + FailFastRuns + " 回で失敗したので、残りは回しません(" + folder + ")"); return; } EditorPrefs.SetInt(KeyIndex, index + 1); if (index + 1 >= conds.Length) { Finish("ぜんぶ終わりました"); return; } StartNext(); } // ---------------- 台帳に1行 ---------------- /// /// その回の台帳の1行を足す。 /// /// 行を作るのは **LedgerRow**(Experiment/LedgerRow.cs)1つだけ。 /// ここは Editor でしか取れない値(.asset・EditorPrefs)を詰めて渡すだけ。 /// 夜の画面なしの実行(HeadlessRun)も同じ LedgerRow を通る /// (依頼 v12 の 2〜3 章・2026-10-26) /// static string WriteLedgerRow(ExperimentPlan plan, string condSeed, string folder, string runDir, float wallS, out string status) { SimParams sp = LoadParams(); string[] parts = condSeed.Split(':'); int condIndex = int.Parse(parts[0], CultureInfo.InvariantCulture); int seed = int.Parse(parts[1], CultureInfo.InvariantCulture); LedgerRow.Inputs inp = new LedgerRow.Inputs(); inp.simParams = sp; inp.cond = sp != null ? sp.ConditionAt(condIndex) : null; inp.robotSpec = AssetDatabase.LoadAssetAtPath(ServiceSimSetup.RobotSpecPath); inp.peopleSpec = AssetDatabase.LoadAssetAtPath(ServiceSimSetup.PeopleSpecPath); inp.unityCs = EditorPrefs.GetString(KeyUnityCs, ""); inp.runFolder = folder; inp.runDir = runDir; inp.seed = seed; inp.wallS = wallS; inp.ledgerPath = ServiceSimLedger.PathOf(plan); return LedgerRow.BuildAndAppend(inp, out status); } // ---------------- 終わり ---------------- static void Finish(string whyDone) { string[] conds = Split(EditorPrefs.GetString(KeyConds, "")); int index = EditorPrefs.GetInt(KeyIndex, 0); string log = EditorPrefs.GetString(KeyLog, ""); StopCaffeinate(); ExperimentPlan plan = AssetDatabase.LoadAssetAtPath( ServiceSimSetup.ExperimentPlanPath); StringBuilder sb = new StringBuilder(); sb.AppendLine("[ServiceSim] " + BatchLabel() + ":" + whyDone); sb.AppendLine(" 回した: " + index + " / " + conds.Length + " 回"); sb.AppendLine(" 台帳: " + (plan != null ? ServiceSimLedger.PathOf(plan) : "-")); sb.AppendLine(" 一覧(出力フォルダ / status / 実時間 / 入店待ち / ロボットが運んだ割合):"); string[] lines = log.Length > 0 ? log.Split(LogSep) : new string[0]; for (int i = 0; i < lines.Length; i++) sb.AppendLine(" " + (i + 1) + ". " + lines[i]); string text = sb.ToString().TrimEnd(); Debug.Log(text); // run_all_log.txt(service_sim/unity/ の中) if (plan != null && Directory.Exists(plan.kioxiaOutputRoot)) { try { string p = Path.Combine(plan.kioxiaOutputRoot, LogFileName); string head = "=== " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + " " + BatchLabel() + "(Unity " + EditorPrefs.GetString(KeyUnityCs, "") + ")===\n"; File.AppendAllText(p, head + text + "\n\n", new UTF8Encoding(false)); Debug.Log("[ServiceSim] 一覧を書きました: " + p); } catch (Exception e) { Debug.LogWarning("[ServiceSim] run_all_log.txt を書けませんでした: " + e.Message); } } EditorPrefs.SetBool(KeyActive, false); EditorPrefs.DeleteKey(KeyConds); EditorPrefs.DeleteKey(KeyFolders); EditorPrefs.DeleteKey(KeyIndex); EditorPrefs.DeleteKey(KeyLog); EditorPrefs.DeleteKey(KeyRunStart); } // ---------------- スリープを止める ---------------- static void StartCaffeinate() { StopCaffeinate(); try { Process p = new Process(); p.StartInfo.FileName = "/usr/bin/caffeinate"; p.StartInfo.Arguments = "-dimsu"; p.StartInfo.UseShellExecute = false; p.StartInfo.CreateNoWindow = true; p.Start(); EditorPrefs.SetInt(KeyCaffeinate, p.Id); Debug.Log("[ServiceSim] スリープを止めました(caffeinate pid " + p.Id + ")"); } catch (Exception e) { Debug.LogWarning("[ServiceSim] caffeinate を起動できませんでした(スリープしないよう手で設定してください): " + e.Message); } } static void StopCaffeinate() { int pid = EditorPrefs.GetInt(KeyCaffeinate, 0); if (pid <= 0) return; EditorPrefs.DeleteKey(KeyCaffeinate); try { Process p = Process.GetProcessById(pid); if (p != null && !p.HasExited) { p.Kill(); Debug.Log("[ServiceSim] スリープの設定を戻しました"); } } catch (Exception) { // もう終わっている } } // ---------------- 小さな道具 ---------------- /// /// 回し始めの changeset 番号と、保留中の変更があるか。 /// /// cm status --machinereadable の1行めが /// STATUS 1 ServiceSim/ServiceSim studio_unity148@unity /// なので、そこから版の番号を取り、2行めから先を数える /// (PR=置いてあるだけ も数える)。 /// /// 版の字は ServiceSimLedger.CsPrefix を頭に付けて "ServiceSim/cs:1" の形にする /// (依頼 v11 の 2)。このプロジェクトは changeset の番号が 1 から振り直されるので、 /// 前のプロジェクトの "cs:144" などと台帳の中で混ざらないようにするため /// // BuildHeadless(依頼 v12 の 1 章)も同じ関数で版を取る。 // 版の取り方が2通りに分かれると、台帳の unity_cs と // build_info.json の unity_cs が食い違うので、入り口は1つにする public static void ReadChangeset(out string cs, out string pendingNote) { cs = ""; pendingNote = ""; string outp = RunCm("status --machinereadable"); if (string.IsNullOrEmpty(outp)) { // cm を呼べなかった。数が分からないので「不明」と書く(依頼 v8 の 3) cs = ServiceSimLedger.CsPrefix + "不明"; pendingNote = "不明(cm status を読めませんでした)"; return; } // 保留の数は Unity Version Control の「保留中の変更」と同じ数え方にする。 // 置いてあるだけ(PR=Private)も、あの一覧には出るので **数に入れる** // (前は PR を外していたので、2件あったのに空になっていた) int controlled = 0, privateItems = 0; string[] lines = outp.Split('\n'); for (int i = 0; i < lines.Length; i++) { string l = lines[i].Trim(); if (l.Length == 0) continue; if (l.StartsWith("STATUS ")) { // STATUS {版} {リポジトリ} {サーバー} string[] f = l.Split(' '); if (f.Length > 1) cs = ServiceSimLedger.CsPrefix + "cs:" + f[1]; // このプロジェクトのワークスペースから答えが返ってきたかを確かめる // (依頼 v11 の 2)。 // ServiceSim/ がまだ自分のワークスペースになっていないと、 // cm は1つ上(/Users/shimazueiichi/Robotics_UX)の古いワークスペースの // 版(Robotics_UX_New の cs:37 など)を返す。 // その番号を台帳に書くと、どの版で回したのか分からなくなるので弾く string repo = f.Length > 2 ? f[2] : ""; if (repo.IndexOf(ExpectRepoName, StringComparison.Ordinal) < 0) { Debug.LogError("[ServiceSim] cm が別のワークスペースの版を返しました: " + l + "\n このプロジェクト(" + Directory.GetCurrentDirectory() + ")を Unity Version Control のワークスペースにしてください。" + "\n いまのままだと、台帳の unity_cs に1つ上の" + "ワークスペースの番号が入ってしまいます"); cs = ServiceSimLedger.CsPrefix + "不明"; pendingNote = "不明(cm が " + repo + " の版を返しました。" + "このプロジェクトがワークスペースになっていません)"; return; } continue; } if (l.StartsWith("PR ")) privateItems++; else controlled++; } int all = controlled + privateItems; if (all > 0) pendingNote = "保留あり " + all + " 件(管理下 " + controlled + " 件・置いてあるだけ " + privateItems + " 件)"; if (cs.Length == 0) cs = ServiceSimLedger.CsPrefix + "不明"; } static string RunCm(string args) { string[] paths = { "/usr/local/bin/cm", "/opt/homebrew/bin/cm", "cm" }; for (int i = 0; i < paths.Length; i++) { try { Process p = new Process(); p.StartInfo.FileName = paths[i]; p.StartInfo.Arguments = args; p.StartInfo.WorkingDirectory = Directory.GetCurrentDirectory(); p.StartInfo.UseShellExecute = false; p.StartInfo.RedirectStandardOutput = true; p.StartInfo.RedirectStandardError = true; p.StartInfo.CreateNoWindow = true; p.Start(); string outp = p.StandardOutput.ReadToEnd(); p.WaitForExit(15000); if (!string.IsNullOrEmpty(outp)) return outp; } catch (Exception) { // 次の場所を試す } } return null; } /// /// いま回している並びの名札(ログに出す。依頼 v11 の 2)。 /// StartBatch が EditorPrefs に入れた名札を読む。無ければ段階B扱い /// static string BatchLabel() { string l = EditorPrefs.GetString(KeyLabel, LabelStageB); return l == LabelStageB ? "段階B" : l; } static string[] Split(string s) { if (string.IsNullOrEmpty(s)) return new string[0]; return s.Split(';'); } static SimParams LoadParams() { SimParams sp = AssetDatabase.LoadAssetAtPath(ServiceSimSetup.SimParamsPath); if (sp == null) { Debug.LogError("[ServiceSim] SimParams が見つかりません: " + ServiceSimSetup.SimParamsPath); return null; } sp.Load(""); if (sp.Conditions.Count == 0) { Debug.LogError("[ServiceSim] unity_conditions を読めません"); return null; } return sp; } } }