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;
}
}
}