using System; using System.Collections.Generic; using System.Globalization; using System.IO; using UnityEngine; namespace ServiceSim { /// /// 画面なしの実行ファイルで、1条件 × 1種を1回だけ回して終わる /// (2026-10-26・依頼 v12 の 2 章)。 /// /// 1本の実行ファイル = 1回。終わったら台帳に1行書いて、終了コードを付けて終わる。 /// 夜の実行係(tools/night_runner.py)が、これを N 本まで同時に起動する。 /// /// 引数(night_runner.py が渡す形。2 章) /// -ssCondition fam_p1_s4_r1_a42 … 条件の名前(必須。これが無ければ何もしない) /// -ssSeed 3 … 種(必須) /// -ssLabel night … 出力フォルダに入る名札(既定 night) /// -ssNight 20261026 … その夜の名前。フォルダ名の先頭にもなる /// -ssParallel 3 … そのとき同時に走っていた本数(記録だけ) /// -ssPassBy on|off … すれ違いの走り方(既定 off) /// -ssTry 2 … やり直しの回。2 以上ならフォルダ名に _try2 /// /// 出力フォルダ(tools/night_runner.py の folder_name と同じ字にする) /// {夜}_{名札}_cs{N}_{店}_r{台数}_s{種}[_passon][_try{n}] /// 例 20261026_night_cs12_fam_p1_r1_s3 /// cs{N} は、実行ファイルのそばの build_info.json の unity_cs(ServiceSim/cs:12)から取る。 /// **実行ファイル側が名前を決める**(実行係は同じ式で先に名前を作って、 /// もう回した回を飛ばすのに使う)。 /// /// 終わり方(2 章) /// 0 … 最後まで回った /// 2 … 途中で止まった(実時間の上限など。記録は残る) /// 1 … 失敗(引数が読めない・条件が無い・KIOXIA が無い・記録が出ない) /// /// KIOXIA(2 章) /// **回し始める前に** つながっているかを確かめる。無ければ何も書かずに 1 で終わる。 /// Mac の中には書かない。 /// /// 回し方は段階Bの Run All と同じ道を通す(ExperimentRunner.Job を1つ作って /// SetSingleJob する)。台帳の行を作るのも同じ LedgerRow 1つだけ。 /// public class HeadlessRun : MonoBehaviour { /// Setup が作るときの名前 public const string RootName = "HeadlessRun"; /// 終了コード(依頼 v12 の 2 章) public const int ExitOk = 0; public const int ExitFailed = 1; public const int ExitStopped = 2; /// build_info.json を上に何階層まで探すか(.app の中から外に出るため) const int BuildInfoUpLevels = 6; /// 回し始めるまでにこれだけかかったら、何か起きていると見て失敗にする(秒) const float StartTimeoutS = 180f; [Header("参照(Setup が入れる)")] public ExperimentRunner runner; public ExperimentPlan plan; public SimParams simParams; public StoreBuilder store; public NavMeshBaker baker; public RobotSpec robotSpec; public PeopleSpawner people; public KitchenMonitor monitor; [Tooltip("条件の店(fam_p1 / fam_p5)に合わせて選ぶ店の形。Setup が入れる")] public JsonStoreLayout[] layouts; // ---- 引数から読んだもの ---- string _condition = "", _label = "night", _night = "", _passBy = "off"; int _seed, _parallel, _try = 1; string _unityCs = "", _buildId = "", _buildInfoPath = ""; string _folder = "", _runDir = ""; SimParams.Condition _cond; bool _active; // 引数があり、回す気がある bool _sawRunning; // runner が回り始めたのを見た bool _finished; // もう後片付けをした float _awakeAt; void Awake() { // 引数に -ssCondition が無ければ、この部品は何もしない。 // エディタで手で Play したときや、段階Bの Run All のときは必ずこちら string[] argv = SafeArgs(); if (Find(argv, "-ssCondition") == null) return; _awakeAt = Time.realtimeSinceStartup; int code; if (!Setup(argv, out code)) { Quit(code, "回し始める前に止めました"); return; } _active = true; } /// /// 引数を読み、確かめ、1回ぶんの仕事を仕込む。 /// 駄目なときは false と終了コードを返す(何も書かない) /// bool Setup(string[] argv, out int code) { code = ExitFailed; // ---- 引数 ---- _condition = Find(argv, "-ssCondition") ?? ""; string seedText = Find(argv, "-ssSeed"); if (string.IsNullOrEmpty(_condition) || seedText == null || !int.TryParse(seedText, NumberStyles.Integer, CultureInfo.InvariantCulture, out _seed)) { Log("-ssCondition と -ssSeed が要ります(例 -ssCondition fam_p1_s4_r1_a42 -ssSeed 3)"); return false; } _label = Find(argv, "-ssLabel") ?? "night"; _night = Find(argv, "-ssNight") ?? DateTime.Now.ToString("yyyyMMdd", CultureInfo.InvariantCulture); _passBy = (Find(argv, "-ssPassBy") ?? "off").ToLowerInvariant() == "on" ? "on" : "off"; int.TryParse(Find(argv, "-ssParallel") ?? "0", NumberStyles.Integer, CultureInfo.InvariantCulture, out _parallel); if (!int.TryParse(Find(argv, "-ssTry") ?? "1", NumberStyles.Integer, CultureInfo.InvariantCulture, out _try) || _try < 1) _try = 1; // ---- シーンの参照 ---- if (runner == null) runner = FindObjectOfType(); if (plan == null && runner != null) plan = runner.plan; if (simParams == null && runner != null) simParams = runner.simParams; if (store == null && runner != null) store = runner.store; if (baker == null && runner != null) baker = runner.baker; if (robotSpec == null && runner != null) robotSpec = runner.robotSpec; if (people == null && runner != null) people = runner.people; if (monitor == null && runner != null) monitor = runner.monitor; if (runner == null || plan == null || simParams == null || store == null || baker == null) { Log("シーンの仕掛けが足りません(ExperimentRunner / ExperimentPlan / SimParams / " + "StoreBuilder / NavMeshBaker)。ServiceSim/Setup を実行したシーンで作り直してください"); return false; } // ---- 条件 ---- // ★ player では誰も SimParams.Load を呼んでいないので、 // 自分で読む(段階Bの LoadParams と同じ sp.Load(""))。 // 読んだ中身(_conditions)は .asset に残らない作りなので、 // Play のたび・起動のたびに読み直す必要がある。 // このあと ExperimentRunner が店の形の版を渡して読み直すが、 // 同じ JSON を読むので値は変わらない if (!simParams.Load("") || simParams.Conditions.Count == 0) { Log("serving-robot-params.json の unity_conditions を読めません" + (simParams.Problems.Count > 0 ? "\n " + string.Join("\n ", ToArray(simParams.Problems)) : "")); return false; } _cond = ConditionById(_condition); if (_cond == null) { Log("知らない条件です: " + _condition + "\n 使える条件: " + KnownConditions()); return false; } // ---- 店の形 ---- JsonStoreLayout layout = LayoutFor(_cond.store); if (layout == null) { Log("店の形が見つかりません: " + _cond.store + "(HeadlessRun の layouts に入っていません。ServiceSim/Setup を実行してください)"); return false; } // ---- 版(出力フォルダの字と、台帳の unity_cs)---- ReadBuildInfo(); if (string.IsNullOrEmpty(_unityCs)) Log("注意:build_info.json の unity_cs が読めませんでした。" + "出力フォルダは csunknown になります(" + _buildInfoPath + ")"); _folder = FolderName(); _runDir = Path.Combine(plan.kioxiaOutputRoot, _folder); // ---- KIOXIA(書く前に確かめる。無ければ何も書かない)---- if (!Directory.Exists(plan.kioxiaVolumePath)) { Log("KIOXIA がつながっていません: " + plan.kioxiaVolumePath + "\n 何も書かずに終わります(Mac の中には書きません)"); return false; } if (!Directory.Exists(plan.kioxiaOutputRoot)) { Log("置き場所がありません: " + plan.kioxiaOutputRoot + "\n MASTER の保存先です。新しく作らずに確かめてください"); return false; } if (Directory.Exists(_runDir)) { Log("その出力フォルダはもうあります: " + _runDir + "\n 上書きしません(やり直しは -ssTry 2 で別の名前にしてください)"); return false; } // ---- 台帳のヘッダ(書けない形なら、回す前に止める)---- string ledgerPath = ServiceSimLedger.PathOf(plan); string headerWhy; if (File.Exists(ledgerPath) && !ServiceSimLedger.CanAppend(ledgerPath, out headerWhy)) { Log("台帳に書けない形です。回す前に止めます\n " + headerWhy.Replace("\n", "\n ")); return false; } // ---- 仕込む(段階Bの StartNext と同じ形)---- store.layout = layout; baker.layout = layout; if (robotSpec != null) robotSpec.passByEnabled = _passBy == "on"; ExperimentRunner.Job job = new ExperimentRunner.Job(); job.siteRun = true; job.conditionIndex = ConditionIndexOf(_cond); job.storeKey = _cond.store; job.robots = _cond.robots; job.seed = _seed; job.aisleWidthIndex = layout.aisleWidthIndex; job.crowd = CrowdLevel.Normal; job.applyDeliveryOffset = false; job.folder = _runDir; runner.SetSingleJob(job); runner.timeMode = ExperimentRunner.TimeMode.FixedStep; runner.captureDeltaTimeOverride = 0f; // 1回の実時間の上限は段階Bと同じ(値は ExperimentRunner に1つだけ置いてある) runner.maxWallSecondsPerRun = ExperimentRunner.DefaultMaxWallSecondsPerRun; runner.unityCs = _unityCs; runner.pendingNote = ""; runner.runLabel = _label; runner.viewMode = false; runner.runnerName = "headless"; runner.buildId = _buildId; runner.night = _night; runner.parallel = _parallel; runner.runOnPlay = true; // Awake なので、runner の Start より前に立つ if (monitor != null) monitor.show = false; // 画面のものは切る(2 章) Log("回します: " + _condition + " 種 " + _seed + " / すれ違い " + (_passBy == "on" ? "入" : "切") + (_try > 1 ? " / やり直し " + _try + " 回め" : "") + "\n 版 " + (_unityCs.Length > 0 ? _unityCs : "(分からない)") + " / build " + (_buildId.Length > 0 ? _buildId : "(分からない)") + " / 夜 " + _night + " / 同時 " + _parallel + "\n 置き場所 " + _runDir); code = ExitOk; return true; } void Update() { if (!_active || _finished) return; if (!_sawRunning) { if (runner.Running) { _sawRunning = true; return; } // 合わない値で回さずに抜けた(ExperimentRunner.AbortRun)。 // 待っても始まらないので、すぐ失敗にする if (runner.Aborted) { Quit(ExitFailed, "合わない値があって回せませんでした" + "(ログの上の行に理由が出ています)"); return; } if (Time.realtimeSinceStartup - _awakeAt > StartTimeoutS) { Quit(ExitFailed, "回し始められませんでした(" + StartTimeoutS + " 秒待ちました)"); } return; } if (runner.Running) return; // まだ回っている // 回り終わった Finish(); } /// /// 回り終わったあと:台帳に1行書いて、終了コードを決めて終わる。 /// 行を作るのは段階Bと同じ LedgerRow 1つだけ /// void Finish() { _finished = true; // ExperimentRunner が店の形の版で SimParams を読み直しているので、 // 条件の入れ物は作り直されている。名前で引き直す // (引けなければ回し始めに取ったものを使う。値は同じ JSON から来る) SimParams.Condition cond = ConditionById(_condition); if (cond == null) cond = _cond; LedgerRow.Inputs inp = new LedgerRow.Inputs(); inp.simParams = simParams; inp.cond = cond; inp.robotSpec = robotSpec; inp.peopleSpec = people != null ? people.spec : null; inp.unityCs = _unityCs; inp.runFolder = _folder; inp.runDir = _runDir; inp.seed = _seed; inp.wallS = Time.realtimeSinceStartup - _awakeAt; inp.ledgerPath = ServiceSimLedger.PathOf(plan); string status = "failed"; string line; try { line = LedgerRow.BuildAndAppend(inp, out status); } catch (Exception e) { line = "台帳の行を作れませんでした: " + e.Message; status = "failed"; } int code = status == "completed" ? ExitOk : status == "stopped" ? ExitStopped : ExitFailed; Quit(code, "おわり: " + line); } // ---------------- 細かい道具 ---------------- /// /// 出力フォルダの名前。 /// tools/night_runner.py の folder_name と **同じ字** にする /// (実行係は同じ名前を先に作って、もう回した回を飛ばすのに使う) /// string FolderName() { return FolderName(_night, _label, CsToken(_unityCs), _cond.store, _cond.robots, _seed, _passBy, _try); } /// /// 出力フォルダの名前(計算だけ。確かめのために外からも呼べるようにしてある)。 /// /// ★ tools/night_runner.py の folder_name と **1字でも違ってはいけない**。 /// 実行係はこの名前を先に作って「もう回した回」を飛ばすので、 /// 食い違うと同じ回を2度回したり、回したのに飛ばしたりする /// public static string FolderName(string night, string label, string csToken, string store, int robots, int seed, string passBy, int tryNo) { string name = night + "_" + label + "_" + csToken + "_" + store + "_r" + robots.ToString(CultureInfo.InvariantCulture) + "_s" + seed.ToString(CultureInfo.InvariantCulture); if (passBy == "on") name += "_passon"; if (tryNo > 1) name += "_try" + tryNo.ToString(CultureInfo.InvariantCulture); return name; } /// /// "ServiceSim/cs:12" → "cs12"。読めなければ "csunknown"。 /// tools/night_runner.py の cs_token(既定 "cs{N}")と同じ字にする /// public static string CsToken(string unityCs) { string n = ""; int at = (unityCs ?? "").IndexOf("cs:", StringComparison.Ordinal); if (at >= 0) { for (int i = at + 3; i < unityCs.Length; i++) { char c = unityCs[i]; if (c < '0' || c > '9') break; n += c; } } return "cs" + (n.Length > 0 ? n : "unknown"); } /// /// 実行ファイルのそばの build_info.json から、版(unity_cs)と build_id を読む。 /// /// macOS の .app は Application.dataPath が /// …/ServiceSim.app/Contents/Resources/Data /// なので、そこから上に何階層か探す(Builds/headless/ に置いてある形) /// void ReadBuildInfo() { string dir = Application.dataPath; for (int i = 0; i <= BuildInfoUpLevels && !string.IsNullOrEmpty(dir); i++) { string p = Path.Combine(dir, "build_info.json"); if (File.Exists(p)) { _buildInfoPath = p; try { string err; JsonValue v = JsonValue.Parse(File.ReadAllText(p), out err); if (v != null && v.IsObject) { _unityCs = v["unity_cs"].S(""); _buildId = v["build_id"].S(""); return; } Log("build_info.json が読めません: " + p + "\n " + err); } catch (Exception e) { Log("build_info.json が読めません: " + p + "\n " + e.Message); } return; } dir = Path.GetDirectoryName(dir); } _buildInfoPath = "(見つかりません。" + Application.dataPath + " から上に " + BuildInfoUpLevels + " 階層まで探しました)"; } SimParams.Condition ConditionById(string id) { if (simParams == null) return null; for (int i = 0; ; i++) { SimParams.Condition c = simParams.ConditionAt(i); if (c == null) return null; if (c.Id == id) return c; } } int ConditionIndexOf(SimParams.Condition cond) { if (simParams == null || cond == null) return -1; for (int i = 0; ; i++) { SimParams.Condition c = simParams.ConditionAt(i); if (c == null) return -1; if (c == cond) return i; } } string KnownConditions() { List ids = new List(); for (int i = 0; simParams != null; i++) { SimParams.Condition c = simParams.ConditionAt(i); if (c == null) break; ids.Add(c.Id); } return ids.Count > 0 ? string.Join(", ", ids.ToArray()) : "(読めません)"; } /// 条件の店(fam_p1 / fam_p5)に合う店の形を選ぶ JsonStoreLayout LayoutFor(string storeKey) { if (layouts == null) return null; for (int i = 0; i < layouts.Length; i++) { if (layouts[i] == null) continue; if (layouts[i].ParamsStoreKey == storeKey) return layouts[i]; } return null; } /// 引数 name の次の字を返す。無ければ null static string Find(string[] argv, string name) { if (argv == null) return null; for (int i = 0; i < argv.Length - 1; i++) if (argv[i] == name) return argv[i + 1]; return null; } static string[] ToArray(System.Collections.Generic.IList list) { string[] a = new string[list != null ? list.Count : 0]; for (int i = 0; i < a.Length; i++) a[i] = list[i]; return a; } static string[] SafeArgs() { try { return Environment.GetCommandLineArgs(); } catch (Exception) { return new string[0]; } } static void Log(string msg) { Debug.Log("[ServiceSim/headless] " + msg); } void Quit(int code, string why) { _finished = true; if (code == ExitOk) Log(why + "\n 終了コード 0(最後まで回りました)"); else if (code == ExitStopped) Debug.LogWarning("[ServiceSim/headless] " + why + "\n 終了コード 2(途中で止まりました)"); else Debug.LogError("[ServiceSim/headless] " + why + "\n 終了コード 1(失敗)"); #if UNITY_EDITOR // エディタでは Play を抜けるだけ(終了コードは付かない) UnityEditor.EditorApplication.isPlaying = false; #else Application.Quit(code); #endif } } }