using System; using System.Collections.Generic; using System.Globalization; using System.IO; using UnityEngine; namespace ServiceSim { /// /// 通し台帳の1行を、その回の summary.json / run_info.json から作って足す /// (2026-10-26・依頼 v12 の 2〜3 章)。 /// /// なぜ分けたか /// 段階Bの Run All(Editor)と、夜の画面なしの実行ファイル(player)の /// 両方が台帳に1行書く。列の作り方が2通りに分かれると、 /// 同じ回でも書く値が違う、という最悪の壊れ方をする。 /// なので **行を作る所はここ1つだけ** にして、 /// Editor だけで取れる値(.asset・EditorPrefs)は呼ぶ側が Inputs に入れて渡す。 /// /// Editor 側:ServiceSimStageB.WriteLedgerRow が AssetDatabase と /// EditorPrefs から詰める /// player 側:HeadlessRun がシーンの参照と build_info.json から詰める /// /// 列の中身・status の決め方・2度書かない決まりは、移すときに変えていない。 /// public static class LedgerRow { /// 行を作るのに要る、外から渡す値 public class Inputs { /// サイトの試算の値(版・店の形の版・条件ごとの site_result) public SimParams simParams; /// この回の条件(store・staff・robots・arrivals_per_h・mode・trays・fragile_by) public SimParams.Condition cond; /// run_info.json に無いときの控え(決まりの入切を読む) public RobotSpec robotSpec; public PeopleSpec peopleSpec; /// "ServiceSim/cs:12" の形 public string unityCs; /// 出力フォルダの名前(照合キー)と、その場所 public string runFolder; public string runDir; /// 種 public int seed; /// 実時間(秒)。run_info.json に wall_seconds があればそちらを使う public float wallS; /// 台帳の実体(unity_ledger_v1.csv) public string ledgerPath; } /// /// その回の summary.json / run_info.json から台帳の1行を作って足す。 /// 戻り値はログ用の短い1行。status も返す /// public static string BuildAndAppend(Inputs inp, out string status) { if (inp == null) throw new ArgumentNullException("inp"); SimParams sp = inp.simParams; SimParams.Condition cond = inp.cond; int seed = inp.seed; string folder = inp.runFolder; string runDir = inp.runDir; float wallS = inp.wallS; JsonValue sum = ServiceSimLedger.ReadSummary(runDir); JsonValue info = ReadJson(Path.Combine(runDir, "run_info.json")); bool stoppedEarly = info != null && info["stopped_early"].B(false); status = sum == null ? "failed" : (stoppedEarly ? "stopped" : "completed"); RobotSpec rs = inp.robotSpec; PeopleSpec ps = inp.peopleSpec; JsonValue simLike = sum != null ? sum["sim_like"] : null; JsonValue siteM = sum != null ? sum["site_metrics"] : null; JsonValue siteRun = sum != null ? sum["site_run"] : null; JsonValue trips = sum != null ? sum["trips"] : null; JsonValue stops = sum != null ? sum["stops"] : null; Dictionary row = new Dictionary(); row["timestamp"] = DateTime.Now.ToString("yyyy-MM-ddTHH:mm:ss"); row["run_folder"] = folder; row["status"] = status; row["unity_cs"] = inp.unityCs != null ? inp.unityCs : ""; row["layout_generation"] = sp != null ? sp.LayoutGeneration : ""; row["params_model"] = sp != null ? sp.Model : ""; if (cond != null) { row["store"] = cond.store; row["staff"] = ServiceSimLedger.Num(cond.staff); row["robots"] = ServiceSimLedger.Num(cond.robots); row["arrivals_per_h"] = ServiceSimLedger.Num(cond.arrivalsPerHour); row["mode"] = cond.mode; row["trays"] = ServiceSimLedger.Num(cond.trays); row["fragile_by"] = cond.fragileBy; } row["seed"] = ServiceSimLedger.Num(seed); // 決まり(回したときの設定)。run_info にあればそれを、無ければ .asset から row["staff_first_yield"] = info != null && !info["staff_first_yield"].IsNull ? ServiceSimLedger.Flag(info["staff_first_yield"].B(false)) : ServiceSimLedger.Flag(ps != null && ps.staffFirstYield); row["yield_order_enabled"] = info != null && !info["yield_order_enabled"].IsNull ? ServiceSimLedger.Flag(info["yield_order_enabled"].B(false)) : ServiceSimLedger.Flag(rs != null && rs.yieldOrderEnabled); row["one_robot_per_aisle"] = info != null && !info["one_robot_per_aisle"].IsNull ? ServiceSimLedger.Flag(info["one_robot_per_aisle"].B(false)) : ServiceSimLedger.Flag(rs != null && rs.oneRobotPerAisle); row["robot_first_wait_s"] = Val(simLike, "robot_first_wait_s"); row["everyone_left"] = siteRun != null && !siteRun["everyone_left"].IsNull ? ServiceSimLedger.Flag(siteRun["everyone_left"].B(false)) : ""; row["total_minutes"] = Val(siteRun, "total_minutes"); row["wall_seconds"] = info != null && !info["wall_seconds"].IsNull ? Val(info, "wall_seconds") : ServiceSimLedger.Num(wallS); row["entry_wait_mean"] = Val(siteM, "entry_wait_mean"); row["staff_wait_mean"] = Val(siteM, "staff_wait_mean"); row["dish_wait_mean"] = Val(siteM, "dish_wait_mean"); row["cold_share"] = Val(siteM, "cold_share"); row["robot_share"] = Val(siteM, "robot_share"); row["staff_util"] = Val(siteM, "staff_util"); row["robot_util"] = Val(siteM, "robot_util"); row["walk_km_per_staff"] = Val(siteM, "walk_km_per_staff"); // サイトの試算(同じ条件の site_result) if (cond != null) { row["site_entry_wait_mean"] = Site(cond, "entry_wait_mean"); row["site_staff_wait_mean"] = Site(cond, "staff_wait_mean"); row["site_dish_wait_mean"] = Site(cond, "dish_wait_mean"); row["site_robot_share"] = Site(cond, "robot_share"); } // サイトの unity_measures と同じ意味で Unity が測った値。 // pause_rate・stuck_rate は hop_m(role=site_only)が要るので空欄にする row["robot_speed_mps"] = Val(trips, "robot_speed_mps"); row["pause_rate"] = ""; row["pause_s_mean"] = Val(trips, "pause_s_mean"); row["stuck_rate"] = ""; row["trips"] = Val(trips, "trips"); row["timeouts"] = Val(trips, "timeouts"); row["stuck_count"] = Val(stops, "stuck_count"); row["stop_s_total"] = Val(stops, "stop_s_total"); row["corner_stop_time_share"] = Val(simLike, "corner_stop_time_share"); row["gave_up"] = Val(simLike, "gave_up"); row["warped"] = Val(simLike, "warped"); row["table_freed"] = Val(simLike, "table_freed"); row["rescues"] = Val(siteM, "rescues"); row["late_staff_dishes"] = Val(simLike, "late_staff_dishes"); row["staff_yield_share_of_staff_time"] = Val(simLike, "staff_yield_share_of_staff_time"); row["ledger_schema_version"] = ServiceSimLedger.Num(ServiceSimLedger.SchemaVersion); // ---- 依頼 v10b の 1:会計待ち・片付け待ちを試算と並べる(schema 2)---- // 詰まる場所が試算(レジ)と Unity(片付け)で違うので、 // 会計待ち・片付け待ちの卓・卓が使えるまでを、Unity とサイトの両方で並べる row["pay_wait_mean"] = Val(siteM, "pay_wait_mean"); row["blocked_wait_mean"] = Val(siteM, "blocked_wait_mean"); row["turn_mean"] = Val(siteM, "turn_mean"); if (cond != null) { row["site_pay_wait_mean"] = Site(cond, "pay_wait_mean"); row["site_blocked_wait_mean"] = Site(cond, "blocked_wait_mean"); row["site_turn_mean"] = Site(cond, "turn_mean"); } // 仕事が板に載ってから取られるまで(依頼 v10 の 2 で summary.json に入れた欄)。 // 仕事の名前は StaffAgent.JobKindName と同じものを使う(字を手で書かない) JsonValue jobMean = sum != null ? sum["job_wait_mean_s"] : null; JsonValue jobMax = sum != null ? sum["job_wait_max_s"] : null; JsonValue jobOver = sum != null ? sum["job_wait_over_count"] : null; string clearName = StaffAgent.JobKindName(StaffJobKind.Clear); string payName = StaffAgent.JobKindName(StaffJobKind.Pay); row["clear_wait_mean_s"] = Val(jobMean, clearName); row["clear_wait_max_s"] = Val(jobMax, clearName); // 600 秒を超えた片付けが1件も無いときは、summary.json にその名前が出ないので 0 を書く row["clear_wait_over_count"] = ValOrZero(jobOver, clearName); row["pay_job_wait_mean_s"] = Val(jobMean, payName); string ledger = inp.ledgerPath; string why; if (!ServiceSimLedger.Append(ledger, row, out why)) Debug.LogWarning("[ServiceSim] 台帳に足しませんでした: " + why); return folder + " / " + status + " / 実時間 " + (wallS / 60f).ToString("0.0") + " 分" + " / 入店待ち " + Or(row, "entry_wait_mean") + " 分" + " / ロボットが運んだ割合 " + Or(row, "robot_share"); } static string Val(JsonValue v, string key) { if (v == null || v.IsNull) return ""; JsonValue x = v[key]; if (x.IsNull) return ""; return ServiceSimLedger.Num(x.F(0f)); } /// /// 数を返すが、入れ物はあって名前が無いときは 0 を返す(空欄にしない)。 /// job_wait_over_count は「1件も無い」と「読めなかった」を分けたいので使う /// static string ValOrZero(JsonValue v, string key) { if (v == null || v.IsNull) return ""; JsonValue x = v[key]; return ServiceSimLedger.Num(x.IsNull ? 0f : x.F(0f)); } static string Site(SimParams.Condition c, string key) { float f; return c != null && c.siteResult.TryGetValue(key, out f) ? ServiceSimLedger.Num(f) : ""; } static string Or(Dictionary row, string key) { string v; return row.TryGetValue(key, out v) && v.Length > 0 ? v : "-"; } static JsonValue ReadJson(string path) { if (!File.Exists(path)) return null; try { string err; JsonValue v = JsonValue.Parse(File.ReadAllText(path), out err); return v != null && v.IsObject ? v : null; } catch (Exception) { return null; } } } }