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)
{
bool unreadable;
return HasRow(path, runFolder, out unreadable);
}
///
/// 同じ出力フォルダの行がもうあるか。
///
/// 2026-10-26(codex レビュー A5):読めなかったときに false(=行なし)を
/// 返していたので、読めないまま2行目を書く道があった。
/// unreadable で「分からない」を返し、呼ぶ側は **書かない** ようにする
///
public static bool HasRow(string path, string runFolder, out bool unreadable)
{
unreadable = false;
if (string.IsNullOrEmpty(runFolder)) return false;
if (!File.Exists(path)) 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)
{
// 読めないときは「行が無い」と決めつけない(2026-10-26・A5)
unreadable = true;
Debug.LogWarning("[ServiceSim] 台帳を読めませんでした: " + e.Message);
}
return false;
}
///
/// 台帳の最後が改行で終わっているか(2026-10-26・codex レビュー A4)。
///
/// 追記の途中で落ちると、最後の行が半端なまま残りうる。
/// そこへ次の行を足すと **2つの行がつながって1行**になり、
/// 列がずれた行が台帳に入る。
/// なので足す前に末尾を見て、改行で終わっていなければ **書かない**。
/// 自動で切り詰めることはしない(台帳は消さない・書き換えないのが決まり)
///
public static bool EndsWithNewline(string path, out string why)
{
why = "";
try
{
if (!File.Exists(path)) return true;
using (FileStream fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
{
if (fs.Length == 0) return true;
fs.Seek(-1, SeekOrigin.End);
int last = fs.ReadByte();
if (last == '\n') return true;
why = "台帳の最後が改行で終わっていません(前の追記が途中で切れた疑い): " + path +
"\n つなげて書くと列がずれるので、何も足しませんでした。" +
"\n 最後の行を見て、エイイチに相談してから直してください" +
"\n (自動では直しません。台帳は書き換えない決まりです)";
return false;
}
}
catch (Exception e)
{
why = "台帳の末尾を読めません: " + path + "\n " + 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])));
}
// ★ 本当にマウントされているかを確かめる(依頼 v14 の 7)。
// Directory.Exists だけだと、KIOXIA が外れたあとに残った
// /Volumes/KIOXIA というフォルダ(Mac の中)にも書けてしまう
string mountWhy;
if (!IsOnMountedVolume(path, out mountWhy))
{
why = mountWhy;
return false;
}
// 2026-10-26(codex レビュー D6):置き場所を **作らない**。
// 作ってしまうと、KIOXIA が外れているときに Mac の中へ
// 同じ道のフォルダを作って書いてしまう。
// KIOXIA の置き場所は前からあるのが正しい形なので、無ければ書かない
string dir = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
why = "台帳の置き場所がありません(作りません): " + dir +
"\n KIOXIA がつながっているか確かめてください(Mac の中には書きません)";
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;
// 末尾が半端なら足さない(A4)
if (!EndsWithNewline(path, out why)) return false;
bool unreadable;
if (HasRow(path, folder, out unreadable))
{
why = "もう台帳にあるので足しません(2度書かない): " + folder;
return false;
}
if (unreadable)
{
// 読めないまま書くと2行目になるかもしれない(A5)
why = "台帳を読めないので、2度書きを避けて足しませんでした: " + folder;
return false;
}
try
{
// 1行ぶんを UTF-8 の byte にしてから **1回で** 書く(依頼 v14 の 2)。
// 何度も書くと、途中で落ちたときに半端な行が残りやすい
bool makeHeader = !File.Exists(path);
string text = (makeHeader ? HeaderLine + "\n" : "") + sb.ToString() + "\n";
byte[] bytes = new UTF8Encoding(false).GetBytes(text);
using (FileStream fs = new FileStream(path, FileMode.Append, FileAccess.Write, FileShare.Read))
{
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
fs.Flush(true); // 落ちても行が消えないよう、ディスクまで落とす
}
}
catch (Exception e)
{
why = "台帳に書けません: " + path + "\n " + e.Message;
return false;
}
return true;
}
finally
{
key.Dispose(); // 鍵ファイルを消す
}
}
// ---------------- 本当にマウントされているか(依頼 v14 の 7)----------------
///
/// その道が **マウントされたボリュームの上** にあるかを確かめる。
///
/// なぜ Directory.Exists では足りないか:
/// KIOXIA を抜いたあと /Volumes/KIOXIA というフォルダが
/// Mac の中に残ることがある。Directory.Exists はそれを通すので、
/// 外付けに書いたつもりで Mac の中に書いてしまう。
///
/// やり方:DriveInfo.GetDrives()(=いまマウントされている所の一覧)に、
/// その道の頭と一致する所があるかを見る。
/// 一覧が取れない場合(player の実行環境による)は、
/// **台帳の実体があること**を代わりの印にして、そのことをログに出す
///
public static bool IsOnMountedVolume(string path, out string why)
{
why = "";
string full;
try { full = Path.GetFullPath(path); }
catch (Exception e)
{
why = "道を読めません: " + path + "\n " + e.Message;
return false;
}
// "/" だけのときは Mac の中。外付けに書く約束と合わない
string best = null;
bool listed = false;
try
{
DriveInfo[] drives = DriveInfo.GetDrives();
for (int i = 0; i < drives.Length; i++)
{
DirectoryInfo root = null;
try { root = drives[i].RootDirectory; } catch (Exception) { }
if (root == null) continue;
listed = true;
string r = root.FullName;
if (r.Length > 1 && r.EndsWith("/", StringComparison.Ordinal))
r = r.Substring(0, r.Length - 1);
if (r.Length == 0) continue;
bool under = full == r ||
full.StartsWith(r == "/" ? "/" : r + "/", StringComparison.Ordinal);
if (under && (best == null || r.Length > best.Length)) best = r;
}
}
catch (Exception e)
{
Debug.LogWarning("[ServiceSim] マウントの一覧が取れません(代わりに台帳の実体で見ます): " +
e.Message);
}
if (listed)
{
if (best != null && best != "/") return true;
why = "ここはマウントされた外付けではありません(Mac の中です): " + full +
"\n いちばん近いマウント地点: " + (best ?? "(見つかりません)") +
"\n KIOXIA がつながっているか確かめてください(Mac の中には書きません)";
return false;
}
// 一覧が取れなかったとき:台帳の実体があることを代わりの印にする
if (File.Exists(path))
{
Debug.LogWarning("[ServiceSim] マウントの一覧が取れないので、" +
"台帳の実体があることで代わりに確かめました: " + path);
return true;
}
why = "マウントの一覧が取れず、台帳の実体もありません: " + full +
"\n KIOXIA がつながっているか確かめてください";
return false;
}
// ---------------- 鍵(依頼 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);
// この回だけの札。鍵の中身の先頭に書き、返すときに
// 「自分の鍵か」を確かめるのに使う
string tag = NewTag();
// 待つ長さは単調時計で測る(時計合わせで伸び縮みしないように)
System.Diagnostics.Stopwatch clock = System.Diagnostics.Stopwatch.StartNew();
while (true)
{
try
{
FileStream fs = new FileStream(lockPath, FileMode.CreateNew,
FileAccess.Write, FileShare.Read);
bool mine = false;
try
{
byte[] bytes = new UTF8Encoding(false).GetBytes(LockBody(tag));
fs.Write(bytes, 0, bytes.Length);
fs.Flush();
mine = TagOf(lockPath) == tag; // 取れたあと読み直して確かめる
}
catch (Exception) { mine = false; }
if (mine) return new LedgerKey(fs, lockPath, tag);
try { fs.Dispose(); } catch (Exception) { }
Debug.LogWarning("[ServiceSim] 台帳の鍵を取り直します" +
"(取った直後に中身が自分の札ではありませんでした): " + lockPath);
}
catch (IOException)
{
// もう誰かが持っている(か、作れない)
}
catch (Exception e)
{
why = "台帳の鍵が作れません: " + lockPath + "\n " + e.Message;
return null;
}
// ★ 古い鍵を **年だけ見て壊すことはしない**(依頼 v14 の 1)。
// 2本が同時に「古い」と見ると、先に取り直した側の新しい鍵を
// もう一方が消して二重所有になりうる。
// 鍵が残ったときは、待ちの上限を過ぎて「台帳に書けなかった」として
// その回を失敗にする(残った鍵は人が消す)
if (clock.Elapsed.TotalSeconds >= LockTimeoutS)
{
why = "台帳の鍵を " + LockTimeoutS + " 秒待っても取れません: " + lockPath +
"\n ほかの回が書いている途中か、前の回の鍵が残っています" +
"\n 残っているなら、中身(札 pid ホスト名 時刻)を見てから" +
"手で消してください(自動では消しません)";
return null;
}
System.Threading.Thread.Sleep(250);
}
}
/// 鍵の中身。「札 pid ホスト名 unix秒」の1行
static string LockBody(string tag)
{
return tag + " " + Pid() + " " + HostName() + " " +
UnixNow().ToString("0.000", CultureInfo.InvariantCulture) + "\n";
}
/// 鍵の中身の先頭(札)。読めなければ null
static string TagOf(string lockPath)
{
try
{
if (!File.Exists(lockPath)) return null;
using (FileStream fs = new FileStream(lockPath, FileMode.Open, FileAccess.Read,
FileShare.ReadWrite))
using (StreamReader r = new StreamReader(fs, new UTF8Encoding(false)))
{
string line = r.ReadLine();
if (line == null) return "";
int sp = line.IndexOf(' ');
return sp > 0 ? line.Substring(0, sp) : line;
}
}
catch (Exception) { return null; }
}
/// この回だけの札(ほかの回とぶつからない字)
static string NewTag()
{
return "k" + Pid().ToString(CultureInfo.InvariantCulture) + "-" +
Guid.NewGuid().ToString("N").Substring(0, 12);
}
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;
readonly string _tag;
public LedgerKey(FileStream fs, string path, string tag)
{
_fs = fs; _path = path; _tag = tag;
}
public void Dispose()
{
if (_fs != null)
{
try { _fs.Dispose(); } catch (Exception) { }
_fs = null;
}
// ★ 自分の札のときだけ消す(2026-10-26・codex レビュー A2)。
// 前は中身を見ずに消していたので、
// 自分の鍵が壊されたあとに他人の鍵を消す道があった
try
{
string now = TagOf(_path);
if (now == null) return; // もう無い・読めない
if (now != _tag)
{
Debug.LogWarning("[ServiceSim] 台帳の鍵が自分のものではないので消しません: " +
_path + "\n いま: " + now + " / 自分: " + _tag);
return;
}
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;
}
}
}
}