using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;
namespace ServiceSim.EditorTools
{
///
/// 段階Bの通し台帳(2026-10-23 エイイチと決めた。依頼 v7)。
///
/// 実体: {kioxiaOutputRoot}/unity_ledger_v1.csv
/// (MASTER の保存先「service_sim/unity/」の中。新しい置き場所は作らない)
///
/// 決まり:
/// ・1回 = 1行・**追記のみ**。既存の行は書き換えない・消さない
/// ・同じ出力フォルダの行を2度書かない(run_folder が照合キー)
/// ・**追記ガード**:実体のヘッダとこのコードの列の並びが1字でも違えば追記しない
/// ・止めた回・失敗した回も status を付けて1行残す
///
/// 列の名前と並びを変えるときは、先にエイイチに聞く(依頼 v7 の 2)。
///
public static class ServiceSimLedger
{
public const int SchemaVersion = 1;
public const string FileName = "unity_ledger_v1.csv";
/// 列(この順。名前を変えるときは先にエイイチに聞く)
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"
};
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)
{
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");
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;
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 = "";
if (!CanAppend(path, out why)) return false;
string folder = Get(row, "run_folder");
if (string.IsNullOrEmpty(folder))
{
why = "run_folder が空なので足しません(照合キーです)";
return false;
}
if (HasRow(path, folder))
{
why = "もう台帳にあるので足しません(2度書かない): " + 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);
bool makeHeader = !File.Exists(path);
using (StreamWriter w = new StreamWriter(path, true, new UTF8Encoding(false)))
{
if (makeHeader) w.Write(HeaderLine + "\n");
w.Write(sb.ToString() + "\n");
}
}
catch (Exception e)
{
why = "台帳に書けません: " + path + "\n " + e.Message;
return false;
}
return true;
}
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;
}
}
}
}