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