using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;
namespace ServiceSim
{
///
/// 段階Bの通し台帳(2026-10-23 エイイチと決めた。依頼 v7)。
///
/// 実体: {kioxiaOutputRoot}/unity_ledger_v1.csv
/// (MASTER の保存先「service_sim/unity/」の中。新しい置き場所は作らない)
///
/// 決まり:
/// ・1回 = 1行・**追記のみ**。既存の行は書き換えない・消さない
/// ・同じ出力フォルダの行を2度書かない(run_folder が照合キー)
/// ・**追記ガード**:実体のヘッダとこのコードの列の並びが1字でも違えば追記しない
/// ・止めた回・失敗した回も status を付けて1行残す
/// ・書くときは **鍵をかける**(依頼 v12 の 3 章)。夜は実行ファイルを
/// 何本も同時に走らせるので、同時に追記すると行が混ざる
///
/// 列の名前と並びを変えるときは、先にエイイチに聞く(依頼 v7 の 2)。
///
/// なぜ Editor/ の外にあるか(2026-10-26・依頼 v12 の 3 章)
/// 画面なしの実行ファイル(BuildPipeline で作る player)には
/// Editor/ の中のコードが入らない。けれど夜の回も**同じコード**で
/// 台帳に書かないと、列の作り方が2通りに分かれてしまう。
/// このクラスは UnityEditor の API を1つも使っていなかったので、
/// Experiment/ に移して player からも呼べるようにした。
/// 中身(列・ガード・2度書かない決まり)は移すときに変えていない。
/// 名前空間も ServiceSim にした。呼んでいたのは ServiceSimStageB だけで、
/// そちらは ServiceSim.EditorTools の中(= ServiceSim の内側)にあるので、
/// 呼ぶ側の字は1つも変わらない。
///
public static class ServiceSimLedger
{
///
/// 台帳の版。列を足したら 1 つ上げる。
/// 1 … 依頼 v7(49 列)
/// 2 … 依頼 v10b(会計待ち・片付け待ちの 10 列を後ろに足した。59 列)
///
public const int SchemaVersion = 2;
public const string FileName = "unity_ledger_v1.csv";
///
/// 鍵ファイルの名前(依頼 v12 の 3 章)。
/// tools/ledger.py の LOCK_NAME と同じ字。
/// O_EXCL / FileMode.CreateNew で作れたら持ち主、閉じるときに消す。
/// 中身は「pid ホスト名 unix秒」の1行(Python 側と同じ形)
///
public const string LockFileName = "unity_ledger_v1.lock";
/// 鍵を待つ上限(秒)。これを超えたら書かずにあきらめる
public const float LockTimeoutS = 60f;
/// これより古い鍵は、持ち主が落ちたと見て1度だけ壊す(Python 側と同じ 120 秒)
public const float LockStaleS = 120f;
///
/// unity_cs の頭に付ける字(依頼 v11 の 2)。
///
/// このプロジェクト(Robotics_UX_New から分けた ServiceSim 専用)は
/// changeset の番号が 1 から振り直されるので、
/// 台帳の中で前のプロジェクトの "cs:144" などと混ざらないように
/// "ServiceSim/cs:1" の形で書く。
/// 台帳の列は増やさない(data_manifest_rules.md 10節)
///
public const string CsPrefix = "ServiceSim/";
///
/// 列(この順。名前を変えるときは先にエイイチに聞く)。
///
/// 足すときは **いちばん後ろ**(ledger_schema_version より後ろ)に足す。
/// 途中に入れると前の行の列の位置がずれて、古い行が読めなくなる(依頼 v10b の 1)。
/// 前の行は新しい列が無いまま(短い行)で置いておく。書き換えない
///
public static readonly string[] Columns =
{
"timestamp", "run_folder", "status", "unity_cs", "layout_generation", "params_model",
"store", "staff", "robots", "arrivals_per_h", "mode", "trays", "fragile_by", "seed",
"staff_first_yield", "yield_order_enabled", "one_robot_per_aisle", "robot_first_wait_s",
"everyone_left", "total_minutes", "wall_seconds",
"entry_wait_mean", "staff_wait_mean", "dish_wait_mean", "cold_share", "robot_share",
"staff_util", "robot_util", "walk_km_per_staff",
"site_entry_wait_mean", "site_staff_wait_mean", "site_dish_wait_mean", "site_robot_share",
"robot_speed_mps", "pause_rate", "pause_s_mean", "stuck_rate",
"trips", "timeouts", "stuck_count", "stop_s_total", "corner_stop_time_share",
"gave_up", "warped", "table_freed", "rescues",
"late_staff_dishes", "staff_yield_share_of_staff_time",
"ledger_schema_version",
// ---- 依頼 v10b の 1(2026-10-25)。ここから後ろは schema 2 で足した列 ----
// 会計待ち・片付け待ちを試算と並べるための 10 列。
// clear_* / pay_job_* は「仕事が板に載ってから取られるまで」の秒数
"pay_wait_mean", "site_pay_wait_mean",
"blocked_wait_mean", "site_blocked_wait_mean",
"turn_mean", "site_turn_mean",
"clear_wait_mean_s", "clear_wait_max_s", "clear_wait_over_count",
"pay_job_wait_mean_s"
};
public static string HeaderLine { get { return string.Join(",", Columns); } }
/// 台帳の実体の場所
public static string PathOf(ExperimentPlan plan)
{
string root = plan != null && !string.IsNullOrEmpty(plan.kioxiaOutputRoot)
? plan.kioxiaOutputRoot
: "/Volumes/KIOXIA/r2r2r-data/service_sim/unity";
return Path.Combine(root, FileName);
}
// ---------------- 追記ガード ----------------
///
/// 追記していいか。
/// ・実体が無い … いい(header を書いて作る)
/// ・1行めがコードの列と同じ … いい
/// ・違う … だめ(why に理由)
///
public static bool CanAppend(string path, out string why)
{
why = "";
if (!File.Exists(path)) return true;
string first;
try
{
using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
first = r.ReadLine();
}
catch (Exception e)
{
why = "台帳を読めません: " + path + "\n " + e.Message;
return false;
}
if (first == null)
{
why = "台帳が空です(ヘッダがありません): " + path +
"\n いちど消すか、ヘッダを書いてください";
return false;
}
string have = first.TrimEnd('\r', '\n');
if (have == HeaderLine) return true;
why = "台帳のヘッダがコードの列と違うので追記しません(追記ガード): " + path +
"\n 実体: " + have +
"\n コード: " + HeaderLine +
"\n 列をそろえるか、エイイチに聞いてから直してください";
return false;
}
/// その出力フォルダの行がもうあるか(run_folder が照合キー)
public static bool HasRow(string path, string runFolder)
{
if (!File.Exists(path) || string.IsNullOrEmpty(runFolder)) return false;
int col = IndexOf("run_folder");
try
{
using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
{
r.ReadLine(); // ヘッダ
string line;
while ((line = r.ReadLine()) != null)
{
if (line.Length == 0) continue;
string[] f = line.Split(',');
if (col < f.Length && f[col] == runFolder) return true;
}
}
}
catch (Exception e)
{
Debug.LogWarning("[ServiceSim] 台帳を読めませんでした(照合を飛ばします): " + e.Message);
}
return false;
}
/// もう台帳にある出力フォルダの名前をぜんぶ返す(2度書きの歯止めに使う)
public static HashSet DoneFolders(string path)
{
HashSet done = new HashSet();
if (!File.Exists(path)) return done;
int col = IndexOf("run_folder");
int statusCol = IndexOf("status");
try
{
using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
{
r.ReadLine();
string line;
while ((line = r.ReadLine()) != null)
{
if (line.Length == 0) continue;
string[] f = line.Split(',');
if (col >= f.Length) continue;
// status に関係なく、1行あるものは「回した」とみなす
// (止めた回・失敗した回を何度も回し直さないため)
if (statusCol < f.Length && f[statusCol].Length == 0) continue;
done.Add(f[col]);
}
}
}
catch (Exception e)
{
Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
}
return done;
}
///
/// 回した回を照らし合わせるキー(依頼 v8 の 3)。
///
/// **店・ロボットの台数・乱数の種・Unity の版** でそろえる。
/// 出力フォルダ名(日付入り)では照らし合わせない。
/// ・同じ版・同じ条件・同じ種の行があれば、日付が違っても回さない
/// ・版が違えば回す(直したあとに同じ18回をもう一度回すため)
///
public static string RunKey(string store, int robots, int seed, string unityCs)
{
return (store ?? "") + "|r" + robots + "|s" + seed + "|" + (unityCs ?? "");
}
/// 台帳にある回のキーをぜんぶ返す(続きから回すのに使う)
public static HashSet DoneKeys(string path) { return DoneKeys(path, null); }
///
/// 台帳にある回のキーを返す(続きから回すのに使う)。
///
/// folderLabel を渡すと、run_folder に "_{名札}_" が入っている行だけを見る(依頼 v11 の 3)。
/// 名札は「まとめて回した種類」で、stageB(基準データの18回)と
/// split_check(移せたかの確かめ)を分けるために使う。
/// こうしておくと、確かめで ③1台・種1 を2回回しても、
/// そのあとの Run All が同じ条件を「済」として飛ばさない。
/// 台帳の列は増やしていない(名札は run_folder の中にある)
///
public static HashSet DoneKeys(string path, string folderLabel)
{
HashSet done = new HashSet();
if (!File.Exists(path)) return done;
int cStore = IndexOf("store");
int cRobots = IndexOf("robots");
int cSeed = IndexOf("seed");
int cCs = IndexOf("unity_cs");
int cStatus = IndexOf("status");
int cFolder = IndexOf("run_folder");
string mark = string.IsNullOrEmpty(folderLabel) ? null : "_" + folderLabel + "_";
try
{
using (StreamReader r = new StreamReader(path, new UTF8Encoding(false)))
{
r.ReadLine();
string line;
while ((line = r.ReadLine()) != null)
{
if (line.Length == 0) continue;
string[] f = line.Split(',');
int need = Mathf.Max(Mathf.Max(cStore, cRobots), Mathf.Max(cSeed, cCs));
if (need >= f.Length) continue;
if (cStatus < f.Length && f[cStatus].Length == 0) continue;
if (mark != null)
{
if (cFolder >= f.Length) continue;
if (f[cFolder].IndexOf(mark, StringComparison.Ordinal) < 0) continue;
}
int robots, seed;
if (!int.TryParse(f[cRobots], out robots)) continue;
if (!int.TryParse(f[cSeed], out seed)) continue;
done.Add(RunKey(f[cStore], robots, seed, f[cCs]));
}
}
}
catch (Exception e)
{
Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
}
return done;
}
static int IndexOf(string name)
{
for (int i = 0; i < Columns.Length; i++) if (Columns[i] == name) return i;
return -1;
}
// ---------------- 1行足す ----------------
///
/// 1行追記する。ヘッダが合わない・同じ出力フォルダの行がもうある場合は足さない。
/// row は列名 → 値。足りない列は空欄にする
///
public static bool Append(string path, IDictionary row, out string why)
{
why = "";
string folder = Get(row, "run_folder");
if (string.IsNullOrEmpty(folder))
{
why = "run_folder が空なので足しません(照合キーです)";
return false;
}
// 書く字は鍵を取る前に作る(鍵を持っている時間を短くする)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < Columns.Length; i++)
{
if (i > 0) sb.Append(',');
sb.Append(Clean(Get(row, Columns[i])));
}
try
{
string dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir)) Directory.CreateDirectory(dir);
}
catch (Exception e)
{
why = "台帳の置き場所が作れません: " + path + "\n " + e.Message;
return false;
}
// ---- ここから鍵の中(依頼 v12 の 3 章)----
// ヘッダの確かめと「もうあるか」の確かめも **鍵の中** でやる。
// 外でやると、2本が同時に「まだ無い」と見てから両方書いてしまう
string lockWhy;
IDisposable key = TakeLock(path, out lockWhy);
if (key == null)
{
why = lockWhy;
return false;
}
try
{
if (!CanAppend(path, out why)) return false;
if (HasRow(path, folder))
{
why = "もう台帳にあるので足しません(2度書かない): " + folder;
return false;
}
try
{
bool makeHeader = !File.Exists(path);
using (FileStream fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read))
using (StreamWriter w = new StreamWriter(fs, new UTF8Encoding(false)))
{
if (makeHeader) w.Write(HeaderLine + "\n");
w.Write(sb.ToString() + "\n");
w.Flush();
fs.Flush(true); // 落ちても行が消えないよう、ディスクまで落とす
}
}
catch (Exception e)
{
why = "台帳に書けません: " + path + "\n " + e.Message;
return false;
}
return true;
}
finally
{
key.Dispose(); // 鍵ファイルを消す
}
}
// ---------------- 鍵(依頼 v12 の 3 章)----------------
///
/// 台帳の鍵ファイルの置き場所(台帳と同じフォルダ)。
/// tools/ledger.py と同じ場所・同じ名前なので、
/// Unity と Python が同じ鍵を取り合える
///
public static string LockPathOf(string ledgerPath)
{
string dir = Path.GetDirectoryName(ledgerPath);
return string.IsNullOrEmpty(dir) ? LockFileName : Path.Combine(dir, LockFileName);
}
///
/// 鍵を取る。取れたら Dispose で消える入れ物を返し、取れなければ null と理由を返す。
///
/// やり方(tools/ledger.py と同じ):
/// ・FileMode.CreateNew で作れたら持ち主。中身は「pid ホスト名 unix秒」
/// ・作れなければ少し待って何度も試す(上限 LockTimeoutS)
/// ・鍵が LockStaleS より古ければ、持ち主が落ちたと見て **1度だけ** 壊す
///
public static IDisposable TakeLock(string ledgerPath, out string why)
{
why = "";
string lockPath = LockPathOf(ledgerPath);
DateTime end = DateTime.UtcNow.AddSeconds(LockTimeoutS);
bool broke = false;
while (true)
{
try
{
FileStream fs = new FileStream(lockPath, FileMode.CreateNew,
FileAccess.Write, FileShare.None);
try
{
string body = Pid() + " " + HostName() + " " +
UnixNow().ToString("0.000", CultureInfo.InvariantCulture) + "\n";
byte[] bytes = new UTF8Encoding(false).GetBytes(body);
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
}
catch (Exception) { /* 中身が書けなくても、鍵そのものは取れている */ }
return new LedgerKey(fs, lockPath);
}
catch (IOException)
{
// もう誰かが持っている(か、作れない)
}
catch (Exception e)
{
why = "台帳の鍵が作れません: " + lockPath + "\n " + e.Message;
return null;
}
if (!broke && StaleLock(lockPath))
{
broke = true;
Debug.LogWarning("[ServiceSim] 台帳の鍵が " + LockStaleS +
" 秒より古いので壊します: " + lockPath);
try { File.Delete(lockPath); } catch (Exception) { }
continue;
}
if (DateTime.UtcNow >= end)
{
why = "台帳の鍵を " + LockTimeoutS + " 秒待っても取れません: " + lockPath +
"\n ほかの回が書いている途中か、鍵が残っています";
return null;
}
System.Threading.Thread.Sleep(250);
}
}
static bool StaleLock(string lockPath)
{
try
{
if (!File.Exists(lockPath)) return false;
return (DateTime.UtcNow - File.GetLastWriteTimeUtc(lockPath)).TotalSeconds > LockStaleS;
}
catch (Exception) { return false; }
}
static int Pid()
{
try { return System.Diagnostics.Process.GetCurrentProcess().Id; }
catch (Exception) { return 0; }
}
static string HostName()
{
// Environment.MachineName(Python 側の socket.gethostname と同じ意味)。
// SystemInfo はエンジンが要るので使わない(player でも Editor でも動く形にする)
try { return Environment.MachineName; }
catch (Exception) { return "unity"; }
}
static double UnixNow()
{
return (DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)).TotalSeconds;
}
/// 取った鍵。Dispose で閉じて消す
sealed class LedgerKey : IDisposable
{
FileStream _fs;
readonly string _path;
public LedgerKey(FileStream fs, string path) { _fs = fs; _path = path; }
public void Dispose()
{
if (_fs != null)
{
try { _fs.Dispose(); } catch (Exception) { }
_fs = null;
}
try { if (File.Exists(_path)) File.Delete(_path); } catch (Exception) { }
}
}
static string Get(IDictionary row, string key)
{
string v;
return row != null && row.TryGetValue(key, out v) && v != null ? v : "";
}
/// CSV を壊さないように、カンマ・改行・引用符は取り除く(値は数と短い名前だけ)
static string Clean(string s)
{
if (string.IsNullOrEmpty(s)) return "";
s = s.Replace(',', ' ').Replace('"', ' ');
s = s.Replace("\r", " ").Replace("\n", " ");
return s.Trim();
}
// ---------------- summary.json から1行を作る ----------------
/// 数を台帳の書き方にそろえる(小数4桁まで・指数表記にしない)
public static string Num(float v)
{
return v.ToString("0.####", CultureInfo.InvariantCulture);
}
public static string Num(int v)
{
return v.ToString(CultureInfo.InvariantCulture);
}
public static string Flag(bool v) { return v ? "1" : "0"; }
/// summary.json を読む(読めなければ null)
public static JsonValue ReadSummary(string runDir)
{
string p = Path.Combine(runDir, "summary.json");
if (!File.Exists(p)) return null;
try
{
string err;
JsonValue v = JsonValue.Parse(File.ReadAllText(p), out err);
if (v == null || !v.IsObject)
{
Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n " + err);
return null;
}
return v;
}
catch (Exception e)
{
Debug.LogWarning("[ServiceSim] summary.json を読めません: " + p + "\n " + e.Message);
return null;
}
}
}
}