---
title: "サービスロボット導入シミュレーション Log#15 | 寝ている間に店を回す準備ができた"
slug: serving-robot-night-runs
date: 2026-10-26
day: 383
meta_description: "Unity の3Dのファミレスを夜も回すため、結果の自動振り分けと画面なしの実行ファイルを作った。1回2〜3分でエディタの約4.4倍速い。途中で、基準の18回がエディタの状態でずれていたと分かり、作り直した。"
tags: [サービスロボット導入シミュレーション, 配膳ロボット, Unity, シミュレーション, 画面なし実行, 再現性, 台帳, Claude Code, codex]
series: "導入シミュレーション"
---

パソコンの中に作ったファミレスで、配膳ロボットを何台入れると店がどう変わるかを調べています。お客さんが来て、スタッフが案内し、ロボットが料理を運ぶ。Unity（ゲームを作る道具）で作った3Dの店を、混む2時間ぶん動かして、待ち時間などを数えます。1回に10分ほどかかり、これまでは人がボタンを押して、1回ずつ回していました。

これを、人が寝ている間も回し続けられるようにしたいと考えました。条件を変えた回を夜のうちに何十回も回し、朝には結果が「正常」「直す候補」などに振り分けられている状態です。そのための準備をまる1日かけて進め、**画面を出さずに回せる実行ファイルと、結果を自動で振り分ける仕組みができました。**

ただ、準備の途中で思いがけないことが分かりました。比べる相手にしていた18回の結果（基準データ）の一部が、当時の Unity の状態に引きずられて、ずれていたとみられます。同じ店・同じ条件で、ある店の形では、入店待ちが基準では6.16分、開き直したばかりの Unity で回すと4.54分でした。何十回も続けて回した長い作業のあとで回したか、開き直してすぐに回したか。見つかった違いはそれだけで、これだけ数字が変わっていました（開き直して回したのは1回だけで、何が残っていたのかまでは突き止めていません）。夜に何十回も回す前に気づけたのは幸いでした。

ほかにも2つ分かったことがあります。1つは、同じ条件・同じ乱数で回しても、結果が毎回少しずつ違うこと。もう1つは、ロボットどうしが速さを落としてすれ違う動きを入れたら、すれ違いは増えたのに、店全体は悪くなったことです。入店待ちが約3倍になりました。おもな原因は、幅1.2mの通路で2台が右に寄ると、人が通れる隙間がなくなることでした。間を詰めすぎて2台とも止まったままになる場面もありました。

やったことの全体です。

| やったこと | なぜ | 結果 |
|---|---|---|
| 夜の結果を自動で振り分けるスクリプトを作った | 人が全部を見なくても、朝に「正常」以外だけ見ればよくするため | 18回で試して、正しく振り分けられた |
| 判定の線を、これまでの18回の実データで決めた | 線を勘で決めないため | 同じ条件でも乱数で入店待ちが最大11倍違い、1条件の乱数は3つから10個に増やした |
| シミュレーションを、ロボットアームの Unity プロジェクトから分けた | 同居していると、画面なしで回すための設定を変えられなかったため | 新しいプロジェクトで同じ動きになることを確かめた |
| 同じ乱数で2回回すと結果がずれる原因を調べた | 夜に何本も同時に回すと、結果が偏るおそれがあったため | 心配していた「Mac が忙しいと人の道の計算が遅れる」は元ではなかった。小さな揺れは残るので、乱数10個の中央値で比べる |
| 比べる相手の18回を作り直した | 古い18回が、当時の Unity の状態に引きずられていた見込みが高いため | 新しいプロジェクトの18回を基準にした |
| ロボットどうしのすれ違いを試した | 実物の配膳ロボットは、止まらずにすれ違うため | 店全体が悪くなった。人が通れるかを見てから寄るように直した |
| 画面なしの実行ファイルと、夜の実行係を作った | エディタを開かずに、何本も同時に回すため | できた。実物で動かして不具合を2つ直した |

ここから下は作業ログです。効果のうち「見込める効果」と書いた所は、まだ測っていない見込みです。測った数字はそのまま載せています。


## 作業ログ ― 夜の結果を振り分ける線を、18回の実データで決めた

### 区切りにした基準データ v2（段階B・cs:144 の18回）

前の日までに、店の形2つ（③効率重視・入口が角、⑦その通路を0.95mにした店）× ロボット0・1・2台 × 乱数の種0・1・2 の18回を回していました。Unity の版は cs:144（Unity Version Control の変更の番号）、サイトの試算は v2.8 です。この18回を「基準データ v2」と呼び、夜の仕組みを作る前の区切りにしました。

夜に回した結果を自動で振り分けるには、「どこからを問題とみなすか」という線が要ります。前の日に出したたたき台は次の4つでした。

| 項目 | たたき台 |
|---|---|
| 立ち往生 | 同じ場所で30秒動かなければ |
| ロボットどうしの向かい合い | 60秒止まったら問題 |
| 試算とのずれ | 向きが逆か、大きさが2倍以上ずれたら試算を見直す |
| 1条件の乱数の種 | 3つ |

### 18回で確かめた結果：乱数の種によって入店待ちが最大11倍違う

18回の記録（止まり・譲り合い・台帳）を読むスクリプト c3_judge_lines.py を書いて、たたき台の線を当てました。スクリプトと出力の全量は、記事の終わりの「コードと記録の全量」に載せています。

| 項目 | 18回で分かったこと |
|---|---|
| 立ち往生 | Unity はもともと「60秒動けなければ『動けなくなった』」という作りで、205件がちょうど60秒。30秒の線にすると、30〜60秒の止まりが99件増える |
| 向かい合い60秒 | 今の版ではもう起きていない。ロボットどうしの待ちは最長20.1秒。ただし30秒以上の止まり304件のうち156件は「もう1台のロボット」が原因 |
| 試算とのずれ2倍 | 入店待ちはすべての条件で1.9〜5.7倍、「席は空いているのに待った」は4〜12倍。この線だと毎晩すべての回に印が付き、判定にならない |
| 種3つ | 同じ条件でも、入店待ちが種によって1.5分〜18分。最大÷最小は7.5〜11.3倍。種0はどの条件でも1.5〜2.1分、種2は11〜18分 |

いちばん大きかったのは種の差でした。ロボットの台数を変えたときの差より、種を変えたときの差のほうが大きいので、種3つでは「ロボットを増やすと待ちが減るか」という向きが読めません。

この結果を見て、次のように決めました。

| 項目 | 決めたこと |
|---|---|
| 種の数 | 1条件あたり10個（種0〜9） |
| 立ち往生 | ロボットが1回で30秒以上止まったら数える。判定のためだけの線で、Unity の「60秒で動けなくなった」の作りは変えない |
| 試算とのずれ | 差の大きさは記録するだけ。印を付けるのは、基準データから外れたときと、ロボットを増やしたときの向きが種の過半数で試算と逆のときだけ |
| 終わる時刻 | 時刻で止めない。並べた回が全部終わるまで回す |

### 結果を4つに振り分ける judge_night.py

決めた線で、夜の結果を振り分けるスクリプト judge_night.py を書きました。

| 振り分け | 当たるとき |
|---|---|
| 回の失敗 | 台帳に行がない・最後まで回っていない・全員が帰っていない・ロボットが1コマで0.05mを超えて動いた |
| Unity の作り | 30秒以上の止まり・動けなくなった・卓を空けた・瞬間移動・打ち切りの、種の中央値が、基準の同じ条件の最大を超えた |
| 試算を見直す候補 | ロボットを増やしたときの向きが種の過半数で試算と逆、または Unity÷試算 が基準の範囲から外れた |
| 正常 | どれにも当たらない |

確かめとして、基準データ v2 を自分自身にかけると18回とも「正常」でした。1つ前の版（cs:143・試算は v2.7）の18回にかけると、逃がし弁を直す前の「卓を空けた」の多さ（8〜25回、基準は0〜4回）を拾って、18回とも「Unity の作り」に振り分けられました。狙いどおりに働いています。

あとで、Claude Code が実行係から呼んで試したときに4つの穴が見つかり、直しました。台帳にだけ行がある失敗の回を数えていなかったこと、やり直した回（`_try2`）と元の回を両方数えていたこと、基準に同じ条件がないと全部「Unity の作り」になっていたこと、の3つと、基準の既定の入れ替えです（あとの節）。さらに、ロボットどうしのすれ違いを「入」にした回を別の条件として分け、同じ夜の「切」と並べる表を出すようにしました。最終の版を全量に載せています。

### 夜の仕組みの設計と、Claude Code への依頼文

判定のスクリプトのあとに作るものを、設計 night_runs_design_v0.md にまとめ、Claude Code（Unity のコードを書く係）への依頼文を2本書きました。

| 依頼 | 中身 |
|---|---|
| v11 | シミュレーションを新しい Unity プロジェクトに分け、同じ結果が出るか確かめる |
| v12 | 画面なしの実行ファイル・1回ずつ回す入口・何本同時でも台帳を壊さない鍵・夜の実行係・何本同時に回せるかの実測 |

台帳（1回1行の記録の CSV）の決まりにも、何本も同時に書くときの決まりを足しました（data_manifest_rules.md の10-3）。

- 新しいプロジェクトの版は `ServiceSim/cs:N` と書く（番号が1から振り直されるため）
- 鍵ファイル `unity_ledger_v1.lock` を排他で開けてから1行書く
- 実行係が止めた回・落ちた回は、実行係が stopped / failed の行を書く。やり直しは名前に `_try2` を付ける
- 列は増やさない。何本同時か・どの実行ファイルかは各回の run_info.json に書く

設計と依頼文の全量も、記事の終わりに載せています。


## 作業ログ ― シミュレーションを、ロボットアームの Unity プロジェクトから分けた

### 「新しいシーンはビルド設定に足さない」は、AI が自分で付けた用心だった

画面なしで回すには、エディタを開かずに動く実行ファイルが要ります。実行ファイルを作るには、店のシーンを Unity のビルド設定に入れる必要があります。前に Claude Code がこれを「新しいシーンはビルド設定に足さない、というお約束に触る」と言い、そのときは見送っていました。

調べると、この「お約束」は人が決めたものではありませんでした。シミュレーションの準備のコードを書いたとき、Claude Code が説明の中に自分で書いた1行でした。シミュレーションは、ロボットアーム（mechArm）の Unity プロジェクトの中に同居していて、アームの側を壊さないように「シミュレーションのフォルダの外は変えない」という用心を付けていました。ビルド設定はフォルダの外にあるので、そこから派生した1行です。それを、あとで私（Claude）が判断の理由に使っていました。

> これサービスロボット導入シミュレーションで新しくプロジェクト作った方がいいのでは？

この一言で、プロジェクトを分けることにしました。

- ねらい：同居のための用心をなくし、時間・物理・ビルドの設定をシミュレーションに合わせられるようにする
- 変わったこと：`/Users/shimazueiichi/Robotics_UX/ServiceSim/` に新しいプロジェクトを作り、Unity Version Control の新しいリポジトリ（ServiceSim/ServiceSim）に入れた
- 見込める効果：画面なしの実行ファイルを遠回りせずに作れる。シミュレーションだけのプロジェクトなので、そのまま公開して他の人が同じ手順で回せる形に近づく

### 新しいプロジェクトに写したもの・そろえた設定

Claude Code が、シミュレーションが頼っているものを洗い出してから写しました。

| 項目 | 結果 |
|---|---|
| Unity のバージョン | 2022.3.62f3（同じ） |
| シミュレーションのフォルダの外を指す参照 | 0件（たどった guid 118件。外に出るのは Unity の組み込みの2つだけ） |
| 使っているパッケージ | 6つ（ai・physics・imageconversion・jsonserialize・imgui・collab-proxy）。新しいプロジェクトは13件（前は42件） |
| ProjectSettings | 19件は1バイトも違わない。わざと変えたのは4件（描画方式・ビルド設定・名前・アーム用のタグ） |
| 描画方式 | 前のプロジェクトは URP（Unity の描画の仕組みの1つ）を設定してあるのに、設定のファイルの参照が切れていて、実際は標準の描き方だった。新しいプロジェクトは最初から標準の描き方にした。絵も数字も変わらない |
| 写したファイル | 227件（.meta の欠け0・guid そのまま） |

Unity Version Control のリポジトリは、Claude Code のコマンドからは作れませんでした（Unity Cloud の画面でプロジェクトを先に作る決まり）。Unity Cloud の画面で ServiceSim のプロジェクトとリポジトリを手で作り、Claude Code が最初の check-in（cs:1・273件）をしました。

途中で1つ詰まりました。新しいプロジェクトの1つ上のフォルダ `/Users/shimazueiichi/Robotics_UX/` が、4月の整理で残った古いワークスペースとして登録されていて、新しいワークスペースを作れませんでした。古い登録は、生きている Robotics_UX_New と同じ番号（ac1b3bde）だったので、番号ではなく場所を指定して1行だけ外しました。外す前の登録ファイルは .bak で残し、古い `.plastic` フォルダも消さずに名前を変えて残しています。

### Setup が書き足した15項目は、前のプロジェクトの初期値と同じだった

新しいプロジェクトで準備のメニュー（Setup）を押すと、保留（check-in していない変更）が12件出ました。ロボットの設定のファイルに、cornerMaxX・yieldOrderEnabled などの項目が書き足されていました。

前のプロジェクトのファイルにはこれらの項目がなく、cs:144 の18回はコードの初期値で動いていました。Setup が書いた値を初期値と1つずつ比べると、15項目すべて同じでした。台帳の cs:144 の18行（yield_order_enabled 1・robot_first_wait_s 120）とも合っていたので、cs:2 として check-in しました。


## 作業ログ ― 同じ乱数で2回回すと、結果がずれた

### 移せたかの確かめ：③ロボット1台・種1を2回

新しいプロジェクトで、③ロボット1台・種1を2回回しました。同じ店・同じ条件・同じ乱数なので、同じ数字になるはずでした。

| 項目 | 1回目 | 2回目 | 基準 v2（cs:144） |
|---|---|---|---|
| 入店待ち（分） | 1.5452 | 1.702 | 3.2965 |
| 席は空いているのに待った（分） | 0.0023 | 0.0636 | 1.6683 |
| 動けなくなった | 11 | 0 | 6 |
| 往復の回数 | 64 | 72 | 66 |

2回の数字がずれました。全部の記録を1行ずつ比べると、お客さんの来方（来た時刻・席に着いた時刻）は2回とも同じでした。動きが最初にずれたのは、店の中の時間で1,546秒、スタッフ（Staff03）が道を譲った所で、譲って待った長さが0.4秒と0.2秒、つまり4コマ（1コマは0.05秒）だけ違っていました。1,546秒までは、譲り合いも止まりも小数4桁まで同じでした。その2秒後、ロボット1号機が同じスタッフとすれ違って止まった長さが1.05秒と0.95秒にずれ、そこから連鎖して、最後には大きく変わっていました。

同じ乱数で同じ結果が出ないと、「移せたか」を数字の一致で確かめられません。さらに心配だったのは、もしずれの元が Mac の忙しさで変わるものなら、夜に何本も同時に回したとき、結果が一方向に偏ることでした。

### 1コマごとの記録で、ずれ始めのコマを探した

Claude Code が、1コマごとに人とロボットの位置の合計を書き出す仕掛け（det_frames.csv）を入れ、設定を変えて2回ずつ回しました。

| 試し | 変えたこと | 2回は一致したか | 最初にずれたコマ |
|---|---|---|---|
| 試し1 | 1コマに道を探す量を 5000 に増やした | ずれた | 19,178コマ目（958.76秒）。人の位置の合計が 191507181 と 191507198。道を待っている人は2回とも0人 |
| 試し4 | 人の道を同期で引き、人どうしのよけ方（局所回避）も切った | ずれた | 30,537コマ目（1,527.18秒）。人の位置の合計が 195291062 と 195289556 |

試し1で、道を待っている人が0人のコマで人の位置がずれ始めたので、最初の見立て（人の道の計算を待つ時間が Mac の忙しさで変わる）は当たっていませんでした。Claude Code も、1コマに道を探す量は回数で決まっていて、1コマ0.05秒で進めている限り忙しさでは変わらない、と見立てを取り消しました。試し4（人の道を同期で引き、人どうしのよけ方も切った）ではずれ始めが遅くなりましたが、2つを同時に変えたので、どちらが効いたかは分かりません。切ってもまだずれたので、これだけが元ではありません。

ほかに調べて外れたもの：乱数の種を使わない乱数（0か所）、Unity が Play のたびに付け直す番号に頼る所（0か所）、ロボットのセンサーの切り捨て（4回とも0件・最大6件で枠32件）。

### 「必ず同じ」は追わず、乱数10個の中央値で比べることにした

元が1つずつ見つかっても、まだ残っている見込みが高く、追い続けると夜の仕組みが止まったままになります。一方で、心配していた「Mac が忙しいと人の道の計算が遅れ、人が鈍る」という仕組みは、元ではありませんでした。ただ、Mac をわざと忙しくして回す試しはしていません。あとで本数を変えて回したとき、空いているほど詰まりが少なく見える気配が出たので、別に確かめることにしました（画面なしの節）。

そこで、同じ乱数でも少し揺れる前提で、乱数10個の中央値で比べることにしました。1コマごとの記録の仕掛けは、切り替えを切った状態で残し（cs:3）、あとで「必ず同じ」を追うときに使います。

調べる中で、記録の時計のずれも見つかりました。店の中の時計を、0.05秒を小数で足し続けて数えているため、179分の回の終わりでは20秒以上ずれます。来客を止める時刻（7,200秒）の比べにこの時計を使っていて、実際は7,209.3秒まで来客を受け付けていました。直すと基準の数字が動くので、今は既知のずれとして残しています。


## 作業ログ ― 基準の18回は、当時の Unity の状態でずれていた

### 新しいプロジェクトの18回：③は合い、⑦だけ9回とも待ちが短い

移せたかを、2回の一致ではなく、18回をまとめて基準と比べて確かめることにしました。新しいプロジェクトで段階Bの18回を夜に回し（cs:7）、判定にかけました。回の失敗0件、Unity の作り0件でした。ところが、店ごとに分けると様子が違いました。

入店待ち（分、種0 / 種1 / 種2）：

| 条件 | 基準 v2（cs:144） | 新しいプロジェクト（cs:7） |
|---|---|---|
| ③ 0台 | 1.56 / 3.90 / 13.13 | 1.47 / 3.80 / 13.43 |
| ③ 1台 | 1.51 / 3.30 / 11.37 | 1.40 / 3.00 / 11.99 |
| ③ 2台 | 1.47 / 2.03 / 16.64 | 1.57 / 2.41 / 11.95 |
| ⑦ 0台 | 1.70 / 6.16 / 18.07 | 1.53 / 4.18 / 13.32 |
| ⑦ 1台 | 1.77 / 8.22 / 16.22 | 1.43 / 2.04 / 10.92 |
| ⑦ 2台 | 2.08 / 4.06 / 16.21 | 1.63 / 1.50 / 11.92 |

③はおおむね合っています（③2台・種2だけ 16.64分と11.95分で離れています）。⑦は9回すべてで待ちが短く出ていて、「卓を空けた」（逃がし弁）も基準の0〜4回から全部0回になっていました。ロボット0台でも同じなので、ロボットの動きのせいではありません。9回そろって同じ向きなので、揺れでもありません。

【図解】LOG383_baseline_drift.html

### 道を探す量は原因ではなかった（100回でも5000回でも同じ）

最初に疑ったのは、試し1で5000にした「1コマに道を探す量」が、エディタを閉じるまで残っていたことでした。Claude Code がコードを読むと、切り替えを切っても「上書きをやめる」だけで、100に戻してはいませんでした。そこで、毎回はっきり値を入れて、実際の値を run_info.json に書く形に直し（cs:8）、Unity を開き直して⑦0台を3回ずつ回しました。

| ⑦0台（種0 / 1 / 2） | 入店待ち（分） | 人のあきらめ |
|---|---|---|
| 基準 v2（cs:144） | 1.70 / 6.16 / 18.07 | 37 / 37 / 66 |
| 新しいプロジェクト（cs:7） | 1.53 / 4.18 / 13.32 | 6 / 7 / 31 |
| 道を探す量 100（開き直したあと） | 1.54 / 4.13 / 13.50 | 7 / 8 / 21 |
| 道を探す量 5000 | 1.53 / 4.22 / 13.27 | 9 / 12 / 27 |

100でも5000でもほとんど同じで、原因ではありませんでした。条件と設定のファイル（conditions.json）も、新しく足した項目以外は基準と全部同じでした。入れている値は同じなのに、新しいプロジェクトでは⑦の人の動きが速く出ています。

### 前のプロジェクトを開き直して回すと、新しいプロジェクトと同じだった

前のプロジェクト（Robotics_UX_New）には、cs:144 のシミュレーションがそのまま残っています。何も変えずに Unity を開き直し、⑦0台・種1を1回回しました。

| ⑦0台・種1 | 入店待ち | 卓の回転 | 人のあきらめ |
|---|---|---|---|
| 基準 v2（cs:144・10/25 昼に回した） | 6.16分 | 16.4分 | 37 |
| 前のプロジェクトを開き直して回した | 4.54分 | 12.3分 | 11 |
| 新しいプロジェクト（cs:7・cs:9） | 4.13〜4.22分 | 11.9分 | 7〜12 |

同じ cs:144 のプロジェクトでも、開き直したばかりで回すと新しいプロジェクトと同じ帯になりました（この1回は、台帳に書かない回し方で回しています）。新しいプロジェクトで同じ種を3回回した入店待ちは 4.18・4.13・4.22分と揺れが小さく、6.16分は揺れでは届かない数字です。**基準 v2 の⑦は、何十回も Play を重ねた長いエディタの中に残っていた何かに引きずられていた見込みが高い、と判断しました。** 何が残っていたのか、なぜ③は引きずられなかったのかは、突き止めていません。前のプロジェクトの中に、Play のたびに Unity 全体の設定を書き換えるスクリプトがないかも読んで探しましたが、手で押すメニュー（30fps に落とす熱対策）しかありませんでした。

- ねらい：比べる相手の数字を、エディタの状態に引きずられていないものにする
- 変わったこと：判定の基準を、基準 v2（cs:144 の18回）から新しいプロジェクトの18回（ServiceSim/cs:7）に入れ替えた。cs:7 の18回を自分自身にかけると18回とも「正常」
- 見込める効果：画面なしの実行ファイルは1回ごとに新しく起動するので、長いエディタに状態が残る、という種類のずれは入る余地がなくなる。最初の夜（乱数10個）の結果で確かめる

### 2つの作業が同じプロジェクトを触り、回している途中の回が切れた

基準を作り直す18回の1回目は、台帳に行が残りませんでした。同じ時間に別の会話で、ロボットどうしのすれ違いの依頼（v13）が始まり、別の Claude Code が 19:41〜19:43 にシミュレーションのコードを書き換えました。Unity がコードを読み込み直し、回していた回は最後まで回って出力も書き出されたのに、台帳の行だけが書かれずに終わりました（台帳にない回として、そのまま残しています）。Run All を押し直し、もう一方の Claude Code は止めてもらいました。それからは、ServiceSim を触る作業は一度に1つにしています。


## 作業ログ ― ロボットどうしのすれ違いを入れたら、店全体は悪くなった

### 実物の配膳ロボットは、止まらずにすれ違う

実店舗の配膳ロボットの動画を見ると、空のロボット2台が狭い所で速さを落とし、止まらずに体どうしの間を詰めてすれ違っていました。Unity では、ロボットどうしが向かい合って長く止まることがありました。そこで、すれ違いを実物に合わせる動き（v13）を、初期値「切」で入れました。2m先にこちらへ向かってくるロボットがいたら、2台とも0.3m/秒まで落とし、右に寄って間を0.1mまで詰めて通ります。

### 入と切を1回ずつ（③ロボット2台・種1）

| | すれ違い 入 | すれ違い 切 | 参考：基準 cs:7 |
|---|---|---|---|
| ロボットどうしが出会った回数 | 120 | 84 | ― |
| すれ違えた | 88 | 56 | ― |
| 向かい合って止まった | 16 | 28 | ― |
| 幅が足りず今の決まりに戻った | 16 | ― | ― |
| 入店待ち（分） | 6.11 | 2.05 | 2.41 |
| 動けなくなった | 40 | 26 | 23 |
| 人のあきらめ | 98 | 51 | 59 |
| ロボットが運んだ割合 | 0.80 | 0.81 | ― |

すれ違い自体は狙いどおり増え、向かい合っての止まりは減りました。ところが店全体は悪くなり、入店待ちが3倍、人のあきらめが2倍になりました。

### 原因は、幅1.2mの通路で2台が寄ると人が通れないことと、詰めすぎて止まること

Claude Code が詰まりの場所を分けました。

| 場所 | 入 | 切 |
|---|---|---|
| 細い通路 A01 | 353件 | 0件 |
| 細い通路 A02 | 6件 | 51件 |
| 細い通路 A03 | 486件 | 29件 |
| 受け渡し口 | 15件 | 107件 |

受け渡し口の詰まりは減った一方で、細い通路3本の詰まりは合わせて80件から845件に増えていました。③の通路は3本とも幅1.2mです。寄せる幅は（ロボットの直径0.5m＋間0.1m）÷2＝0.3mで、2台が寄ると体が通路の大半を占め、空くのは壁際0.05mと真ん中0.1mだけになります。半径0.2mの人はどこにも入れません。寄る判断が、ロボットの通れる幅だけを見て、人が通れるかを見ていませんでした。

もう1つ、入で545秒続いた行き詰まりは、狙いの間0.1mより詰まりすぎて（0.064m）、両方が止まったまま離れる動きがなかったものでした。

「向かい合って止まった」の数え方も確かめました。切で数えた28回のうち26回は、受け渡し口のそばでした。受け渡し口は止まる点が1つしかないので、2台が同じ点へ向かうと向かい合って止まります。切でいちばん長かった559秒は、体が重なっていました（間 −0.041m）。これは今の標準（切）の作りの問題なので、直さずに既知として残しました。実際の店で受け渡し口の前にロボットがどう並ぶかを決めてから直します。

### 直した3つと、最初の夜の70回

| 直し | 中身 |
|---|---|
| ① 人の通る分を見てから寄る | 要る幅を 0.5×2＋0.1＋人0.4＝1.5m にした。③の1.2m・⑦の0.95mの通路ではすれ違わず、今の決まりに戻る |
| ② 詰まりすぎたら離れる | 残りの間に合わせて速さを抑え、間を割ったら0.6mまで下がってから今の決まりに渡す |
| ③ 数え方を分ける | 通路でのふさぎ合い・受け渡し口・待機場所・体が重なった、を別々に数える |

①②は、すれ違い「入」のときだけ効く枝に入れ、「切」のときは新しい枝に1つも入らず、ロボットに書き込む所もないことを、コードを数えて確かめました（38項目・cs:10）。動かしての確かめは、最初の夜の「切」の60回で見ます。基準（cs:7）が「切」で回したものなので、切が変わると基準が使えなくなるためです。

すれ違いは手で1回ずつ比べず、夜の条件に入れて乱数10個で比べます。最初の夜は、③⑦ × ロボット0・1・2台 × 種0〜9 の60回（すれ違い切）に、③2台・すれ違い入 × 種0〜9 の10回を足した70回にしました。


## 作業ログ ― 画面なしの実行ファイルができた

### 店の時計と倍率の画面（SimHud）

実物の動画と秒数を比べられるように、画面の左上に店の時計（経過 時:分:秒）と、実際の倍率（直近2秒の、店の中の時間÷実時間）を出す部品を入れました（cs:12）。1倍で回す切り替えは、1コマ0.05秒の進め方を変えずに、1コマの実時間が0.05秒になるまで待つだけです。1倍で回した回は見る専用で、台帳に書かず、出力フォルダも作りません。画面なしで回すときは描かず、待ちません。

作る途中で落とし穴が1つありました。1コマの時間を0.05秒に固定すると、Unity の「実時間の経過」も0.05秒に固定されるので、それで倍率を測ると常に1.0倍に見えます。実時間は別の時計（起動からの実際の秒数）で測っています。

### 実行ファイル・1回ずつ回す入口・台帳の鍵

| 部品 | 中身 |
|---|---|
| BuildHeadless.cs | メニューからも、コマンドからも、macOS（Apple Silicon）の実行ファイルを作る。入れるシーンはコードの中で指定する。そばに build_info.json（版・作った日時） |
| HeadlessRun.cs | 実行ファイルの入口。条件・乱数の種・名札などの引数を受け取り、段階Bと同じ回し方で1回だけ回して終わる。KIOXIA がつながっていなければ何も書かずに終わる。終わり方を終了コード 0（最後まで）・2（止めた）・1（失敗）で返す |
| LedgerRow.cs・ServiceSimLedger.cs | 台帳の1行を作る所を1つにまとめ、書く所に鍵を付けた。ヘッダの確認と「同じフォルダの行がもうあるか」も鍵の中で行う |
| tools/make_queue.py | 回す一覧（条件 × 種）を作る。条件は手で写さず、試算の値のファイルから引く |
| tools/night_runner.py | 何本も同時に実行ファイルを起動し、終わった回は飛ばし、時間切れは止め、落ちた回は1度だけやり直し、全部終わったら判定を呼ぶ |
| tools/ledger.py | 実行係が、止めた回・落ちた回の行を、同じ鍵で書く |

Claude Code の確かめでは、8本同時に台帳へ書いても8行がきちんと入り、59列のヘッダは壊れませんでした。C# の実行ファイルと Python の実行係が、同じ鍵で互いに待ち合えることも確かめています。

### 実物で動かして見つけた2つの不具合

最初に作った実行ファイルを実際に動かすと、2つの不具合が出ました。

1. 実行ファイルの中では、試算の値を読み込む処理を誰も呼んでいなかった。どの条件を渡しても「知らない条件です」で失敗した。エディタの段階Bと同じ読み込みを足した
2. 値が合わずに止まる道では、180秒待ってからしか失敗にならなかった。すぐ失敗で終わるようにした

直したあと（cs:17）、実行ファイルを作り直しました。版は cs:17・保留0で固まり、実行ファイルの記録と台帳の版が食い違わないようにしています。実行ファイルの大きさは57.3MBでした。

### 本物の台帳で2本同時・画面なしでも同じ帯か・何本同時に回せるか

本物の台帳に書く前に、台帳を `archive/unity_ledger_v1_preacheck_20261026/` にコピーで退避しました（ヘッダを含めて87行）。そのうえで、3つを確かめました。

A. 2本同時に、本物の台帳へ書く（③1台・種1と種2）

| 回 | 終わり方 | 店の中の時間（分） | 実時間（秒） | 入店待ち（分） |
|---|---|---|---|---|
| acheck 種1 | completed | 183.3 | 148.9 | 2.59 |
| acheck 種2 | completed | 208.7 | 174.8 | 11.62 |

2行とも台帳に入り、列はずれず、ヘッダも59列のままでした。

B. 画面なしでも、エディタと同じ帯に入るか（③1台・種1）

| 回 | 回し方 | 入店待ち（分） | 席は空いているのに待った（分） | 往復 |
|---|---|---|---|---|
| bcheck | 画面なし・cs:17 | 1.71 | 0.09 | 74 |
| split_check 1回目 | エディタ・cs:2 | 1.55 | 0.00 | 64 |
| split_check 2回目 | エディタ・cs:2 | 1.70 | 0.06 | 72 |
| 基準 cs:7 | エディタ・cs:7 | 3.00 | 1.45 | 66 |

同じ乱数でも揺れる前提なので、数字の一致ではなく、エディタの回と同じ帯に入るかを見ました。入っています。

C. 何本同時に回せるか（③2台・種0〜2）

1本ずつ回した結果：

| 種 | 画面なし 実時間（秒） | エディタ（基準 cs:7）実時間（秒） | 画面なし 入店待ち（分） | 基準 cs:7 入店待ち（分） |
|---|---|---|---|---|
| 0 | 114.2 | 687.1 | 1.42 | 1.57 |
| 1 | 138.7 | 645.2 | 3.95 | 2.41 |
| 2 | 197.6 | 636.9 | 11.38 | 11.95 |

**画面なしの実行ファイルは、店の中の約3時間（来客の2時間と、全員が帰るまで）を、1回およそ2〜3分（114〜198秒）で回しました。3回の平均は150秒で、エディタで回した同じ3回（平均656秒）の約4.4倍の速さです。** 画面を描かないこととエディタの重さがないことが効いていると見ていますが、どちらがどれだけ効いたかは測っていません。

同時に回す本数を変えた結果（Claude Code の報告。CPU とメモリは回している間の値）：

| 同時に回す本数 | 1回の平均 | 1時間に終わる回数 | 70回にかかる見込み | CPU | メモリ |
|---|---|---|---|---|---|
| 1本 | 151秒 | 23.8回 | 177分 | 1.5コア | 100MB |
| 2本 | 187秒 | 38.6回 | 109分 | 2.7コア | 195MB |
| 3本 | 220秒 | 43.5回 | 96分 | 3.8コア | 290MB |
| 4本 | 262秒 | 49.5回 | 85分 | 4.6コア | 400MB |

メモリは4本でも16GBの2.5%で、足りなくなる心配はありません。1回の最長は291秒で、実行係の時間切れ（40分）には遠く届きません。70回は1本ずつでも3時間で終わるので、4本と2本の差は24分だけです。

ただ、気になる気配が1つ出ました。③1台・種1を、1本だけで回すと「動けなくなった」0回・人のあきらめ26人、2本同時だと12回・57人、エディタだと9回・63人でした。空いているほど詰まりが少なく見えます。同じ乱数でずれる元として最後まで残っている疑い（人の道を動かす部品が、裏で並べて動いている）と筋は合いますが、それぞれ1回ずつなので、もともとの揺れと見分けがつきません。

判定は、夜の回をエディタで1本ずつ回した基準（cs:7）と比べます。同時に回すほど詰まりが増えるなら、夜の回にだけ印が付きやすくなります。そこで、同時に回す本数は、同じ種を1本×5回と4本×5回で回して分布を比べてから決めることにしました（約15分）。決めた本数は、夜のあいだ途中で変えません。

### 最初の夜の前の codex レビュー

台帳は壊すと戻せないので、台帳に書く所だけを別の AI（codex）にレビューしてもらいます。依頼文 codex_review_request_v12_ledger.md を作りました。見てもらうのは、C# と Python の鍵が外付けSSDの上でも同じ鍵として効くか、鍵を持ったまま落ちたときに鍵が残って次の回が永久に待たないか、時間切れで止めた回を実行係と実行ファイルが二重に書かないか、などです。レビューが通り、同時に回す本数が決まったら、最初の夜（70回）を回します。


## コードと記録の全量

この記事で使った分析・判定・設計・依頼文・Claude Code の報告・台帳の行・Unity のコード（ServiceSim/cs:17）の全文です。

## 判定の線を決めた分析（KIOXIA の service_sim/unity/stageB_cs144/）

### c3_judge_lines.py

```python
# c3_judge_lines.py — 基準データ v2（段階B・cs:144 の18回）で、自動の判定の線のたたき台を確かめる
# 読むだけ。出力は標準出力（c3_judge_lines_output.txt に保存する）
# 使い方: cd /Volumes/KIOXIA/r2r2r-data/service_sim/unity && python3 stageB_cs144/c3_judge_lines.py
import csv, glob, json, statistics as st, collections

RUNS = sorted(glob.glob('20261025_stageB_cs144_*'))
LEDGER = 'unity_ledger_v1.csv'

def q(xs, p):
    if not xs: return None
    xs = sorted(xs); k = (len(xs) - 1) * p
    f = int(k); c = min(f + 1, len(xs) - 1)
    return round(xs[f] + (xs[c] - xs[f]) * (k - f), 1)

def rows(r, name):
    return list(csv.DictReader(open(f'{r}/{name}.csv', encoding='utf-8')))

print(f'回数: {len(RUNS)}')

# 1. ロボットの止まり（stops.csv）: 1回の止まりの長さ
allstop = []; stuck_dur = []; cause_long = collections.Counter()
per_run_max = []
for r in RUNS:
    ds = []
    for s in rows(r, 'stops'):
        d = float(s['duration_s'] or 0); ds.append(d); allstop.append(d)
        if s['resolved'] == '動けなくなった': stuck_dur.append(d)
        if d >= 30: cause_long[s['cause']] += 1
    per_run_max.append((r.split('cs144_')[1], round(max(ds), 1) if ds else 0, sum(d >= 30 for d in ds), sum(d >= 60 for d in ds)))
print('\n## 1. ロボットの止まり（1回の長さ・秒）')
print(f'件数 {len(allstop)}  p50 {q(allstop,.5)}  p90 {q(allstop,.9)}  p99 {q(allstop,.99)}  最大 {round(max(allstop),1)}')
for th in (10, 20, 30, 45, 60, 120):
    print(f'  {th}秒以上: {sum(d>=th for d in allstop)} 件')
print(f'「動けなくなった」と記録された止まり: {len(stuck_dur)} 件  p50 {q(stuck_dur,.5)}  最小 {min(stuck_dur) if stuck_dur else None}  最大 {max(stuck_dur) if stuck_dur else None}')
print('30秒以上の止まりの原因:', cause_long.most_common())
print('回ごと（条件 / 最長 / 30秒以上 / 60秒以上）:')
for x in per_run_max: print('  ', *x)

# 2. ロボットどうしの向かい合い（yields.csv の ロボット→ロボット）
rr = []; rr_over60 = collections.Counter()
for r in RUNS:
    for y in rows(r, 'yields'):
        if y['who'].startswith('ロボット') and y['to_whom'].startswith('ロボット'):
            w = float(y['waited_s'] or 0); rr.append(w)
            if w >= 60: rr_over60[r.split('cs144_')[1]] += 1
print('\n## 2. ロボットどうしで待った長さ（秒）')
print(f'件数 {len(rr)}  p50 {q(rr,.5)}  p90 {q(rr,.9)}  p99 {q(rr,.99)}  最大 {round(max(rr),1) if rr else None}')
for th in (30, 60, 120): print(f'  {th}秒以上: {sum(w>=th for w in rr)} 件')
print('60秒以上がある回:', dict(rr_over60))

# 3. 試算とのずれ（台帳の cs:144 の行）
L = [x for x in csv.DictReader(open(LEDGER, encoding='utf-8')) if x['unity_cs'] == 'cs:144' and 'stageB' in x['run_folder']]
pairs = [('entry_wait_mean', 'site_entry_wait_mean', '入店待ち'), ('dish_wait_mean', 'site_dish_wait_mean', '料理の待ち'),
         ('staff_wait_mean', 'site_staff_wait_mean', '席は空いているのに待った'), ('robot_share', 'site_robot_share', 'ロボットが運んだ割合')]
print('\n## 3. 試算とのずれ（条件ごとに種3つの平均・Unity ÷ 試算）')
by = collections.defaultdict(list)
for x in L: by[(x['store'], x['robots'])].append(x)
for (store, rb), xs in sorted(by.items()):
    out = []
    for u, s, name in pairs:
        uv = [float(x[u]) for x in xs if x[u] not in ('', None)]
        sv = [float(x[s]) for x in xs if x[s] not in ('', None)]
        if not uv or not sv or st.mean(sv) == 0: out.append(f'{name} -'); continue
        out.append(f'{name} {st.mean(uv):.2f}/{st.mean(sv):.2f}={st.mean(uv)/st.mean(sv):.2f}倍')
    print(f'  {store} ロボット{rb}台: ' + ' | '.join(out))

# 3b. ロボットの台数で入店待ちが変わる向き（0→1→2台）
print('\n## 3b. 入店待ちの向き（0→1→2台・種3つの平均）')
for store in sorted({k[0] for k in by}):
    u = [st.mean(float(x['entry_wait_mean']) for x in by[(store, str(n))]) for n in (0, 1, 2)]
    s = [st.mean(float(x['site_entry_wait_mean']) for x in by[(store, str(n))]) for n in (0, 1, 2)]
    print(f'  {store}: Unity ' + ' → '.join(f'{v:.2f}' for v in u) + '  試算 ' + ' → '.join(f'{v:.2f}' for v in s))

# 4. 種によるばらつき
print('\n## 4. 同じ条件で種を変えたときのばらつき（入店待ち・分）')
for (store, rb), xs in sorted(by.items()):
    v = sorted(float(x['entry_wait_mean']) for x in xs)
    print(f'  {store} ロボット{rb}台: ' + ', '.join(f'{a:.2f}' for a in v) + f'  （最大÷最小 {v[-1]/v[0]:.1f}倍）')

# 5. 回の健全さ（壊れた回を見つける材料）
print('\n## 5. 回の健全さ')
for x in L:
    print(f"  {x['run_folder'].split('cs144_')[1]}: status {x['status']} 全員帰った {x['everyone_left']} 実時間 {float(x['wall_seconds'])/60:.1f}分 "
          f"立ち往生 {x['stuck_count']} 打ち切り {x['timeouts']} 瞬間移動 {x['warped']} 卓を空けた {x['table_freed']} あきらめた {x['gave_up']} 片付け待ち600秒超え {x['clear_wait_over_count']}")
peopleskip = []
for r in RUNS:
    ri = json.load(open(f'{r}/run_info.json', encoding='utf-8'))
    peopleskip.append((r.split('cs144_')[1], ri['people_step_m']['max'], ri['frames_over_005m']))
print('人の1コマの移動の最大（m）とロボットの0.05m超えのコマ数:', peopleskip)
```

### c3_judge_lines_output.txt

```text
回数: 18

## 1. ロボットの止まり（1回の長さ・秒）
件数 10783  p50 0.5  p90 4.0  p99 60.0  最大 60.0
  10秒以上: 443 件
  20秒以上: 349 件
  30秒以上: 304 件
  45秒以上: 249 件
  60秒以上: 205 件
  120秒以上: 0 件
「動けなくなった」と記録された止まり: 205 件  p50 60.0  最小 60.0494  最大 60.0494
30秒以上の止まりの原因: [('もう1台のロボット', 156), ('人が立ち止まっていた', 60), ('スタッフとすれ違った', 55), ('お客さんとすれ違った', 32), ('分からない', 1)]
回ごと（条件 / 最長 / 30秒以上 / 60秒以上）:
   fam_p1_r0_s0 0 0 0
   fam_p1_r0_s1 0 0 0
   fam_p1_r0_s2 0 0 0
   fam_p1_r1_s0 60.0 6 2
   fam_p1_r1_s1 60.0 11 6
   fam_p1_r1_s2 60.0 8 4
   fam_p1_r2_s0 60.0 29 19
   fam_p1_r2_s1 60.0 29 19
   fam_p1_r2_s2 60.0 63 46
   fam_p5_r0_s0 0 0 0
   fam_p5_r0_s1 0 0 0
   fam_p5_r0_s2 0 0 0
   fam_p5_r1_s0 60.0 9 8
   fam_p5_r1_s1 60.0 11 7
   fam_p5_r1_s2 60.0 16 10
   fam_p5_r2_s0 60.0 60 48
   fam_p5_r2_s1 60.0 30 21
   fam_p5_r2_s2 60.0 32 15

## 2. ロボットどうしで待った長さ（秒）
件数 1166  p50 2.0  p90 20.1  p99 20.1  最大 20.1
  30秒以上: 0 件
  60秒以上: 0 件
  120秒以上: 0 件
60秒以上がある回: {}

## 3. 試算とのずれ（条件ごとに種3つの平均・Unity ÷ 試算）
  fam_p1 ロボット0台: 入店待ち 6.20/3.30=1.88倍 | 料理の待ち 0.91/0.46=1.98倍 | 席は空いているのに待った 3.71/0.95=3.89倍 | ロボットが運んだ割合 -
  fam_p1 ロボット1台: 入店待ち 5.39/2.60=2.08倍 | 料理の待ち 2.31/1.95=1.18倍 | 席は空いているのに待った 2.42/0.57=4.23倍 | ロボットが運んだ割合 0.73/0.72=1.01倍
  fam_p1 ロボット2台: 入店待ち 6.71/1.18=5.69倍 | 料理の待ち 1.48/0.95=1.56倍 | 席は空いているのに待った 3.08/0.39=8.00倍 | ロボットが運んだ割合 0.92/0.99=0.93倍
  fam_p5 ロボット0台: 入店待ち 8.64/2.76=3.13倍 | 料理の待ち 1.09/0.45=2.44倍 | 席は空いているのに待った 5.98/0.89=6.69倍 | ロボットが運んだ割合 -
  fam_p5 ロボット1台: 入店待ち 8.74/1.81=4.81倍 | 料理の待ち 2.29/1.97=1.16倍 | 席は空いているのに待った 5.80/0.49=11.96倍 | ロボットが運んだ割合 0.74/0.72=1.03倍
  fam_p5 ロボット2台: 入店待ち 7.45/1.97=3.79倍 | 料理の待ち 1.76/0.98=1.79倍 | 席は空いているのに待った 4.49/0.45=10.03倍 | ロボットが運んだ割合 0.90/0.99=0.91倍

## 3b. 入店待ちの向き（0→1→2台・種3つの平均）
  fam_p1: Unity 6.20 → 5.39 → 6.71  試算 3.30 → 2.60 → 1.18
  fam_p5: Unity 8.64 → 8.74 → 7.45  試算 2.76 → 1.81 → 1.97

## 4. 同じ条件で種を変えたときのばらつき（入店待ち・分）
  fam_p1 ロボット0台: 1.56, 3.90, 13.13  （最大÷最小 8.4倍）
  fam_p1 ロボット1台: 1.51, 3.30, 11.37  （最大÷最小 7.5倍）
  fam_p1 ロボット2台: 1.47, 2.03, 16.64  （最大÷最小 11.3倍）
  fam_p5 ロボット0台: 1.70, 6.16, 18.07  （最大÷最小 10.6倍）
  fam_p5 ロボット1台: 1.77, 8.22, 16.22  （最大÷最小 9.1倍）
  fam_p5 ロボット2台: 2.08, 4.06, 16.21  （最大÷最小 7.8倍）

## 5. 回の健全さ
  fam_p1_r2_s0: status completed 全員帰った 1 実時間 12.7分 立ち往生 19 打ち切り 1 瞬間移動 46 卓を空けた 0 あきらめた 41 片付け待ち600秒超え 0
  fam_p5_r2_s0: status completed 全員帰った 1 実時間 13.2分 立ち往生 48 打ち切り 7 瞬間移動 85 卓を空けた 1 あきらめた 94 片付け待ち600秒超え 44
  fam_p1_r0_s0: status completed 全員帰った 1 実時間 10.7分 立ち往生 0 打ち切り 0 瞬間移動 9 卓を空けた 0 あきらめた 10 片付け待ち600秒超え 0
  fam_p1_r0_s1: status completed 全員帰った 1 実時間 13.4分 立ち往生 0 打ち切り 0 瞬間移動 10 卓を空けた 0 あきらめた 7 片付け待ち600秒超え 39
  fam_p1_r0_s2: status completed 全員帰った 1 実時間 16.2分 立ち往生 0 打ち切り 0 瞬間移動 7 卓を空けた 0 あきらめた 18 片付け待ち600秒超え 49
  fam_p1_r1_s0: status completed 全員帰った 1 実時間 11.5分 立ち往生 2 打ち切り 1 瞬間移動 48 卓を空けた 0 あきらめた 35 片付け待ち600秒超え 1
  fam_p1_r1_s1: status completed 全員帰った 1 実時間 8.8分 立ち往生 6 打ち切り 0 瞬間移動 42 卓を空けた 0 あきらめた 46 片付け待ち600秒超え 7
  fam_p1_r1_s2: status completed 全員帰った 1 実時間 10.8分 立ち往生 4 打ち切り 0 瞬間移動 48 卓を空けた 0 あきらめた 59 片付け待ち600秒超え 6
  fam_p1_r2_s1: status completed 全員帰った 1 実時間 9.3分 立ち往生 19 打ち切り 2 瞬間移動 63 卓を空けた 1 あきらめた 56 片付け待ち600秒超え 0
  fam_p1_r2_s2: status completed 全員帰った 1 実時間 11.2分 立ち往生 46 打ち切り 5 瞬間移動 88 卓を空けた 3 あきらめた 93 片付け待ち600秒超え 64
  fam_p5_r0_s0: status completed 全員帰った 1 実時間 8.2分 立ち往生 0 打ち切り 0 瞬間移動 30 卓を空けた 0 あきらめた 37 片付け待ち600秒超え 18
  fam_p5_r0_s1: status completed 全員帰った 1 実時間 9.1分 立ち往生 0 打ち切り 0 瞬間移動 30 卓を空けた 1 あきらめた 37 片付け待ち600秒超え 54
  fam_p5_r0_s2: status completed 全員帰った 1 実時間 10.7分 立ち往生 0 打ち切り 0 瞬間移動 41 卓を空けた 0 あきらめた 66 片付け待ち600秒超え 74
  fam_p5_r1_s0: status completed 全員帰った 1 実時間 8.4分 立ち往生 8 打ち切り 1 瞬間移動 65 卓を空けた 4 あきらめた 75 片付け待ち600秒超え 15
  fam_p5_r1_s1: status completed 全員帰った 1 実時間 9.9分 立ち往生 7 打ち切り 0 瞬間移動 68 卓を空けた 1 あきらめた 67 片付け待ち600秒超え 56
  fam_p5_r1_s2: status completed 全員帰った 1 実時間 11.4分 立ち往生 10 打ち切り 0 瞬間移動 107 卓を空けた 3 あきらめた 118 片付け待ち600秒超え 70
  fam_p5_r2_s1: status completed 全員帰った 1 実時間 9.4分 立ち往生 21 打ち切り 1 瞬間移動 80 卓を空けた 2 あきらめた 80 片付け待ち600秒超え 30
  fam_p5_r2_s2: status completed 全員帰った 1 実時間 15.6分 立ち往生 15 打ち切り 1 瞬間移動 105 卓を空けた 4 あきらめた 141 片付け待ち600秒超え 85
人の1コマの移動の最大（m）とロボットの0.05m超えのコマ数: [('fam_p1_r0_s0', 21.9666, 0), ('fam_p1_r0_s1', 22.5693, 0), ('fam_p1_r0_s2', 21.4365, 0), ('fam_p1_r1_s0', 22.1104, 0), ('fam_p1_r1_s1', 22.0382, 0), ('fam_p1_r1_s2', 21.6634, 0), ('fam_p1_r2_s0', 22.5693, 0), ('fam_p1_r2_s1', 22.5693, 0), ('fam_p1_r2_s2', 21.6857, 0), ('fam_p5_r0_s0', 22.0074, 0), ('fam_p5_r0_s1', 22.5693, 0), ('fam_p5_r0_s2', 21.6888, 0), ('fam_p5_r1_s0', 21.9361, 0), ('fam_p5_r1_s1', 22.1701, 0), ('fam_p5_r1_s2', 22.1149, 0), ('fam_p5_r2_s0', 21.8306, 0), ('fam_p5_r2_s1', 22.5693, 0), ('fam_p5_r2_s2', 22.2563, 0)]
```

## 夜の結果の判定

### tools/judge_night.py（ServiceSim・cs:16 で入れた版）

```python
#!/usr/bin/env python3
"""judge_night.py — 夜に回した Unity の結果を自動で振り分ける（読むだけ。Unity も台帳も変えない）

振り分け（2026-10-25 エイイチ決定）
  回の失敗        : 最後まで回っていない・台帳に行がない・ロボットが1コマで0.05m超え動いた
  Unity の作り    : 立ち往生（ロボットが30秒以上止まった）などが、基準データと比べて外れた
  試算を見直す候補 : ロボットを増やしたときの向きが、種の過半数で試算と逆
                    または Unity ÷ 試算 の大きさが、基準データの同じ条件の範囲から外れた
  正常            : どれにも当たらない
  試算との差の大きさそのものは、記録するだけで印にしない（歩く距離の差などは既知のずれとして扱う）

決めた線
  STUCK_S = 30            立ち往生とみなす1回の止まりの長さ（Unity の『動けなくなった』60秒とは別の、判定だけの線）
  DEAD_BAND = 5% と 0.2分  これより小さい変化は「変わらない」として向きに数えない
  基準データ               段階B・ServiceSim/cs:7 の18回（③⑦ × 0/1/2台 × 種0〜2）。cs:144 の18回は使わない

使い方（service_sim/unity/ で）
  python3 judge_night.py --runs '20261026_night_*' --out night/20261026
  python3 judge_night.py --runs '20261025_stageB_cs144_*' --out /tmp/self   （基準どうしの確かめ）
標準ライブラリだけで動く。
"""
import argparse, csv, fnmatch, glob, json, os, statistics as st, sys, collections

STUCK_S = 30.0
DEAD_REL, DEAD_ABS = 0.05, 0.2
BASELINE_DEFAULT = '20261025_stageB_cs7_*'   # 2026-10-26：新しいプロジェクト（ServiceSim/cs:7）の18回に入れ替え。cs:144 の18回は当時のエディタの状態に引きずられていた（⑦）
LEDGER = 'unity_ledger_v1.csv'
# pass_by は台帳の列にないので run_info.json から取る（無い回 = 切）。robots は必ず最後（向きの比べで使う）
COND_KEYS = ('store', 'staff', 'arrivals_per_h', 'mode', 'trays', 'pass_by', 'robots')
# 向きと大きさを見る数字（Unity の列, 試算の列, 表示名）
PAIRS = [('entry_wait_mean', 'site_entry_wait_mean', '入店待ち'),
         ('dish_wait_mean', 'site_dish_wait_mean', '料理の待ち'),
         ('staff_wait_mean', 'site_staff_wait_mean', '席は空いているのに待った')]
# Unity の作りを見る数字（1回ごと）。多いほど悪い
BUILD_METRICS = [('stuck30_per_trip', '30秒以上の止まり（1往復あたり）'),
                 ('robot_robot30', '30秒以上の止まりのうち、もう1台のロボットが原因'),
                 ('stuck_count', '動けなくなった（60秒）'),
                 ('table_freed', '卓を空けた（逃がし弁）'),
                 ('warped', '瞬間移動'),
                 ('timeouts', '打ち切り')]


def fnum(x):
    try:
        return float(x)
    except (TypeError, ValueError):
        return None


def load_ledger():
    rows = {}
    with open(LEDGER, encoding='utf-8') as f:
        for r in csv.DictReader(f):
            rows[r['run_folder']] = r   # 同じ出力フォルダは2度書かない決まりなので後勝ちでよい
    return rows


def run_record(folder, ledger):
    """1回分の数字をまとめる。足りないものは失敗の理由に入れる"""
    rec = {'run_folder': folder, 'fail': []}
    lr = ledger.get(folder)
    if lr is None:
        rec['fail'].append('台帳に行がない')
        return rec
    rec['ledger'] = lr
    rec['seed'] = lr.get('seed')
    pass_by = '切'
    if lr.get('status') != 'completed':
        rec['fail'].append(f"status={lr.get('status')}")
    if lr.get('everyone_left') not in ('1', '1.0', 'True', 'true'):
        rec['fail'].append('全員が帰っていない')
    ri_path = os.path.join(folder, 'run_info.json')
    if os.path.exists(ri_path):
        ri = json.load(open(ri_path, encoding='utf-8'))
        if (ri.get('pass_by') or {}).get('enabled'):
            pass_by = '入'
        if (ri.get('frames_over_005m') or 0) > 0:
            rec['fail'].append(f"ロボットが1コマで0.05m超え {ri['frames_over_005m']}コマ")
    else:
        rec['fail'].append('run_info.json がない')
    vals = dict(lr)
    vals['pass_by'] = pass_by
    rec['cond'] = tuple(vals.get(k, '') for k in COND_KEYS)
    # 止まり
    stops_path = os.path.join(folder, 'stops.csv')
    long_n = rr_n = 0
    if os.path.exists(stops_path):
        with open(stops_path, encoding='utf-8') as f:
            for s in csv.DictReader(f):
                d = fnum(s.get('duration_s')) or 0
                if d >= STUCK_S:
                    long_n += 1
                    if s.get('cause') == 'もう1台のロボット':
                        rr_n += 1
    trips = fnum(lr.get('trips')) or 0
    rec['m'] = {
        'stuck30': long_n,
        'stuck30_per_trip': (long_n / trips) if trips else 0.0,
        'robot_robot30': rr_n,
        'stuck_count': fnum(lr.get('stuck_count')) or 0,
        'table_freed': fnum(lr.get('table_freed')) or 0,
        'warped': fnum(lr.get('warped')) or 0,
        'timeouts': fnum(lr.get('timeouts')) or 0,
    }
    for u, s, _ in PAIRS:
        rec['m'][u] = fnum(lr.get(u))
        rec['m'][s] = fnum(lr.get(s))
    return rec


def collect(pattern, ledger, superseded=None):
    """出力フォルダがある回と、台帳にだけ行がある回（failed / stopped で
    フォルダが無いもの）の両方を拾う。やり直し（_try2）があるときは1つにまとめる：
    最後まで回った方を使い、どちらも失敗なら後の方を使う"""
    names = {p for p in glob.glob(pattern) if os.path.isdir(p)}
    names |= {k for k in ledger if fnmatch.fnmatch(k, pattern)}
    recs = [run_record(f, ledger) for f in sorted(names)]
    by_key = collections.OrderedDict()
    for r in recs:
        key = r['run_folder'][:-len('_try2')] if r['run_folder'].endswith('_try2') else r['run_folder']
        by_key.setdefault(key, []).append(r)
    out = []
    for key, rs in by_key.items():
        rs.sort(key=lambda r: r['run_folder'].endswith('_try2'))   # 元 → やり直し の順
        ok = [r for r in rs if not r['fail']]
        pick = ok[-1] if ok else rs[-1]
        out.append(pick)
        if superseded is not None:
            superseded.extend(r['run_folder'] for r in rs if r is not pick)
    return out


def by_cond(recs):
    g = collections.defaultdict(list)
    for r in recs:
        if 'cond' in r and not r['fail']:
            g[r['cond']].append(r)
    return g


def sign(a, b):
    """a→b の向き。小さい変化は 0"""
    if a is None or b is None:
        return None
    d = b - a
    if abs(d) < max(DEAD_ABS, DEAD_REL * max(abs(a), abs(b))):
        return 0
    return 1 if d > 0 else -1


def direction_flags(groups):
    """台数を1つ増やしたときの向きを、種ごとに試算と比べる。過半数が逆なら印"""
    out = []   # (条件の頭, 台数a→b, 数字, 逆の種の数, 比べられた種の数, 試算の向き)
    heads = collections.defaultdict(dict)
    for cond, rs in groups.items():
        heads[cond[:-1]][cond[-1]] = rs
    for head, byr in heads.items():
        counts = sorted(byr, key=lambda x: float(x))
        for a, b in zip(counts, counts[1:]):
            sa = {r['seed']: r for r in byr[a]}
            sb = {r['seed']: r for r in byr[b]}
            seeds = sorted(set(sa) & set(sb), key=lambda x: int(x))
            for u, s, name in PAIRS:
                site_a = [sa[k]['m'][s] for k in seeds if sa[k]['m'][s] is not None]
                site_b = [sb[k]['m'][s] for k in seeds if sb[k]['m'][s] is not None]
                if not site_a or not site_b:
                    continue
                site_dir = sign(st.mean(site_a), st.mean(site_b))
                if site_dir in (None, 0):
                    continue   # 試算が変わらないところは向きを比べない
                opp = n = 0
                for k in seeds:
                    d = sign(sa[k]['m'][u], sb[k]['m'][u])
                    if d is None:
                        continue
                    n += 1
                    if d == -site_dir:
                        opp += 1
                if n and opp * 2 > n:
                    out.append((head, f'{a}→{b}台', name, opp, n, '短くなる' if site_dir < 0 else '長くなる'))
    return out


def ratio_values(rs, u, s):
    v = []
    for r in rs:
        a, b = r['m'][u], r['m'][s]
        if a is not None and b:
            v.append(a / b)
    return v


def compare_to_base(groups, base_groups):
    """条件ごとに、種の中央値が基準の同じ条件の最小〜最大から外れたか"""
    build, site, nobase = [], [], []
    for cond, rs in groups.items():
        brs = base_groups.get(cond)
        if not brs:
            nobase.append(cond)   # 比べる相手がないので判定しない（Unity の作りにしない）
            continue
        for key, name in BUILD_METRICS:
            now = st.median(r['m'][key] for r in rs)
            bv = [r['m'][key] for r in brs]
            if now > max(bv) and now - max(bv) >= (0.01 if key == 'stuck30_per_trip' else 1):
                build.append((cond, name, round(now, 3), round(min(bv), 3), round(max(bv), 3)))
        for u, s, name in PAIRS:
            nv, bv = ratio_values(rs, u, s), ratio_values(brs, u, s)
            if not nv or not bv:
                continue
            now = st.median(nv)
            if now < min(bv) or now > max(bv):
                site.append((cond, f'{name}（Unity÷試算）', round(now, 2), round(min(bv), 2), round(max(bv), 2)))
    return build, site, nobase


def cond_label(c):
    d = dict(zip(COND_KEYS, c))
    pb = '・すれ違い入' if d.get('pass_by') == '入' else ''
    return f"{d['store']} スタッフ{d['staff']} ロボット{d['robots']}台{pb}"


def head_label(h):
    d = dict(zip(COND_KEYS[:-1], h))
    pb = '・すれ違い入' if d.get('pass_by') == '入' else ''
    return f"{d['store']} スタッフ{d['staff']}{pb}"


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--runs', required=True, help='振り分ける回の出力フォルダ（glob）')
    ap.add_argument('--baseline', default=BASELINE_DEFAULT, help='基準データの出力フォルダ（glob）')
    ap.add_argument('--out', required=True, help='judge.csv と judge.md を書くフォルダ')
    a = ap.parse_args()
    if not os.path.exists(LEDGER):
        sys.exit(f'{LEDGER} がない。service_sim/unity/ で動かす（KIOXIA がつながっているか確かめる）')
    ledger = load_ledger()
    superseded = []
    recs = collect(a.runs, ledger, superseded)
    base = [r for r in collect(a.baseline, ledger) if not r['fail']]
    if not recs:
        sys.exit('振り分ける回が見つからない')
    if not base:
        sys.exit(f'基準データが見つからない（{a.baseline}）。--baseline を確かめる')
    groups, bgroups = by_cond(recs), by_cond(base)
    build, site_mag, nobase = compare_to_base(groups, bgroups)
    dirs = direction_flags(groups)
    known_dirs = {(d[0], d[1], d[2]) for d in direction_flags(bgroups)}

    # 1回ごとの判定（回の失敗だけは1回ごと。ほかは条件ごとの印を、その条件の回に付ける）
    build_conds = {b[0] for b in build}
    site_conds = {c[0] for c in site_mag}
    for d in dirs:
        if (d[0], d[1], d[2]) not in known_dirs:
            for cond in groups:
                if cond[:-1] == d[0]:
                    site_conds.add(cond)
    os.makedirs(a.out, exist_ok=True)
    counts = collections.Counter()
    with open(os.path.join(a.out, 'judge.csv'), 'w', encoding='utf-8', newline='') as f:
        w = csv.writer(f)
        w.writerow(['run_folder', 'store', 'staff', 'robots', 'seed', 'judge', 'reasons', 'stuck30', 'stuck30_per_trip', 'robot_robot30'])
        for r in recs:
            if r['fail']:
                j, why = '回の失敗', ' / '.join(r['fail'])
            elif r['cond'] in nobase:
                j, why = '基準なし', '基準データに同じ条件がない（判定していない）'
            elif r['cond'] in build_conds:
                j, why = 'Unity の作り', '条件ごとの印（judge.md）'
            elif r['cond'] in site_conds:
                j, why = '試算を見直す候補', '条件ごとの印（judge.md）'
            else:
                j, why = '正常', ''
            counts[j] += 1
            d = dict(zip(COND_KEYS, r.get('cond', ('',) * len(COND_KEYS))))
            m = r.get('m', {})
            w.writerow([r['run_folder'], d['store'], d['staff'], d['robots'], r.get('seed', ''), j, why,
                        m.get('stuck30', ''), round(m.get('stuck30_per_trip', 0), 4) if m else '', m.get('robot_robot30', '')])

    L = []
    L.append(f'# 夜の結果の振り分け（{a.runs}）\n')
    L.append(f'基準データ: {a.baseline}（{len(base)}回）／振り分けた回: {len(recs)}回\n')
    pm_now = sorted({r['ledger'].get('params_model', '') for r in recs if 'ledger' in r} - {''})
    pm_base = sorted({r['ledger'].get('params_model', '') for r in base if 'ledger' in r} - {''})
    if pm_now != pm_base:
        L.append(f'**注意：試算の版が基準と違う（今回 {", ".join(pm_now)}／基準 {", ".join(pm_base)}）。試算との比べは参考にとどめる**\n')
    L.append('| 振り分け | 回数 |\n|---|---|')
    for k in ('正常', '試算を見直す候補', 'Unity の作り', '回の失敗', '基準なし'):
        L.append(f'| {k} | {counts.get(k, 0)} |')
    L.append('\n## 回の失敗\n')
    fails = [r for r in recs if r['fail']]
    L += [f"- {r['run_folder']}: {' / '.join(r['fail'])}" for r in fails] or ['- なし']
    if superseded:
        L.append('\nやり直しで置き換えた回（判定に入れていない）: ' + ', '.join(superseded))
    if nobase:
        L.append('\n## 基準なし（基準データに同じ条件がないので判定していない）\n')
        L += [f'- {cond_label(c)}' for c in sorted(nobase)]
    L.append('\n## Unity の作り（種の中央値が、基準の同じ条件の最大を超えた）\n')
    L += [f'- {cond_label(c)}: {name} {now}（基準 {lo}〜{hi}）' if now is not None else f'- {cond_label(c)}: {name}'
          for c, name, now, lo, hi in build] or ['- なし']
    L.append('\n## 試算を見直す候補\n')
    L.append('### ロボットを増やしたときの向きが、種の過半数で試算と逆\n')
    if dirs:
        for h, step, name, opp, n, sd in dirs:
            tag = '基準 v2 でも同じ（既知）' if (h, step, name) in known_dirs else '新しく出た'
            L.append(f'- {head_label(h)} {step}: {name}（試算は{sd}）— 逆 {opp}/{n} 種 — {tag}')
    else:
        L.append('- なし')
    L.append('\n### Unity÷試算 の大きさが、基準の同じ条件の範囲から外れた\n')
    L += [f'- {cond_label(c)}: {name} {now}倍（基準 {lo}〜{hi}倍）' for c, name, now, lo, hi in site_mag] or ['- なし']
    L.append('\n## 記録（印にしない）：試算との差の大きさ（条件ごとの種の中央値・Unity÷試算）\n')
    L.append('| 条件 | 種の数 | ' + ' | '.join(p[2] for p in PAIRS) + ' |')
    L.append('|---|---|' + '---|' * len(PAIRS))
    for cond in sorted(groups):
        rs = groups[cond]
        cells = []
        for u, s, _ in PAIRS:
            v = ratio_values(rs, u, s)
            cells.append(f'{st.median(v):.2f}' if v else '-')
        L.append(f'| {cond_label(cond)} | {len(rs)} | ' + ' | '.join(cells) + ' |')
    # すれ違い 入 の条件は、同じ夜の 切 の同じ条件と並べる（記録。印にしない）
    pb_i = COND_KEYS.index('pass_by')
    pairs = [(c, c[:pb_i] + ('切',) + c[pb_i + 1:]) for c in groups if c[pb_i] == '入']
    if pairs:
        L.append('\n## すれ違い 入 と 切 の比べ（同じ夜・同じ条件・種の中央値。印にしない）\n')
        cols = [('entry_wait_mean', '入店待ち'), ('dish_wait_mean', '料理の待ち'), ('staff_wait_mean', '席は空いているのに待った'),
                ('stuck_count', '動けなくなった'), ('gave_up', 'あきらめた'), ('robot_share', 'ロボットが運んだ割合')]
        L.append('| 条件 | ' + ' | '.join(n for _, n in cols) + ' |')
        L.append('|---|' + '---|' * len(cols))
        for on, off in pairs:
            for c in (off, on):
                rs = groups.get(c, [])
                cells = []
                for k, _ in cols:
                    v = [fnum(r['ledger'].get(k)) for r in rs if fnum(r['ledger'].get(k)) is not None]
                    cells.append(f'{st.median(v):.2f}' if v else '-')
                L.append(f'| {cond_label(c)}（{len(rs)}回） | ' + ' | '.join(cells) + ' |')
    L.append(f'\n線: 立ち往生 {STUCK_S:.0f}秒以上の止まり／向きは {int(DEAD_REL*100)}%・{DEAD_ABS}分より小さい変化を数えない／外れは基準の同じ条件の最小〜最大')
    open(os.path.join(a.out, 'judge.md'), 'w', encoding='utf-8').write('\n'.join(L) + '\n')
    print('\n'.join(L))


if __name__ == '__main__':
    main()
```

### night/split_check_cs7/judge.md（新しい基準の18回を自分自身にかけた結果）

```markdown
# 夜の結果の振り分け（20261025_stageB_cs7_*）

基準データ: 20261025_stageB_cs144_*（18回）／振り分けた回: 18回

| 振り分け | 回数 |
|---|---|
| 正常 | 12 |
| 試算を見直す候補 | 6 |
| Unity の作り | 0 |
| 回の失敗 | 0 |
| 基準なし | 0 |

## 回の失敗

- なし

## Unity の作り（種の中央値が、基準の同じ条件の最大を超えた）

- なし

## 試算を見直す候補

### ロボットを増やしたときの向きが、種の過半数で試算と逆

- なし

### Unity÷試算 の大きさが、基準の同じ条件の範囲から外れた

- fam_p5 スタッフ4 ロボット0台: 料理の待ち（Unity÷試算） 2.07倍（基準 2.31〜2.6倍）
- fam_p5 スタッフ4 ロボット2台: 入店待ち（Unity÷試算） 0.83倍（基準 1.06〜8.24倍）
- fam_p5 スタッフ4 ロボット2台: 席は空いているのに待った（Unity÷試算） 0.89倍（基準 1.82〜21.99倍）

## 記録（印にしない）：試算との差の大きさ（条件ごとの種の中央値・Unity÷試算）

| 条件 | 種の数 | 入店待ち | 料理の待ち | 席は空いているのに待った |
|---|---|---|---|---|
| fam_p1 スタッフ4 ロボット0台 | 3 | 1.15 | 2.09 | 2.72 |
| fam_p1 スタッフ4 ロボット1台 | 3 | 1.16 | 1.20 | 3.07 |
| fam_p1 スタッフ4 ロボット2台 | 3 | 2.04 | 1.53 | 3.03 |
| fam_p5 スタッフ4 ロボット0台 | 3 | 1.52 | 2.07 | 3.33 |
| fam_p5 スタッフ4 ロボット1台 | 3 | 1.13 | 1.20 | 1.65 |
| fam_p5 スタッフ4 ロボット2台 | 3 | 0.83 | 1.54 | 0.89 |

線: 立ち往生 30秒以上の止まり／向きは 5%・0.2分より小さい変化を数えない／外れは基準の同じ条件の最小〜最大
```

## 設計と依頼文

### night_runs_design_v0.md（夜の仕組みの設計）

````markdown
# 夜も回せる仕組み 設計 v0（2026-10-25）

サービスロボット導入シミュレーションの Unity を、人が見ていない夜のあいだも回し続け、朝には結果が振り分けられている状態にする。

## 1. 決めたこと（2026-10-25・エイイチ）

| 項目 | 決めたこと |
|---|---|
| 区切り | 基準データ v2（段階B・cs:144 の18回・試算 v2.8）。ここで Unity の作りを固定し、夜の仕組みの答え合わせはここと比べる |
| 種の数 | 1条件あたり10個（種0〜9）。種によって入店待ちが8〜11倍ばらつき、3つでは向きが読めないため |
| 立ち往生 | ロボットが1回で30秒以上止まったら立ち往生として数える（判定だけの線。Unity の「動けなくなった」60秒の作りは変えない） |
| 試算とのずれ | 差の大きさは記録だけにする。印を付けるのは、基準データ v2 から外れたときと、ロボットを増やしたときの向きが種の過半数で試算と逆のとき |
| 終わる時刻 | 時刻で止めない。並べた回が全部終わるまで回す |
| Unity のプロジェクト | サービスロボット導入シミュレーションを、mechArm の Robotics_UX_New から新しいプロジェクトに分ける |
| ビルド設定 | 「新しいシーンはビルド設定に足さない」は、Claude Code が mechArm と同居させるために自分で付けた用心で、決まりではない。新しいプロジェクトでは当てはまらない |

元のデータ：KIOXIA `service_sim/unity/stageB_cs144/c3_judge_lines.py`・`c3_judge_lines_output.txt`

## 2. 流れ

```
並べる（queue.json：条件 × 種）
  ↓
夜の実行係 night_runner.py（Mac・Python）
  ├ KIOXIA がつながっているか確かめる。外れたら新しい回を始めない
  ├ Mac をスリープさせない（caffeinate）
  ├ 画面なしの実行ファイルを N 本同時に起動（1本 = 1条件 × 1種）
  └ 終わった回は飛ばす（途中で止まっても続きから）
        ↓
実行ファイル（-batchmode -nographics）
  ├ 段階Bと同じ作り・同じ1コマ0.05秒で1回回す
  ├ 出力フォルダ service_sim/unity/{日付}_{ラベル}_{版}_{条件}_s{種}/
  └ 台帳 unity_ledger_v1.csv に1行足す（同時に書かないよう鍵をかける）
        ↓
判定 judge_night.py（全部終わったら実行係が呼ぶ）
  └ service_sim/unity/night/{日付}/judge.md・judge.csv
        ↓
朝：judge.md を読む（人が見るのは「正常」以外だけ）
```

## 3. 判定の決まり（judge_night.py）

| 振り分け | 当たるとき | 単位 |
|---|---|---|
| 回の失敗 | 台帳に行がない／最後まで回っていない／全員が帰っていない／ロボットが1コマで0.05 m を超えて動いた | 1回ごと |
| Unity の作り | 次の数字の種の中央値が、基準データの同じ条件の最大を超えた：30秒以上の止まり（1往復あたり）・そのうちもう1台のロボットが原因のもの・動けなくなった（60秒）・卓を空けた・瞬間移動・打ち切り | 条件ごと |
| 試算を見直す候補 | 入店待ち・料理の待ち・席は空いているのに待った のどれかで、(1) ロボットを1台増やしたときの向きが種の過半数で試算と逆（基準データ v2 でも逆だったものは「既知」として分けて書く）、または (2) Unity÷試算 の種の中央値が、基準データの同じ条件の範囲から外れた | 条件ごと |
| 正常 | どれにも当たらない | |

- 向きは、5%か0.2分より小さい変化を「変わらない」として数えない
- 試算の版が基準と違う回を振り分けたときは、judge.md の頭に注意を出す
- 判定は台帳を書き換えない。台帳の列も増やさない

確かめ（2026-10-25）：基準データ v2 を自分自身にかけると18回とも「正常」。1つ前の cs:143 の18回にかけると、逃がし弁を直す前の「卓を空けた」の多さ（8〜25回、基準0〜4回）で18回とも「Unity の作り」に振り分けられた。

## 4. 基準データの入れ替え

基準データ v2 は種が3つしかない。最初の夜（③⑦ × ロボット0・1・2台 × 種0〜9 の60回）を v2 と比べ、「回の失敗」と「Unity の作り」がなければ、その60回を基準データ v3 にする（入れ替えはエイイチが決める）。以後の夜は v3 と比べる。

## 5. 作る順番

| 順 | 中身 | だれが | 依頼文 |
|---|---|---|---|
| 1 | 判定のスクリプト（judge_night_v0.py） | Claude | 済み（このフォルダ） |
| 2 | 新しいプロジェクトに移し、同じ結果が出るか確かめる | Claude Code | claude_code_request_v11_project_split.md |
| 3 | 画面なしの実行ファイル・夜の実行係・何本同時に回せるかの実測 | Claude Code | claude_code_request_v12_night_runner.md |
| 4 | 最初の夜（60回）→ 基準データ v3 | エイイチが1つのコマンドで始める | v12 の中 |
| 5 | ケースの型（店の形 × 機種 × 運用の決まり × 来客の波） | 決めることはエイイチ・作るのは Claude と Claude Code | 4 のあと |
| 6 | 次に回す条件を選ぶ仕組み（試算で境目を探し、Unity は境目だけ回す） | Claude と Claude Code | 5 のあと |

## 6. 置き場所

| もの | 場所 |
|---|---|
| 新しい Unity プロジェクト | `/Users/shimazueiichi/Robotics_UX/ServiceSim/`（Unity Version Control の新しいリポジトリ） |
| 夜の実行係・判定・並べる道具 | 新しいプロジェクトの `tools/`（プロジェクトと一緒に版を管理する） |
| 画面なしの実行ファイル | 新しいプロジェクトの `Builds/headless/`（VCS に入れない。作った版は build_info.json に残す） |
| 各回の出力・台帳 | KIOXIA `service_sim/unity/`（今までどおり） |
| 夜ごとの判定と実行の記録 | KIOXIA `service_sim/unity/night/{日付}/`（Unity の出力の行の中。新しい行は作らない） |

MASTER の「Unityの絶対ルール」の作業ディレクトリは Robotics_UX_New だけが書かれている。新しいプロジェクトの場所を足す必要がある（MASTER はエイイチが直す）。

## 7. 台帳（data_manifest_rules.md 10節）

- 新しいプロジェクトは changeset の番号が1から振り直されるので、`unity_cs` は `ServiceSim/cs:N` の形で書く（今までの `cs:144` などと混ざらない）
- 何本も同時に回すので、台帳に書くときは鍵ファイル `service_sim/unity/unity_ledger_v1.lock` で順番を守る
- 実行係が途中で止めた回（時間切れ・落ちた）は、実行係が status = stopped / failed の行を1行書く
- 列は増やさない。何本同時か・どの実行ファイルかは各回の run_info.json に書く
````

### claude_code_request_v11_project_split.md（プロジェクトを分ける依頼）

```markdown
# 依頼 v11：ServiceSim を新しい Unity プロジェクトに分け、同じ結果が出るか確かめる（2026-10-25）

夜も回せる仕組みの最初の一歩。ServiceSim は今、mechArm の Robotics_UX_New に同居していて、「Assets/ServiceSim/ の外を変えない」用心のせいで、ビルド設定・時間・物理などの設定をサービスロボット導入シミュレーションに合わせられない。新しいプロジェクトに分ける。全体の設計は同じフォルダの `night_runs_design_v0.md`。

今回は移すだけで、店の回し方・ロボットの動き・記録は1つも変えない。

## 変えないもの

- **Robotics_UX_New は一切変えない。** ServiceSim はコピーで持っていく。Robotics_UX_New 側の ServiceSim は、新しいプロジェクトで同じ結果が出たのを確かめてから、消すかどうかをエイイチが決める
- ServiceSim の中身（cs:144 の状態）：店の回し方・ロボット・人・記録・段階Bの18回の中身と順番・出力フォルダの名前の付け方
- 台帳 unity_ledger_v1.csv の列と、今までの行
- `git clean` は使わない。Unity Version Control の check-in は区切りごとに行う

## 1. 先に洗い出す（報告に書く）

Assets/ServiceSim/ の外にあって、ServiceSim が頼っているものを全部挙げる。

- Unity のエディタのバージョン
- パッケージ（Packages/manifest.json のうち ServiceSim が使うもの。AI Navigation など）
- ProjectSettings のうち ServiceSim の結果に効くもの：NavMesh のエージェントの種類と大きさ・エリア、タグとレイヤー、Time（Fixed Timestep など）、Physics、Quality、描画の設定（URP などのパイプラインのアセット）
- Assets/ServiceSim/ の外にあるアセット（マテリアル・シェーダー・フォントなど）
- 読みに行くファイルの場所（サイトの写し site_copy_*・serving-robot-params.json・layouts）と、書き出す場所（KIOXIA service_sim/unity/）

洗い出して、移せないもの・結果が変わりそうなものがあれば、移す前に書いて止める。

## 2. 新しいプロジェクトを作る

- 場所：`/Users/shimazueiichi/Robotics_UX/ServiceSim/`
- エディタのバージョンは今と同じ
- Assets/ServiceSim/ と、1で挙げたパッケージ・設定・アセットを写す。設定は値をそろえる（今の値と同じになっていることを報告に並べる）
- Unity Version Control に新しいリポジトリを作り、最初の check-in をする
- `Builds/` と `Library/` などは VCS に入れない（次の v12 で `Builds/headless/` を使う）
- 台帳とrun_info.json の `unity_cs` は、新しいプロジェクトでは `ServiceSim/cs:N` の形で書く（番号が1から振り直されるため、今までの `cs:144` などと混ざらないように）
- ServiceSimSetup の説明にある「新しいシーンはビルド設定に足さない」は、新しいプロジェクトでは外してよい。ビルド設定に ServiceSim_Family.unity を入れてよい（このプロジェクトには ServiceSim しかない）

## 3. 確かめ

**A. Claude Code が見る**
- コンパイルが通る。ServiceSim のメニューが今と同じに出る
- 1で挙げた設定の値が、Robotics_UX_New と新しいプロジェクトで同じ（並べて報告）

**B. 同じ結果が出るか（エイイチが押す）**

1. 新しいプロジェクトで check-in して番号を報告
2. ServiceSim → Stage B → 押す前の確かめ（回さない）で、版が v2.8・店の形 8a5b2f6a016a・レジ1つで、18回とも「まだ」になっていること
3. 段階Bの18回のうち **③ ロボット1台・種1**（基準データ v2 では `20261025_stageB_cs144_fam_p1_r1_s1`）だけを、**2回**回す。出力フォルダは段階Bと区別できる名前にする（例：ラベル `split_check`）

見ること：
- 2回の結果が同じか（同じ種で同じ数字が出るか）。台帳の数字を、実時間（wall_seconds）と timestamp を除いて全部並べる
- 基準データ v2 の同じ回と同じか

結果で次を分ける：
- **2回とも同じで、基準データ v2 とも同じ** → 移せた。ここで終わり
- **2回は同じだが、基準データ v2 と違う** → 何が違うか（設定・パッケージ・読んだファイル）を調べて報告して止める
- **2回が違う**（同じ種でも数字が揺れる）→ 揺れる所（どの記録の何列から違い始めるか）を報告して止める。この場合は、18回を回して判定のスクリプトで比べる形に切り替えるかを相談する

## 報告

- 1の洗い出しの一覧
- 写したもの・そろえた設定の値（Robotics_UX_New と並べて）
- 確かめ A・B の結果
- check-in の番号
- この依頼どおりにすると困る所があれば、直す前に書いて止める

報告は `unity/claude_code_reports/` に、今までと同じ形の名前で置く。
```

### claude_code_request_v12_night_runner.md（画面なしの実行ファイルと夜の実行係の依頼）

```markdown
# 依頼 v12：画面なしの実行ファイルと夜の実行係を作り、最初の夜（60回）を回せるようにする（2026-10-25）

v11 で ServiceSim を新しいプロジェクト（`/Users/shimazueiichi/Robotics_UX/ServiceSim/`）に分け、同じ結果が出るのを確かめたあとに進める。全体の設計は同じフォルダの `night_runs_design_v0.md`。

目的：エディタを開かずに、段階Bと同じ回し方の1回を何本も同時に走らせ、夜のあいだに並べた回を全部回し、朝には判定まで済んでいる状態にする。

## 変えないもの

- 店の回し方・ロボットと人の動き・記録の中身（v11 で確かめた状態）。今回は回し方の入れ物を作るだけ
- 1コマ0.05秒で回すこと（段階Bと同じ時間の進め方）
- 台帳の列。今までの行
- Robotics_UX_New には触らない

## 1. 画面なしの実行ファイルを作る

- エディタのスクリプト（例：`Assets/ServiceSim/Editor/BuildHeadless.cs`）で作る。メニューからも、コマンド（`-batchmode -executeMethod`）からも作れるようにする
- 入れるシーンは、ビルドのコードの中で直接指定する（`BuildPipeline.BuildPlayer` に渡すシーンの一覧）
- 出力：`Builds/headless/`（VCS に入れない）。そばに `build_info.json`（作った日時・ServiceSim/cs 番号・エディタのバージョン）を置く
- macOS 向けの普通の実行ファイルで、`-batchmode -nographics` で起動して回す。Dedicated Server 向けのほうが向いていれば、理由を書いてそちらでもよい

## 2. 実行ファイルの1回の回し方

1本の実行ファイル = 1条件 × 1種 を1回回して終わる。

- 引数（例）：`-ssCondition fam_p1_s4_r1_a42 -ssSeed 3 -ssLabel night -ssNight 20261026`
- 条件は、段階Bと同じ所（serving-robot-params.json の unity_conditions・店の形）から引く。段階Bの Run All と同じコードを通る形にする（回し方を2通りに分けない）
- 画面に関わるもの（カメラ・録画・キッチンの屋根のモニター・字幕・スクリーンショット）は、画面なしのときは切る。数字に効かないことを確かめる（3-B）
- 出力フォルダ：`service_sim/unity/{日付}_{ラベル}_{ServiceSim の cs}_{条件}_s{種}/`（例：`20261026_night_ss12_fam_p1_r1_s3`）。中身のファイルは段階Bと同じ
- run_info.json に足す：`runner`（headless）・`build_id`・`night`・`parallel`（同時に何本走っていたか）・`seed`
- 終わり方：最後まで回った = 終了コード0、途中で止まった = 2、失敗 = 1
- 書き出す前に KIOXIA がつながっているかを確かめる。つながっていなければ何も書かずに終了コード1で終わる（Mac の中に書かない）

## 3. 台帳に同時に書かない

何本も同時に台帳 unity_ledger_v1.csv に1行足すので、鍵をかける。

- 鍵ファイル：`service_sim/unity/unity_ledger_v1.lock`（排他で開けたら書き、閉じる。開けなければ少し待って何度か試す）
- 今の追記のガード（実体のヘッダとコードの列の並びが違えば書かない）は残す
- `unity_cs` は `ServiceSim/cs:N` の形（v11 と同じ）

## 4. 夜の実行係（新しいプロジェクトの `tools/`・Python の標準ライブラリだけ）

### make_queue.py：並べる
- 条件 × 種の一覧を `queue.json` に書く
- 最初の夜：段階Bと同じ6条件（③⑦ × ロボット0・1・2台・スタッフ4・普通の混み具合・上げ膳だけ・1往復で3卓）× 種0〜9 ＝ 60回

### night_runner.py：回す
- `python3 tools/night_runner.py --queue queue.json --parallel N --night 20261026`
- 始める前に KIOXIA がつながっているか、実行ファイルがあるかを確かめる。どちらかがなければ始めない
- `caffeinate` で Mac がスリープしないようにする（実行係が終わったら解ける形）
- N 本まで同時に実行ファイルを起動する。1本終わったら次を起動する
- 台帳で、同じ night・同じ条件・同じ種がすでに completed の回は飛ばす（途中で止まっても続きから回せる）
- 1回の時間の上限（例：90分。3-C の実測で決める）を超えたら止め、実行係が status = stopped の行を台帳に1行書く（鍵をかけて）。落ちた回は failed の行を書く
- 途中で KIOXIA が外れたら、新しい回を始めずに待ち、走っている回が終わったら止まる
- 時刻では止めない（並べた回が全部終わるまで回す。エイイチの決定）
- 記録：`service_sim/unity/night/{night}/runner_log.txt`（いつ・どの回を・何本目で始めて、どう終わったか・実時間）
- 止まった・落ちた回は、その夜のうちに1度だけやり直す。やり直しの出力フォルダは名前の後ろに `_try2` を付ける（台帳は「同じ出力フォルダの行を2度書かない」決まりなので、同じ名前を使わない）
- 全部終わったら判定を呼ぶ：`python3 tools/judge_night.py --runs '{night}_night_*' --out night/{night}`（service_sim/unity/ で。同じ日のほかの回を混ぜないよう、ラベルまで含めて指定する）

### judge_night.py：判定
- 同じフォルダの `judge_night_v0.py` を `tools/judge_night.py` として入れる（中身は Claude が書いた。変えずに入れる。直したほうがよい所があれば報告に書く）
- 出力フォルダの名前の付け方が変わるので、`--runs` の glob が新しい名前に合うかだけ確かめる

## 5. 確かめ

**A. Claude Code が見る**
- 実行ファイルが作れる。build_info.json が出る
- 店の中の時間30分で止める短い1回（③1台・種1）が、画面なしで最後まで回り、段階Bと同じファイルが全部出る
- 2本同時に短い回を走らせて、台帳に2行がきちんと入る（行が混ざらない・ヘッダが壊れない）

**B. 画面なしでも同じ結果か（エイイチが押す・Claude Code が比べる）**
- ③1台・種1 を、画面なしで最後まで1回回す
- v11 で回したエディタの同じ回と並べる（v11 で同じ種なら同じ数字が出ると分かっていれば、数字が全部同じになるはず）
- 違えば、どこから違うかを報告して止める

**C. 何本同時に回せるか（実測して決める）**
- 同じ条件（③2台・種0〜2）を、1本ずつ・2本同時・3本同時で回し、1回あたりの実時間と、1時間に終わる回の数を並べる
- そのときの CPU とメモリの使い方（アクティビティモニタ や `top` の値でよい）
- 1時間に終わる回の数がいちばん多く、メモリが足りている本数を N にする。60回にかかる見込みの時間も書く
- 3本同時で1回が遅くなりすぎる・メモリが足りないなど、困る所があればそれも書く

**D. 最初の夜の前に**
- 台帳に書く所（3の鍵と、実行係が stopped / failed の行を書く所）は、台帳を壊すと戻しにくいので、codex にレビューをかける（code_review_request_template.md の形で。依頼文は Claude が作る）
- 最初の夜を始める前に、台帳を `archive/unity_ledger_v1_prenight_{night}/` に写しておく（コピーのみ）

**E. 最初の夜（エイイチが1つのコマンドで始める）**
- 報告に、エイイチが打つコマンドを1行で書く（make_queue.py と night_runner.py）
- 60回が終わったら、判定の judge.md を Claude が読んで、基準データ v3 にするかをエイイチに相談する

## 報告

- 作った・直したファイルと、変えた所の説明
- 確かめ A〜C の結果（C は表で）。D のレビューは、報告を見てから Claude が依頼文を作る
- 決めた N と、60回の見込みの時間
- 最初の夜のコマンド
- check-in の番号
- この依頼どおりにすると困る所があれば、直す前に書いて止める

報告は `unity/claude_code_reports/` に、今までと同じ形の名前で置く。
```

### claude_code_request_v13_pass_by_and_clock.md（すれ違いと店の時計の依頼）

```markdown
# 依頼 v13：ロボットどうしのすれ違いを実物に合わせる（cs:4）と、実物の動画と秒数を比べられる画面（cs:5）（2026-10-25）

cs:3（det_check の仕掛け）のあとに進める。依頼1 を cs:4、依頼2 を cs:5 として、別々に check-in する。

## きっかけ

実店舗の配膳ロボットの動画を見た。空のロボット2台が、台車の横の狭い所で、2台とも速さを落とし、止まらずに体どうしの間を詰めてすれ違っていた。Unity で起きている「ロボットどうしが60秒向かい合って止まる」は実物では起きていない。エイイチの決定：すれ違いは実物に合わせてよい。録画は実物の動画と秒数を比べられるように調整してよい。

## 変えないもの

- 最高速 0.6m/秒、1コマ0.05秒の時間の進め方
- 人とロボットのよけ方、店の形、条件の値（サイトから読む値）
- 台帳 unity_ledger_v1.csv の列
- cs:3 の det_check の切り替え。ExperimentPlan.asset は今のまま（切は C# の初期値のままでよい。.asset に書き足さない）
- 既存の trips.csv の「ロボット同士が向かい合ったか」の列（そのまま残す）

## 依頼1（cs:4）：すれ違い

### 切り替えと値の置き場所
- RobotSpec に1か所にまとめる（check-in する）
  - passByEnabled：初期値は切（切のときは cs:3 と同じ動き）
  - passByDistanceM = 2.0、passBySpeedMps = 0.3、passBySeparationM = 0.1
- 入を標準にするかは、下の「確かめ」の結果を見てエイイチが決める

### 動き（入のとき）
- 進む先 passByDistanceM 以内に、こちらへ向かってくるロボット（進む向きの差が135度以上）がいたら「すれ違い中」に入る
- すれ違い中は、2台とも速さの上限を passBySpeedMps にする。止まらない
- 譲り合いの順位は付けない。2台とも進む向きの右側へ寄り、体どうしの間を passBySeparationM まで詰めて通る（前方センサーは、相手がロボットのときだけ、この間まで全停止しない）
- 寄った位置で通れる幅が「ロボットの幅×2 ＋ passBySeparationM」に足りないときだけ、今の決まりを使う（料理を載せたロボットが先に行き、空のロボットがよける／3秒空かなければ回り込む道を探す）
- すれ違い終わったら、元の速さと通路の真ん中寄りの走り方に戻す

### 数の定義
- すれ違い1回：上の条件で「すれ違い中」に入ってから、相手が自分の後ろ側に回るか、2台の距離が passByDistanceM を超えるまで。同じ2台の1回は1回と数える（2台で2回にしない）
- 減速していた秒数：ロボットごとに「すれ違い中」だった時間の合計（速さの値では数えない）
- 向かい合って止まった：2台が passByDistanceM 以内・進む向きの差135度以上で、2台とも速さ 0.05m/秒以下が1秒以上続いたら始まり、どちらかが 0.05m/秒を超えたら終わり。回数と最長の秒数。入でも切でも数える（切の回と比べるため）

### 書き出し（台帳の列は足さない）
- summary.json に足す：pass_count、pass_slow_s（全ロボットの合計）、face_stop_count、face_stop_max_s
- 回ごとに passes.csv（すれ違い1回 = 1行）：始まりと終わりの時刻・2台のID・場所・一番近づいたときの体どうしの間・一番遅かった速さ・終わり方（すれ違えた／幅が足りず今の決まりへ／向かい合って止まった）
- run_info.json に足す：pass_by の入切と3つの値

### 確かめ
- 切のときに cs:3 から動きが変わる行がないことを、差分で確かめて報告する
- ③（fam_p1）ロボット2台・スタッフ4・種1 で、入と切を1回ずつ回す（ロボット1台の条件ではすれ違いが起きない）。エディタのメニューで押す形でよい。押す順番を1行で出せばエイイチが押す
- 2回の pass_count・pass_slow_s・face_stop_count・face_stop_max_s と、入店待ち・ロボットが運んだ割合を並べて報告する

## 依頼2（cs:5）：実物の動画と秒数を比べられる画面

- 録画はエイイチが Mac の画面録画で撮る。Unity Recorder は入れない
- 画面の左上に出す：店の時計（回し始めからのシミュレーションの中の経過 時:分:秒）と、実際の倍率（直近数秒の、シミュレーションの時間 ÷ 実時間）
- 1倍で回す切り替え：1コマ0.05秒のまま、実時間に合わせて待つ形（中身の進み方は変えない）
- 置き場所：新しい SimHud（シーンに置くコンポーネント）の Inspector の項目にする。表示の入切と1倍の入切の2つ。check-in する
- 1倍で回した回は見る専用。台帳に書かず、出力フォルダも作らない
- 画面なしの実行（Application.isBatchMode・graphicsDeviceType が Null）では描かない。測る回は表示の初期値を切にして、数字に影響させない

## 順番

- v12（画面なしの実行ファイルと夜の実行係）はこのあと。先に作らない
- 作業の区切りごとに VCS の check-in をする。git clean は使わない
```

### codex_review_request_v12_ledger.md（台帳に書く所の codex レビュー依頼）

```markdown
# コードレビュー依頼：夜の実行で台帳に書く所（サービスロボット導入シミュレーション・v12）

対象（読むだけ。書き換えない）：Unity プロジェクト `/Users/shimazueiichi/Robotics_UX/ServiceSim/`（ServiceSim/cs:17）

- `Assets/ServiceSim/` の中の `ServiceSimLedger.cs`（台帳に1行足す。Editor の外へ移した版）
- `Assets/ServiceSim/Experiment/LedgerRow.cs`（1行の中身を作る）
- `Assets/ServiceSim/Experiment/HeadlessRun.cs`（画面なしの実行ファイルで1回回す入口）
- `tools/ledger.py`（夜の実行係が、止めた回・落ちた回の行を書く）
- `tools/night_runner.py`（何本も同時に起動し、やり直し・止める・判定を呼ぶ）

---

## このコードがやろうとしていること

Unity で配膳ロボットの店を回すシミュレーションを、画面なしの実行ファイルで夜のあいだ何本も同時に回す。1回が終わるたびに、KIOXIA（外付けSSD）の台帳 `/Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv` に1行足す。台帳は唯一の正本で、壊すと戻せない。このレビューでは**台帳に書く所だけ**を見てほしい（店の回し方・ロボットの動きは対象外）。

## 設計仕様（台帳の決まり：data_manifest_rules.md 10節）

1. 1回 = 1行・追記のみ。既存の行の上書き・移動・削除をしない
2. 追記ガード：実体のヘッダとコードの列の並び（59列）が違えば書かない
3. 同じ出力フォルダ名の行を2度書かない（出力フォルダ名が照合キー）。やり直しは名前の後ろに `_try2` を付ける
4. 何本も同時に書くので、鍵ファイル `service_sim/unity/unity_ledger_v1.lock` を排他で開けてから書く。開けなければ待って試し直す。C#（実行ファイル）と Python（実行係）が同じ鍵を使う
5. 実行係が止めた回（時間切れ）・落ちた回は、実行係が status = stopped / failed の行を書く（鍵をかけて）
6. 書く前に KIOXIA がつながっているか確かめる。つながっていなければ何も書かない（Mac の中に fallback しない）
7. `unity_cs` は `ServiceSim/cs:N` の形

## レビューしてほしいチェック項目

### A. ロジックの正しさ

A1. C# と Python の鍵は、本当に同じ鍵として待ち合えるか（使っている仕組み：ファイルの排他オープン／flock／ロックファイルの作成と削除 など。macOS の外付けSSD（exFAT か APFS）で同じように効くか）
A2. 鍵を持ったまま落ちた場合（実行ファイルが落ちる・実行係が強制終了される）、鍵が残って次の回が永久に待たないか。古い鍵の扱い
A3. 「ヘッダの確認」「もう同じフォルダの行があるか」「1行足す」が、すべて鍵の中で行われているか
A4. 1行を書く途中で落ちたとき、半端な行が残らないか（1回の write で書いているか・改行の扱い）
A5. 実行係が stopped / failed の行を書いたあと、遅れて実行ファイル自身も同じフォルダの行を書く、という二重書きが起きないか（時間切れで止めた回を SIGTERM したのに、終了処理で台帳に書いてしまう など）
A6. `_try2` の名前の付け方と、「completed は飛ばす」の照合が、C# と Python で同じ規則か

### B. 副作用・破壊チェック

B1. 既存の行（87行）を書き換える経路が1つもないか（ファイルを開くモード：追記のみか）
B2. CSV のクオート・カンマ・改行を含む値（日本語の pending_changes など）で列がずれないか
B3. ヘッダが違うときに、黙って書かないだけでなく、失敗として記録・終了コードに出るか

### C. 暴走・安全リスク

C1. 鍵が取れないまま待ち続けて、夜じゅう止まる道はないか（待つ上限と、その後の扱い）
C2. 実行係が同じ回を何度も起動し続ける道はないか（やり直しは1度だけ）
C3. KIOXIA が途中で外れたとき、新しい回を始めず、Mac の中に書かないか

### D. 制約ルールの遵守

D5. 台帳への追記処理を持たない「通し完走」の経路がないか（回の終わり方が completed / stopped / failed のどれでも1行残るか）
D6. KIOXIA へ書き込む所すべてに、マウントの確かめが入っているか（未マウント時にローカルへ fallback しない）
（D1〜D4 は実機のアーム用の項目なので対象外）

## レビュー結果のフォーマット

各項目について、✅ OK／⚠️ 要注意（何が懸念か）／❌ NG（該当行番号と修正案）で答えてほしい。
最後にサマリとして：最初の夜（70回・2〜3本同時）に進んで良いか、進む前に直すべき項目があるか。

## 補足情報

- 本物の台帳で2本同時の確かめ（名札 acheck）を 2026-10-26 に行った。結果はレビューの参考にしてよい（台帳の acheck の行）
- 台帳は確かめの前に `archive/unity_ledger_v1_preacheck_20261026/` にコピーで退避済み
- `git clean` 禁止・`git add .` 禁止（個別ファイル指定）
```

## Claude Code の報告

### 20261025_1545_v11_project_split.txt

```text
====================================================================
依頼 v11 の報告：ServiceSim を新しい Unity プロジェクトに分けた
2026-10-25 15:45
====================================================================

結論：新しいプロジェクトを作りました。組み立ては通っています（54 本・エラー 0）。
Robotics_UX_New は一切変えていません（cs:144 のまま・保留は前からの2件だけ）。

  /Users/shimazueiichi/Robotics_UX/ServiceSim/

★ **check-in の番号：cs:1**（台帳には "ServiceSim/cs:1" の形で書かれます）
  リポジトリ ServiceSim/ServiceSim@studio_unity148@unity に 273 件。
  いきさつと、途中で1つ判断をもらった件は 8 章に書きました。

★ 回す前に知っておいてほしい落とし穴を見つけて、守りを入れました（4 章 (3)）。


====================================================================
1. 洗い出し：Assets/ServiceSim/ の外で頼っているもの
====================================================================

(1) Unity のエディタのバージョン

  2022.3.62f3（96770f904ca7）。新しいプロジェクトも同じにしました。

(2) パッケージ

  ServiceSim の .cs が実際に使っているもの（using と API を全部数えました）:

    使う名前空間・API                        要るパッケージ
    UnityEngine.AI（NavMeshAgent・NavMesh）  com.unity.modules.ai
    Collider（FrontSensor の当たり判定）     com.unity.modules.physics
    Texture2D.EncodeToPNG（絵を書き出す）    com.unity.modules.imageconversion
    JsonUtility（.asset を JSON に写す）     com.unity.modules.jsonserialize
    GUIStyle・Handles（エディタの字）        com.unity.modules.imgui
    Unity Version Control                    com.unity.collab-proxy 2.11.3

  シーンと prefab に入っている Unity の部品も数えました（classID）。
  NavMeshAgent・NavMeshObstacle・NavMeshSettings・BoxCollider・CapsuleCollider・
  Camera・Light・MeshRenderer・MeshFilter だけで、
  AudioListener も TextMeshPro も UI（Canvas）もありません。

  **使っていないもの**（新しいプロジェクトには入れませんでした）:
    com.unity.render-pipelines.universal（URP）… (5) を見てください
    com.unity.textmeshpro・com.unity.ugui・com.unity.timeline・
    com.unity.visualscripting・com.unity.robotics.ros-tcp-connector・
    com.unity.robotics.urdf-importer・org.nesnausk.gaussian-splatting（3DGS）
    modules.audio・cloth・terrain・particlesystem・video・vr・xr・vehicles・wind など

(3) ProjectSettings のうち、ServiceSim の結果に効くもの

  結果に効くと見たもの（値はぜんぶ 2 章に並べました）:
    NavMeshAreas.asset   通行者の種類と大きさ・エリアの名前
    TimeManager.asset    Fixed Timestep
    DynamicsManager.asset 物理（FrontSensor の当たり判定が使う）
    QualitySettings.asset
    TagManager.asset     ServiceSimSetup が足す Tag（SimRobot など7種）
    GraphicsSettings.asset 描画方式（絵にだけ効く。数字には効かない）

  NavMesh の中身は、ServiceSim が自分で決めていました（結果に効くのは .asset 側）:
    ・ロボット用の面：NavMesh.GetSettingsByID(0) を読んだあと、半径・高さ・登れる段・
      傾き・ボクセルの大きさ・最小の面積を、店の形の .asset（StoreLayout）の値で上書き
    ・人用の面：NavMesh.CreateSettings() で通行者の種類を1つ作り、半径だけ変える
    ・エリアは NavMesh.GetAreaFromName("Not Walkable") で引く（Unity の既定の名前）
    → 通行者の種類 0（Humanoid）は、いまの値が Unity の新規プロジェクトの既定と
      ぴったり同じ（半径 0.5・高さ 2・傾き 45・段 0.75・tileSize 256・cellSize 0.1666…）。
      それでも取りこぼしが無いように、NavMeshAreas.asset は1バイトも変えずに写しました。

  Tag は「付けるだけで NavMesh・センサー・よけ方・店の形の確かめは変わらない」と
  SimTags.cs に書いてあるとおりで、数字には効きません。Layer は1つも使っていません。

(4) Assets/ServiceSim/ の外にあるアセット

  **0 件でした。** ServiceSim の .unity・.prefab・.mat・.asset が指している guid を
  全部（118 件）たどって確かめました。外を指しているものはありません。
  外に出るのは次の2つだけで、どちらも Unity の組み込みです。
    0000000000000000f000000000000000（unity default resources。Standard シェーダーなど）
    0000000000000000e000000000000000（unity builtin extra）

  マテリアル 18 個のシェーダーは、ぜんぶ **組み込みの Standard**
  （guid f000…・fileID 46 が 15 個、fileID 30 が 3 個）。URP のシェーダーではありません。

(5) 描画方式（URP）— 結果に効かないが、はっきりさせておきます

  Robotics_UX_New は **URP を設定してあるのに、実際は Built-in で描いています**。
  2026-10-20 の報告（20261020_2232_render_pipeline_fix.txt）にある話で、今も同じです。
  いま確かめ直しました：

    ProjectSettings/GraphicsSettings.asset の m_CustomRenderPipeline
      → Assets/Settings/UniversalRenderPipelineAsset.asset（実在）
    その .asset の m_Script の guid
      → 8a3509c6a43b4e64a9e5e5e5e5e5e5e5
      → Assets と Library/PackageCache を全部探しても **どこにも無い**（参照切れ）

  参照が切れているので Unity はこのアセットをパイプラインとして読めず、
  GraphicsSettings.currentRenderPipeline は null（＝Built-in）になります。
  だから ServiceSimRender.FindOpaqueShader は Shader.Find("Standard") を返し、
  マテリアルも Standard になっています（(4) と合っています）。

  新しいプロジェクトでは URP を入れず、GraphicsSettings の
  m_CustomRenderPipeline を {fileID: 0} にしました。
  → currentRenderPipeline は null（Built-in）。**いまと同じ**です。
  絵も数字も変わりません。壊れたアセットを持っていかずに済みます。

  もし「ちゃんと URP で描く」ようにしたいなら、それは別の判断です。
  URP を入れて動くパイプラインアセットを置くと、
  次の Setup でマテリアルが自動で URP/Lit に切り替わり、**絵が変わります**
  （数字は変わりません）。今回は「移すだけ」なので触っていません。

(6) 読みに行く場所・書き出す場所

  読む（ぜんぶ Assets/ServiceSim/ の中。決め打ちのパスも全部中）:
    Experiment/serving-robot-params.json      試算 v2.8（サイトの写し）
    Store/Layouts/*.json                      店の形と地図
    Store/StoreLayout_*.asset・各 Spec.asset
    ※ 決め打ちのパスは "Assets/ServiceSim/..." の形が 10 か所。
      新しいプロジェクトでも同じ場所なので直す必要なし

  site_copy_* は Unity は読みません（私が Mac の中で写すだけ）。

  書く（ExperimentPlan.asset。写したので同じ）:
    /Volumes/KIOXIA/r2r2r-data/service_sim/unity   各回の出力と台帳
    /Users/shimazueiichi/ServiceSim_tmp            単発の試しの出力
    …/サービスロボット導入シミュレーション/unity/screens   画面の絵

  → 移せないもの・結果が変わりそうなものは **ありませんでした**
    （(5) の URP だけが「絵に効きうる」話で、いまと同じにそろえて避けました）


====================================================================
2. 作ったもの・そろえた設定（Robotics_UX_New と並べて）
====================================================================

  /Users/shimazueiichi/Robotics_UX/ServiceSim/
    Assets/ServiceSim/        227 ファイル（旧と同じ数）
    Assets/ServiceSim.meta
    Packages/manifest.json
    ProjectSettings/          23 ファイル
    ignore.conf               Library・Temp・Builds などを VCS に入れない

  Assets/ServiceSim/ は **227 ファイルとも写しました**。
  .meta が無いファイル・フォルダは 0。中身が違うのは、直した .cs の 3 本だけです
  （7 章）。guid はぜんぶそのままなので、参照も切れません。

  ProjectSettings の突き合わせ（1バイト単位で比べました）:

    1バイトも違わない 19 件
      AudioManager / ClusterInputManager / DynamicsManager / EditorSettings /
      InputManager / MemorySettings / **NavMeshAreas** / PackageManagerSettings /
      Physics2DSettings / PresetManager / **ProjectVersion.txt** / **QualitySettings** /
      SceneTemplateSettings.json / ShaderGraphSettings / **TimeManager** /
      UnityConnectSettings / VFXManager / VersionControlSettings / XRSettings
      （ProjectSettings/Packages/ の中身も同じ）

    わざと変えた 4 件（これ以外の差はありません）

      ファイル                  Robotics_UX_New                     ServiceSim
      ----------------------------------------------------------------------------
      GraphicsSettings.asset    m_CustomRenderPipeline:             m_CustomRenderPipeline:
                                  {guid: 5777c334…（参照切れの        {fileID: 0}
                                   URP アセット）}
                                m_SRPDefaultSettings:               m_SRPDefaultSettings: {}
                                  Universal…: {guid: 179fe4a4…}
                                → どちらも currentRenderPipeline は null（Built-in）。同じ

      EditorBuildSettings.asset m_Scenes: []                        ServiceSim_Family.unity を
                                                                    1件・有効で入れた
                                → 依頼 2 章のとおり。v12 の画面なしの実行ファイルが開くため

      ProjectSettings.asset     productName: Robotics_UX_New        productName: ServiceSim
                                （ほかの 1600 行は同じ）
                                → apiCompatibilityLevel 6（.NET Standard 2.1）・
                                  scriptingBackend 既定（Mono）・
                                  useDeterministicCompilation 1・runInBackground 1・
                                  gcIncremental 1・bakeCollisionMeshes 0 はそのまま

      TagManager.asset          tags: robot, SimRobot, SimStaff,    tags: SimRobot, SimStaff,
                                  SimGuest, SimSeatedGuest,           SimGuest, SimSeatedGuest,
                                  SimBench, SimTable, SimChair        SimBench, SimTable, SimChair
                                → mechArm の "robot" を外した。ServiceSim は使いません。
                                  Layer は Unity の既定のまま（どちらも同じ）

  結果に効く値（並べて確かめ。ぜんぶ同じ）:
    Fixed Timestep                0.01            / 0.01
    Maximum Allowed Timestep      0.33333334      / 0.33333334
    重力                          (0, -9.81, 0)   / 同じ
    Default Solver Iterations     6               / 同じ
    Default Contact Offset        0.01            / 同じ
    Queries Hit Triggers          1               / 同じ
    NavMesh 通行者の種類          Humanoid 1種    / 同じ（半径 0.5・高さ 2・
                                                     傾き 45・段 0.75・tileSize 256）
    NavMesh のエリア              Walkable / Not Walkable / Jump（既定）/ 同じ
    品質レベル                    6（Very Low〜Ultra）/ 同じ。パイプラインの上書きは 0 件
    Asset Serialization           Force Text（2）  / 同じ
    Enter Play Mode Options       切（ドメインリロードあり）/ 同じ

  パッケージ（新しいプロジェクトの manifest.json。版は旧の packages-lock に合わせた）:
    com.unity.collab-proxy 2.11.3      Unity Version Control
    com.unity.ide.rider 3.0.36         コードを書く用
    com.unity.ide.visualstudio 2.0.22
    com.unity.modules.ai 1.0.0         ★ NavMesh
    com.unity.modules.physics 1.0.0    ★ 当たり判定
    com.unity.modules.imageconversion  ★ 絵の書き出し
    com.unity.modules.jsonserialize    ★ JsonUtility
    com.unity.modules.imgui            ★ エディタの字
    com.unity.modules.screencapture / animation / ui / uielements / unitywebrequest
    （★ が ServiceSim が実際に使うもの。全 13 件。旧は 42 件でした）

  ※ ShaderGraphSettings.asset は ShaderGraph（URP の連れ）用の設定ですが、
    設定をそろえるため写したままにしています。パッケージが無いので Unity は読みません。


====================================================================
3. 確かめ A（Claude Code が見た）
====================================================================

(1) 組み立て

Unity 同梱の csc（Roslyn・2022.3.62f3）で、新しいプロジェクトの .cs を
54 本ぜんぶ組み立てました。**エラー 0・警告 0**。
Unity に切り替えたら Console は見てください（.meta の作られ方は Unity 側でしか分かりません）。

(2) ServiceSim のメニューが今と同じに出るか

MenuItem の一覧を旧・新で並べて比べました。

  旧 36 件 → 新 37 件。差は 1 件だけで、依頼 3 章 B 3 のために足したもの:
    ＋ ServiceSim/Stage B/移せたかの確かめ（③1台・種1 を2回）
  ほかの 36 件は1字も違いません。

(3) 台帳まわりの決まり（Unity 抜きで走らせた）

新しいプロジェクトの dll の決まりをそのまま呼ぶ試しを書いて走らせました
（/private/tmp/claude-501/-Users-shimazueiichi/d0d60c2a-d756-4c07-a05b-13221e3d700f/scratchpad/SplitCheckLogic.cs。16 件ぜんぶ OK）。

  ・unity_cs の頭の字 = "ServiceSim/"（ServiceSimLedger.CsPrefix）
  ・出力フォルダの名前（"/" も ":" も入らない）
       ServiceSim/cs:1 ＋ 名札 stageB      → 20261025_stageB_cs1_fam_p1_r1_s1
       ServiceSim/cs:1 ＋ 名札 split_check → 20261025_split_check_cs1_fam_p1_r1_s1
       cs:144（前のプロジェクト）          → 20261025_stageB_cs144_fam_p1_r1_s1
                                             （名前の形は変わっていない）
       ServiceSim/不明                     → 20261025_stageB_cs不明_…（"/" が消える）
  ・名札ごとの照合
       名札なし 54 件 / stageB 54 件 / split_check 0 件
       cs:144 の ③1台・種1 は読める（前の行がそのまま読める）
       ServiceSim/cs:1 は無い ＝ 新しいプロジェクトでは18回とも「まだ」
       版の字が違うので、台帳の中で前のプロジェクトの行と混ざらない
  ・台帳の列は 59 のまま（増やしていない）。追記ガードも通る

(4) まだ確かめられないこと

  ・Unity が実際にプロジェクトを開いて取り込めるか（Editor が旧プロジェクトを
    押さえているので、私からは開けません）
  ・絵（Build Store の見た目）。(5) の理屈では今と同じ Built-in のままです


====================================================================
4. 見つけた困りそうな所
====================================================================

(1) ★ Unity Version Control のリポジトリを私では作れない

  cm で2通り試して、どちらも断られました。

    cm repository create "ServiceSim/ServiceSim@studio_unity148@unity"
      → Module can't be created because parent repository doesn't exist.
    cm repository create "ServiceSim@studio_unity148@unity"
      → Repositories must be created under a project in Unity Organizations.

  いまあるリポジトリは3つとも「{プロジェクト名}/{リポジトリ名}」の形です。
    PixelstoMotors-R2R2R/Robotics_UX
    Robotics_UX_New/Robotics_UX_New
    病院デジタルツイン開発/病院デジタルツイン開発

  Unity Cloud の画面でプロジェクトを作らないとリポジトリが作れない決まりなので、
  ここはエイイチにお願いします（5 章の手順）。

(2) ★ /Users/shimazueiichi/Robotics_UX/ が、それ自体ワークスペースになっている

  新しいプロジェクトを置いた場所の1つ上が、もう Plastic のワークスペースでした。

    cm workspace list
      Robotics_UX_New-Robotics_UX_New  /Users/shimazueiichi/Robotics_UX/Robotics_UX_New  ← いつも使っている方
      Robotics_UX_New-Robotics_UX_New  /Users/shimazueiichi/Robotics_UX                  ← こちらも同じリポジトリ

  外側のワークスペースは cs:37 で止まっていて（head は 144）、
  いま status を打つと保留が 8570 件出ます（中の Robotics_UX_New の中身が
  「置いてあるだけ」として見えている）。もう使えない状態です。

  なので ServiceSim/ を作ったことで、この外側のワークスペースの保留が少し増えます。
  **Robotics_UX_New は変わりません**（check-in していないので）。
  ただ、外側のワークスペースから「全部 check-in」をすると、
  ServiceSim の中身が Robotics_UX_New に入ってしまいます。
  外側はもう使えない状態なので、使わないままにしておくのが安全です。
  （外側の ignore.conf に ServiceSim を足せば確実ですが、
   その ignore.conf は Robotics_UX_New の管理下のファイルなので触っていません）

  Plastic はワークスペースの入れ子を許します（上のとおり、もう入れ子になっています）。
  なので ServiceSim/ を自分のワークスペースにできるはずです。

(3) ★ ワークスペースを作る前に回すと、台帳に別の版の番号が入る（守りを入れました）

  ReadChangeset は Unity の作業フォルダ（＝プロジェクトの場所）で cm status を打ちます。
  ServiceSim/ がまだ自分のワークスペースになっていないと、
  cm は1つ上のワークスペースの答えを返します。実際に打って確かめました:

    cd /Users/shimazueiichi/Robotics_UX/ServiceSim && cm status --machinereadable
      STATUS 37 Robotics_UX_New/Robotics_UX_New studio_unity148@unity

  このままだと台帳の unity_cs に "ServiceSim/cs:37" と書かれ、
  どの版で回したのか追えなくなります。

  守り（ServiceSimStageB.ReadChangeset）:
    cm が返したリポジトリ名に "ServiceSim" が入っていなければ、
    版を「不明」にして Console に赤い字を出す。
    さらに「押す前の確かめ」が「回せません」になるので、18回を始められません。

(4) split_check の回が、段階Bの18回を「済」にしないようにした

  依頼 3 章 B 3 で ③1台・種1 を 2 回回しますが、
  台帳の「もう回したか」は（店・台数・種・版）で見ているので、
  そのままだと確かめのあとに Run All を押したとき、
  ③1台・種1 が「済」になって18回が17回になってしまいます。

  出力フォルダに入っている名札（stageB / split_check）で照合を分けました。
  台帳の列は増やしていません（名札は run_folder の中にあります）。
  確かめの2回めのフォルダ名が1回めと同じにならないようにもしてあります（末尾 _2）。


====================================================================
5. エイイチにお願いすること
====================================================================

【A】Unity Version Control — **済み（8 章）**。cs:1 まで終わっています。

【B】Unity で開いて確かめる

  4. Unity Hub で /Users/shimazueiichi/Robotics_UX/ServiceSim を開く
     （2022.3.62f3。初回はパッケージの取り込みで少し時間がかかります）。
     Console に赤い字が無いか見てください。

  5. ServiceSim → Setup を押す。報告に次が出るはず:
       ビルド設定（EditorBuildSettings）: このシーンが入っています（1 / 1 番め・有効）
       種類の印（Tag）: 7 種（新しく作った 0 種）
     ※ マテリアルのシェーダーが Standard のままであること（ピンクにならないこと）も
       Build Store の絵で見てください

  6. ServiceSim → Stage B → 押す前の確かめ（回さない）。次が出るはず:
       試算の版 (meta.model): v2.8 / 店の形の版: 8a5b2f6a016a
       条件: 6 件 / レジ: 1 つ（6条件とも同じ）
       合わないもの: なし
       Unity の版: ServiceSim/cs:1（保留なし）
       この版の18回（名札 stageB）: まだ 18 回 / 済 0 回
       列（59 列・台帳の版 2）
     ★ 「Unity の版」が ServiceSim/cs:… になっていること。
       cs:37 や「不明」と出たら【A】がまだです（4 章 (3)）。押さないでください

  7. ServiceSim → Stage B → 移せたかの確かめ（③1台・種1 を2回）。
     2回まとめて回ります（1回ごとに Play を出入り・caffeinate でスリープを止める）。
     出力は
       /Volumes/KIOXIA/r2r2r-data/service_sim/unity/
         {日付}_split_check_cs1_fam_p1_r1_s1/
         {日付}_split_check_cs1_fam_p1_r1_s1_2/
     台帳にも2行足されます（名札が split_check なので18回とは混ざりません）。

  終わったら台帳の2行を見せてください。
  実時間（wall_seconds）と timestamp を除いて全部並べ、
  基準データ v2 の 20261025_stageB_cs144_fam_p1_r1_s1 と比べます。
  依頼 3 章 B のとおりに3つに分けて見ます（2回とも同じで基準とも同じ／
  2回は同じだが基準と違う／2回が違う）。


====================================================================
6. 直したファイル（新しいプロジェクトの中だけ）
====================================================================

  Robotics_UX_New は1バイトも変えていません（cs:144・保留は前からの Mat_MonitorBoard 2件のみ）。

  Assets/ServiceSim/Editor/ServiceSimLedger.cs
    ＋ CsPrefix = "ServiceSim/"（unity_cs の頭に付ける字。依頼 2 章）
    ＋ DoneKeys(path, folderLabel)：名札ごとに照合する版を足した
      （1引数の版はそのまま残してあるので、ほかの呼び出しは変わりません）

  Assets/ServiceSim/Editor/ServiceSimStageB.cs
    ＋ 名札 LabelStageB / LabelSplitCheck
    ＋ メニュー「移せたかの確かめ（③1台・種1 を2回）」
    ・RunAll の中身を StartBatch に切り出し、Run All と確かめで共通にした
      （回し方・実時間の上限・caffeinate・Play の出入りは変えていません）
    ・FolderName に名札を渡すようにし、"ServiceSim/" と "/" ":" を外すようにした
    ・FreeFolderName が「この並びでもう使う名前」も見るようにした（2回めが _2 になる）
    ・ReadChangeset が "ServiceSim/cs:N" を返すようにし、
      別のワークスペースの答えなら弾くようにした（4 章 (3)）
    ・押す前の確かめに、版が不明なら「回せません」を足した
    ・ログの「段階B」を名札で出すようにした（確かめのときは「移せたかの確かめ」）

  Assets/ServiceSim/Editor/ServiceSimSetup.cs
    ・説明の「新しいシーンはビルド設定に足さない」を、このプロジェクト向けに書き直した
    ＋ BuildSettingsNote()：シーンがビルド設定に入っているかを Setup の報告に出す
      （Setup はビルド設定を書き換えません。読んで知らせるだけ）

  店の回し方・ロボットの動き・人の動き・記録（GroupManager・ServiceOps・
  ServingRobotController・ExperimentRunner・TripLogger・TimelineLogger など）は
  **1行も変えていません**。


====================================================================
7. 入れていないこと（v12 で）
====================================================================

  ・Builds/headless/ と tools/ はまだ作っていません（設計 v0 の 6 章。v12 の分）。
    ignore.conf には Builds を入れてあるので、作っても VCS には入りません。
  ・MASTER の「Unityの絶対ルール」の作業ディレクトリに
    /Users/shimazueiichi/Robotics_UX/ServiceSim を足す必要があります
    （設計 v0 の 6 章。MASTER はエイイチが直す）。
  ・Robotics_UX_New 側の Assets/ServiceSim は残してあります。
    新しいプロジェクトで同じ結果が出たのを確かめてから、消すかどうかを決めてください。
  ・URP をちゃんと動かすかどうか（1 章 (5)）。今は Built-in のままで、いまと同じです。


====================================================================
8. Unity Version Control（2026-10-25 16:42 追記）
====================================================================

エイイチが Unity Cloud で ServiceSim のプロジェクトとリポジトリを作ってくれたので、
ワークスペースの作成と最初の check-in をしました。

  ★ check-in の番号：cs:1
    リポジトリ  ServiceSim/ServiceSim@studio_unity148@unity
    入れたもの  273 件（Assets/ServiceSim 240・ProjectSettings・Packages・ignore.conf）
    台帳の書き方  unity_cs は "ServiceSim/cs:1"
    ワークスペース  ServiceSim  /Users/shimazueiichi/Robotics_UX/ServiceSim
    保留           0 件

(1) 途中で止まった所：ワークスペースを作れなかった

  最初に cm workspace create を打つと、こう断られました。

    The workspace Robotics_UX_New-Robotics_UX_New (/Users/shimazueiichi/Robotics_UX)
    already exists.

  1つ上の /Users/shimazueiichi/Robotics_UX が、それ自体ワークスペースとして
  登録されていたためです（4 章 (2) で書いた入れ子）。
  cm workspace create には入れ子を許す指定が無く、ここで止まりました。

(2) 調べたこと：その登録は4月の整理で残った古いもの

    登録ファイル ~/.plastic4/plastic.workspaces に、**同じワークスペース ID
    （ac1b3bde-85cc-4804-b2cb-e48425a32509）が2行**入っていた
      ac1b3bde…  /Users/shimazueiichi/Robotics_UX/Robotics_UX_New   ← 生きている
      ac1b3bde…  /Users/shimazueiichi/Robotics_UX                  ← 古い

    外側 /Users/shimazueiichi/Robotics_UX
      .plastic/plastic.selector  2026-04-10 の日付・changeset "37" で止まっている
      Assets                     202 ファイル・ServiceSim は無い（cs:37 の中身）
    内側 /Users/shimazueiichi/Robotics_UX/Robotics_UX_New
      .plastic/plastic.selector  2026-10-25 の日付・changeset "144"
      Assets                     640 ファイル・ServiceSim あり

  → 外側は、4月にフォルダを整理したときに残った cs:37 の古い写しでした。

(3) エイイチの判断（2026-10-25）

  3つの進め方を出して、「古い登録を外す（ファイルは消さない）」を選んでもらいました。
  あわせて次の3つを指示されたので、そのとおりにしました。

    1. 進める前に ~/.plastic4/plastic.workspaces を同じフォルダに .bak で写す
    2. ID（両方 ac1b3bde）ではなく **場所** で1行だけ外す
    3. 外したあと Robotics_UX_New で cm status を打って cs:144 のままか確かめる

(4) やったことと、確かめた結果

  写し（戻せるように2か所）:
    ~/.plastic4/plastic.workspaces.bak                        外す前の3行（指示 1）
    /private/tmp/claude-501/-Users-shimazueiichi/d0d60c2a-d756-4c07-a05b-13221e3d700f/scratchpad/plastic_backup_20261025/
      plastic.workspaces.before                               同じもの
      Robotics_UX_dot_plastic/                                外側 .plastic の中身4ファイル

  外したもの（**ファイルは1つも消していません**）:
    ・~/.plastic4/plastic.workspaces から、場所が
      ちょうど "/Users/shimazueiichi/Robotics_UX" の **1行だけ**（指示 2）。
      diff で1行だけ消えていることを確かめました
    ・/Users/shimazueiichi/Robotics_UX/.plastic/ は消さず、名前を変えて残しました
        .plastic → .plastic_stale_cs37_20261025
      （cm が1つ上をワークスペースと見なくなる。戻したいときは名前を戻すだけ）

  /Users/shimazueiichi/Robotics_UX/ 直下の Assets・ProjectSettings・Packages・
  index.html・README.md などは、ぜんぶそのまま置いてあります。

  確かめ（指示 3）:
    Robotics_UX_New で cm status
      cs:144@Robotics_UX_New/Robotics_UX_New@studio_unity148@unity (head)
      保留 2 件（Mat_MonitorBoard.mat と .meta。前からの「置いてあるだけ」）
    → **cs:144 のまま・保留も前と同じ 2 件。1つも変わっていません。**

    ワークスペースの登録（いま3つ）
      病院デジタルツイン開発-病院デジタルツイン開発  …/病院デジタルツイン開発
      Robotics_UX_New-Robotics_UX_New                …/Robotics_UX_New
      ServiceSim                                     …/ServiceSim      ← 足した

(5) check-in の中身の確かめ

    ignore.conf が効いていること … status に Library・Temp・Builds が0件
    追加 … 272 件ぜんぶ AD（追加待ち）になり、PR（置いてあるだけ）は0件
    check-in 後 … cs:1・ワークスペースの保留 0 件
    リポジトリの履歴 … cs:0（空の始まり）と cs:1 の2つだけ

    ★ ReadChangeset の守り（4 章 (3)）が通ることも確かめました。
      /Users/shimazueiichi/Robotics_UX/ServiceSim で cm status を打つと
        STATUS 1 ServiceSim/ServiceSim studio_unity148@unity
      リポジトリ名に ServiceSim が入っているので弾かれません。
      （ワークスペースを作る前は STATUS 37 Robotics_UX_New/… が返っていました）

(6) 残っている片付け（急ぎません）

  /Users/shimazueiichi/Robotics_UX/ 直下の cs:37 の古い中身（Assets 202 ファイル・
  ProjectSettings・Packages・index.html など）は、登録を外しただけでそのまま残っています。
  .plastic_stale_cs37_20261025 も残しました。
  消すか別の場所へ移すかは、中身を一覧にして見てもらってから決めたいです
  （4 章 (2) で出した3つめの選び方）。急ぎではありません。


====================================================================
9. Setup 後の12件を調べた（2026-10-25 16:55 追記）
====================================================================

結論：**値はぜんぶ同じ。書き直しだけでした。** cs:2 として check-in しました。
      いまの保留は 0 件です。

(1) 12件それぞれ、何がどう変わったか（cm cat で cs:1 の中身を出して突き合わせ）

  中身が本当に変わったのは **3 ファイルだけ**で、どれも「行が足された」だけです。
  もとからあった行の値が動いた所は **1つもありません**。

    ファイル                        変わった所
    ----------------------------------------------------------------------
    Robot/RobotSpec.asset           10 行 足された（下の表）
    Service/ServiceSpec.asset        1 行 足された（robotFirstWaitSec: 120）
    Scenes/ServiceSim_Family.unity   4 行 足された
                                       ServiceOps に     _dishesWaitingForRobot: 0
                                       ExperimentRunner に maxWallSecondsPerRun: 0
                                                           unityCs:（空）
                                                           pendingNote:（空）

  残り9件は **1バイトも違いませんでした**（Unity が開いて日付だけ変えた分）:
    People/PeopleSpec.asset ／ Experiment/ExperimentPlan.asset ／
    Store/StoreLayout_Family.asset ／ Store/StoreLayout_Grid.asset ／
    Store/Materials/Mat_PeopleFloor.mat ／ Mat_RobotFloor.mat ／ Mat_RobotKeepout.mat ／
    ProjectSettings/ProjectSettings.asset
    ＋ Packages/packages-lock.json（Unity が新しく作ったもの。「置いてあるだけ」の1件）

(2) 書き足された値が、cs:144 の段階Bで使われていた値と同じか

  まず **Robotics_UX_New の .asset にその項目があるか** を見ました。
  → **15 項目ぜんぶ「ファイルに無い」**。
    Unity は YAML に無い項目を C# の初期値で埋めるので、
    cs:144 の18回は **C# の初期値で動いていた** ことになります。
    なので「Setup が書いた値」と「C# の初期値」を1つずつ比べました。

    項目（RobotSpec）        Setup が書いた   C# の初期値（RobotSpec.cs）   判定
    ---------------------------------------------------------------------------
    yieldOrderEnabled        1                = true（124行）              同じ
    yieldSideClearM          0.6              = 0.6f（127行）              同じ
    yieldSideSearchM         2.5              = 2.5f（130行）              同じ
    yieldSideStepM           0.2              = 0.2f（133行）              同じ
    yieldSideWaitMaxSec      30               = 30f（136行）               同じ
    yieldPathAheadM          2                = 2.0f（142行）              同じ
    cornerMinX               10.5             = 10.5f（146行）             同じ
    cornerMaxX               13.5             = 13.5f（149行）             同じ
    cornerMinZ               3.5              = 3.5f（152行）              同じ
    cornerMaxZ               5.8              = 5.8f（155行）              同じ

    項目（ServiceSpec）
    robotFirstWaitSec        120              = 120f（93行）               同じ

    項目（シーン）
    _dishesWaitingForRobot   0                int の既定 0（初期値なし）   同じ
    maxWallSecondsPerRun     0                float の既定 0（初期値なし） 同じ
    unityCs                  （空）           = ""                         同じ
    pendingNote              （空）           = ""                         同じ

  さらに、台帳に残っている「その18回で実際に使った値」でも裏を取りました。
  cs:144 の18行はぜんぶ:

    yield_order_enabled  1      ← yieldOrderEnabled: 1 と合う
    one_robot_per_aisle  1
    staff_first_yield    1
    robot_first_wait_s   120    ← robotFirstWaitSec: 120 と合う

  robotFirstWaitSec は、そもそも回すたびに SiteValues が
  試算の common.robot_first_wait_s（role=site_rule・値 120）から入れ直す項目です。
  .asset に 120 と書かれても、回し方は変わりません。

  corner* の4つは、ロボットが決まった線を走る版（SiteRoutes）のときだけ働きます。
  2つのプロジェクトとも pathMode: 0（MapPlanner）で、
  台帳の corner_stop_time_share も18回とも 0 でした。いまは使われていません。

  maxWallSecondsPerRun・unityCs・pendingNote は、段階Bが回すたびに入れ直します
  （ServiceSimStageB の 492 行など）。シーンに 0・空が書かれても効きません。
  _dishesWaitingForRobot は画面に出す数で、回し始めに 0 に戻ります。

  ※ cornerReachDistance: 0.12 はもとから cs:1 にあった行で、動いていません。

(3) check-in：cs:2

    cs:2 に入れたもの（4件）
      C  Assets/ServiceSim/Scenes/ServiceSim_Family.unity
      C  Assets/ServiceSim/Robot/RobotSpec.asset
      C  Assets/ServiceSim/Service/ServiceSpec.asset
      A  Packages/packages-lock.json

    packages-lock.json も入れました。Unity が解決した版を固定しておくと、
    v12 の画面なしの実行ファイルで同じ版になります。中身は 15 件で、
    URP・TextMeshPro・ROS・3DGS・ShaderGraph・Timeline・VisualScripting は
    1つも入っていません（私が manifest.json に書いた13件＋
    ide.visualstudio が連れてくる test-framework 1.1.33 と ext.nunit 1.0.6）。

    check-in のあと、中身が同じなのに「変更あり」の印が残った 8 件がありました。
    Unity が日付だけ変えたせいです。cs:2 の中身と1バイトも違わないことを
    1件ずつ確かめたうえで cm undochange で印を落としました（中身は変わりません）。
    確かめ直し:
      trialSeed: 1 / expectModel: v2.8 / registersSupported: 1 /
      robotFirstWaitSec: 120 / tableStuckFreeSec: 600 /
      ビルド設定にシーン1件 / Assets/ServiceSim は 227 ファイル /
      組み立て 54 本・エラー 0

    いま
      ServiceSim        cs:2・保留 0 件
      Robotics_UX_New   cs:144・保留 2 件（前からの Mat_MonitorBoard）＝変わっていない

(4) 次

  「移せたかの確かめ（③1台・種1 を2回）」を押して大丈夫です。
  押す前の確かめで「Unity の版: ServiceSim/cs:2（保留なし）」と出るはずです
  （保留 0 にしたので、run_info と台帳にも「保留なし」で残ります）。
```

### 20261025_1745_v11_nondeterminism.txt

```text
====================================================================
依頼 v11 の確かめ B：同じ種で2回の数字がずれる元を調べた（直していません）
2026-10-25 17:45
====================================================================

比べた回：KIOXIA service_sim/unity/
  20261025_split_check_cs2_fam_p1_r1_s1    （1回め・実時間 654.5 秒）
  20261025_split_check_cs2_fam_p1_r1_s1_2  （2回め・実時間 601.1 秒）
どちらも ServiceSim/cs:2・83 組・全員が店を出た・店の中 179.2 分 / 182.0 分。

結論を先に書きます。

  ・**人（スタッフと客）だけが Unity の NavMeshAgent 自身に動かされていて、
    その行き先の道の計算が「非同期」です。** 道が引けるまでに何コマかかるかは、
    Mac の忙しさで変わります。
  ・その「引けていない間」が、**よけるかどうかの判断と、着いたかどうかの判断を
    その場で打ち切る**作りになっています（3 か所）。
    だから道の計算が1〜数コマ遅れると、よける／着くが数コマずれ、そこから連鎖します。
  ・ロボットは updatePosition を切って自分の script で動かし、
    道も同期の NavMesh.CalculatePath で引いているので、ロボット側は決まっています。
    最初にずれたのがスタッフ（Staff03）の譲りだったのと合います。
  ・実時間の値（plan_ms・wall_seconds）は**1回めの往復から違っていました**が、
    どちらも記録だけで、動きには一切使っていません。

直す案は 4 章に 5 つ並べました。**どれを選ぶかで基準データ v2 が使えるかが変わります。**


====================================================================
1. ずれ方（データで確かめた）
====================================================================

最初に中身が違った所を、ファイルごとに1行ずつ突き合わせました。

  ファイル        最初に違う所                         中身
  ------------------------------------------------------------------------
  trips.csv       2 行目（1回めの往復）                plan_ms_max 3.2536 → 2.8529
                                                       **これだけ。ほかの列は同じ**
  yields.csv      21 行目のデータ（t_s 1546）          Staff03 の譲り
                                                         t_s      1546.253 → 1546.453
                                                         x        5.0317   → 5.0491
                                                         z        4.119    → 4.1142
                                                         waited_s 0.4      → 0.2
  stops.csv       16 行目のデータ（t_s 1548.05）        1号機が Staff03 とすれ違った止まり
                                                         duration_s 1.05 → 0.95
                                                         blocked_s  0.1  → 0
  timeline.csv    43 行目（t_s 2460）                  board_serve 1 → 0
  groups.csv      2 行目以降                           来方（arrive_s・seated_s）は同じ

  ずれの大きさは 0.2 秒 = **コマ 4 つぶん**（1 コマ 0.05 秒）。
  stops の duration_s / blocked_s の差 0.1 秒 = コマ 2 つぶん。

  ・trips.csv の plan_ms_max は Stopwatch で測った「道を引くのにかかった実時間」で、
    1回めの往復から違います。**これは動きに使っていません**（4 章 (0)）。
    つまり「実時間は最初から違うが、動きは 1546 秒まで完全に同じ」でした。
  ・1546 秒までは yields も stops も小数4桁まで一致していました。
    毎コマ少しずつ狂っていく類（浮動小数の積み上げ・スレッドの足し算の順番）ではなく、
    **ある瞬間に数コマ分ずれる類**です。これが下の原因と合います。


====================================================================
2. ずれる元（全部挙げる）
====================================================================

(A) ★ 本命：人の道の計算が非同期（NavMeshAgent.SetDestination）

  人（スタッフ・客・待合の客）は **NavMeshAgent 自身に動かされています**。
  updatePosition を切っていないのは人だけで、行き先は agent.SetDestination で渡します
  （People/PeopleNav.cs:106-107）。SetDestination は **非同期**で、
  道が引けるまで agent.pathPending が true のままです。
  引けるまでに何コマかかるかは、Unity が毎コマ道探しに使える分と、
  そのとき何人ぶんの注文が溜まっているかで決まります。

  その「引けていない間」を見ている所が 3 か所あり、**どれも「このコマは何もしない」**
  という作りです。

    1. People/PersonRobotYield.cs:774   AisleAboutToEnter()
         if (_agent.pathPending) return -1;
       → -1 は「これから細い通路に入らない」の意味なので、
         UpdateClear() の「1. これから入る細い通路にロボットがいたら入らない」が
         丸ごと飛ばされ、**そのコマは譲らない**（260 行）。
       → 今回ずれたのは、まさにこの譲り（Staff03・waited_s 0.4 → 0.2）です。

    2. People/StaffAgent.cs:189         着いたかの判定
         if (_agent.pathPending) return;
       → 着いた判定が遅れ、dwell（会計 90 秒・片付け 40+40 秒）の始まりがずれる。

    3. People/GuestAgent.cs:175         着いたかの判定（同じ形）
       → ドリンクバー・トイレ・退店のタイミングがずれる。

(B) 人の局所回避（Unity の RVO）が入っている

  PeopleSpec の avoidanceQuality = 3（GoodQualityObstacleAvoidance）、
  avoidancePriority = 50。ロボットは NoObstacleAvoidance で切ってあります
  （ServingRobotController.cs:612）。
  Unity の局所回避はワーカースレッドで解くので、スレッドの数や割り当てで
  わずかに変わりうる所です。
  ただし今回は 1546 秒まで小数4桁まで一致していたので、
  **毎コマ効く形では出ていません**。可能性としては残りますが、今回の主因ではありません。

(C) 物理の問い合わせの、返ってくる順番と「あふれ」

  Robot/FrontSensor.cs:70        Physics.OverlapBoxNonAlloc（配列 32 個）
  ServingRobotController.cs:1728 Physics.OverlapSphereNonAlloc
  ServingRobotController.cs:1844 Physics.RaycastNonAlloc

  ・FrontSensor は 32 個を超えると `if (n > MaxHits) n = MaxHits;` で切り捨てます。
    あふれたとき **どれが落ちるかは返る順番で決まる**ので、順番が変われば結果が変わります。
  ・近いものを選ぶ所は `if (d >= r.nearestDistance) continue;`（厳密に近い方が勝つ）なので、
    ぴったり同距離のときだけ、返る順番で勝ち負けが変わります。
  → 今回のずれの説明にはなりません（ロボットより先に人がずれている）。
    ただし「3本同時」で順番が変わる余地は残っています。

(D) 実時間を見ている所（動きには使っていないが、記録に出る）

  Robot/GridPlanner.cs:446,605,675  Stopwatch → LastPlanMs
    → ServingRobotController:2209 で PlanMsMax に入り、trips.csv の plan_ms_max と
      run_info の plan_ms に出るだけ。**打ち切りには使っていません**
      （Plan() の中を読みました。経過時間で抜ける枝はありません）。
  Experiment/ExperimentRunner.cs:186,518  Time.realtimeSinceStartup → _wallSeconds
    → 段階Bの「1回 40 分」の打ち切りにだけ使う。超えなければ動きは変わりません。
      今回は 654 秒・601 秒なので超えていません。
  Robot/ServingRobotController.cs:1805  _open.startTimeS = Time.time
    → stops.csv の t_s に出るだけ（下の (E)）。

(E) 時計が2系統あって、ずれていく（記録の話。今回の原因ではない）

  ・stops.csv の t_s は `Time.time - _timeOffset`（ExperimentRunner:746）。
    Time.captureDeltaTime = 0.05 が効いているので、**ちょうど 0.05 の倍数**になります
    （観測値 1548.05・1562.15 と合う）。
  ・ほかの記録（yields・groups・give_ups・summary の total_minutes）は ops.NowS。
    こちらは `NowS += dt` と float で足し続けているので、だんだんずれます。
    実際に float32 で 0.05 を積んで測りました:

        30,925 コマ（本当 1546.25 秒）→ NowS 1546.58 秒   ずれ +0.33 秒
       100,000 コマ（本当 5000.00 秒）→ NowS 4999.28 秒   ずれ −0.72 秒
       215,000 コマ（本当 10750.0 秒）→ NowS 10726.8 秒   ずれ −23.2 秒

    今回の回は 179 分（約 215,000 コマ）なので、**終わりごろには 20 秒以上**
    2つの時計が食い違います。どちらの回でも同じようにずれるので
    「2回がずれる」原因ではありませんが、記録として直した方がよい所です。

(F) 見つからなかったもの（念のため確かめた）

  ・種を使わない乱数（UnityEngine.Random.Range / .value など）… **1か所もなし**
    （客の中身・来方は Group.rng と _arrivalRng で種から作っている。依頼 v8b の B）
  ・コルーチン・フレームをまたぐ待ち（StartCoroutine / yield return / async）… なし
  ・Time.frameCount で分岐する所 … なし
  ・FindObjectsOfType で順番に頼る所 … 回している間は使っていない（下ごしらえだけ）
  ・Time.captureDeltaTime は両方の回で 0.05 で効いていた（run_info.json）


====================================================================
3. Mac が忙しい（3本同時）とどちらに効くか
====================================================================

  元                      3本同時にすると            結果がどちらに動くか
  --------------------------------------------------------------------------------
  (A) 道の計算が非同期    1コマに回せる道探しが減り   ★ 効く。向きも読める:
                          pathPending が **長くなる**   ・人が譲るのが遅れる →
                                                          ロボットの止まりが増える・長くなる
                                                        ・着いた判定が遅れる →
                                                          会計・片付けの始まりが遅れ、
                                                          卓が回らなくなる → 入店待ちが伸びる
                                                        ・どちらも「Unity の作り」の
                                                          判定線（止まり・卓を空けた）を
                                                          踏みやすい向き
  (B) 局所回避（RVO）     スレッドの割り当てが変わる  効きうるが向きは読めない。
                                                      今回は主因ではない
  (C) 物理の返る順番      変わりうる                  32 個を超えたときだけ。
                                                      向きは読めない
  (D) plan_ms             **大きくなる**              記録だけ。動きは変わらない
  (D) wall_seconds        **大きくなる**              ★ 40 分の打ち切りに当たると
                                                      status が stopped になる。
                                                      今回 11 分なので 3 本でも 33 分の見込みで、
                                                      余裕は少ない。⑦2台のような重い条件は
                                                      当たる恐れあり
  (E) 時計のずれ          変わらない                  record。同じようにずれる

  まとめると、**忙しくすると「人の反応が鈍る」向きに偏ります。**
  夜に3本同時で回した結果を、昼に1本で回した基準データ v2 と比べると、
  Unity の作りが悪くなったように見える恐れがあります。
  （判定 judge_night.py は「種の中央値が基準の最大を超えたら Unity の作り」なので、
   この偏りがそのまま印になりえます）


====================================================================
4. 直す案と、動きがどれだけ変わりそうか
====================================================================

(0) 先に：記録だけの直し（動きは1つも変わらない。基準データ v2 はそのまま使える）

  ・plan_ms_max / wall_seconds は実時間なので、台帳や判定で「同じか」を比べる列から外す。
    （依頼 v11 の 3 B で「実時間と timestamp を除いて並べる」と書いてあるのと同じ扱いを、
      plan_ms_max にも広げる）
  ・stops.csv の t_s を ops.NowS に合わせる（いまは Time.time 由来）。
  ・ops.NowS を float の足し算から「コマ数 × 0.05」に変える（double かコマ数で持つ）。
    → (E) の 20 秒の食い違いが消えます。
  いちばん安くて、確かめの土台が固まります。

(1) 人の道の計算を同期にする（本命の直し）

  PeopleNav.Go で agent.SetDestination をやめ、
  NavMesh.CalculatePath（同期）＋ agent.SetPath に変える。
  → pathPending が true になる場面が無くなり、(A) の 3 か所が
    「このコマは何もしない」をしなくなります。

  動きの変わり方：**小さいが確かに変わります。**
    いままで道が引けるまで 1〜数コマ「何もしない」でいた分が無くなるので、
    人は少し早く動き出し、少し早く着き、少し早く譲ります。
    今回のずれ（4 コマ = 0.2 秒）と同じ大きさの前倒しが、
    1回の中で人の動きのたびに起きます。
  → **基準データ v2 はそのままでは使えなくなります**（18回の回し直しが必要）。
  → 1回あたりの計算は重くなります（同期で引く分、コマの実時間が伸びる）。
    どれだけ重くなるかは測らないと分かりません。

(2) 毎コマの道探しの枠を大きくする（NavMesh.pathfindingIterationsPerFrame）

  いまは触っていないので Unity の既定（100）です。これを大きく（例 5000）すると、
  ほとんどの注文が 1 コマで片付き、pathPending の長さのばらつきが小さくなります。
  → 1行足すだけ。動きの変わり方も (1) より小さい。
  → ただし **「必ず同じ」の保証にはなりません**（混んだときは依然ずれます）。
  → これも「少し早く道が引ける」方向なので、基準データ v2 とは少しずれます。

(3) よけの判断だけ、pathPending で打ち切らない

  PersonRobotYield の AisleAboutToEnter が、道が引けていないときは
  -1（入らない）ではなく「前に引けていた道」を使うようにする。
  → 今回出た症状（譲りのずれ）には直接効きます。
  → 着いた判定（StaffAgent・GuestAgent）のずれは残るので、
    「必ず同じ」にはなりません。
  → 動きの変わり方は (1) より小さいが、やはり基準データ v2 とは変わります。

(4) 人の局所回避を切る（おすすめしません）

  avoidanceQuality を NoObstacleAvoidance にすると (B) が消えますが、
  人が人をよけなくなるので、人どうしの詰まり（people_jams）・すれ違い・
  通路の混み方がまるごと変わります。
  → **動きが大きく変わります。基準データ v2 は使えません。**
    サービスロボットの話として、人が人をよけないのは実感に合わないので、
    これは選ばない方がよいと思います。

(5) 「必ず同じ」を目指さず、ばらつきとして扱う（動きを変えない案）

  いまのまま回し、同じ種の1回を取り直しても同じにならない前提で、
  **種を10個にして中央値で比べる**（設計 v0 の 1 章で決めた通り）。
  → 動きは1つも変わらないので、**基準データ v2 がそのまま使えます**。
  → ただし 3 章の「忙しいと人の反応が鈍る」偏りは残るので、
    夜（3本同時）と昼（1本）の結果を混ぜて比べられません。
    判定するなら「夜の条件で取り直した基準」と比べる必要があります。
    （設計 v0 の 4 章で、最初の夜の60回を基準データ v3 にすると決めてあるので、
      そこで「夜の条件の基準」に置き換わります。筋は通ります）

(6) 選ぶときに知っておいてほしいこと

  ・(1)〜(4) はどれも「基準データ v2 の 18 回を回し直す」が付いてきます。
  ・(0) と (5) だけなら回し直しは要りません。
  ・v12（夜の仕組み）に進むうえで必要なのは「同じ結果が出ること」ではなく
    「忙しさで結果が偏らないこと」です。(5) だけだと偏りは残るので、
    (0) ＋ (2) ＋ (5) の組み合わせ（安く・偏りを小さくし・ばらつきは認める）が
    いちばん釣り合いがよいと思います。
    「同じ種なら必ず同じ」まで欲しいなら (0) ＋ (1) ＋ (2) で、
    そのうえで 18 回を回し直す形です。


====================================================================
5. 決める前にやれる、安い確かめ（提案）
====================================================================

どれが効いているかを切り分けるのに、同じ ③1台・種1 を2回ずつで足ります。

  試し1  (2) だけ入れる（pathfindingIterationsPerFrame を 5000 に）
         → 2回が一致するか。しなければ (A) 以外の元も残っていると分かる
  試し2  (1) を入れる（人の道を同期にする）
         → 2回が一致するか。ここで一致すれば、元は (A) だけと確定できる
  試し3  試し2 の状態で、わざと重くして回す（ほかに2本回しながら1本）
         → 忙しさで結果が動かないかを見る。v12 で本当に欲しいのはこれ

  どれも1回 11 分なので、2回ずつ3通りで 70 分ほどです。
  (1) を入れた版での基準データの回し直しは、試し2・試し3 の結果を見てから決める形に
  できます。

いまは何も直していません。どの案で進めるか決めてください。


====================================================================
6. 試し1・試し4 の結果と、エイイチの決定（2026-10-25 19:40 追記）
====================================================================

(1) 結果

  出力：KIOXIA service_sim/unity/20261025_det_check_cs2_fam_p1_r1_s1 と _2・_3・_4

    試し  切り替え                                        2回がずれたコマ
    ------------------------------------------------------------------------
    試し1  道探しの枠 5000 / 同期 切 / 回避 切            frame 19178（t_s 958.759）
    試し4  道探しの枠 5000 / 同期 入 / 回避 切る          frame 30537（t_s 1527.184）

  ★ **ずれ方が「コマのずれ」ではなく「小数の最下位」でした。**

    試し1 の最初のずれ   people_sum 191507181 → 191507198（差 17）
    試し4 の最初のずれ   people_sum 195291062 → 195289556（差 1506）

    指紋は x を 31 倍・z を 17 倍して足しているので、差 17 は
    **1人の z が 0.1 mm だけ違った**という意味です。
    コマが1つずれたなら 0.05 秒 × 歩く速さ ＝ 6 cm 以上（600 単位以上）動くので、
    前に見た 1546 秒の「4コマずれ」とは別の形です。
    どちらの回も、ずれたコマの path_pending は 0（道待ちの人はいない）でした。

  → 道の非同期（pathPending）は **原因から外れました**。
    局所回避（RVO）を切っても残るので、**RVO も単独の原因ではありません**。

(2) 物理の返る順番は、白と出ました

  run_info.json（4回ぶんぜんぶ）
    sensor_max_hits = 6 ／ sensor_overflows = 0

  前のセンサーの箱に入った当たりは最大 6 個で、枠の 32 個に遠く届いていません。
  **切り捨ては一度も起きていない**ので、2 章 (C) の「あふれたときにどれが落ちるか」は
  原因から外せます（残るのは「ぴったり同じ距離で並んだとき」だけ）。

(3) エイイチの決定（2026-10-25）

  **「同じ種なら必ず同じ」は今は追わない。ばらつきとして扱い、
    種10個の中央値で比べる（4 章の (5)）で進める。**

  ・(0) の記録の直しも入れない（下の (5)）
  ・試しの直しは元に戻す（(4)）
  ・残りの候補は直さず、別の課題にする（7 章）


====================================================================
7. 残っている候補の一覧（直していません。別の課題にします）
====================================================================

エイイチの挙げた「Play ごとに変わるインスタンス ID の順番に頼る所」を
ぜんぶ当たりました。**挙げられた3つは、どれも原因ではありませんでした。**

  候補                                        調べた結果
  --------------------------------------------------------------------------
  GetInstanceID()                             **1か所もなし**（ServiceSim 全体）
  Object を鍵にした Dictionary / HashSet       4 つあるが、**どれも回していない**
    JamWatcher._state                           （Dictionary<PersonBody, State>）
    ServiceOps._carrying / _lastBatch /          （Dictionary<ServingRobotController, …>）
                _stopDishes
    → ぜんぶ TryGetValue と鍵引きだけ。foreach は 0 か所。
      回し順が変わっても結果は変わりません
  FindObjectsOfType の並び                     回している間は使っていない。
    ExperimentRunner:195-199（下ごしらえで1つずつ拾う）
    ExperimentRunner:230 DisableCameras（ぜんぶ enabled=false にするので順番に意味なし）
    ほかは Editor の絵と札だけ
  NavMesh を焼く元（_sources）の並び            Unity の NavMeshBuilder.CollectSources
                                              （階層の順）＋ 店を建てた順。決まっています

理屈では残る所（**未確認**。小数の最下位を動かしうるもの）

  ア. Unity の NavMeshAgent 自身の動き（本命の疑い）
     人（スタッフ・客）は updatePosition を切っていないので、
     位置を決めているのは **Unity のエージェントの計算**です。
     これはワーカースレッドの仕事として解かれるので、
     どのスレッドがどの人を担当するか・どの順でまとめられるかが
     Play ごとに変わると、最下位の桁が変わりえます。
     回避を切っても残るのは、回避ではなく
     この「進める部分」が効いているからではないか、と見ています。
     → 確かめるには、人も updatePosition を切って自分の script で動かす形
       （ロボットと同じやり方）にして2回回すのが早いです。
       ただし人の動きは大きく変わるので、基準データは作り直しになります。

  イ. Play ごとに作り直す NavMesh と、作り直す通行者の種類
     1回ごとに Play を出入りするので、NavMesh は毎回焼き直しです。
     人用の面は NavMeshBaker が `NavMesh.CreateSettings()` で
     **その Play のための通行者の種類を1つ作って**焼きます
     （NavMeshBaker.cs:148-151）。
     焼き直した面のポリゴンの並びや種類の番号が前の Play と違うと、
     CalculatePath が返す角の座標が最下位の桁で変わりえます。
     → NavMeshAreas.asset は Humanoid 1種のまま増えていないので
       「残り続ける漏れ」はありません。変わるのは Play の中だけです。
     → 確かめるには、1回の Play の中で同じ条件を2回続けて回して
       （Play を出入りしない）比べると分かります。

  ウ. 同じ距離で並んだときの勝ち負け（可能性は低い）
     FrontSensor の「いちばん近いもの」は `d >= nearest` で切るので、
     **ぴったり同じ距離**のときだけ、返ってきた順で勝ちが変わります。
     HigherPriorityNear（`d > bestD` で切る）も同じ形です。
     小数がぴったり一致することはまず無いので、可能性は低いと見ています。

  エ. 使い回しの体の並び（PersonBody.All / StaffAgent.All）
     待合・立ち歩きの人は体を使い回すので、
     OnEnable / OnDisable の順で静的リストの並びが変わります。
     ただし、これを回している所は
       ・いちばん近い／いちばん小さいを選ぶ（順番に依らない）
       ・数を数える（順番に依らない）
     だけで、**float を足し込んでいる所はありませんでした**。
     指紋（det_frames）の people_sum は私が足した記録用で、動きには使っていません。

別の課題にするときの進め方（案）
  1. イ を先に確かめる（Play を出入りせず、同じ Play の中で2回）。安くて切り分けが進む
  2. イ でも残るなら ア。人の動かし方を変える話になるので、
     基準データの作り直しとセットで決める
  3. どちらも「ばらつきとして扱う」決定を覆すものではありません。
     種10個の中央値で比べる限り、0.1 mm のずれは判定に出ません


====================================================================
8. 既知のずれとして残すもの（直していません）
====================================================================

(1) 来客の締め切りが 9.3 秒ぶん長い

  ops.NowS は `NowS += dt` と float で足しているので、だんだん遅れます。
  来客を止める時刻だけは **絶対値で比べている**ため、ずれがそのまま効きます。

    ExperimentRunner.cs:602  if (ops.NowS >= _arrivalsEndS …)
    GroupManager.cs（Arrive） if (now >= arrivalsEndS) return;
      _arrivalsEndS = peak_min × 60 = 7200 秒（定数）

    本当の 7200 秒のとき NowS = 7190.68 秒（−9.32 秒）
    → **本当の 7209.3 秒まで来客を受け付けています**（9.3 秒ぶん長い）

  2回の回しで同じようにずれるので、ばらつきの原因ではありません。
  直すと来客が 9 秒ぶん減るので、基準データを作り直すときに一緒に決めます。

(2) 記録の時計が2系統ある

  stops.csv の t_s は `Time.time - _timeOffset`（ちょうど 0.05 の倍数）。
  ほかの記録（yields・groups・give_ups・summary の total_minutes）は ops.NowS。
  179 分の回では 20 秒以上食い違います。記録だけの話です。

(3) plan_ms_max は実時間

  trips.csv の plan_ms_max と run_info の plan_ms は Stopwatch で測った実時間で、
  **毎回必ず違います**（1回めの往復から違う）。動きには一切使っていません。
  台帳や判定で「同じか」を比べる列から外してください
  （実時間・timestamp と同じ扱い）。

  (1)(2)(3) はどれも 4 章の (0) に当たる直しです。**入れていません。**
```

### 20261025_1804_v11_det_check_prep.txt

```text
====================================================================
依頼 v11 の 5 章：ずれの切り分けの仕掛けを作った（回すのはエイイチ）
2026-10-25 18:04
====================================================================

結論

  ・試し1〜3 を押せる形にしました。名札は **det_check** で、
    段階Bの18回とも split_check とも混ざりません。組み立ては通っています（54 本・エラー 0）。
  ・**私からは Play を押せません**（Unity が ServiceSim のプロジェクトを押さえています）。
    4 章の手順で押してください。1回 11 分・2回ずつ3通りで 70 分ほどです。
  ・試しの直しは **check-in していません**。保留は私が直した 6 本だけです。
  ・★ ご指摘のとおりでした。3 章の「忙しいと pathPending が長くなる」は
    **私が確かめずに書いた見込み**です。1 章で訂正し、試し3 で確かめる形にしました。
  ・ops.NowS の洗い出しは 2 章です。**(0) の直しは入れていません。**
    会計・片付けの時間、60 秒であきらめる、600 秒で卓を空ける は NowS を使っておらず、
    2 分待ちは NowS を使っていますが「引き算」なのでずれは消えます。
    1 か所だけ、来客を止める時刻が絶対値で比べられていて、ずれが効きます。


====================================================================
1. 【訂正】pathfindingIterationsPerFrame は忙しさで変わらないはず
====================================================================

ご指摘のとおりです。Unity の説明では

  NavMesh.pathfindingIterationsPerFrame
    = 「1コマで処理する道探しの節の最大数」（既定 100）

で、**実時間ではなくコマあたりの回数**です。1コマ 0.05 秒に固定してあり、
注文が溜まる順番はシミュレーションの中身だけで決まるので、
**この枠が原因なら Mac の忙しさでは変わらないはず**です。

前の報告の 3 章で「忙しいと pathPending が長くなる」と書いたのは、
私が「非同期だから実時間に引っ張られるだろう」と見込みで書いたもので、
確かめていませんでした。取り消します。すみません。

では、2回がずれた元は何か。いまの時点で言えるのは次までです。

  確かなこと
    ・2回の回しで違っていた入力は、実時間の値だけ（plan_ms_max が1回めの往復から違う。
      wall_seconds は 654.5 秒 / 601.1 秒）
    ・1546.25 秒までは、出力が小数4桁まで完全に一致していた
    ・最初のずれは「スタッフの譲りが4コマ遅れた」で、
      その判断は _agent.pathPending が true の間は打ち切られる作りになっている

  まだ分かっていないこと
    ・pathPending の長さが、何によって2回で変わったのか。
      枠がコマあたりなら、残る筋は「道探しや局所回避がワーカースレッドで解かれていて、
      main スレッドが結果を読む時点で終わっているかどうかが、
      スレッドの込み具合で変わる」あたりですが、**確かめていません**。
    ・そもそも「忙しさ」ではなく、同じ Editor の中で2回続けて回したことによる
      持ち越し（前の回の状態）かもしれません。

  だから試し3 を「わざと重くして回す」に変えずに、**2つに分けました**（4 章）。
    試し3a  同じ条件でもう2回（何も重くしない）
            → 毎回ずれるのか、たまたまだったのかが分かる
    試し3b  ほかに2本回しながら1本
            → 忙しさで**結果が変わるか**。ご指摘どおりなら変わらないはず

  試し3b で結果が変わったら、「何が変えたか」を次の順で詰めます。
    ・run_info.json の wall_seconds と plan_ms（どれだけ重くなったか）
    ・det_frames.csv の path_pending 列（1コマごとに、道待ちの人が何人いたか）を
      3通りで並べる。忙しいときに path_pending が増えていれば、
      「枠はコマあたりでも、結果を読む時点で終わっていない」ことの裏づけになります
    ・増えていないのに結果が変わったら、道待ち以外（局所回避）が原因と分かります

  ついでに1つ消しました。
    通行者の種類（NavMesh.CreateSettings）が Play ごとに増えて、
    1回めと2回めで人用の種類の番号が違ったのでは、と疑いましたが、
    NavMeshAreas.asset は cs:1 のときも今も **Humanoid 1種のまま**でした。
    この筋は無しです。


====================================================================
2. ops.NowS を判断に使っている所（(0) の直しは入れていません）
====================================================================

NowS を読んでいる所は 56 か所ありました。判断に効くものだけ並べます。

(a) 引き算なので、NowS がずれても **影響しない**もの

    ServiceOps.cs:1317  if (NowS - d.readyS < wait)
      → ★ 料理の「2分待ち」（robotFirstWaitSec = 120）。
        readyS も NowS から入れているので、引き算でずれが消えます。安全
    GroupManager.cs:312  float now = ops.NowS → Arrive / SeatWaiting / Advance
      → 来客の間隔（now < _nextArrivalS）、料理ができる時刻（now < g.cookAt[i]）、
        食べ終わり（now < g.eatEndS）、呼び出し、ドリンクバー・トイレ。
        どれも「now ＋ 決めた秒数」で入れた時刻と now を比べているので、引き算。安全
    ServiceOps.cs:1057,1063  turnMin / seatWaitMin
    ServiceOps.cs:516       仕事が板で待った秒数（依頼 v10 の 2）
      → 記録。引き算なので安全

(b) **絶対値で比べていて、NowS のずれがそのまま効く**もの

    ExperimentRunner.cs:602  if (ops.NowS >= _arrivalsEndS && !groups.AnyGroupInStore)
    ExperimentRunner.cs:607  return ops.NowS >= _hardEndS;
    GroupManager.cs（Arrive） if (arrivalsEndS > 0f && now >= arrivalsEndS) return;
      → _arrivalsEndS = peak_min × 60 = **7200 秒**（定数）。
        NowS は float で足し続けているので、本当の 7200 秒のときには
        NowS = 7190.68 秒（**−9.32 秒**）。
        つまり **来客を 9.3 秒ぶん長く受け付けています**（本当の 7209.3 秒まで）。
        打ち切り（_hardEndS = 7200 + 600×60）も同じだけずれます。
      → ただし **2回の回しで同じようにずれる**ので、今回の「2回がずれる」の原因ではありません。
        直すと来客が 9 秒ぶん減るので、**基準データ v2 の数字は少し動きます**。

    ExperimentRunner.cs:560  if (ops.NowS >= _nextTimelineS)
      → timeline.csv の t_s（0・60・120…）。記録だけですが、
        終わりごろには本当の時刻と 20 秒以上ずれた札になります
        （179 分の回で測ると −23.4 秒）

(c) NowS を **使っていない**もの（ご指摘の項目はこちら）

    ★ 会計 90 秒・片付け 40＋40 秒・案内 45 秒の dwell
        → StaffAgent の `_timer += Time.deltaTime`
    ★ 60 秒であきらめる（staffGiveUpSec）
        → StaffAgent の `_stepS += Time.deltaTime`
    ★ 600 秒で卓を空ける（tableStuckFreeSec・依頼 v10 の 1）
        → GroupManager の `g.stuckS += dt`
    ・ロボットが 60 秒で動けなくなる（stuckAfterStoppedSec）、
      1往復の打ち切り（maxTripSec）、人どうしの詰まり（JamWatcher）
        → どれも dt の足し算
    ・StoppedNowS は名前に NowS が付いていますが、中身は積み上げた秒数です

  まとめ
    ・ご心配の「会計・片付けの時間、60 秒であきらめる」は NowS と無関係でした。
    ・「2 分待ち」は NowS を使いますが引き算なので安全です。
    ・効くのは **来客を止める時刻と打ち切りの時刻**（9.3 秒ぶん長い）だけです。
    ・なので (0) の直しは「記録をきれいにする」だけでなく、
      **来客の長さが 9 秒変わる＝基準データ v2 の数字が少し動く**直しになります。
      前の報告で「(0) なら v2 はそのまま使える」と書いたのは不正確でした。
      訂正します。(0) のうち
        ・plan_ms_max を比べる列から外す      … 動き変わらない
        ・stops.csv の t_s を NowS にそろえる  … 動き変わらない
        ・NowS をコマ数×0.05 にする            … **来客が 9 秒短くなる。v2 が動く**
      の3つに分けて決めてください。


====================================================================
3. 試し2 で一致しなかったときの、残りの元の見分け方
====================================================================

残るのは (B) 局所回避（RVO）と (C) 物理の返る順番です。
見分けられるように、2つの道具を足しました。

(C) 物理の返る順番 — **先に白黒がつきます**

  FrontSensor は箱に入った当たりを 32 個の配列で受け、
  超えたら切り捨てます（どれが落ちるかは返る順番で決まる）。
  実際に超えているかを数えて run_info.json に出すようにしました。

    "sensor_max_hits":  その回でいちばん多かった当たりの数
    "sensor_overflows": 32 を超えて切り捨てた回数

  → sensor_overflows が 0 なら、**切り捨ては起きていない＝(C) は原因から外せます**。
    （残るのは「ぴったり同じ距離で並んだとき」だけですが、
      小数の一致はまず起きないので、実質無しと見ます）
  → これは試し1 の出力でもう分かります。試し2 を待つ必要がありません。

(B) 局所回避（RVO） — ずれ方の形で分かります

  1コマごとの指紋を det_frames.csv に書くようにしました（名札 det_check のときだけ）。

    frame,t_s,people,robots,people_sum,robot_sum,path_pending

    people_sum / robot_sum  位置を 0.1 mm まで整数にして混ぜた数。
                            2回ぶんを並べると **どのコマで初めてずれたか** が1コマ単位で分かる
    path_pending            そのコマで道待ちだった人の数

  見分け方
    ・**RVO なら**：ずれ始めが **早い**（数分以内）。人が2人以上近づいた最初の場面から
      people_sum が違い始め、path_pending は同じまま。
      位置の差はごく小さく（1 mm 未満から）、だんだん大きくなる
    ・**道待ち（A）が残っているなら**：ずれ始めまで people_sum がぴったり一致し、
      ずれた**そのコマで path_pending が違う**。差は「コマ何個ぶん」の形で出る
      （いまの 1546 秒・4コマのずれと同じ形）
    ・どちらでもないなら、試し4（局所回避も切る）を押します。
      それで一致すれば RVO、しなければさらに別の元があると分かります

  念のため：試し1・試し2 でも指紋を書くようにしてあります。
  一致していても「最後まで people_sum が同じ」ことが確かめられます。


====================================================================
4. エイイチにお願いすること（押す順番）
====================================================================

  Unity に切り替えてください（私が直した .cs が取り込まれます）。Console の赤い字を確認。

  試し1 — 道探しの枠だけ大きくする
    1. ServiceSim → Stage B → 切り分けの設定 → 試し1：道探しの枠を 5000 にする
       （Console に「道探しの枠 5000 回/コマ / 人の道を同期 切 / …」と出ます）
    2. ServiceSim → Stage B → ずれの切り分け（③1台・種1 を2回）
       → {日付}_det_check_cs2_fam_p1_r1_s1 と _2 ができます

  試し2 — 人の道を同期で引く
    3. 切り分けの設定 → 試し2：人の道を同期で引く
    4. ずれの切り分け（③1台・種1 を2回）

  試し3a — 試し2 のまま、もう2回（たまたまかを見る）
    5. 4 をもう一度押す（_3 と _4 になります）

  試し3b — 忙しくして1回
    6. ほかの重い処理を2つ走らせながら（または別の Unity を2つ回しながら）、
       もう一度 4 を押す
       ※ 1回の実時間の上限は 40 分です。3本同時で超えそうなら、
         先に ExperimentPlan の maxWallSecondsPerRun ではなく
         ServiceSimStageB の上限（40 分）に当たるので、
         status が stopped になったら教えてください

  終わったら
    7. 切り分けの設定 → ぜんぶ切る（cs:2 と同じ動きに戻す）
    8. 出力フォルダ名を教えてください。det_frames.csv と run_info.json を私が並べます

  ★ 試し1〜4 はどれも **ExperimentPlan.asset を書き換えます**（切り替えの保存）。
    これも check-in しないでください。終わったら 7 を押して、
    私が cm undochange で元に戻します。


====================================================================
5. 私が直したファイル（check-in していません）
====================================================================

  いまの保留 6 件。ぜんぶ「試しの切り替えを入れた」分で、
  切り替えを切れば cs:2 と同じ動きになります。

  Experiment/ExperimentPlan.cs      切り替え4つを足した（初期値はぜんぶ切）
                                      detCheckPathIterations（0 = 触らない）
                                      detCheckSyncPeoplePaths
                                      detCheckNoAvoidance
                                      detCheckFrameFingerprint
  People/PeopleNav.cs               Go に同期で引く道を足した（SyncPaths が true のとき）。
                                    引けなければ今までどおり非同期に任せる
  Robot/FrontSensor.cs              当たりの最大数と切り捨て回数を数えた（MaxSeen / Overflows）
  Experiment/ExperimentRunner.cs    切り替えを入れる・戻す（ApplyDetCheck / RestoreDetCheck）、
                                    det_frames.csv を書く、run_info に det_check を出す
  Experiment/TripLogger.cs          run_info.json に det_check・sensor_max_hits・
                                    sensor_overflows を足した
  Editor/ServiceSimStageB.cs        名札 det_check・メニュー
                                    「ずれの切り分け（③1台・種1 を2回）」と
                                    「切り分けの設定」5つ

  変えていないもの
    ・店の回し方・ロボットの動き・人のよけ方の決まり（切り替えを切った状態で）
    ・ops.NowS（(0) の直しは入れていません。2 章のとおり決めてから）
    ・台帳の列（増やしていません）
    ・段階Bの18回の中身と順番

  増えたメニュー（ほかは cs:2 と同じ）
    ServiceSim/Stage B/ずれの切り分け（③1台・種1 を2回）
    ServiceSim/Stage B/切り分けの設定/試し1：道探しの枠を 5000 にする
    ServiceSim/Stage B/切り分けの設定/試し2：人の道を同期で引く
    ServiceSim/Stage B/切り分けの設定/試し4：局所回避も切る（要るときだけ）
    ServiceSim/Stage B/切り分けの設定/ぜんぶ切る（cs:2 と同じ動きに戻す）
    ServiceSim/Stage B/切り分けの設定/いまの設定を見る
```

### 20261025_1945_v12_tools.txt

```text
====================================================================
依頼 v12 の 4 章：夜の実行係の道具（tools/）を作った
2026-10-25 19:45
====================================================================

結論

  ・tools/ に4本作り、**Unity 抜きで通しの試験まで通しました**。
    check-in は **cs:4**（tools）と **cs:5**（ignore.conf）です。
  ・**Assets は1バイトも触っていません**（18回を回している途中なので）。
  ・1〜3 章（Assets の中）は手を付けていません。18回が終わってからです。

★ 先に2つ、見ておいてほしいことがあります（6 章）。
  (1) いま **Assets の Robot まわり4本が書き換わっています**（19:41〜19:43）。
      私の分ではありません。「依頼 v13」と書かれているので、別の手で
      すれ違いの実装が進んでいるようです。
      **スクリプトの読み込み直しが起きるので、18回が止まっている疑いがあります。**
  (2) 段階Bの18回は、台帳に cs:3 の行が **0 件**・出力フォルダも **0 件**で、
      出力先に 30 分なにも書かれていません。


====================================================================
1. 作ったもの（tools/・Python の標準ライブラリだけ）
====================================================================

  tools/make_queue.py     条件 × 種を queue.json に並べる
  tools/night_runner.py   N 本同時に回す・台帳に書く・判定を呼ぶ
  tools/ledger.py         台帳に鍵をかけて1行足す（night_runner が使う）
  tools/judge_night.py    judge_night_v0.py を **1バイトも変えずに** 入れた

依頼文には3本と書いてありましたが、**台帳に書く所だけ ledger.py に分けました**。
依頼 5 章 D で「台帳を壊すと戻しにくいので codex にレビューをかける」と決まっているので、
レビューの的を1ファイル（230 行）にしたかったからです。

### make_queue.py

条件は **手で写さず** Unity と同じ所から引きます。
  Assets/ServiceSim/Experiment/serving-robot-params.json の unity_conditions
条件の名前は Unity の SimParams.Condition.Id と同じ形（fam_p1_s4_r1_a42）で、
そのまま実行ファイルの -ssCondition に渡せます。

  python3 tools/make_queue.py --out queue.json
  → 試算の版 v2.8 / 店の形の版 8a5b2f6a016a
    条件 6 / 種 0〜9 / 合わせて 60 回

queue.json には、条件ごとに store・staff・robots・arrivals_per_h・mode・trays・
fragile_by・registers も入れました（実行係が台帳の行を書くときに使います）。

### night_runner.py

  python3 tools/night_runner.py --queue queue.json --parallel 2 --night 20261026

依頼 4 章のとおり:
  ・始める前に KIOXIA・実行ファイル・台帳のヘッダ・版を確かめ、
    どれかが駄目なら **始めない**
  ・caffeinate でスリープを止め、終わりに必ず解く（KeyboardInterrupt でも解く）
  ・N 本まで同時。1本終わったら次を起こす（--stagger で少しずらす。既定5秒）
  ・台帳に completed の行がある回は飛ばす（途中で止めても続きから）
  ・1回の上限（--limit-min。既定90分）を超えたら SIGTERM → 10秒待って kill、
    status=stopped の行を台帳に足す
  ・落ちた回は status=failed の行を足す
  ・止まった・落ちた回は **その夜のうちに1度だけ** やり直す（フォルダに _try2、
    実行ファイルには -ssTry 2 を渡す）
  ・KIOXIA が外れたら、新しい回を始めずに走っている回を待ち、終わったら止まる
  ・時刻では止めない
  ・記録は service_sim/unity/night/{night}/runner_log.txt と logs/{回}.log
  ・全部終わったら judge_night.py を呼ぶ（service_sim/unity/ で）
  ・終了コード 0＝ぜんぶ completed／2＝止まった・落ちた回があった／1＝始められなかった

実行ファイルの呼び方（依頼 2 章の形のまま）:
  {実行ファイル} -batchmode -nographics -logFile {その回のログ}
      -ssCondition fam_p1_s4_r1_a42 -ssSeed 3 -ssLabel night -ssNight 20261026
      -ssParallel 2 [-ssTry 2]

### ledger.py

C# の ServiceSimLedger と同じ決まりにしました。
  ・**追記だけ**。前の行は読むだけ
  ・同じ run_folder の行は2度書かない
  ・**列の並びは実体の1行め（ヘッダ）が正**。
    C# 側が「ヘッダとコードの列が1字でも違えば書かない」を守っているので、
    実体のヘッダを信じれば C# と Python の列がずれません
    （列の一覧を Python にもう1つ持たせると、そこがずれる元になるので持たせていません）
  ・そのうえで、頭の並び（timestamp,run_folder,status,unity_cs,…）と
    ledger_schema_version があることだけ確かめる
  ・ヘッダに無い列名を渡したら例外（打ち間違いを通さない）
  ・値のカンマ・引用符・改行は落とす（C# の Clean と同じ）
  ・最後が改行で終わっていない台帳に足すときは、先に改行を入れる
  ・書いたら fsync する

鍵：unity_ledger_v1.lock を O_EXCL で作れたら持ち主、閉じるときに消す。
取れなければ 0.25 秒おきに最大60秒試す。
C# から new FileStream(lock, FileMode.CreateNew) で取るのと同じやり方なので、
依頼 3 章の Unity 側と同じ鍵を取り合えます。
持ち主が落ちて鍵が残ったら、120 秒より古ければ1度だけ壊します。


====================================================================
2. 確かめ A（Unity 抜きでやれた所）
====================================================================

(1) 構文とライブラリ
  4本とも py_compile を通る。import は argparse / csv / datetime / errno / glob /
  json / os / re / signal / socket / statistics / subprocess / sys / time /
  collections（ぜんぶ標準）。judge_night.py は元のファイルと **cmp で同一**。

(2) make_queue.py
  60 回（6条件 × 種0〜9）。試算の版 v2.8・店の形 8a5b2f6a016a を queue.json に写した。
  --conditions / --seeds で絞れることも確かめた。

(3) ledger.py（本物の台帳は読むだけ。書いたのは写しに対してだけ）
  23 件ぜんぶ OK。
    ・59 列のヘッダを読める／ledger_schema_version がある
    ・1行足せて、書いた値（status・seed・store）が読み直せる。測った値の列は空のまま
    ・同じ run_folder は2度書かない
    ・ヘッダに無い列名は例外で止まる
    ・run_folder が空なら書かない
    ・値のカンマ・改行を落とすので、足した行はどれも 59 列
    ・ヘッダが違う台帳には書かない（追記ガード）
    ・鍵は同時に2つ持てない／離せば取れる／離すとファイルが消える
    ・**10本から同時に足しても10行ぜんぶ入り、行が混ざらず、ヘッダも壊れない**
  ※ 台帳の行の列数は {49列: 36行, 59列: 新しい行} です。
    49 列は依頼 v10b で schema 2 にしたときに書き換えなかった古い行で、仕様どおりです。
  ※ 本物の台帳は sha256（頭12桁）2e0580691ee1 のまま変わっていません。

(4) night_runner.py（偽の実行ファイルで通しの試験）
  実行ファイルの代わりに、終了コードと待ち時間を変えられる偽物を作り、
  台帳も写しに向けて1通り流しました（4回・同時2本・上限0.2分）。

    種0・種1  終了コード0 → completed（偽物が自分で台帳に行を書く＝本物と同じ）
    種7       終了コード1 → failed。実行係が行を足し、_try2 でやり直し、
                            2回めも failed（**3回めはやらない**）
    種8       終わらない  → 上限で SIGTERM → stopped。行を足し、_try2 でやり直し、
                            2回めも stopped
    まとめ    completed 2 / failed 2 / stopped 2。終了コード 2
    判定      service_sim/unity/ で judge_night.py --runs '20261026_night_*'
              --out night/20261026 を呼び、judge.md を書いた

  できた台帳（写し）を確かめた結果:
    足された6行はぜんぶ 59 列・unity_cs は ServiceSim/cs:3・seed と wall_seconds が入る
    ヘッダは無事・鍵は残っていない

(5) 空回し（--dry-run）
  実行ファイルが無くても、打つコマンドだけ見られます。
  版は cm status から ServiceSim/cs:3 を拾い、フォルダの字は cs3 になりました。

やれなかった確かめ（1〜3 章が済んでから）
  依頼 5 章 A の「実行ファイルが作れる」「画面なしで短い1回が回る」「2本同時で
  台帳に2行」は、実行ファイルがまだ無いのでできません。
  いまできる範囲は、偽の実行ファイルで (4) まで見た形です。
  5 章 B（画面なしでも同じ結果か）・C（何本同時に回せるか）も 1〜3 章のあとです。


====================================================================
3. judge_night.py について（変えていません。直した方がよい所の報告）
====================================================================

glob の確かめ（本物のフォルダで・読むだけ）

    20261025_stageB_cs144_*   → 18 件   ← 基準データ v2。合う
    20261025_det_check_*      →  4 件
    20261025_split_check_*    →  2 件
    20261026_night_*          →  0 件（まだ回していない）
    20261025_stageB_cs3_*     →  0 件（6 章を見てください）

  → 新しい名前（{night}_{ラベル}_cs{N}_{店}_r{台数}_s{種}）でも
    `{night}_{ラベル}_*` でちゃんと拾えます。**glob は直す必要なし**です。

直した方がよいと思う所（**直していません**。別の課題か、v12 の続きで決めてください）

  ア. **落ちた回・止まった回を拾えません**（いちばん大事）
     judge は `glob` で **フォルダがある回** だけを見ます（collect の isdir）。
     出力フォルダを作る前に落ちた回は、台帳に failed の行があっても
     judge からは見えず、「回の失敗 … なし」と出ます。
     偽の実行ファイルの試験でも、6回のうち judge が見たのは **2回だけ**でした
     （completed の2回。failed 2・stopped 2 は見えていない）。
     → 直すなら「--runs に合うフォルダ」＋「台帳でその夜・そのラベルの行」の
       両方を見て、フォルダが無い行を「回の失敗」に入れる形です。

  イ. **基準データの既定が、前のプロジェクトのままです**
     BASELINE_DEFAULT = '20261025_stageB_cs144_*'（Robotics_UX_New の cs:144）。
     これから基準にするのは ServiceSim の18回なので、
     夜に呼ぶときは --baseline を明示する必要があります。
     いまの実行係は --baseline を渡さなければ既定のままにします
     （--baseline で渡せるようにしてあります）。

  ウ. **基準データが0件のとき、全部「Unity の作り」になります**
     試験で基準が0件だったとき、completed の2回がどちらも
     「Unity の作り」に振り分けられました（比べる範囲が無いので外れ扱いになる）。
     → 基準が0件なら、警告して止まる方が安全だと思います。

  エ. **_try2 の回と、元の回の両方が judge に入ります**
     `{night}_{ラベル}_*` は _try2 も拾います。
     元の回（stopped）と やり直し（completed）が両方入るので、
     元の回は「回の失敗」に数えられます。
     記録としては正しいのですが、「60回のうち何回が正常か」を見るときに
     分母が増えます。どう数えるかを決めてください。


====================================================================
4. 決めてほしいこと（1つだけ）
====================================================================

出力フォルダに入る「版の字」

  依頼 2 章の例      20261026_night_**ss12**_fam_p1_r1_s3
  いまの Unity 側    20261025_stageB_**cs144**_…／20261025_det_check_**cs2**_…
                     （ServiceSimStageB.FolderName が "cs{N}" にしている）

  いまは **cs{N}** にそろえてあります（service_sim/unity/ の中の名前が
  ぜんぶ同じ形になるので、台帳の名札の読み分けもそのまま使えます）。
  ss{N} にしたいなら、実行係は --cs-token 'ss{N}' で合わせられますが、
  **1〜3 章の実行ファイル側も同じにそろえる**必要があります。
  私は cs{N} のままが良いと思いますが、決めてください。


====================================================================
5. 最初の夜のコマンド（実行ファイルができてから）
====================================================================

  cd /Users/shimazueiichi/Robotics_UX/ServiceSim
  python3 tools/make_queue.py --out queue.json
  python3 tools/night_runner.py --queue queue.json --parallel 2 --night 20261026 \
      --baseline '20261025_stageB_cs3_*'

  ・--parallel は 5 章 C の実測で決める値です（いまは仮に 2）
  ・--baseline は基準にする18回の glob（6 章のとおり、まだ回り切っていません）
  ・上限を変えるなら --limit-min 90
  ・先に打つコマンドだけ見たいなら --dry-run を足す

  ★ 依頼 5 章 D のとおり、最初の夜の前に台帳を写します（コピーだけ）:
      mkdir -p /Volumes/KIOXIA/r2r2r-data/service_sim/unity/archive
      cp /Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv \
         /Volumes/KIOXIA/r2r2r-data/service_sim/unity/archive/unity_ledger_v1_prenight_20261026.csv
    これは夜を始める直前に私がやります（いまやると18回の行が入りません）。


====================================================================
6. ★ 見ておいてほしいこと（私の分ではありません）
====================================================================

(1) Assets の Robot まわりが、いま書き換わっています

  cm status（19:45 時点）

    PR  Assets/ServiceSim/Robot/RobotPassBy.cs              19:42 に作られた（新しい）
    CH  Assets/ServiceSim/Robot/ServingRobotController.cs   19:43
    CH  Assets/ServiceSim/Robot/RobotSpec.cs                19:41
    CH  Assets/ServiceSim/Experiment/ExperimentRunner.cs    19:45 ごろ
    CH  Assets/ServiceSim/Experiment/TripLogger.cs          19:45 ごろ

  中身に「依頼 v13 の 依頼1」「すれ違い（実店舗の動画に合わせた）」と書かれており、
  RobotSpec に passByEnabled / passByDistanceM 2.0 / passBySpeedMps 0.3 /
  passBySeparationM 0.1 が足されています。
  前回私が「こう変える」と報告した すれ違いの実装そのものです。

  **私は Assets を1つも触っていません**（ご指示どおり）。
  別の手（エイイチか、別のセッション）で進んでいるのだと思います。
  ただ、ご指示の理由そのまま、**スクリプトの読み込み直しが起きるので、
  回している途中の回が壊れます**。心当たりがなければ確かめてください。

(2) 段階Bの18回が進んでいないようです

  台帳          ServiceSim/cs:3 の行が **0 件**
                （内訳は cs:140 18・cs:143 18・cs:144 18・ServiceSim/cs:2 6）
  出力フォルダ  20261025_stageB_cs3_* が **0 件**
  出力先        ここ30分で書かれたファイルが **0 件**
  run_all_log   最後の記録は det_check の2回（19時前）
  Unity         動いてはいる。Editor.log の最後はアセットの解放とライセンス
                （＝読み込み直しの後のような見え方）

  18回が始まっていないのか、(1) の読み込み直しで止まったのかは、
  Unity の Console と「ServiceSim/Stage B/押す前の確かめ」で分かります。
  cs:3 の18回をやり直すことになっても、台帳は名札＋版で照合するので
  「まだ」の回から続けられます（行が残っていないので18回とも「まだ」のままです）。


====================================================================
7. check-in
====================================================================

  cs:4  tools/make_queue.py・night_runner.py・ledger.py・judge_night.py を足した
  cs:5  ignore.conf に __pycache__ と *.pyc を足した（Python の中間ファイルを入れない）

  ★ Assets の5本（6 章 (1)）は **入れていません**。私の分ではないので触っていません。
  ★ 試験に使った偽の実行ファイル・台帳の写しは scratchpad の中だけで、
    プロジェクトにも KIOXIA にも残していません。
    から回しが作った service_sim/unity/night/20261026/ も消しました
    （本番の夜に作り直されます）。

  作ったファイル
    tools/make_queue.py     174 行
    tools/night_runner.py   440 行
    tools/ledger.py         230 行
    tools/judge_night.py    291 行（judge_night_v0.py と同一）
    ignore.conf             2 行足した

  次（18回が終わってから）
    ・1〜3 章：BuildHeadless.cs・実行ファイルの引数の受け取り・台帳の鍵（C# 側）
    ・5 章 A の残り・B・C
    ・5 章 D：ledger.py と C# 側の台帳まわりに codex のレビュー（依頼文は私が作ります）
```

### 20261026_0725_path_iterations_leak.txt

```text
====================================================================
道探しの枠（pathfindingIterationsPerFrame）が残る穴を直した
2026-10-26 07:25
====================================================================

結論

  ・エイイチの疑いは **当たっている可能性が高い**です。
    戻し方に **穴が3つ**あり、どれでも 5000 がエディタを閉じるまで残りえます。
    いちばん効くのは「**切り替えを『切』にしても、Unity の中の値は戻らない**」です。
  ・ただし **18回が 5000 で回ったと断定はできません**。
    cs:7 の run_info.json には枠の値が入っていないからです（記録が無かった）。
    これが今回の直し (2) で記録されるようになります。
  ・直して check-in しました（**cs:8**）。保留 0。組み立ては通っています（55 本・エラー 0）。
  ・⑦0台・種0〜2 を 100 と 5000 で回すメニューを足しました（押す順番は 4 章）。


====================================================================
1. (1) RestoreDetCheck は戻しているか → 戻すが、穴が3つある
====================================================================

直す前のコード（cs:7）

    void ApplyDetCheck()          // StartJob から呼ぶ
    {
        _savedPathIterations = NavMesh.pathfindingIterationsPerFrame;   // 今の値を覚える
        if (plan.detCheckPathIterations > 0)
            NavMesh.pathfindingIterationsPerFrame = plan.detCheckPathIterations;
        …
    }

    void RestoreDetCheck()        // OnDisable からだけ呼ぶ
    {
        if (_savedPathIterations >= 0)
        {
            NavMesh.pathfindingIterationsPerFrame = _savedPathIterations;
            _savedPathIterations = -1;
        }
        …
    }

形としては戻しています。けれど次の3つの穴があります。

  穴A ★ いちばん効く：**「切」にしても戻らない**

    「ぜんぶ切る」を押すと detCheckPathIterations が 0 になります。
    すると ApplyDetCheck は **今の値を覚えるだけで、何も入れません**。
    つまり、その時点で Unity の中が 5000 だったら 5000 のまま回り、
    RestoreDetCheck は覚えた 5000 をそのまま書き戻します。
    → 「切」は「上書きをやめる」だけで、「100 に戻す」ではありませんでした。
      いちど 5000 が残ると、エディタを閉じるまでずっと 5000 です。

  穴B 戻すのが OnDisable だけ

    FinishJob や AllDone では戻していません。Play を抜けるときの OnDisable だけです。
    19:41〜43 のように **Play の最中にスクリプトが読み込み直された**ときは、
    _savedPathIterations（[SerializeField] でない private）が初期値 −1 に戻るため、
    `if (_savedPathIterations >= 0)` が偽になり **戻しが丸ごと飛びます**。

  穴C 1回の Play で2条件以上回すと、2度目が壊れた値を覚える

    StartJob は、並べた条件がある間くり返し呼ばれます（ExperimentRunner:881）。
    RestoreDetCheck は Play の終わりに1回だけです。
    → 1条件め：100 を覚えて 5000 を入れる
      2条件め：**5000 を覚えて** 5000 を入れる
      Play の終わり：**5000 を書き戻す**
    段階Bと Site Run は1回の Play で1条件なので、この穴は踏みません。
    段階2・段階3の Run All は踏みます。

断定できない理由

    cs:7 の18回の run_info.json を見ましたが、
      path_iterations_per_frame … **記録なし**（当時は書いていない）
      det_check … 空（切り替えはぜんぶ切）
    det_check が空なのは「上書きしていない」ことしか示さず、
    穴A のとおり **そのとき Unity の中が何だったかは分かりません**。
    なので「18回は 5000 で回った」とは言えず、**4 章の確かめで決める**しかありません。


====================================================================
2. (2) 直した中身（cs:8）
====================================================================

考え方：**knob を1つにして、回すたびに必ず入れる。戻す処理をやめる。**
戻す処理がある限り「戻し損ね」が起きるので、その形そのものを無くしました。

  ExperimentPlan.cs
    ＋ pathIterationsPerFrame = 100（新しい項目。Unity の既定と同じ）
    − detCheckPathIterations を廃止（knob を2つにしない＝隠れた設定を作らない）

  ExperimentRunner.cs
    ・回し始め（StartJob → ApplyDetCheck）で **必ず** 入れる:

        int want = plan.pathIterationsPerFrame > 0 ? plan.pathIterationsPerFrame : 100;
        NavMesh.pathfindingIterationsPerFrame = want;
        _pathIterationsUsed = NavMesh.pathfindingIterationsPerFrame;   // 読み直す
        if (_pathIterationsUsed != want) → Debug.LogWarning

      0 以下なら 100 を使います。**入れたつもりではなく、読み直した値**を記録します。
    ・RestoreDetCheck から枠の戻しを外した（_savedPathIterations も消した）
    ・回し始めのログに「道探しの枠: N 回/コマ」を必ず出す

  TripLogger.cs
    ＋ run_info.json に "path_iterations_per_frame": N（実際に入った値）

  Editor/ServiceSimStageB.cs
    ・「切り分けの設定」の表示を、枠は切り替えではなく「いまの値」として常に出す形に
    ・「ぜんぶ切る」は **枠も 100 に戻す**（穴A をメニュー側でも閉じる）
    ＋ メニュー「道探しの枠を 100 にする（既定）」「道探しの枠を 5000 にする」
    ＋ メニュー「道探しの枠の確かめ（⑦0台・種0〜2）」（名札 path_check）
    ・測る回を止める条件から枠を外した（枠はもう隠れないので）

  店の回し方・ロボットと人の動き・台帳の列は変えていません。

確かめ（Unity 抜き・19 件ぜんぶ OK）
    初期値 100 ／ detCheckPathIterations が Runner と StageB から消えた ／
    0 以下なら 100 ／ 回し始めに必ず入れる ／ 入れたあと読み直す ／
    入らなかったら警告 ／ RestoreDetCheck は枠を触らない ／
    _savedPathIterations そのものが無い ／ run_info に出る ／
    回し始めのログに出る ／ メニュー3つ ／ 名札 path_check ／ 条件3を3回・種0〜2
  組み立て：55 本・エラー 0


====================================================================
3. なぜ⑦だけ・ロボット0台でも出たのか（筋は通ります）
====================================================================

枠が大きいと、人（スタッフ・客）の道が早く引き上がります。
依頼 v11 の調べで分かったとおり、道が引けていない間は

    StaffAgent:189   着いたかの判定を打ち切る → 会計・片付けの始まりが遅れる
    GuestAgent:175   同じ
    PersonRobotYield:774  よけるかの判断を打ち切る

ので、枠が大きい＝人の反応が早い＝**待ちが短く出る**向きです。

  ・⑦（fam_p5）の通路は 0.95 m、③（fam_p1）は 1.2 m。
    狭いほど人の道の引き直しが増えるので、⑦に強く出るのは筋が通ります。
  ・ロボット0台でも出るのは、効くのが **人の道**だからです。ロボットは関係ありません。
  ・「卓を空けた 1〜4 → 0」も、片付けの始まりが早まれば減る向きです。

ただしこれは筋の話で、証拠は 4 章の確かめで取ります。


====================================================================
4. (3) 押す順番（Unity を一度閉じて開き直してから）
====================================================================

  0. Unity を **閉じて開き直す**（これで Unity の中の枠が既定 100 に戻ります）

  1. ServiceSim → Stage B → 切り分けの設定 → 道探しの枠を 100 にする（既定）
  2. ServiceSim → Stage B → 道探しの枠の確かめ（⑦0台・種0〜2）
  3. ServiceSim → Stage B → 切り分けの設定 → 道探しの枠を 5000 にする
  4. ServiceSim → Stage B → 道探しの枠の確かめ（⑦0台・種0〜2）
  5. ServiceSim → Stage B → 切り分けの設定 → 道探しの枠を 100 にする（既定）
     ← 終わったら必ず 100 に戻してください（次の回のため）

  出力は 6 フォルダ。名札が path_check なので18回とは混ざりません。
    20261026_path_check_cs8_fam_p5_r0_s0 / _s1 / _s2        （1〜2 が枠 100）
    20261026_path_check_cs8_fam_p5_r0_s0_2 / _s1_2 / _s2_2  （3〜4 が枠 5000）

  ★ どちらの枠で回したかは、各回の run_info.json の
    path_iterations_per_frame で必ず分かります（取り違えません）。
    Console の回し始めの行にも「道探しの枠: 100 回/コマ」と出ます。

  見るところ（私が並べます）
    入店待ち・席は空いているのに待った・卓を空けた・客が帰ってから卓が使えるまで
    → 100 と 5000 で差が出れば、18回（⑦）のずれの原因はこれと決まります。
      そのうえで、18回を枠 100 で回し直すかを決めましょう。
    → 差が出なければ、⑦のずれは別の原因です。cs:144 と cs:7 の間で
      ⑦に効く変更（v10 の逃がし弁の数え方・v10b・v11 の移設）を1つずつ見ます。

  1回 12 分前後 × 6 回で 70〜80 分の見込みです。


====================================================================
5. 気をつけること
====================================================================

  ・ExperimentPlan.asset には pathIterationsPerFrame の行がまだありません。
    Unity を開き直すと C# の初期値 100 で書き出され、保留に1件出ます。
    **その行（100）はそのまま check-in して構いません**（既定を .asset に残す形）。
    私がやってもよいので、開き直したあと声をかけてください。
  ・枠を 5000 にしたまま段階Bを回さないでください。
    「ぜんぶ切る」でも 100 に戻るようにしましたが、
    回し始めのログと run_info で必ず確かめられます。
  ・18回（cs:7）の判定結果は、枠がどちらだったか分からないまま残ります。
    基準データにするかは、4 章の結果を見てから決めるのが安全だと思います。

  check-in：**cs:8**（直したファイル4本）。保留 0。
  依頼 v13 の 依頼2（店の時計と倍率の画面）は、まだ手付かずです（次は cs:9）。
```

### 20261026_1202_v13_passby_check.txt

```text
====================================================================
依頼 v13 の 依頼1 の確かめ：すれ違い 入／切 を調べた（まだ直していません）
2026-10-26 12:02
====================================================================

比べた回（③ fam_p1・スタッフ4・ロボット2台・種1・ServiceSim/cs:9・枠 100）
  20261026_pass_on_cs9_fam_p1_r2_s1    すれ違い 入
  20261026_pass_off_cs9_fam_p1_r2_s1   すれ違い 切

片付け
  RobotSpec.asset は「切で1回回す」が最後だったので passByEnabled: 0 に戻っていました。
  中身が cs:9 と同じことを確かめて印だけ落としました。**保留 0・cs:9・check-in なし**です。

結論（先に）

  ・すれ違い自体は **狙いどおり働いています**。
    向かい合って止まった 28回/1252秒 → **16回/841秒**、
    「すれ違い中」だった秒 6084 → **2012 秒**。
  ・店が悪くなった原因は **2つ**で、どちらも「すれ違いの作り」ではなく
    **「すれ違いの失敗の後始末」と「人の居場所を見ていないこと」**です。
      ① 1.2 m の通路で2台が寄ると、**人の通る隙間が無くなる**（計算上 0.05 m）
      ② 体の間が 0.1 m より詰まると **2台とも止まって抜けられない**。
        しかもその位置は、昔の作りなら絶対に起きない「鼻先 0.06 m」で、
        今までの立て直し（譲る順番・回り込み）が効きません（545 秒の正体）
  ・直し方は 4 章に3つ出しました。**①+②の2つを直せば、入を夜の条件に入れて
    種10個で比べる価値があります。**


====================================================================
1. 入で悪くなった所と、その場所
====================================================================

数字（入 / 切）

  入店待ち            6.11 / 2.05 分       席は空いているのに待った  4.87 / 0.75 分
  客が帰ってから卓まで 13.64 / 5.02 分     ロボットが運んだ割合      0.80 / 0.81（同じ）
  立ち往生            40 / 26              卓を空けた                1 / 0
  スタッフのあきらめ   70 / 32             客のあきらめ              28 / 19
  止まりの回数        1440 / 2687          止まりの合計              4517 / 4077 秒
  うち「もう1台のロボット」1141 / 2381

  → 止まりの **回数は半分に減った**のに、**合計の秒は増えた**。
    短い止まりが消えて、長い行き詰まりに置き換わっています。

場所（人どうしの詰まり。people_jams.csv を場所で分けた）

  場所                                   入            切
  ----------------------------------------------------------------
  客席の下の横通路（z≦4.6）              546件 634秒   211件 249秒
  細い通路 A03（x8.6–9.8）               486件 511秒    29件  30秒   ← 16倍
  細い通路 A01（x1.2–2.4）               353件 371秒     0件   0秒   ← 0→353
  細い通路 A02                             6件   6秒    51件  54秒
  受け渡し口そのもの (15.3,5.95)           15件  16秒   107件 266秒  ← 入で激減
  合計                                  1483件1957秒  453件 737秒

  ・**細い通路の中の詰まりが 35件→845件**。入では通路が詰まり場所に変わっています。
  ・一方 **受け渡し口の詰まりは 107件→15件** に減っています
    （切では2台が受け渡し口で向かい合って固まる＝26/28件がここ。入はそれを解いた）。
  ・人がロボットに近づきすぎた回数：330 / 243。**うち細い通路の中は 45 / 7（6.4倍）**。
  ・細い通路の中でロボットが止まっていた秒：**425 / 70（6倍）**。

★ 原因①：1.2 m の通路で2台が寄ると、人の隙間が無くなる

  寄せ幅は RobotSpec.PassByShiftM = (直径 0.5 ＋ 間 0.1) ÷ 2 = **0.3 m**。
  ③の通路は 3本ともちょうど 1.2 m（A01・A02・A03）。

    通路の幅                        1.20 m
    2台の中心を ±0.3 m に置く        体は −0.55〜−0.05 と +0.05〜+0.55 m
    壁は ±0.60 m
    → 空くのは 2台の間の 0.10 m と、壁際の **0.05 m ずつ**

  人は半径 0.2 m（直径 0.4 m）なので、**どこにも入れません**。
  すれ違っている数秒のあいだ、通路は人に対して完全にふさがります。

  依頼文の条件「寄った位置で通れる幅が ロボットの幅×2 ＋ 間 に足りないとき」は
  1.1 m なので、1.2 m の通路は **条件を満たしてしまい**、今の決まりに渡りません。
  実物の動画は「台車の横」＝相手が止まっている物で、
  **人が同じ通路を歩いてくる場面ではなかった**のだと思います。

  コードでも裏が取れました。寄せ先の可否は

      CanShiftRight → NavMesh.SamplePosition(位置 + 右 × 0.3, 0.05, RobotFilter)
      PassByTarget  → 同じく RobotFilter で寄せ先を確かめ、乗らなければ 0.67倍・0.33倍に減らす

  と **ロボット用の NavMesh だけ**を見ています。ロボット用の面は半径 0.25 m で
  削ってあるので 1.2 m の通路でも ±0.35 m まで乗り、0.3 m の寄せは通ります。
  **人が通れるかは1か所も見ていません。**

★ 原因②：体の間が 0.1 m より詰まると、2台とも抜けられない

  passes.csv のいちばん長い行（入）

    545.1 秒  x=13.27 z=7.02  台1-2  いちばん近づいた間 **0.0642 m**  最遅 0 m/秒
              → 「向かい合って止まった」

  0.0642 m は **狙いの 0.1 m より詰まっています**。全停止を抑える条件が

      if (PassByMoving && blocked && reading.nearestIsRobot && … &&
          reading.nearestDistance > spec.passBySeparationM) blocked = false;

  なので、間が 0.1 m 以下になった瞬間に **両方とも blocked に戻って速さ 0**。
  1コマ 0.05 秒 × 0.3 m/秒 = 15 mm ずつ詰まるので、0.1 m をまたいで
  行き過ぎることがあります（0.064 m はその行き過ぎ）。
  そこから先、間を広げる動きが1つも無いので **両方が止まったまま**になります。

  逃げ道（detourAfterSec 3 秒で今の決まりに渡す）は入っていて、働いています。
  けれど渡された先の状態が **鼻先 0.064 m** です。
  昔の作りなら前のセンサー（0.6 m）で止まるので、立て直しには 0.6 m の余地がありました。
  0.064 m からでは、よける場所（2台の半径の和＋0.1 m 以上離れた所）も
  回り込む道も見つからず、545 秒かかっています。

  この行き詰まりの場所（x13.27 z7.02）は **スタッフが待つ列（x13.75・z6.8〜9.8）の真上**です。
  受け渡し口と客席を行き来するスタッフの通り道をふさぐので、
  スタッフのあきらめ（70件）と立ち往生（40件）に直接つながっています。


====================================================================
2. 「向かい合って止まった」は本当に向かい合っていたか
====================================================================

ご懸念のとおり、**切の方は「待っているだけ」を数えていました。**
ただし待機場所ではなく **受け渡し口そのもの** です。

  切：28件のうち **26件が受け渡し口 (15.3, 5.95) のそば**
      いちばん長い 559.0 秒は x=6.62 z=4.22（客席の下の横通路）で、
      いちばん近づいた間が **−0.0412 m ＝ 体が重なっている**
      2番目〜5番目はぜんぶ受け渡し口（x14.7〜15.6, z5.4〜5.8）で 168/164/82/51 秒

  入：16件のうち 受け渡し口のそばは 9件。
      いちばん長い 545 秒は x13.27 z7.02（スタッフ待機の列。1 章の②）

  ロボットの待機場所（x16.9/17.7/18.5, z6.05）のそばは **入 1件・切 0件**。
  → **待機場所で並んでいるだけを数えてはいません。** ご懸念はここでは外れです。

  問題は受け渡し口です。受け渡し口は **点が1つ (15.3, 5.95)** しかないので、
  2台が同じ点へ向かうと必ず向かい合い、両方止まります。
  これは「通路でふさぎ合っている」のとは別の現象で、
  **数え方としては混ぜない方がよい**と思います（依頼 v8b の A で
  「打ち切りで2台を同じ点に置くと重なる」を直したのと同じ場所です）。

  いまの数え方（依頼 v13 の定義そのまま）
    2台が 2.0 m 以内・向きの差 135度以上・2台とも 0.05 m/秒 以下が 1 秒以上

  受け渡し口で荷積みを待っている2台は、この3つをぜんぶ満たします。
  体が重なっている（間が負）行まで「向かい合って止まった」に入っているので、
  **いまの 28件/1252秒（切）は、通路のふさぎ合いの数としては多すぎます。**


====================================================================
3. まとめ（何が起きたか）
====================================================================

  すれ違いは、受け渡し口の固まり（107件→15件）と
  向かい合いの秒（1252→841）を **確かに減らしました**。

  けれど代わりに
    ・1.2 m の通路を人がまったく通れない時間を作り（通路の詰まり 35→845件）
    ・失敗したときに鼻先 0.06 m という、立て直せない位置に2台を残しました（545秒）
  この2つで、スタッフが動けず（あきらめ 32→70）、
  卓が回らず（回転 5.02→13.64分）、入店待ちが 2.05→6.11分 になりました。

  つまり **考え方は合っていて、2か所の作りが足りない**状態です。


====================================================================
4. 直し方の案（3つ。①②は必須、③は選び）
====================================================================

① 人の通る分を見てから寄る（原因①）

  いまは寄せ先をロボット用の NavMesh でしか見ていません。
  **人用の NavMesh（半径 0.2 m）で、寄ったあとも人が通れるか**を見ます。

    ・寄る前に、2台が寄った位置の間を人が通れるか（人用の面が残るか）を確かめる
      いちばん簡単なのは「地図の clear_mm（障害物までの実距離）」を使う形:
        要る幅 = ロボット直径×2 ＋ 間 ＋ **人の直径 0.4 m** = 0.5×2 ＋ 0.1 ＋ 0.4 = **1.5 m**
      ③の通路は 1.2 m なので **細い通路では すれ違いをしない**ことになります
      （＝今までの決まり：料理を載せた方が先・3秒で回り込む）
    ・広い所（受け渡し口の前の廊下など）では今までどおり すれ違う

  効き方：A01・A03 の詰まり（845件）がほぼ消え、
          受け渡し口の固まりを減らす効果（107→15件）は残る見込み。
  ただし ③の通路が3本とも 1.2 m なので、**すれ違いが働く場所は廊下だけ**になります。
  「それでも入れる価値があるか」は種10個の比べで決める形です。

  ★ 1.5 m の内訳（ロボット2台＋人1人）を RobotSpec の値にして出し入れできるようにします
    （passByNeedPersonM = 0.4 のような形。人の半径は PeopleSpec から取ってもよい）

② 詰まりすぎたら離れる（原因②）

  いま：間 ≤ 0.1 m で両方 blocked → 速さ 0 → そのまま
  直し：次の3つを足します

    (a) 行き過ぎないように、狙う速さを間に応じて落とす
        （残りの間 ÷ 1コマ より速く進まない。15 mm の行き過ぎを無くす）
    (b) それでも 0.1 m を割ったら、**0.1 m になるまで少し下がる**
        （2台とも同じだけ下がるので、どちらが譲るかを決めなくてよい）
    (c) 3 秒で今の決まりに渡すとき、**渡す前に 0.6 m（前のセンサーの届く距離）まで
        離れてから渡す**。昔の作りが立て直せる位置に戻してから渡す

  効き方：545 秒の行き詰まりが無くなる見込み。
          立ち往生 40 とスタッフのあきらめ 70 の多くはこれが元なので、
          ①と合わせれば入店待ちは切に近づくはずです。

③ 「向かい合って止まった」の数え方を分ける（2 章）

  受け渡し口で荷積みを待っている2台を、通路のふさぎ合いと混ぜないようにします。

    ・受け渡し口の rect（14.3,6.5〜16.3,6.9）とロボット待機場所の rect の中は、
      別の欄に数える（face_stop_at_pass / face_stop_in_aisle のように分ける）
    ・体が重なっている（間が負）行は、別に「重なった」として数える
      （v8b で直した「同じ点に2台」の再発を見張る欄になります）

  これは **記録だけ**の直しなので、動きは変わりません。
  ①②の効果を正しく読むために、先に入れておく方がよいと思います。

  ※ 依頼 v13 の「数の定義」を変えることになるので、入れるかはエイイチの判断です。


====================================================================
5. 夜の条件に入れるときの形（提案）
====================================================================

  ①②を入れた版を作り、夜の条件に

      すれ違い 入／切 × 種0〜9 × ③ロボット2台     ＝ 20 回

  を足す形がよいと思います（⑦は通路 0.95 m なので、①を入れると
  すれ違いがまったく働きません。③2台でだけ比べれば足ります）。

  見る数字
    pass_count / すれ違えた・幅が足りず・向かい合って止まった の内訳
    face_stop_count・face_stop_max_s（③で分けたあとの「通路のふさぎ合い」だけ）
    入店待ち・席は空いているのに待った・回転・立ち往生・スタッフのあきらめ
    細い通路の中の詰まり（people_jams を場所で分けた数。私の集計で出します）

  判定は「切より良くなったか」で、種10個の中央値で見ます。

  いまは何も直していません。①②③のどれを入れるか決めてください。
  決まれば cs:10 として入れ、そのあと 依頼2（店の時計と倍率の画面）に進みます。


====================================================================
6. ①②③ を入れた（cs:10）と、夜の70回（cs:11）／2026-10-26 12:20 追記
====================================================================

cs:10：すれ違いの直し①②③

  ① 人が横を通れる幅があるときだけ寄る
     RobotSpec に passByNeedPersonM = 0.4（人の直径）を足し、
       要る幅 PassByNeedWidthM = 体 0.5×2 ＋ 間 0.1 ＋ 人 0.4 = **1.5 m**
     判定は CanShiftRight（ロボット用の NavMesh）をやめ、**地図の clear_mm**
     （そのマスから障害物までの実距離）で通路の幅そのものを見る形にした
     （CanPassByHere）。足元と 0.5 m・1.0 m 先の3点を見る。
     地図が無いとき（SiteRoutes）は すれ違わない。
     → ③の通路 1.2 m・⑦の通路 0.95 m は **どちらも 1.5 m に足りないので
       すれ違わず、今までの決まり（料理を載せた方が先・3秒で回り込む）に渡る**。
       すれ違いが働くのは受け渡し口の前の廊下などの広い所だけになります。

  ② 行き過ぎない・詰まったら離れてから渡す
     (a) すれ違い中の速さの上限に「残りの間 ÷ 2 ÷ 1コマ」を足した
         （2台で近づくので間は2台ぶんの速さで縮む）。0.1 m を行き過ぎない
     (b) 間が passBySeparationM を割ったら、3 秒を待たずに渡しに入る
     (c) 渡す前に passByBackOffM = **0.6 m**（前のセンサーが届く距離）まで
         後ろへ下がる（passByBackSpeedMps 0.3 m/秒）。
         下がれない所では下がらずに渡す。
         FaceAndMove の頭で道を追うのだけを飛ばすので、
         記録の後始末（止まりの開き・閉じ）は今までどおり動く

  ③ 向かい合いを場所で分けて数えた（数の定義の変更。エイイチ了承）
     summary.json に足した欄
       face_stop_in_aisle_count / _max_s   ← **通路のふさぎ合い。直しの効果はここで見る**
       face_stop_at_pass_count / _max_s    受け渡し口のそば（荷積みの順番待ち）
       face_stop_at_standby_count / _max_s 待機場所のそば
       face_stop_overlap_count             そのうち体が重なっていた数
     face_stop_count / face_stop_max_s（ぜんぶ合わせた数）は今までどおり残す。
     場所は受け渡し口・待機場所の点から 1.2 m 以内で見分ける
     （ExperimentRunner が store.PassPoint と store.RobotStandbyPoints を渡す）。

  ★「切」の動きが cs:9 と変わらないことの確かめ（ご指定の分）

    行の増減（cs:9 → cs:10）
      RobotSpec.cs               +25  −0
      RobotPassBy.cs             +92  −2
      ServingRobotController.cs +128  −6
      ExperimentRunner.cs        +12  −0
      TripLogger.cs              +23  −0

    **既存の行を置き換えたのは 8 行だけ**で、中身は次のとおりです。
      ServingRobotController（6行）… ぜんぶ UpdatePassBy の中。
        ・3秒の判定に tooClose を足した行
        ・CanShiftRight(…) → CanPassByHere(…) の行
        ・CanShiftRight の宣言と中身（CanPassByHere に置き換えて消えた）
      RobotPassBy（2行）… コメント1行と、FaceStopMaxS を入れる行
        （置き換え後も同じように FaceStopMaxS を入れ、場所ごとの最長を足しただけ）

    新しく足した動きは、ぜんぶ入のときだけ通る枝の中にあります。
      速さの上限 → if (PassByMoving && _passByOther != null && dt > 0f)
      離れ       → if (_passByBackingOff) { BackOffStep(dt); return; }
    そして UpdatePassBy は入口で

      if (spec == null || !spec.passByEnabled || …) {
          _passByOther = null; _passByHasLane = false;
          _passByStallS = 0f;  _passByGaveUp = false;
          _passByBackingOff = false;      ← 2026-10-26 に足した
          return; }

    と戻るので、切のときは
      ・PassByMoving は false（_passByOther が null）
      ・_passByBackingOff は false
    になり、**新しい枝に1つも入りません**。
    _passByBackingOff を true にしうる代入は **1か所だけ**（UpdatePassBy の
    渡しの中）であることも数えて確かめました。

    ③ は記録だけです。PassByWatch は自分の記録（Live と数）にしか書かず、
    **ロボットには1つも書き込みません**（正規表現で当たって 0 件）。
    なので切の **動き** は cs:9 と同じで、**記録の欄が増えるだけ**です。

    確かめは 38 項目ぜんぶ OK、組み立ては 55 本・エラー 0。

cs:11：夜の70回を並べられるようにした

  make_queue.py
    --pass-by（ふだんの回。既定 off＝基準データ cs:7 と同じ動き）
    --pass-by-extra（その条件だけ、同じ種で入の回も足す）

      python3 tools/make_queue.py --out queue.json \
          --pass-by-extra fam_p1_s4_r2_a42
      → 条件 6 / 種 0〜9 / 合わせて **70 回**（切 60 回・入 10 回）
        入は ③2台（fam_p1_s4_r2_a42）の種0〜9 の10回だけ

    queue.json の各回に pass_by: on / off が入ります。

  night_runner.py
    ・回ごとに **-ssPassBy on|off** を実行ファイルに渡す
      （依頼 v12 の 2 章の引数に1つ足す形。実行ファイル側は v12 で受けます）
    ・入の回だけフォルダ名に **_passon** を付ける
        20261027_night_cs10_fam_p1_r2_s0          （切）
        20261027_night_cs10_fam_p1_r2_s0_passon   （入）
      切の回の名前は付ける前と同じなので、基準データと並べられます
      （付けないと同じ条件・同じ種で名前がぶつかります）


====================================================================
7. 既知として残すもの（今は直しません。エイイチの決定）
====================================================================

受け渡し口の点が1つしかないため、「切」でも2台が向かい合う

  ・受け渡し口は点が1つ（15.3, 5.95）しかなく、2台が同じ点へ向かうと
    必ず向かい合って両方止まります。
    2026-10-26 の確かめでは、切の向かい合い 28 件のうち **26 件がここ**でした。
  ・いちばん長い 559 秒の回（x6.62 z4.22）は、いちばん近づいた間が
    **−0.0412 m ＝ 体が重なっていました**。
    依頼 v8b の A で直した「打ち切りで2台を同じ点に置くと重なる」と同じ場所です。
  ・直す案（やっていません）：受け渡し口に並ぶ点を2つ以上作る、
    または荷積みを1台ずつにする（レジの payAtRegisterByOne と同じ形）。
  ・いまは ③ の分け方で **face_stop_at_pass_count** として別に数えるので、
    通路のふさぎ合い（face_stop_in_aisle_count）と混ざりません。
    夜の70回でこの数が大きいままなら、次の版で扱います。

次（このあと）
  ・依頼2（店の時計と倍率の画面。SimHud）
  ・そのあと v12 の Unity の中の部分（画面なしの実行ファイル・-ssPassBy の受け）
```

### 20261026_1241_v13_simhud.txt

```text
====================================================================
依頼 v13 の 依頼2：店の時計と倍率の画面（SimHud）＋1倍で回す
2026-10-26 12:41　check-in：ServiceSim/cs:12（保留 0）
====================================================================

結論

  ・画面の左上に「店の時計」と「**測った倍率**」を出す部品を作りました。
  ・1倍で回す切り替えも付けました。1コマ 0.05 秒は**そのまま**で、
    1コマの実時間が 0.05 秒になるまで**待つだけ**なので、中身の進み方は
    1つも変わりません。
  ・入切は2つとも SimHud の Inspector にあり、**初期値はどちらも切**です。
    測る回（段階B・夜の60回など）の数字には効きません。
  ・1倍で回した回は**見る専用**で、台帳の行も出力フォルダも作りません。
  ・画面なしの実行では描かず、待ちもしません（夜の実行を遅くしません）。
  ・check-in：**cs:12**（新しい 1 本・直した 3 本）。保留 0。
    組み立て 56 本・エラー 0。確かめ 31 件＋実際に動かした 11 件、ぜんぶ OK。


====================================================================
1. 作った・直したファイル
====================================================================

  ＋ Assets/ServiceSim/Store/SimHud.cs（新しい・240 行ほど）

      Inspector の項目（2つが本体）
        Show               … 画面に出す（初期値 **切**）
        Real Time Speed    … 1倍で回す（初期値 **切**）
      細かい項目（ふだんは触らなくてよい）
        Measure Window Sec … 倍率を測る窓。既定 2 秒
        Font Scale         … 字の大きさ。画面の高さに対する割合。既定 0.028
        Margin             … 左上からの余白。既定 (16, 16) px
        Ops                … 店の時計を読む先。Setup が入れる（空なら自分で探す）

  ・Assets/ServiceSim/Experiment/ExperimentRunner.cs
      回し始めに SimHud を見て「見る専用（_viewOnly）」を決め、
      WriteOutputs と det_frames の書き出しを止める

  ・Assets/ServiceSim/Editor/ServiceSimSetup.cs
      シーンに SimHud を置き、ops をつなぐ。
      Show と Real Time Speed は**触らない**（エイイチが Inspector で決める）

  ・Assets/ServiceSim/Editor/ServiceSimStageB.cs
      押す前の確かめに「1倍が入っていたら測る回は回せない」を1件足した

  台帳の列・run_info.json の項目・店の回し方・ロボットと人の動きは
  1つも変えていません（確かめ済み。4 章）。


====================================================================
2. 画面に出るもの
====================================================================

  左上に黒い半透明の帯で1行だけ出ます。

      0:12:34   23.8 倍

  店の時計 … 回し始めからの、**シミュレーションの中の**経過（時:分:秒）。
             ServiceOps.NowS をそのまま読みます。
             90 分の1回なら 0:00:00 → 1:30:00 まで進みます。
             実物の動画の経過時間と並べて見る用です。

  倍率     … **測った値**です。直近 2 秒ぶんの
             「中の時間の進み ÷ 実時間の進み」。
             決め打ちの数字ではないので、コマが重くなれば下がります。
             1倍で回しているときは「1.0 倍（実時間）」と出ます。

  ★ 実時間の測り方で1つ落とし穴がありました（直してあります）

    最初は Time.unscaledDeltaTime で実時間を測っていました。これは誤りです。
    Time.captureDeltaTime（= 0.05）を入れて回していると、Unity は
    deltaTime も unscaledDeltaTime も **その 0.05 に固定**するので、
    何倍で回していても倍率が常に 1.0 に見えてしまいます。
    実時間は Time.realtimeSinceStartup の差で測るように直しました。


====================================================================
3. 1倍で回す（中身の進み方は変えない）
====================================================================

  やっていること

      void LateUpdate()
      {
          if (!RealTimeNow) return;
          float step = Time.captureDeltaTime;          // 読むだけ（0.05）
          if (step <= 0f) return;
          float used = Time.realtimeSinceStartup - _frameRealStart;
          float wait = step - used;
          if (wait > 0f && wait < 1f)
              System.Threading.Thread.Sleep((int)(wait * 1000f));
          _frameRealStart = Time.realtimeSinceStartup;
      }

  ・captureDeltaTime は **読むだけ**で、書き換えません（1コマ 0.05 秒のまま）。
  ・timeScale も触りません。
  ・LateUpdate なので、そのコマの計算が**ぜんぶ終わったあと**に待ちます。
    待っている間は何も起きないので、「どの順で何が起きるか」は
    1倍でも 20 倍でも同じです。
  ・コマの計算が 0.05 秒より長くかかったときは待ちません（そのまま進む）。
    その回は 1 倍より遅くなり、画面の倍率が 1.0 を下回って見えます
    （嘘をつかないので、録画で気づけます）。
  ・待ちの細かさはミリ秒までです。1コマ 50 ミリ秒に対して十分で、
    中の時間には影響しません（待ちは中の時間に入らないので）。

  1倍で回した回は見る専用

      ExperimentRunner.WriteOutputs(dir, …)
          _outputsWritten = true;
          if (_viewOnly) { ログだけ出して return; }      ← ここで止まる

    なので台帳の行も、出力フォルダ（summary.json・trips.csv・timeline.csv など）も
    作りません。Runner の中でフォルダを作る所は2か所しかなく
    （WriteOutputs と det_frames）、両方で止めてあります。

  測る回は押す前に止まる

    段階B・分かれの確かめ・夜の回などで 1倍 が入ったままだと、
    記録が出ないまま時間を無駄にします。押す前の確かめに1件足しました。

      ・SimHud の「1倍で回す」が入っています。その回は見る専用で
        記録を書き出さないので、測る回は回せません。
        シーンの SimHud で Real Time Speed を切ってください


====================================================================
4. 測る回に影響しないことの確かめ
====================================================================

  決まりごと（コードで確かめた・4 本すべて）

    ① 初期値が切          show = false / realTimeSpeed = false
    ② Setup が触らない    Setup には hud.show / hud.realTimeSpeed が無い
                          （Inspector の値は Setup を押しても消えない）
    ③ 画面なしでは描かない・待たない
                          CanDraw = !Application.isBatchMode &&
                                    graphicsDeviceType != Null
                          OnGUI は CanDraw が偽なら即 return
                          RealTimeNow = realTimeSpeed && CanDraw
                          → 夜の実行（-batchmode -nographics）では
                            入れてあっても 1倍 にならない
    ④ 店に書き込まない    SimHud が代入するのは自分の覚えだけ。
                          店・ロボット・人のフィールドに1つも書かない
    ⑤ Unity の状態も触らない
                          触るのは Thread.Sleep 1 か所だけ。
                          captureDeltaTime・timeScale・物理の設定は触らない
    ⑥ 台帳と run_info は据え置き
                          ServiceSimLedger は SchemaVersion = 2 のまま、
                          TripLogger にも項目を足していない

  Unity 抜きの確かめ：**31 件ぜんぶ OK**
    時計の出し方・左上・倍率の式・実時間の測り方・初期値2つ・
    Setup が触らない・batchmode・graphicsDeviceType・OnGUI の門・
    RealTimeNow・LateUpdate の門・captureDeltaTime を書かない・
    timeScale 無し・1コマ固定でないときは待たない・店に書かない・
    Sleep は1か所・_viewOnly の立て方・WriteOutputs の門・
    det_frames の門・フォルダを作るのは2か所だけ・押す前の確かめ2件・
    Setup の作りと ops・ops が空でも探す・台帳の列・run_info の項目

  実際に動かした確かめ（mono で SimHud を呼んだ）：**11 件ぜんぶ OK**
    時計    0 → 0:00:00／59.9 → 0:00:59／60 → 0:01:00／
            3599 → 0:59:59／3600 → 1:00:00／9045 → 2:30:45／
            −5 → 0:00:00／5400 → 1:30:00（90 分の1回ぶん）
    倍率    1コマ sim 0.05 秒 / 実 0.05 秒  → 1.00 倍
            1コマ sim 0.05 秒 / 実 0.005 秒 → 10.00 倍
            1コマ sim 0.05 秒 / 実 0.002 秒 → 25.00 倍

  組み立て：56 本・エラー 0（Unity 抜き・同梱の csc）


====================================================================
5. 使い方（録画のとき）
====================================================================

  Unity を開き直すと、SimHud.cs の .meta が新しく1件 保留に出ます。
  それは私が次の区切りで check-in します（中身の無いファイルなので
  そのまま入れて構いません）。

  1. ServiceSim → Setup（シーンに SimHud が入ります。1回だけ）
  2. Hierarchy の ServiceSim → SimHud を選ぶ
  3. Inspector で
       Show            を **入**（画面に時計と倍率を出す）
       Real Time Speed を **入**（1倍で回す。実物の動画と並べるとき）
  4. Mac の画面録画（⌘⇧5）を始める
  5. ふだんの回し方（Site Run など）で回す
       → この回は**見る専用**です。台帳にも書かず、出力フォルダも作りません
       → 段階B・夜の回は、1倍が入っていると押す前の確かめで止まります
  6. 終わったら Real Time Speed と Show を **切**に戻す

  ★ 1倍の回は 90 分の中を 90 分かけて回します。
    短い所だけ見たいときは、ExperimentPlan の回す長さを短くするか、
    Real Time Speed を切って（倍率だけ見ながら）回してください。
    Show だけ入れて速く回しても、数字には影響しません（③〜⑥のとおり）。

  ★ 倍率は「いま出ている値」です。
    実物の動画と秒数を比べるときは、**店の時計**を見てください。
    倍率は「録画が何倍速で見えているか」の目安です。


====================================================================
6. 困る所・残したこと
====================================================================

  ・1倍の待ちは Thread.Sleep です。macOS の眠りの精度は数ミリ秒ずれるので、
    1コマが 0.050 秒ぴったりにはなりません（0.050〜0.052 秒あたり）。
    90 分の回で実時間が数十秒ぶん長くなる向きですが、
    **中の時間は 0.05 秒きざみのまま**なので、記録の数字は一切変わりません。
    画面の倍率も測った値なので、そのずれがそのまま見えます。
  ・Show を入れたまま測る回を回しても数字は変わりませんが、
    OnGUI の描画ぶんだけ実時間は遅くなります（倍率が下がる）。
    測る回は切のままにしておくのが無駄がありません。
  ・字は Unity の既定のフォントです。日本語（「倍」）が出ます。
    録画で小さければ Font Scale を上げてください。

  既知として残しているもの（前の報告のまま、直していません）
    ・受け渡し口の点が1つしかないため、「切」でも2台が向かい合う件
      （559 秒・体が重なる）
    ・来客の締め切りのずれ 9.3 秒
    ・記録の中に時計が2つある件


====================================================================
7. 次（v12 の Unity の中の部分）
====================================================================

  依頼どおり、このあと v12 の 1〜3 章（画面なしの実行ファイル）に進みます。

    1 章 Editor/BuildHeadless.cs（メニューとコマンドの両方から作れる）
         出力 Builds/headless/ ＋ build_info.json（VCS に入れない）
    2 章 実行ファイルの引数（-ssCondition / -ssSeed / -ssLabel / -ssNight /
         -ssParallel / -ssPassBy / -ssTry）、終了コード 0・2・1、
         書く前に KIOXIA を確かめる、run_info.json に runner・build_id・
         night・parallel・seed を足す
    3 章 台帳の鍵（C# 側）。今の追記のガードは残す

  1つ決めておきたいこと（勝手に変えません）

    出力フォルダの名前の中の cs の書き方です。
    依頼 v12 の 2 章の例は `20261026_night_ss12_fam_p1_r1_s3`（ss12）ですが、
    いま動いている段階Bと夜の実行係は `cs12` で揃えています
      （例 20261026_night_cs12_fam_p1_r1_s3）。
    判定（judge_night.py）の glob も `cs` の形を見ています。
    **このまま `cs` で揃えて進めます**。`ss` にしたいときは言ってください
    （tools/ の3本と判定も合わせて直します）。
```

### 20261026_1300_v12_headless_build.txt

```text
====================================================================
依頼 v12 の 1〜3 章：画面なしの実行ファイル・1回の回し方・台帳の鍵
2026-10-26 13:00　check-in：ServiceSim/cs:13・cs:14（保留 0）
====================================================================

結論

  ・1〜3 章を作りました。組み立ては通っています（59 本・エラー 0）。
  ・Unity 抜きで確かめられる所はぜんぶ確かめました（下の 5 章）。
    いちばん怖い2つ（**台帳の鍵**と**フォルダ名の食い違い**）は、
    実際に動かして確かめてあります。
      鍵 … 8本同時に追記して 8 行・59 列・ヘッダ無傷。
            C# と Python が同じ鍵を待ち合えることも両方向で確認
      名前 … C# と night_runner.py が作る名前を 8 通り突き合わせて1字も違わない
  ・**まだ実行ファイルは作っていません**。いま Unity が ServiceSim を開いているので、
    私からはビルドを打てません（プロジェクトが押さえられています）。
    5 章 A〜C は、エイイチが 6 章の順で押してからになります。
  ・check-in は2つに分かれました（cm が足した分と直した分を分けたため）。
    **cs:13**（新しい3本と台帳の移動）と **cs:14**（直した5本）。中身は一組です。


====================================================================
1. 作った・直したファイル
====================================================================

  新しく作った（3本）

    Assets/ServiceSim/Editor/BuildHeadless.cs       1 章（実行ファイルを作る）
    Assets/ServiceSim/Experiment/HeadlessRun.cs     2 章（1回を回して終わる）
    Assets/ServiceSim/Experiment/LedgerRow.cs       台帳の行を作る所（下の 4 章）

  場所を移した（1本・中身は変えていない）

    Assets/ServiceSim/Editor/ServiceSimLedger.cs
      → Assets/ServiceSim/Experiment/ServiceSimLedger.cs

  直した（5本）

    Experiment/ServiceSimLedger.cs   鍵を付けた（3 章）。名前空間を ServiceSim に
    Experiment/ExperimentRunner.cs   run_info の4項目・上限の既定・画面のものを切る
    Experiment/TripLogger.cs         run_info.json に runner・build_id・night・parallel
    Editor/ServiceSimStageB.cs       行は LedgerRow に任せる。ReadChangeset を公開
    Editor/ServiceSimSetup.cs        シーンに HeadlessRun を置く。店の形の場所の const

  店の回し方・ロボットと人の動き・台帳の列（59・版 2）は変えていません。


====================================================================
2. 1 章：画面なしの実行ファイルを作る
====================================================================

  ２通りの作り方（どちらも同じ関数を通ります）

    メニュー  ServiceSim → 画面なしの実行ファイルを作る
    コマンド  /Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity \
                -batchmode -quit -nographics \
                -projectPath /Users/shimazueiichi/Robotics_UX/ServiceSim \
                -executeMethod ServiceSim.EditorTools.BuildHeadless.Build \
                -logFile /dev/stdout

  決めたこと

    ・**普通の macOS の player** にしました（Dedicated Server ではない）。
      理由：-batchmode -nographics で画面を作らずに回せるので足りる。
            Dedicated Server は別のモジュールを入れる手間が増える。
            いまは「画面なしでも同じ数字が出るか」を確かめる段（5 章 B）なので、
            段階Bと同じ player のまま回すほうが、違いの元が増えません。
    ・**Apple Silicon（ARM64）** 向けにします。Rosetta で動くと遅くなり、
      何本同時に回せるか（5 章 C）の実測が変わってしまうためです。
      この設定は PlaybackEngines の DLL の中にあり、Unity 抜きの組み立てでは
      参照できないので反射で入れています。入らなければ既定のまま作り、
      build_info.json の architecture にそう書きます（黙って変えない）。
    ・シーンは **コードの中で直接指定**します（opt.scenes に
      Assets/ServiceSim/Scenes/ServiceSim_Family.unity の1本だけ）。
      ビルド設定（EditorBuildSettings）は読みも書きもしません。
    ・前の ServiceSim.app は消して作り直します
      （古い実行ファイルで夜を回してしまわないため）。

  出力

    Builds/headless/ServiceSim.app
    Builds/headless/build_info.json

    Builds は ignore.conf に前から入っているので、VCS には入りません。

  build_info.json（14 項目。区切りの形も確かめ済み）

    schema / build_id / unity_cs / pending_changes / built_at /
    editor_version / target / architecture / scene / app / exe /
    ledger_schema_version / total_size_bytes / build_seconds

    build_id は 版＋作った時刻（例 cs14_20261026_1310）。
    unity_cs と build_id は、夜の実行係（night_runner.py の read_unity_cs）と
    実行ファイルの中（HeadlessRun）の両方が読みます。

  ★ 保留があるまま作ると、警告を出して build_info.json の pending_changes に残します
    （止めはしません）。夜に使う実行ファイルは、**保留 0 で作り直してください**。
    保留があるまま作ると、台帳の unity_cs が「cs:14」なのに中身が違う、
    という取り違えが起きます。

  版の取り方は段階Bと同じ ServiceSimStageB.ReadChangeset です。
  2通りに分けると、台帳の unity_cs と build_info.json の unity_cs が
  食い違うので、入り口を1つにしました。


====================================================================
3. 2 章：実行ファイルの1回の回し方
====================================================================

  引数（night_runner.py が渡す形。2 章のとおり）

    -ssCondition fam_p1_s4_r1_a42   条件（必須）
    -ssSeed 3                        種（必須）
    -ssLabel night                   名札（既定 night）
    -ssNight 20261026                その夜の名前。フォルダ名の先頭にもなる
    -ssParallel 3                    同時に走っていた本数（記録だけ）
    -ssPassBy on|off                 すれ違い（既定 off。依頼 v13）
    -ssTry 2                          やり直しの回。2 以上で _try2

  ★ **-ssCondition が無ければ、この部品は何もしません。**
    エディタで手で Play したときも、段階Bの Run All のときも必ずこちらなので、
    いまの回し方には1つも効きません（cs:12 と同じ動き）。

  回し方は段階Bと同じ道

    ExperimentRunner.Job を1つ作って SetSingleJob する、という
    段階Bの StartNext とまったく同じ形にしました。
      siteRun = true / conditionIndex / storeKey / robots / seed /
      aisleWidthIndex / crowd = Normal / applyDeliveryOffset = false
      timeMode = FixedStep / captureDeltaTimeOverride = 0（1コマ 0.05 秒のまま）
      maxWallSecondsPerRun = 40 分（段階Bと同じ値。置き場所を1つにしました）

  出力フォルダ

    {夜}_{名札}_cs{N}_{店}_r{台数}_s{種}[_passon][_try{n}]
    例 20261026_night_cs14_fam_p1_r1_s3

    ★ 名前を決めるのは **実行ファイル側**です。実行係は同じ式で先に名前を作って
      「もう回した回」を飛ばすので、1字でも違うと同じ回を2度回したり、
      回したのに飛ばしたりします。
      なので C# と Python の両方に同じ式を書いたうえで、
      **8 通りを突き合わせて1字も違わないことを確かめました**（5 章）。
      読めない版は両方とも csunknown になります（ここも一致）。

  KIOXIA（書く前に確かめる）

    回し始める前に、ボリュームと置き場所の両方を確かめます。
    どちらか無ければ **何も書かずに終了コード 1** で終わります（Mac の中に書かない）。
    出力フォルダがもうあるときも、上書きせずに 1 で終わります
    （やり直しは -ssTry 2 で別の名前にしてください）。
    台帳のヘッダがコードの列と違うときも、**回す前に** 1 で止めます
    （12 分回してから書けないと分かるのは無駄なので）。

  終わり方

    0 … 最後まで回った（status completed）
    2 … 途中で止まった（status stopped。実時間 40 分の上限など。記録は残る）
    1 … 失敗（引数が読めない・条件が無い・KIOXIA が無い・記録が出ない）

  画面のもの（2 章）

    ・キッチンの屋根のモニター … 画面なしでは出しません。
      そもそも吹き出しを描くのは Editor だけの ServiceSimLabels なので、
      player では文を作っても誰も見ません。
      **文を作るか作らないかは、店の回し方・数字には1つも効きません**
      （KitchenMonitor は文字列を作るだけで、店に書き込みません）。
    ・カメラ … 画面なしでは切ります（-nographics では元から何も描かれませんが、
      はっきり切っておきます）。
    ・録画・字幕・スクリーンショット … ServiceSim にはそういうコードがありません
      （grep で確認。ScreenCapture も Recorder も1件も無い）。
    ・SimHud（依頼 v13 の 依頼2）… 画面なしでは描かず、1倍にもなりません。

  run_info.json に足したもの

    runner      "editor"（段階B・Site Run）/ "headless"（画面なしの実行ファイル）
    build_id    実行ファイルの版
    night       その夜の名前
    parallel    同時に走っていた本数
    （seed は前からあります）

    エディタの回は runner が "editor" になるだけで、前と同じ値です。


====================================================================
4. 3 章：台帳に同時に書かない（鍵）＋ 行を作る所を1つに
====================================================================

  困った所と、どう解いたか

    player（実行ファイル）には **Editor/ の中のコードが入りません**。
    ところが台帳に書くコード（ServiceSimLedger）と行を作るコード
    （段階Bの WriteLedgerRow）は、両方 Editor/ の中にありました。
    そのままでは、夜の回は「別のコード」で台帳に書くしかなく、
    同じ回でも書く値が違う、という最悪の壊れ方をします。

    なので2つ動かしました。

      ① ServiceSimLedger.cs を Editor/ → Experiment/ に移した
         このクラスは UnityEditor の API を **1つも使っていなかった**ので、
         移すだけで player からも呼べます。中身（列・追記ガード・
         2度書かない決まり）は移すときに1字も変えていません。
         名前空間も ServiceSim にしました。呼んでいたのは ServiceSimStageB
         だけで、そちらは ServiceSim.EditorTools（= ServiceSim の内側）に
         あるので、**呼ぶ側の字は1つも変わりません**。

      ② 段階Bの WriteLedgerRow を Experiment/LedgerRow.cs に移した
         Editor でしか取れない値（.asset・EditorPrefs）は、呼ぶ側が
         LedgerRow.Inputs に詰めて渡す形にしました。
           Editor 側 … ServiceSimStageB が AssetDatabase と EditorPrefs から詰める
           player 側 … HeadlessRun がシーンの参照と build_info.json から詰める

         ★ row[...] の中身は cs:12 と **1行も変えていません**。
           差分を取って確かめました。変わったのは次の5か所だけです。
             署名（Inputs を受ける）／sp・cond・seed・folder・runDir・wallS の出どころ／
             RobotSpec・PeopleSpec の出どころ／unity_cs の出どころ／台帳の場所
           59 列ぜんぶの row[...] = … は字も並びもそのままです。

  鍵（依頼 v12 の 3 章）

    鍵ファイル   {台帳と同じフォルダ}/unity_ledger_v1.lock
    取り方       FileMode.CreateNew で作れたら持ち主（Python の O_EXCL と同じ）
    中身         "pid ホスト名 unix秒" の1行（Python 側と同じ形）
    待ち         0.25 秒ごとに試し、上限 60 秒。取れなければ書かずにあきらめる
    古い鍵       120 秒より古ければ、持ち主が落ちたと見て1度だけ壊す
                 （tools/ledger.py の LOCK_STALE_SEC と同じ値）
    返し方       finally で必ず閉じて消す

    ★ 大事な所：**ヘッダの確かめと「もうあるか」の確かめも、鍵の中でやります。**
      鍵の外でやると、2本が同時に「まだ無い」と見てから両方書いてしまいます。
      書く字（59 列）は鍵を取る前に作って、鍵を持っている時間を短くしています。

    ★ 書いたあと fs.Flush(true) でディスクまで落とします。
      夜に Mac が落ちても、書いた行が消えないようにするためです。

    今までのガードはそのままです。
      ・実体のヘッダとコードの列が1字でも違えば追記しない
      ・同じ run_folder の行は2度書かない
      ・追記だけ。前の行は読むだけ
      ・unity_cs は "ServiceSim/cs:N" の形

    鍵のコードはエンジンの要る API を使いません（SystemInfo を使うのをやめて
    Environment.MachineName にしました）。player でも Editor でも同じに動きます。


====================================================================
5. 確かめた結果（Unity 抜きでできる分。ぜんぶ OK）
====================================================================

  組み立て　59 本・エラー 0（同梱の csc。Editor の中も含む）

  ① 台帳の鍵を実際に動かした（12 件）

      同時に 8 本足して 8 本とも書けた          8 / 8
      行数はヘッダ＋8 行                        9 行
      ヘッダが壊れていない                      一致
      どの行も 59 列（行が混ざっていない）      OK
      8 本ぶんの run_folder が重複なく入った    8 種
      書き終わったら鍵ファイルは消えている      OK
      同じ run_folder は2度書かない             断った
      断られたあとも 8 行のまま／鍵も残らない   OK
      ヘッダが1字違えば足さない（ガード生存）   断った
      そのときも鍵は残らない                    OK
      run_folder が空なら足さない               断った

  ② C# と Python が同じ鍵を待ち合えるか（両方向）

      C# が 3 秒持っている間 → Python から鍵が見える／2.5 秒待って取れた
      Python が 3 秒持っている間 → C# が 2.1 秒待って取れた
      どちらも、返したあと鍵ファイルは消えている

      ★ これは夜にいちばん効く確かめです。実行ファイル（C#）が completed の行を
        書き、実行係（Python）が stopped / failed の行を書くので、
        同じ鍵を取り合えないと行が混ざります。

  ③ 出力フォルダの名前が実行係と一致するか（8 通り・1字も違わない）

      20261026_night_cs12_fam_p1_r1_s3
      20261026_night_cs12_fam_p1_r2_s0_passon
      20261026_night_cs12_fam_p5_r0_s9_try2
      20261026_night_cs12_fam_p5_r2_s7_passon_try3
      20261101_stageB_cs7_fam_p1_r0_s0
      20261026_night_csunknown_fam_p1_r1_s1     ← 版が読めないときも一致
      20261026_night_cs144_fam_p5_r2_s5
      20261026_night_cs3_fam_p1_r1_s2_passon

  ④ 引数の読み方（10 件）

      night_runner.py が実際に組む引数列をそのまま読ませて、
      7 つの引数を正しく取れること、無い引数は null になること、
      Unity 自身の引数（-batchmode など）を値と取り違えないこと、
      その引数列から作った名前が実行係の名前と同じになることを確かめました。

  ⑤ 筋の確かめ（45 件・コードを読んで）

      1 章 … メニューとコマンドの両方／シーンを直接指定／ビルド設定を書き換えない／
             出力の場所／StandaloneOSX／build_info の項目／版の取り方は1つ／
             保留の警告と記録／失敗したら終了コード 1／前の版を消す／ignore.conf
      2 章 … 引数 7 つ／-ssCondition が無ければ何もしない／段階Bと同じ Job ／
             1コマ 0.05 秒のまま／上限の値は1か所／KIOXIA を書く前に確かめる／
             無ければ何も書かない／フォルダがあれば上書きしない／
             台帳が書けない形なら回す前に止める／終了コード 0・2・1 の割り当て／
             Application.Quit で終了コードを付ける／run_info の4項目／
             runner の既定は editor ／画面なしでは屋根のモニターとカメラを切る／
             すれ違いの入切を引数で入れる
      3 章 … 鍵の名前が Python と同じ／FileMode.CreateNew ／120 秒で壊す／
             確かめは鍵の中／finally で必ず返す／追記ガード生存／
             ディスクまで落とす／エンジンの要る API を使わない
      ほか … 行を作るのは LedgerRow 1つだけ／段階Bも画面なしも同じ所を呼ぶ／
             台帳の列は 59・版 2 のまま／unity_cs は ServiceSim/ の形／
             Setup が HeadlessRun を置く／店の形の場所は1か所／
             build_info.json の区切り（最後だけカンマ無し・14 項目）

  ⑥ まだ確かめていない（Unity が要る。5 章 A〜C）

      ・実行ファイルが実際に作れるか（BuildPipeline が通るか）
      ・画面なしで1回が最後まで回り、段階Bと同じファイルが全部出るか
      ・2本同時で台帳に2行がきちんと入るか
      ・画面なしでも同じ数字が出るか（5 章 B）
      ・何本同時に回せるか（5 章 C）


====================================================================
6. 押す順番（エイイチへ）
====================================================================

  ★ 先に1回だけ必要なこと

    cs:13・cs:14 で、シーンに置く部品が2つ増えました（SimHud と HeadlessRun）。
    シーンに入れるのは Setup なので、**1回だけ Setup を押してください**。

    1. Unity に切り替える（スクリプトが読み込み直され、新しい .cs の .meta が
       3〜4 件 保留に出ます。これは私が次に check-in します）
    2. ServiceSim → Setup（アセット・prefab・シーンを作る）
       → Hierarchy の ServiceSim の下に SimHud と HeadlessRun が増えます
       → シーンが保留に出ます（これも私が check-in します）
    3. 声をかけてください。.meta とシーンを check-in して保留 0 にします

  ★ そのあと（5 章 A の前半。ビルド）

    4. ServiceSim → 画面なしの実行ファイルを作る
       → Builds/headless/ServiceSim.app と build_info.json が出ます
       → Console に「版: ServiceSim/cs:15（保留 0）」と出ることを確かめてください
         （保留があると警告が出ます。そのときは3に戻ってください）
    5. 声をかけてください。build_info.json を読んで、
       そのあと私が短い1回と2本同時を走らせます（5 章 A の後半）。

  ★ 1つ決めてほしいこと（5 章 A の後半の前に）

    5 章 A の「2本同時に短い回を走らせて、台帳に2行がきちんと入る」を
    実際にやると、**本物の台帳に確かめ用の行が2〜3行 残ります**。
    台帳は追記だけで消さない決まりなので、あとで取り消せません。

    ・名札を acheck にすれば、夜の回や段階Bとは混ざりません
      （フォルダ名が 20261026_acheck_cs15_… になり、判定の対象にも入りません）
    ・それでも台帳の行としては残ります

    どちらにしますか。
      (ア) 本物の台帳に acheck の行を残して確かめる（依頼どおり）
      (イ) KIOXIA の別の場所に台帳のコピーを置いて、そこで確かめる
           （ExperimentPlan の置き場所を一時的に変える必要があるので、
             確かめたあと必ず元に戻します）
    私は (ア) でよいと思います。名札で分けられるうえ、
    **本物の台帳に本当に書けるか**を確かめるのが 5 章 A の目的だからです。
    ただ、台帳はエイイチが「消さない・書き換えない」と決めた所なので、
    勝手には進めません。


====================================================================
7. 困る所・気をつけること
====================================================================

  ① 出力フォルダの cs の字は **cs{N}** で進めました

    依頼 v12 の 2 章の例は `20261026_night_ss12_fam_p1_r1_s3`（ss12）ですが、
    いま動いている段階Bと夜の実行係・判定はすべて `cs{N}` で揃っています。
    `ss` にすると、判定の glob と基準データの名前も直す必要があります。
    **cs のまま**で進めました。`ss` にしたいときは言ってください
    （night_runner.py の --cs-token でその場でも変えられます）。

  ② player で -batchmode / -nographics がどう効くかは、実物で確かめます

    Application.isBatchMode と graphicsDeviceType を見て
    「画面のものを切る」「1倍にしない」を決めています。
    macOS の player でこの2つがきちんと立つかは、5 章 A の1回で確かめます。
    ★ 立たなかった場合でも壊れません。屋根のモニターの文が作られるだけで
      （数字には効かない）、SimHud は表示の初期値が切なので何も出ません。
      結果が変わる所はありません。

  ③ 実行ファイルは「作ったときのシーンの中身」で固まります

    シーンに置いた部品（HeadlessRun の参照・店の形の2つ）は、
    ビルドしたときの状態で焼き付きます。
    .asset（RobotSpec・ExperimentPlan など）も同じです。
    **.asset を直したら、実行ファイルを作り直してください。**
    作り直さないと、台帳の unity_cs は新しいのに中身が古い、という
    取り違えが起きます（build_info.json の built_at で気づけます）。

  ④ 1回の実時間の上限は 40 分（段階Bと同じ）

    実行係側の上限は 90 分（night_runner.py の --limit-min）です。
    中（40 分）が先に効いて status = stopped・終了コード 2 になります。
    実行係の 90 分は「中の上限も効かずに固まった」ときの最後の備えです。
    5 章 C の実測で 1回 12 分前後なら、このままでよいと思います。

  ⑤ check-in が2つに分かれました

    cs:13 … 新しい3本（BuildHeadless・HeadlessRun・LedgerRow）と台帳の移動
    cs:14 … 直した5本
    cm が「足した分」と「直した分」を分けたためで、中身は一組です。
    夜に使う実行ファイルは cs:14 以降（Setup のシーンを入れたら cs:15）で
    作ってください。

  ⑥ 残っている既知のもの（前の報告のまま）

    ・受け渡し口の点が1つしかないため、すれ違い「切」でも2台が向かい合う件
      （559 秒・体が重なる）
    ・来客の締め切りのずれ 9.3 秒／記録の中に時計が2つある件
    ・plan_ms_max が実時間であること
    ・判定は出力フォルダがある回だけ見る／基準が空だと全部に印が付く
    ・_try2 と元の回の両方が判定に入る


====================================================================
8. 次（順に）
====================================================================

  1. エイイチ：6 章の 1〜3（Setup を押す）→ 私が .meta とシーンを check-in
  2. エイイチ：6 章の 4（実行ファイルを作る）
  3. 私：5 章 A の後半（短い1回・2本同時）。6 章の「決めてほしいこと」の返事待ち
  4. エイイチ：5 章 B（③1台・種1 を画面なしで1回）→ 私が cs:7 の同じ回と並べる
  5. 私とエイイチ：5 章 C（1本・2本・3本の実測）→ N を決める
  6. 私：5 章 D の codex レビューの依頼文を作る（台帳に書く所。
     3 章の鍵・LedgerRow・night_runner の stopped/failed の行）
  7. 最初の夜 70 回（台帳を archive/ に写してから）

  check-in：**cs:13・cs:14**。保留 0。
```

### 20261026_1420_v12_check_abc.txt

```text
====================================================================
依頼 v12 の 5 章 A・B・C：画面なしの実行ファイルの確かめ
2026-10-26 14:20　check-in：ServiceSim/cs:15・cs:16・cs:17（保留 0）
====================================================================

結論

  ・**A（台帳）OK**。2本同時で台帳にちょうど2行入り、行は混ざらず、
    前の86行は 1 byte も変わりませんでした。鍵も残りません。
  ・**B（画面なしでも同じか）合っていると見ます**。20 項目のうち **17 が
    cs:7 の帯の中**。外れた3つも帯の縁より「良い側」で、わずかです。
  ・**C（何本同時か）→ N = 4 を勧めます**（49.5 回/時・70回に 85 分）。
    CPU は 8 コアのうち 4.6 コアぶん、メモリは 400 MB（16 GB のうち）。
  ・**画面なしは 3.4 倍速い**（1本 151 秒 対 エディタ 488〜612 秒）。
    70回が1本ずつでも3時間で終わります。
  ・確かめで台帳に **16 行**足しました（名札 acheck・bcheck・c1〜c4check）。
    夜の回（{夜}_night_*）とは名札で分かれるので、判定には入りません。
  ・★ 気になる並びを1つ見つけました（6 章）。**Mac が空いているほど
    「詰まり」が減る**ように見えます。断定はできませんが、
    夜の進め方に関わるので書きます。


====================================================================
0. 先にやったこと
====================================================================

  台帳の退避（依頼 5 章 D）

    /Volumes/KIOXIA/r2r2r-data/service_sim/unity/
      archive/unity_ledger_v1_preacheck_20261026/unity_ledger_v1.csv

    87 行（ヘッダ込み）・28,010 byte
    md5  4d8486b9f75e3bef8f130bad4ba3e6b5（元と退避で一致）
    cmp  1 byte も違わない
    ※ コピーのみ。元の台帳は動かしていません。

  判定の差し替え（tools/judge_night.py ← iCloud の judge_night_v1.py）

    cmp で一致（md5 daf8987a596cb2e984d90e0ac3177e37）。中身は触っていません。
    v0 から変わった所:
      ・基準データの既定を 20261025_stageB_cs7_* に（cs:144 の18回は使わない）
      ・条件のキーに pass_by（すれ違いの入切）を入れた
      ・出力フォルダが無い回（failed / stopped）も台帳の行から拾う
      ・やり直し（_try2）は1つにまとめる（最後まで回った方を使う）
    → 前の報告で「既知」として残していた4件のうち3件が、これで閉じました。
    check-in：cs:16

  Setup の保留（cs:15）

    シーンは **111 行足しただけ・消した行 0**。
      SimHud       show: 0 / realTimeSpeed: 0（初期値は切。測る回に効かない）
      HeadlessRun  参照8つ全部つながっている。layouts は
                   StoreLayout_p1_efficient_corner と StoreLayout_p5_crammed
    RobotSpec は cs:10 の3項目が既定値で書き出されただけ（3行）。
      passByNeedPersonM: 0.4 / passByBackOffM: 0.6 / passByBackSpeedMps: 0.3
      **passByEnabled: 0 はそのまま**（すれ違いは「切」）。
    ほか8本（ExperimentPlan・PeopleSpec・ServiceSpec・Mat 3本・StoreLayout 2本）は
    Setup が書き直しただけで、cs:14 と **1字も違いません**（差分なしを確認）。

  ★ 実行ファイルの不具合を2つ見つけて直しました（cs:17）

    実物を1回動かしてすぐ出ました。Unity 抜きの確かめでは出ない種類です。

    ① **player では SimParams.Load を誰も呼んでいなかった**
       条件（unity_conditions）が空のままなので、正しい条件を渡しても
       「知らない条件です」で終了コード 1 になっていました。
       読んだ中身は .asset に残らない作りなので、起動のたびに読む必要があります。
       段階Bの LoadParams と同じ sp.Load("") を HeadlessRun で呼ぶようにしました。
       そのあと ExperimentRunner が店の形の版で読み直すので、
       終わったあとは条件を名前で引き直しています（値は同じ JSON から来ます）。

    ② 合わない値で回さずに抜けたとき（AbortRun）、player では
       180 秒待ってからしか失敗になりませんでした。
       ExperimentRunner に Aborted を足し（AbortRun で立てるだけ。
       エディタの動きは変えていない）、すぐ終了コード 1 にします。

  使った実行ファイル

    build_id        cs17_20261026_1347
    unity_cs        ServiceSim/cs:17
    pending_changes （空）＝保留 0 で作られている
    architecture    ARM64（Apple Silicon）… 反射で入る所もきちんと効きました
    editor_version  2022.3.62f3
    total_size      60.1 MB / 作るのに 2.1 秒


====================================================================
1. A：台帳に2行がきちんと入るか（③1台・種1 と 種2 を 2本同時）
====================================================================

  押したもの（1本 = 1回。実行係なしで直に起動）

    ServiceSim -batchmode -nographics -logFile … \
      -ssCondition fam_p1_s4_r1_a42 -ssSeed 1 -ssLabel acheck \
      -ssNight 20261026 -ssParallel 2        （種2 も同時に）

  結果

    終了コード            2本とも **0**
    出力フォルダ          20261026_acheck_cs17_fam_p1_r1_s1
                          20261026_acheck_cs17_fam_p1_r1_s2
    台帳の行              87 → **89 行**（ちょうど2行）
    status                2行とも completed
    unity_cs              2行とも ServiceSim/cs:17
    列の数                2行とも **59 列**（行が混ざっていない）
    ヘッダ                退避版と **同一**
    前の86行              **1 byte も変わっていない**
    鍵ファイル            残っていない

  ★ 依頼の「店の中の時間30分で止める短い1回」は、ExperimentPlan を
    一時的に書き換えることになるので、**丸ごと1回**で代わりにしました。
    そのほうが B の比べる相手にもなるので、無駄がありません。
    丸ごと回しても、2本同時で 151 秒・177 秒しかかかりませんでした。

  最後まで回ったことの確かめ

    everyone_left   2本とも 1（全員帰った）
    total_minutes   183.3 分（種1）/ 208.7 分（種2）
                    ← エディタ cs:7 の 182.2 / 206.7 分とほぼ同じ
    つまり「速く終わった」のは中身を省いたからではなく、
    画面を作らない・Editor の重みが無いぶん速いだけです。

  ★ 16回ぜんぶ足し終わったあとの台帳（最終確認）

    行数              87 → **103**（足した 16 行）
    どの行も          **59 列**
    status            completed × 16
    unity_cs          ServiceSim/cs:17 × 16
    run_folder        重複なし
    退避版の86行      **そのまま（1 byte も違わない）**
    鍵                残っていない
    名札ごと          acheck 2 / bcheck 1 / c1check 3 / c2check 3 /
                      c3check 3 / c4check 4

    4本同時のときの行の並びは s0 → s3 → s1 → s2 で、**終わった順**です。
    鍵が順番に渡っている証拠で、狙いどおりです。


====================================================================
2. B：画面なしでも同じ結果か（③1台・種1）
====================================================================

  比べ方（エイイチの指示どおり）
    同じ種でも揺れるので、**数字の一致ではなく cs:7 の同じ条件の近くに
    入るか**で見ます。cs:7 の ③1台 は種0〜2 で

      入店待ち   1.40 / 3.00 / 11.99 分   ← 種による差がこれだけ大きい
      店員待ち   0.16 / 1.76 / 5.41 分

  結果（画面なしを1本だけで回した回。acheck の2本同時も並べます）

    項目                エディタ種1   画面なし    画面なし    cs:7 種0〜2
                        (cs:7)      1本(B)     2本同時(A)   の幅
    ───────────────────────────────────────────────────────────────────
    入店待ち（分）        3.004       1.711      2.587      1.4〜12.0    帯の中
    店員待ち（分）        1.758       0.468      1.351      0.16〜5.41   帯の中
    料理待ち（分）        2.849       2.249      2.563      1.97〜2.85   帯の中
    冷めた割合           0.413       0.248      0.316      0.175〜0.413 帯の中
    ロボット分担          0.646       0.709      0.641      0.646〜0.825 帯の中
    店員の忙しさ          0.772       0.721      0.768      0.579〜0.800 帯の中
    ロボットの忙しさ       0.701       0.695      0.739      0.701〜0.803 ★0.006 下
    店員の歩き km         2.646       2.642      2.630      2.16〜3.18   帯の中
    会計待ち（分）        5.277       5.966      4.889      2.54〜6.48   帯の中
    卓が空くまで（分）     1.452       0.091      1.051      0〜5.16      帯の中
    回転（分）            6.949       4.355      8.098      2.90〜7.35   帯の中
    卓を空けた              0           0          0        0〜0         帯の中
    人のあきらめ           63          26         57        36〜63       ★10 下
    動けなくなった          9           0         12        3〜9         ★3 下
    往復                  66          74         66        66〜84       帯の中
    時間切れ               1           0          0        0〜1         帯の中
    店の中（分）         182.2       182.2      183.3      174〜207     帯の中
    全員帰った             1           1          1        1〜1         帯の中
    片付け待ち（秒）      279.1       123.1      339.6      27.9〜308    帯の中
    会計待ち仕事（秒）    177.3       166.5      144.1      99.2〜177    帯の中

  見立て：**合っていると見ます。**
    20 項目のうち 17 が帯の中。外れた3つは
      ロボットの忙しさ   帯の下端より 0.006 低いだけ（誤差の域）
      人のあきらめ       帯の下端より 10 低い（＝良い側）
      動けなくなった      帯の下端より 3 低い（＝良い側）
    どれも「悪くなった」方向ではありません。
    店の中の時間（182.2 分）は **エディタと小数第1位まで一致**、
    店員の歩き km も 2.642 対 2.646 でほぼ同じです。
    通し番号（往復 74 対 66）は、同じ種でも揺れる所です。

  ★ ただし 6 章の並びがあるので、「画面なしだから同じ」と言い切るのは
    まだ早いと思います。


====================================================================
3. C：何本同時に回せるか（③2台・fam_p1・種0〜2）
====================================================================

  Mac
    8 コア（高性能 6＋高効率 2）・メモリ 16 GB
    ★ 測っている間、**Unity エディタは開いたまま**でした。
      夜は閉じるので、本番はこれより余裕があります。

  結果

    同時   回数  1回の平均   束が終わるまで  1時間に終わる数  70回の見込み  rc
    ──────────────────────────────────────────────────────────────────
    1本    3     151 秒      454 秒（3回）   23.8 回/時      177 分       0
    2本    3     187 秒      186 秒（2回）   38.6 回/時      109 分       0
    3本    3     220 秒      248 秒（3回）   43.5 回/時       96 分       0
    4本    4     262 秒      291 秒（4回）   49.5 回/時       85 分       0

    1本を基準にした伸び
      2本 … 1.63 倍（1回の実時間は 1.23 倍に伸びる）
      3本 … 1.83 倍（1回の実時間は 1.45 倍に伸びる）
      4本 … 2.08 倍（1回の実時間は 1.73 倍に伸びる）

  CPU とメモリ（回している間の山）

    同時   CPU（8コア＝800%）   メモリ（RSS 合計）   load1
    ─────────────────────────────────────────────────────
    1本    約 145%（1.5 コア）   約 100 MB          6〜11
    2本    約 270%（2.7 コア）   約 195 MB          15〜19
    3本    約 380%（3.8 コア）   約 290 MB          12〜40
    4本    約 458%（4.6 コア）   約 400 MB          21〜22

    ・メモリは **まったく問題ありません**（4本でも 400 MB。16 GB のうち 2.5%）。
    ・CPU も 4本で 4.6 コアぶん。8 コアのうち 3.4 コア空いています
      （この空きで Unity エディタが動いていました）。
    ・load1 が大きく出ますが、これは Unity の糸（スレッド）の数が
      そのまま乗るためで、詰まっているという意味ではありません。
      1回の実時間が 40 分の上限（2400 秒）に遠く届かない（最長 291 秒）ので、
      詰まりの心配はありません。

  決めた N

    依頼の決め方（1時間に終わる回の数がいちばん多く、メモリが足りている本数）
    では **N = 4** です。49.5 回/時・70回に 85 分。

    ★ ただし、速さで選ぶ意味はほとんどありません。
      70回は **1本ずつでも3時間**で終わり、夜は時刻で止めない決まり
      （並べた回が全部終わるまで回す）なので、
      4本（85 分）と 2本（109 分）の差は **24 分**しかありません。
      6 章の心配があるので、迷うなら少ない方が安全です。

    私の案：**N = 4 で始める**（依頼の決め方どおり）。
      run_info.json に parallel が入るので、あとで
      「同時本数と数字に関係があったか」を台帳から確かめられます。
      もし 6 章の心配をいま潰しておきたいなら、**N = 2** にしてください
      （夜が 24 分長くなるだけです）。


====================================================================
4. まだ確かめていないこと
====================================================================

  ・⑦（fam_p5）での実時間は測っていません（依頼は ③2台 を指定）。
    ⑦ は通路が狭く重いので、1回がもう少し長くなる見込みです。
    4本同時でも 40 分の上限には遠いので、問題にはならないと思います。
  ・夜の実行係（night_runner.py）を通した起動はまだです（直に起動して測りました）。
    実行係の引数の組み方は、実物の引数列と突き合わせて確かめてあります。
  ・stopped（途中で止まった）回・failed の回の台帳の行は、まだ実物で出していません。
    5 章 D の codex レビューで、この道を重点的に見てもらうのがよいと思います。


====================================================================
5. 直したあとの check-in
====================================================================

  cs:15  Setup でシーンに SimHud と HeadlessRun を入れた（.meta 4件も）
  cs:16  判定を judge_night_v1.py に差し替えた
  cs:17  画面なしの実行で見つかった2件を直した（SimParams.Load・Aborted）

  いまの保留 0。使った実行ファイルは cs:17（保留 0）で作ったものです。


====================================================================
6. ★ 気になる並び：Mac が空いているほど「詰まり」が減る
====================================================================

  同じ種1・同じ条件（③1台）で、こうなりました。

    回し方                  動けなくなった   人のあきらめ
    ────────────────────────────────────────────────
    画面なし・1本だけ             0            26
    画面なし・2本同時            12            57
    エディタ（cs:7）              9            63

  ③2台 でも、同じ向きが見えます（種ごと・1本 → 2本 → 3本）。

    種0   動けなくなった 22 → 35 → 31 ／ 人のあきらめ 47 → 64 → 59
    種1   動けなくなった 27 → 20 → 40 ／ 人のあきらめ 49 → 82 → 47
    種2   動けなくなった 42 → 37 → 61 ／ 人のあきらめ 109 → 87 → 94

  筋は通ります。10/25 の切り分けで、最後まで消えなかった容疑が
  「NavMeshAgent の動きが Unity の job（並列の仕事）で動いている」でした。
  job の割り振りは **Mac の忙しさで変わる**ので、同時に何本走らせるかで
  数字がずれる、という説明になります。

  ★ ただし、これは断定できません。
    ・各マスが1回ずつです
    ・同じ種でもずれることは 10/25 に分かっています
      （③1台・種1 で 958.76 秒 と 1527.18 秒）
    ・③2台 の並びは種1 で逆を向いています（27 → 20 → 40）
    つまり、既知のばらつきと見分けがつきません。

  夜への効き方（ここは落ち着いて考えられます）

    夜の70回は **お互いを比べる**ものです（③ 対 ⑦、ロボット 0/1/2 台、
    すれ違い 入 対 切）。全部を同じ N で回せば、忙しさの効果は
    どの条件にも同じように乗るので、**比べる分には消えます**。
    cs:7 との比較は二の次です（cs:7 はエディタで測ったものなので、
    N をどうしても画面なしとは条件が違います）。

    なので私の勧めは
      ① **N を夜の途中で変えない**（いまのコードは run_info.json の
         parallel に残すので、あとから確かめられます）
      ② 夜は **Unity エディタを閉じる**（忙しさの元を1つ減らす）
      ③ 比べるのは **種10個の中央値**（9/25 に決めたとおり）

  はっきりさせたいときの確かめ（やるなら 15 分ほど）

    同じ条件・同じ種を **1本ずつ 5 回** と **4本同時で 5 回** 回して、
    分布（中央値と幅）を比べる。重なれば忙しさは効いていない、
    ずれれば効いている、と分かります。台帳に 10 行増えます。
    やりますか。


====================================================================
7. 次（順に）
====================================================================

  1. N を決める（私の案は 4。6 章が気になるなら 2）
  2. 6 章の確かめ（やるかどうか。1本×5回 と 4本×5回）
  3. 5 章 D：codex レビューの依頼文を私が作る
     見てもらう所 … 台帳の鍵（ServiceSimLedger.TakeLock / Append）・
                    LedgerRow・night_runner.py の stopped / failed の行
  4. 夜の実行係を通した から回し（--dry-run）で70回の並びを確かめる
  5. 最初の夜 70 回
     （台帳を archive/unity_ledger_v1_prenight_{夜}/ に写してから）

  最初の夜のコマンド（いまのところ。N は決まり次第）

    cd /Users/shimazueiichi/Robotics_UX/ServiceSim
    python3 tools/make_queue.py --out queue.json \
        --pass-by-extra fam_p1_s4_r2_a42
    python3 tools/night_runner.py --queue queue.json --parallel 4 \
        --night 20261027

  確かめで台帳に足した 16 行（名札 acheck・bcheck・c1〜c4check）は、
  夜の回（20261027_night_*）とは名札で分かれるので、判定には入りません。
```

### night/20261027/runner_log.txt（夜の実行係の空回し）

```text
2026-10-26 12:16:32  === 夜の実行係を始めます（night 20261027・同時 1 本・上限 90.0 分）===
2026-10-26 12:16:32  並べた回: /private/tmp/claude-501/-Users-shimazueiichi/d0d60c2a-d756-4c07-a05b-13221e3d700f/scratchpad/q4.json（4 回）
2026-10-26 12:16:33  （から回しなので通します）実行ファイルがありません（または実行できません）: /Users/shimazueiichi/Robotics_UX/ServiceSim/Builds/headless/ServiceSim.app/Contents/MacOS/ServiceSim
2026-10-26 12:16:33  台帳: /Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv（59 列）
2026-10-26 12:16:33  版: ServiceSim/cs:10（cm status）→ 出力フォルダの字 cs10
2026-10-26 12:16:33  並べた回の試算の版: v2.8 / 店の形の版: 8a5b2f6a016a
2026-10-26 12:16:33  並べた回 4 / もう completed の回を飛ばす 0 / 回す 4
2026-10-26 12:16:33  （から回し）1 回め fam_p1_s4_r2_a42 種0 すれ違い切 → 20261027_night_cs10_fam_p1_r2_s0
2026-10-26 12:16:33    /Users/shimazueiichi/Robotics_UX/ServiceSim/Builds/headless/ServiceSim.app/Contents/MacOS/ServiceSim -batchmode -nographics -logFile /Volumes/KIOXIA/r2r2r-data/service_sim/unity/night/20261027/logs/20261027_night_cs10_fam_p1_r2_s0.log -ssCondition fam_p1_s4_r2_a42 -ssSeed 0 -ssLabel night -ssNight 20261027 -ssParallel 1 -ssPassBy off
2026-10-26 12:16:33  （から回し）2 回め fam_p1_s4_r2_a42 種1 すれ違い切 → 20261027_night_cs10_fam_p1_r2_s1
2026-10-26 12:16:33    /Users/shimazueiichi/Robotics_UX/ServiceSim/Builds/headless/ServiceSim.app/Contents/MacOS/ServiceSim -batchmode -nographics -logFile /Volumes/KIOXIA/r2r2r-data/service_sim/unity/night/20261027/logs/20261027_night_cs10_fam_p1_r2_s1.log -ssCondition fam_p1_s4_r2_a42 -ssSeed 1 -ssLabel night -ssNight 20261027 -ssParallel 1 -ssPassBy off
2026-10-26 12:16:33  （から回し）3 回め fam_p1_s4_r2_a42 種0 すれ違い入 → 20261027_night_cs10_fam_p1_r2_s0_passon
2026-10-26 12:16:33    /Users/shimazueiichi/Robotics_UX/ServiceSim/Builds/headless/ServiceSim.app/Contents/MacOS/ServiceSim -batchmode -nographics -logFile /Volumes/KIOXIA/r2r2r-data/service_sim/unity/night/20261027/logs/20261027_night_cs10_fam_p1_r2_s0_passon.log -ssCondition fam_p1_s4_r2_a42 -ssSeed 0 -ssLabel night -ssNight 20261027 -ssParallel 1 -ssPassBy on
2026-10-26 12:16:33  （から回し）4 回め fam_p1_s4_r2_a42 種1 すれ違い入 → 20261027_night_cs10_fam_p1_r2_s1_passon
2026-10-26 12:16:33    /Users/shimazueiichi/Robotics_UX/ServiceSim/Builds/headless/ServiceSim.app/Contents/MacOS/ServiceSim -batchmode -nographics -logFile /Volumes/KIOXIA/r2r2r-data/service_sim/unity/night/20261027/logs/20261027_night_cs10_fam_p1_r2_s1_passon.log -ssCondition fam_p1_s4_r2_a42 -ssSeed 1 -ssLabel night -ssNight 20261027 -ssParallel 1 -ssPassBy on
2026-10-26 12:16:33  --- まとめ ---
2026-10-26 12:16:33    dry-run: 4 回
2026-10-26 12:16:33  === 夜の実行係を終わります（終了コード 0）===
```

## 台帳の行（unity_ledger_v1.csv・この記事で回した回）

49行（ヘッダ1行と、2026-10-25 15時以降の行）。

```csv
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,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
2026-10-25T15:03:33,20261025_stageB_cs144_fam_p5_r2_s2,completed,cs:144,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,2,1,1,1,120,1,223.7393,933.7357,16.2146,9.8482,1.2676,0.0669,0.9646,0.8141,0.6812,3.0099,1.9669,0.4479,0.9839,0.991,0.5648,,1.3246,,166,1,15,3867.828,0,141,105,4,15,9,0.064,2,6.734,7.9555,9.4085,0.3816,14.0696,2.0967,739.1896,1635.237,85,156.016
2026-10-25T17:23:47,20261025_split_check_cs2_fam_p1_r1_s1,completed,ServiceSim/cs:2,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,179.2347,654.4564,1.5452,0.3161,2.611,0.3155,0.6602,0.7719,0.7395,2.5836,2.5992,0.572,1.9487,0.7204,0.5768,,2.3906,,64,1,11,1117.142,0,53,45,1,11,70,0.0433,2,5.4686,7.9893,0.0023,0.4827,5.7867,2.1338,228.7283,938.5195,9,158.4242
2026-10-25T17:37:41,20261025_split_check_cs2_fam_p1_r1_s1_2,completed,ServiceSim/cs:2,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,182.042,601.1398,1.702,0.4713,2.2184,0.267,0.7039,0.7169,0.6899,2.6179,2.5992,0.572,1.9487,0.7204,0.58,,3.1523,,72,0,0,545.3484,0,28,35,0,0,61,0.0396,2,6.0809,7.9893,0.0636,0.4827,3.5919,2.1338,76.5789,378.5654,0,165.8392
2026-10-25T18:28:06,20261025_det_check_cs2_fam_p1_r1_s1,completed,ServiceSim/cs:2,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,190.5296,809.6896,3.326,2.0819,2.7463,0.3204,0.6748,0.7593,0.7249,2.6241,2.5992,0.572,1.9487,0.7204,0.5763,,2.7702,,68,1,12,1288.491,0,59,36,0,12,67,0.0505,2,5.0516,7.9893,1.7869,0.4827,10.1562,2.1338,455.614,1279.482,30,145.1615
2026-10-25T18:46:33,20261025_det_check_cs2_fam_p1_r1_s1_2,completed,ServiceSim/cs:2,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,183.312,745.9092,2.4759,1.2355,2.3798,0.267,0.6942,0.7242,0.7037,2.6493,2.5992,0.572,1.9487,0.7204,0.5745,,2.6825,,70,0,3,799.7974,0,34,31,0,3,63,0.0391,2,5.9814,7.9893,0.9287,0.4827,5.4044,2.1338,182.7099,777.5508,1,168.0557
2026-10-25T19:02:16,20261025_det_check_cs2_fam_p1_r1_s1_3,completed,ServiceSim/cs:2,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,184.592,688.7477,1.4077,0.205,2.1738,0.199,0.699,0.6795,0.6797,2.6869,2.5992,0.572,1.9487,0.7204,0.5767,,2.5806,,71,0,0,544.4995,0,34,40,0,0,62,0.0254,2,7.2586,7.9893,0,0.4827,2.6641,2.1338,26.331,206.1914,0,169.4946
2026-10-25T19:14:45,20261025_det_check_cs2_fam_p1_r1_s1_4,completed,ServiceSim/cs:2,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,182.0968,746.5142,1.824,0.6458,2.2958,0.2282,0.699,0.7057,0.7039,2.6114,2.5992,0.572,1.9487,0.7204,0.5772,,3.1182,,70,0,0,657.9477,0,38,48,0,0,62,0.0375,2,5.6444,7.9893,0.3357,0.4827,4.8269,2.1338,153.8569,522.4512,0,165.6561
2026-10-25T20:23:00,20261025_stageB_cs7_fam_p1_r2_s0,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,173.449,687.1277,1.5745,0.3432,1.4146,0.1125,0.9625,0.6459,0.6466,1.9791,1.1788,0.3853,0.9488,0.9912,0.5615,,1.4891,,114,2,16,3660.529,0,66,85,1,16,6,0.0875,2,3.1158,7.9844,0,0.322,6.2137,2.0672,222.825,878.4053,6,111.2345
2026-10-25T20:33:50,20261025_stageB_cs7_fam_p5_r2_s0,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,0,1,1,1,120,1,173.6267,647.3616,1.6255,0.3969,1.4993,0.1313,0.9375,0.6265,0.6508,2.0102,1.9669,0.4479,0.9839,0.991,0.5724,,2.1195,,118,2,32,3924.459,0,51,59,0,32,10,0.0355,2,2.3164,7.9555,0,0.3816,5.1098,2.0967,163.6243,641.4844,1,92.0545
2026-10-25T20:41:39,20261025_stageB_cs7_fam_p1_r0_s0,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,0,42,serve,1,any,0,1,1,1,120,1,172.355,466.069,1.4727,0.294,0.848,0,0,0.6618,0,2.7917,3.301,0.9547,0.459,0,0,,0,,0,0,0,0,0,6,6,0,0,0,0,2,2.1123,12.2785,0,0.7327,3.7187,2.4872,85.4063,316.3096,0,93.4232
2026-10-25T21:08:51,20261025_stageB_cs7_fam_p1_r0_s1,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,0,42,serve,1,any,1,1,1,1,120,1,185.7051,553.2836,3.8008,2.5929,0.9676,0,0,0.771,0,3.2248,3.301,0.9547,0.459,0,0,,0,,0,0,0,0,0,7,12,0,0,0,0,2,4.6743,12.2785,2.0966,0.7327,10.7419,2.4872,510.0704,1133.654,38,140.821
2026-10-25T21:19:12,20261025_stageB_cs7_fam_p1_r0_s2,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,0,42,serve,1,any,2,1,1,1,120,1,214.684,618.064,13.4258,8.2967,0.9592,0,0,0.829,0,4.0434,3.301,0.9547,0.459,0,0,,0,,0,0,0,0,0,17,9,0,0,0,0,2,5.5283,12.2785,7.8552,0.7327,11.3469,2.4872,544.4601,1200.841,49,142.456
2026-10-25T21:27:22,20261025_stageB_cs7_fam_p1_r1_s0,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,0,1,1,1,120,1,174.1156,487.887,1.3996,0.1557,1.9692,0.175,0.825,0.5788,0.7366,2.158,2.5992,0.572,1.9487,0.7204,0.5822,,2.8458,,66,0,3,661.0967,0,36,31,0,3,28,0.0321,2,2.5366,7.9893,0,0.4827,2.8967,2.1338,27.8724,205.7507,0,99.2408
2026-10-25T21:35:55,20261025_stageB_cs7_fam_p1_r1_s1,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,182.174,509.964,3.004,1.7583,2.8487,0.4126,0.6456,0.7724,0.7009,2.6464,2.5992,0.572,1.9487,0.7204,0.5779,,2.7118,,66,1,9,1033.993,0,63,43,0,9,73,0.0375,2,5.2772,7.9893,1.4521,0.4827,6.9495,2.1338,279.0711,670.2217,4,177.2719
2026-10-25T21:46:10,20261025_stageB_cs7_fam_p1_r1_s2,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,2,1,1,1,120,1,206.6712,612.4709,11.9859,5.4119,2.3323,0.2717,0.7008,0.8005,0.803,3.1759,2.5992,0.572,1.9487,0.7204,0.5771,,2.9387,,84,0,4,1018.947,0,63,54,0,4,76,0.0445,2,6.4823,7.9893,5.1621,0.4827,7.3454,2.1338,307.871,741.791,12,164.0701
2026-10-25T21:56:58,20261025_stageB_cs7_fam_p1_r2_s1,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,189.5003,645.2049,2.4067,1.1663,1.5772,0.1019,0.9078,0.7247,0.6231,2.4985,1.1788,0.3853,0.9488,0.9912,0.5619,,1.407,,127,2,23,3840.492,0,59,55,0,23,19,0.056,2,10.2363,7.9844,0.8025,0.322,5.7342,2.0672,211.6329,940.0137,9,190.9436
2026-10-25T22:07:38,20261025_stageB_cs7_fam_p1_r2_s2,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,211.9555,636.8694,11.9488,5.1589,1.4559,0.0906,0.9055,0.7837,0.7112,3.0315,1.1788,0.3853,0.9488,0.9912,0.5632,,1.5011,,149,4,36,4829.172,0,82,82,2,36,24,0.0653,2,8.3662,7.9844,4.988,0.322,7.3937,2.0672,330.2848,1012.031,28,166.7876
2026-10-25T22:15:37,20261025_stageB_cs7_fam_p5_r0_s0,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,172.7667,476.3867,1.5299,0.3506,0.8645,0,0,0.6674,0,2.8415,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,6,14,0,0,0,0,2,2.2834,11.7376,0,0.6821,3.3222,2.5036,59.4114,310.603,0,96.3434
2026-10-25T23:22:45,20261025_stageB_cs7_fam_p5_r0_s1,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,187.391,543.5219,4.1816,2.9715,0.9264,0,0,0.7752,0,3.2879,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,7,11,0,0,0,0,2,4.6659,11.7376,2.5809,0.6821,11.8725,2.5036,576.3293,1231.122,44,142.5328
2026-10-25T23:33:02,20261025_stageB_cs7_fam_p5_r0_s2,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,212.1614,613.5511,13.3201,8.1513,1.0722,0.0039,0,0.8451,0,4.0814,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,31,21,0,0,0,0,2,5.4035,11.7376,7.6697,0.6821,11.4699,2.5036,544.9147,1194.715,50,138.4893
2026-10-25T23:41:18,20261025_stageB_cs7_fam_p5_r1_s0,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,1,42,serve,3,any,0,1,1,1,120,1,173.796,493.8488,1.4342,0.2161,1.8762,0.125,0.825,0.5955,0.7423,2.2415,1.815,0.4851,1.9698,0.721,0.5836,,2.5473,,67,0,3,605.5473,0,35,46,0,3,28,0.0294,2,2.9874,8.0624,0,0.3915,2.9471,2.1404,35.0688,363.5244,0,109.9481
2026-10-25T23:49:53,20261025_stageB_cs7_fam_p5_r1_s1,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,1,42,serve,3,any,1,1,1,1,120,1,180.3354,511.4709,2.0421,0.8027,2.357,0.2718,0.6553,0.7463,0.7044,2.6153,1.815,0.4851,1.9698,0.721,0.579,,3.512,,67,0,3,840.3956,0,39,30,0,3,71,0.0395,2,4.8516,8.0624,0.5437,0.3915,6.0049,2.1404,220.1042,666.4863,1,157.2674
2026-10-26T00:00:53,20261025_stageB_cs7_fam_p5_r1_s2,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,1,42,serve,3,any,2,1,1,1,120,1,209.8023,657.5893,10.9248,4.3229,2.3991,0.2992,0.7087,0.7584,0.7908,3.1329,1.815,0.4851,1.9698,0.721,0.5804,,3.0255,,79,0,3,872.9967,0,49,51,0,3,74,0.0268,2,7.5986,8.0624,4.087,0.3915,5.5989,2.1404,197.1136,576.0908,0,174.424
2026-10-26T00:10:03,20261025_stageB_cs7_fam_p5_r2_s1,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,1,1,1,1,120,1,185.906,547.6191,1.5009,0.2781,1.5168,0.1068,0.8932,0.6903,0.588,2.485,1.9669,0.4479,0.9839,0.991,0.5652,,1.3455,,128,2,15,2970.061,0,48,47,0,15,22,0.0283,2,9.1146,7.9555,0.0094,0.3816,3.4778,2.0967,66.3567,622.21,1,175.9501
2026-10-26T00:21:19,20261025_stageB_cs7_fam_p5_r2_s2,completed,ServiceSim/cs:7,8a5b2f6a016a,v2.8,fam_p5,4,2,42,serve,3,any,2,1,1,1,120,1,209.7575,672.9712,11.9207,5.5244,1.5407,0.1102,0.8701,0.7971,0.7199,3.0789,1.9669,0.4479,0.9839,0.991,0.5663,,1.3747,,151,5,37,4914.905,0,79,57,0,37,33,0.0381,2,8.0116,7.9555,5.3036,0.3816,7.192,2.0967,290.2795,758.874,15,165.5899
2026-10-26T08:26:05,20261026_path_check_cs9_fam_p5_r0_s0,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,172.6696,624.1475,1.5417,0.3488,0.8947,0,0,0.6738,0,2.8637,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,7,14,0,0,0,0,2,1.8059,11.7376,0,0.6821,4.1494,2.5036,108.9035,404.165,0,82.2173
2026-10-26T08:36:23,20261026_path_check_cs9_fam_p5_r0_s1,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,186.3177,599.3001,4.1315,2.9258,0.9196,0,0,0.7774,0,3.2708,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,8,8,0,0,0,0,2,4.6838,11.7376,2.4808,0.6821,11.5091,2.5036,553.6439,1201.887,40,138.4467
2026-10-26T08:58:49,20261026_path_check_cs9_fam_p5_r0_s2,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,213.4829,895.8413,13.4992,8.3509,1.0755,0,0,0.8354,0,4.0686,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,21,12,0,0,0,0,2,5.5808,11.7376,7.835,0.6821,11.2034,2.5036,535.9978,1185.302,48,147.8599
2026-10-26T09:30:57,20261026_path_check_cs9_fam_p5_r0_s0_2,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,0,1,1,1,120,1,173.3611,707.5295,1.535,0.3445,0.8579,0,0,0.669,0,2.8665,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,9,10,0,0,0,0,2,2.1758,11.7376,0,0.6821,3.9266,2.5036,94.8157,331.5498,0,93.4128
2026-10-26T09:50:03,20261026_path_check_cs9_fam_p5_r0_s1_2,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,1,1,1,1,120,1,186.7892,801.6776,4.2162,3.0114,1.0149,0.0049,0,0.78,0,3.3023,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,12,11,0,0,0,0,2,4.6692,11.7376,2.4887,0.6821,11.8159,2.5036,578.1798,1263.645,44,141.8102
2026-10-26T10:20:55,20261026_path_check_cs9_fam_p5_r0_s2_2,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p5,4,0,42,serve,1,any,2,1,1,1,120,1,211.9995,851.306,13.2701,8.0118,1.0473,0.0039,0,0.8416,0,4.0599,2.7562,0.8929,0.4478,0,0,,0,,0,0,0,0,0,27,13,0,0,0,0,2,5.982,11.7376,7.5902,0.6821,11.0546,2.5036,526.0934,1200.941,48,152.7574
2026-10-26T11:32:43,20261026_pass_on_cs9_fam_p1_r2_s1,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,198.8926,794.4053,6.1057,4.8707,2.35,0.301,0.801,0.7604,0.6439,2.5848,1.1788,0.3853,0.9488,0.9912,0.5355,,1.5108,,117,4,40,4517.039,0,98,58,1,40,41,0.068,2,6.3773,7.9844,4.4921,0.322,13.6376,2.0672,658.147,1755.167,43,154.3291
2026-10-26T11:55:46,20261026_pass_off_cs9_fam_p1_r2_s1,completed,ServiceSim/cs:9,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,186.4422,774.6969,2.0486,0.7517,2.1654,0.2476,0.8058,0.7452,0.6263,2.6035,1.1788,0.3853,0.9488,0.9912,0.5545,,0.9454,,114,4,26,4077.037,0,51,45,0,26,40,0.0437,2,7.6822,7.9844,0.2866,0.322,5.016,2.0672,153.2059,724.6084,5,169.2852
2026-10-26T13:51:18,20261026_acheck_cs17_fam_p1_r1_s1,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,183.3178,148.8888,2.5865,1.3513,2.5635,0.3155,0.6408,0.7681,0.7387,2.6298,2.5992,0.572,1.9487,0.7204,0.5781,,2.9761,,66,0,12,1274.141,0,57,44,0,12,74,0.0345,2,4.8895,7.9893,1.0512,0.4827,8.098,2.1338,339.6106,826.459,16,144.0676
2026-10-26T13:51:44,20261026_acheck_cs17_fam_p1_r1_s2,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,2,1,1,1,120,1,208.6518,174.7515,11.615,4.7043,2.4136,0.2756,0.685,0.7776,0.8088,3.1805,2.5992,0.572,1.9487,0.7204,0.5803,,4.0375,,78,0,5,1220.792,0,73,63,0,5,80,0.0338,2,7.3152,7.9893,4.4309,0.4827,6.4605,2.1338,254.9967,781.7842,6,168.4475
2026-10-26T13:54:41,20261026_bcheck_cs17_fam_p1_r1_s1,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,1,42,serve,3,any,1,1,1,1,120,1,182.1972,116.8897,1.711,0.4682,2.2494,0.2476,0.7087,0.7209,0.6954,2.6419,2.5992,0.572,1.9487,0.7204,0.5776,,2.8154,,74,0,0,565.8988,0,26,32,0,0,60,0.0351,2,5.9664,7.9893,0.0912,0.4827,4.3548,2.1338,123.0728,429.8643,0,166.4513
2026-10-26T13:56:36,20261026_c1check_cs17_fam_p1_r2_s0,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,172.5816,114.217,1.4214,0.1977,1.1261,0.0438,0.9813,0.6135,0.6415,2.1878,1.1788,0.3853,0.9488,0.9912,0.5654,,1.8156,,123,1,22,3550.603,0,47,56,0,22,3,0.0487,2,2.6513,7.9844,0,0.322,3.3076,2.0672,46.5036,319.3115,0,103.4987
2026-10-26T13:58:56,20261026_c1check_cs17_fam_p1_r2_s1,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,186.6274,138.7348,3.9472,2.6993,1.6935,0.1602,0.8641,0.736,0.6463,2.4897,1.1788,0.3853,0.9488,0.9912,0.5587,,1.9116,,124,3,27,4249.809,0,49,44,1,27,28,0.0428,2,4.8154,7.9844,2.3529,0.322,9.5555,2.0672,424.8903,1144.96,30,144.7217
2026-10-26T14:02:15,20261026_c1check_cs17_fam_p1_r2_s2,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,220.0397,197.5813,11.3755,4.353,1.8818,0.1929,0.8228,0.8053,0.6584,3.1248,1.1788,0.3853,0.9488,0.9912,0.5673,,1.8895,,146,4,42,4761.137,0,109,67,0,42,45,0.0605,2,8.5617,7.9844,3.9736,0.322,7.9276,2.0672,336.8946,1032.302,21,174.3177
2026-10-26T14:05:17,20261026_c2check_cs17_fam_p1_r2_s0,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,176.5087,180.5937,1.8103,0.5388,1.461,0.1188,0.9375,0.6396,0.6588,2.0931,1.1788,0.3853,0.9488,0.9912,0.5697,,2.3908,,116,1,35,4131.553,0,64,55,2,35,10,0.064,2,2.1205,7.9844,0,0.322,6.5706,2.0672,251.2814,921.9346,7,91.8843
2026-10-26T14:05:28,20261026_c2check_cs17_fam_p1_r2_s1,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,183.9014,190.6597,1.8692,0.5961,1.6722,0.165,0.8932,0.7676,0.648,2.4712,1.1788,0.3853,0.9488,0.9912,0.5568,,1.2321,,125,1,20,3870.863,0,82,61,0,20,22,0.0754,2,7.803,7.9844,0.2891,0.322,5.2886,2.0672,176.4584,575.8423,0,179.2318
2026-10-26T14:08:34,20261026_c2check_cs17_fam_p1_r2_s2,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,210.9354,184.9777,11.3375,3.8375,1.6631,0.1299,0.874,0.8086,0.6952,3.1367,1.1788,0.3853,0.9488,0.9912,0.5645,,1.6795,,157,4,37,4737.677,0,87,74,0,37,32,0.0489,2,9.8781,7.9844,3.5372,0.322,4.9244,2.0672,142.2564,477.0293,0,174.4782
2026-10-26T14:11:53,20261026_c3check_cs17_fam_p1_r2_s0,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,173.3967,197.1472,1.7301,0.4815,1.7293,0.1625,0.8813,0.6695,0.6828,2.2482,1.1788,0.3853,0.9488,0.9912,0.5485,,0.9849,,108,4,31,4757.408,0,59,51,0,31,19,0.0413,2,2.6867,7.9844,0,0.322,7.1946,2.0672,265.0999,1144.512,11,97.3156
2026-10-26T14:12:08,20261026_c3check_cs17_fam_p1_r2_s1,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,184.0707,212.1384,3.1864,1.9545,1.9882,0.2573,0.7864,0.7364,0.657,2.4823,1.1788,0.3853,0.9488,0.9912,0.5641,,1.5243,,115,5,40,4406.642,0,47,47,0,40,44,0.0419,2,5.909,7.9844,1.7088,0.322,7.0202,2.0672,283.5531,783.386,5,176.1658
2026-10-26T14:12:42,20261026_c3check_cs17_fam_p1_r2_s2,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,213.36,246.5533,15.2811,7.4581,1.8875,0.1772,0.7874,0.8371,0.7463,3.2416,1.1788,0.3853,0.9488,0.9912,0.5654,,1.7621,,145,7,61,6128.284,0,94,72,0,61,54,0.0525,2,7.4084,7.9844,7.188,0.322,9.6885,2.0672,458.5016,1027.919,53,162.3679
2026-10-26T14:17:57,20261026_c4check_cs17_fam_p1_r2_s0,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,0,1,1,1,120,1,174.0558,242.9338,1.9294,0.7069,1.4377,0.1,0.95,0.6494,0.6638,2.1147,1.1788,0.3853,0.9488,0.9912,0.5642,,2.1578,,114,3,28,4045.251,0,63,64,0,28,8,0.0717,2,3.3862,7.9844,0,0.322,6.0094,2.0672,204.6626,902.8308,3,132.3062
2026-10-26T14:18:08,20261026_c4check_cs17_fam_p1_r2_s3,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,3,1,1,1,120,1,181.8926,253.3527,1.5072,0.2604,1.0563,0.0207,0.9637,0.665,0.6271,2.5034,1.1788,0.3853,0.9488,0.9912,0.5691,,1.7235,,138,2,18,3028.414,0,36,42,0,18,7,0.0407,2,8.2899,7.9844,0,0.322,2.6707,2.0672,15.4003,185.6719,0,182.9092
2026-10-26T14:18:09,20261026_c4check_cs17_fam_p1_r2_s1,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,1,1,1,1,120,1,184.0201,254.5276,1.7925,0.5372,1.4351,0.1311,0.8932,0.7014,0.6106,2.4981,1.1788,0.3853,0.9488,0.9912,0.5655,,1.8072,,131,1,18,3381.419,0,38,43,0,18,22,0.0379,2,7.606,7.9844,0.2691,0.322,3.8112,2.0672,93.6172,665.3408,1,176.5192
2026-10-26T14:18:44,20261026_c4check_cs17_fam_p1_r2_s2,completed,ServiceSim/cs:17,8a5b2f6a016a,v2.8,fam_p1,4,2,42,serve,3,any,2,1,1,1,120,1,212.6686,289.3067,13.0754,6.5903,1.5856,0.126,0.8701,0.8196,0.7268,3.2147,1.1788,0.3853,0.9488,0.9912,0.5594,,1.2155,,156,5,43,5308.693,0,92,65,0,43,33,0.0592,2,7.6476,7.9844,6.3014,0.322,8.6018,2.0672,398.6656,978.5127,47,161.9296
```

## Unity のコード（ServiceSim/cs:17）

前のプロジェクト（cs:144）から変えたファイルと、新しく足したファイルの全文です。

### Assets/ServiceSim/Editor/ServiceSimSetup.cs

```csharp
using System;
using System.Collections.Generic;
using System.IO;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
using UnityEngine.AI;
using UnityEngine.SceneManagement;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// Assets/ServiceSim/ の中の .asset・prefab・シーンを作る。
    /// すでにあるものは作り直さないが、マテリアルのシェーダーだけは毎回確かめて、
    /// いまの描画方式に合わせ直す（合っていないとピンクになるため）。
    /// Assets/ServiceSim/ の外のファイルは一切作らない・変えない
    /// （Tag だけは ProjectSettings/TagManager.asset に足す。SimTags を見る）。
    ///
    /// シーンとビルド設定（依頼 v11 の 2・2026-10-25）:
    ///   このプロジェクトは ServiceSim だけなので、ServiceSim_Family.unity は
    ///   ビルド設定（EditorBuildSettings）に入れてある。画面なしの実行ファイル（v12）が
    ///   このシーンを開いて回すため。
    ///   mechArm と同居していたときの「新しいシーンはビルド設定に足さない」という用心は、
    ///   このプロジェクトでは当てはまらない（night_runs_design_v0.md の 1 章）。
    ///   Setup はビルド設定を書き換えない（入れるのは人が1回やる）。
    /// </summary>
    public static class ServiceSimSetup
    {
        public const string Root = "Assets/ServiceSim";
        public const string ScenePath = Root + "/Scenes/ServiceSim_Family.unity";
        public const string FamilyLayoutPath = Root + "/Store/StoreLayout_Family.asset";
        public const string GridLayoutPath = Root + "/Store/StoreLayout_Grid.asset";
        public const string RobotSpecPath = Root + "/Robot/RobotSpec.asset";
        public const string PeopleSpecPath = Root + "/People/PeopleSpec.asset";
        public const string ExperimentPlanPath = Root + "/Experiment/ExperimentPlan.asset";
        public const string ServiceSpecPath = Root + "/Service/ServiceSpec.asset";

        /// <summary>サイトの試算の値（v5）。serving-robot-params.json を読む .asset</summary>
        public const string SimParamsPath = Root + "/Experiment/SimParams.asset";

        /// <summary>
        /// サイトから取った店の形（③ fam_p1 と ⑦ fam_p5）。
        /// 段階B・画面なしの実行ファイルの両方がここを見る（置き場所は1つ）
        /// </summary>
        public const string P1LayoutPath = Root + "/Store/StoreLayout_p1_efficient_corner.asset";
        public const string P5LayoutPath = Root + "/Store/StoreLayout_p5_crammed.asset";

        /// <summary>座っている客の形（見た目だけ）の prefab</summary>
        public const string SeatedBodyPrefabPath = Root + "/People/SeatedGuestBody.prefab";

        /// <summary>客の黄色（立っている客・座っている客で同じ色を使う）</summary>
        static readonly Color GuestColor = new Color(0.98f, 0.80f, 0.13f);

        /// <summary>スタッフの青・待合の客の紫（見た目を作り直すときにも使う）</summary>
        static readonly Color StaffColor = new Color(0.09f, 0.38f, 0.90f);
        static readonly Color WaitingColor = new Color(0.55f, 0.30f, 0.75f);

        const string MatDir = Root + "/Store/Materials";
        const string StorePrefabDir = Root + "/Store/Prefabs";
        const string OldDir = Root + "/Store/old~";

        enum WalkerKind { Staff, Guest, Waiting }

        static Shader _shader;
        static int _materialsFixed;

        [MenuItem("ServiceSim/Check Render Pipeline", false, 1)]
        public static void CheckRenderPipeline()
        {
            string how;
            Shader shader = ServiceSimRender.FindOpaqueShader(out how);
            string body = ServiceSimRender.Describe() + "使うシェーダー: " + how;
            if (shader == null) Debug.LogError("[ServiceSim] 描画方式に合うシェーダーが見つかりません\n" + body);
            else Debug.Log("[ServiceSim] 描画方式のしらべ\n" + body);
        }

        [MenuItem("ServiceSim/Use Store Layout/Family (ファミレス型)", false, 30)]
        public static void UseFamilyLayout() { SwitchLayout(FamilyLayoutPath); }

        [MenuItem("ServiceSim/Use Store Layout/Grid (格子型・比べる用)", false, 31)]
        public static void UseGridLayout() { SwitchLayout(GridLayoutPath); }

        static void SwitchLayout(string path)
        {
            StoreLayout layout = AssetDatabase.LoadAssetAtPath<StoreLayout>(path);
            if (layout == null)
            {
                Debug.LogError("[ServiceSim] " + path + " が読めません。先に ServiceSim/Setup を実行してください");
                return;
            }
            StoreBuilder builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            NavMeshBaker baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            if (builder == null)
            {
                Debug.LogError("[ServiceSim] シーンに StoreBuilder がありません。ServiceSim_Family シーンを開いてください");
                return;
            }
            builder.layout = layout;
            if (baker != null) baker.layout = layout;
            EditorUtility.SetDirty(builder);
            if (baker != null) EditorUtility.SetDirty(baker);
            EditorSceneManager.MarkSceneDirty(SceneManager.GetActiveScene());
            Debug.Log("[ServiceSim] 店の形を切り替えました: " + layout.name + "（" + layout.StoreKindName + "）" +
                      "\n  このあと ServiceSim/Build Store を実行してください");
        }

        [MenuItem("ServiceSim/Setup (アセット・prefab・シーンを作る)", false, 10)]
        public static void Setup()
        {
            // まず描画方式を確かめる。合うシェーダーが無ければ何も作らずに止める
            string how;
            _shader = ServiceSimRender.FindOpaqueShader(out how);
            if (_shader == null)
            {
                Debug.LogError("[ServiceSim] この描画方式に合うシェーダーが見つかりません。" +
                               "マテリアルを作らずに止めました。\n" +
                               ServiceSimRender.Describe() + "探した結果: " + how);
                return;
            }
            _materialsFixed = 0;

            if (!EditorSceneManager.SaveCurrentModifiedScenesIfUserWantsTo())
            {
                Debug.Log("[ServiceSim] Setup を取りやめました");
                return;
            }

            EnsureFolders();

            // 前の版の StoreLayout_Family.asset（格子型）は種類が変わったので、
            // Unity が見ないフォルダ（old~）へ寄せておく。消さない
            string moved = MoveStaleFamilyLayoutAside();

            Scene scene;
            if (File.Exists(ScenePath))
            {
                scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
            }
            else
            {
                scene = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single);
                EditorSceneManager.SaveScene(scene, ScenePath);
                scene = EditorSceneManager.OpenScene(ScenePath, OpenSceneMode.Single);
            }

            // 設定ファイル（数字の置き場所）
            BandStoreLayout family = LoadOrCreate<BandStoreLayout>(FamilyLayoutPath);
            LoadOrCreate<GridStoreLayout>(GridLayoutPath);
            RobotSpec robotSpec = LoadOrCreate<RobotSpec>(RobotSpecPath);
            PeopleSpec peopleSpec = LoadOrCreate<PeopleSpec>(PeopleSpecPath);
            ExperimentPlan plan = LoadOrCreate<ExperimentPlan>(ExperimentPlanPath);
            ServiceSpec serviceSpec = LoadOrCreate<ServiceSpec>(ServiceSpecPath);

            // 見た目。真上から見て、床・通路・卓・椅子・ベンチ・人・ロボットが見分けられる色にする
            Material matFloor = Mat("Mat_Floor", new Color(0.86f, 0.84f, 0.79f));        // 床：明るいクリーム
            Material matAisle = Mat("Mat_Aisle", new Color(0.52f, 0.58f, 0.67f));        // 通路：青灰色
            Material matWall = Mat("Mat_Wall", new Color(0.40f, 0.43f, 0.48f));          // 壁・厨房・事務所
            Material matBench = Mat("Mat_Bench", new Color(0.55f, 0.29f, 0.26f));        // ベンチ・仕切り：暗い赤茶
            Material matTable = Mat("Mat_Table", new Color(0.86f, 0.62f, 0.30f));        // 卓：明るい木の色
            Material matChair = Mat("Mat_Chair", new Color(0.13f, 0.37f, 0.42f));        // 椅子：濃い青緑
            Material matCounter = Mat("Mat_Counter", new Color(0.20f, 0.22f, 0.26f));    // カウンター類：ほぼ黒
            Material matRobot = Mat("Mat_Robot", new Color(0.96f, 0.96f, 0.97f));        // ロボット：白
            Material matRobotFront = Mat("Mat_RobotFront", new Color(1.00f, 0.33f, 0.04f)); // ロボットの前：橙
            Material matStaff = Mat("Mat_Staff", StaffColor);                           // スタッフ：青
            Material matGuest = Mat("Mat_Guest", GuestColor);                           // 歩く客：黄
            Material matWaiting = Mat("Mat_WaitingGuest", WaitingColor);                // 待合の客：紫
            // ベンチの見た目（当たりなし）。色と素材はここ1か所で変えられる
            Material matBenchSeat = Mat("Mat_BenchSeat", new Color(0.62f, 0.33f, 0.29f));  // 座面：赤茶（明るめ）
            Material matBenchBack = Mat("Mat_BenchBack", new Color(0.48f, 0.25f, 0.22f));  // 背もたれ：赤茶（暗め）
            // ロボットが通らない所（会計の列）の印。人は通れるので、当たりは持たない
            Material matKeepout = TransparentMat("Mat_RobotKeepout", new Color(0.95f, 0.35f, 0.35f, 0.35f));
            // 床の塗り分け（見た目だけ）。家具の色が透けて見える程度にして、光と影も乗せる。
            // 水色＝ロボットがふだん走る床／濃い水色＝細い通路（2026-10-23）
            Material matRobotFloor = TransparentMat("Mat_RobotFloor", new Color(0.45f, 0.78f, 0.95f, 0.35f));
            // 濃い水色＝細い通路（狭い道で入る）。前の「人だけが入れる床（薄い黄色）」から変えた
            Material matPeopleFloor = TransparentMat("Mat_PeopleFloor", new Color(0.13f, 0.45f, 0.72f, 0.55f));

            // 部品（prefab）。無ければ作る
            EnsureBoxPrefab(StorePrefabDir + "/Floor.prefab", "Floor", matFloor, true);
            EnsureBoxPrefab(StorePrefabDir + "/Wall.prefab", "Wall", matWall, true);
            EnsureBoxPrefab(StorePrefabDir + "/Bench.prefab", "Bench", matBench, true);
            EnsureBoxPrefab(StorePrefabDir + "/Table_Booth4.prefab", "Table_Booth4", matTable, true);
            EnsureBoxPrefab(StorePrefabDir + "/KitchenPass.prefab", "KitchenPass", matCounter, true);
            // 通路の色板。当たりは付けない（NavMesh に焼かれないようにする）
            EnsureBoxPrefab(StorePrefabDir + "/AisleMark.prefab", "AisleMark", matAisle, false);
            EnsureBoxPrefab(StorePrefabDir + "/KeepoutMark.prefab", "KeepoutMark", matKeepout, false);
            SetPrefabMaterial(StorePrefabDir + "/KeepoutMark.prefab", matKeepout);
            // ベンチの見た目（座面・背もたれ）。当たりは付けない
            // （NavMesh・前のセンサー・左右の空きの測りに効かないようにする）
            EnsureBoxPrefab(StorePrefabDir + "/BenchSeat.prefab", "BenchSeat", matBenchSeat, false);
            EnsureBoxPrefab(StorePrefabDir + "/BenchBack.prefab", "BenchBack", matBenchBack, false);
            // ベンチの当たりの箱。大きさ・位置・高さはベンチのまま、**見た目を持たない**
            EnsureColliderOnlyBoxPrefab(StorePrefabDir + "/BenchBody.prefab", "BenchBody");
            // 色を変えたときに追いつくよう、すでにある prefab のマテリアルも合わせ直す
            SetPrefabMaterial(StorePrefabDir + "/BenchSeat.prefab", matBenchSeat);
            SetPrefabMaterial(StorePrefabDir + "/BenchBack.prefab", matBenchBack);
            EnsureChairPrefab(StorePrefabDir + "/Chair.prefab", matChair);
            EnsureRobotPrefab(Root + "/Robot/ServingRobot.prefab", robotSpec, matRobot, matRobotFront);
            EnsureWalkerPrefab(Root + "/People/Staff.prefab", "Staff", peopleSpec, matStaff, WalkerKind.Staff);
            EnsureWalkerPrefab(Root + "/People/Guest.prefab", "Guest", peopleSpec, matGuest, WalkerKind.Guest);
            EnsureWalkerPrefab(Root + "/People/WaitingGuest.prefab", "WaitingGuest", peopleSpec, matWaiting, WalkerKind.Waiting);
            EnsureSeatedGuestPrefab(Root + "/People/SeatedGuest.prefab", peopleSpec);
            EnsureSeatedBodyPrefab(SeatedBodyPrefabPath, peopleSpec, matGuest);

            // すでにある人の prefab の見た目を、胴＋頭にそろえる（当たり判定などは触らない）
            EnsurePersonLook(peopleSpec);

            // 種類の印（Tag）。あとで映像から見分ける学習に使う。いまは付けるだけ
            int tagsAdded = EnsureTags();
            SetPrefabTag(Root + "/Robot/ServingRobot.prefab", SimTags.Robot);
            SetPrefabTag(Root + "/People/Staff.prefab", SimTags.Staff);
            SetPrefabTag(Root + "/People/Guest.prefab", SimTags.Guest);
            SetPrefabTag(Root + "/People/WaitingGuest.prefab", SimTags.Guest);
            SetPrefabTag(SeatedBodyPrefabPath, SimTags.SeatedGuest);
            SetPrefabTag(StorePrefabDir + "/Chair.prefab", SimTags.Chair);
            SetPrefabTag(StorePrefabDir + "/BenchSeat.prefab", SimTags.Bench);
            SetPrefabTag(StorePrefabDir + "/BenchBack.prefab", SimTags.Bench);
            SetPrefabTag(StorePrefabDir + "/BenchBody.prefab", SimTags.Bench);

            // すでに作ってある prefab も直す。
            //  ・NavMeshAgent は切る（置く前に有効だと「NavMesh に近くないので作れません」の警告が出る）
            //  ・通路の色板の当たりは外す（NavMesh に焼かれないようにする）
            int agentsDisabled = 0;
            agentsDisabled += FixPrefab(Root + "/Robot/ServingRobot.prefab", true, false) ? 1 : 0;
            agentsDisabled += FixPrefab(Root + "/People/Staff.prefab", true, false) ? 1 : 0;
            agentsDisabled += FixPrefab(Root + "/People/Guest.prefab", true, false) ? 1 : 0;
            agentsDisabled += FixPrefab(Root + "/People/WaitingGuest.prefab", true, false) ? 1 : 0;
            int collidersRemoved = FixPrefab(StorePrefabDir + "/AisleMark.prefab", false, true) ? 1 : 0;
            collidersRemoved += FixPrefab(StorePrefabDir + "/BenchSeat.prefab", false, true) ? 1 : 0;
            collidersRemoved += FixPrefab(Root + "/People/SeatedGuestBody.prefab", false, true) ? 1 : 0;

            // 人の種類（止まった原因を分けるのに使う）を、すでにある prefab にも入れ直す
            int kindsFixed = 0;
            kindsFixed += FixPersonKind(Root + "/People/Staff.prefab", PersonKind.Staff) ? 1 : 0;
            kindsFixed += FixPersonKind(Root + "/People/Guest.prefab", PersonKind.Guest) ? 1 : 0;
            kindsFixed += FixPersonKind(Root + "/People/WaitingGuest.prefab", PersonKind.Waiting) ? 1 : 0;
            kindsFixed += FixPersonKind(Root + "/People/SeatedGuest.prefab", PersonKind.Seated) ? 1 : 0;

            // 直したあとに読み直す（保存し直すと、前に取った参照が使えなくなるため）
            AssetDatabase.Refresh();
            GameObject floorPrefab = Load(StorePrefabDir + "/Floor.prefab");
            GameObject wallPrefab = Load(StorePrefabDir + "/Wall.prefab");
            GameObject benchPrefab = Load(StorePrefabDir + "/Bench.prefab");
            GameObject tablePrefab = Load(StorePrefabDir + "/Table_Booth4.prefab");
            GameObject passPrefab = Load(StorePrefabDir + "/KitchenPass.prefab");
            GameObject aisleMarkPrefab = Load(StorePrefabDir + "/AisleMark.prefab");
            GameObject benchSeatPrefab = Load(StorePrefabDir + "/BenchSeat.prefab");
            GameObject chairPrefab = Load(StorePrefabDir + "/Chair.prefab");
            GameObject robotPrefab = Load(Root + "/Robot/ServingRobot.prefab");
            GameObject staffPrefab = Load(Root + "/People/Staff.prefab");
            GameObject guestPrefab = Load(Root + "/People/Guest.prefab");
            GameObject waitingPrefab = Load(Root + "/People/WaitingGuest.prefab");
            GameObject seatedPrefab = Load(Root + "/People/SeatedGuest.prefab");
            GameObject seatedBodyPrefab = Load(SeatedBodyPrefabPath);

            if (floorPrefab == null || wallPrefab == null || benchPrefab == null || tablePrefab == null ||
                passPrefab == null || aisleMarkPrefab == null || benchSeatPrefab == null ||
                chairPrefab == null || robotPrefab == null ||
                staffPrefab == null || guestPrefab == null || waitingPrefab == null || seatedPrefab == null)
            {
                Debug.LogError("[ServiceSim] prefab を用意できませんでした。止めます");
                return;
            }

            // シーンの組み立て（置くだけ。中身は持たない）
            GameObject root = FindOrCreateRoot(scene, "ServiceSim");

            GameObject storeGo = FindOrCreateChild(root.transform, "Store");
            StoreBuilder builder = Ensure<StoreBuilder>(storeGo);
            if (builder.layout == null) builder.layout = family;
            builder.floorPrefab = floorPrefab;
            builder.wallPrefab = wallPrefab;
            builder.benchPrefab = benchPrefab;
            builder.benchSeatPrefab = benchSeatPrefab;
            builder.benchBackPrefab = Load(StorePrefabDir + "/BenchBack.prefab");
            builder.benchBodyPrefab = Load(StorePrefabDir + "/BenchBody.prefab");
            builder.tablePrefab = tablePrefab;
            builder.chairPrefab = chairPrefab;
            builder.kitchenPassPrefab = passPrefab;
            builder.aisleMarkPrefab = aisleMarkPrefab;
            builder.keepoutMarkPrefab = Load(StorePrefabDir + "/KeepoutMark.prefab");
            builder.robotFloorMaterial = matRobotFloor;
            builder.peopleFloorMaterial = matPeopleFloor;

            NavMeshBaker baker = Ensure<NavMeshBaker>(storeGo);
            baker.layout = builder.layout;
            baker.bakeRoot = storeGo.transform;
            baker.store = builder;
            baker.peopleRadius = peopleSpec.personRadius;   // 人用の NavMesh を焼く半径
            baker.registerBackArea = peopleSpec.registerBackArea;   // 店員側の区画（客は入れない）

            GameObject peopleGo = FindOrCreateChild(root.transform, "People");
            PeopleSpawner spawner = Ensure<PeopleSpawner>(peopleGo);
            spawner.spec = peopleSpec;
            spawner.store = builder;
            spawner.staffPrefab = staffPrefab;
            spawner.seatedGuestPrefab = seatedPrefab;
            spawner.seatedBodyPrefab = seatedBodyPrefab;
            spawner.service = serviceSpec;

            // キッチンのモニター（2026-10-23）。吹き出しを出す文を作るだけで、
            // 見た目は持たない（描くのは ServiceSimLabels）
            GameObject monitorGo = FindOrCreateChild(root.transform, KitchenMonitor.RootName);
            KitchenMonitor monitor = Ensure<KitchenMonitor>(monitorGo);
            monitor.store = builder;
            monitor.show = plan.showKitchenMonitor;
            monitor.RemoveOldBoards();   // 前の作り（屋根の上の板）を片付ける

            // 録画のときに出す画面（依頼 v13 の 依頼2。2026-10-26）。
            // 出す・1倍にするの入切は、この部品の Inspector で切り替える。
            // 初期値はどちらも切なので、測る回には効かない（Setup でも触らない）
            GameObject hudGo = FindOrCreateChild(root.transform, SimHud.RootName);
            SimHud hud = Ensure<SimHud>(hudGo);

            GameObject robotsGo = FindOrCreateChild(root.transform, "Robots");
            robotsGo.transform.position = Vector3.zero;

            // 店の回し方（卓の空き・料理の待ち行列・仕事板）と、1組ごとの流れ
            GameObject svcGo = FindOrCreateChild(root.transform, "Service");
            ServiceOps opsC = Ensure<ServiceOps>(svcGo);
            opsC.spec = serviceSpec;
            opsC.robotSpec = robotSpec;
            opsC.store = builder;

            GroupManager groupsC = Ensure<GroupManager>(svcGo);
            groupsC.spec = serviceSpec;
            groupsC.people = peopleSpec;
            groupsC.ops = opsC;
            groupsC.store = builder;
            groupsC.guestPrefab = guestPrefab;
            groupsC.waitingGuestPrefab = waitingPrefab;
            groupsC.bodyRoot = svcGo.transform;

            JamWatcher jamsC = Ensure<JamWatcher>(svcGo);
            jamsC.spec = peopleSpec;
            jamsC.ops = opsC;
            jamsC.store = builder;

            spawner.ops = opsC;

            GameObject expGo = FindOrCreateChild(root.transform, "Experiment");
            ExperimentRunner runner = Ensure<ExperimentRunner>(expGo);
            runner.plan = plan;
            runner.service = serviceSpec;
            // サイトの試算の値（v5 3章）。無くてもこれまでの回し方はできる
            runner.simParams = AssetDatabase.LoadAssetAtPath<SimParams>(SimParamsPath);
            runner.ops = opsC;
            runner.groups = groupsC;
            runner.jams = jamsC;
            runner.store = builder;
            runner.baker = baker;
            runner.people = spawner;
            runner.robotSpec = robotSpec;
            runner.robotPrefab = robotPrefab;
            runner.robotsRoot = robotsGo.transform;
            monitor.ops = opsC;
            hud.ops = opsC;                 // 店の時計を読む先（依頼 v13 の 依頼2）

            // 画面なしの実行ファイルで1回を回す部品（依頼 v12 の 2 章・2026-10-26）。
            // 引数 -ssCondition が無いときは何もしないので、
            // エディタで手で Play しても、段階Bの Run All にも効かない
            GameObject headGo = FindOrCreateChild(root.transform, HeadlessRun.RootName);
            HeadlessRun head = Ensure<HeadlessRun>(headGo);
            head.runner = runner;
            head.plan = plan;
            head.simParams = AssetDatabase.LoadAssetAtPath<SimParams>(SimParamsPath);
            head.store = builder;
            head.baker = baker;
            head.robotSpec = robotSpec;
            head.people = spawner;
            head.monitor = monitor;
            head.layouts = new JsonStoreLayout[]
            {
                AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(P1LayoutPath),
                AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(P5LayoutPath),
            };
            monitor.groups = groupsC;
            monitor.people = spawner;
            runner.runOnPlay = false;      // メニューから回すときだけ立てる
            EditorUtility.SetDirty(runner);

            EnsureLighting(root.transform);
            EnsureCameras(root.transform);

            EditorUtility.SetDirty(builder);
            EditorUtility.SetDirty(baker);
            EditorUtility.SetDirty(spawner);
            EditorUtility.SetDirty(opsC);
            EditorUtility.SetDirty(groupsC);
            EditorUtility.SetDirty(jamsC);
            EditorSceneManager.MarkSceneDirty(scene);
            EditorSceneManager.SaveScene(scene, ScenePath);

            // .asset に書かれていない項目を、いまの初期値で書きそろえる。
            // すでに書かれている値は、読み込んだときにそのまま入っているので上書きされない。
            string added = FillMissingAssetFields(new ScriptableObject[]
            {
                family, robotSpec, peopleSpec, plan, serviceSpec,
                AssetDatabase.LoadAssetAtPath<StoreLayout>(GridLayoutPath)
            });

            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

            Debug.Log("[ServiceSim] Setup おわり。" +
                      "\n  .asset に書き足した項目: " + (added.Length > 0 ? added : "なし") +
                      "\n  描画方式: " + (CurrentPipeline() == null ? "Built-in" : CurrentPipeline().GetType().Name) +
                      "\n  使うシェーダー: " + _shader.name + "（" + how + "）" +
                      "\n  シェーダーを直したマテリアル: " + _materialsFixed + " 個" +
                      "\n  NavMeshAgent を切った prefab: " + agentsDisabled + " 個" +
                      "\n  当たりを外した通路板: " + collidersRemoved + " 個" +
                      "\n  人の種類を直した prefab: " + kindsFixed + " 個" +
                      (moved.Length > 0 ? "\n  前の版の店の設定を寄せました: " + moved : "") +
                      "\n  いまの店の形: " + builder.layout.name + "（" + builder.layout.StoreKindName + "）" +
                      "\n  店の設定: " + FamilyLayoutPath + " / " + GridLayoutPath +
                      "\n  シーン: " + ScenePath + "（run_name=" + plan.runName + "）" +
                      "\n  ビルド設定（EditorBuildSettings）: " + BuildSettingsNote() +
                      "\n  種類の印（Tag）: " + SimTags.All.Length + " 種（新しく作った " + tagsAdded + " 種）");
        }

        /// <summary>
        /// ビルド設定にこのシーンが入っているかを、人が読める形で返す（依頼 v11 の 2）。
        /// 入っていないと、画面なしの実行ファイル（v12）が開くシーンが無くて回せない。
        /// Setup はここを書き換えない（読んで知らせるだけ）
        /// </summary>
        static string BuildSettingsNote()
        {
            EditorBuildSettingsScene[] scenes = EditorBuildSettings.scenes;
            for (int i = 0; i < scenes.Length; i++)
            {
                if (scenes[i] == null || scenes[i].path != ScenePath) continue;
                return scenes[i].enabled
                    ? "このシーンが入っています（" + (i + 1) + " / " + scenes.Length + " 番め・有効）"
                    : "このシーンは入っているが無効です。Build Settings で ✓ を付けてください";
            }
            return "このシーンが入っていません（" + scenes.Length + " 件）。" +
                   "画面なしの実行ファイルを作る前に、File ▸ Build Settings で " +
                   ScenePath + " を足してください";
        }

        // ---------------- .asset の項目を書きそろえる ----------------

        /// <summary>
        /// .asset に書かれていない項目を、いまの初期値で書き出す。
        /// Unity は .asset を読むとき、書かれていない項目に C# の初期値を入れる。
        /// そのまま SetDirty して保存すると、読み込んだ値はそのまま・足りない項目だけが増える。
        ///
        /// これをしないと、設計書の「数字はすべて .asset に置く」が守られず、
        /// 実際の値が .cs の初期値になってしまう。
        /// 何を書き足したかを返す（報告に出す）。
        /// </summary>
        static string FillMissingAssetFields(ScriptableObject[] assets)
        {
            List<string> added = new List<string>();

            for (int i = 0; i < assets.Length; i++)
            {
                ScriptableObject a = assets[i];
                if (a == null) continue;

                string path = AssetDatabase.GetAssetPath(a);
                if (string.IsNullOrEmpty(path)) continue;

                HashSet<string> before = TopLevelKeys(path);
                EditorUtility.SetDirty(a);
                AssetDatabase.SaveAssets();

                HashSet<string> after = TopLevelKeys(path);
                foreach (string k in after)
                    if (!before.Contains(k)) added.Add(a.name + "." + k);
            }

            if (added.Count == 0) return "";
            added.Sort();
            return string.Join(", ", added.ToArray());
        }

        /// <summary>.asset（YAML）の、いちばん外側の項目の名前を集める</summary>
        static HashSet<string> TopLevelKeys(string path)
        {
            HashSet<string> keys = new HashSet<string>();
            if (!File.Exists(path)) return keys;

            string[] lines = File.ReadAllLines(path);
            for (int i = 0; i < lines.Length; i++)
            {
                string line = lines[i];
                // MonoBehaviour の中身は半角スペース2つ下げ。さらに下げた行は中の要素なので見ない
                if (!line.StartsWith("  ") || line.StartsWith("   ")) continue;
                int colon = line.IndexOf(':');
                if (colon <= 2) continue;
                string name = line.Substring(2, colon - 2).Trim();
                if (name.Length == 0 || name.StartsWith("-")) continue;
                keys.Add(name);
            }
            return keys;
        }

        static UnityEngine.Rendering.RenderPipelineAsset CurrentPipeline()
        {
            return UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline;
        }

        // ---------------- 前の版の .asset を寄せる ----------------

        /// <summary>
        /// StoreLayout_Family.asset が前の版（格子型）のままなら、Unity が見ないフォルダへ寄せる。
        /// 消さない。寄せたら、そのパスを返す。
        /// </summary>
        static string MoveStaleFamilyLayoutAside()
        {
            if (!File.Exists(FamilyLayoutPath)) return "";
            if (AssetDatabase.LoadAssetAtPath<BandStoreLayout>(FamilyLayoutPath) != null) return "";

            try
            {
                Directory.CreateDirectory(OldDir);
                string dst = OldDir + "/StoreLayout_Family_v0_grid.asset";
                int n = 1;
                while (File.Exists(dst))
                {
                    dst = OldDir + "/StoreLayout_Family_v0_grid_" + n.ToString() + ".asset";
                    n++;
                }
                File.Move(FamilyLayoutPath, dst);
                if (File.Exists(FamilyLayoutPath + ".meta")) File.Move(FamilyLayoutPath + ".meta", dst + ".meta");
                AssetDatabase.Refresh();
                return dst;
            }
            catch (Exception e)
            {
                Debug.LogError("[ServiceSim] 前の版の " + FamilyLayoutPath + " を寄せられませんでした。" +
                               "手で消すか動かしてから、もう一度 Setup を実行してください\n" + e.Message);
                return "";
            }
        }

        // ---------------- フォルダ ----------------

        static void EnsureFolders()
        {
            EnsureFolder("Assets", "ServiceSim");
            EnsureFolder(Root, "Scenes");
            EnsureFolder(Root, "Store");
            EnsureFolder(Root + "/Store", "Prefabs");
            EnsureFolder(Root + "/Store", "Materials");
            EnsureFolder(Root, "Robot");
            EnsureFolder(Root, "People");
            EnsureFolder(Root, "Experiment");
            EnsureFolder(Root, "Service");
            EnsureFolder(Root, "Recording");
        }

        static void EnsureFolder(string parent, string name)
        {
            string path = parent + "/" + name;
            if (!AssetDatabase.IsValidFolder(path)) AssetDatabase.CreateFolder(parent, name);
        }

        // ---------------- 設定ファイル ----------------

        static T LoadOrCreate<T>(string path) where T : ScriptableObject
        {
            T asset = AssetDatabase.LoadAssetAtPath<T>(path);
            if (asset != null) return asset;
            asset = ScriptableObject.CreateInstance<T>();
            AssetDatabase.CreateAsset(asset, path);
            return asset;
        }

        // ---------------- 見た目 ----------------

        /// <summary>
        /// マテリアルを用意する。新しく作るときは色も付ける。
        /// すでにあるときはシェーダーだけ確かめて、合っていなければ直し、
        /// そのときだけ色も付け直す（手で変えた色を毎回消さないため）。
        /// </summary>
        static Material Mat(string name, Color color)
        {
            string path = MatDir + "/" + name + ".mat";
            Material mat = AssetDatabase.LoadAssetAtPath<Material>(path);

            if (mat == null)
            {
                mat = new Material(_shader);
                ServiceSimRender.ApplyLook(mat, color);
                AssetDatabase.CreateAsset(mat, path);
                return mat;
            }

            bool wrong = mat.shader == null
                         || mat.shader != _shader
                         || mat.shader.name == ServiceSimRender.ErrorShaderName
                         || !mat.shader.isSupported;
            if (wrong)
            {
                mat.shader = _shader;
                ServiceSimRender.ApplyLook(mat, color);
                EditorUtility.SetDirty(mat);
                _materialsFixed++;
            }
            return mat;
        }

        // ---------------- 部品 ----------------

        /// <summary>
        /// 透けて光が乗るマテリアルを作る（床の塗り分け用）。
        /// すでにあればシェーダーと色だけ合わせ直す（止まっているときと再生中で同じ見た目にする）。
        /// </summary>
        static Material TransparentMat(string name, Color color)
        {
            string how;
            Shader sh = ServiceSimRender.FindTransparentLitShader(out how);
            string path = MatDir + "/" + name + ".mat";
            Material mat = AssetDatabase.LoadAssetAtPath<Material>(path);

            if (mat == null)
            {
                mat = new Material(sh != null ? sh : _shader);
                AssetDatabase.CreateAsset(mat, path);
            }
            if (sh != null && mat.shader != sh) { mat.shader = sh; _materialsFixed++; }

            mat.color = color;
            if (mat.HasProperty("_BaseColor")) mat.SetColor("_BaseColor", color);

            // Standard しか無いときは、透過の設定も入れる
            if (mat.shader != null && mat.shader.name == "Standard")
            {
                mat.SetFloat("_Mode", 3f);
                mat.SetOverrideTag("RenderType", "Transparent");
                mat.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
                mat.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
                mat.SetInt("_ZWrite", 0);
                mat.DisableKeyword("_ALPHATEST_ON");
                mat.EnableKeyword("_ALPHABLEND_ON");
                mat.renderQueue = 3000;
            }
            EditorUtility.SetDirty(mat);
            return mat;
        }

        static void EnsureBoxPrefab(string path, string name, Material mat, bool withCollider)
        {
            if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null) return;

            GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube);
            go.name = name;
            SetMaterial(go, mat);
            if (!withCollider)
            {
                Collider col = go.GetComponent<Collider>();
                if (col != null) UnityEngine.Object.DestroyImmediate(col);
            }
            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        /// <summary>
        /// 当たりだけの箱（見た目を持たない）。ベンチの当たりの箱に使う。
        /// 大きさ・位置・高さは今までのベンチと同じなので、NavMesh も前のセンサーも変わらない。
        /// </summary>
        static void EnsureColliderOnlyBoxPrefab(string path, string name)
        {
            if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null) return;

            GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube);
            go.name = name;
            // 見た目だけ外す（当たりは残す）
            MeshRenderer mr = go.GetComponent<MeshRenderer>();
            if (mr != null) UnityEngine.Object.DestroyImmediate(mr);
            MeshFilter mf = go.GetComponent<MeshFilter>();
            if (mf != null) UnityEngine.Object.DestroyImmediate(mf);

            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        /// <summary>prefab のマテリアルを合わせ直す（色を1か所で変えられるように）</summary>
        static void SetPrefabMaterial(string path, Material mat)
        {
            if (mat == null || !File.Exists(path)) return;

            GameObject contents = PrefabUtility.LoadPrefabContents(path);
            if (contents == null) return;
            try
            {
                bool changed = false;
                MeshRenderer[] rs = contents.GetComponentsInChildren<MeshRenderer>(true);
                for (int i = 0; i < rs.Length; i++)
                {
                    if (rs[i].sharedMaterial == mat) continue;
                    rs[i].sharedMaterial = mat;
                    changed = true;
                }
                if (changed) PrefabUtility.SaveAsPrefabAsset(contents, path);
            }
            finally { PrefabUtility.UnloadPrefabContents(contents); }
        }

        /// <summary>prefab に種類の印（Tag）を付ける</summary>
        static void SetPrefabTag(string path, string tag)
        {
            if (string.IsNullOrEmpty(tag) || !File.Exists(path)) return;

            GameObject contents = PrefabUtility.LoadPrefabContents(path);
            if (contents == null) return;
            try
            {
                if (contents.tag == tag) return;
                contents.tag = tag;
                PrefabUtility.SaveAsPrefabAsset(contents, path);
            }
            catch (UnityException)
            {
                Debug.LogWarning("[ServiceSim] Tag \"" + tag + "\" を付けられませんでした: " + path);
            }
            finally { PrefabUtility.UnloadPrefabContents(contents); }
        }

        /// <summary>
        /// 種類の印（Tag）を ProjectSettings/TagManager.asset に足す。
        /// Tag は物理にも NavMesh にも効かないので、測る数字は変わらない。
        /// **Assets/ServiceSim の外に出るのはここだけ**（LABEL_RULES.md に書いてある）。
        /// </summary>
        static int EnsureTags()
        {
            int added = 0;
            for (int i = 0; i < SimTags.All.Length; i++)
            {
                string tag = SimTags.All[i];
                if (System.Array.IndexOf(UnityEditorInternal.InternalEditorUtility.tags, tag) >= 0) continue;
                UnityEditorInternal.InternalEditorUtility.AddTag(tag);
                added++;
            }
            return added;
        }

        static void EnsureChairPrefab(string path, Material mat)
        {
            if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null) return;

            GameObject go = GameObject.CreatePrimitive(PrimitiveType.Cube);
            go.name = "Chair";
            SetMaterial(go, mat);

            NavMeshObstacle obstacle = go.AddComponent<NavMeshObstacle>();
            obstacle.shape = NavMeshObstacleShape.Box;
            obstacle.center = Vector3.zero;
            obstacle.size = Vector3.one;
            obstacle.carving = true;
            obstacle.carveOnlyStationary = false;
            obstacle.enabled = false;        // 引かれているあいだだけ有効にする

            go.AddComponent<Chair>();

            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        static void EnsureRobotPrefab(string path, RobotSpec spec, Material body, Material front)
        {
            if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null) return;

            float r = spec.Radius;
            float h = spec.height;

            GameObject go = new GameObject("ServingRobot");

            GameObject bodyGo = GameObject.CreatePrimitive(PrimitiveType.Cylinder);
            bodyGo.name = "Body";
            bodyGo.transform.SetParent(go.transform, false);
            bodyGo.transform.localPosition = new Vector3(0f, h * 0.5f, 0f);
            bodyGo.transform.localScale = new Vector3(spec.diameter, h * 0.5f, spec.diameter);
            UnityEngine.Object.DestroyImmediate(bodyGo.GetComponent<Collider>());
            SetMaterial(bodyGo, body);

            GameObject frontGo = GameObject.CreatePrimitive(PrimitiveType.Cube);
            frontGo.name = "FrontMark";
            frontGo.transform.SetParent(go.transform, false);
            frontGo.transform.localPosition = new Vector3(0f, h * 0.86f, r * 0.9f);
            frontGo.transform.localScale = new Vector3(spec.diameter * 0.7f, 0.08f, 0.08f);
            UnityEngine.Object.DestroyImmediate(frontGo.GetComponent<Collider>());
            SetMaterial(frontGo, front);

            CapsuleCollider col = go.AddComponent<CapsuleCollider>();
            col.radius = r;
            col.height = h;
            col.center = new Vector3(0f, h * 0.5f, 0f);

            NavMeshAgent agent = go.AddComponent<NavMeshAgent>();
            agent.radius = r;
            agent.height = h;
            agent.speed = spec.maxSpeed;
            agent.acceleration = spec.acceleration;
            agent.angularSpeed = spec.turnSpeedDegPerSec;
            agent.obstacleAvoidanceType = ObstacleAvoidanceType.NoObstacleAvoidance;
            agent.avoidancePriority = spec.avoidancePriority;
            agent.autoRepath = false;
            agent.updatePosition = false;
            agent.updateRotation = false;
            agent.enabled = false;   // 置いたあとに有効にする（警告を出さないため）

            ServingRobotController controller = go.AddComponent<ServingRobotController>();
            FrontSensor sensor = go.AddComponent<FrontSensor>();
            sensor.spec = spec;
            sensor.owner = controller;
            controller.spec = spec;
            controller.sensor = sensor;

            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        /// <summary>
        /// 人の見た目（胴＋頭）を作る。立っている人も座っている客も同じ作りにする。
        /// 当たり判定は付けない（親が持っているものには触れない）。
        ///   bottomY … 足もと（立っている人は 0、座っている客は座面の高さ）
        ///   topY    … 頭の先（立っている人は背の高さ、座っている客は 1.35 m）
        /// すでにある "Body" と "Head" は作り直す。ほかの子どもと親の部品には触れない。
        /// </summary>
        static void BuildPersonLook(GameObject root, float radius, float bottomY, float topY, Material mat)
        {
            if (root == null) return;

            for (int i = root.transform.childCount - 1; i >= 0; i--)
            {
                Transform c = root.transform.GetChild(i);
                if (c.name == "Body" || c.name == "Head")
                    UnityEngine.Object.DestroyImmediate(c.gameObject);
            }

            float span = Mathf.Max(0.2f, topY - bottomY);
            float headR = Mathf.Min(0.11f, span * 0.25f);
            float headCenter = topY - headR;
            float bodyH = Mathf.Max(0.1f, headCenter - bottomY);   // 頭は胴に少し食い込ませる

            GameObject body = GameObject.CreatePrimitive(PrimitiveType.Capsule);
            body.name = "Body";
            body.transform.SetParent(root.transform, false);
            body.transform.localPosition = new Vector3(0f, bottomY + bodyH * 0.5f, 0f);
            body.transform.localScale = new Vector3(radius * 2f, bodyH * 0.5f, radius * 2f);
            UnityEngine.Object.DestroyImmediate(body.GetComponent<Collider>());
            SetMaterial(body, mat);

            GameObject head = GameObject.CreatePrimitive(PrimitiveType.Sphere);
            head.name = "Head";
            head.transform.SetParent(root.transform, false);
            head.transform.localPosition = new Vector3(0f, headCenter, 0f);
            head.transform.localScale = new Vector3(headR * 2f, headR * 2f, headR * 2f);
            UnityEngine.Object.DestroyImmediate(head.GetComponent<Collider>());
            SetMaterial(head, mat);
        }

        /// <summary>
        /// すでにある人の prefab の **見た目だけ** を胴＋頭に取り替える。
        /// 当たり判定・NavMeshAgent・PersonBody・よける処理・Tag には触れない。
        /// もう胴＋頭になっていれば何もしない。
        /// </summary>
        static bool FixWalkerLook(string path, PeopleSpec spec, Material mat)
        {
            if (!File.Exists(path) || spec == null || mat == null) return false;

            GameObject contents = PrefabUtility.LoadPrefabContents(path);
            if (contents == null) return false;
            try
            {
                float r = spec.personRadius;
                float h = spec.personHeight;

                Transform head = contents.transform.Find("Head");
                if (head != null &&
                    Mathf.Abs(head.localPosition.y + head.localScale.y * 0.5f - h) < 0.001f)
                    return false;                       // もう同じ形

                BuildPersonLook(contents, r, 0f, h, mat);
                PrefabUtility.SaveAsPrefabAsset(contents, path);
                return true;
            }
            finally { PrefabUtility.UnloadPrefabContents(contents); }
        }

        static void EnsureWalkerPrefab(string path, string name, PeopleSpec spec, Material mat, WalkerKind kind)
        {
            if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null) return;

            float r = spec.personRadius;
            float h = spec.personHeight;

            GameObject go = new GameObject(name);

            // 見た目は胴＋頭（座っている客と同じ作り）。当たり判定はこの下で別に付ける
            BuildPersonLook(go, r, 0f, h, mat);

            CapsuleCollider col = go.AddComponent<CapsuleCollider>();
            col.radius = r;
            col.height = h;
            col.center = new Vector3(0f, h * 0.5f, 0f);

            NavMeshAgent agent = go.AddComponent<NavMeshAgent>();
            agent.radius = r;
            agent.height = h;
            agent.speed = kind == WalkerKind.Staff ? spec.staffSpeed : spec.guestSpeed;
            agent.acceleration = spec.acceleration;
            agent.angularSpeed = spec.angularSpeedDegPerSec;
            agent.stoppingDistance = spec.stoppingDistance;
            agent.obstacleAvoidanceType = spec.avoidanceQuality;
            agent.avoidancePriority = spec.avoidancePriority;
            agent.enabled = false;   // 置いたあとに有効にする（警告を出さないため）

            PersonBody personBody = go.AddComponent<PersonBody>();
            personBody.radius = r;
            personBody.walksInAisle = kind != WalkerKind.Waiting;   // 待合に立つ客は通路を歩かない
            personBody.kind = kind == WalkerKind.Staff ? PersonKind.Staff
                            : kind == WalkerKind.Waiting ? PersonKind.Waiting
                            : PersonKind.Guest;

            if (kind == WalkerKind.Staff) { StaffAgent a = go.AddComponent<StaffAgent>(); a.spec = spec; }
            else if (kind == WalkerKind.Guest) { GuestAgent a = go.AddComponent<GuestAgent>(); a.spec = spec; }
            else { WaitingGuest a = go.AddComponent<WaitingGuest>(); a.spec = spec; }

            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        /// <summary>
        /// 座っている客の形（見た目だけ）。立っている客と同じ黄色で、背を低くする。
        /// 当たり判定・PersonBody・NavMeshAgent は付けない
        /// （NavMesh にも、前のセンサーにも、人の数の記録にも効かないようにする）。
        /// </summary>
        /// <summary>
        /// 立っている人（スタッフ・歩く客・待合の客）の見た目を、胴＋頭にそろえる。
        /// Setup を押していなくても Build Store だけで揃うようにするための出口。
        /// 取り替えるのは見た目（Renderer）だけで、
        /// 当たり判定・NavMeshAgent・PersonBody・よける処理・Tag には触れない。
        /// 作り直した prefab の数を返す。
        /// </summary>
        public static int EnsurePersonLook(PeopleSpec spec)
        {
            if (spec == null) return 0;
            if (_shader == null)
            {
                string how;
                _shader = ServiceSimRender.FindOpaqueShader(out how);
                if (_shader == null) return 0;
            }

            int fixedCount = 0;
            fixedCount += FixWalkerLook(Root + "/People/Staff.prefab",
                                        spec, Mat("Mat_Staff", StaffColor)) ? 1 : 0;
            fixedCount += FixWalkerLook(Root + "/People/Guest.prefab",
                                        spec, Mat("Mat_Guest", GuestColor)) ? 1 : 0;
            fixedCount += FixWalkerLook(Root + "/People/WaitingGuest.prefab",
                                        spec, Mat("Mat_WaitingGuest", WaitingColor)) ? 1 : 0;
            if (fixedCount > 0)
                Debug.Log("[ServiceSim] 立っている人の見た目を胴＋頭にそろえました（" + fixedCount +
                          " 体ぶん・背の高さ " + spec.personHeight.ToString("0.##") + " m）。" +
                          "当たり判定・NavMeshAgent・PersonBody は触っていません");
            return fixedCount;
        }

        /// <summary>
        /// ベンチの見た目（座面・背もたれ）と、当たりだけの箱を用意して StoreBuilder に入れる。
        /// Setup を押していなくても Build Store だけで揃うようにするための出口（v5）。
        /// 当たりの箱の大きさ・位置・高さは今までのベンチと同じなので、
        /// NavMesh も前のセンサーも、通れる床も卓までの道のりも変わらない。
        /// </summary>
        public static bool EnsureBenchLook(StoreBuilder builder)
        {
            if (builder == null) return false;
            if (_shader == null)
            {
                string how;
                _shader = ServiceSimRender.FindOpaqueShader(out how);
                if (_shader == null)
                {
                    Debug.LogError("[ServiceSim] 描画方式に合うシェーダーが見つかりません。" +
                                   "ベンチの見た目は作りませんでした（探した結果: " + how + "）");
                    return false;
                }
            }

            EnsureTags();

            Material seatMat = Mat("Mat_BenchSeat", new Color(0.62f, 0.33f, 0.29f));
            Material backMat = Mat("Mat_BenchBack", new Color(0.48f, 0.25f, 0.22f));
            EnsureBoxPrefab(StorePrefabDir + "/BenchSeat.prefab", "BenchSeat", seatMat, false);
            EnsureBoxPrefab(StorePrefabDir + "/BenchBack.prefab", "BenchBack", backMat, false);
            EnsureColliderOnlyBoxPrefab(StorePrefabDir + "/BenchBody.prefab", "BenchBody");
            SetPrefabMaterial(StorePrefabDir + "/BenchSeat.prefab", seatMat);
            SetPrefabMaterial(StorePrefabDir + "/BenchBack.prefab", backMat);
            SetPrefabTag(StorePrefabDir + "/BenchSeat.prefab", SimTags.Bench);
            SetPrefabTag(StorePrefabDir + "/BenchBack.prefab", SimTags.Bench);
            SetPrefabTag(StorePrefabDir + "/BenchBody.prefab", SimTags.Bench);
            SetPrefabTag(StorePrefabDir + "/Chair.prefab", SimTags.Chair);
            SetPrefabTag(Root + "/Robot/ServingRobot.prefab", SimTags.Robot);
            SetPrefabTag(Root + "/People/Staff.prefab", SimTags.Staff);
            SetPrefabTag(Root + "/People/Guest.prefab", SimTags.Guest);
            SetPrefabTag(Root + "/People/WaitingGuest.prefab", SimTags.Guest);
            SetPrefabTag(SeatedBodyPrefabPath, SimTags.SeatedGuest);

            // ロボットが通らない所（会計の列）の印。当たりなし（人は通れる）
            Material keepMat = TransparentMat("Mat_RobotKeepout", new Color(0.95f, 0.35f, 0.35f, 0.35f));
            EnsureBoxPrefab(StorePrefabDir + "/KeepoutMark.prefab", "KeepoutMark", keepMat, false);
            SetPrefabMaterial(StorePrefabDir + "/KeepoutMark.prefab", keepMat);

            bool changed = false;
            changed |= Assign(ref builder.keepoutMarkPrefab, StorePrefabDir + "/KeepoutMark.prefab");
            changed |= Assign(ref builder.benchSeatPrefab, StorePrefabDir + "/BenchSeat.prefab");
            changed |= Assign(ref builder.benchBackPrefab, StorePrefabDir + "/BenchBack.prefab");
            changed |= Assign(ref builder.benchBodyPrefab, StorePrefabDir + "/BenchBody.prefab");
            if (changed) EditorUtility.SetDirty(builder);
            return true;
        }

        static bool Assign(ref GameObject field, string path)
        {
            GameObject want = AssetDatabase.LoadAssetAtPath<GameObject>(path);
            if (want == null || field == want) return false;
            field = want;
            return true;
        }

        /// <summary>
        /// 座っている客の形を用意して返す（Build Store からも呼ぶ）。
        /// 10/21 の分では、この prefab を作る前から参照だけしていたので、席に何も出ていなかった。
        /// </summary>
        public static GameObject EnsureSeatedBodyPrefab(PeopleSpec spec)
        {
            if (spec == null) return null;
            if (_shader == null)
            {
                string how;
                _shader = ServiceSimRender.FindOpaqueShader(out how);
                if (_shader == null)
                {
                    Debug.LogError("[ServiceSim] 描画方式に合うシェーダーが見つかりません。" +
                                   "座っている客の形は作りませんでした（探した結果: " + how + "）");
                    return null;
                }
            }
            EnsureSeatedBodyPrefab(SeatedBodyPrefabPath, spec, Mat("Mat_Guest", GuestColor));
            return AssetDatabase.LoadAssetAtPath<GameObject>(SeatedBodyPrefabPath);
        }

        static void EnsureSeatedBodyPrefab(string path, PeopleSpec spec, Material mat)
        {
            float r = spec.personRadius;
            float h = spec.seatedBodyHeight;

            // すでにあっても、高さを変えたら作り直す（PeopleSpec.seatedBodyHeight が正）
            GameObject had = AssetDatabase.LoadAssetAtPath<GameObject>(path);
            if (had != null)
            {
                // 胴と頭の2つになっていて、頭の先が h と合っていれば、そのまま使う
                Transform head0 = had.transform.Find("Head");
                bool sameShape = had.transform.childCount == 2 && head0 != null &&
                                 Mathf.Abs(head0.localPosition.y + head0.localScale.y * 0.5f - h) < 0.001f;
                if (sameShape) return;
                AssetDatabase.DeleteAsset(path);
            }

            // 座面の高さから立ち上がる胴と、その上の頭。h は床から頭の先までの高さ
            float seat = Mathf.Clamp(spec.seatedSeatHeight, 0f, h - 0.3f);

            GameObject go = new GameObject("SeatedGuestBody");
            BuildPersonLook(go, r, seat, h, mat);

            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        static void EnsureSeatedGuestPrefab(string path, PeopleSpec spec)
        {
            if (AssetDatabase.LoadAssetAtPath<GameObject>(path) != null) return;

            // 座っている客は、椅子を引く仕掛けだけ。見た目は持たない（計測に効かないため）
            GameObject go = new GameObject("SeatedGuest");

            PersonBody body = go.AddComponent<PersonBody>();
            body.radius = spec.personRadius;
            body.walksInAisle = false;
            body.kind = PersonKind.Seated;

            SeatedGuest seated = go.AddComponent<SeatedGuest>();
            seated.spec = spec;

            PrefabUtility.SaveAsPrefabAsset(go, path);
            UnityEngine.Object.DestroyImmediate(go);
        }

        /// <summary>
        /// すでにある prefab を直す（NavMeshAgent を切る／当たりを外す）。直したら true。
        /// 中身を開いて保存し直す公式の手順を使う。
        /// </summary>
        static bool FixPrefab(string path, bool disableAgent, bool removeCollider)
        {
            if (!File.Exists(path)) return false;

            GameObject contents = PrefabUtility.LoadPrefabContents(path);
            if (contents == null) return false;

            bool changed = false;
            try
            {
                if (disableAgent)
                {
                    NavMeshAgent agent = contents.GetComponent<NavMeshAgent>();
                    if (agent != null && agent.enabled) { agent.enabled = false; changed = true; }
                }
                if (removeCollider)
                {
                    Collider[] cols = contents.GetComponentsInChildren<Collider>(true);
                    for (int i = 0; i < cols.Length; i++)
                    {
                        UnityEngine.Object.DestroyImmediate(cols[i]);
                        changed = true;
                    }
                }
                if (changed) PrefabUtility.SaveAsPrefabAsset(contents, path);
            }
            finally
            {
                PrefabUtility.UnloadPrefabContents(contents);
            }
            return changed;
        }

        /// <summary>すでにある人の prefab に、人の種類を入れ直す。直したら true</summary>
        static bool FixPersonKind(string path, PersonKind kind)
        {
            if (!File.Exists(path)) return false;

            GameObject contents = PrefabUtility.LoadPrefabContents(path);
            if (contents == null) return false;

            bool changed = false;
            try
            {
                PersonBody body = contents.GetComponent<PersonBody>();
                if (body != null && body.kind != kind)
                {
                    body.kind = kind;
                    changed = true;
                }
                if (changed) PrefabUtility.SaveAsPrefabAsset(contents, path);
            }
            finally
            {
                PrefabUtility.UnloadPrefabContents(contents);
            }
            return changed;
        }

        static GameObject Load(string path)
        {
            return AssetDatabase.LoadAssetAtPath<GameObject>(path);
        }

        static void SetMaterial(GameObject go, Material mat)
        {
            if (mat == null) return;
            Renderer rend = go.GetComponent<Renderer>();
            if (rend != null) rend.sharedMaterial = mat;
        }

        // ---------------- シーンの骨 ----------------

        static GameObject FindOrCreateRoot(Scene scene, string name)
        {
            GameObject[] roots = scene.GetRootGameObjects();
            for (int i = 0; i < roots.Length; i++)
                if (roots[i].name == name) return roots[i];
            GameObject go = new GameObject(name);
            SceneManager.MoveGameObjectToScene(go, scene);
            return go;
        }

        static GameObject FindOrCreateChild(Transform parent, string name)
        {
            Transform t = parent.Find(name);
            if (t != null) return t.gameObject;
            GameObject go = new GameObject(name);
            go.transform.SetParent(parent, false);
            return go;
        }

        static T Ensure<T>(GameObject go) where T : Component
        {
            T c = go.GetComponent<T>();
            if (c == null) c = go.AddComponent<T>();
            return c;
        }

        static void EnsureLighting(Transform root)
        {
            GameObject lightsGo = FindOrCreateChild(root, "Lighting");

            GameObject sunGo = FindOrCreateChild(lightsGo.transform, "Sun");
            Light sun = Ensure<Light>(sunGo);
            sun.type = LightType.Directional;
            sun.intensity = 1.1f;
            sun.shadows = LightShadows.Soft;
            sun.color = new Color(1f, 0.98f, 0.94f);
            sunGo.transform.rotation = Quaternion.Euler(52f, -34f, 0f);

            GameObject fillGo = FindOrCreateChild(lightsGo.transform, "Fill");
            Light fill = Ensure<Light>(fillGo);
            fill.type = LightType.Directional;
            fill.intensity = 0.35f;
            fill.shadows = LightShadows.None;
            fill.color = new Color(0.85f, 0.9f, 1f);
            fillGo.transform.rotation = Quaternion.Euler(28f, 160f, 0f);

            RenderSettings.ambientMode = UnityEngine.Rendering.AmbientMode.Flat;
            RenderSettings.ambientLight = new Color(0.46f, 0.47f, 0.50f);
            RenderSettings.fog = false;
        }

        static void EnsureCameras(Transform root)
        {
            GameObject camsGo = FindOrCreateChild(root, "Cameras");

            GameObject mainGo = FindOrCreateChild(camsGo.transform, "Main Camera");
            Camera main = Ensure<Camera>(mainGo);
            main.clearFlags = CameraClearFlags.SolidColor;
            main.backgroundColor = new Color(0.13f, 0.14f, 0.17f);
            mainGo.tag = "MainCamera";

            GameObject topGo = FindOrCreateChild(camsGo.transform, "Cam_Top");
            Camera top = Ensure<Camera>(topGo);
            top.enabled = false;
            top.clearFlags = CameraClearFlags.SolidColor;
            top.backgroundColor = new Color(0.13f, 0.14f, 0.17f);

            GameObject obliqueGo = FindOrCreateChild(camsGo.transform, "Cam_Oblique");
            Camera oblique = Ensure<Camera>(obliqueGo);
            oblique.enabled = false;
            oblique.clearFlags = CameraClearFlags.SolidColor;
            oblique.backgroundColor = new Color(0.13f, 0.14f, 0.17f);

            // SRP が有効なときに必要な付属データを先に付けておく
            EnsureSrpCameraData(mainGo);
            EnsureSrpCameraData(topGo);
            EnsureSrpCameraData(obliqueGo);
        }

        /// <summary>
        /// URP / HDRP のカメラ付属データを、型名で探して付ける。
        /// URP が入っていなくてもコンパイルできるように、直接の参照は持たない。
        /// </summary>
        static void EnsureSrpCameraData(GameObject camGo)
        {
            if (UnityEngine.Rendering.GraphicsSettings.currentRenderPipeline == null) return;

            string[] typeNames =
            {
                "UnityEngine.Rendering.Universal.UniversalAdditionalCameraData, Unity.RenderPipelines.Universal.Runtime",
                "UnityEngine.Rendering.HighDefinition.HDAdditionalCameraData, Unity.RenderPipelines.HighDefinition.Runtime"
            };
            for (int i = 0; i < typeNames.Length; i++)
            {
                Type t = Type.GetType(typeNames[i]);
                if (t == null) continue;
                if (camGo.GetComponent(t) == null) camGo.AddComponent(t);
                return;
            }
        }
    }
}
```

### Assets/ServiceSim/Editor/ServiceSimStageB.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.SceneManagement;
using UnityEngine;
// System.Diagnostics をまるごと using すると Debug が UnityEngine.Debug と
// ぶつかるので、要る型だけ別名で入れる
using Process = System.Diagnostics.Process;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 段階B（依頼 v7。2026-10-23）：18回まとめて回して、台帳に1行ずつ残す。
    ///
    /// 回すもの:
    ///   サイトの条件 6 つ（③ fam_p1・⑦ fam_p5 × ロボット 0・1・2 台）× 種 0・1・2 ＝ 18 回。
    ///   決まりは固定版のまま。条件ごとに .asset へ入れた値は、ExperimentRunner と SiteValues が
    ///   回し終わりに必ず元へ戻す（途中で止めても戻す）。
    ///
    /// 回す順番:
    ///   ③ロボット2台 種0 → ⑦ロボット2台 種0 → 残りを条件・種の順。
    ///   **最初の2回で失敗したら、残りは回さずに止める**。
    ///
    /// 回しっぱなしにするための作り:
    ///   ・1回ごとに Play を出入りする（前の回の人・ロボット・NavMesh を持ち越さない）。
    ///     Play をまたいで消えないよう、進みは EditorPrefs に置く
    ///   ・1回の実時間の上限は ExperimentRunner.maxWallSecondsPerRun（40 分）。
    ///     超えた回は書き出して stopped で台帳に残し、次へ進む
    ///   ・回しているあいだは caffeinate で Mac のスリープを止める（終わったら戻す）
    ///   ・途中で止めても、もう一度押せば **台帳を見て** まだ回していない回から続ける
    ///
    /// シミュレーションの動きは1行も変えない。入れたのは「まとめて回す仕組み」と
    /// 「台帳への書き出し」だけ。
    /// </summary>
    public static class ServiceSimStageB
    {
        // 店の形の場所は ServiceSimSetup に1つだけ置く
        // （画面なしの実行ファイルも同じ所を見る。依頼 v12 の 2 章・2026-10-26）
        const string P1Path = ServiceSimSetup.P1LayoutPath;
        const string P5Path = ServiceSimSetup.P5LayoutPath;

        /// <summary>1回の実時間の上限（秒）。依頼 v7 の 3</summary>
        // 1回の実時間の上限。値は ExperimentRunner に1つだけ置く
        // （画面なしの実行ファイルと同じ値を使うため。依頼 v12 の 2 章・2026-10-26）
        const float MaxWallSecondsPerRun = ExperimentRunner.DefaultMaxWallSecondsPerRun;

        /// <summary>最初の何回かが失敗したら、残りを回さずに止める</summary>
        const int FailFastRuns = 2;

        const string LogFileName = "run_all_log.txt";

        /// <summary>
        /// まとめて回すときの名札（依頼 v11 の 2・3 B 3）。
        /// 出力フォルダの名前に入り、台帳の「もう回したか」の照合もこの名札ごとに分ける。
        ///   stageB      … 段階Bの18回（基準データを作る回）
        ///   split_check … プロジェクトを分けたあとの「同じ結果が出るか」の確かめ
        /// 名札を分けてあるので、split_check を回しても段階Bの18回が「済」にならない
        /// </summary>
        const string LabelStageB = "stageB";
        const string LabelSplitCheck = "split_check";

        /// <summary>
        /// 同じ種でずれる元の切り分け（依頼 v11 の 5 章）。
        /// 名札を分けてあるので、段階Bの18回とも split_check とも混ざらない
        /// </summary>
        const string LabelDetCheck = "det_check";

        /// <summary>
        /// 道探しの枠（pathfindingIterationsPerFrame）が結果を動かすかの確かめ（2026-10-26）。
        /// 段階Bの18回（⑦）で待ちが短く出たので、枠 100 と 5000 で同じ回を比べる
        /// </summary>
        const string LabelPathCheck = "path_check";

        /// <summary>
        /// ロボットどうしのすれ違い（依頼 v13 の 依頼1）。
        /// すれ違いの走り方を入れた回と切った回で名札を分けるので、
        /// 出力フォルダ名も台帳の照合も混ざらない（段階Bの18回も「済」にならない）
        /// </summary>
        const string LabelPassOn = "pass_on";
        const string LabelPassOff = "pass_off";

        /// <summary>
        /// このプロジェクトの Unity Version Control のリポジトリ名に入っているはずの字。
        /// cm status が返したリポジトリ名にこれが無ければ、
        /// 1つ上のワークスペース（Robotics_UX_New）の版を見てしまっているので回さない（依頼 v11 の 2）
        /// </summary>
        const string ExpectRepoName = "ServiceSim";

        /// <summary>EditorPrefs にためるログの区切り（1文字。行の中に出ない字を使う）</summary>
        const char LogSep = '\u001f';

        // ---- Play をまたいで持つ進み（EditorPrefs）----
        const string KeyActive = "ServiceSim.StageB.active";
        const string KeyIndex = "ServiceSim.StageB.index";
        const string KeyConds = "ServiceSim.StageB.conds";     // "2:0;5:0;..."
        const string KeyFolders = "ServiceSim.StageB.folders";  // ";" 区切り
        const string KeyUnityCs = "ServiceSim.StageB.unityCs";
        const string KeyPending = "ServiceSim.StageB.pending";
        const string KeyLog = "ServiceSim.StageB.log";
        const string KeyRunStart = "ServiceSim.StageB.runStart";
        const string KeyCaffeinate = "ServiceSim.StageB.caffeinate";
        const string KeyBatchStart = "ServiceSim.StageB.batchStart";
        const string KeyLabel = "ServiceSim.StageB.label";

        // ---------------- メニュー ----------------

        [MenuItem("ServiceSim/Stage B/Run All（18回）", false, 200)]
        public static void RunAll()
        {
            List<int> condIdx; List<int> seeds;
            BuildOrder(out condIdx, out seeds);
            StartBatch("段階B", LabelStageB, condIdx, seeds, true);
        }

        /// <summary>
        /// プロジェクトを分けたあとの「同じ結果が出るか」の確かめ（依頼 v11 の 3 B 3）。
        ///
        /// 段階Bの18回のうち ③ ロボット1台・種1 だけを 2 回回す。
        /// 同じ種で同じ数字が出るか（2回が同じか）と、
        /// 基準データ v2 の同じ回（20261025_stageB_cs144_fam_p1_r1_s1）と同じかを見る。
        ///
        /// 名札が split_check なので、出力フォルダは
        ///   {日付}_split_check_cs{版}_fam_p1_r1_s1（2回めは末尾に _2）
        /// になり、段階Bの18回とは混ざらない。
        /// 台帳の「もう回したか」も名札ごとなので、
        /// この確かめのあとで Run All を押しても18回とも「まだ」のまま。
        /// 回し方・記録・条件の中身は段階Bとまったく同じ
        /// </summary>
        [MenuItem("ServiceSim/Stage B/移せたかの確かめ（③1台・種1 を2回）", false, 203)]
        public static void RunSplitCheck()
        {
            // 条件 1 = ③（fam_p1）・ロボット 1 台（unity_conditions の並び）。種は 1
            List<int> condIdx = new List<int> { 1, 1 };
            List<int> seeds = new List<int> { 1, 1 };
            StartBatch("移せたかの確かめ", LabelSplitCheck, condIdx, seeds, false);
        }

        /// <summary>
        /// 同じ種でずれる元の切り分け（依頼 v11 の 5 章）。③1台・種1 を2回回す。
        ///
        /// どの試しになるかは ExperimentPlan の det_check の切り替えで決まります
        /// （試し1 = ExperimentPlan.pathIterationsPerFrame、試し2 = detCheckSyncPeoplePaths、
        ///   試し4 = detCheckNoAvoidance）。ここは回すだけで、切り替えは触りません。
        ///
        /// 出力フォルダは {日付}_det_check_cs{版}_fam_p1_r1_s1（2回めは _2）。
        /// 名札が det_check なので、段階Bの18回とも split_check とも混ざりません。
        /// 入れた切り替えは run_info.json の det_check に残ります
        /// </summary>
        [MenuItem("ServiceSim/Stage B/ずれの切り分け（③1台・種1 を2回）", false, 204)]
        public static void RunDetCheck()
        {
            List<int> condIdx = new List<int> { 1, 1 };
            List<int> seeds = new List<int> { 1, 1 };
            StartBatch("ずれの切り分け", LabelDetCheck, condIdx, seeds, false);
        }

        // ---------------- すれ違い（依頼 v13 の 依頼1）----------------

        /// <summary>
        /// すれ違いの確かめ（依頼 v13 の 依頼1）。③（fam_p1）ロボット2台・スタッフ4・種1 を1回。
        ///
        /// ロボット1台の条件ではすれ違いが起きないので2台で回す。
        /// 押した方（入／切）に合わせて RobotSpec.passByEnabled を書いてから回すので、
        /// 切り替えと名札が食い違わない。
        /// 出力フォルダは {日付}_pass_on_cs{版}_fam_p1_r2_s1（切は pass_off）。
        /// 入れた値は run_info.json の pass_by に残る
        /// </summary>
        [MenuItem("ServiceSim/Stage B/すれ違い（③2台・種1）/入で1回回す", false, 230)]
        public static void RunPassOn() { RunPassCheck(true); }

        [MenuItem("ServiceSim/Stage B/すれ違い（③2台・種1）/切で1回回す", false, 231)]
        public static void RunPassOff() { RunPassCheck(false); }

        [MenuItem("ServiceSim/Stage B/すれ違い（③2台・種1）/いまの設定を見る", false, 232)]
        public static void PassByShow()
        {
            RobotSpec spec = LoadRobotSpec();
            if (spec != null) Debug.Log("[ServiceSim] すれ違い: " + PassByNote(spec));
        }

        static void RunPassCheck(bool on)
        {
            RobotSpec spec = LoadRobotSpec();
            if (spec == null) return;

            if (spec.passByEnabled != on)
            {
                spec.passByEnabled = on;
                EditorUtility.SetDirty(spec);
                AssetDatabase.SaveAssets();
            }
            Debug.Log("[ServiceSim] すれ違いの走り方を " + (on ? "入" : "切") + " にしました\n  " +
                      PassByNote(spec));

            // 条件 2 = ③（fam_p1）・ロボット 2 台（unity_conditions の並び）。種は 1
            List<int> condIdx = new List<int> { 2 };
            List<int> seeds = new List<int> { 1 };
            StartBatch("すれ違いの確かめ（" + (on ? "入" : "切") + "）",
                       on ? LabelPassOn : LabelPassOff, condIdx, seeds, false);
        }

        /// <summary>いまのすれ違いの設定を、人が読める1行にする</summary>
        static string PassByNote(RobotSpec spec)
        {
            if (spec == null) return "（RobotSpec を読めません）";
            if (!spec.passByEnabled) return "切（cs:3 と同じ動き）";
            return "入 / 見る距離 " + spec.passByDistanceM + " m / すれ違い中の速さ " +
                   spec.passBySpeedMps + " m/秒 / 体どうしの間 " + spec.passBySeparationM +
                   " m（右へ " + spec.PassByShiftM.ToString("0.##") + " m 寄る）";
        }

        static RobotSpec LoadRobotSpec()
        {
            RobotSpec s = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            if (s == null) Debug.LogError("[ServiceSim] RobotSpec が見つかりません");
            return s;
        }

        /// <summary>
        /// 道探しの枠が結果を動かすかの確かめ（2026-10-26）。
        /// ⑦（fam_p5）ロボット0台・種0・1・2 を3回回す。
        ///
        /// 段階Bの18回（cs:7）で、⑦が移す前（cs:144）より9回とも待ちが短く出た。
        /// 昼の切り分けで入れた枠 5000 が、エディタを閉じるまで残っていた疑いがあるため、
        /// 枠 100 と 5000 で同じ3回を回して並べる。ロボット0台でも出ているので、
        /// ロボットではなく人（スタッフ）の動きを見る条件を選んでいる。
        ///
        /// 枠は ExperimentPlan.pathIterationsPerFrame で決まる（切り替えメニューで変える）。
        /// 入った値は run_info.json の path_iterations_per_frame に出るので、
        /// あとから「どちらの枠で回したか」が必ず分かる。
        /// 名札が path_check なので、段階Bの18回とは混ざらない
        /// </summary>
        [MenuItem("ServiceSim/Stage B/道探しの枠の確かめ（⑦0台・種0〜2）", false, 205)]
        public static void RunPathCheck()
        {
            // 条件 3 = ⑦（fam_p5）・ロボット 0 台（unity_conditions の並び）
            List<int> condIdx = new List<int> { 3, 3, 3 };
            List<int> seeds = new List<int> { 0, 1, 2 };
            StartBatch("道探しの枠の確かめ", LabelPathCheck, condIdx, seeds, false);
        }

        /// <summary>
        /// まとめて回し始める（Run All と、移せたかの確かめで共通）。
        ///
        /// skipDone が true なら、同じ名札・同じ版・同じ条件・同じ種の行が台帳にある回を飛ばす。
        /// 確かめは同じ条件を 2 回回すので false にする（飛ばさない）。
        /// 回し方（1回ごとに Play を出入りする・実時間の上限・caffeinate）は同じ
        /// </summary>
        static void StartBatch(string title, string label,
                               List<int> condIdx, List<int> seeds, bool skipDone)
        {
            ExperimentRunner runner; ExperimentPlan plan;
            if (!ServiceSimRunSupport.Prepare(out runner, out plan)) return;

            // --- 押す前の確かめ（合わなければ回し始めない）---
            if (!CheckBeforeRun(plan, label, out string why))
            {
                Debug.LogError("[ServiceSim] " + title + "：回し始めません\n" + why);
                return;
            }

            SimParams sp = LoadParams();
            if (sp == null) return;
            if (sp.Conditions.Count != 6)
            {
                Debug.LogError("[ServiceSim] " + title + "：unity_conditions が 6 件ではありません（" +
                               sp.Conditions.Count + " 件）。params を確かめてください");
                return;
            }

            string ledger = ServiceSimLedger.PathOf(plan);
            string day = DateTime.Now.ToString("yyyyMMdd");

            // --- 回し始めの版（照合キーに入れるので先に読む。依頼 v8 の 3）---
            string cs, pendingNote;
            ReadChangeset(out cs, out pendingNote);

            // 照合は「店・台数・種・版」。出力フォルダ名（日付入り）では照らし合わせない。
            // 名札ごとに分けるので、確かめの回が段階Bの18回を「済」にしない（依頼 v11）
            HashSet<string> done = skipDone
                ? ServiceSimLedger.DoneKeys(ledger, label)
                : new HashSet<string>();

            List<string> conds = new List<string>();
            List<string> folders = new List<string>();
            int skipped = 0;
            for (int i = 0; i < condIdx.Count; i++)
            {
                SimParams.Condition c = sp.ConditionAt(condIdx[i]);
                if (c == null)
                {
                    Debug.LogError("[ServiceSim] " + title + "：条件 " + condIdx[i] + " がありません");
                    return;
                }
                if (done.Contains(ServiceSimLedger.RunKey(c.store, c.robots, seeds[i], cs)))
                { skipped++; continue; }
                conds.Add(condIdx[i] + ":" + seeds[i]);
                folders.Add(FreeFolderName(plan, day, label, cs, c, seeds[i], folders));
            }

            if (conds.Count == 0)
            {
                Debug.Log("[ServiceSim] " + title + "：この版（" + cs + "）の " + condIdx.Count +
                          " 回はぜんぶ台帳にあります（" + skipped + " 回を飛ばしました）。" +
                          "\n  やり直すときは台帳の行を見て、エイイチに相談してください" +
                          "\n  台帳: " + ledger);
                return;
            }

            EditorPrefs.SetBool(KeyActive, true);
            EditorPrefs.SetInt(KeyIndex, 0);
            EditorPrefs.SetString(KeyConds, string.Join(";", conds.ToArray()));
            EditorPrefs.SetString(KeyFolders, string.Join(";", folders.ToArray()));
            EditorPrefs.SetString(KeyUnityCs, cs);
            EditorPrefs.SetString(KeyPending, pendingNote);
            EditorPrefs.SetString(KeyLog, "");
            EditorPrefs.SetString(KeyBatchStart, day);
            EditorPrefs.SetString(KeyLabel, label);

            StartCaffeinate();

            Debug.Log("[ServiceSim] " + title + "：" + conds.Count + " / " + condIdx.Count + " 回を回します" +
                      (skipped > 0 ? "（台帳にある " + skipped + " 回は飛ばします）" : "") +
                      "\n  名札: " + label +
                      "\n  Unity の版: " + cs + (pendingNote.Length > 0 ? "（" + pendingNote + "）" : "") +
                      "\n  台帳: " + ledger +
                      "\n  1回の実時間の上限: " + (MaxWallSecondsPerRun / 60f).ToString("0") + " 分" +
                      "\n  1回ごとに Play を出入りします。止めたいときは、Play を抜けたところで" +
                      " ServiceSim/Stage B/いまの回で止める を押してください" +
                      "\n  ずれの切り分けの切り替え: " + DetCheckNote(plan) +
                      "\n  すれ違い（依頼 v13）: " + PassByNote(LoadRobotSpec()) +
                      "\n  置き場所: " + string.Join(" / ", folders.ToArray()));

            StartNext();
        }

        [MenuItem("ServiceSim/Stage B/押す前の確かめ（回さない）", false, 201)]
        public static void CheckOnly()
        {
            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (plan == null) { Debug.LogError("[ServiceSim] ExperimentPlan が見つかりません"); return; }

            string why;
            bool ok = CheckBeforeRun(plan, out why);
            string ledger = ServiceSimLedger.PathOf(plan);
            HashSet<string> doneFolders = ServiceSimLedger.DoneFolders(ledger);

            StringBuilder sb = new StringBuilder();
            sb.AppendLine("[ServiceSim] 段階B：押す前の確かめ " + (ok ? "→ 回せます" : "→ 回せません"));
            if (!ok) sb.AppendLine(why);
            sb.AppendLine("  台帳: " + ledger);
            sb.AppendLine("  台帳にある回（ぜんぶの版）: " + doneFolders.Count + " 件");
            sb.AppendLine("  列（" + ServiceSimLedger.Columns.Length + " 列・台帳の版 " +
                          ServiceSimLedger.SchemaVersion + "）: " + ServiceSimLedger.HeaderLine);

            // --- 試算の版・店の形・レジの数（依頼 v10b の 3 B 2）---
            SimParams sp = LoadParams();
            if (sp == null)
            {
                sb.AppendLine("  試算の値を読めませんでした（回せません）");
                Debug.LogError(sb.ToString().TrimEnd());
                return;
            }
            sb.AppendLine("  試算の版 (meta.model): " + sp.Model +
                          " / 店の形の版 (meta.layout_generation): " + sp.LayoutGeneration);
            sb.AppendLine("  条件: " + sp.Conditions.Count + " 件 / レジ: " + RegistersNote(sp));
            if (sp.Problems.Count > 0)
            {
                ok = false;
                sb.AppendLine("  合わないもの: " + sp.Problems.Count + " 件");
                for (int i = 0; i < sp.Problems.Count; i++)
                    sb.AppendLine("    - " + sp.Problems[i]);
            }
            else sb.AppendLine("  合わないもの: なし");

            // --- 18回が「済」か「まだ」か（この版での照合。Run All と同じ見方）---
            string cs, pendingNote;
            ReadChangeset(out cs, out pendingNote);
            // 照合は名札（stageB）ごと。移せたかの確かめ（split_check）の回は数えない（依頼 v11）
            HashSet<string> doneKeys = ServiceSimLedger.DoneKeys(ledger, LabelStageB);

            List<int> condIdx; List<int> seeds;
            BuildOrder(out condIdx, out seeds);

            int todo = 0;
            StringBuilder list = new StringBuilder();
            for (int i = 0; i < condIdx.Count; i++)
            {
                SimParams.Condition c = sp.ConditionAt(condIdx[i]);
                if (c == null) continue;
                bool had = doneKeys.Contains(
                    ServiceSimLedger.RunKey(c.store, c.robots, seeds[i], cs));
                if (!had) todo++;
                list.AppendLine("    " + (i + 1).ToString("00") + ". " + c.store +
                                "・ロボット " + c.robots + " 台・種 " + seeds[i] +
                                " … " + (had ? "済（台帳にあるので飛ばします）" : "まだ"));
            }

            sb.AppendLine("  Unity の版: " + cs +
                          (pendingNote.Length > 0 ? "（" + pendingNote + "）" : "（保留なし）"));
            // 版が分からないまま回すと、台帳の unity_cs が「不明」になって
            // どの版で回したか追えなくなる（依頼 v11 の 2）
            if (cs.IndexOf("不明", StringComparison.Ordinal) >= 0)
            {
                ok = false;
                sb.AppendLine("    ・版が分かりません。このプロジェクトを Unity Version Control の" +
                              "ワークスペースにして、check-in してから回してください");
            }
            sb.AppendLine("  この版の18回（名札 " + LabelStageB + "）: まだ " + todo +
                          " 回 / 済 " + (condIdx.Count - todo) + " 回");
            sb.Append(list.ToString().TrimEnd('\n'));
            sb.AppendLine();
            sb.AppendLine("  移せたかの確かめ（名札 " + LabelSplitCheck +
                          "）は、この18回の「済／まだ」に入りません");

            if (ok) Debug.Log(sb.ToString().TrimEnd()); else Debug.LogError(sb.ToString().TrimEnd());
        }

        // ---------------- ずれの切り分けの切り替え（依頼 v11 の 5 章。試しだけ）----------------

        /// <summary>いま入っている切り替えを、人が読める1行にする</summary>
        static string DetCheckNote(ExperimentPlan plan)
        {
            if (plan == null) return "（ExperimentPlan を読めません）";
            // 道探しの枠は切り替えではなく「回すたびに入れる値」なので、いつでも出す（2026-10-26）
            string head = "道探しの枠 " + plan.pathIterationsPerFrame + " 回/コマ"
                          + (plan.pathIterationsPerFrame == 100 ? "（既定）" : "（既定は 100）");
            if (!plan.detCheckSyncPeoplePaths &&
                !plan.detCheckNoAvoidance && !plan.detCheckFrameFingerprint)
                return head + " / ほかの切り替えはぜんぶ切";
            return head +
                   " / 人の道を同期 " + (plan.detCheckSyncPeoplePaths ? "入" : "切") +
                   " / 局所回避を切る " + (plan.detCheckNoAvoidance ? "入" : "切") +
                   " / 1コマごとの指紋 " + (plan.detCheckFrameFingerprint ? "入" : "切");
        }

        static ExperimentPlan LoadPlan()
        {
            ExperimentPlan p = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (p == null) Debug.LogError("[ServiceSim] ExperimentPlan が見つかりません");
            return p;
        }

        static void SetDetCheck(string title, int pathIterations, bool syncPaths,
                                bool noAvoidance, bool fingerprint)
        {
            ExperimentPlan p = LoadPlan();
            if (p == null) return;
            p.pathIterationsPerFrame = pathIterations > 0 ? pathIterations : 100;
            p.detCheckSyncPeoplePaths = syncPaths;
            p.detCheckNoAvoidance = noAvoidance;
            p.detCheckFrameFingerprint = fingerprint;
            EditorUtility.SetDirty(p);
            AssetDatabase.SaveAssets();
            Debug.Log("[ServiceSim] ずれの切り分け：" + title +
                      "\n  " + DetCheckNote(p) +
                      "\n  このまま ServiceSim/Stage B/ずれの切り分け（③1台・種1 を2回）を押してください");
        }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/試し1：道探しの枠を 5000 にする", false, 220)]
        public static void DetCheck1() { SetDetCheck("試し1", 5000, false, false, true); }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/試し2：人の道を同期で引く", false, 221)]
        public static void DetCheck2() { SetDetCheck("試し2", 5000, true, false, true); }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/試し4：局所回避も切る（要るときだけ）", false, 222)]
        public static void DetCheck4() { SetDetCheck("試し4", 5000, true, true, true); }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/ぜんぶ切る（枠 100・ほかは切）", false, 223)]
        public static void DetCheckOff() { SetDetCheck("ぜんぶ切る", 100, false, false, false); }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/道探しの枠を 100 にする（既定）", false, 225)]
        public static void PathIter100() { SetPathIterations(100); }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/道探しの枠を 5000 にする", false, 226)]
        public static void PathIter5000() { SetPathIterations(5000); }

        /// <summary>
        /// 道探しの枠だけを入れ直す（2026-10-26）。
        /// ほかの切り替えは触らない。回すたびに ExperimentRunner がこの値を入れるので、
        /// エディタの中に前の値が残らない
        /// </summary>
        static void SetPathIterations(int n)
        {
            ExperimentPlan p = LoadPlan();
            if (p == null) return;
            p.pathIterationsPerFrame = n > 0 ? n : 100;
            EditorUtility.SetDirty(p);
            AssetDatabase.SaveAssets();
            Debug.Log("[ServiceSim] 道探しの枠を " + p.pathIterationsPerFrame +
                      " 回/コマ にしました（既定は 100）" +
                      "\n  " + DetCheckNote(p) +
                      "\n  回すたびにこの値が入り、run_info.json の" +
                      " path_iterations_per_frame に実際の値が出ます");
        }

        [MenuItem("ServiceSim/Stage B/切り分けの設定/いまの設定を見る", false, 224)]
        public static void DetCheckShow()
        {
            ExperimentPlan p = LoadPlan();
            if (p != null) Debug.Log("[ServiceSim] ずれの切り分けの切り替え: " + DetCheckNote(p));
        }

        /// <summary>6条件のレジの数を短く書く（ぜんぶ同じなら1つだけ）</summary>
        static string RegistersNote(SimParams sp)
        {
            if (sp.Conditions.Count == 0) return "（条件なし）";
            int first = sp.Conditions[0].registers;
            bool same = true;
            for (int i = 1; i < sp.Conditions.Count; i++)
                if (sp.Conditions[i].registers != first) { same = false; break; }
            if (same) return first + " つ（6条件とも同じ）";

            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < sp.Conditions.Count; i++)
            {
                if (i > 0) sb.Append(" / ");
                sb.Append(sp.Conditions[i].registers);
            }
            return "ばらばら: " + sb;
        }

        [MenuItem("ServiceSim/Stage B/いまの回で止める", false, 202)]
        public static void StopBatch()
        {
            if (!EditorPrefs.GetBool(KeyActive, false))
            {
                Debug.Log("[ServiceSim] 段階B：いま回していません");
                return;
            }
            Finish("エイイチが止めました");
        }

        [MenuItem("ServiceSim/Stage B/台帳を開く", false, 210)]
        public static void OpenLedger()
        {
            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (plan == null) return;
            string p = ServiceSimLedger.PathOf(plan);
            if (!File.Exists(p)) { Debug.LogWarning("[ServiceSim] まだありません: " + p); return; }
            EditorUtility.RevealInFinder(p);
        }

        // ---------------- 押す前の確かめ ----------------

        static bool CheckBeforeRun(ExperimentPlan plan, out string why)
        {
            return CheckBeforeRun(plan, null, out why);
        }

        /// <summary>
        /// 押す前の確かめ。label に名札を渡すと、
        /// 測る回（stageB / split_check）では「ずれの切り分けの切り替え」が
        /// 入ったままかどうかも見る（依頼 v11 の 5 章）。
        /// 切り替えが入ったまま測る回を回すと、黙って動きが変わった数字が
        /// 台帳に入ってしまうので、そこで止める
        /// </summary>
        static bool CheckBeforeRun(ExperimentPlan plan, string label, out string why)
        {
            StringBuilder sb = new StringBuilder();

            if (label != null && label != LabelDetCheck && plan != null &&
                (plan.detCheckSyncPeoplePaths || plan.detCheckNoAvoidance))
                sb.AppendLine("  ・ずれの切り分けの切り替えが入ったままです（" + DetCheckNote(plan) + "）。" +
                              "測る回は回せません。ServiceSim/Stage B/切り分けの設定/" +
                              "ぜんぶ切る を押してください");

            // 1倍で回す切り替え（SimHud）が入っていると、その回は見る専用で
            // 記録を書き出さない。測る回では必ず止める（依頼 v13 の 依頼2・2026-10-26）
            SimHud hud = UnityEngine.Object.FindObjectOfType<SimHud>();
            if (hud != null && hud.realTimeSpeed)
                sb.AppendLine("  ・SimHud の「1倍で回す」が入っています。" +
                              "その回は見る専用で記録を書き出さないので、測る回は回せません。" +
                              "シーンの SimHud で Real Time Speed を切ってください");

            if (!Directory.Exists(plan.kioxiaVolumePath))
                sb.AppendLine("  ・KIOXIA がつながっていません: " + plan.kioxiaVolumePath);

            if (!Directory.Exists(plan.kioxiaOutputRoot))
                sb.AppendLine("  ・置き場所がありません: " + plan.kioxiaOutputRoot +
                              "（MASTER の保存先です。新しく作らずに確かめてください）");

            string ledger = ServiceSimLedger.PathOf(plan);
            string headerWhy;
            if (!ServiceSimLedger.CanAppend(ledger, out headerWhy))
                sb.AppendLine("  ・" + headerWhy.Replace("\n", "\n  "));

            StoreBuilder builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            NavMeshBaker baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            if (builder == null || baker == null)
                sb.AppendLine("  ・StoreBuilder / NavMeshBaker が見つかりません。ServiceSim/Setup を実行してください");

            why = sb.ToString().TrimEnd();
            return why.Length == 0;
        }

        // ---------------- 18回の並び ----------------

        /// <summary>
        /// ③ロボット2台 種0 → ⑦ロボット2台 種0 → 残りを条件・種の順。
        /// 条件の番号は unity_conditions の並び（0..5）
        /// </summary>
        static void BuildOrder(out List<int> conds, out List<int> seeds)
        {
            conds = new List<int>();
            seeds = new List<int>();

            // 最初の2回（この2回で失敗したら残りを回さない）
            Add(conds, seeds, 2, 0);   // ③ ロボット2台 種0
            Add(conds, seeds, 5, 0);   // ⑦ ロボット2台 種0

            int[] allSeeds = { 0, 1, 2 };
            for (int c = 0; c < 6; c++)
                for (int s = 0; s < allSeeds.Length; s++)
                {
                    if (c == 2 && allSeeds[s] == 0) continue;   // もう入れた
                    if (c == 5 && allSeeds[s] == 0) continue;
                    Add(conds, seeds, c, allSeeds[s]);
                }
        }

        static void Add(List<int> conds, List<int> seeds, int c, int s)
        {
            conds.Add(c);
            seeds.Add(s);
        }

        /// <summary>
        /// 出力フォルダの名前（依頼 v8 の 3・v11 の 2）。
        /// {日付}_{名札}_cs{版}_{店}_r{台数}_s{種}。
        /// 版が分かるようにしておき、照合は台帳のキー（店・台数・種・版）で行う。
        ///
        /// 版の字は "ServiceSim/cs:1" の形で来るので（依頼 v11 の 2）、
        /// フォルダ名に使えない "/" と ":" を外して "cs1" にする。
        /// 前のプロジェクトの "cs:144" も同じく "cs144" になり、名前の形は変わらない
        /// </summary>
        static string FolderName(string day, string label, string cs, SimParams.Condition c, int seed)
        {
            string ver = (cs ?? "")
                .Replace(ServiceSimLedger.CsPrefix, "")   // "ServiceSim/" を外す
                .Replace("cs:", "")
                .Replace(":", "").Replace("/", "")
                .Replace("（", "").Replace("）", "");
            if (ver.Length == 0) ver = "unknown";
            return day + "_" + label + "_cs" + ver + "_" + c.store + "_r" + c.robots + "_s" + seed;
        }

        /// <summary>
        /// 書き出す先が既にあるときは上書きせず、末尾に _2・_3 … を付けた
        /// 新しいフォルダ名を返す（依頼 v8 の 3）。
        /// 同じ条件を2回回す確かめ（依頼 v11）では、2回めがこれで _2 になる
        /// </summary>
        static string FreeFolderName(ExperimentPlan plan, string day, string label, string cs,
                                     SimParams.Condition c, int seed, List<string> taken)
        {
            string baseName = FolderName(day, label, cs, c, seed);
            string name = baseName;
            for (int n = 2; n < 100; n++)
            {
                // taken は、この並びでもう使うことに決めた名前（まだフォルダは無い）。
                // 同じ条件を2回回すときに、2回めが同じ名前にならないようにする（依頼 v11）
                bool free = !Directory.Exists(Path.Combine(plan.kioxiaOutputRoot, name)) &&
                            (taken == null || !taken.Contains(name));
                if (free) return name;
                name = baseName + "_" + n;
            }
            return name;
        }

        // ---------------- 1回ずつ回す ----------------

        static void StartNext()
        {
            if (!EditorPrefs.GetBool(KeyActive, false)) return;

            string[] conds = Split(EditorPrefs.GetString(KeyConds, ""));
            string[] folders = Split(EditorPrefs.GetString(KeyFolders, ""));
            int index = EditorPrefs.GetInt(KeyIndex, 0);

            if (index >= conds.Length || index >= folders.Length) { Finish("ぜんぶ終わりました"); return; }

            ExperimentRunner runner; ExperimentPlan plan;
            if (!ServiceSimRunSupport.Prepare(out runner, out plan))
            {
                Finish("Play の下ごしらえができませんでした");
                return;
            }

            SimParams sp = LoadParams();
            if (sp == null) { Finish("params を読めませんでした"); return; }
            if (runner.simParams == null) runner.simParams = sp;

            string[] parts = conds[index].Split(':');
            int condIndex = int.Parse(parts[0], CultureInfo.InvariantCulture);
            int seed = int.Parse(parts[1], CultureInfo.InvariantCulture);

            SimParams.Condition cond = sp.ConditionAt(condIndex);
            if (cond == null) { Finish("条件 " + condIndex + " がありません"); return; }

            string assetPath = cond.store == "fam_p1" ? P1Path : cond.store == "fam_p5" ? P5Path : null;
            JsonStoreLayout layout = assetPath != null
                ? AssetDatabase.LoadAssetAtPath<JsonStoreLayout>(assetPath) : null;
            if (layout == null) { Finish("店の形が見つかりません: " + cond.store); return; }

            StoreBuilder builder = UnityEngine.Object.FindObjectOfType<StoreBuilder>();
            NavMeshBaker baker = UnityEngine.Object.FindObjectOfType<NavMeshBaker>();
            if (builder == null || baker == null) { Finish("StoreBuilder / NavMeshBaker がありません"); return; }

            builder.layout = layout;
            baker.layout = layout;
            EditorUtility.SetDirty(builder);
            EditorUtility.SetDirty(baker);

            ExperimentRunner.Job job = new ExperimentRunner.Job();
            job.siteRun = true;
            job.conditionIndex = condIndex;
            job.storeKey = cond.store;
            job.robots = cond.robots;
            job.seed = seed;
            job.aisleWidthIndex = layout.aisleWidthIndex;
            job.crowd = CrowdLevel.Normal;
            job.applyDeliveryOffset = false;
            job.folder = Path.Combine(plan.kioxiaOutputRoot, folders[index]);

            runner.SetSingleJob(job);
            runner.timeMode = ExperimentRunner.TimeMode.FixedStep;
            runner.captureDeltaTimeOverride = 0f;
            runner.maxWallSecondsPerRun = MaxWallSecondsPerRun;
            // 回し始めの版と、保留中の変更があったかを run_info に残す（依頼 v7 の 2）
            runner.unityCs = EditorPrefs.GetString(KeyUnityCs, "");
            runner.pendingNote = EditorPrefs.GetString(KeyPending, "");
            runner.runLabel = "stageB";
            runner.viewMode = false;
            runner.runOnPlay = true;
            EditorUtility.SetDirty(runner);
            EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene());

            EditorPrefs.SetString(KeyRunStart, DateTime.UtcNow.Ticks.ToString(CultureInfo.InvariantCulture));

            Debug.Log("[ServiceSim] " + BatchLabel() + " " + (index + 1) + "/" + conds.Length + " を回します" +
                      "\n  条件 " + condIndex + "：" + cond.Id + "（" + cond.store +
                      "・ロボット " + cond.robots + " 台）/ 種 " + seed +
                      "\n  置き場所: " + job.folder);

            EditorApplication.EnterPlaymode();
        }

        /// <summary>Play を抜けたら、その回を台帳に足して次へ</summary>
        [InitializeOnLoadMethod]
        static void Hook()
        {
            EditorApplication.playModeStateChanged -= OnPlayModeChanged;
            EditorApplication.playModeStateChanged += OnPlayModeChanged;
        }

        static void OnPlayModeChanged(PlayModeStateChange state)
        {
            if (state != PlayModeStateChange.EnteredEditMode) return;
            if (!EditorPrefs.GetBool(KeyActive, false)) return;

            // Play を抜けた直後にそのまま Play へ入れないので、1 コマ置く
            EditorApplication.delayCall += AfterRun;
        }

        static void AfterRun()
        {
            if (!EditorPrefs.GetBool(KeyActive, false)) return;

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);
            if (plan == null) { Finish("ExperimentPlan が見つかりません"); return; }

            string[] conds = Split(EditorPrefs.GetString(KeyConds, ""));
            string[] folders = Split(EditorPrefs.GetString(KeyFolders, ""));
            int index = EditorPrefs.GetInt(KeyIndex, 0);
            if (index >= folders.Length) { Finish("ぜんぶ終わりました"); return; }

            string folder = folders[index];
            string runDir = Path.Combine(plan.kioxiaOutputRoot, folder);

            float wallS = 0f;
            long ticks;
            if (long.TryParse(EditorPrefs.GetString(KeyRunStart, "0"),
                              NumberStyles.Integer, CultureInfo.InvariantCulture, out ticks) && ticks > 0)
                wallS = (float)(DateTime.UtcNow - new DateTime(ticks, DateTimeKind.Utc)).TotalSeconds;

            string status;
            string line = WriteLedgerRow(plan, conds[index], folder, runDir, wallS, out status);

            // ログに1行ためる
            string log = EditorPrefs.GetString(KeyLog, "");
            EditorPrefs.SetString(KeyLog, log.Length > 0 ? log + LogSep + line : line);

            Debug.Log("[ServiceSim] " + BatchLabel() + " " + (index + 1) + "/" + conds.Length + " おわり: " + line);

            // 最初の2回で失敗したら、残りは回さない
            if (status == "failed" && index + 1 <= FailFastRuns)
            {
                Finish("最初の " + FailFastRuns + " 回で失敗したので、残りは回しません（" + folder + "）");
                return;
            }

            EditorPrefs.SetInt(KeyIndex, index + 1);
            if (index + 1 >= conds.Length) { Finish("ぜんぶ終わりました"); return; }

            StartNext();
        }

        // ---------------- 台帳に1行 ----------------
        /// <summary>
        /// その回の台帳の1行を足す。
        ///
        /// 行を作るのは **LedgerRow**（Experiment/LedgerRow.cs）1つだけ。
        /// ここは Editor でしか取れない値（.asset・EditorPrefs）を詰めて渡すだけ。
        /// 夜の画面なしの実行（HeadlessRun）も同じ LedgerRow を通る
        /// （依頼 v12 の 2〜3 章・2026-10-26）
        /// </summary>
        static string WriteLedgerRow(ExperimentPlan plan, string condSeed, string folder,
                                     string runDir, float wallS, out string status)
        {
            SimParams sp = LoadParams();
            string[] parts = condSeed.Split(':');
            int condIndex = int.Parse(parts[0], CultureInfo.InvariantCulture);
            int seed = int.Parse(parts[1], CultureInfo.InvariantCulture);

            LedgerRow.Inputs inp = new LedgerRow.Inputs();
            inp.simParams = sp;
            inp.cond = sp != null ? sp.ConditionAt(condIndex) : null;
            inp.robotSpec = AssetDatabase.LoadAssetAtPath<RobotSpec>(ServiceSimSetup.RobotSpecPath);
            inp.peopleSpec = AssetDatabase.LoadAssetAtPath<PeopleSpec>(ServiceSimSetup.PeopleSpecPath);
            inp.unityCs = EditorPrefs.GetString(KeyUnityCs, "");
            inp.runFolder = folder;
            inp.runDir = runDir;
            inp.seed = seed;
            inp.wallS = wallS;
            inp.ledgerPath = ServiceSimLedger.PathOf(plan);

            return LedgerRow.BuildAndAppend(inp, out status);
        }

        // ---------------- 終わり ----------------

        static void Finish(string whyDone)
        {
            string[] conds = Split(EditorPrefs.GetString(KeyConds, ""));
            int index = EditorPrefs.GetInt(KeyIndex, 0);
            string log = EditorPrefs.GetString(KeyLog, "");

            StopCaffeinate();

            ExperimentPlan plan = AssetDatabase.LoadAssetAtPath<ExperimentPlan>(
                ServiceSimSetup.ExperimentPlanPath);

            StringBuilder sb = new StringBuilder();
            sb.AppendLine("[ServiceSim] " + BatchLabel() + "：" + whyDone);
            sb.AppendLine("  回した: " + index + " / " + conds.Length + " 回");
            sb.AppendLine("  台帳: " + (plan != null ? ServiceSimLedger.PathOf(plan) : "-"));
            sb.AppendLine("  一覧（出力フォルダ / status / 実時間 / 入店待ち / ロボットが運んだ割合）:");
            string[] lines = log.Length > 0
                ? log.Split(LogSep) : new string[0];
            for (int i = 0; i < lines.Length; i++) sb.AppendLine("    " + (i + 1) + ". " + lines[i]);

            string text = sb.ToString().TrimEnd();
            Debug.Log(text);

            // run_all_log.txt（service_sim/unity/ の中）
            if (plan != null && Directory.Exists(plan.kioxiaOutputRoot))
            {
                try
                {
                    string p = Path.Combine(plan.kioxiaOutputRoot, LogFileName);
                    string head = "=== " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") +
                                  " " + BatchLabel() + "（Unity " + EditorPrefs.GetString(KeyUnityCs, "") + "）===\n";
                    File.AppendAllText(p, head + text + "\n\n", new UTF8Encoding(false));
                    Debug.Log("[ServiceSim] 一覧を書きました: " + p);
                }
                catch (Exception e)
                {
                    Debug.LogWarning("[ServiceSim] run_all_log.txt を書けませんでした: " + e.Message);
                }
            }

            EditorPrefs.SetBool(KeyActive, false);
            EditorPrefs.DeleteKey(KeyConds);
            EditorPrefs.DeleteKey(KeyFolders);
            EditorPrefs.DeleteKey(KeyIndex);
            EditorPrefs.DeleteKey(KeyLog);
            EditorPrefs.DeleteKey(KeyRunStart);
        }

        // ---------------- スリープを止める ----------------

        static void StartCaffeinate()
        {
            StopCaffeinate();
            try
            {
                Process p = new Process();
                p.StartInfo.FileName = "/usr/bin/caffeinate";
                p.StartInfo.Arguments = "-dimsu";
                p.StartInfo.UseShellExecute = false;
                p.StartInfo.CreateNoWindow = true;
                p.Start();
                EditorPrefs.SetInt(KeyCaffeinate, p.Id);
                Debug.Log("[ServiceSim] スリープを止めました（caffeinate pid " + p.Id + "）");
            }
            catch (Exception e)
            {
                Debug.LogWarning("[ServiceSim] caffeinate を起動できませんでした（スリープしないよう手で設定してください）: "
                                 + e.Message);
            }
        }

        static void StopCaffeinate()
        {
            int pid = EditorPrefs.GetInt(KeyCaffeinate, 0);
            if (pid <= 0) return;
            EditorPrefs.DeleteKey(KeyCaffeinate);
            try
            {
                Process p = Process.GetProcessById(pid);
                if (p != null && !p.HasExited) { p.Kill(); Debug.Log("[ServiceSim] スリープの設定を戻しました"); }
            }
            catch (Exception)
            {
                // もう終わっている
            }
        }

        // ---------------- 小さな道具 ----------------

        /// <summary>
        /// 回し始めの changeset 番号と、保留中の変更があるか。
        ///
        /// cm status --machinereadable の1行めが
        ///   STATUS 1 ServiceSim/ServiceSim studio_unity148@unity
        /// なので、そこから版の番号を取り、2行めから先を数える
        /// （PR＝置いてあるだけ も数える）。
        ///
        /// 版の字は ServiceSimLedger.CsPrefix を頭に付けて "ServiceSim/cs:1" の形にする
        /// （依頼 v11 の 2）。このプロジェクトは changeset の番号が 1 から振り直されるので、
        /// 前のプロジェクトの "cs:144" などと台帳の中で混ざらないようにするため
        /// </summary>
        // BuildHeadless（依頼 v12 の 1 章）も同じ関数で版を取る。
        // 版の取り方が2通りに分かれると、台帳の unity_cs と
        // build_info.json の unity_cs が食い違うので、入り口は1つにする
        public static void ReadChangeset(out string cs, out string pendingNote)
        {
            cs = "";
            pendingNote = "";

            string outp = RunCm("status --machinereadable");
            if (string.IsNullOrEmpty(outp))
            {
                // cm を呼べなかった。数が分からないので「不明」と書く（依頼 v8 の 3）
                cs = ServiceSimLedger.CsPrefix + "不明";
                pendingNote = "不明（cm status を読めませんでした）";
                return;
            }

            // 保留の数は Unity Version Control の「保留中の変更」と同じ数え方にする。
            // 置いてあるだけ（PR＝Private）も、あの一覧には出るので **数に入れる**
            // （前は PR を外していたので、2件あったのに空になっていた）
            int controlled = 0, privateItems = 0;
            string[] lines = outp.Split('\n');
            for (int i = 0; i < lines.Length; i++)
            {
                string l = lines[i].Trim();
                if (l.Length == 0) continue;
                if (l.StartsWith("STATUS "))
                {
                    // STATUS {版} {リポジトリ} {サーバー}
                    string[] f = l.Split(' ');
                    if (f.Length > 1) cs = ServiceSimLedger.CsPrefix + "cs:" + f[1];

                    // このプロジェクトのワークスペースから答えが返ってきたかを確かめる
                    // （依頼 v11 の 2）。
                    // ServiceSim/ がまだ自分のワークスペースになっていないと、
                    // cm は1つ上（/Users/shimazueiichi/Robotics_UX）の古いワークスペースの
                    // 版（Robotics_UX_New の cs:37 など）を返す。
                    // その番号を台帳に書くと、どの版で回したのか分からなくなるので弾く
                    string repo = f.Length > 2 ? f[2] : "";
                    if (repo.IndexOf(ExpectRepoName, StringComparison.Ordinal) < 0)
                    {
                        Debug.LogError("[ServiceSim] cm が別のワークスペースの版を返しました: " + l +
                                       "\n  このプロジェクト（" + Directory.GetCurrentDirectory() +
                                       "）を Unity Version Control のワークスペースにしてください。" +
                                       "\n  いまのままだと、台帳の unity_cs に1つ上の" +
                                       "ワークスペースの番号が入ってしまいます");
                        cs = ServiceSimLedger.CsPrefix + "不明";
                        pendingNote = "不明（cm が " + repo + " の版を返しました。" +
                                      "このプロジェクトがワークスペースになっていません）";
                        return;
                    }
                    continue;
                }
                if (l.StartsWith("PR ")) privateItems++;
                else controlled++;
            }

            int all = controlled + privateItems;
            if (all > 0)
                pendingNote = "保留あり " + all + " 件（管理下 " + controlled +
                              " 件・置いてあるだけ " + privateItems + " 件）";
            if (cs.Length == 0) cs = ServiceSimLedger.CsPrefix + "不明";
        }

        static string RunCm(string args)
        {
            string[] paths = { "/usr/local/bin/cm", "/opt/homebrew/bin/cm", "cm" };
            for (int i = 0; i < paths.Length; i++)
            {
                try
                {
                    Process p = new Process();
                    p.StartInfo.FileName = paths[i];
                    p.StartInfo.Arguments = args;
                    p.StartInfo.WorkingDirectory = Directory.GetCurrentDirectory();
                    p.StartInfo.UseShellExecute = false;
                    p.StartInfo.RedirectStandardOutput = true;
                    p.StartInfo.RedirectStandardError = true;
                    p.StartInfo.CreateNoWindow = true;
                    p.Start();
                    string outp = p.StandardOutput.ReadToEnd();
                    p.WaitForExit(15000);
                    if (!string.IsNullOrEmpty(outp)) return outp;
                }
                catch (Exception)
                {
                    // 次の場所を試す
                }
            }
            return null;
        }

        /// <summary>
        /// いま回している並びの名札（ログに出す。依頼 v11 の 2）。
        /// StartBatch が EditorPrefs に入れた名札を読む。無ければ段階B扱い
        /// </summary>
        static string BatchLabel()
        {
            string l = EditorPrefs.GetString(KeyLabel, LabelStageB);
            return l == LabelStageB ? "段階B" : l;
        }

        static string[] Split(string s)
        {
            if (string.IsNullOrEmpty(s)) return new string[0];
            return s.Split(';');
        }

        static SimParams LoadParams()
        {
            SimParams sp = AssetDatabase.LoadAssetAtPath<SimParams>(ServiceSimSetup.SimParamsPath);
            if (sp == null)
            {
                Debug.LogError("[ServiceSim] SimParams が見つかりません: " + ServiceSimSetup.SimParamsPath);
                return null;
            }
            sp.Load("");
            if (sp.Conditions.Count == 0)
            {
                Debug.LogError("[ServiceSim] unity_conditions を読めません");
                return null;
            }
            return sp;
        }
    }
}
```

### Assets/ServiceSim/Editor/BuildHeadless.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEditor;
using UnityEditor.Build.Reporting;
using UnityEngine;

namespace ServiceSim.EditorTools
{
    /// <summary>
    /// 画面なしの実行ファイル（macOS の普通の player）を作る
    /// （2026-10-26・依頼 v12 の 1 章）。
    ///
    /// メニューからも、コマンドからも作れる:
    ///   メニュー  ServiceSim → 画面なしの実行ファイルを作る
    ///   コマンド  /Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity \
    ///               -batchmode -quit -nographics \
    ///               -projectPath /Users/shimazueiichi/Robotics_UX/ServiceSim \
    ///               -executeMethod ServiceSim.EditorTools.BuildHeadless.Build \
    ///               -logFile /dev/stdout
    ///
    /// 出力（VCS に入れない。ignore.conf に Builds/ を入れてある）
    ///   Builds/headless/ServiceSim.app
    ///   Builds/headless/build_info.json
    ///
    /// 回し方は Dedicated Server ではなく **普通の macOS の player** にした。
    ///   ・-batchmode -nographics で画面を作らずに回せるので、これで足りる
    ///   ・Dedicated Server は別のモジュールが要り、入れる手間が増える
    ///   ・いまのコードは「画面なしでも同じ数字が出る」ことを確かめる段（5 章 B）なので、
    ///     段階Bと同じ player のまま回すほうが、違いの元が増えない
    ///
    /// シーンはここで直接指定する（ビルド設定に何が入っていても、
    /// 作られる実行ファイルの中身が変わらないようにするため。1 章）。
    /// </summary>
    public static class BuildHeadless
    {
        /// <summary>出力の置き場所（プロジェクトの中。VCS に入れない）</summary>
        public const string OutDir = "Builds/headless";

        /// <summary>実行ファイルの名前。tools/night_runner.py の DEFAULT_BUILD と合わせる</summary>
        public const string AppName = "ServiceSim.app";

        /// <summary>そばに置く覚え書き</summary>
        public const string InfoName = "build_info.json";

        [MenuItem("ServiceSim/画面なしの実行ファイルを作る", false, 60)]
        public static void Build()
        {
            string err;
            if (!BuildTo(OutDir, out err))
            {
                Debug.LogError("[ServiceSim] 画面なしの実行ファイルを作れませんでした\n  " + err);
                if (Application.isBatchMode) EditorApplication.Exit(1);
                return;
            }
            if (Application.isBatchMode) EditorApplication.Exit(0);
        }

        /// <summary>
        /// 作る本体。できたら true。
        /// 中で ProjectSettings やビルド設定（EditorBuildSettings）は書き換えない
        /// </summary>
        public static bool BuildTo(string outDir, out string err)
        {
            err = "";

            if (!File.Exists(ServiceSimSetup.ScenePath))
            {
                err = "シーンがありません: " + ServiceSimSetup.ScenePath +
                      "\n  先に ServiceSim/Setup を実行してください";
                return false;
            }

            // 版と保留の数（台帳の unity_cs に入る字。段階Bと同じ関数を使う）
            string cs, pendingNote;
            ServiceSimStageB.ReadChangeset(out cs, out pendingNote);
            if (pendingNote.Length > 0)
                Debug.LogWarning("[ServiceSim] 保留中の変更があるまま作ります: " + pendingNote +
                                 "\n  この実行ファイルの中身は " + cs +
                                 " と同じではありません（台帳の unity_cs は " + cs + " になります）。" +
                                 "\n  夜の回に使うなら、先に check-in して保留 0 にしてから作り直してください");

            string root = Directory.GetCurrentDirectory();
            string dir = Path.IsPathRooted(outDir) ? outDir : Path.Combine(root, outDir);
            string appPath = Path.Combine(dir, AppName);

            try
            {
                if (!Directory.Exists(dir)) Directory.CreateDirectory(dir);
                // 前の版が残っていると、古い実行ファイルで夜を回してしまう。消して作り直す
                if (Directory.Exists(appPath)) Directory.Delete(appPath, true);
            }
            catch (Exception e)
            {
                err = "置き場所を用意できません: " + dir + "\n  " + e.Message;
                return false;
            }

            // Apple Silicon で動く形にする（Rosetta で動くと遅くなり、
            // 何本同時に回せるか（5 章 C）の実測が変わってしまう）。
            // 設定は PlaybackEngines の DLL の中にあり、Unity 抜きの組み立てでは
            // 参照できないので、反射で入れる（入らなくてもビルドは続ける）
            string archNote = SetAppleSiliconArchitecture();

            BuildPlayerOptions opt = new BuildPlayerOptions();
            opt.scenes = new string[] { ServiceSimSetup.ScenePath };   // シーンはここで直接指定（1 章）
            opt.locationPathName = appPath;
            opt.target = BuildTarget.StandaloneOSX;
            opt.targetGroup = BuildTargetGroup.Standalone;
            opt.options = BuildOptions.None;

            BuildReport report = BuildPipeline.BuildPlayer(opt);
            if (report == null)
            {
                err = "BuildPipeline が何も返しませんでした";
                return false;
            }
            BuildSummary sum = report.summary;
            if (sum.result != BuildResult.Succeeded)
            {
                err = "ビルドが " + sum.result + " で終わりました（エラー " + sum.totalErrors + " 件）";
                return false;
            }

            string exe = Path.Combine(appPath, "Contents", "MacOS");
            string exeNote = Directory.Exists(exe) ? FirstFile(exe) : "";

            string buildId = BuildId(cs);
            string infoPath = Path.Combine(dir, InfoName);
            if (!WriteInfo(infoPath, buildId, cs, pendingNote, appPath, exeNote, sum, archNote, out err))
                return false;

            Debug.Log("[ServiceSim] 画面なしの実行ファイルを作りました" +
                      "\n  " + appPath +
                      "\n  実行ファイル: " + (exeNote.Length > 0 ? Path.Combine(exe, exeNote) : "（見つかりません）") +
                      "\n  版: " + cs + (pendingNote.Length > 0 ? "（" + pendingNote + "）" : "（保留 0）") +
                      "\n  build_id: " + buildId +
                      "\n  覚え書き: " + infoPath +
                      "\n  大きさ: " + (sum.totalSize / (1024f * 1024f)).ToString("0.0") + " MB" +
                      " / かかった時間: " + sum.totalTime.TotalSeconds.ToString("0") + " 秒" +
                      "\n  作り方: " + archNote +
                      "\n  回し方: " + (exeNote.Length > 0 ? Path.Combine(exe, exeNote) : "<実行ファイル>") +
                      " -batchmode -nographics -ssCondition fam_p1_s4_r1_a42 -ssSeed 1");
            return true;
        }

        /// <summary>
        /// build_info.json（依頼 v12 の 1 章）。
        /// unity_cs と build_id は、夜の実行係（night_runner.py）と
        /// 実行ファイルの中（HeadlessRun）の両方が読む。
        /// 名前を変えるときは、tools/night_runner.py の read_unity_cs も直すこと
        /// </summary>
        static bool WriteInfo(string path, string buildId, string cs, string pendingNote,
                              string appPath, string exeName, BuildSummary sum,
                              string archNote, out string err)
        {
            err = "";
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"schema\": \"servicesim-build-info/0.1\",\n");
            sb.Append("  \"build_id\": ").Append(Str(buildId)).Append(",\n");
            sb.Append("  \"unity_cs\": ").Append(Str(cs)).Append(",\n");
            sb.Append("  \"pending_changes\": ").Append(Str(pendingNote)).Append(",\n");
            sb.Append("  \"built_at\": ")
              .Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture))).Append(",\n");
            sb.Append("  \"editor_version\": ").Append(Str(Application.unityVersion)).Append(",\n");
            sb.Append("  \"target\": ").Append(Str(BuildTarget.StandaloneOSX.ToString())).Append(",\n");
            sb.Append("  \"architecture\": ").Append(Str(archNote)).Append(",\n");
            sb.Append("  \"scene\": ").Append(Str(ServiceSimSetup.ScenePath)).Append(",\n");
            sb.Append("  \"app\": ").Append(Str(Rel(appPath))).Append(",\n");
            sb.Append("  \"exe\": ").Append(Str(exeName)).Append(",\n");
            sb.Append("  \"ledger_schema_version\": ").Append(ServiceSimLedger.SchemaVersion).Append(",\n");
            sb.Append("  \"total_size_bytes\": ").Append(sum.totalSize).Append(",\n");
            sb.Append("  \"build_seconds\": ")
              .Append(sum.totalTime.TotalSeconds.ToString("0.0", CultureInfo.InvariantCulture)).Append("\n");
            sb.Append("}\n");

            try
            {
                File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
            }
            catch (Exception e)
            {
                err = "build_info.json を書けません: " + path + "\n  " + e.Message;
                return false;
            }
            return true;
        }

        /// <summary>
        /// この実行ファイルの名札。版と作った時刻から作る。
        /// 例 cs12_20261026_1305（保留があれば頭に dirty_ を付ける）
        /// </summary>
        static string BuildId(string cs)
        {
            string n = "";
            int at = (cs ?? "").IndexOf("cs:", StringComparison.Ordinal);
            if (at >= 0)
                for (int i = at + 3; i < cs.Length && cs[i] >= '0' && cs[i] <= '9'; i++) n += cs[i];
            if (n.Length == 0) n = "unknown";
            return "cs" + n + "_" + DateTime.Now.ToString("yyyyMMdd_HHmm", CultureInfo.InvariantCulture);
        }

        /// <summary>
        /// Apple Silicon 向けにする。
        /// UnityEditor.OSXStandalone.UserBuildSettings.architecture を反射で入れる
        /// （この型は PlaybackEngines の DLL にあり、Unity 抜きの組み立てでは参照できない）。
        /// 入れられなければ、そのことを書いて既定のまま作る
        /// </summary>
        static string SetAppleSiliconArchitecture()
        {
            try
            {
                Type t = null;
                System.Reflection.Assembly[] all = AppDomain.CurrentDomain.GetAssemblies();
                for (int i = 0; i < all.Length && t == null; i++)
                    t = all[i].GetType("UnityEditor.OSXStandalone.UserBuildSettings", false);
                if (t == null) return "既定（UserBuildSettings が見つかりません）";

                System.Reflection.PropertyInfo prop = t.GetProperty("architecture",
                    System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
                if (prop == null) return "既定（architecture が見つかりません）";

                object arm = Enum.Parse(prop.PropertyType, "ARM64");
                prop.SetValue(null, arm, null);
                return "ARM64（Apple Silicon）";
            }
            catch (Exception e)
            {
                return "既定（入れられませんでした: " + e.Message + "）";
            }
        }

        static string FirstFile(string dir)
        {
            try
            {
                string[] files = Directory.GetFiles(dir);
                for (int i = 0; i < files.Length; i++)
                {
                    string name = Path.GetFileName(files[i]);
                    if (name.StartsWith(".", StringComparison.Ordinal)) continue;
                    return name;
                }
            }
            catch (Exception) { }
            return "";
        }

        static string Rel(string full)
        {
            string root = Directory.GetCurrentDirectory();
            return full.StartsWith(root, StringComparison.Ordinal)
                ? full.Substring(root.Length).TrimStart('/') : full;
        }

        static string Str(string s)
        {
            if (s == null) s = "";
            StringBuilder sb = new StringBuilder("\"");
            for (int i = 0; i < s.Length; i++)
            {
                char c = s[i];
                if (c == '"' || c == '\\') sb.Append('\\').Append(c);
                else if (c == '\n') sb.Append("\\n");
                else if (c == '\r') sb.Append("\\r");
                else if (c == '\t') sb.Append("\\t");
                else if (c < ' ') sb.Append(' ');
                else sb.Append(c);
            }
            return sb.Append('"').ToString();
        }
    }
}
```

### Assets/ServiceSim/Experiment/ExperimentPlan.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 回す条件と、書き出す場所。実験に関する数字と場所はすべてこの .asset に置く。
    /// 条件を順に回すのは ExperimentRunner（段階2で作る）。
    /// </summary>
    [CreateAssetMenu(menuName = "ServiceSim/Experiment Plan", fileName = "ExperimentPlan")]
    public class ExperimentPlan : ScriptableObject
    {
        public struct Condition
        {
            public string id;
            public int aisleWidthIndex;
            public float aisleWidthM;
            public CrowdLevel crowd;
            public int robots;
        }

        [Header("この回の名前")]
        public string runName = "family_v0";

        [Header("回す条件")]
        [Tooltip("細い通路の幅（StoreLayout.aisleWidthOptions の番号）")]
        public int[] aisleWidthIndices = { 0, 1, 2 };

        [Tooltip("混み具合")]
        public CrowdLevel[] crowdLevels = { CrowdLevel.Empty, CrowdLevel.Normal, CrowdLevel.Busy };

        [Tooltip("ロボットの台数")]
        public int[] robotCounts = { 1, 2 };

        [Header("1条件で回す量")]
        [Tooltip("1条件・1つの種で回す往復の数。台数を合わせた合計")]
        public int tripsPerCondition = 100;

        [Tooltip("乱数の種")]
        public int[] seeds = { 0, 1, 2, 3, 4 };

        [Tooltip("時間の進みを速める倍率")]
        public float timeScale = 1f;

        [Header("書き出す場所")]
        [Tooltip("KIOXIA がつながっているかを確かめる場所")]
        public string kioxiaVolumePath = "/Volumes/KIOXIA";

        [Tooltip("測ったデータの置き場所。この下に {YYYYMMDD}_{run_name}/ を作る")]
        public string kioxiaOutputRoot = "/Volumes/KIOXIA/r2r2r-data/service_sim/unity";

        [Header("段階2：1条件だけ試す")]
        [Tooltip("細い通路の幅（StoreLayout.aisleWidthOptions の番号）")]
        public int trialAisleWidthIndex = 1;

        [Tooltip("混み具合")]
        public CrowdLevel trialCrowd = CrowdLevel.Normal;

        [Tooltip("ロボットの台数")]
        public int trialRobots = 2;

        [Tooltip("乱数の種")]
        public int trialSeed = 0;

        [Tooltip("回す往復の数（台数を合わせた合計）。timeScale 1 で20分程度に収まる数にする")]
        public int trialTrips = 20;

        [Tooltip("速めるときの倍率")]
        public float trialTimeScaleFast = 10f;

        [Tooltip("1コマの時間を固定して回すときの、1コマの長さ（秒）")]
        public float trialCaptureDeltaTime = 0.02f;

        [Tooltip("実時間でこれを超えたら打ち切る（秒）。保険")]
        public float trialMaxWallSeconds = 2400f;

        [Tooltip("段階2の置き場所。Plastic のワークスペースと git の外にする（測定データとして残さない）")]
        public string trialOutputRoot = "/Users/shimazueiichi/ServiceSim_tmp";

        [Header("v2：1回の実行の区切り")]
        [Tooltip("1回の実行の長さ（店の中の分）。試算の peak_min と同じ")]
        public float measureMinutes = 120f;

        [Tooltip("慣らしの長さ（店の中の分）。この分は計測に数えない")]
        public float warmupMinutes = 15f;

        // ------------------------------------------------------------------
        // 同じ種でずれる元を切り分けるための試しの切り替え（依頼 v11 の 5 章）。
        //
        // ★ これは **調べるためだけ** の仕掛けです。check-in しません。
        //   切り分けが終わったら、どれを本採用にするか決めてから入れ直します。
        //   初期値はぜんぶ「切」で、切のときは cs:2 と同じ動きになります。
        // ------------------------------------------------------------------
        [Header("ずれの切り分け（試しだけ。check-in しない）")]
        // 試し1（毎コマの道探しの枠）は detCheckPathIterations を廃止し、
        // 下の pathIterationsPerFrame（回すたびに必ず入れる値）に一本化した。
        // 2026-10-26：切り替えを「切」にしても Unity の中の値が 5000 のまま残り、
        // 段階Bの18回（⑦）の待ちが短く出た疑いがあるため（隠れた設定を残さない）

        [Tooltip("試し2：人の道を同期で引く（SetDestination をやめ CalculatePath＋SetPath にする）。" +
                 "pathPending が true になる場面が無くなる")]
        public bool detCheckSyncPeoplePaths = false;

        [Tooltip("試し4（要るときだけ）：人の局所回避（RVO）を切る。動きが大きく変わる")]
        public bool detCheckNoAvoidance = false;

        [Tooltip("1コマごとの指紋を det_frames.csv に書く。どのコマで2回がずれたかを突き止める")]
        public bool detCheckFrameFingerprint = false;

        [Header("NavMesh の道探し（回すたびに必ず入れる。隠れた設定を残さない）")]
        [Tooltip("1コマで処理する道探しの節の数（NavMesh.pathfindingIterationsPerFrame）。" +
                 "Unity の既定は 100。**回し始めに毎回この値を入れ直す**ので、" +
                 "前の回や試しの値がエディタに残らない。" +
                 "実際に入った値は run_info.json の path_iterations_per_frame に出る。" +
                 "1 以上にすること（0 以下なら 100 を使う）")]
        public int pathIterationsPerFrame = 100;

        [Header("v2：速く回す")]
        [Tooltip("段階3で使う1コマの長さ（秒）。0.05 で約24倍速")]
        public float measureCaptureDeltaTime = 0.05f;

        [Tooltip("確かめ用に使う1コマの長さ（秒）。検査1・2でこちらと比べる")]
        public float referenceCaptureDeltaTime = 0.02f;

        [Tooltip("回している間、カメラを切って描画を止める。計測を速くするため初期値は ON")]
        public bool disableRenderingWhileRunning = true;

        [Tooltip("回している間も頭の上の字幕を出す（B11 を一時的に外す）。" +
                 "人がロボットをよける様子を見て絵を撮るときだけ ON にする。計測は遅くなる")]
        public bool showLabelsWhileRunning;

        [Tooltip("キッチンの屋根のモニターを出す（2026-10-23）。" +
                 "板1枚と文字だけなので、測る回でも出す。倍速が落ちるなど問題があれば切る")]
        public bool showKitchenMonitor = true;

        [Header("v2：段階3（18条件 × 種）")]
        [Tooltip("段階3の置き場所。この下に {YYYYMMDD}_{run_name}/{condition}_s{seed}/ を作る")]
        public string stage3OutputRoot = "/Users/shimazueiichi/ServiceSim_tmp/stage3";

        [Tooltip("すでに書き出してある条件は飛ばす（途中から再開する）")]
        public bool stage3Resume = true;

        [Header("スクリーンショット")]
        [Tooltip("段階1の報告に使う画面の置き場所")]
        public string screenshotDir = "/Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/unity/screens";

        public int screenshotWidth = 1920;
        public int screenshotHeight = 1080;

        /// <summary>条件の数（通路の幅 × 混み具合 × 台数）</summary>
        public int ConditionCount
        {
            get
            {
                int w = aisleWidthIndices != null ? aisleWidthIndices.Length : 0;
                int c = crowdLevels != null ? crowdLevels.Length : 0;
                int r = robotCounts != null ? robotCounts.Length : 0;
                return w * c * r;
            }
        }

        /// <summary>18条件を順に並べる</summary>
        public List<Condition> BuildConditions(StoreLayout layout)
        {
            List<Condition> list = new List<Condition>();
            if (aisleWidthIndices == null || crowdLevels == null || robotCounts == null) return list;

            for (int wi = 0; wi < aisleWidthIndices.Length; wi++)
            {
                int widthIndex = aisleWidthIndices[wi];
                float widthM = 0f;
                if (layout != null && layout.aisleWidthOptions != null &&
                    widthIndex >= 0 && widthIndex < layout.aisleWidthOptions.Length)
                    widthM = layout.aisleWidthOptions[widthIndex];

                for (int ci = 0; ci < crowdLevels.Length; ci++)
                {
                    for (int ri = 0; ri < robotCounts.Length; ri++)
                    {
                        Condition c = new Condition();
                        c.aisleWidthIndex = widthIndex;
                        c.aisleWidthM = widthM;
                        c.crowd = crowdLevels[ci];
                        c.robots = robotCounts[ri];
                        c.id = ConditionId(widthM, c.crowd, c.robots);
                        list.Add(c);
                    }
                }
            }
            return list;
        }

        public static string ConditionId(float aisleWidthM, CrowdLevel crowd, int robots)
        {
            int mm = Mathf.RoundToInt(aisleWidthM * 100f);
            return "w" + mm.ToString("000") + "_" + CrowdName(crowd) + "_r" + robots.ToString();
        }

        public static string CrowdName(CrowdLevel crowd)
        {
            switch (crowd)
            {
                case CrowdLevel.Empty: return "empty";
                case CrowdLevel.Busy: return "busy";
                default: return "normal";
            }
        }
    }
}
```

### Assets/ServiceSim/Experiment/ExperimentRunner.cs

```csharp
using System.Collections.Generic;
using System.IO;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 条件を回して記録する。1回の Play の中で、条件を順に通せる（段階3の Run All）。
    /// 数字と場所は ExperimentPlan の .asset に置く。回し方だけメニューが決める。
    ///
    /// この script は実験だけを扱う。店の運営は ServiceOps、人は GroupManager に任せる。
    /// </summary>
    public class ExperimentRunner : MonoBehaviour
    {
        /// <summary>時間の進め方</summary>
        public enum TimeMode { RealTime, FastTimeScale, FixedStep }

        /// <summary>回す条件1つ</summary>
        [System.Serializable]
        public class Job
        {
            public int aisleWidthIndex;
            public CrowdLevel crowd;
            public int robots;
            public int seed;
            public string folder;
            public string conditionId;

            /// <summary>
            /// 卓の停止点を通路の中心からずらす距離（m）。メニューが決める（C1）。
            /// applyDeliveryOffset が false のときは、.asset の値をそのまま使う。
            /// true のときも、使うのはこの回だけ。回し終わりに .asset の値へ戻すので、
            /// .asset は書き換わらない（run_info.json の delivery_offset_used_m に実際の値が出る）。
            /// </summary>
            public bool applyDeliveryOffset;
            public float deliveryOffset;

            // ---- サイトの条件で回す（v5 3章）----
            /// <summary>
            /// サイトの条件（unity_conditions）で回すか。
            /// true なら、店の回し方の数字は serving-robot-params.json から読み、
            /// 始め方もサイトと同じ「空の店から peak_min 分だけ来客し、全員が帰るまで」にする。
            /// </summary>
            public bool siteRun;

            /// <summary>unity_conditions の何番めか（siteRun のとき）</summary>
            public int conditionIndex = -1;

            /// <summary>試算の stores の名前（fam_p1 / fam_p5）。報告とフォルダ名に使う</summary>
            public string storeKey = "";
        }

        [Header("参照（Setup が入れる）")]
        public ExperimentPlan plan;
        public ServiceSpec service;

        [Tooltip("サイトの試算の値（v5 3章）。サイトの条件で回すときに読む")]
        public SimParams simParams;

        public StoreBuilder store;
        public NavMeshBaker baker;
        public PeopleSpawner people;
        public ServiceOps ops;
        public GroupManager groups;
        public JamWatcher jams;

        [Tooltip("キッチンの屋根のモニター（2026-10-23）。測る回でも出す")]
        public KitchenMonitor monitor;
        public RobotSpec robotSpec;
        public GameObject robotPrefab;
        public Transform robotsRoot;

        [Header("回し方（メニューが決める）")]
        public bool runOnPlay;

        /// <summary>
        /// 見る用の回か。字幕と描画をこの回だけ出し、時間は1倍速で進める。
        /// 設定ファイル（ExperimentPlan）は書き換えない。計測の比べには使わない。
        /// </summary>
        public bool viewMode;
        public TimeMode timeMode = TimeMode.FixedStep;
        public string runLabel = "v2";
        public float captureDeltaTimeOverride;

        [Tooltip("1回の実時間の上限（秒）。0 なら上限なし。" +
                 "超えたらその回を書き出して止める（段階B。台帳の status は stopped）。" +
                 "シミュレーションの動きは変えない")]
        public float maxWallSecondsPerRun;

        /// <summary>
        /// 1回の実時間の上限（秒）の既定。段階Bの Run All（40 分）と、
        /// 画面なしの実行ファイル（依頼 v12 の 2 章）で **同じ値** を使う。
        /// 2か所に書くと片方だけ変わるので、置き場所はここ1つにする
        /// </summary>
        public const float DefaultMaxWallSecondsPerRun = 40f * 60f;

        [Tooltip("回し始めの Unity Version Control の changeset（段階Bのメニューが入れる）。run_info に残す")]
        public string unityCs = "";

        [Tooltip("回し始めに保留中の変更があったか（段階Bのメニューが入れる）。run_info に残す")]
        public string pendingNote = "";

        [Header("いまの様子（読むだけ）")]
        [SerializeField] int _jobIndex;
        [SerializeField] int _jobCount;
        [SerializeField] float _simMinutes;
        [SerializeField] float _wallSeconds;
        [SerializeField] int _runsDone;
        [SerializeField] bool _running;

        // Play に入るとき、Unity はいちど script を読み直す（Domain Reload）。
        // そのときに消えないように、回す条件は .unity に書き出して持たせる。
        // メニューが入れた条件が Start() まで残らないと「回す条件がありません」で止まる。
        [SerializeField] List<Job> _queue = new List<Job>();

        readonly TripLogger _logger = new TripLogger();
        readonly List<ServingRobotController> _robots = new List<ServingRobotController>();
        readonly TripLogger.RunInfo _info = new TripLogger.RunInfo();
        readonly List<int> _walkerSamples = new List<int>();

        /// <summary>時間帯ごとの記録（依頼 v10b の 2。timeline.csv）</summary>
        readonly TimelineLogger _timeline = new TimelineLogger();

        /// <summary>
        /// ロボットどうしのすれ違いを数える（依頼 v13 の 依頼1。passes.csv）。
        /// 見るだけなので、passByEnabled が切の回でも同じように数える
        /// </summary>
        readonly PassByWatch _passBy = new PassByWatch();

        /// <summary>次に timeline の1行を取る時刻（店の中, 秒）</summary>
        float _nextTimelineS;

        /// <summary>timeline の間（秒）。店の中の時間で 60 秒ごと</summary>
        const float TimelineStepS = 60f;

        /// <summary>計測が始まってからこの回数走っても記録が0なら、取りこぼしとみて止める</summary>
        const int TripRecordAlarmRuns = 3;

        Job _job;

        // 停止点は、メニューが決めた値をこの回だけ使う（C1）。
        // .asset を書き換えたままにしないよう、もとの値を覚えて回し終わりに戻す。
        float _savedDeliveryOffset;
        bool _deliveryOffsetSaved;

        // サイトの試算の値も、この回だけ入れて回し終わりに戻す（v5 3章）
        readonly SiteValues _siteValues = new SiteValues();
        SimParams.Condition _condition;

        /// <summary>来客を受け付け終わる時刻（店の中, 秒）。siteRun のときだけ使う</summary>
        float _arrivalsEndS;

        /// <summary>全員が帰るのを待つ上限（店の中, 秒）。これを超えたら打ち切る</summary>
        float _hardEndS;

        /// <summary>全員が帰って終わったか（打ち切りでないか）</summary>
        bool _everyoneLeft;

        float _endS, _measureStartS, _wallStart, _lastLog, _timeOffset;
        int _tripSeq;
        int _runsFinished;        // ロボットが走り終わった回数（慣らしも含む）
        int _runsAfterMeasure;    // 計測が始まってから走り終わった回数
        bool _stoppedEarly;
        bool _jobRunning;

        /// <summary>
        /// 1倍で回している（見る専用）か。2026-10-26・依頼 v13 の 依頼2。
        /// true のときは記録を書き出さない
        /// </summary>
        bool _viewOnly;

        // ---- 誰が回したか（依頼 v12 の 2 章・2026-10-26。run_info.json に残す）----
        /// <summary>"editor"（段階B・Site Run）/ "headless"（画面なしの実行ファイル）</summary>
        [HideInInspector] public string runnerName = "editor";

        /// <summary>画面なしの実行ファイルの版（build_info.json の build_id）</summary>
        [HideInInspector] public string buildId = "";

        /// <summary>その夜の名前（例 20261026）</summary>
        [HideInInspector] public string night = "";

        /// <summary>そのとき同時に走っていた本数</summary>
        [HideInInspector] public int parallel;

        /// <summary>この条件ぶんを書き出したか（途中で止めたときに二重に書かないため。v5 3章）</summary>
        bool _outputsWritten;

        public bool Running { get { return _running; } }

        /// <summary>
        /// 合わない値があって回さずに抜けたか（AbortRun を通ったか）。
        /// 画面なしの実行（HeadlessRun）が、待たずに失敗と分かるために読む
        /// （2026-10-26・依頼 v12 の 2 章）
        /// </summary>
        public bool Aborted { get { return _aborted; } }
        bool _aborted;

        /// <summary>回す条件を1つだけ入れる（段階2のやり方）</summary>
        public void SetSingleJob(Job job)
        {
            _queue.Clear();
            _queue.Add(job);
        }

        /// <summary>回す条件をまとめて入れる（段階3の Run All）</summary>
        public void SetQueue(IList<Job> jobs)
        {
            _queue.Clear();
            if (jobs != null) for (int i = 0; i < jobs.Count; i++) _queue.Add(jobs[i]);
        }

        void Start()
        {
            if (!runOnPlay) return;
            if (!Resolve()) return;
            if (_queue.Count == 0) { Debug.LogError("[ServiceSim] 回す条件がありません"); return; }

            Application.runInBackground = true;
            ApplyTimeMode();
            // 見る用の回は、描画を止めない（字幕も ServiceSimLabels が出す）
            // 画面なしの実行では、描くものが無いのでカメラも切る（依頼 v12 の 2 章）。
            // -nographics では元から何も描かれないが、はっきり切っておく
            if ((plan.disableRenderingWhileRunning || Application.isBatchMode) && !viewMode) DisableCameras();

            _jobIndex = 0;
            _jobCount = _queue.Count;
            _runsDone = 0;
            _wallStart = Time.realtimeSinceStartup;
            _running = true;
            StartJob(_queue[0]);
        }

        bool Resolve()
        {
            if (store == null) store = FindObjectOfType<StoreBuilder>();
            if (baker == null) baker = FindObjectOfType<NavMeshBaker>();
            if (people == null) people = FindObjectOfType<PeopleSpawner>();
            if (ops == null) ops = FindObjectOfType<ServiceOps>();
            if (groups == null) groups = FindObjectOfType<GroupManager>();
            if (jams == null) jams = FindObjectOfType<JamWatcher>();
            if (monitor == null) monitor = FindObjectOfType<KitchenMonitor>();
            if (robotsRoot == null && store != null && store.transform.parent != null)
                robotsRoot = store.transform.parent.Find("Robots");

            if (plan == null || service == null || store == null || baker == null || people == null ||
                ops == null || groups == null || jams == null || robotPrefab == null ||
                robotsRoot == null || store.layout == null)
            {
                Debug.LogError("[ServiceSim] ExperimentRunner の参照が足りません。ServiceSim/Setup を実行してください");
                return false;
            }
            return true;
        }

        void ApplyTimeMode()
        {
            Time.captureDeltaTime = 0f;
            switch (timeMode)
            {
                case TimeMode.RealTime: Time.timeScale = 1f; break;
                case TimeMode.FastTimeScale: Time.timeScale = plan.trialTimeScaleFast; break;
                default:
                    Time.timeScale = 1f;
                    Time.captureDeltaTime = captureDeltaTimeOverride > 0f
                        ? captureDeltaTimeOverride : plan.measureCaptureDeltaTime;
                    break;
            }
        }

        void DisableCameras()
        {
            Camera[] cams = FindObjectsOfType<Camera>();
            for (int i = 0; i < cams.Length; i++) cams[i].enabled = false;
        }

        // ---------------- 1条件ぶん ----------------

        void StartJob(Job job)
        {
            _job = job;
            _logger.Clear();
            _info.frameDeltas.Clear();
            _info.robotSteps.Clear();
            _info.peopleSteps.Clear();
            _info.chairPullsByReason.Clear();
            _info.chairPulls = 0;
            // 依頼 v9：道探しの時間と、寄せた回数・引けなかった回数は回ごとに数え直す
            _info.planMs.Clear();
            _info.mapSnapStarts = 0;
            _info.planFails = 0;
            _walkerSamples.Clear();
            _timeline.Clear();              // 時間帯ごとの記録（依頼 v10b の 2）
            _tripSeq = 0;
            _runsFinished = 0;
            _runsAfterMeasure = 0;
            _stoppedEarly = false;
            _everyoneLeft = false;
            _outputsWritten = false;
            _condition = null;

            StoreLayout layout = store.layout;
            layout.aisleWidthIndex = job.aisleWidthIndex;

            // 停止点は条件の1つ（C1）。メニューが決めた値を、組み立てる前に入れる。
            // .asset のもとの値は覚えておき、ぜんぶ終わったら戻す（.asset は書き換えない）。
            if (!_deliveryOffsetSaved)
            {
                _savedDeliveryOffset = layout.deliveryOffsetFromAisleCenter;
                _deliveryOffsetSaved = true;
            }
            layout.deliveryOffsetFromAisleCenter = job.applyDeliveryOffset
                ? job.deliveryOffset : _savedDeliveryOffset;

            job.conditionId = ExperimentPlan.ConditionId(layout.AisleWidth, job.crowd, job.robots);

            // サイトの条件で回すとき（v5 3章）。
            // 建てる前に値を入れる（卓の数・来客のペース・時間の決まりが組み立てと来客に効く）。
            // 1つでも読めない値があれば、回さずに止める（Unity の .asset で埋めない）
            if (job.siteRun && !ApplySiteValues(job))
            {
                AbortRun();
                return;
            }

            store.Build();

            // 建てた卓の数が、試算の stores.tables と違ったら回さない（v5 3章）。
            // 店の形（JSON）が合わないときは Unity で埋めずに止める
            if (job.siteRun && !TablesMatchSite(job))
            {
                AbortRun();
                return;
            }

            baker.Bake();

            // 見る用の回は、床の塗り分けを Build Store と同じに作り直す。
            // 計測の回では作らない（見た目だけのものなので、速さを落とさない）
            if (viewMode) store.PaintFloor(baker, people.spec != null ? people.spec.personRadius : 0.2f);

            ops.spec = service;
            ops.robotSpec = robotSpec;
            ops.store = store;
            ops.Setup();

            people.service = service;
            people.ops = ops;
            people.Spawn(job.seed);

            groups.spec = service;
            groups.people = people.spec;
            groups.ops = ops;
            groups.store = store;
            groups.spawner = people;     // 人の動きに合わせて椅子を引く（B6）
            groups.bodyRoot = transform;

            jams.spec = people.spec;
            jams.ops = ops;
            jams.store = store;
            jams.Setup();

            // キッチンのモニター（測る回でも出す。切り替えは ExperimentPlan）
            if (monitor != null)
            {
                monitor.store = store;
                monitor.ops = ops;
                monitor.groups = groups;
                monitor.people = people;
                // 画面なしの実行（夜の実行ファイル）では出さない（依頼 v12 の 2 章）。
                // 吹き出しを描くのは Editor だけの ServiceSimLabels なので、
                // player では文を作っても誰も見ない。作るだけ無駄になる。
                // 文を作るか作らないかは、店の回し方・数字には1つも効かない
                monitor.show = plan.showKitchenMonitor && !Application.isBatchMode;
            }

            SpawnRobots(job.robots);

            // 始め方（v5 3章）。
            //   サイトの条件 … 空の店から、peak_min 分だけ来客し、全員が帰るまで回す
            //                  （max_extra_min で打ち切り）。慣らしは無し
            //   それ以外     … これまでどおり、8割埋まった店＋慣らし 15 分
            if (job.siteRun)
            {
                groups.seedWarmupGroups = false;
                _arrivalsEndS = service.peakMinutes * 60f;
                groups.arrivalsEndS = _arrivalsEndS;
                _hardEndS = _arrivalsEndS + service.maxExtraMin * 60f;
                _measureStartS = 0f;
                _endS = _hardEndS;
            }
            else
            {
                groups.seedWarmupGroups = true;
                groups.arrivalsEndS = 0f;
                _arrivalsEndS = 0f;
                _hardEndS = 0f;
                _measureStartS = plan.warmupMinutes * 60f;
                _endS = (plan.warmupMinutes + plan.measureMinutes) * 60f;
            }

            // 時間帯ごとの記録は、来客が始まるところから取る（依頼 v10b の 2）。
            // サイトの条件では _measureStartS が 0（空の店から）なので t_s = 0 が1行め
            _nextTimelineS = _measureStartS;

            groups.Setup(job.crowd, job.seed, people.Targets);
            ops.SetStaffCount(people.StaffSpawned);

            _timeOffset = Time.time;
            _lastLog = 0f;
            _jobRunning = true;

            ApplyDetCheck();

            // 2026-10-26（依頼 v13 の 依頼2）：1倍で回した回は **見る専用**。
            // 記録を書き出さない（出力フォルダも作らない）。
            // 1倍の切り替えは SimHud にあり、初期値は切なので測る回には効かない
            SimHud hud = FindObjectOfType<SimHud>();
            _viewOnly = hud != null && hud.RealTimeNow;
            if (_viewOnly)
                Debug.Log("[ServiceSim] 1倍で回します（SimHud）。" +
                          "この回は見る専用で、記録を書き出しません（出力フォルダも作りません）");

            // 2026-10-26（依頼 v13 の直し③）：向かい合いを場所で分けて数えるため、
            // 受け渡し口とロボットの待機場所を渡す
            if (store != null) _passBy.SetPlaces(store.PassPoint, store.RobotStandbyPoints);

            // 回し始めの行に、Unity だけの決まりの切り替えを必ず出す
            string how = "\n  人のよけ方: " +
                         (people.spec != null && people.spec.staffFirstYield ? "店員優先の版" : "今のよけ方") +
                         " / 同じ通路に2台を集めない: " +
                         (robotSpec != null && robotSpec.oneRobotPerAisle ? "入れた" : "入れない") +
                         " / 戻りの線: " +
                         (robotSpec != null && robotSpec.useReturnLine ? "使う" : "使わない") +
                         " / キッチンのモニター: " + (plan.showKitchenMonitor ? "出す" : "出さない") +
                         // 2026-10-26：道探しの枠を回し始めの行に必ず出す（隠れた設定を残さない）
                         " / 道探しの枠: " + _pathIterationsUsed + " 回/コマ" +
                         // 依頼 v13 の 依頼1：すれ違いの入切を、回し始めの行に必ず出す
                         " / すれ違い: " +
                         (robotSpec != null && robotSpec.passByEnabled
                            ? "入（速さ " + robotSpec.passBySpeedMps.ToString("0.##") + " m/秒・間 " +
                              robotSpec.passBySeparationM.ToString("0.##") + " m）"
                            : "切（cs:3 と同じ動き）");

            Debug.Log("[ServiceSim] 回し始め " + (_jobIndex + 1) + "/" + _jobCount +
                      "：" + job.conditionId + " 種" + job.seed + how +
                      (job.siteRun
                        ? "（サイトの条件。空の店から来客 " + service.peakMinutes +
                          " 分 ＋ 全員が帰るまで（上限 " + service.maxExtraMin + " 分）" +
                          "・スタッフ " + people.StaffSpawned + " 人・ロボット " + job.robots + " 台）"
                        : "（慣らし " + plan.warmupMinutes + " 分 ＋ 計測 " + plan.measureMinutes + " 分）"));
        }

        /// <summary>
        /// 合わない値があって回せないとき。書き出さずに Play を抜ける（v5 3章）。
        /// .asset に入れた値は、OnDisable と同じようにもとへ戻す
        /// </summary>
        void AbortRun()
        {
            _aborted = true;
            _running = false;
            _jobRunning = false;
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            Time.timeScale = 1f;
            Debug.LogError("[ServiceSim] 回しませんでした。Console の上の行を見てください");
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#endif
        }

        /// <summary>
        /// 建てた卓の数が、試算の stores.{店}.tables と合っているか（v5 3章）。
        /// 合わなければ、どちらかが古い。Unity で埋めずに止める
        /// </summary>
        bool TablesMatchSite(Job job)
        {
            JsonStoreLayout js = store.layout as JsonStoreLayout;
            if (js == null || simParams == null) return true;

            // ops.Setup() はこのあとなので、建てた数は店の形（JSON）から数えたものを見る
            int want = Mathf.RoundToInt(simParams.StoreValue(job.storeKey, "tables", -1f));
            int got = js.TableCount;
            if (want < 0 || want == got) return true;

            Debug.LogError("[ServiceSim] 卓の数が合いません（回しません）：試算 stores." + job.storeKey +
                           ".tables = " + want + " / 建てた店 = " + got +
                           "\n  どちらかが古いので、サイトから写し直してください");
            return false;
        }

        /// <summary>
        /// サイトの試算の値を、この回だけ ServiceSpec / PeopleSpec / RobotSpec に入れる（v5 3章）。
        /// 版の番号（店の形の meta.generation ↔ 試算の meta.layout_generation）が合わなければ回さない。
        /// 読めない値が1つでもあれば回さない（Unity の .asset で埋めない）。
        /// </summary>
        bool ApplySiteValues(Job job)
        {
            if (simParams == null)
            {
                Debug.LogError("[ServiceSim] SimParams が入っていません（ServiceSim/Setup を実行してください）");
                return false;
            }

            JsonStoreLayout js = store.layout as JsonStoreLayout;
            if (js == null)
            {
                Debug.LogError("[ServiceSim] サイトの条件で回すには、JSON から建てた店（JsonStoreLayout）が要ります。" +
                               "いまの店: " + store.layout);
                return false;
            }

            string storeKey = string.IsNullOrEmpty(job.storeKey) ? js.ParamsStoreKey : job.storeKey;
            job.storeKey = storeKey;

            if (!simParams.Load(js.ReadGeneration()))
            {
                string why = "";
                for (int i = 0; i < simParams.Problems.Count; i++) why += "\n  - " + simParams.Problems[i];
                Debug.LogError("[ServiceSim] 試算の値を読めません（回しません）" + why);
                return false;
            }

            _condition = job.conditionIndex >= 0 ? simParams.ConditionAt(job.conditionIndex) : null;
            if (_condition == null)
            {
                Debug.LogError("[ServiceSim] 条件 " + job.conditionIndex + " が unity_conditions にありません");
                return false;
            }
            if (_condition.store != storeKey)
            {
                Debug.LogError("[ServiceSim] 条件の店（" + _condition.store + "）と、建てる店（" +
                               storeKey + "）が違います");
                return false;
            }

            if (!_siteValues.Apply(simParams, storeKey, _condition, service, people.spec, robotSpec))
            {
                string why = "";
                for (int i = 0; i < _siteValues.Problems.Count; i++) why += "\n  - " + _siteValues.Problems[i];
                Debug.LogError("[ServiceSim] 試算の値が足りません（回しません）" + why);
                return false;
            }

            job.robots = _condition.robots;
            job.conditionId = _condition.Id;
            Debug.Log("[ServiceSim] " + _siteValues.Report());
            return true;
        }

        void SpawnRobots(int count)
        {
            // 往復の記録は、ServiceOps が片づけを終えてから書く（依頼 v8 の 2）。
            // ロボットの TripCompleted を直に受けると、呼ばれる順番によって
            // LastBatchOf が1つ前の往復の皿を返していた
            ops.RunRecorded -= OnRunDone;
            ops.RunRecorded += OnRunDone;

            ops.ClearRobots();
            for (int i = robotsRoot.childCount - 1; i >= 0; i--) Destroy(robotsRoot.GetChild(i).gameObject);
            _robots.Clear();

            // 置き始めは、1台に1つの待機場所へ（v5）。
            // 受け渡し口の前に重ねて置くと、はじめからふさぎ合う
            Vector3 pass = store.PassPoint;
            IList<Vector3> standby = store.RobotStandbyPoints;
            for (int i = 0; i < count; i++)
            {
                GameObject go = Instantiate(robotPrefab, robotsRoot);
                go.name = "ServingRobot" + (i + 1).ToString("00");
                ServingRobotController c = go.GetComponent<ServingRobotController>();
                if (c == null) { Destroy(go); continue; }
                c.robotId = i + 1;
                Vector3 start = standby.Count > 0
                    ? standby[i % standby.Count]
                    : pass + new Vector3(0f, 0f, -0.8f * i);
                c.PlaceAt(start, Quaternion.LookRotation(Vector3.back, Vector3.up));
                _robots.Add(c);
                ops.RegisterRobot(c);
            }
        }

        void Update()
        {
            if (!_running || !_jobRunning) return;

            float dt = Time.deltaTime;
            _wallSeconds = Time.realtimeSinceStartup - _wallStart;
            _info.frameDeltas.Add(dt);

            // 実時間の上限（段階B）。超えたら書き出して止める。
            // シミュレーションの動きは変えない。回しっぱなしで1回に居座らないための保険
            if (maxWallSecondsPerRun > 0f && _wallSeconds >= maxWallSecondsPerRun)
            {
                Debug.LogWarning("[ServiceSim] 実時間の上限を超えたので、この回を止めます: " +
                                 (_wallSeconds / 60f).ToString("0.0") + " 分 ≥ " +
                                 (maxWallSecondsPerRun / 60f).ToString("0.0") + " 分" +
                                 "\n  店の中 " + (ops != null ? (ops.NowS / 60f).ToString("0.0") : "?") + " 分" +
                                 "\n  書き出してから次へ進みます（台帳の status は stopped）");
                StopEarly();
                return;
            }

            float step = 0f;
            for (int i = 0; i < _robots.Count; i++)
                if (_robots[i] != null) step = Mathf.Max(step, _robots[i].LastStepM);
            _info.robotSteps.Add(step);

            // 人の1コマの移動量も記録する（B10）。通路を歩く人のうちいちばん大きいもの
            float peopleStep = 0f;
            IReadOnlyList<PersonBody> bodies = PersonBody.All;
            for (int i = 0; i < bodies.Count; i++)
            {
                PersonBody b = bodies[i];
                if (b == null || !b.walksInAisle) continue;
                if (b.LastStepM > peopleStep) peopleStep = b.LastStepM;
            }
            _info.peopleSteps.Add(peopleStep);

            ops.Advance(dt);
            groups.Tick(dt);
            jams.Tick(dt);
            // ロボットどうしのすれ違いを数える（依頼 v13 の 依頼1）。見るだけ
            _passBy.Tick(dt, ops.NowS, _robots, robotSpec);
            _simMinutes = ops.NowS / 60f;

            if (ops.NowS >= _measureStartS) _walkerSamples.Add(groups.WalkingBodies + people.StaffSpawned);

            // 1コマごとの指紋（依頼 v11 の 5 章。試しだけ）
            WriteDetFrame(_job.folder);

            // 時間帯ごとの記録（依頼 v10b の 2）。店の中の時間で 60 秒ごとに1行。
            // 1コマは 0.02 秒なので飛ぶことは無いが、万一飛んだときに
            // 同じ様子を何行も書かないよう、時刻だけ先へ進める
            if (ops.NowS >= _nextTimelineS)
            {
                _timeline.Sample(_nextTimelineS, groups, ops);
                do { _nextTimelineS += TimelineStepS; } while (ops.NowS >= _nextTimelineS);
            }

            if (_wallSeconds - _lastLog >= 60f)
            {
                _lastLog = _wallSeconds;

                // 「走り」はロボットが走り終わった回数、「記録」は trips.csv に書いた行数。
                // 走りがあるのに記録が増えないときは、記録の取りこぼしなのでその場で止める
                Debug.Log("[ServiceSim] とちゅう " + (_jobIndex + 1) + "/" + _jobCount + "：" +
                          _simMinutes.ToString("0.0") + " / " +
                          (_job.siteRun
                            ? "来客 " + (_arrivalsEndS / 60f).ToString("0") + " 分・上限 " +
                              (_hardEndS / 60f).ToString("0") + " 分"
                            : (_endS / 60f).ToString("0") + " 分") + "、" +
                          "組 " + groups.GroupsArrived + "（待ち " + groups.WaitingGroups +
                          "・帰った " + groups.GroupsDone + "）、" +
                          "走り " + _runsFinished + " 回・記録 " + _logger.RowCount + " 行、" +
                          "椅子 " + ops.chairPulls + " 回、" +
                          "あきらめ " + (ops.gaveUpCount + ops.warpedCount + ops.tableFreedCount) + " 回、" +
                          "立ち往生 " + ops.rescueCalls + " 回、" +
                          "実時間 " + (_wallSeconds / 60f).ToString("0.0") + " 分");

                CheckTripsAreRecorded();
            }

            if (ShouldFinish()) FinishJob();
        }

        /// <summary>
        /// 1条件を終える時。
        ///   サイトの条件（v5 3章）… 来客を受け付け終わって、全員が帰ったら終わり。
        ///                            帰らないまま max_extra_min を過ぎたら打ち切る
        ///   それ以外               … これまでどおり、慣らし＋計測の長さで終わり
        /// </summary>
        bool ShouldFinish()
        {
            if (!_job.siteRun) return ops.NowS >= _endS;

            if (ops.NowS >= _arrivalsEndS && !groups.AnyGroupInStore)
            {
                _everyoneLeft = true;
                return true;
            }
            return ops.NowS >= _hardEndS;
        }

        /// <summary>
        /// 走りがあるのに trips.csv の行が増えないときは、記録の取りこぼし。
        /// 気づかないまま何時間も回さないように、その場で止めて知らせる。
        /// 慣らしの間に始まった走りは数えないので、計測が始まってから見る。
        /// </summary>
        void CheckTripsAreRecorded()
        {
            if (ops.NowS < _measureStartS) return;
            if (_runsAfterMeasure <= 0) return;
            if (_logger.RowCount > 0) return;

            // 計測が始まってから走り終わった回数がこれを超えても記録が0なら、おかしい
            if (_runsAfterMeasure < TripRecordAlarmRuns) return;

            Debug.LogError("[ServiceSim] 走りが " + _runsAfterMeasure +
                           " 回あるのに trips.csv の行が 0 です。記録を取りこぼしています。" +
                           "\n  止めました。ExperimentRunner.OnRunDone と " +
                           "ServingRobotController.RunStartS を確かめてください" +
                           "\n  店の中 " + (ops.NowS / 60f).ToString("0.0") + " 分 / 計測の始まり " +
                           (_measureStartS / 60f).ToString("0.0") + " 分");
            StopEarly();
        }

        /// <summary>途中で止める（書き出しはする）</summary>
        void StopEarly()
        {
            _stoppedEarly = true;
            FinishJob();
            AllDone();
        }

        void OnRunDone(ServingRobotController r)
        {
            _runsFinished++;
            if (ops.NowS >= _measureStartS) _runsAfterMeasure++;

            // 走り始めた時刻はロボットが持っている（ServingRobotController.RunStartS）。
            // ここで覚えておくと、走り終わってすぐ次の仕事が入ったとき、
            // 「空いている」状態を一度も見ないまま時刻が古いままになり、全部の走りを取りこぼす。
            float startS = r.RunStartS >= 0f ? r.RunStartS : ops.NowS - r.TotalS;
            if (startS < _measureStartS) return;

            IList<DishOrder> batch = ops.LastBatchOf(r);
            _tripSeq++;

            TripLogger.Row row = new TripLogger.Row();
            row.conditionId = _job.conditionId;
            row.aisleWidthM = store.layout.AisleWidth;
            row.crowd = ExperimentPlan.CrowdName(_job.crowd);
            row.robots = _job.robots;
            row.seed = _job.seed;
            row.robotId = r.robotId;
            row.tripId = _tripSeq;
            row.tableId = r.TableIds.Count > 0 ? r.TableIds[0] : 0;
            row.distanceM = r.DistanceM;
            row.totalS = r.TotalS;
            row.stoppedS = r.StoppedS;
            row.nStops = r.StopCount;
            row.stopDurationsS = ToArray(r.StopDurationsS);
            row.stuck = r.Stuck;
            row.timeout = r.TimedOut;
            row.waitAisleS = r.WaitAisleS;
            row.maxDecelMps2 = r.MaxDecelMps2;
            row.minClearanceM = r.MinClearanceM;
            row.metRobot = r.MetRobot;

            int[] ids = new int[r.TableIds.Count];
            for (int i = 0; i < ids.Length; i++) ids[i] = r.TableIds[i];
            row.tableIds = ids;
            row.dishes = batch.Count;

            float[] ready = new float[batch.Count], arrived = new float[batch.Count];
            int undelivered = 0;
            for (int i = 0; i < batch.Count; i++)
            {
                ready[i] = batch[i].readyS;
                arrived[i] = batch[i].arrivedS;
                if (batch[i].arrivedS < 0f) undelivered++;
            }
            row.dishReadyS = ready;
            row.dishArrivedS = arrived;
            row.undelivered = undelivered;

            // ---- 後ろに足した列（依頼 v8 の 2。今ある列の並びは変えない）----
            row.runStartS = startS;

            int[] dishIds = new int[batch.Count];
            for (int i = 0; i < batch.Count; i++) dishIds[i] = batch[i].id;
            row.dishIds = dishIds;

            float[] stopArrived = new float[r.StopArrivedS.Count];
            for (int i = 0; i < stopArrived.Length; i++) stopArrived[i] = r.StopArrivedS[i];
            row.stopArrivedS = stopArrived;
            row.resetTo = r.ResetTo;        // 打ち切りで置き直した先（依頼 v8b の A）

            // ---- 往復ごとの測り（v5 3章・設計書2章の4つ）----
            // 実際に走れた速さは、止まっていた分を除いた時間で出す（止まり込みの平均は別物）
            float movingS = r.TotalS - r.StoppedS;
            row.speedMps = movingS > 0.01f ? r.DistanceM / movingS : 0f;
            row.stuckCount = r.StuckCount;
            row.stuckWaitS = r.StuckWaitS;
            row.viaNarrow = r.LegWasNarrow;
            SplitStopsByAisle(r, ref row);

            // ---- 依頼 v9 の 5 章：地図の上で自分で道を引く ----
            row.tripEndS = r.TripEndS >= 0f ? r.TripEndS : ops.NowS;
            row.pathMode = r.ActivePathMode.ToString();
            row.replans = r.Replans;
            row.detours = r.Detours;
            row.robotYields = r.RobotYields;
            row.siteRouteM = r.SiteRouteM;
            row.detourExtraM = r.DistanceM - r.SiteRouteM;
            row.narrowUsed = r.LegWasNarrow;
            row.planMsMax = r.PlanMsMax;

            _info.planMs.Add(r.PlanMsMax);
            _info.mapSnapStarts += r.MapSnapStarts;
            _info.planFails += r.PlanFails;

            _logger.Add(row);

            AddStopRows(r, _tripSeq);
        }

        void AddStopRows(ServingRobotController robot, int tripId)
        {
            float normal = NormalWidthM();
            IReadOnlyList<ServingRobotController.StopEvent> events = robot.StopEvents;
            for (int i = 0; i < events.Count; i++)
            {
                ServingRobotController.StopEvent e = events[i];
                TripLogger.StopRow s = new TripLogger.StopRow();
                s.conditionId = _job.conditionId;
                s.seed = _job.seed;
                s.robotId = robot.robotId;
                s.tripId = tripId;
                s.tS = e.startTimeS - _timeOffset;
                s.x = e.position.x;
                s.z = e.position.z;
                s.aisle = AisleNameAt(e.position);
                s.inNarrow = InNarrowAisle(e.position, normal);
                s.cause = TripLogger.CauseLabel(e.cause);
                s.causeDetail = e.causeDetail;
                s.pathBlocked = e.pathBlocked;
                s.durationS = e.durationS;
                s.resolved = TripLogger.ResolveLabel(e.resolve);
                s.clearanceLeftCm = e.clearanceLeftM * 100f;
                s.clearanceRightCm = e.clearanceRightM * 100f;
                s.minGapCm = Mathf.Min(s.clearanceLeftCm, s.clearanceRightCm);

                // 何で動けなかったかの内訳（B7）
                s.blockedS = e.blockedS;
                s.noPathS = e.noPathS;
                s.yieldS = e.yieldS;
                s.clearWaitS = e.clearWaitS;
                s.replans = e.replans;

                // 道を引いたときの中身（調べるためだけ）
                s.solveFrom = Xz(e.solveFrom);
                s.solveTo = Xz(e.solveTo);
                s.solveStatus = string.IsNullOrEmpty(e.solveStatus) ? "なし" : e.solveStatus;
                s.solveCorners = e.solveCorners;
                s.offMeshM = e.offMeshM;

                _logger.AddStop(s);
            }
        }

        /// <summary>
        /// 止まりを、狭い通路の中と外に分けて数える（v5 3章。⑦の読み分け）。
        /// 「狭い」はサイトの決まりのまま、幅が meta.settings.normal_width（1.0 m）未満の通路。
        /// ③の通路は 1.2 m なので、ぜんぶ「外」になる。
        /// </summary>
        void SplitStopsByAisle(ServingRobotController robot, ref TripLogger.Row row)
        {
            float normal = NormalWidthM();
            IReadOnlyList<ServingRobotController.StopEvent> events = robot.StopEvents;
            for (int i = 0; i < events.Count; i++)
            {
                if (InNarrowAisle(events[i].position, normal))
                {
                    row.stopsInNarrow++;
                    row.stoppedInNarrowS += events[i].durationS;
                }
                else
                {
                    row.stopsOutNarrow++;
                    row.stoppedOutNarrowS += events[i].durationS;
                }
            }
        }

        /// <summary>サイトの「ふだん走らせる道の幅」（m）。JSON に無ければ 0（＝ぜんぶ「外」）</summary>
        float NormalWidthM()
        {
            JsonStoreLayout js = store != null ? store.layout as JsonStoreLayout : null;
            return js != null ? js.NormalWidth : 0f;
        }

        bool InNarrowAisle(Vector3 p, float normalWidth)
        {
            if (normalWidth <= 0f) return false;
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                if (aisles[i].width >= normalWidth) continue;
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                return true;
            }
            return false;
        }

        /// <summary>点を "x;z" の1つの欄にする（stops.csv の solve_from / solve_to）</summary>
        static string Xz(Vector3 p)
        {
            return p.x.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture) + ";" +
                   p.z.ToString("0.###", System.Globalization.CultureInfo.InvariantCulture);
        }

        string AisleNameAt(Vector3 p)
        {
            string wide = "";
            IList<StoreBuilder.AisleInfo> aisles = store.Aisles;
            for (int i = 0; i < aisles.Count; i++)
            {
                Bounds b = aisles[i].zone;
                if (p.x < b.min.x || p.x > b.max.x || p.z < b.min.z || p.z > b.max.z) continue;
                if (aisles[i].narrow) return aisles[i].name;
                if (wide.Length == 0) wide = aisles[i].name;
            }
            return wide;
        }

        static float[] ToArray(IReadOnlyList<float> list)
        {
            if (list == null || list.Count == 0) return new float[0];
            float[] a = new float[list.Count];
            for (int i = 0; i < a.Length; i++) a[i] = list[i];
            return a;
        }

        // ---------------- 書き出して次へ ----------------

        void FinishJob()
        {
            _jobRunning = false;
            CloseDetWriter();        // 依頼 v11 の 5 章（試しだけ）
            AddGroupRows();
            WriteOutputs(_job.folder, _stoppedEarly);

            _jobIndex++;
            if (_jobIndex < _queue.Count) { StartJob(_queue[_jobIndex]); return; }
            AllDone();
        }

        /// <summary>
        /// 1条件ぶんの記録を書き出す（v5 3章で切り出した）。
        /// 途中で Play を止めたときも、ここまでの分を同じ形で書き出せるようにするため、
        /// 置き場所と「途中で止めたか」を渡す形にした。
        /// 書き出しの中で、オブジェクトを探したり消したりしないこと
        /// （Play を抜ける最中にも呼ばれるため）。
        /// </summary>
        void WriteOutputs(string dir, bool stoppedEarly)
        {
            _outputsWritten = true;

            // 1倍の回は見る専用（依頼 v13 の 依頼2）。フォルダも作らない
            if (_viewOnly)
            {
                Debug.Log("[ServiceSim] 見る専用の回なので、記録は書き出しません: " + dir);
                return;
            }
            try
            {
                Directory.CreateDirectory(dir);

                TripLogger.SimLike sim = new TripLogger.SimLike();
                sim.dishWaitMin.AddRange(ops.dishWaitMin);
                sim.seatWaitMin.AddRange(ops.seatWaitMin);
                sim.turnMin.AddRange(ops.turnMin);
                CollectPayWait(sim);                // pay_wait_mean（依頼 v10 の 2）
                sim.coldMin = service.coldMin;
                sim.robotDishes = ops.robotDishes;
                sim.staffDishes = ops.staffDishes;
                sim.requeuedDishes = ops.requeuedDishes;
                sim.groups = groups.GroupsDone;
                sim.jamCount = jams.JamCount;
                sim.jamTotalS = jams.TotalJamSeconds;
                sim.robotBlockedJams = jams.RobotBlockedJamCount;   // B9
                sim.chairPulls = ops.chairPulls;                     // B6
                sim.robotContacts = ops.Contacts.Count;              // 人がロボットをよける
                sim.robotYieldWaits = ops.robotYieldWaits;
                sim.robotYieldWaitSeconds = ops.robotYieldWaitSeconds;
                sim.robotYieldDetours = ops.robotYieldDetours;
                // ロボットどうしのすれ違い（依頼 v13 の 依頼1）。切の回でも数える
                sim.passCount = _passBy.PassCount;
                sim.passSlowS = _passBy.PassSlowS;
                sim.faceStopCount = _passBy.FaceStopCount;
                sim.faceStopMaxS = _passBy.FaceStopMaxS;
                // 2026-10-26（直し③）：場所で分けた数
                sim.faceStopAtPassCount = _passBy.FaceStopAtPassCount;
                sim.faceStopAtPassMaxS = _passBy.FaceStopAtPassMaxS;
                sim.faceStopAtStandbyCount = _passBy.FaceStopAtStandbyCount;
                sim.faceStopAtStandbyMaxS = _passBy.FaceStopAtStandbyMaxS;
                sim.faceStopInAisleCount = _passBy.FaceStopInAisleCount;
                sim.faceStopInAisleMaxS = _passBy.FaceStopInAisleMaxS;
                sim.faceStopOverlapCount = _passBy.FaceStopOverlapCount;
                sim.registerCrowdMax = ops.registerCrowdMax;         // レジの前の人数
                sim.registerCrowdMean = ops.RegisterCrowdMean;
                sim.registerPayCount = ops.registerPayCount;
                sim.registerPayNotAtBackCount = ops.registerPayNotAtBackCount;
                sim.registerPayOffSpotCount = ops.registerPayOffSpotCount;
                sim.registerPayMeanOffsetM = ops.RegisterPayMeanOffsetM;
                sim.registerPayMaxOffsetM = ops.registerPayMaxOffsetM;
                sim.registerBackIntruderCount = ops.registerBackIntruderCount;
                sim.registerBackIntruderSeconds = ops.registerBackIntruderSeconds;
                sim.staffTime.Clear();
                foreach (KeyValuePair<string, float> kv in ops.StaffTime) sim.staffTime.Add(kv);
                sim.dishParts.Clear();
                sim.dishParts.AddRange(ops.DishPartsList);
                // サイトの試算 v2.7：2分を過ぎて人が拾った皿と、その回の robot_first_wait_s
                sim.lateStaffDishes = ops.lateStaffDishes;
                sim.robotFirstWaitS = service.robotFirstWaitSec;
                // 譲る順番 v6：譲りの記録と、角の範囲（RobotSpec の1か所から写す）
                sim.yields.Clear();
                sim.yields.AddRange(ops.Yields);
                // 依頼 v9：角の箱（cornerMin/Max）は SiteRoutes のときだけ働く。
                // MapPlanner では範囲を入れないので、summary.json の corner_stops は 0 になる
                if (robotSpec != null && !robotSpec.UseMapPlanner)
                {
                    sim.cornerMinX = robotSpec.cornerMinX;
                    sim.cornerMaxX = robotSpec.cornerMaxX;
                    sim.cornerMinZ = robotSpec.cornerMinZ;
                    sim.cornerMaxZ = robotSpec.cornerMaxZ;
                }
                sim.jobWaitMean.Clear();
                foreach (KeyValuePair<string, float> kv in ops.JobWaitSum)
                {
                    int n;
                    ops.JobWaitCount.TryGetValue(kv.Key, out n);
                    sim.jobWaitMean.Add(new KeyValuePair<string, float>(kv.Key, n > 0 ? kv.Value / n : 0f));
                }
                // 依頼 v10 の 2：板で待った最大と、長びいた数（片付け・会計を見る）
                sim.jobWaitMax.Clear();
                foreach (KeyValuePair<string, float> kv in ops.JobWaitMax) sim.jobWaitMax.Add(kv);
                sim.jobWaitOver.Clear();
                foreach (KeyValuePair<string, int> kv in ops.JobWaitOverCount) sim.jobWaitOver.Add(kv);
                sim.jobWaitOverSec = ops.JobWaitOverSec;
                sim.drinkBarQueued = ops.drinkBarQueued;             // B4
                sim.drinkBarGaveUp = ops.drinkBarGaveUp;
                sim.drinkBarWaitMin.Clear();
                sim.drinkBarWaitMin.AddRange(ops.drinkBarWaitMin);
                sim.gaveUp = ops.gaveUpCount;                        // B1
                sim.warped = ops.warpedCount;
                sim.tableFreed = ops.tableFreedCount;
                sim.walkersMean = MeanInt(_walkerSamples);
                sim.measuredMinutes = _job.siteRun ? ops.NowS / 60f : plan.measureMinutes;

                // ---- サイトと同じ名前で出す値（v5 3章・meta.metrics）----
                sim.entryWaitMin.AddRange(ops.entryWaitMin);
                sim.fullWaitMin.AddRange(ops.fullWaitMin);
                sim.blockedWaitMin.AddRange(ops.blockedWaitMin);
                sim.guideWaitMin.AddRange(ops.guideWaitMin);
                sim.staffWaitMin.AddRange(ops.staffWaitMin);
                sim.walkKmPerStaff = ops.WalkKmPerStaff;
                sim.staffUtil = StaffUtil();
                sim.robotUtil = ops.RobotUtil;
                sim.aisleDeferrals = ops.aisleDeferrals;
                sim.robotStuckDetours = ops.robotStuckDetours;
                sim.staffFirstYield = people.spec != null && people.spec.staffFirstYield;
                sim.rescues = ops.rescueCalls;
                sim.rescuesDone = ops.rescueDone;
                sim.rescueWaitS = ops.rescueWaitSeconds;
                sim.pauses = _logger.TotalStopCount();
                sim.siteRun = _job.siteRun;
                sim.siteConditionId = _job.conditionId;
                sim.siteResult.Clear();
                if (_condition != null)
                    foreach (KeyValuePair<string, float> kv in _condition.siteResult)
                        sim.siteResult.Add(kv);
                sim.arrivalsMinutes = _job.siteRun ? _arrivalsEndS / 60f : 0f;
                sim.totalMinutes = ops.NowS / 60f;
                sim.everyoneLeft = _everyoneLeft;
                FillTimelineCheck(sim);         // 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）
                _logger.SetSimLike(sim);

                _logger.WriteTripsCsv(Path.Combine(dir, "trips.csv"));
                _logger.WriteStopsCsv(Path.Combine(dir, "stops.csv"));
                _logger.WriteGroupsCsv(Path.Combine(dir, "groups.csv"));
                TripLogger.WriteJamsCsv(Path.Combine(dir, "people_jams.csv"),
                                        _job.conditionId, _job.seed, jams.Rows);
                TripLogger.WriteGiveUpsCsv(Path.Combine(dir, "give_ups.csv"),
                                        _job.conditionId, _job.seed, ops.GiveUps);
                // dishes.csv は皿ごとの記録（誰が運んだか・受け渡し口で待った秒数・
                // 2分を過ぎて人が拾ったか）。戻した1回ずつは requeues.csv に分けた
                TripLogger.WriteDishesCsv(Path.Combine(dir, "dishes.csv"),
                                        _job.conditionId, _job.seed, ops.DishRows);
                TripLogger.WriteRequeuesCsv(Path.Combine(dir, "requeues.csv"),
                                        _job.conditionId, _job.seed, ops.Requeues);
                TripLogger.WriteContactsCsv(Path.Combine(dir, "contacts.csv"),
                                        _job.conditionId, _job.seed, ops.Contacts);
                // 誰が誰に譲ったか（譲る順番 v6）
                TripLogger.WriteYieldsCsv(Path.Combine(dir, "yields.csv"),
                                        _job.conditionId, _job.seed, ops.Yields);
                // ロボットどうしのすれ違い1回ずつ（依頼 v13 の 依頼1）
                TripLogger.WritePassesCsv(Path.Combine(dir, "passes.csv"),
                                        _job.conditionId, _job.seed, _passBy.Rows);
                // 時間帯ごとの記録（依頼 v10b の 2）。段階Bと Site Run で同じ作り
                _timeline.Write(Path.Combine(dir, "timeline.csv"),
                                        _job.conditionId, _job.seed);
                _logger.WriteSummaryJson(Path.Combine(dir, "summary.json"), plan.runName);

                _info.runLabel = runLabel;
                _info.conditionId = _job.conditionId;
                _info.timeMode = timeMode.ToString();
                _info.tripsRequested = 0;
                _info.tripsDone = _logger.RowCount;
                _info.robots = _job.robots;
                _info.seed = _job.seed;
                _info.aisleWidthM = store.layout.AisleWidth;
                _info.deliveryOffsetM = store.layout.deliveryOffsetFromAisleCenter;
                _info.deliveryOffsetUsedM = store.layout.DeliveryOffset();
                _info.crowd = ExperimentPlan.CrowdName(_job.crowd);
                _info.simSeconds = ops.NowS;
                _info.wallSeconds = _wallSeconds;
                _info.timeScale = timeMode == TimeMode.FastTimeScale ? plan.trialTimeScaleFast : 1f;
                _info.captureDeltaTime = Time.captureDeltaTime;

                // 椅子を引いた回数（B6）
                _info.chairPulls = ops.chairPulls;
                _info.chairPullsByReason.Clear();
                foreach (KeyValuePair<string, int> kv in ops.ChairPullsByReason)
                    _info.chairPullsByReason.Add(kv);
                _info.stoppedEarly = stoppedEarly;
                _info.oneRobotPerAisle = robotSpec != null && robotSpec.oneRobotPerAisle;
                _info.yieldOrderEnabled = robotSpec != null && robotSpec.yieldOrderEnabled;
                _info.unityCs = unityCs;
                _info.pendingNote = pendingNote;
                // 依頼 v12 の 2 章：誰が回したか
                _info.runner = string.IsNullOrEmpty(runnerName) ? "editor" : runnerName;
                _info.buildId = buildId != null ? buildId : "";
                _info.night = night != null ? night : "";
                _info.parallel = parallel;
                // 依頼 v11 の 5 章（試しだけ）
                _info.detCheck = DetCheckNote();
                _info.pathIterationsPerFrame = _pathIterationsUsed;
                _info.sensorMaxHits = FrontSensor.MaxSeen;
                _info.sensorOverflows = FrontSensor.Overflows;
                _info.staffFirstYield = people.spec != null && people.spec.staffFirstYield;
                _info.storeKind = store.layout.name;

                // 依頼 v9 の 5 章：道の決め方と、地図の版・道の引き方の数字
                if (robotSpec != null)
                {
                    _info.narrowCellCost = robotSpec.narrowCellCost;
                    _info.centerWeight = robotSpec.centerWeight;
                    _info.preferClearM = robotSpec.preferClearM;
                    _info.detourAfterSec = robotSpec.detourAfterSec;
                    _info.pathMode = robotSpec.pathMode.ToString();
                    // 依頼 v13 の 依頼1：すれ違いの入切と3つの値
                    _info.passByEnabled = robotSpec.passByEnabled;
                    _info.passByDistanceM = robotSpec.passByDistanceM;
                    _info.passBySpeedMps = robotSpec.passBySpeedMps;
                    _info.passBySeparationM = robotSpec.passBySeparationM;
                }
                // 実際に使った決め方（地図が無ければ SiteRoutes に落ちる）は台から読む
                IList<ServingRobotController> rs = ops != null ? ops.Robots : null;
                if (rs != null && rs.Count > 0 && rs[0] != null)
                    _info.pathMode = rs[0].ActivePathMode.ToString();
                JsonStoreLayout jsMap = store.layout as JsonStoreLayout;
                _info.mapGeneration = jsMap != null ? jsMap.MapGeneration : "";
                TripLogger.WriteRunInfoJson(Path.Combine(dir, "run_info.json"), _info);

                TripLogger.WriteConditionsJson(Path.Combine(dir, "conditions.json"),
                    plan.runName, store.layout, robotSpec, people.spec, plan,
                    plan.BuildConditions(store.layout));

                _runsDone++;
                Debug.Log("[ServiceSim] 書き出し " + (_jobIndex + 1) + "/" + _jobCount + "：" + _job.conditionId +
                          " 種" + _job.seed +
                          "\n  走り " + _runsFinished + " 回 / 記録 " + _logger.RowCount + " 行 / 止まり " +
                          _logger.StopCount + " 回 / 組 " + _logger.GroupRowCount +
                          "\n  椅子を引いた " + ops.chairPulls + " 回 / 人どうしの詰まり " + jams.JamCount +
                          " 回（うちロボットが近くで止まっていた " + jams.RobotBlockedJamCount + " 回）" +
                          "\n  会計 " + ops.registerPayCount + " 回（店員側にいなかった " +
                          ops.registerPayNotAtBackCount + " 回・立つ点から離れた " +
                          ops.registerPayOffSpotCount + " 回・いちばん離れた " +
                          ops.registerPayMaxOffsetM.ToString("0.00") + " m・会計でないのに店員側にいた " +
                          ops.registerBackIntruderCount + " 回）/ レジの前の人数 最大 " +
                          ops.registerCrowdMax + "・平均 " + ops.RegisterCrowdMean.ToString("0.0") +
                          "\n  人がロボットを待った " + ops.robotYieldWaits + " 回・" +
                          ops.robotYieldWaitSeconds.ToString("0") + " 秒 / 回り道 " +
                          ops.robotYieldDetours + " 回 / 近づきすぎ " + ops.Contacts.Count + " 回" +
                          "\n  人のよけ方: " +
                          (people.spec != null && people.spec.staffFirstYield ? "店員優先の版" : "いまの版") +
                          "（店員が止まったロボットを回り道した " + ops.robotStuckDetours + " 回）" +
                          "\n  同じ通路を避けて後に回した " + ops.aisleDeferrals + " 回" +
                          "（同じ通路に2台を集めない: " +
                          (robotSpec != null && robotSpec.oneRobotPerAisle ? "入れた" : "入れない") + "）" +
                          "\n  戻りの線を使えなかった " + ReturnLineFallbacks() + " 回" +
                          "（使えないときは受け渡し口の前へ戻る）" +
                          // 依頼 v13 の 依頼1：すれ違いの数（切の回でも出る）
                          "\n  すれ違い " + _passBy.PassCount + " 回・減速していた " +
                          _passBy.PassSlowS.ToString("0") + " 秒 / 向かい合って止まった " +
                          _passBy.FaceStopCount + " 回（最長 " +
                          _passBy.FaceStopMaxS.ToString("0.0") + " 秒）" +
                          "（すれ違いの走り方: " +
                          (robotSpec != null && robotSpec.passByEnabled ? "入" : "切") + "）" +
                          "\n  立ち往生で呼んだ " + ops.rescueCalls + " 回（動かした " + ops.rescueDone +
                          " 回・待った " + ops.rescueWaitSeconds.ToString("0") + " 秒）" +
                          "\n  入店待ち " + Mean(ops.entryWaitMin).ToString("0.00") + " 分（満席 " +
                          Mean(ops.fullWaitMin).ToString("0.00") + "・片付け待ち " +
                          Mean(ops.blockedWaitMin).ToString("0.00") + "・案内待ち " +
                          Mean(ops.guideWaitMin).ToString("0.00") + "）/ スタッフの歩き " +
                          ops.WalkKmPerStaff.ToString("0.00") + " km・手がふさがり " +
                          (StaffUtil() * 100f).ToString("0") + "%・ロボット " +
                          (ops.RobotUtil * 100f).ToString("0") + "%" +
                          "\n  あきらめた " + ops.gaveUpCount + " / 移した " + ops.warpedCount +
                          " / 卓を空けた " + ops.tableFreedCount +
                          " / 戻した皿 " + ops.requeuedDishes + "（届いた " + ops.robotDishes + "）" +
                          "\n  " + TripLogger.FrameStatsText(_info) +
                          "\n  " + dir);
            }
            catch (System.Exception e)
            {
                Debug.LogError("[ServiceSim] 書き出しに失敗: " + dir + "\n" + e);
            }
        }

        // ---------------- ずれの切り分け（依頼 v11 の 5 章。試しだけ）----------------

        /// <summary>
        /// この回で実際に入った道探しの枠（run_info.json に出す。2026-10-26）。
        /// 入れたあとに Unity から読み直した値なので、「入れたつもり」ではなく本当の値
        /// </summary>
        int _pathIterationsUsed;
        System.IO.StreamWriter _detWriter;
        int _detFrame;

        /// <summary>
        /// 試しの切り替えを入れる。ぜんぶ切なら cs:2 と同じ動きになる。
        /// ★ 調べるためだけの仕掛け。check-in しない
        /// </summary>
        void ApplyDetCheck()
        {
            FrontSensor.MaxSeen = 0;
            FrontSensor.Overflows = 0;
            _detFrame = 0;
            // すれ違いの数も回ごとに数え直す（依頼 v13。前の回の分を持ち越さない）
            _passBy.Reset();
            if (plan == null) return;

            // 毎コマの道探しの枠を、**回すたびに必ず** ExperimentPlan の値で入れ直す
            // （2026-10-26）。前は「試しのときだけ入れて、終わりに戻す」形だったが、
            // 戻し損ねるとエディタを閉じるまで残り、次の回の人の動きが変わってしまう
            // （⑦の18回で待ちが短く出た疑い）。入れたあとに読み直して記録する
            int want = plan.pathIterationsPerFrame > 0 ? plan.pathIterationsPerFrame : 100;
            UnityEngine.AI.NavMesh.pathfindingIterationsPerFrame = want;
            _pathIterationsUsed = UnityEngine.AI.NavMesh.pathfindingIterationsPerFrame;
            if (_pathIterationsUsed != want)
                Debug.LogWarning("[ServiceSim] 道探しの枠を入れられませんでした: 入れた " + want +
                                 " / 実際 " + _pathIterationsUsed);

            // 試し2：人の道を同期で引く
            PeopleNav.SyncPaths = plan.detCheckSyncPeoplePaths;

            // 試し4：人の局所回避（RVO）を切る。要るときだけ
            if (plan.detCheckNoAvoidance)
            {
                IReadOnlyList<StaffAgent> st = StaffAgent.All;
                for (int i = 0; i < st.Count; i++)
                    if (st[i] != null) SetNoAvoidance(st[i].gameObject);
            }

            if (plan.detCheckSyncPeoplePaths ||
                plan.detCheckNoAvoidance || plan.detCheckFrameFingerprint)
                Debug.Log("[ServiceSim] ずれの切り分け（試しの切り替え）が入っています: " + DetCheckNote());
        }

        static void SetNoAvoidance(GameObject go)
        {
            UnityEngine.AI.NavMeshAgent a =
                go != null ? go.GetComponent<UnityEngine.AI.NavMeshAgent>() : null;
            if (a != null)
                a.obstacleAvoidanceType = UnityEngine.AI.ObstacleAvoidanceType.NoObstacleAvoidance;
        }

        /// <summary>入れた切り替えを、人が読める1行にする（run_info とログに出す）</summary>
        string DetCheckNote()
        {
            if (plan == null) return "";
            if (!plan.detCheckSyncPeoplePaths &&
                !plan.detCheckNoAvoidance && !plan.detCheckFrameFingerprint) return "";
            return "sync_people_paths=" + (plan.detCheckSyncPeoplePaths ? "入" : "切") +
                   " / no_avoidance=" + (plan.detCheckNoAvoidance ? "入" : "切") +
                   " / frame_fingerprint=" + (plan.detCheckFrameFingerprint ? "入" : "切");
        }

        /// <summary>試しの切り替えを元に戻す（.asset と Unity の設定を残さない）</summary>
        void RestoreDetCheck()
        {
            // 道探しの枠は戻さない。回し始めに毎回入れ直すので、
            // 「戻し損ねて次の回に残る」という形そのものを無くした（2026-10-26）
            PeopleNav.SyncPaths = false;
            CloseDetWriter();
        }

        /// <summary>
        /// 1コマごとの指紋を det_frames.csv に1行書く。
        /// 人とロボットの位置をまとめた数なので、2回ぶんを並べれば
        /// **どのコマで初めてずれたか** が1コマ単位で分かる。
        /// 位置は 0.1 mm まで整数にして、小数の書き方で差が出ないようにする
        /// </summary>
        void WriteDetFrame(string dir)
        {
            if (plan == null || !plan.detCheckFrameFingerprint) return;
            if (_viewOnly) return;      // 見る専用の回はフォルダも作らない（依頼 v13 の 依頼2）
            if (_detWriter == null)
            {
                try
                {
                    Directory.CreateDirectory(dir);
                    _detWriter = new System.IO.StreamWriter(
                        Path.Combine(dir, "det_frames.csv"), false,
                        new System.Text.UTF8Encoding(false));
                    _detWriter.Write("frame,t_s,people,robots,people_sum,robot_sum,path_pending\n");
                }
                catch (System.Exception e)
                {
                    Debug.LogWarning("[ServiceSim] det_frames.csv を開けません: " + e.Message);
                    plan.detCheckFrameFingerprint = false;
                    return;
                }
            }

            long pSum = 0; int pCount = 0, pending = 0;
            IReadOnlyList<PersonBody> bodies = PersonBody.All;
            for (int i = 0; i < bodies.Count; i++)
            {
                PersonBody b = bodies[i];
                if (b == null) continue;
                Vector3 p = b.transform.position;
                pSum += (long)Mathf.RoundToInt(p.x * 10000f) * 31 +
                        (long)Mathf.RoundToInt(p.z * 10000f) * 17;
                pCount++;
                UnityEngine.AI.NavMeshAgent a = b.GetComponent<UnityEngine.AI.NavMeshAgent>();
                if (a != null && a.isOnNavMesh && a.pathPending) pending++;
            }

            long rSum = 0; int rCount = 0;
            for (int i = 0; i < _robots.Count; i++)
            {
                ServingRobotController r = _robots[i];
                if (r == null) continue;
                Vector3 p = r.transform.position;
                rSum += (long)Mathf.RoundToInt(p.x * 10000f) * 31 +
                        (long)Mathf.RoundToInt(p.z * 10000f) * 17;
                rCount++;
            }

            _detWriter.Write(_detFrame.ToString(System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write(',');
            _detWriter.Write(ops.NowS.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write(',');
            _detWriter.Write(pCount.ToString(System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write(',');
            _detWriter.Write(rCount.ToString(System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write(',');
            _detWriter.Write(pSum.ToString(System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write(',');
            _detWriter.Write(rSum.ToString(System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write(',');
            _detWriter.Write(pending.ToString(System.Globalization.CultureInfo.InvariantCulture)); _detWriter.Write('\n');
            _detFrame++;
        }

        void CloseDetWriter()
        {
            if (_detWriter == null) return;
            try { _detWriter.Flush(); _detWriter.Dispose(); }
            catch (System.Exception) { }
            _detWriter = null;
        }

        /// <summary>
        /// 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）。
        ///
        /// どの行でも
        ///   seated_groups ＋ tables_free ＋ tables_dirty_untaken ＋ tables_clearing ＝ 卓の数
        ///   staff_idle ＋ …（仕事の種類 6 つ）＝ スタッフの人数
        /// になっているかを数えて、summary.json の timeline_check に出す。
        /// 合わない行があれば Console にも出す（夜にまとめて回したあとでも分かるように）
        /// </summary>
        void FillTimelineCheck(TripLogger.SimLike sim)
        {
            int tables = ops != null ? ops.Tables.Count : 0;
            int staff = people != null ? people.StaffSpawned : 0;

            int badTables, badStaff;
            string why;
            bool ok = _timeline.CheckSums(tables, staff, out badTables, out badStaff, out why);

            sim.timelineRows = _timeline.RowCount;
            sim.timelineTables = tables;
            sim.timelineStaff = staff;
            sim.timelineBadTableRows = badTables;
            sim.timelineBadStaffRows = badStaff;
            sim.timelineFirstBad = why;

            if (!ok)
                Debug.LogWarning("[ServiceSim] timeline.csv の合計が合いません（" +
                                 "卓 " + badTables + " 行・スタッフ " + badStaff + " 行 / " +
                                 _timeline.RowCount + " 行）\n  いちばん早い行: " + why);
        }

        /// <summary>
        /// pay_wait_mean：食べ終えてから会計が始まるまで（分。依頼 v10 の 2）。
        ///
        /// 組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）。
        /// groups.csv の pay_s 列（会計が終わった時刻）と eat_end_s から出せる値と同じで、
        /// 引く pay_s は ServiceSpec.paySec（会計そのものにかかる 90 秒）。
        ///
        /// 入れない組:
        ///   ・逃がし弁で卓を空けられた組（会計が途中で切れている）
        ///   ・会計が終わっていない組（payS が -1。打ち切りで店に残った組）
        ///   ・慣らしで最初から座っていた組（ほかの site_metrics と同じ扱い）
        ///
        /// サイトの試算は「レジの順番とスタッフの手が空くのを待った時間」だけだが、
        /// Unity は会計に立つ人がレジまで歩く時間と、スタッフがレジへ歩く時間も入るので、
        /// 試算より少し長く出る
        /// </summary>
        void CollectPayWait(TripLogger.SimLike sim)
        {
            if (groups == null || service == null) return;
            IList<GroupManager.Group> gs = groups.Groups;
            for (int i = 0; i < gs.Count; i++)
            {
                GroupManager.Group g = gs[i];
                if (g.warmup || g.tableForceFreed) continue;
                if (g.payS < 0f || g.eatEndS < 0f) continue;
                sim.payWaitMin.Add(Mathf.Max(0f, g.payS - g.eatEndS - service.paySec) / 60f);

                // 照らし合わせ用：食べ終えた時刻ではなく、会計に入った時刻（pay_start_s）から数えた分。
                // Unity は eat_end_s（食べ始めに決めた食べ終わりの時刻）のあとも、
                // 遅れて届いた皿やドリンクバー・トイレの人を待ってから会計に入るので、
                // その待ちが pay_wait_mean に混ざる。混ざった分の大きさが見えるように両方出す
                if (g.payStartS >= 0f)
                    sim.payWaitFromStartMin.Add(
                        Mathf.Max(0f, g.payS - g.payStartS - service.paySec) / 60f);
            }
        }

        void AddGroupRows()
        {
            IList<GroupManager.Group> gs = groups.Groups;
            for (int i = 0; i < gs.Count; i++)
            {
                GroupManager.Group g = gs[i];
                TripLogger.GroupRow r = new TripLogger.GroupRow();
                r.conditionId = _job.conditionId;
                r.seed = _job.seed;
                r.groupId = g.id;
                r.size = g.size;
                r.tableId = g.table != null ? g.table.tableId : 0;
                r.tableSeats = g.table != null ? g.table.seatCount : 0;   // 2人席／4人席
                r.arriveS = g.arriveS;
                r.guideStartS = g.guideStartS;
                r.seatedS = g.seatedS;
                r.eatStartS = g.eatStartS;      // 食べ始めた時刻（依頼 v8 の 2）
                r.eatEndS = g.eatEndS;
                r.payStartS = g.payStartS;
                r.payS = g.payS;
                r.leftS = g.leftS;
                r.waitedMin = g.seatedS >= 0f && g.arriveS >= 0f ? (g.seatedS - g.arriveS) / 60f : -1f;
                r.warmup = g.warmup;
                r.state = g.state.ToString();
                _logger.AddGroup(r);
            }
        }

        /// <summary>
        /// staff_util：スタッフの手がふさがっていた割合（v5 3章）。
        /// ops.StaffTime が仕事ごとの秒を持っているので、「待機」以外の割合で出す。
        /// </summary>
        float StaffUtil()
        {
            float total = 0f, idle = 0f;
            foreach (KeyValuePair<string, float> kv in ops.StaffTime)
            {
                total += kv.Value;
                if (kv.Key == "待機") idle += kv.Value;
            }
            return total > 0.01f ? (total - idle) / total : 0f;
        }

        /// <summary>
        /// 戻りの線が使えず、今までどおり受け渡し口の前へ戻った回数（v5）。
        /// 0 でなければ、線が通れる床に乗っていないか、出ていく線・ドリンクバーの列に近すぎる
        /// </summary>
        int ReturnLineFallbacks()
        {
            int n = 0;
            for (int i = 0; i < _robots.Count; i++)
                if (_robots[i] != null) n += _robots[i].ReturnLineFallbacks;
            return n;
        }

        static float Mean(List<float> v)
        {
            if (v == null || v.Count == 0) return 0f;
            float s = 0f;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return s / v.Count;
        }

        static float MeanInt(List<int> v)
        {
            if (v == null || v.Count == 0) return 0f;
            long s = 0;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return (float)s / v.Count;
        }

        /// <summary>
        /// メニューが決めた停止点を、.asset のもとの値へ戻す（C1）。
        /// 書き出しが終わったあとに呼ぶ。.asset を書き換えたままにしないため。
        /// </summary>
        void RestoreDeliveryOffset()
        {
            if (!_deliveryOffsetSaved) return;
            _deliveryOffsetSaved = false;
            if (store != null && store.layout != null)
                store.layout.deliveryOffsetFromAisleCenter = _savedDeliveryOffset;
        }

        /// <summary>
        /// サイトの試算から入れた値を、.asset のもとの値へ戻す（v5 3章）。
        /// 停止点（C1）と同じ扱い。.asset を書き換えたままにしないため
        /// </summary>
        void RestoreSiteValues()
        {
            _siteValues.Restore();
        }

        void AllDone()
        {
            _running = false;
            RestoreDeliveryOffset();
            RestoreSiteValues();
            Time.captureDeltaTime = 0f;
            Time.timeScale = 1f;
            Debug.Log("[ServiceSim] ぜんぶ終わりました。" + _runsDone + " / " + _jobCount + " 条件、" +
                      "実時間 " + (_wallSeconds / 60f).ToString("0.0") + " 分");
#if UNITY_EDITOR
            UnityEditor.EditorApplication.isPlaying = false;
#endif
        }

        /// <summary>
        /// 途中で Play を止めたとき、そこまでの記録を書き出す（v5 3章）。
        ///
        /// 回している最中に止めると、これまでは何も残らなかった。
        /// 詰まった回でも give_ups.csv と summary.json を見られるように、同じ形で書き出す。
        ///   ・置き場所は、ふだんのフォルダ名に _stopped を付けたもの
        ///   ・run_info.json の stopped_early が true、summary.json の
        ///     site_run.everyone_left は false のまま
        /// 書き出しに失敗しても Play を抜けられるよう、まるごと try/catch で包む。
        /// Play を抜ける最中なので、ここから新しくオブジェクトを探したり消したりしない。
        /// </summary>
        void WriteOnStop()
        {
            if (!_running || !_jobRunning || _outputsWritten || _job == null) return;

            try
            {
                _stoppedEarly = true;
                _jobRunning = false;
                AddGroupRows();
                WriteOutputs(_job.folder + "_stopped", true);
                Debug.LogWarning("[ServiceSim] 途中で止めました。ここまでの記録を書き出しました" +
                                 "（計測には使えません）\n  店の中 " +
                                 (ops != null ? (ops.NowS / 60f).ToString("0.0") : "?") + " 分\n  " +
                                 _job.folder + "_stopped");
            }
            catch (System.Exception e)
            {
                Debug.LogError("[ServiceSim] 途中で止めたときの書き出しに失敗しました\n" + e);
            }
        }

        void OnApplicationQuit()
        {
            WriteOnStop();
        }

        void OnDisable()
        {
            // 回している最中に止めたときは、ここまでの記録を書き出す（v5 3章）
            WriteOnStop();

            // 途中で Play を抜けたときも、停止点とサイトの値を .asset のもとの値へ戻す
            RestoreDeliveryOffset();
            RestoreSiteValues();
            RestoreDetCheck();       // 依頼 v11 の 5 章（試しだけ）
            Time.captureDeltaTime = 0f;
            if (_running) Time.timeScale = 1f;
        }
    }
}
```

### Assets/ServiceSim/Experiment/TripLogger.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// trips.csv・conditions.json・summary.json を書く。
    /// この script は実験だけを扱う。店・ロボット・人は動かさない。
    /// 1往復ぶんの値は ExperimentRunner が Row に詰めて渡す。
    /// </summary>
    public class TripLogger
    {
        /// <summary>trips.csv の1行</summary>
        public struct Row
        {
            public string conditionId;
            public float aisleWidthM;
            public string crowd;
            public int robots;
            public int seed;
            public int robotId;
            public int tripId;
            public int tableId;
            public float distanceM;
            public float totalS;
            public float stoppedS;
            public int nStops;
            public float[] stopDurationsS;
            public bool stuck;
            public bool timeout;
            public float waitAisleS;
            public float maxDecelMps2;
            public float minClearanceM;
            public bool metRobot;
            // v2：1行＝ロボットの1走り
            public int[] tableIds;          // 回った卓
            public int dishes;              // 載せた料理の数
            public float[] dishReadyS;      // 各料理ができた時刻
            public float[] dishArrivedS;    // 各料理が届いた時刻（-1 は届かなかった）
            public int undelivered;         // 届けられず受け渡し口に戻した数

            // ---- 往復ごとの測り（v5 3章・設計書2章の4つ）----
            /// <summary>その往復で実際に走れた速さ（m/秒）＝ distance_m /（total_s − stopped_s）</summary>
            public float speedMps;

            /// <summary>動けなくなってスタッフを呼んだ回数と、スタッフを待った秒数</summary>
            public int stuckCount;
            public float stuckWaitS;

            /// <summary>その往復でサイトの道順が 0.8 m の道を使ったか（routes の use が narrow）</summary>
            public bool viaNarrow;

            /// <summary>止まりの回数と秒数を、狭い通路の中と外に分けたもの（⑦の読み分け用）</summary>
            public int stopsInNarrow, stopsOutNarrow;
            public float stoppedInNarrowS, stoppedOutNarrowS;

            // ---- 後ろに足した列（依頼 v8 の 2。今ある列の並びは変えない）----
            /// <summary>その往復を始めた時刻（店の中, 秒。ほかの _s 列と同じ基準）</summary>
            public float runStartS;

            /// <summary>その往復で載せた皿の番号（dishes.csv の dish_id と同じ）</summary>
            public int[] dishIds;

            /// <summary>止まる所ごとに着いた時刻（着かなかった所は -1）</summary>
            public float[] stopArrivedS;

            /// <summary>
            /// 打ち切りで置き直した先（依頼 v8b の A）。pass / standby1 / standby2 …。
            /// 打ち切りでない往復は空
            /// </summary>
            public string resetTo;

            // ---- 後ろに足した列（依頼 v9 の 5 章。今ある列の並びは変えない）----

            /// <summary>往復が実際に終わった店の中の時刻（卓での25秒を含む, 秒）。total_s はそのまま</summary>
            public float tripEndS;

            /// <summary>道の決め方（MapPlanner / SiteRoutes）</summary>
            public string pathMode;

            /// <summary>道を引き直した回数（回り込み・よけを含む）</summary>
            public int replans;

            /// <summary>3 章の回り込みで道を変えた回数</summary>
            public int detours;

            /// <summary>
            /// 4 章でよけた回数。よける場所へ動いた回数 ＋
            /// 相手の道をよけた道に引き直した回数
            /// </summary>
            public int robotYields;

            /// <summary>その往復でサイトの道順をたどったときの長さ（routes の length の和, m）</summary>
            public float siteRouteM;

            /// <summary>走った距離 − site_route_m（m）</summary>
            public float detourExtraM;

            /// <summary>狭い道（free_class 1 のマス）を通ったか</summary>
            public bool narrowUsed;

            /// <summary>その往復で 1 回の道探しにかかった最大の時間（ミリ秒）</summary>
            public float planMsMax;
        }

        /// <summary>groups.csv の1行（1組ぶん）</summary>
        public struct GroupRow
        {
            public string conditionId;
            public int seed;
            public int groupId;
            public int size;
            public int tableId;

            /// <summary>座った卓の大きさ（2 なら2人席、4 なら4人席。0 は座れなかった）</summary>
            public int tableSeats;
            public float arriveS, guideStartS, seatedS, eatEndS, payS, leftS;

            /// <summary>食べ始めた時刻（秒）。食べ始めなかった組は -1（依頼 v8 の 2）</summary>
            public float eatStartS;

            /// <summary>会計を始めた時刻（食べ終わって会計待ちに入った時刻, 秒）</summary>
            public float payStartS;
            public float waitedMin;
            public bool warmup;
            public string state;
        }

        /// <summary>stops.csv の1行（止まり1回ぶん）</summary>
        public struct StopRow
        {
            public string conditionId;
            public int seed;
            public int robotId;
            public int tripId;
            public float tS;            // 店の中の時刻
            public float x, z;
            public string aisle;        // 通路の名前

            /// <summary>
            /// サイトの決まりで「狭い通路」（幅が meta.settings.normal_width 未満）の中で
            /// 止まったか（v5 3章。⑦の読み分け。③は 1.2 m なのでいつも 0）
            /// </summary>
            public bool inNarrow;
            public string cause;
            public string causeDetail;
            public bool pathBlocked;    // 行き先への道が引けていなかったか
            public float durationS;
            public string resolved;
            public float clearanceLeftCm;
            public float clearanceRightCm;
            public float minGapCm;

            // 止まっている間、何で動けなかったかの内訳（秒）。B7 を調べるため
            public float blockedS;
            public float noPathS;
            public float yieldS;
            public float clearWaitS;
            public int replans;

            // 道を引いたときの中身（調べるためだけ。動きは変えない）
            /// <summary>寄せたあとの出発点・行き先（"x;z"）</summary>
            public string solveFrom, solveTo;
            /// <summary>CalculatePath の結果と、引けた角の数</summary>
            public string solveStatus;
            public int solveCorners;
            /// <summary>ロボットの位置から、いちばん近い焼いた面までの距離（m）。-1 は見つからない</summary>
            public float offMeshM;
        }

        /// <summary>
        /// 止まった原因の名前。観察シート（serving-robot-observation.html）の
        /// 「止まった原因」の選択肢にそろえる。Unity にしかない原因は足した。
        /// </summary>
        public static string CauseLabel(ServingRobotController.StopCause cause)
        {
            switch (cause)
            {
                case ServingRobotController.StopCause.Guest: return "お客さんとすれ違った";
                case ServingRobotController.StopCause.Staff: return "スタッフとすれ違った";
                case ServingRobotController.StopCause.PersonStanding: return "人が立ち止まっていた";
                case ServingRobotController.StopCause.Chair: return "椅子・荷物・ベビーカー";
                case ServingRobotController.StopCause.NoPath: return "通路の幅が足りない";
                case ServingRobotController.StopCause.OtherRobot: return "もう1台のロボット";
                case ServingRobotController.StopCause.AisleQueue: return "通路の順番待ち";
                default: return "分からない";
            }
        }

        /// <summary>どう動き出したかの名前</summary>
        public static string ResolveLabel(ServingRobotController.StopResolve r)
        {
            switch (r)
            {
                case ServingRobotController.StopResolve.SelfResume: return "自分で動き出した";
                case ServingRobotController.StopResolve.Replanned: return "経路を引き直した";
                case ServingRobotController.StopResolve.Stuck: return "動けなくなった";
                // 依頼 v9 の 5 章
                case ServingRobotController.StopResolve.Detoured: return "回り込んで動き出した";
                case ServingRobotController.StopResolve.YieldedToRobot: return "よける場所へ動いた";
                default: return "入口で待った";
            }
        }

        readonly List<Row> _rows = new List<Row>();
        readonly List<StopRow> _stops = new List<StopRow>();

        public int StopCount { get { return _stops.Count; } }

        /// <summary>
        /// 一時停止の回数の合計（pauses）。
        /// 往復ごとの n_stops（自分で動き出した止まり）を足す。
        /// 通路の入口で待った分と、動けなくなった分は入らない
        /// </summary>
        public int TotalStopCount()
        {
            int n = 0;
            for (int i = 0; i < _rows.Count; i++) n += _rows[i].nStops;
            return n;
        }
        public IReadOnlyList<StopRow> Stops { get { return _stops; } }

        public void AddStop(StopRow row) { _stops.Add(row); }

        public int RowCount { get { return _rows.Count; } }
        public IReadOnlyList<Row> Rows { get { return _rows; } }

        public void Add(Row row)
        {
            _rows.Add(row);
        }

        public void Clear()
        {
            _rows.Clear();
            _stops.Clear();
            _groups.Clear();
        }

        // ---------------- trips.csv ----------------

        public const string Header =
            "condition_id,aisle_width_m,crowd,robots,seed,robot_id,trip_id,table_id," +
            "distance_m,total_s,stopped_s,n_stops,stop_durations_s,stuck,timeout,wait_aisle_s," +
            "max_decel_mps2,min_clearance_m,met_robot," +
            "table_ids,dishes,dish_ready_s,dish_arrived_s,undelivered," +
            // v5 3章：往復ごとの測り（設計書2章の4つ）。⑦は狭い通路の中と外を分ける
            "speed_mps,stuck_count,stuck_wait_s,via_narrow," +
            "stops_in_narrow,stops_out_narrow,stopped_in_narrow_s,stopped_out_narrow_s," +
            // 依頼 v8 の 2：後ろに足した列
            "run_start_s,dish_ids,stop_arrived_s," +
            // 依頼 v8b の A：打ち切りで置き直した先
            "reset_to," +
            // 依頼 v9 の 5 章：地図の上で自分で道を引く
            "trip_end_s,path_mode,replans,detours,robot_yields," +
            "site_route_m,detour_extra_m,narrow_used,plan_ms_max";

        public void WriteTripsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(Header).Append('\n');
            for (int i = 0; i < _rows.Count; i++) AppendRow(sb, _rows[i]);
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static void AppendRow(StringBuilder sb, Row r)
        {
            sb.Append(r.conditionId).Append(',')
              .Append(F(r.aisleWidthM)).Append(',')
              .Append(r.crowd).Append(',')
              .Append(r.robots).Append(',')
              .Append(r.seed).Append(',')
              .Append(r.robotId).Append(',')
              .Append(r.tripId).Append(',')
              .Append(r.tableId).Append(',')
              .Append(F(r.distanceM)).Append(',')
              .Append(F(r.totalS)).Append(',')
              .Append(F(r.stoppedS)).Append(',')
              .Append(r.nStops).Append(',')
              .Append(Join(r.stopDurationsS)).Append(',')
              .Append(r.stuck ? 1 : 0).Append(',')
              .Append(r.timeout ? 1 : 0).Append(',')
              .Append(F(r.waitAisleS)).Append(',')
              .Append(F(r.maxDecelMps2)).Append(',')
              .Append(F(r.minClearanceM)).Append(',')
              .Append(r.metRobot ? 1 : 0).Append(',')
              .Append(JoinInt(r.tableIds)).Append(',')
              .Append(r.dishes).Append(',')
              .Append(Join(r.dishReadyS)).Append(',')
              .Append(Join(r.dishArrivedS)).Append(',')
              .Append(r.undelivered).Append(',')
              .Append(F(r.speedMps)).Append(',')
              .Append(r.stuckCount).Append(',')
              .Append(F(r.stuckWaitS)).Append(',')
              .Append(r.viaNarrow ? 1 : 0).Append(',')
              .Append(r.stopsInNarrow).Append(',')
              .Append(r.stopsOutNarrow).Append(',')
              .Append(F(r.stoppedInNarrowS)).Append(',')
              .Append(F(r.stoppedOutNarrowS)).Append(',')
              .Append(F(r.runStartS)).Append(',')
              .Append(JoinInt(r.dishIds)).Append(',')
              .Append(Join(r.stopArrivedS)).Append(',')
              .Append(r.resetTo ?? "").Append(',')
              // 依頼 v9 の 5 章
              .Append(F(r.tripEndS)).Append(',')
              .Append(r.pathMode ?? "").Append(',')
              .Append(r.replans).Append(',')
              .Append(r.detours).Append(',')
              .Append(r.robotYields).Append(',')
              .Append(F(r.siteRouteM)).Append(',')
              .Append(F(r.detourExtraM)).Append(',')
              .Append(r.narrowUsed ? 1 : 0).Append(',')
              .Append(F(r.planMsMax))
              .Append('\n');
        }

        static string Join(float[] values)
        {
            if (values == null || values.Length == 0) return "";
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(';');
                sb.Append(F(values[i]));
            }
            return sb.ToString();
        }

        static string JoinInt(int[] values)
        {
            if (values == null || values.Length == 0) return "";
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(';');
                sb.Append(values[i]);
            }
            return sb.ToString();
        }

        static string F(float v)
        {
            return v.ToString("0.####", CultureInfo.InvariantCulture);
        }

        // ---------------- groups.csv ----------------

        readonly List<GroupRow> _groups = new List<GroupRow>();
        public int GroupRowCount { get { return _groups.Count; } }
        public void AddGroup(GroupRow r) { _groups.Add(r); }

        /// <summary>table_seats は座った卓の大きさ（2＝2人席・4＝4人席・0＝座れなかった）</summary>
        public const string GroupHeader =
            "condition_id,seed,group_id,size,table_id,table_seats,arrive_s,guide_start_s,seated_s," +
            "eat_end_s,pay_start_s,pay_s,left_s,waited_min,warmup,state," +
            // 依頼 v8 の 2：後ろに足した列
            "eat_start_s";

        public void WriteGroupsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(GroupHeader).Append('\n');
            for (int i = 0; i < _groups.Count; i++)
            {
                GroupRow r = _groups[i];
                sb.Append(r.conditionId).Append(',').Append(r.seed).Append(',')
                  .Append(r.groupId).Append(',').Append(r.size).Append(',').Append(r.tableId).Append(',')
                  .Append(r.tableSeats).Append(',')
                  .Append(F(r.arriveS)).Append(',').Append(F(r.guideStartS)).Append(',')
                  .Append(F(r.seatedS)).Append(',').Append(F(r.eatEndS)).Append(',')
                  .Append(F(r.payStartS)).Append(',')
                  .Append(F(r.payS)).Append(',').Append(F(r.leftS)).Append(',')
                  .Append(F(r.waitedMin)).Append(',').Append(r.warmup ? 1 : 0).Append(',')
                  .Append(r.state).Append(',')
                  .Append(F(r.eatStartS)).Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- people_jams.csv ----------------

        public const string JamHeader =
            "condition_id,seed,t_s,x,z,aisle,people,duration_s,resolved,robot_blocked,robot_id";

        public static void WriteJamsCsv(string path, string conditionId, int seed,
                                        IReadOnlyList<JamWatcher.JamRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(JamHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    JamWatcher.JamRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.people).Append(',')
                      .Append(F(r.durationS)).Append(',').Append(r.resolved).Append(',')
                      .Append(r.robotBlocked ? 1 : 0).Append(',').Append(r.robotId).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- give_ups.csv（B1）----------------

        public const string GiveUpHeader =
            "condition_id,seed,t_s,who,job,table_id,to_x,to_z,x,z,aisle,waited_s,action";

        /// <summary>あきらめた・移した・卓を空けたことを1回1行で書く</summary>
        public static void WriteGiveUpsCsv(string path, string conditionId, int seed,
                                           IReadOnlyList<GiveUpRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(GiveUpHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    GiveUpRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.who).Append(',').Append(r.job).Append(',')
                      .Append(r.tableId).Append(',')
                      .Append(F(r.toX)).Append(',').Append(F(r.toZ)).Append(',')
                      .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(F(r.waitedS)).Append(',')
                      .Append(r.action).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- contacts.csv（人がロボットに近づきすぎた）----------------

        public const string ContactHeader =
            "condition_id,seed,t_s,x,z,aisle,place,who,careless,robot_id,gap_cm";

        /// <summary>人がロボットの外周の近くまで来た1回を1行で書く</summary>
        public static void WriteContactsCsv(string path, string conditionId, int seed,
                                            IReadOnlyList<ContactRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(ContactHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    ContactRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.place).Append(',')
                      .Append(r.who).Append(',')
                      .Append(r.careless ? 1 : 0).Append(',').Append(r.robotId).Append(',')
                      .Append(F(r.gapM * 100f)).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- 往復のまとめ（段階Bの台帳が写す・2026-10-23）----------------

        /// <summary>止まっていた秒数の合計（stops.csv の duration_s の合計）</summary>
        float StopSecondsTotal()
        {
            float s = 0f;
            for (int i = 0; i < _stops.Count; i++) s += _stops[i].durationS;
            return s;
        }

        /// <summary>
        /// 往復のまとめを summary.json に出す（段階Bの台帳が写す）。
        ///
        /// robot_speed_mps は、サイトの common.robot_speed（role=unity_measures
        /// ＝「Unity で測ってサイトに戻すための仮の値」）と同じ意味で Unity が測った値。
        ///   走れた速さ ＝ 走った距離の合計 ÷（かかった秒数 − 止まっていた秒数）
        /// pause_s_mean は、サイトの common.pause_s（同じ role）と同じ意味で、
        /// 自分で動き出した止まり1回の長さの平均（秒）。
        ///
        /// サイトの pause_rate・stuck_rate は「hop_m（3 m）進むごとの起きやすさ」だが、
        /// hop_m は role=site_only（試算の中だけの設定）で Unity は読まない決まりなので、
        /// ここでは出さない。かわりに、割り出すのに要る素の数を出す
        /// （pauses ＝ stops.count、stuck_count、distance_m_total）。
        /// </summary>
        void AppendTripSummary(StringBuilder sb)
        {
            int trips = _rows.Count;
            int timeouts = 0, stuckTrips = 0;
            float distance = 0f, total = 0f, stopped = 0f;

            for (int i = 0; i < _rows.Count; i++)
            {
                Row r = _rows[i];
                if (r.timeout) timeouts++;
                if (r.stuck) stuckTrips++;
                distance += r.distanceM;
                total += r.totalS;
                stopped += r.stoppedS;
            }

            float moving = Mathf.Max(0f, total - stopped);

            // 自分で動き出した止まりの長さの平均（pause_s と同じ意味）
            float pauseSum = 0f;
            int pauseN = 0;
            for (int i = 0; i < _rows.Count; i++)
            {
                float[] d = _rows[i].stopDurationsS;
                if (d == null) continue;
                for (int k = 0; k < d.Length; k++) { pauseSum += d[k]; pauseN++; }
            }

            sb.Append("  \"trips\": {");
            sb.Append("\"trips\": ").Append(trips).Append(", ");
            sb.Append("\"timeouts\": ").Append(timeouts).Append(", ");
            sb.Append("\"stuck_trips\": ").Append(stuckTrips).Append(", ");
            sb.Append("\"distance_m_total\": ").Append(F(distance)).Append(", ");
            sb.Append("\"total_s\": ").Append(F(total)).Append(", ");
            sb.Append("\"stopped_s\": ").Append(F(stopped)).Append(", ");
            sb.Append("\"robot_speed_mps\": ")
              .Append(F(moving > 0.001f ? distance / moving : 0f)).Append(", ");
            sb.Append("\"pause_s_mean\": ")
              .Append(F(pauseN > 0 ? pauseSum / pauseN : 0f)).Append(", ");
            sb.Append("\"pause_count\": ").Append(pauseN).Append(", ");
            sb.Append("\"pause_rate_note\": ")
              .Append(Str("hop_m は role=site_only で Unity は読まないため出さない。" +
                          "割り出すときは stops.count ÷（distance_m_total ÷ hop_m）")).Append(", ");
            sb.Append("\"stuck_rate_note\": ")
              .Append(Str("同じ理由で出さない。stops.stuck_count ÷（distance_m_total ÷ hop_m）"));
            sb.Append("},\n");
        }

        // ---------------- summary に出す譲り合いと角の止まり（2026-10-23）----------------

        /// <summary>
        /// 譲った回数を summary に出す。譲った側の種類ごと（ロボット・スタッフ・客）と、
        /// そのうち相手の道から横へどけた回数・角で起きた回数・待った秒数の合計。
        /// </summary>
        void AppendYields(StringBuilder sb)
        {
            int all = 0, robot = 0, staff = 0, guest = 0, stepped = 0, inCorner = 0, passed = 0;
            float waited = 0f, staffWaited = 0f;

            if (_sim != null)
            {
                for (int i = 0; i < _sim.yields.Count; i++)
                {
                    YieldRow y = _sim.yields[i];
                    all++;
                    if (y.rank == RobotSpec.RankRobot) robot++;
                    else if (y.rank == RobotSpec.RankStaff) { staff++; staffWaited += y.waitedS; }
                    else guest++;
                    if (y.steppedAside) stepped++;
                    if (y.inCorner) inCorner++;
                    if (y.passed) passed++;
                    waited += y.waitedS;
                }
            }

            sb.Append("\"yields\": ").Append(all).Append(", ");
            sb.Append("\"yields_by_robot\": ").Append(robot).Append(", ");
            sb.Append("\"yields_by_staff\": ").Append(staff).Append(", ");
            sb.Append("\"yields_by_guest\": ").Append(guest).Append(", ");
            sb.Append("\"yields_stepped_aside\": ").Append(stepped).Append(", ");
            sb.Append("\"yields_in_corner\": ").Append(inCorner).Append(", ");
            sb.Append("\"yields_passed\": ").Append(passed).Append(", ");
            sb.Append("\"yield_wait_s\": ").Append(F(waited)).Append(", ");
            sb.Append("\"yield_wait_mean_s\": ").Append(F(all > 0 ? waited / all : 0f)).Append(", ");

            // スタッフが譲った回数・合計秒数と、スタッフの時間に占める割合（v6b）。
            // 分母はスタッフの時間の合計（staff_time_s の全部。待機もふくむ
            // ＝ スタッフの人数 × 回した秒数）
            float staffTotal = 0f;
            if (_sim != null)
                for (int i = 0; i < _sim.staffTime.Count; i++) staffTotal += _sim.staffTime[i].Value;

            sb.Append("\"staff_yields\": ").Append(staff).Append(", ");
            sb.Append("\"staff_yield_wait_s\": ").Append(F(staffWaited)).Append(", ");
            sb.Append("\"staff_yield_mean_s\": ")
              .Append(F(staff > 0 ? staffWaited / staff : 0f)).Append(", ");
            sb.Append("\"staff_yield_share_of_staff_time\": ")
              .Append(F(staffTotal > 0.001f ? staffWaited / staffTotal : 0f)).Append(", ");
            sb.Append("\"staff_time_total_s\": ").Append(F(staffTotal)).Append(", ");
        }

        /// <summary>
        /// 受け渡し口と客席をつなぐ角（RobotSpec の範囲）での、ロボットの止まりの
        /// 回数・秒数と、止まり全体に対する割合を summary に出す。
        /// 1311・1536 の「止まった時間の約3分の1がこの角」と比べるため
        /// </summary>
        void AppendCornerStops(StringBuilder sb)
        {
            int n = 0, allN = 0;
            float secs = 0f, allSecs = 0f;

            bool haveRange = _sim != null && _sim.cornerMaxX > _sim.cornerMinX;
            for (int i = 0; i < _stops.Count; i++)
            {
                StopRow r = _stops[i];
                allN++;
                allSecs += r.durationS;
                if (!haveRange) continue;
                if (r.x < _sim.cornerMinX || r.x > _sim.cornerMaxX) continue;
                if (r.z < _sim.cornerMinZ || r.z > _sim.cornerMaxZ) continue;
                n++;
                secs += r.durationS;
            }

            sb.Append("\"corner_stops\": ").Append(n).Append(", ");
            sb.Append("\"corner_stop_s\": ").Append(F(secs)).Append(", ");
            sb.Append("\"corner_stop_count_share\": ")
              .Append(F(allN > 0 ? (float)n / allN : 0f)).Append(", ");
            sb.Append("\"corner_stop_time_share\": ")
              .Append(F(allSecs > 0.001f ? secs / allSecs : 0f)).Append(", ");
            sb.Append("\"corner_range\": \"x ")
              .Append(F(_sim != null ? _sim.cornerMinX : 0f)).Append("-")
              .Append(F(_sim != null ? _sim.cornerMaxX : 0f)).Append(" / z ")
              .Append(F(_sim != null ? _sim.cornerMinZ : 0f)).Append("-")
              .Append(F(_sim != null ? _sim.cornerMaxZ : 0f)).Append("\", ");
        }

        // ---------------- yields.csv（誰が誰に譲ったか・2026-10-23）----------------

        public const string YieldHeader =
            "condition_id,seed,t_s,who,rank,to_whom,x,z,aisle,place,in_corner," +
            "waited_s,stepped_aside,passed";

        /// <summary>
        /// 譲り1回を1行で書く（yields.csv）。
        /// rank は譲った側の順番（0=ロボット・1=スタッフ・2=客）、
        /// stepped_aside は相手の道から横へ 0.6 m 以上どけたか、
        /// passed は相手が通り過ぎて終わったか（0 なら待ちすぎてやめた）。
        /// </summary>
        public static void WriteYieldsCsv(string path, string conditionId, int seed,
                                          IReadOnlyList<YieldRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(YieldHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    YieldRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.who).Append(',')
                      .Append(r.rank).Append(',').Append(r.toWhom).Append(',')
                      .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(r.aisle).Append(',').Append(r.place).Append(',')
                      .Append(r.inCorner ? 1 : 0).Append(',')
                      .Append(F(r.waitedS)).Append(',')
                      .Append(r.steppedAside ? 1 : 0).Append(',')
                      .Append(r.passed ? 1 : 0).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- passes.csv（ロボットどうしのすれ違い・依頼 v13）----------------

        public const string PassHeader =
            "condition_id,seed,t_start_s,t_end_s,robot_a,robot_b,x,z," +
            "min_gap_m,min_speed_mps,outcome";

        /// <summary>
        /// すれ違い1回を1行で書く（passes.csv）。
        /// min_gap_m は いちばん近づいたときの体どうしの間、
        /// min_speed_mps は そのあいだで2台のうちいちばん遅かった速さ、
        /// outcome は すれ違えた／幅が足りず今の決まりへ／向かい合って止まった。
        /// passByEnabled が切の回でも同じように書く（入の回と比べるため）
        /// </summary>
        public static void WritePassesCsv(string path, string conditionId, int seed,
                                          IReadOnlyList<PassRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(PassHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    PassRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.startS)).Append(',').Append(F(r.endS)).Append(',')
                      .Append(r.robotA).Append(',').Append(r.robotB).Append(',')
                      .Append(F(r.x)).Append(',').Append(F(r.z)).Append(',')
                      .Append(F(r.minGapM)).Append(',').Append(F(r.minSpeedMps)).Append(',')
                      .Append(r.outcome).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- dishes.csv（皿ごと・サイトの試算 v2.7）----------------

        public const string DishHeader =
            "condition_id,seed,dish_id,table_id,group_id,fragile," +
            "ready_s,first_taken_s,arrived_s,carried_by,pass_wait_s,carry_s,requeue_lost_s," +
            "requeued,staff_from_start,robot_wait_expired,late_staff_pickup,deferred_for_aisle";

        /// <summary>
        /// 届いた料理1皿を1行で書く（dishes.csv）。
        /// carried_by は「ロボット」か「スタッフ」、pass_wait_s は受け渡し口で待った秒数、
        /// late_staff_pickup は robot_first_wait_s を過ぎて人が拾った皿（1 なら該当）。
        /// </summary>
        public static void WriteDishesCsv(string path, string conditionId, int seed,
                                          IReadOnlyList<DishRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(DishHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    DishRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(r.dishId).Append(',').Append(r.tableId).Append(',')
                      .Append(r.groupId).Append(',').Append(r.fragile ? 1 : 0).Append(',')
                      .Append(F(r.readyS)).Append(',').Append(F(r.firstTakenS)).Append(',')
                      .Append(F(r.arrivedS)).Append(',')
                      .Append(r.byRobot ? "ロボット" : "スタッフ").Append(',')
                      .Append(F(r.passWaitS)).Append(',').Append(F(r.carryS)).Append(',')
                      .Append(F(r.requeueS)).Append(',').Append(r.requeued).Append(',')
                      .Append(r.staffFromStart ? 1 : 0).Append(',')
                      .Append(r.robotWaitExpired ? 1 : 0).Append(',')
                      .Append(r.lateStaffPickup ? 1 : 0).Append(',')
                      .Append(r.deferredForAisle ? 1 : 0).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- requeues.csv（B8）----------------

        public const string RequeueHeader =
            "condition_id,seed,t_s,dish_id,table_id,robot_id,fragile,ready_s,waited_s,requeued_so_far,why";

        /// <summary>届けられずに受け渡し口へ戻した1回を1行で書く</summary>
        public static void WriteRequeuesCsv(string path, string conditionId, int seed,
                                            IReadOnlyList<RequeueRow> rows)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(RequeueHeader).Append('\n');
            if (rows != null)
            {
                for (int i = 0; i < rows.Count; i++)
                {
                    RequeueRow r = rows[i];
                    sb.Append(conditionId).Append(',').Append(seed).Append(',')
                      .Append(F(r.tS)).Append(',').Append(r.dishId).Append(',').Append(r.tableId).Append(',')
                      .Append(r.robotId).Append(',').Append(r.fragile ? 1 : 0).Append(',')
                      .Append(F(r.readyS)).Append(',').Append(F(r.tS - r.readyS)).Append(',')
                      .Append(r.requeuedSoFar).Append(',').Append(r.why).Append('\n');
                }
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- stops.csv ----------------

        public const string StopHeader =
            "condition_id,seed,robot_id,trip_id,t_s,x,z,aisle,in_narrow,cause,cause_detail,path_blocked," +
            "duration_s,resolved,clearance_left_cm,clearance_right_cm,min_gap_cm," +
            "blocked_s,no_path_s,yield_s,clear_wait_s,replans," +
            // 道が引けない原因を追うための列（記録だけ。動きは変えない）
            "solve_from,solve_to,solve_status,solve_corners,off_mesh_m";

        public void WriteStopsCsv(string path)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append(StopHeader).Append('\n');
            for (int i = 0; i < _stops.Count; i++)
            {
                StopRow r = _stops[i];
                sb.Append(r.conditionId).Append(',')
                  .Append(r.seed).Append(',')
                  .Append(r.robotId).Append(',')
                  .Append(r.tripId).Append(',')
                  .Append(F(r.tS)).Append(',')
                  .Append(F(r.x)).Append(',')
                  .Append(F(r.z)).Append(',')
                  .Append(r.aisle).Append(',')
                  .Append(r.inNarrow ? 1 : 0).Append(',')
                  .Append(r.cause).Append(',')
                  .Append(r.causeDetail).Append(',')
                  .Append(r.pathBlocked ? 1 : 0).Append(',')
                  .Append(F(r.durationS)).Append(',')
                  .Append(r.resolved).Append(',')
                  .Append(F(r.clearanceLeftCm)).Append(',')
                  .Append(F(r.clearanceRightCm)).Append(',')
                  .Append(F(r.minGapCm)).Append(',')
                  .Append(F(r.blockedS)).Append(',')
                  .Append(F(r.noPathS)).Append(',')
                  .Append(F(r.yieldS)).Append(',')
                  .Append(F(r.clearWaitS)).Append(',')
                  .Append(r.replans).Append(',')
                  .Append(r.solveFrom).Append(',')
                  .Append(r.solveTo).Append(',')
                  .Append(r.solveStatus).Append(',')
                  .Append(r.solveCorners).Append(',')
                  .Append(F(r.offMeshM))
                  .Append('\n');
            }
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        // ---------------- run_info.json（1回ぶんの回し方とコマの粗さ）----------------

        /// <summary>1回ぶんの回し方と、コマの粗さの記録</summary>
        public class RunInfo
        {
            public string runLabel = "";
            public string conditionId = "";
            public string timeMode = "";
            public string storeKind = "";
            public string crowd = "";
            public int tripsRequested;
            public int tripsDone;
            public int robots;
            public int seed;
            public float aisleWidthM;

            /// <summary>卓の停止点を通路の中心からずらす距離（m）。.asset に入れた値（C1）</summary>
            public float deliveryOffsetM;

            /// <summary>そのうち、実際に使った分（通路が狭いときは NavMesh に乗る範囲に抑える）</summary>
            public float deliveryOffsetUsedM;

            public float simSeconds;
            public float wallSeconds;
            public float timeScale;
            public float captureDeltaTime;
            public bool stoppedEarly;

            /// <summary>
            /// 同じ通路に2台を集めない決まり（v5・A）で回したか。
            /// Unity だけの決まりなので、どちらで回したかを残す
            /// </summary>
            public bool oneRobotPerAisle;

            /// <summary>譲る順番の決まりで回したか（v6。RobotSpec.yieldOrderEnabled）</summary>
            public bool yieldOrderEnabled;

            // ---- すれ違い（依頼 v13 の 依頼1）----
            /// <summary>すれ違いの走り方を入れて回したか（RobotSpec.passByEnabled）</summary>
            public bool passByEnabled;

            /// <summary>そのときの3つの値（m・m/秒・m）</summary>
            public float passByDistanceM, passBySpeedMps, passBySeparationM;

            /// <summary>回し始めの Unity Version Control の changeset（段階B。メニューが入れる）</summary>
            public string unityCs = "";

            // ---- 誰が回したか（依頼 v12 の 2 章・2026-10-26）----
            /// <summary>
            /// 回した人（入れ物）。"editor"（段階B・Site Run）/ "headless"（画面なしの実行ファイル）。
            /// 夜の回とエディタの回を、台帳と出力から取り違えないため
            /// </summary>
            public string runner = "editor";

            /// <summary>画面なしの実行ファイルの版（build_info.json の build_id）。エディタのときは空</summary>
            public string buildId = "";

            /// <summary>その夜の名前（例 20261026）。夜の実行係が渡す。エディタのときは空</summary>
            public string night = "";

            /// <summary>そのとき同時に何本走っていたか。夜の実行係が渡す。エディタのときは 0</summary>
            public int parallel;

            /// <summary>
            /// 回し始めに保留中の変更があったか（段階B）。
            /// あれば「保留あり N 件」。status に関係なく残す（依頼 v7 の 2）
            /// </summary>
            public string pendingNote = "";

            // ---- ずれの切り分け（依頼 v11 の 5 章。試しだけ。check-in しない）----
            /// <summary>入れた試しの切り替え（空なら cs:2 と同じ動き）</summary>
            public string detCheck = "";

            /// <summary>
            /// この回で実際に入った道探しの枠（NavMesh.pathfindingIterationsPerFrame）。
            /// 2026-10-26：隠れた設定が残っていないかを、回ごとに確かめられるようにした
            /// </summary>
            public int pathIterationsPerFrame;

            /// <summary>前のセンサーの箱に入った当たりのいちばん多かった数と、切り捨てた回数</summary>
            public int sensorMaxHits, sensorOverflows;

            /// <summary>店員優先の版で回したか（v5。人のよけ方の切り替え）</summary>
            public bool staffFirstYield;

            /// <summary>1コマの時間（秒）。Time.deltaTime をそのまま</summary>
            public readonly List<float> frameDeltas = new List<float>();

            /// <summary>1コマでロボットが進んだ距離（m）。台のうちいちばん大きいもの</summary>
            public readonly List<float> robotSteps = new List<float>();

            /// <summary>
            /// 1コマで人が進んだ距離（m）。通路を歩く人のうちいちばん大きいもの（B10）。
            /// スタッフは 1.0 m/秒 なので、1コマ 0.05 秒だと 0.05 m でちょうど境目になる。
            /// </summary>
            public readonly List<float> peopleSteps = new List<float>();

            /// <summary>椅子を引いた回数（B6）</summary>
            public int chairPulls;

            /// <summary>椅子を引いたきっかけごとの回数（B6）</summary>
            public readonly List<KeyValuePair<string, int>> chairPullsByReason =
                new List<KeyValuePair<string, int>>();

            // ---- 依頼 v9 の 5 章：地図の上で自分で道を引く ----

            /// <summary>実際に使った道の決め方（MapPlanner / SiteRoutes）</summary>
            public string pathMode = "";

            /// <summary>地図の版の番号（map.json の meta.generation）</summary>
            public string mapGeneration = "";

            /// <summary>道の引き方の数字</summary>
            public float narrowCellCost, centerWeight, preferClearM, detourAfterSec;

            /// <summary>1 回の道探しにかかった時間（ミリ秒。往復ごとの最大を集めたもの）</summary>
            public readonly List<float> planMs = new List<float>();

            /// <summary>
            /// 出発点が地図では通れないマス（0）だったために寄せた回数の合計。
            /// 通路の入口で待つ点・横へどいた点は free_class では 0 になるので、ここに出る
            /// </summary>
            public int mapSnapStarts;

            /// <summary>道が引けなかった回数の合計</summary>
            public int planFails;
        }

        /// <summary>コマの粗さを1行にまとめる（Console 用）</summary>
        public static string FrameStatsText(RunInfo info)
        {
            if (info == null || info.frameDeltas.Count == 0) return "コマの記録なし";
            int over = 0;
            for (int i = 0; i < info.robotSteps.Count; i++) if (info.robotSteps[i] > 0.05f) over++;
            return "コマ " + info.frameDeltas.Count +
                   " / 1コマの時間 中央 " + F(Percentile(info.frameDeltas, 0.5f)) +
                   "s・90% " + F(Percentile(info.frameDeltas, 0.9f)) +
                   "s・最大 " + F(Percentile(info.frameDeltas, 1f)) + "s" +
                   " / 1コマで進む距離 最大 " + F(Percentile(info.robotSteps, 1f)) +
                   "m・90% " + F(Percentile(info.robotSteps, 0.9f)) + "m" +
                   " / 0.05 m を超えたコマ ロボット " + over + "（" +
                   (info.robotSteps.Count > 0 ? (100f * over / info.robotSteps.Count).ToString("0.0") : "0") +
                   "%）・人 " + CountOver(info.peopleSteps, 0.05f) + "（" +
                   (info.peopleSteps.Count > 0
                        ? (100f * CountOver(info.peopleSteps, 0.05f) / info.peopleSteps.Count).ToString("0.0")
                        : "0") + "%）";
        }

        public static void WriteRunInfoJson(string path, RunInfo info)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_label\": ").Append(Str(info.runLabel)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"condition_id\": ").Append(Str(info.conditionId)).Append(",\n");
            sb.Append("  \"store_kind\": ").Append(Str(info.storeKind)).Append(",\n");
            sb.Append("  \"time_mode\": ").Append(Str(info.timeMode)).Append(",\n");
            sb.Append("  \"time_scale\": ").Append(F(info.timeScale)).Append(",\n");
            sb.Append("  \"capture_delta_time\": ").Append(F(info.captureDeltaTime)).Append(",\n");
            sb.Append("  \"aisle_width_m\": ").Append(F(info.aisleWidthM)).Append(",\n");
            sb.Append("  \"delivery_offset_m\": ").Append(F(info.deliveryOffsetM)).Append(",\n");
            sb.Append("  \"delivery_offset_used_m\": ").Append(F(info.deliveryOffsetUsedM)).Append(",\n");
            sb.Append("  \"crowd\": ").Append(Str(info.crowd)).Append(",\n");
            sb.Append("  \"robots\": ").Append(info.robots).Append(",\n");
            sb.Append("  \"seed\": ").Append(info.seed).Append(",\n");
            sb.Append("  \"trips_requested\": ").Append(info.tripsRequested).Append(",\n");
            sb.Append("  \"trips_done\": ").Append(info.tripsDone).Append(",\n");
            sb.Append("  \"stopped_early\": ").Append(info.stoppedEarly ? "true" : "false").Append(",\n");
            sb.Append("  \"one_robot_per_aisle\": ")
              .Append(info.oneRobotPerAisle ? "true" : "false").Append(",\n");
            sb.Append("  \"yield_order_enabled\": ")
              .Append(info.yieldOrderEnabled ? "true" : "false").Append(",\n");
            // 依頼 v13 の 依頼1：すれ違いの入切と3つの値
            sb.Append("  \"pass_by\": {");
            sb.Append("\"enabled\": ").Append(info.passByEnabled ? "true" : "false").Append(", ");
            sb.Append("\"distance_m\": ").Append(F(info.passByDistanceM)).Append(", ");
            sb.Append("\"speed_mps\": ").Append(F(info.passBySpeedMps)).Append(", ");
            sb.Append("\"separation_m\": ").Append(F(info.passBySeparationM));
            sb.Append("},\n");
            sb.Append("  \"unity_cs\": ").Append(Str(info.unityCs)).Append(",\n");
            // 依頼 v12 の 2 章：誰が回したか（夜の回とエディタの回を取り違えないため）
            sb.Append("  \"runner\": ").Append(Str(info.runner)).Append(",\n");
            sb.Append("  \"build_id\": ").Append(Str(info.buildId)).Append(",\n");
            sb.Append("  \"night\": ").Append(Str(info.night)).Append(",\n");
            sb.Append("  \"parallel\": ").Append(info.parallel).Append(",\n");
            // 依頼 v11 の 5 章（試しだけ）。det_check が空なら cs:2 と同じ動き
            sb.Append("  \"det_check\": ").Append(Str(info.detCheck)).Append(",\n");
            // 2026-10-26：実際に入った道探しの枠（既定 100）
            sb.Append("  \"path_iterations_per_frame\": ")
              .Append(info.pathIterationsPerFrame).Append(",\n");
            sb.Append("  \"sensor_max_hits\": ").Append(info.sensorMaxHits).Append(",\n");
            sb.Append("  \"sensor_overflows\": ").Append(info.sensorOverflows).Append(",\n");
            sb.Append("  \"pending_changes\": ").Append(Str(info.pendingNote)).Append(",\n");
            sb.Append("  \"staff_first_yield\": ")
              .Append(info.staffFirstYield ? "true" : "false").Append(",\n");
            sb.Append("  \"sim_seconds\": ").Append(F(info.simSeconds)).Append(",\n");
            sb.Append("  \"wall_seconds\": ").Append(F(info.wallSeconds)).Append(",\n");
            sb.Append("  \"speedup\": ").Append(F(info.wallSeconds > 0.01f ? info.simSeconds / info.wallSeconds : 0f)).Append(",\n");

            sb.Append("  \"frame_delta_s\": ");
            AppendStats(sb, info.frameDeltas);
            sb.Append(",\n");

            sb.Append("  \"robot_step_m\": ");
            AppendStats(sb, info.robotSteps);
            sb.Append(",\n");

            sb.Append("  \"people_step_m\": ");
            AppendStats(sb, info.peopleSteps);
            sb.Append(",\n");

            // すり抜けの確かめ（検査3）。ロボットと人を分けて出す
            int over = CountOver(info.robotSteps, 0.05f);
            int overPeople = CountOver(info.peopleSteps, 0.05f);
            sb.Append("  \"frames_over_005m\": ").Append(over).Append(",\n");
            sb.Append("  \"frames_over_005m_ratio\": ")
              .Append(F(info.robotSteps.Count > 0 ? (float)over / info.robotSteps.Count : 0f)).Append(",\n");
            sb.Append("  \"people_frames_over_005m\": ").Append(overPeople).Append(",\n");
            sb.Append("  \"people_frames_over_005m_ratio\": ")
              .Append(F(info.peopleSteps.Count > 0 ? (float)overPeople / info.peopleSteps.Count : 0f)).Append(",\n");

            // 椅子を引いた回数（B6）
            sb.Append("  \"chair_pulls\": ").Append(info.chairPulls).Append(",\n");
            sb.Append("  \"chair_pulls_by_reason\": {");
            for (int i = 0; i < info.chairPullsByReason.Count; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(Str(info.chairPullsByReason[i].Key)).Append(": ")
                  .Append(info.chairPullsByReason[i].Value);
            }
            sb.Append("},\n");

            // 依頼 v9 の 5 章：地図の上で自分で道を引く
            sb.Append("  \"path_mode\": ").Append(Str(info.pathMode)).Append(",\n");
            sb.Append("  \"map_generation\": ").Append(Str(info.mapGeneration)).Append(",\n");
            sb.Append("  \"narrow_cell_cost\": ").Append(F(info.narrowCellCost)).Append(",\n");
            sb.Append("  \"center_weight\": ").Append(F(info.centerWeight)).Append(",\n");
            sb.Append("  \"prefer_clear_m\": ").Append(F(info.preferClearM)).Append(",\n");
            sb.Append("  \"detour_after_sec\": ").Append(F(info.detourAfterSec)).Append(",\n");
            sb.Append("  \"plan_ms_max_per_trip\": ");
            AppendStats(sb, info.planMs);
            sb.Append(",\n");
            sb.Append("  \"map_snap_starts\": ").Append(info.mapSnapStarts).Append(",\n");
            sb.Append("  \"plan_fails\": ").Append(info.planFails).Append("\n");
            sb.Append("}\n");

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        /// <summary>しきい値を超えたコマの数</summary>
        static int CountOver(List<float> values, float limit)
        {
            if (values == null) return 0;
            int n = 0;
            for (int i = 0; i < values.Count; i++) if (values[i] > limit) n++;
            return n;
        }

        static void AppendStats(StringBuilder sb, List<float> values)
        {
            if (values == null || values.Count == 0) { sb.Append("null"); return; }
            float sum = 0f;
            for (int i = 0; i < values.Count; i++) sum += values[i];
            sb.Append("{\"count\": ").Append(values.Count);
            sb.Append(", \"mean\": ").Append(F(sum / values.Count));
            sb.Append(", \"min\": ").Append(F(Percentile(values, 0f)));
            sb.Append(", \"p50\": ").Append(F(Percentile(values, 0.5f)));
            sb.Append(", \"p90\": ").Append(F(Percentile(values, 0.9f)));
            sb.Append(", \"p99\": ").Append(F(Percentile(values, 0.99f)));
            sb.Append(", \"max\": ").Append(F(Percentile(values, 1f)));
            sb.Append("}");
        }

        // ---------------- conditions.json ----------------

        /// <summary>
        /// 18条件の中身と、設定ファイル（.asset）の値すべてを書く。
        /// 値は実行したときのものを JsonUtility でそのまま出す。
        /// </summary>
        public static void WriteConditionsJson(string path, string runName,
                                               StoreLayout layout, RobotSpec robotSpec,
                                               PeopleSpec peopleSpec, ExperimentPlan plan,
                                               List<ExperimentPlan.Condition> conditions)
        {
            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_name\": ").Append(Str(runName)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"unity_version\": ").Append(Str(Application.unityVersion)).Append(",\n");
            sb.Append("  \"trips_csv_columns\": ").Append(Str(Header)).Append(",\n");
            sb.Append("  \"groups_csv_columns\": ").Append(Str(GroupHeader)).Append(",\n");
            sb.Append("  \"dishes_csv_columns\": ").Append(Str(DishHeader)).Append(",\n");

            sb.Append("  \"settings\": {\n");
            sb.Append("    \"store_layout\": ").Append(Dump(layout)).Append(",\n");
            sb.Append("    \"robot_spec\": ").Append(Dump(robotSpec)).Append(",\n");
            sb.Append("    \"people_spec\": ").Append(Dump(peopleSpec)).Append(",\n");
            sb.Append("    \"experiment_plan\": ").Append(Dump(plan)).Append("\n");
            sb.Append("  },\n");

            // 組み立てた形から出る値（conditions.json だけ見れば店の大きさが分かるように）
            sb.Append("  \"derived\": {\n");
            if (layout != null)
            {
                StorePlan sp = layout.NewPlan();
                sb.Append("    \"store_kind\": ").Append(Str(layout.StoreKindName)).Append(",\n");
                sb.Append("    \"aisle_width_m\": ").Append(F(layout.AisleWidth)).Append(",\n");
                if (sp != null)
                {
                    sb.Append("    \"floor_min_x\": ").Append(F(sp.floor.xMin)).Append(",\n");
                    sb.Append("    \"floor_max_x\": ").Append(F(sp.floor.xMax)).Append(",\n");
                    sb.Append("    \"floor_min_z\": ").Append(F(sp.floor.zMin)).Append(",\n");
                    sb.Append("    \"floor_max_z\": ").Append(F(sp.floor.zMax)).Append(",\n");
                    sb.Append("    \"outer_size_x_m\": ").Append(F(sp.floor.SizeX)).Append(",\n");
                    sb.Append("    \"outer_size_z_m\": ").Append(F(sp.floor.SizeZ)).Append(",\n");
                    sb.Append("    \"table_count\": ").Append(sp.seats.Count).Append(",\n");
                    sb.Append("    \"aisle_count\": ").Append(sp.aisles.Count).Append(",\n");
                    sb.Append("    \"narrow_aisle_count\": ").Append(sp.NarrowAisleCount).Append("\n");
                }
                else
                {
                    sb.Append("    \"plan\": null\n");
                }
            }
            sb.Append("  },\n");

            sb.Append("  \"conditions\": [\n");
            if (conditions != null)
            {
                for (int i = 0; i < conditions.Count; i++)
                {
                    ExperimentPlan.Condition c = conditions[i];
                    sb.Append("    {");
                    sb.Append("\"condition_id\": ").Append(Str(c.id)).Append(", ");
                    sb.Append("\"aisle_width_m\": ").Append(F(c.aisleWidthM)).Append(", ");
                    sb.Append("\"aisle_width_index\": ").Append(c.aisleWidthIndex).Append(", ");
                    sb.Append("\"crowd\": ").Append(Str(ExperimentPlan.CrowdName(c.crowd))).Append(", ");
                    sb.Append("\"walkers\": ").Append(peopleSpec != null ? peopleSpec.WalkerCount(c.crowd) : 0).Append(", ");
                    sb.Append("\"robots\": ").Append(c.robots).Append(", ");
                    sb.Append("\"trips\": ").Append(plan != null ? plan.tripsPerCondition : 0).Append(", ");
                    sb.Append("\"seeds\": ").Append(IntArray(plan != null ? plan.seeds : null)).Append(", ");
                    sb.Append("\"aisle_yielding\": ").Append(robotSpec != null && robotSpec.aisleYielding ? "true" : "false");
                    sb.Append('}');
                    if (i < conditions.Count - 1) sb.Append(',');
                    sb.Append('\n');
                }
            }
            sb.Append("  ]\n");
            sb.Append("}\n");

            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        static string Dump(ScriptableObject asset)
        {
            if (asset == null) return "null";
            return JsonUtility.ToJson(asset, false);
        }

        static string IntArray(int[] values)
        {
            if (values == null || values.Length == 0) return "[]";
            StringBuilder sb = new StringBuilder("[");
            for (int i = 0; i < values.Length; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(values[i]);
            }
            return sb.Append(']').ToString();
        }

        static string Str(string s)
        {
            if (s == null) return "null";
            StringBuilder sb = new StringBuilder("\"");
            for (int i = 0; i < s.Length; i++)
            {
                char c = s[i];
                if (c == '"' || c == '\\') sb.Append('\\').Append(c);
                else if (c == '\n') sb.Append("\\n");
                else if (c == '\r') sb.Append("\\r");
                else if (c == '\t') sb.Append("\\t");
                else sb.Append(c);
            }
            return sb.Append('"').ToString();
        }

        // ---------------- summary.json ----------------

        /// <summary>条件ごとにまとめる</summary>
        /// <summary>試算（haizen_sim.py）と同じ意味の数字。名前も同じにする</summary>
        public class SimLike
        {
            public List<float> dishWaitMin = new List<float>();
            public List<float> seatWaitMin = new List<float>();
            public List<float> turnMin = new List<float>();
            public float coldMin = 3f;
            public int robotDishes, staffDishes, requeuedDishes, groups;

            /// <summary>あきらめた・移した・卓を空けた回数（B1）</summary>
            public int gaveUp, warped, tableFreed;

            /// <summary>人どうしの詰まりのうち、近くでロボットが止まっていたもの（B9）</summary>
            public int robotBlockedJams;

            /// <summary>人がロボットに近づきすぎた回数・待った回数と秒数・回り道した回数</summary>
            public int robotContacts, robotYieldWaits, robotYieldDetours;
            public float robotYieldWaitSeconds;

            // ---- ロボットどうしのすれ違い（依頼 v13 の 依頼1）----
            /// <summary>すれ違いの回数（同じ2台の1回は1回）と、減速していた秒数の合計</summary>
            public int passCount;
            public float passSlowS;

            /// <summary>向かい合って止まった回数と、いちばん長かった秒数（入でも切でも数える）</summary>
            public int faceStopCount;
            public float faceStopMaxS;

            // 2026-10-26（依頼 v13 の直し③）：向かい合いを場所で分けて数える。
            // 受け渡し口は点が1つなので2台が必ず向かい合う。通路のふさぎ合いと混ぜない
            public int faceStopAtPassCount, faceStopAtStandbyCount;
            public float faceStopAtPassMaxS, faceStopAtStandbyMaxS;
            public int faceStopInAisleCount, faceStopOverlapCount;
            public float faceStopInAisleMaxS;

            /// <summary>レジの前にいた人数（いちばん多いとき・平均）</summary>
            public int registerCrowdMax;
            public float registerCrowdMean;

            /// <summary>会計の回数と、そのうちスタッフが店員側にいなかった回数（0 になるはず）</summary>
            public int registerPayCount, registerPayNotAtBackCount;

            /// <summary>立つ点から離れて会計した回数（0 になるはず）と、その距離</summary>
            public int registerPayOffSpotCount;
            public float registerPayMeanOffsetM, registerPayMaxOffsetM;

            /// <summary>会計でないスタッフが店員側にいた回数と秒数（どちらも 0 になるはず）</summary>
            public int registerBackIntruderCount;
            public float registerBackIntruderSeconds;

            /// <summary>スタッフの時間の使い方（仕事の名前 → 秒）</summary>
            public readonly List<KeyValuePair<string, float>> staffTime =
                new List<KeyValuePair<string, float>>();

            /// <summary>仕事が板に出てから取られるまでの秒数の平均（仕事の名前 → 秒）</summary>
            public readonly List<KeyValuePair<string, float>> jobWaitMean =
                new List<KeyValuePair<string, float>>();

            /// <summary>届いた料理の、届くまでの内訳（ロボット・スタッフ別に分ける）</summary>
            public readonly List<ServiceOps.DishParts> dishParts = new List<ServiceOps.DishParts>();

            /// <summary>
            /// 2分を過ぎて人が拾った皿の数（サイトの試算 v2.7）。
            /// ロボットだけを待つ時間（robot_first_wait_s）を過ぎてからスタッフが運んだ皿
            /// </summary>
            public int lateStaffDishes;

            /// <summary>その回に使った robot_first_wait_s（秒）。サイトから読んだ値</summary>
            public float robotFirstWaitS;

            // ---- 譲り合い（2026-10-23・譲る順番 v6）----
            /// <summary>誰が誰に譲った1回ずつ（yields.csv と同じもの）</summary>
            public readonly List<YieldRow> yields = new List<YieldRow>();

            /// <summary>
            /// 受け渡し口と客席をつなぐ角の範囲（RobotSpec の1か所から写す）。
            /// 角での止まりの回数・秒数を summary に出すのに使う
            /// </summary>
            public float cornerMinX, cornerMaxX, cornerMinZ, cornerMaxZ;

            /// <summary>椅子を引いた回数（B6）</summary>
            public int chairPulls;

            /// <summary>ドリンクバー：並んだ回数・あきらめた回数・並んで待った分（B4）</summary>
            public int drinkBarQueued, drinkBarGaveUp;
            public readonly List<float> drinkBarWaitMin = new List<float>();
            public int jamCount; public float jamTotalS;
            public float walkersMean;
            public float measuredMinutes;

            // ---- サイトと同じ名前で出す値（v5 3章・meta.metrics）----
            /// <summary>入店待ちの内訳のもと（分）。分け方はサイトの試算と同じ</summary>
            public readonly List<float> entryWaitMin = new List<float>();
            public readonly List<float> fullWaitMin = new List<float>();
            public readonly List<float> blockedWaitMin = new List<float>();
            public readonly List<float> guideWaitMin = new List<float>();
            public readonly List<float> staffWaitMin = new List<float>();

            /// <summary>
            /// pay_wait_mean のもと（分。依頼 v10 の 2）。
            /// 組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）。
            /// 逃がし弁で卓を空けられた組と、会計が終わっていない組は入れない
            /// </summary>
            public readonly List<float> payWaitMin = new List<float>();

            /// <summary>
            /// 同じものを、食べ終えた時刻ではなく会計に入った時刻（pay_start_s）から数えた分。
            /// 遅れて届いた皿・立ち歩きの人を待った分が pay_wait_mean にどれだけ混ざっているかを見る
            /// </summary>
            public readonly List<float> payWaitFromStartMin = new List<float>();

            /// <summary>
            /// 片付け・会計の仕事が板に載ってから取られるまでの最大（秒）と、
            /// そのうち長びいた数（依頼 v10 の 2）。仕事の名前 → 値
            /// </summary>
            public readonly List<KeyValuePair<string, float>> jobWaitMax =
                new List<KeyValuePair<string, float>>();
            public readonly List<KeyValuePair<string, int>> jobWaitOver =
                new List<KeyValuePair<string, int>>();

            /// <summary>長びいたと数える境目（秒）。tableStuckFreeSec と同じ 600 秒</summary>
            public float jobWaitOverSec;

            /// <summary>walk_km_per_staff・staff_util・robot_util</summary>
            public float walkKmPerStaff, staffUtil, robotUtil;

            /// <summary>
            /// 同じ通路に2台を集めないために、料理を後に回した回数（v5・A）
            /// </summary>
            public int aisleDeferrals;

            /// <summary>
            /// 店員優先の版の逃げ道：相手が止まったままで店員が回り道した回数（v5）
            /// </summary>
            public int robotStuckDetours;

            /// <summary>店員優先の版で回したか（v5）</summary>
            public bool staffFirstYield;

            /// <summary>rescues：立ち往生でスタッフを呼んだ回数（着いて動かした回数も残す）</summary>
            public int rescues, rescuesDone;
            public float rescueWaitS;

            /// <summary>pauses：一時停止の回数（自分で動き出した止まりの合計）</summary>
            public int pauses;

            /// <summary>サイトの条件で回したか（v5 3章）と、その条件の名前</summary>
            public bool siteRun;
            public string siteConditionId = "";

            /// <summary>
            /// サイトの試算の答え（unity_conditions の site_result）。
            /// 同じ summary.json に入れておけば、並べた表をあとから手で写さずに作れる
            /// </summary>
            public readonly List<KeyValuePair<string, float>> siteResult =
                new List<KeyValuePair<string, float>>();

            /// <summary>来客を受け付けた分と、全員が帰るまでの分（打ち切りなら everyoneLeft が false）</summary>
            public float arrivalsMinutes, totalMinutes;
            public bool everyoneLeft;

            // ---- 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）----
            /// <summary>timeline.csv の行数と、そのときの卓・スタッフの数</summary>
            public int timelineRows, timelineTables, timelineStaff;

            /// <summary>合計が合わなかった行の数（どちらも 0 になるはず）</summary>
            public int timelineBadTableRows, timelineBadStaffRows;

            /// <summary>合わなかった行のうち、いちばん早いもの（合っていれば空）</summary>
            public string timelineFirstBad = "";
        }

        SimLike _sim;
        public void SetSimLike(SimLike s) { _sim = s; }

        public void WriteSummaryJson(string path, string runName)
        {
            // 条件ごとに行を分ける（出てきた順を保つ）
            List<string> order = new List<string>();
            Dictionary<string, List<Row>> byCondition = new Dictionary<string, List<Row>>();
            for (int i = 0; i < _rows.Count; i++)
            {
                string id = _rows[i].conditionId;
                if (!byCondition.ContainsKey(id)) { byCondition[id] = new List<Row>(); order.Add(id); }
                byCondition[id].Add(_rows[i]);
            }

            StringBuilder sb = new StringBuilder();
            sb.Append("{\n");
            sb.Append("  \"run_name\": ").Append(Str(runName)).Append(",\n");
            sb.Append("  \"written_at\": ").Append(Str(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"))).Append(",\n");
            sb.Append("  \"note\": ").Append(Str(
                "speed_mps = distance_m / (total_s - stopped_s)。" +
                "stopped_s には、動けなくなった60秒と、スタッフを待った秒数（stuck_wait_s）、" +
                "通路の入口で待った秒数も入る。" +
                "pause_s は stop_durations_s（自分で動き出した止まりだけ）の分布。" +
                "site_metrics はサイトの meta.metrics と同じ名前・同じ定義。" +
                "入店待ちの分け方もサイトの試算と同じで、" +
                "blocked_wait は片付け待ちの卓の数だけ待っている列の前から順に足した分、" +
                "full_wait は seat_wait − blocked_wait、" +
                "staff_wait は blocked_wait + guide_wait。" +
                "pay_wait は（会計が終わった時刻 − 食べ終えた時刻 − pay_s）で、" +
                "逃がし弁で卓を空けられた組は入れない。" +
                "Unity は会計の人とスタッフがレジまで歩く時間が入るので、試算より少し長く出る。" +
                "pauses と rescues は、サイト側が unity_measures の仮の値から出した数なので出どころが違う。"))
              .Append(",\n");
            if (_sim != null)
            {
                sb.Append("  \"sim_like\": {");
                sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", ");
                sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", ");
                sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", ");
                sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", ");
                sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", ");
                sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                        ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
                sb.Append("\"robot_dishes\": ").Append(_sim.robotDishes).Append(", ");
                sb.Append("\"staff_dishes\": ").Append(_sim.staffDishes).Append(", ");
                sb.Append("\"requeued_dishes\": ").Append(_sim.requeuedDishes).Append(", ");
                // サイトの試算 v2.7：2分（robot_first_wait_s）を過ぎて人が拾った皿
                sb.Append("\"late_staff_dishes\": ").Append(_sim.lateStaffDishes).Append(", ");
                sb.Append("\"late_staff_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                        ? (float)_sim.lateStaffDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
                sb.Append("\"robot_first_wait_s\": ").Append(F(_sim.robotFirstWaitS)).Append(", ");
                // 譲る順番 v6：譲った回数（譲った側の種類ごと）と、角での止まり
                AppendYields(sb);
                AppendCornerStops(sb);
                sb.Append("\"groups\": ").Append(_sim.groups).Append(", ");
                sb.Append("\"people_jams\": ").Append(_sim.jamCount).Append(", ");
                sb.Append("\"robot_blocked_jams\": ").Append(_sim.robotBlockedJams).Append(", ");
                sb.Append("\"robot_blocked_jam_share\": ").Append(F(_sim.jamCount > 0
                        ? (float)_sim.robotBlockedJams / _sim.jamCount : 0f)).Append(", ");
                sb.Append("\"chair_pulls\": ").Append(_sim.chairPulls).Append(", ");
                sb.Append("\"robot_contacts\": ").Append(_sim.robotContacts).Append(", ");
                sb.Append("\"robot_yield_waits\": ").Append(_sim.robotYieldWaits).Append(", ");
                sb.Append("\"robot_yield_wait_s\": ").Append(F(_sim.robotYieldWaitSeconds)).Append(", ");
                sb.Append("\"robot_yield_detours\": ").Append(_sim.robotYieldDetours).Append(", ");
                // ロボットどうしのすれ違い（依頼 v13 の 依頼1）。切の回でも数える
                sb.Append("\"pass_count\": ").Append(_sim.passCount).Append(", ");
                sb.Append("\"pass_slow_s\": ").Append(F(_sim.passSlowS)).Append(", ");
                sb.Append("\"face_stop_count\": ").Append(_sim.faceStopCount).Append(", ");
                sb.Append("\"face_stop_max_s\": ").Append(F(_sim.faceStopMaxS)).Append(", ");
                // 2026-10-26（直し③）：場所で分けた数。
                // すれ違いの直しが効いたかは face_stop_in_aisle_* で見る
                sb.Append("\"face_stop_in_aisle_count\": ")
                  .Append(_sim.faceStopInAisleCount).Append(", ");
                sb.Append("\"face_stop_in_aisle_max_s\": ")
                  .Append(F(_sim.faceStopInAisleMaxS)).Append(", ");
                sb.Append("\"face_stop_at_pass_count\": ")
                  .Append(_sim.faceStopAtPassCount).Append(", ");
                sb.Append("\"face_stop_at_pass_max_s\": ")
                  .Append(F(_sim.faceStopAtPassMaxS)).Append(", ");
                sb.Append("\"face_stop_at_standby_count\": ")
                  .Append(_sim.faceStopAtStandbyCount).Append(", ");
                sb.Append("\"face_stop_at_standby_max_s\": ")
                  .Append(F(_sim.faceStopAtStandbyMaxS)).Append(", ");
                sb.Append("\"face_stop_overlap_count\": ")
                  .Append(_sim.faceStopOverlapCount).Append(", ");
                sb.Append("\"register_crowd_max\": ").Append(_sim.registerCrowdMax).Append(", ");
                sb.Append("\"register_crowd_mean\": ").Append(F(_sim.registerCrowdMean)).Append(", ");
                sb.Append("\"register_pay_count\": ").Append(_sim.registerPayCount).Append(", ");
                sb.Append("\"register_pay_not_at_back\": ").Append(_sim.registerPayNotAtBackCount).Append(", ");
                sb.Append("\"register_pay_off_spot\": ").Append(_sim.registerPayOffSpotCount).Append(", ");
                sb.Append("\"register_pay_offset_mean_m\": ").Append(F(_sim.registerPayMeanOffsetM)).Append(", ");
                sb.Append("\"register_pay_offset_max_m\": ").Append(F(_sim.registerPayMaxOffsetM)).Append(", ");
                sb.Append("\"register_back_intruders\": ").Append(_sim.registerBackIntruderCount).Append(", ");
                sb.Append("\"register_back_intruder_s\": ").Append(F(_sim.registerBackIntruderSeconds)).Append(", ");
                sb.Append("\"drink_bar_queued\": ").Append(_sim.drinkBarQueued).Append(", ");
                sb.Append("\"drink_bar_gave_up\": ").Append(_sim.drinkBarGaveUp).Append(", ");
                sb.Append("\"drink_bar_wait_mean\": ").Append(F(Mean(_sim.drinkBarWaitMin))).Append(", ");
                sb.Append("\"drink_bar_wait_p90\": ").Append(F(Percentile(_sim.drinkBarWaitMin, 0.9f))).Append(", ");
                sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", ");
                sb.Append("\"warped\": ").Append(_sim.warped).Append(", ");
                sb.Append("\"table_freed\": ").Append(_sim.tableFreed).Append(", ");
                sb.Append("\"people_jam_total_s\": ").Append(F(_sim.jamTotalS)).Append(", ");
                sb.Append("\"walkers_mean\": ").Append(F(_sim.walkersMean)).Append(", ");
                sb.Append("\"measured_minutes\": ").Append(F(_sim.measuredMinutes));
                sb.Append("},\n");

                AppendSiteMetrics(sb);

                // スタッフの時間の使い方（割合も出す）
                float staffTotal = 0f;
                for (int i = 0; i < _sim.staffTime.Count; i++) staffTotal += _sim.staffTime[i].Value;
                sb.Append("  \"staff_time_s\": {");
                for (int i = 0; i < _sim.staffTime.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.staffTime[i].Key)).Append(": ").Append(F(_sim.staffTime[i].Value));
                }
                sb.Append("},\n");
                // 料理ができてから届くまでの内訳（受け渡し口で待った／運んでいた／戻されて失った）
                sb.Append("  \"dish_wait_parts\": {");
                AppendDishParts(sb, "robot", true);
                sb.Append(", ");
                AppendDishParts(sb, "staff", false);
                sb.Append(", ");
                AppendDishParts(sb, "all", null);
                sb.Append("},\n");

                sb.Append("  \"job_wait_mean_s\": {");
                for (int i = 0; i < _sim.jobWaitMean.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitMean[i].Key)).Append(": ").Append(F(_sim.jobWaitMean[i].Value));
                }
                sb.Append("},\n");

                // 依頼 v10 の 2：仕事が板に載ってから取られるまでの最大と、長びいた数。
                // 直す前は、片付け・会計の長い待ちが逃がし弁「卓を空けた」で消えていたので、
                // ここに出てくるようになるはず
                sb.Append("  \"job_wait_max_s\": {");
                for (int i = 0; i < _sim.jobWaitMax.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitMax[i].Key)).Append(": ").Append(F(_sim.jobWaitMax[i].Value));
                }
                sb.Append("},\n");
                sb.Append("  \"job_wait_over_count\": {");
                for (int i = 0; i < _sim.jobWaitOver.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.jobWaitOver[i].Key)).Append(": ").Append(_sim.jobWaitOver[i].Value);
                }
                sb.Append("},\n");
                sb.Append("  \"job_wait_note\": {");
                sb.Append("\"over_s\": ").Append(F(_sim.jobWaitOverSec)).Append(", ");
                sb.Append("\"pay_wait_from_pay_start_mean\": ")
                  .Append(F(Mean(_sim.payWaitFromStartMin))).Append(", ");
                sb.Append("\"note\": ").Append(Str(
                    "job_wait_* は、仕事が板に載ってからスタッフが取るまでの秒数。" +
                    "取られないまま終わった仕事（卓を空けたときに板から外した分）は入らない。" +
                    "あきらめて板に戻った仕事は、取られるたびに1回ずつ数える。" +
                    "over_s は長びいたと数える境目で、tableStuckFreeSec と同じ。" +
                    "pay_wait_from_pay_start_mean は pay_wait_mean を" +
                    "食べ終えた時刻ではなく会計に入った時刻から数えたもの（分）"));
                sb.Append("},\n");
                sb.Append("  \"staff_time_share\": {");
                for (int i = 0; i < _sim.staffTime.Count; i++)
                {
                    if (i > 0) sb.Append(", ");
                    sb.Append(Str(_sim.staffTime[i].Key)).Append(": ")
                      .Append(F(staffTotal > 0.01f ? _sim.staffTime[i].Value / staffTotal : 0f));
                }
                sb.Append("},\n");
            }

            // 止まり全体のまとめ（B7 の前後を並べるため）
            sb.Append("  \"stops\": {");
            sb.Append("\"count\": ").Append(_stops.Count).Append(", ");
            sb.Append("\"stuck_count\": ").Append(StuckStopCount()).Append(", ");
            sb.Append("\"partial_path_count\": ").Append(PartialPathStopCount()).Append(", ");
            sb.Append("\"stop_s_total\": ").Append(F(StopSecondsTotal()));
            sb.Append("},\n");

            // 往復のまとめ（段階Bの台帳が写す）
            AppendTripSummary(sb);

            sb.Append("  \"stop_causes\": [\n");
            AppendCauseSummary(sb);
            sb.Append("  ],\n");

            sb.Append("  \"conditions\": [\n");

            for (int i = 0; i < order.Count; i++)
            {
                List<Row> rows = byCondition[order[i]];
                AppendConditionSummary(sb, order[i], rows);
                if (i < order.Count - 1) sb.Append(',');
                sb.Append('\n');
            }

            sb.Append("  ]\n");
            sb.Append("}\n");
            File.WriteAllText(path, sb.ToString(), new UTF8Encoding(false));
        }

        /// <summary>
        /// サイトと同じ名前・同じ定義で出す欄（v5 3章・meta.metrics）。
        /// site_result と並べるときは、この欄だけを見れば足りるようにする。
        ///
        /// 入店待ちの分け方は、サイトの試算（haizen_sim.py）と同じ:
        ///   seat_wait    店に来てから案内が始まるまで
        ///   blocked_wait そのうち、卓は空いたのに片付けが終わらず待った分
        ///                （片付け待ちの卓の数だけ、待っている列の前から順に足す。サイトの _acc と同じ）
        ///   full_wait    seat_wait − blocked_wait
        ///   guide_wait   案内の仕事が板に出てから、スタッフが取るまで
        ///   staff_wait   blocked_wait ＋ guide_wait
        ///   entry_wait   店に来てから卓に着くまで（案内の歩きと guide_s を含む）
        ///
        /// pay_wait_mean は v2.8 で足された欄（依頼 v10 の 2）:
        ///   組ごとに（会計が終わった時刻 − 食べ終えた時刻 − pay_s）の平均（分）。
        ///   逃がし弁で卓を空けられた組と、会計が終わっていない組は入れない。
        ///   pay_wait_groups は、その平均に入れた組の数
        /// </summary>
        void AppendSiteMetrics(StringBuilder sb)
        {
            sb.Append("  \"site_metrics\": {");
            sb.Append("\"entry_wait_mean\": ").Append(F(Mean(_sim.entryWaitMin))).Append(", ");
            sb.Append("\"full_wait_mean\": ").Append(F(Mean(_sim.fullWaitMin))).Append(", ");
            sb.Append("\"staff_wait_mean\": ").Append(F(Mean(_sim.staffWaitMin))).Append(", ");
            sb.Append("\"blocked_wait_mean\": ").Append(F(Mean(_sim.blockedWaitMin))).Append(", ");
            sb.Append("\"guide_wait_mean\": ").Append(F(Mean(_sim.guideWaitMin))).Append(", ");
            sb.Append("\"seat_wait_mean\": ").Append(F(Mean(_sim.seatWaitMin))).Append(", ");
            sb.Append("\"dish_wait_mean\": ").Append(F(Mean(_sim.dishWaitMin))).Append(", ");
            sb.Append("\"dish_wait_p90\": ").Append(F(Percentile(_sim.dishWaitMin, 0.9f))).Append(", ");
            sb.Append("\"cold_share\": ").Append(F(Share(_sim.dishWaitMin, _sim.coldMin))).Append(", ");
            sb.Append("\"turn_mean\": ").Append(F(Mean(_sim.turnMin))).Append(", ");
            // 依頼 v10 の 2：食べ終えてから会計が始まるまで（分）。
            // Unity はレジまで歩く時間が入るので、試算より少し長く出てよい
            sb.Append("\"pay_wait_mean\": ").Append(F(Mean(_sim.payWaitMin))).Append(", ");
            sb.Append("\"pay_wait_groups\": ").Append(_sim.payWaitMin.Count).Append(", ");
            sb.Append("\"walk_km_per_staff\": ").Append(F(_sim.walkKmPerStaff)).Append(", ");
            sb.Append("\"staff_util\": ").Append(F(_sim.staffUtil)).Append(", ");
            sb.Append("\"robot_util\": ").Append(F(_sim.robotUtil)).Append(", ");
            sb.Append("\"robot_share\": ").Append(F(_sim.robotDishes + _sim.staffDishes > 0
                    ? (float)_sim.robotDishes / (_sim.robotDishes + _sim.staffDishes) : 0f)).Append(", ");
            sb.Append("\"rescues\": ").Append(_sim.rescues).Append(", ");
            sb.Append("\"pauses\": ").Append(_sim.pauses).Append(", ");
            sb.Append("\"groups\": ").Append(_sim.groups);
            sb.Append("},\n");

            sb.Append("  \"site_run\": {");
            sb.Append("\"site_run\": ").Append(_sim.siteRun ? "true" : "false").Append(", ");
            sb.Append("\"condition_id\": ").Append(Str(_sim.siteConditionId)).Append(", ");
            sb.Append("\"arrivals_minutes\": ").Append(F(_sim.arrivalsMinutes)).Append(", ");
            sb.Append("\"total_minutes\": ").Append(F(_sim.totalMinutes)).Append(", ");
            sb.Append("\"everyone_left\": ").Append(_sim.everyoneLeft ? "true" : "false").Append(", ");
            sb.Append("\"aisle_deferrals\": ").Append(_sim.aisleDeferrals).Append(", ");
            sb.Append("\"staff_first_yield\": ").Append(_sim.staffFirstYield ? "true" : "false").Append(", ");
            sb.Append("\"robot_stuck_detours\": ").Append(_sim.robotStuckDetours).Append(", ");
            sb.Append("\"rescues_done\": ").Append(_sim.rescuesDone).Append(", ");
            sb.Append("\"rescue_wait_s\": ").Append(F(_sim.rescueWaitS)).Append(", ");
            // 逃がし弁（0 回に近いことを確かめる。仕組みは消さない）
            sb.Append("\"gave_up\": ").Append(_sim.gaveUp).Append(", ");
            sb.Append("\"warped\": ").Append(_sim.warped).Append(", ");
            sb.Append("\"table_freed\": ").Append(_sim.tableFreed);
            sb.Append("},\n");

            // 時間帯ごとの記録の確かめ（依頼 v10b の 3 A）。
            // bad_table_rows・bad_staff_rows はどちらも 0 になるはず
            sb.Append("  \"timeline_check\": {");
            sb.Append("\"rows\": ").Append(_sim.timelineRows).Append(", ");
            sb.Append("\"step_s\": 60, ");
            sb.Append("\"tables\": ").Append(_sim.timelineTables).Append(", ");
            sb.Append("\"staff\": ").Append(_sim.timelineStaff).Append(", ");
            sb.Append("\"bad_table_rows\": ").Append(_sim.timelineBadTableRows).Append(", ");
            sb.Append("\"bad_staff_rows\": ").Append(_sim.timelineBadStaffRows).Append(", ");
            sb.Append("\"first_bad\": ").Append(Str(_sim.timelineFirstBad)).Append(", ");
            sb.Append("\"note\": ").Append(Str(
                "timeline.csv の各行で、seated_groups + tables_free + tables_dirty_untaken + " +
                "tables_clearing が tables と同じか、staff_* の合計が staff と同じかを数えたもの。" +
                "合わない行があれば bad_* が 0 より大きくなり、Console にも出る"));
            sb.Append("},\n");

            // サイトの試算の答え（並べる表のもと）
            sb.Append("  \"site_result\": {");
            for (int i = 0; i < _sim.siteResult.Count; i++)
            {
                if (i > 0) sb.Append(", ");
                sb.Append(Str(_sim.siteResult[i].Key)).Append(": ").Append(F(_sim.siteResult[i].Value));
            }
            sb.Append("},\n");
        }

        /// <summary>
        /// 原因ごとの回数と秒数の合計。
        /// stuck_count は「動けなくなった」で終わった止まり（止まりの上限に達した回数）。
        /// partial_path_count は「途中までの経路」に縛られて引き直して出た止まり（B7）。
        /// この2つを、停止点を変えた2回で並べれば「椅子が原因で上限に達した回数」の差が出る。
        /// </summary>
        void AppendCauseSummary(StringBuilder sb)
        {
            string stuckLabel = ResolveLabel(ServingRobotController.StopResolve.Stuck);

            List<string> order = new List<string>();
            Dictionary<string, int> count = new Dictionary<string, int>();
            Dictionary<string, float> secs = new Dictionary<string, float>();
            Dictionary<string, float> longest = new Dictionary<string, float>();
            Dictionary<string, int> blocked = new Dictionary<string, int>();
            Dictionary<string, int> stuck = new Dictionary<string, int>();
            Dictionary<string, int> partial = new Dictionary<string, int>();
            Dictionary<string, float> noPath = new Dictionary<string, float>();

            for (int i = 0; i < _stops.Count; i++)
            {
                string c = _stops[i].cause;
                if (!count.ContainsKey(c))
                {
                    order.Add(c); count[c] = 0; secs[c] = 0f; longest[c] = 0f; blocked[c] = 0;
                    stuck[c] = 0; partial[c] = 0; noPath[c] = 0f;
                }
                count[c]++;
                secs[c] += _stops[i].durationS;
                if (_stops[i].durationS > longest[c]) longest[c] = _stops[i].durationS;
                if (_stops[i].pathBlocked) blocked[c]++;
                if (_stops[i].resolved == stuckLabel) stuck[c]++;
                if (HasPartialPathMark(_stops[i].causeDetail)) partial[c]++;
                noPath[c] += _stops[i].noPathS;
            }

            for (int i = 0; i < order.Count; i++)
            {
                string c = order[i];
                sb.Append("    {\"cause\": ").Append(Str(c))
                  .Append(", \"count\": ").Append(count[c])
                  .Append(", \"total_s\": ").Append(F(secs[c]))
                  .Append(", \"mean_s\": ").Append(F(count[c] > 0 ? secs[c] / count[c] : 0f))
                  .Append(", \"longest_s\": ").Append(F(longest[c]))
                  .Append(", \"path_blocked_count\": ").Append(blocked[c])
                  .Append(", \"stuck_count\": ").Append(stuck[c])
                  .Append(", \"partial_path_count\": ").Append(partial[c])
                  .Append(", \"no_path_total_s\": ").Append(F(noPath[c]))
                  .Append('}');
                if (i < order.Count - 1) sb.Append(',');
                sb.Append('\n');
            }
        }

        /// <summary>
        /// 届くまでの内訳を1つぶん書く。byRobot が null なら全部。
        /// 秒は「分」に直して出す（dish_wait_mean と同じ単位にそろえる）。
        /// </summary>
        void AppendDishParts(StringBuilder sb, string name, bool? byRobot)
        {
            int n = 0, requeued = 0;
            float pass = 0f, carry = 0f, requeue = 0f;

            if (_sim != null)
            {
                for (int i = 0; i < _sim.dishParts.Count; i++)
                {
                    ServiceOps.DishParts p = _sim.dishParts[i];
                    if (byRobot.HasValue && p.byRobot != byRobot.Value) continue;
                    n++;
                    pass += p.passWaitS;
                    carry += p.carryS;
                    requeue += p.requeueS;
                    requeued += p.requeued;
                }
            }

            float d = n > 0 ? n : 1f;
            sb.Append(Str(name)).Append(": {");
            sb.Append("\"dishes\": ").Append(n).Append(", ");
            sb.Append("\"pass_wait_mean_min\": ").Append(F(pass / d / 60f)).Append(", ");
            sb.Append("\"carry_mean_min\": ").Append(F(carry / d / 60f)).Append(", ");
            sb.Append("\"requeue_lost_mean_min\": ").Append(F(requeue / d / 60f)).Append(", ");
            sb.Append("\"total_mean_min\": ").Append(F((pass + carry + requeue) / d / 60f)).Append(", ");
            sb.Append("\"requeued_per_dish\": ").Append(F(requeued / d)).Append(", ");
            sb.Append("\"requeued_total\": ").Append(requeued);
            sb.Append("}");
        }

        /// <summary>この止まりが「途中までの経路」に縛られたものか（B7）</summary>
        static bool HasPartialPathMark(string causeDetail)
        {
            return !string.IsNullOrEmpty(causeDetail) &&
                   causeDetail.Contains(ServingRobotController.PartialPathMark);
        }

        /// <summary>「動けなくなった」で終わった止まりの数（止まりの上限に達した回数）</summary>
        int StuckStopCount()
        {
            string label = ResolveLabel(ServingRobotController.StopResolve.Stuck);
            int n = 0;
            for (int i = 0; i < _stops.Count; i++) if (_stops[i].resolved == label) n++;
            return n;
        }

        /// <summary>「途中までの経路」から引き直して出た止まりの数（B7）</summary>
        int PartialPathStopCount()
        {
            int n = 0;
            for (int i = 0; i < _stops.Count; i++) if (HasPartialPathMark(_stops[i].causeDetail)) n++;
            return n;
        }

        static void AppendConditionSummary(StringBuilder sb, string id, List<Row> rows)
        {
            List<float> speeds = new List<float>();
            List<float> stopped = new List<float>();
            List<float> pauses = new List<float>();
            List<float> waits = new List<float>();
            int stuckCount = 0, timeoutCount = 0, pausedCount = 0, metRobotCount = 0;
            float aisleWidth = 0f; int robots = 0; string crowd = "";

            for (int i = 0; i < rows.Count; i++)
            {
                Row r = rows[i];
                aisleWidth = r.aisleWidthM; robots = r.robots; crowd = r.crowd;

                float moving = r.totalS - r.stoppedS;
                if (moving > 0.01f) speeds.Add(r.distanceM / moving);
                stopped.Add(r.stoppedS);
                waits.Add(r.waitAisleS);
                if (r.stopDurationsS != null)
                    for (int k = 0; k < r.stopDurationsS.Length; k++) pauses.Add(r.stopDurationsS[k]);
                if (r.stuck) stuckCount++;
                if (r.timeout) timeoutCount++;
                if (r.nStops >= 1) pausedCount++;
                if (r.metRobot) metRobotCount++;
            }

            int n = rows.Count;
            sb.Append("    {");
            sb.Append("\"condition_id\": ").Append(Str(id)).Append(", ");
            sb.Append("\"aisle_width_m\": ").Append(F(aisleWidth)).Append(", ");
            sb.Append("\"crowd\": ").Append(Str(crowd)).Append(", ");
            sb.Append("\"robots\": ").Append(robots).Append(", ");
            sb.Append("\"trips\": ").Append(n).Append(", ");
            sb.Append("\"robot_speed_mps_median\": ").Append(F(Percentile(speeds, 0.5f))).Append(", ");
            sb.Append("\"stopped_s_median\": ").Append(F(Percentile(stopped, 0.5f))).Append(", ");
            sb.Append("\"stopped_s_p90\": ").Append(F(Percentile(stopped, 0.9f))).Append(", ");
            sb.Append("\"pause_s_median\": ").Append(F(Percentile(pauses, 0.5f))).Append(", ");
            sb.Append("\"pause_s_p90\": ").Append(F(Percentile(pauses, 0.9f))).Append(", ");
            sb.Append("\"pause_count\": ").Append(pauses.Count).Append(", ");
            sb.Append("\"wait_aisle_s_median\": ").Append(F(Percentile(waits, 0.5f))).Append(", ");
            sb.Append("\"stuck_rate\": ").Append(F(n > 0 ? stuckCount / (float)n : 0f)).Append(", ");
            sb.Append("\"stuck_count\": ").Append(stuckCount).Append(", ");
            sb.Append("\"timeout_rate\": ").Append(F(n > 0 ? timeoutCount / (float)n : 0f)).Append(", ");
            sb.Append("\"timeout_count\": ").Append(timeoutCount).Append(", ");
            sb.Append("\"pause_rate\": ").Append(F(n > 0 ? pausedCount / (float)n : 0f)).Append(", ");
            sb.Append("\"met_robot_rate\": ").Append(F(n > 0 ? metRobotCount / (float)n : 0f));
            sb.Append('}');
        }

        static float Mean(List<float> v)
        {
            if (v == null || v.Count == 0) return -1f;
            float s = 0f;
            for (int i = 0; i < v.Count; i++) s += v[i];
            return s / v.Count;
        }

        static float Share(List<float> v, float over)
        {
            if (v == null || v.Count == 0) return -1f;
            int n = 0;
            for (int i = 0; i < v.Count; i++) if (v[i] > over) n++;
            return (float)n / v.Count;
        }

        /// <summary>小さい順に並べて p の位置の値を返す（0〜1）。空なら -1</summary>
        public static float Percentile(List<float> values, float p)
        {
            if (values == null || values.Count == 0) return -1f;
            List<float> v = new List<float>(values);
            v.Sort();
            if (v.Count == 1) return v[0];
            float pos = Mathf.Clamp01(p) * (v.Count - 1);
            int lo = Mathf.FloorToInt(pos);
            int hi = Mathf.Min(lo + 1, v.Count - 1);
            float t = pos - lo;
            return Mathf.Lerp(v[lo], v[hi], t);
        }
    }
}
```

### Assets/ServiceSim/Experiment/HeadlessRun.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 画面なしの実行ファイルで、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つだけ。
    /// </summary>
    public class HeadlessRun : MonoBehaviour
    {
        /// <summary>Setup が作るときの名前</summary>
        public const string RootName = "HeadlessRun";

        /// <summary>終了コード（依頼 v12 の 2 章）</summary>
        public const int ExitOk = 0;
        public const int ExitFailed = 1;
        public const int ExitStopped = 2;

        /// <summary>build_info.json を上に何階層まで探すか（.app の中から外に出るため）</summary>
        const int BuildInfoUpLevels = 6;

        /// <summary>回し始めるまでにこれだけかかったら、何か起きていると見て失敗にする（秒）</summary>
        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;
        }

        /// <summary>
        /// 引数を読み、確かめ、1回ぶんの仕事を仕込む。
        /// 駄目なときは false と終了コードを返す（何も書かない）
        /// </summary>
        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<ExperimentRunner>();
            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();
        }

        /// <summary>
        /// 回り終わったあと：台帳に1行書いて、終了コードを決めて終わる。
        /// 行を作るのは段階Bと同じ LedgerRow 1つだけ
        /// </summary>
        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);
        }

        // ---------------- 細かい道具 ----------------

        /// <summary>
        /// 出力フォルダの名前。
        /// tools/night_runner.py の folder_name と **同じ字** にする
        /// （実行係は同じ名前を先に作って、もう回した回を飛ばすのに使う）
        /// </summary>
        string FolderName()
        {
            return FolderName(_night, _label, CsToken(_unityCs),
                              _cond.store, _cond.robots, _seed, _passBy, _try);
        }

        /// <summary>
        /// 出力フォルダの名前（計算だけ。確かめのために外からも呼べるようにしてある）。
        ///
        /// ★ tools/night_runner.py の folder_name と **1字でも違ってはいけない**。
        ///   実行係はこの名前を先に作って「もう回した回」を飛ばすので、
        ///   食い違うと同じ回を2度回したり、回したのに飛ばしたりする
        /// </summary>
        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;
        }

        /// <summary>
        /// "ServiceSim/cs:12" → "cs12"。読めなければ "csunknown"。
        /// tools/night_runner.py の cs_token（既定 "cs{N}"）と同じ字にする
        /// </summary>
        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");
        }

        /// <summary>
        /// 実行ファイルのそばの build_info.json から、版（unity_cs）と build_id を読む。
        ///
        /// macOS の .app は Application.dataPath が
        ///   …/ServiceSim.app/Contents/Resources/Data
        /// なので、そこから上に何階層か探す（Builds/headless/ に置いてある形）
        /// </summary>
        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<string> ids = new List<string>();
            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()) : "（読めません）";
        }

        /// <summary>条件の店（fam_p1 / fam_p5）に合う店の形を選ぶ</summary>
        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;
        }

        /// <summary>引数 name の次の字を返す。無ければ null</summary>
        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<string> 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
        }
    }
}
```

### Assets/ServiceSim/Experiment/LedgerRow.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 通し台帳の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度書かない決まりは、移すときに変えていない。
    /// </summary>
    public static class LedgerRow
    {
        /// <summary>行を作るのに要る、外から渡す値</summary>
        public class Inputs
        {
            /// <summary>サイトの試算の値（版・店の形の版・条件ごとの site_result）</summary>
            public SimParams simParams;

            /// <summary>この回の条件（store・staff・robots・arrivals_per_h・mode・trays・fragile_by）</summary>
            public SimParams.Condition cond;

            /// <summary>run_info.json に無いときの控え（決まりの入切を読む）</summary>
            public RobotSpec robotSpec;
            public PeopleSpec peopleSpec;

            /// <summary>"ServiceSim/cs:12" の形</summary>
            public string unityCs;

            /// <summary>出力フォルダの名前（照合キー）と、その場所</summary>
            public string runFolder;
            public string runDir;

            /// <summary>種</summary>
            public int seed;

            /// <summary>実時間（秒）。run_info.json に wall_seconds があればそちらを使う</summary>
            public float wallS;

            /// <summary>台帳の実体（unity_ledger_v1.csv）</summary>
            public string ledgerPath;
        }


        /// <summary>
        /// その回の summary.json / run_info.json から台帳の1行を作って足す。
        /// 戻り値はログ用の短い1行。status も返す
        /// </summary>
        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<string, string> row = new Dictionary<string, string>();
            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));
        }

        /// <summary>
        /// 数を返すが、入れ物はあって名前が無いときは 0 を返す（空欄にしない）。
        /// job_wait_over_count は「1件も無い」と「読めなかった」を分けたいので使う
        /// </summary>
        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<string, string> 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; }
        }
    }
}
```

### Assets/ServiceSim/Experiment/ServiceSimLedger.cs

```csharp
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 段階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つも変わらない。
    /// </summary>
    public static class ServiceSimLedger
    {
        /// <summary>
        /// 台帳の版。列を足したら 1 つ上げる。
        ///   1 … 依頼 v7（49 列）
        ///   2 … 依頼 v10b（会計待ち・片付け待ちの 10 列を後ろに足した。59 列）
        /// </summary>
        public const int SchemaVersion = 2;
        public const string FileName = "unity_ledger_v1.csv";

        /// <summary>
        /// 鍵ファイルの名前（依頼 v12 の 3 章）。
        /// tools/ledger.py の LOCK_NAME と同じ字。
        /// O_EXCL / FileMode.CreateNew で作れたら持ち主、閉じるときに消す。
        /// 中身は「pid ホスト名 unix秒」の1行（Python 側と同じ形）
        /// </summary>
        public const string LockFileName = "unity_ledger_v1.lock";

        /// <summary>鍵を待つ上限（秒）。これを超えたら書かずにあきらめる</summary>
        public const float LockTimeoutS = 60f;

        /// <summary>これより古い鍵は、持ち主が落ちたと見て1度だけ壊す（Python 側と同じ 120 秒）</summary>
        public const float LockStaleS = 120f;

        /// <summary>
        /// unity_cs の頭に付ける字（依頼 v11 の 2）。
        ///
        /// このプロジェクト（Robotics_UX_New から分けた ServiceSim 専用）は
        /// changeset の番号が 1 から振り直されるので、
        /// 台帳の中で前のプロジェクトの "cs:144" などと混ざらないように
        /// "ServiceSim/cs:1" の形で書く。
        /// 台帳の列は増やさない（data_manifest_rules.md 10節）
        /// </summary>
        public const string CsPrefix = "ServiceSim/";

        /// <summary>
        /// 列（この順。名前を変えるときは先にエイイチに聞く）。
        ///
        /// 足すときは **いちばん後ろ**（ledger_schema_version より後ろ）に足す。
        /// 途中に入れると前の行の列の位置がずれて、古い行が読めなくなる（依頼 v10b の 1）。
        /// 前の行は新しい列が無いまま（短い行）で置いておく。書き換えない
        /// </summary>
        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); } }

        /// <summary>台帳の実体の場所</summary>
        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);
        }

        // ---------------- 追記ガード ----------------

        /// <summary>
        /// 追記していいか。
        ///   ・実体が無い … いい（header を書いて作る）
        ///   ・1行めがコードの列と同じ … いい
        ///   ・違う … だめ（why に理由）
        /// </summary>
        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;
        }

        /// <summary>その出力フォルダの行がもうあるか（run_folder が照合キー）</summary>
        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;
        }

        /// <summary>もう台帳にある出力フォルダの名前をぜんぶ返す（2度書きの歯止めに使う）</summary>
        public static HashSet<string> DoneFolders(string path)
        {
            HashSet<string> done = new HashSet<string>();
            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;
        }

        /// <summary>
        /// 回した回を照らし合わせるキー（依頼 v8 の 3）。
        ///
        /// **店・ロボットの台数・乱数の種・Unity の版** でそろえる。
        /// 出力フォルダ名（日付入り）では照らし合わせない。
        ///   ・同じ版・同じ条件・同じ種の行があれば、日付が違っても回さない
        ///   ・版が違えば回す（直したあとに同じ18回をもう一度回すため）
        /// </summary>
        public static string RunKey(string store, int robots, int seed, string unityCs)
        {
            return (store ?? "") + "|r" + robots + "|s" + seed + "|" + (unityCs ?? "");
        }

        /// <summary>台帳にある回のキーをぜんぶ返す（続きから回すのに使う）</summary>
        public static HashSet<string> DoneKeys(string path) { return DoneKeys(path, null); }

        /// <summary>
        /// 台帳にある回のキーを返す（続きから回すのに使う）。
        ///
        /// folderLabel を渡すと、run_folder に "_{名札}_" が入っている行だけを見る（依頼 v11 の 3）。
        /// 名札は「まとめて回した種類」で、stageB（基準データの18回）と
        /// split_check（移せたかの確かめ）を分けるために使う。
        /// こうしておくと、確かめで ③1台・種1 を2回回しても、
        /// そのあとの Run All が同じ条件を「済」として飛ばさない。
        /// 台帳の列は増やしていない（名札は run_folder の中にある）
        /// </summary>
        public static HashSet<string> DoneKeys(string path, string folderLabel)
        {
            HashSet<string> done = new HashSet<string>();
            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行足す ----------------

        /// <summary>
        /// 1行追記する。ヘッダが合わない・同じ出力フォルダの行がもうある場合は足さない。
        /// row は列名 → 値。足りない列は空欄にする
        /// </summary>
        public static bool Append(string path, IDictionary<string, string> 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 章）----------------

        /// <summary>
        /// 台帳の鍵ファイルの置き場所（台帳と同じフォルダ）。
        /// tools/ledger.py と同じ場所・同じ名前なので、
        /// Unity と Python が同じ鍵を取り合える
        /// </summary>
        public static string LockPathOf(string ledgerPath)
        {
            string dir = Path.GetDirectoryName(ledgerPath);
            return string.IsNullOrEmpty(dir) ? LockFileName : Path.Combine(dir, LockFileName);
        }

        /// <summary>
        /// 鍵を取る。取れたら Dispose で消える入れ物を返し、取れなければ null と理由を返す。
        ///
        /// やり方（tools/ledger.py と同じ）:
        ///   ・FileMode.CreateNew で作れたら持ち主。中身は「pid ホスト名 unix秒」
        ///   ・作れなければ少し待って何度も試す（上限 LockTimeoutS）
        ///   ・鍵が LockStaleS より古ければ、持ち主が落ちたと見て **1度だけ** 壊す
        /// </summary>
        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;
        }

        /// <summary>取った鍵。Dispose で閉じて消す</summary>
        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<string, string> row, string key)
        {
            string v;
            return row != null && row.TryGetValue(key, out v) && v != null ? v : "";
        }

        /// <summary>CSV を壊さないように、カンマ・改行・引用符は取り除く（値は数と短い名前だけ）</summary>
        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行を作る ----------------

        /// <summary>数を台帳の書き方にそろえる（小数4桁まで・指数表記にしない）</summary>
        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>summary.json を読む（読めなければ null）</summary>
        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;
            }
        }
    }
}
```

### Assets/ServiceSim/People/PeopleNav.cs

```csharp
using UnityEngine;
using UnityEngine.AI;

namespace ServiceSim
{
    /// <summary>
    /// 人の NavMeshAgent を、安全に使えるようにする小さな道具。
    ///
    /// prefab では NavMeshAgent を切ってある。置いた直後に有効にすると
    /// 「NavMesh に近くないので agent を作れません」の警告が出たり、
    /// NavMesh に乗る前に SetDestination を呼んで
    /// 「can only be called on an active agent that has been placed on a NavMesh」
    /// のエラーが出るため、ここを通してから使う。
    ///
    /// この script は人だけを扱う。
    /// </summary>
    public static class PeopleNav
    {
        /// <summary>NavMesh の上を探す距離（m）</summary>
        const float SampleRadius = 2.0f;

        /// <summary>
        /// 人が歩く NavMesh の種類（agentTypeID）。NavMeshBaker が焼いたあとに入れる。
        /// 人は半径 0.2 m の面、ロボットは 0.25 m の面を歩く。
        /// 焼けていなければ 0（ロボットと同じ面）のまま。
        /// </summary>
        public static int PeopleAgentTypeId { get; private set; }

        /// <summary>人用の面を探すための絞り込み</summary>
        public static NavMeshQueryFilter Filter
        {
            get
            {
                NavMeshQueryFilter f = new NavMeshQueryFilter();
                f.agentTypeID = PeopleAgentTypeId;
                f.areaMask = NavMesh.AllAreas;
                return f;
            }
        }

        /// <summary>焼いたあとに NavMeshBaker が呼ぶ</summary>
        public static void SetPeopleAgentType(int agentTypeId)
        {
            PeopleAgentTypeId = Mathf.Max(0, agentTypeId);
        }

        /// <summary>
        /// 客が歩ける区画（レジの内側＝店員側を外す）。
        /// スタッフは NavMesh.AllAreas のまま。
        /// </summary>
        public static int GuestAreaMask(PeopleSpec spec)
        {
            if (spec == null || spec.registerBackArea <= 2) return NavMesh.AllAreas;
            return NavMesh.AllAreas & ~(1 << spec.registerBackArea);
        }

        /// <summary>その agent を人用の面で歩かせる</summary>
        public static void ApplyAgentType(NavMeshAgent agent)
        {
            if (agent == null) return;
            if (agent.agentTypeID != PeopleAgentTypeId) agent.agentTypeID = PeopleAgentTypeId;
        }

        /// <summary>
        /// agent を使える状態にする。
        /// 編集モード（Play していないとき）は何もせず false を返す。
        /// </summary>
        public static bool EnsureReady(Transform tr, NavMeshAgent agent)
        {
            if (!Application.isPlaying) return false;
            if (agent == null || tr == null) return false;

            if (!agent.enabled)
            {
                // 有効にする前に NavMesh の上へ移す（警告を出さないため）
                NavMeshHit hit;
                if (NavMesh.SamplePosition(tr.position, out hit, SampleRadius, Filter))
                    tr.position = hit.position;
                else
                    return false;   // 近くに NavMesh が無いので、まだ有効にしない
                ApplyAgentType(agent);
                agent.enabled = true;
            }

            if (!agent.isActiveAndEnabled) return false;

            if (!agent.isOnNavMesh)
            {
                NavMeshHit hit;
                if (NavMesh.SamplePosition(tr.position, out hit, SampleRadius, Filter))
                    agent.Warp(hit.position);
            }
            return agent.isOnNavMesh;
        }

        /// <summary>
        /// 行き先を指示する。指示できたら true。
        /// EnsureReady が true を返したあとだけ呼ぶ。
        /// </summary>
        /// <summary>
        /// 試し2（依頼 v11 の 5 章）：人の道を **同期** で引く。
        ///
        /// SetDestination は非同期なので、道が引けるまで agent.pathPending が true のままで、
        /// 何コマかかるかは Unity の都合で変わる。
        /// その間「着いたかの判定」と「よけるかの判断」が打ち切られるので、
        /// 同じ種でも数コマずれる（確かめ B で出たずれ）。
        ///
        /// true にすると NavMesh.CalculatePath（同期）で引いて SetPath で渡すので、
        /// pathPending が true になる場面が無くなる。
        ///
        /// ★ 調べるためだけの切り替え。ExperimentRunner が回し始めに
        ///   ExperimentPlan.detCheckSyncPeoplePaths から入れ、回し終わりに false に戻す
        /// </summary>
        public static bool SyncPaths;

        /// <summary>同期で引くときに使い回す道の入れ物（毎回 new しない）</summary>
        static readonly NavMeshPath _path = new NavMeshPath();

        /// <summary>
        /// 行き先を指示する。指示できたら true。
        /// EnsureReady が true を返したあとだけ呼ぶ。
        /// </summary>
        public static bool Go(NavMeshAgent agent, Vector3 point)
        {
            if (agent == null || !agent.isActiveAndEnabled || !agent.isOnNavMesh) return false;

            NavMeshHit hit;
            Vector3 to = NavMesh.SamplePosition(point, out hit, 1.5f, Filter) ? hit.position : point;

            if (!SyncPaths) return agent.SetDestination(to);

            // 同期で引く。引けなければ、これまでどおり非同期に任せる
            // （引けない所へ行こうとしている場面の動きを変えないため）
            NavMeshQueryFilter f = new NavMeshQueryFilter();
            f.agentTypeID = agent.agentTypeID;
            f.areaMask = agent.areaMask;
            if (!NavMesh.CalculatePath(agent.nextPosition, to, f, _path) ||
                _path.corners.Length == 0)
                return agent.SetDestination(to);
            return agent.SetPath(_path);
        }
    }
}
```

### Assets/ServiceSim/Robot/FrontSensor.cs

```csharp
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// ロボットの前に人・ロボット・椅子があるかを見る。
    /// 見るだけで、止まる・動き出すの判断はしない（それは ServingRobotController）。
    /// 止まった原因を分けられるように、いちばん近いものの種類も返す。
    /// </summary>
    public class FrontSensor : MonoBehaviour
    {
        public struct Reading
        {
            /// <summary>前に何かあるか</summary>
            public bool blocked;
            /// <summary>いちばん近いものまでの、ロボットの外周からの距離（m）。何もなければ sensorRange</summary>
            public float nearestDistance;

            public bool sawRobot;
            public bool sawPerson;
            public bool sawChair;

            /// <summary>いちばん近いものが人か</summary>
            public bool nearestIsPerson;
            /// <summary>いちばん近いものが人のときの種類</summary>
            public PersonKind nearestPersonKind;
            /// <summary>いちばん近いものが人のときの速さ（m/秒）</summary>
            public float nearestPersonSpeed;
            /// <summary>いちばん近いものが人で、立ち止まっているか</summary>
            public bool nearestPersonStanding;
            /// <summary>いちばん近いものがロボットか</summary>
            public bool nearestIsRobot;
            /// <summary>いちばん近いものが椅子か</summary>
            public bool nearestIsChair;
            /// <summary>いちばん近いものの名前（cause_detail に使う）</summary>
            public string nearestName;
        }

        [Header("設定")]
        public RobotSpec spec;

        [Tooltip("自分のロボット。自分を見ないようにするために使う")]
        public ServingRobotController owner;

        const int MaxHits = 32;
        readonly Collider[] _hits = new Collider[MaxHits];

        /// <summary>
        /// この回で、前のセンサーの箱に入った当たりのいちばん多かった数（依頼 v11 の 5 章）。
        /// MaxHits（32）に届いていなければ切り捨てが起きていないので、
        /// 「物理の返る順番」はずれの元から外せる。ExperimentRunner が回し始めに 0 に戻す
        /// </summary>
        public static int MaxSeen;

        /// <summary>切り捨てが起きた回数（0 のはず）</summary>
        public static int Overflows;

        Reading _last;
        public Reading Last { get { return _last; } }

        /// <summary>いまの前の様子を見る</summary>
        public Reading Sense()
        {
            Reading r = new Reading();
            if (spec == null)
            {
                _last = r;
                return r;
            }

            r.nearestDistance = spec.sensorRange;
            r.nearestName = "";

            float radius = spec.Radius;
            Vector3 fwd = transform.forward;
            Vector3 eye = transform.position + Vector3.up * (spec.sensorHeight * 0.5f);
            Vector3 center = eye + fwd * (radius + spec.sensorRange * 0.5f);
            Vector3 half = new Vector3(spec.sensorWidth * 0.5f, spec.sensorHeight * 0.5f, spec.sensorRange * 0.5f);

            int n = Physics.OverlapBoxNonAlloc(center, half, _hits, transform.rotation, ~0, QueryTriggerInteraction.Ignore);

            // 依頼 v11 の 5 章：当たりが MaxHits を超えると切り捨てになり、
            // どれが落ちるかは物理の返す順番で決まる（＝順番に頼る所）。
            // 実際に超えているかを数えて summary.json に出す。
            // 超えていなければ「物理の返る順番」はずれの元から外せる
            if (n > MaxSeen) MaxSeen = n;
            if (n > MaxHits) { n = MaxHits; Overflows++; }

            for (int i = 0; i < n; i++)
            {
                Collider col = _hits[i];
                if (col == null) continue;

                PersonBody person = col.GetComponentInParent<PersonBody>();
                ServingRobotController other = null;
                Chair chair = null;

                if (person == null)
                {
                    other = col.GetComponentInParent<ServingRobotController>();
                    if (other != null && owner != null && other == owner) continue;   // 自分は見ない
                    if (other == null) chair = col.GetComponentInParent<Chair>();
                }

                if (person == null && other == null && chair == null) continue;   // 床・壁・卓は見ない

                r.blocked = true;
                if (person != null) r.sawPerson = true;
                if (other != null) r.sawRobot = true;
                if (chair != null) r.sawChair = true;

                // ロボットの外周からの距離
                Vector3 closest = col.ClosestPoint(eye);
                Vector3 flat = closest - transform.position;
                flat.y = 0f;
                float d = Mathf.Max(0f, Vector3.Dot(flat, fwd) - radius);
                if (d >= r.nearestDistance) continue;

                r.nearestDistance = d;
                r.nearestIsPerson = person != null;
                r.nearestIsRobot = other != null;
                r.nearestIsChair = chair != null;
                if (person != null)
                {
                    r.nearestPersonKind = person.kind;
                    r.nearestPersonSpeed = person.Speed;
                    r.nearestPersonStanding = person.IsStanding;
                    r.nearestName = person.name;
                }
                else if (other != null) r.nearestName = other.name;
                else if (chair != null) r.nearestName = chair.transform.parent != null
                    ? chair.transform.parent.name + "/" + chair.name : chair.name;
            }

            _last = r;
            return r;
        }

        void OnDrawGizmosSelected()
        {
            if (spec == null) return;
            Vector3 fwd = transform.forward;
            Vector3 center = transform.position
                             + Vector3.up * (spec.sensorHeight * 0.5f)
                             + fwd * (spec.Radius + spec.sensorRange * 0.5f);
            Gizmos.color = _last.blocked ? new Color(1f, 0.3f, 0.2f, 0.6f) : new Color(0.3f, 1f, 0.4f, 0.4f);
            Matrix4x4 old = Gizmos.matrix;
            Gizmos.matrix = Matrix4x4.TRS(center, transform.rotation, Vector3.one);
            Gizmos.DrawWireCube(Vector3.zero, new Vector3(spec.sensorWidth, spec.sensorHeight, spec.sensorRange));
            Gizmos.matrix = old;
        }
    }
}
```

### Assets/ServiceSim/Robot/RobotSpec.cs

```csharp
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 道の決め方（依頼 v9 の 2 章）。
    ///   MapPlanner … ロボットが地図（サイトの {名前}_map.json）の上で自分で道を引く。NavMesh は使わない
    ///   SiteRoutes … サイトの routes の点を順にたどる（v5b〜v8b と同じ。比べるために残す）
    /// </summary>
    public enum PathMode
    {
        MapPlanner,
        SiteRoutes
    }

    /// <summary>
    /// 配膳ロボットの数字。ロボットに関する数字はすべてこの .asset に置く。
    /// </summary>
    [CreateAssetMenu(menuName = "ServiceSim/Robot Spec", fileName = "RobotSpec")]
    public class RobotSpec : ScriptableObject
    {
        [Header("大きさ")]
        [Tooltip("直径（m）")]
        public float diameter = 0.5f;

        [Tooltip("高さ（m）")]
        public float height = 1.2f;

        [Header("走り")]
        [Tooltip("最高速度（m/秒）")]
        public float maxSpeed = 0.6f;

        [Tooltip("加速（m/秒^2）")]
        public float acceleration = 1.0f;

        [Tooltip("減速（m/秒^2）")]
        public float deceleration = 1.0f;

        [Tooltip("向きを変える速さ（度/秒）。仮の値には無かったので追加した")]
        public float turnSpeedDegPerSec = 180f;

        [Header("前のセンサー")]
        [Tooltip("進む向きの前、どこまで見るか（m）")]
        public float sensorRange = 0.6f;

        [Tooltip("センサーの幅（m）。ロボットの直径に合わせる")]
        public float sensorWidth = 0.5f;

        [Tooltip("センサーの高さ（m）")]
        public float sensorHeight = 1.2f;

        [Header("止まる・動き出す")]
        [Tooltip("前がこの秒数だけ空いたら動き出す（秒）")]
        public float resumeAfterClearSec = 1.0f;

        [Tooltip("止まってこの秒数たったら経路を引き直す（秒）")]
        public float replanAfterStoppedSec = 10f;

        [Tooltip("この秒数動けなかったら「動けなくなった」と記録する（秒）")]
        public float stuckAfterStoppedSec = 60f;

        [Tooltip("使っていない（v5 3章で、受け渡し口へ移すのをやめた）。" +
                 "動けなくなったらスタッフを呼び、ServiceSpec.rescueSec（サイトの rescue_s）動かしてもらう")]
        public float stuckRecoveryWaitSec = 15f;

        [Tooltip("1往復がこの秒数を超えたら、受け渡し口へ置き直して往復を打ち切る（秒）。無限に止まらないための保険")]
        public float maxTripSec = 600f;

        [Header("通路の譲り合い")]
        [Tooltip("行き先の細い通路にもう1台のロボットがいたら、太い通路側の入口で待つ。場面2の撮影では切る")]
        public bool aisleYielding = true;

        [Header("台どうしの譲り合い（Unity だけの仮の値。サイトには無い）")]
        [Tooltip("入口で順番を待つ上限（秒）。これを過ぎたら譲らずに進む。" +
                 "人がロボットを待つ上限（PeopleSpec.robotYieldMaxSec 20 秒）と同じ考え方")]
        public float aisleWaitMaxSec = 20f;

        [Tooltip("入口で待つとき、入口の点から通路の外へ下がる距離（m）。" +
                 "入口の点そのものに立つと、中の台が出られなくなる")]
        public float aisleWaitSetbackM = 0.9f;

        [Tooltip("入口で待つとき、中の台が出てくる道をふさがないよう横へずれる距離（m）")]
        public float aisleWaitSideM = 0.6f;

        [Tooltip("もう1台にふさがれてから、譲り始めるまでの秒数（秒）。" +
                 "すれ違うだけで譲り合わないよう、少し待ってから決める")]
        public float robotYieldAfterSec = 2f;

        [Tooltip("譲って下がったあと、相手を待つ上限（秒）。これを過ぎたら戻って進む")]
        public float robotYieldMaxSec = 20f;

        [Tooltip("相手が通り過ぎたとみなす、外周どうしの空き（m）")]
        public float robotYieldClearM = 0.6f;

        [Tooltip("下がる先が見つからないときに、その場から後ろへ下がる距離（m）")]
        public float robotYieldBackM = 0.9f;

        [Header("受け渡し口の前の通り方（Unity だけの決まり。サイトには無い）")]
        [Tooltip("戻る台が、受け渡し口の前の点を通らず、その少し下を通って右の待機場所へ抜けるか。" +
                 "線が通れる床に乗らないときは、使わずに今までどおり受け渡し口の前へ戻る")]
        public bool useReturnLine = true;

        [Tooltip("戻りの線を、受け渡し口の前の点より何 m 下（−z）に通すか")]
        public float passReturnOffsetM = 1.0f;

        [Tooltip("戻りの線を、出ていく台の線（受け渡し口の前の点を通る線）から" +
                 "中心どうしで何 m 以上離すか")]
        public float passReturnSeparationM = 1.0f;

        [Tooltip("戻りの線を、ドリンクバーの並ぶ点から何 m 以上離すか")]
        public float passReturnKeepAwayM = 0.6f;

        [Tooltip("戻りの線が通れる床に乗っているかを調べる刻み（m）")]
        public float passReturnCheckStepM = 0.05f;

        [Header("同じ通路に2台を集めない（Unity だけの決まり。サイトには無い）")]
        [Tooltip("料理を受け持つとき、ほかの台が向かっている・中にいる細い通路（店の形の aisles）の" +
                 "卓を後に回すか。後に回せる料理が無ければ、通路の入口ではなく待機場所で待つ。" +
                 "外すと、これまでどおり近い卓から順に受け持つ")]
        public bool oneRobotPerAisle = true;

        [Header("譲る順番と、道から横へどく（Unity だけの仮の値。サイトには無い）")]
        [Tooltip("譲る順番の決まりを使うか。外すと、前のとおり互いに相手が動くのを待つ")]
        public bool yieldOrderEnabled = true;

        [Tooltip("譲る側が、相手の道（サイトの routes の線）から横へどく最小の距離（m）")]
        public float yieldSideClearM = 0.6f;

        [Tooltip("どく点を探す広さ（m）。相手の道から横へこの距離まで探す")]
        public float yieldSideSearchM = 2.5f;

        [Tooltip("どく点を探す刻み（m）")]
        public float yieldSideStepM = 0.2f;

        [Tooltip("どいて相手を待つ上限（秒）。過ぎたら元の道へ戻って進む")]
        public float yieldSideWaitMaxSec = 30f;

        [Tooltip("スタッフが譲るかを決めるとき、ロボットの「これから進む道」" +
                 "（サイトの routes の線の、ロボットの今の位置から先）を何 m 分見るか。" +
                 "この中に、横 yieldSideClearM＋体の半径 以内で入っているときだけ譲る。" +
                 "ただのすれ違い（近くにいるだけ）では譲らない（v6b）")]
        public float yieldPathAheadM = 2.0f;

        [Header("角の止まりを数える範囲（Unity だけの仮の値。記録だけ）")]
        [Tooltip("受け渡し口と客席をつなぐ角。x の下限（m）")]
        public float cornerMinX = 10.5f;

        [Tooltip("同じく x の上限（m）")]
        public float cornerMaxX = 13.5f;

        [Tooltip("同じく z の下限（m）")]
        public float cornerMinZ = 3.5f;

        [Tooltip("同じく z の上限（m）")]
        public float cornerMaxZ = 5.8f;

        [Header("卓での止まり")]
        [Tooltip("卓に着いたら止まる秒数（秒）。客が料理を取る時間。「止まった」には数えない")]
        public float tableDwellSec = 25f;

        [Header("経路をたどる（仮の値には無かったので追加した）")]
        [Tooltip("行き先にこれだけ近づいたら着いたとみなす（m）")]
        public float arriveRadius = 0.25f;

        [Tooltip("経路の角にこれだけ近づいたら次の角へ進む（m）")]
        public float cornerReachDistance = 0.12f;

        [Tooltip("これより速度が小さければ「止まっている」とみなす（m/秒）")]
        public float stoppedSpeedEpsilon = 0.02f;

        [Header("止まったときに測る左右の空き")]
        [Tooltip("左右を見る高さ（m）。椅子（0.45 m）に当たる高さにする")]
        public float clearanceProbeHeight = 0.3f;

        [Tooltip("左右を見る距離（m）。これより遠いものは見ない")]
        public float clearanceProbeRange = 3.0f;

        [Tooltip("道が引けないとき、ふさいでいるものを探す半径（m）")]
        public float pathBlockSearchRadius = 2.0f;

        [Tooltip("NavMeshAgent の譲り合いの優先度。小さいほど譲らない。人より小さくして人がよけるようにする")]
        public int avoidancePriority = 0;

        [Tooltip("経路を引き直しても見つからないとき、次に試すまでの間（秒）")]
        public float repathRetrySec = 1.0f;

        [Header("途中までの経路から出る（B7）")]
        [Tooltip("止まっているあいだの「道が引けていなかった秒数（no_path_s）」がこれを超えたら、" +
                 "repathRetrySec を待たずに強く引き直す（秒）")]
        public float noPathForceReplanSec = 10f;

        [Tooltip("強く引き直してもまだ途中までしか引けないとき、いちど通路の入口を目指して出る")]
        public bool escapeToAisleEntranceOnPartialPath = true;

        public float Radius { get { return diameter * 0.5f; } }

        // ---- 譲る順番（1か所にまとめる）----

        /// <summary>
        /// 譲る順番。**小さいほど先に通る**（譲られる側）。
        ///   ロボット（0）＞ スタッフ（1）＞ 客（2）
        ///
        /// 立ち往生を助けに行くスタッフは、この順番の外（PersonRobotYield.ignoreRobot）。
        /// その台には近づかないと仕事ができないので、今までどおりよけない。
        /// </summary>
        public const int RankRobot = 0;
        public const int RankStaff = 1;
        public const int RankGuest = 2;

        /// <summary>その人の順番</summary>
        public static int RankOf(PersonKind kind)
        {
            return kind == PersonKind.Staff ? RankStaff : RankGuest;
        }

        /// <summary>その人はロボットに譲るか（順番が後ろなら譲る）</summary>
        public static bool YieldsToRobot(PersonKind kind)
        {
            return RankOf(kind) > RankRobot;
        }

        /// <summary>受け渡し口と客席をつなぐ角の中か（記録のため）</summary>
        public bool InCorner(Vector3 p)
        {
            return p.x >= cornerMinX && p.x <= cornerMaxX &&
                   p.z >= cornerMinZ && p.z <= cornerMaxZ;
        }
        [Header("道順（v5b・サイトから受け取る）")]
        [Tooltip("サイトの routes（曲がり角の点の並び）をたどるか。" +
                 "外すと、今までどおり行き先まで Unity の道探しで直接行く（比べるとき用）")]
        public bool followSiteRoutes = true;

        [Tooltip("道順の点に着いたとみなす距離（m）。最後の点（止まる位置）だけは arriveRadius を使う")]
        public float waypointReachDistance = 0.25f;

        [Tooltip("道順の最後の点（止まる位置）を、焼いた面へ寄せるときに探す半径（m）。" +
                 "サイトが通れる所に置いているので、寄せない（0.1 m 以下）。" +
                 "この半径で面に乗らなければ広げて引き直し、報告に回数を出す")]
        public float routePointSnapM = 0.1f;

        // ================= 依頼 v9：地図の上で自分で道を引く =================

        [Header("道の決め方（依頼 v9 の 2 章）")]
        [Tooltip("MapPlanner … 地図の上でロボットが自分で道を引く（新しい・既定）。" +
                 "SiteRoutes … サイトの routes の点をたどる（v8b までと同じ。比べるとき用）。" +
                 "戻りの線（useReturnLine）・横へどく点（yieldOrderEnabled）・角の箱（cornerMin/Max）は " +
                 "SiteRoutes のときだけ働く")]
        public PathMode pathMode = PathMode.MapPlanner;

        [Tooltip("通れる幅の道だけのマス（free_class 1）を通るときの重さの倍。" +
                 "狭い所は必要な所だけ通らせるため（目安 3 倍）")]
        public float narrowCellCost = 3f;

        [Tooltip("通路の真ん中寄りを走らせる強さ。1 歩の重さに " +
                 "centerWeight × max(0, preferClearM − clear) / preferClearM を足す（エイイチの決定）")]
        public float centerWeight = 2f;

        [Tooltip("障害物からこれだけ離れていれば、真ん中寄りの重さを足さない（m）。" +
                 "1.2 m の通路ならちょうど真ん中（エイイチの決定）")]
        public float preferClearM = 0.6f;

        [Tooltip("道を引き直すのは、1 台につきこの秒数に 1 回まで（秒）")]
        public float replanMinIntervalSec = 0.5f;

        [Tooltip("1 回の道探しで広げるマスの数の上限。超えたら引けないことにする。" +
                 "いつまでも探して回が止まらないようにするための保険")]
        public int planMaxExpand = 200000;

        [Tooltip("ロボットがいま立っている所が地図では通れないマス（0）のとき、" +
                 "近くの通れるマスを探す半径（m）。" +
                 "NavMesh は体の半径 0.25 m で焼いてあるのに free_class は道の幅で削ってあるので、" +
                 "通路の入口で待つ点・横へどいた点は 0 になる。地図は塗り直さず、出発点だけ寄せる")]
        public float mapSnapM = 1.0f;

        [Tooltip("曲がり角だけに縮めるとき、1 本の近道の長さの上限（m）。" +
                 "長すぎる近道を試すと道探しが重くなる")]
        public float planSimplifyMaxM = 6f;

        [Header("人や物で前がふさがったとき（依頼 v9 の 3 章）")]
        [Tooltip("止まってこの秒数たっても前が空かなければ、回り込む道を探す（秒。エイイチの決定）。" +
                 "引けなければ待ち、この秒数ごとに探し直す")]
        public float detourAfterSec = 3f;

        [Tooltip("回り込む道を探すとき、通れないマスとして置く人・ロボットを探す半径（m）")]
        public float detourNearM = 3f;

        [Tooltip("回り込む道と人のあいだに足す余裕（m）。" +
                 "人の半径＋ロボットの半径＋この値 を通れないマスにする")]
        public float detourPersonMarginM = 0.1f;

        [Tooltip("回り込む道が、もとの残りの道のこの倍＋detourExtraAddM より長ければ取らずに待つ")]
        public float detourMaxFactor = 2f;

        [Tooltip("同じく、足し算のぶん（m）")]
        public float detourExtraAddM = 5f;

        [Header("ロボットどうし（依頼 v9 の 4 章・エイイチの決定）")]
        [Tooltip("相手の「先の道」を何 m 分見るか（m）")]
        public float robotPathAheadM = 4f;

        [Tooltip("優先が高い台の先の道から、2 台の半径の和＋この値 以内のマスを重くする（m）")]
        public float robotPathCostMarginM = 0.2f;

        [Tooltip("重くする強さ（1 歩の重さに足す）。相手の道とかぶらない道があればそちらを選ばせる")]
        public float robotPathCostAdd = 6f;

        [Tooltip("よける場所は、相手の先の道から 2 台の半径の和＋この値 以上離れていること（m）")]
        public float robotYieldSeparationM = 0.1f;

        [Tooltip("よける場所を探す広さ（m）。自分のまわりこの距離まで探す")]
        public float robotYieldSpotSearchM = 2.5f;

        [Tooltip("優先が高い台がこの距離まで来て、先の道がかぶるときに引き直す（m）")]
        public float robotReplanNearM = 3f;

        [Tooltip("2 台が向かい合ってこの秒数止まったら、優先が低い方がよける場所へ動く（秒）")]
        public float headOnStuckSec = 10f;

        [Tooltip("「向かい合っている」と見る、2 台の向きの内積の上限。" +
                 "−1 が真正面。−0.5 は 120 度より正面寄り（依頼に数字が無かったのでここで決めた）")]
        public float headOnDotMax = -0.5f;

        // ================= 依頼 v13：ロボットどうしのすれ違い =================

        [Header("すれ違い（依頼 v13 の 依頼1。実店舗の動画に合わせた）")]
        [Tooltip("すれ違いの走り方を使うか。切のときは cs:3 と同じ動き（向かい合ったら" +
                 "譲る順番の決まりに入る）。入にすると、向かい合った2台は止まらずに" +
                 "速さを落として右へ寄り、体どうしの間を詰めてすれ違う。" +
                 "入を標準にするかは、入・切で1回ずつ回した結果を見てエイイチが決める")]
        public bool passByEnabled = false;

        [Tooltip("進む先どれだけ以内に向かってくる台がいたら「すれ違い中」に入るか（m）。" +
                 "中心どうしの距離で見る。向かい合って止まった回数もこの距離の中で数える")]
        public float passByDistanceM = 2.0f;

        [Tooltip("すれ違い中の速さの上限（m/秒）。止まらずにこの速さまで落とす")]
        public float passBySpeedMps = 0.3f;

        [Tooltip("すれ違うときに詰める、体どうしの間（m）。" +
                 "前のセンサーは、相手がロボットのときだけこの間まで全停止しない")]
        public float passBySeparationM = 0.1f;

        [Tooltip("すれ違うときに、人が横を通れるよう残す幅（m。人の直径ぶん）。" +
                 "2026-10-26：1.2 m の通路で2台が寄ると壁際 0.05 m しか残らず、" +
                 "人がまったく通れなくなっていた（通路の詰まりが 35件→845件）。" +
                 "いまは『体の幅×2 ＋ 間 ＋ この値』が無い所では すれ違わず、" +
                 "今までの決まり（料理を載せた方が先・3秒で回り込む）に渡す。" +
                 "0 にすると 2026-10-26 より前の動きに戻る")]
        public float passByNeedPersonM = 0.4f;

        [Tooltip("すれ違いをやめて今までの決まりに渡す前に、いったん離れる間（m）。" +
                 "2026-10-26：鼻先 0.06 m で渡すと、譲る場所も回り込む道も見つからず" +
                 "545 秒固まった。前のセンサーが届く 0.6 m まで下がってから渡す")]
        public float passByBackOffM = 0.6f;

        [Tooltip("離れるときの速さ（m/秒）。すれ違い中の速さと同じで十分")]
        public float passByBackSpeedMps = 0.3f;

        /// <summary>
        /// すれ違うときに、進む向きの右へ寄る距離（m）。
        /// 2台が左右に分かれるので、中心どうしは この2倍 ＝ 体の幅＋間 だけ離れる。
        /// 値は上の3つから出す（別の数字を増やさない）
        /// </summary>
        public float PassByShiftM { get { return (diameter + passBySeparationM) * 0.5f; } }

        /// <summary>
        /// すれ違うのに要る通路の幅（m）。
        /// 体の幅×2 ＋ 2台の間 ＋ 人が横を通る分（2026-10-26）
        /// </summary>
        public float PassByNeedWidthM
        {
            get { return diameter * 2f + passBySeparationM + Mathf.Max(0f, passByNeedPersonM); }
        }

        [Header("見せる（依頼 v9 の 6 章。測る回でも出してよい）")]
        [Tooltip("いま引いている道を床の上に細い線で出すか")]
        public bool showPathLine = true;

        [Tooltip("線の太さ（m）")]
        public float pathLineWidthM = 0.04f;

        [Tooltip("線を床から浮かせる高さ（m）。床の塗りより上に出す")]
        public float pathLineHeightM = 0.04f;

        /// <summary>MapPlanner で走るか（依頼 v9）</summary>
        public bool UseMapPlanner { get { return pathMode == PathMode.MapPlanner; } }

        /// <summary>道の引き方の数字を GridPlanner に渡す形にする</summary>
        public GridPlanner.Settings PlannerSettings()
        {
            GridPlanner.Settings s = new GridPlanner.Settings();
            s.narrowCellCost = narrowCellCost;
            s.centerWeight = centerWeight;
            s.preferClearM = preferClearM;
            s.maxExpand = planMaxExpand;
            s.snapM = mapSnapM;
            s.simplifyMaxM = planSimplifyMaxM;
            return s;
        }
    }
}
```

### Assets/ServiceSim/Robot/ServingRobotController.cs

```csharp
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.AI;

namespace ServiceSim
{
    /// <summary>
    /// 配膳ロボット1台。受け渡し口 → 卓 → 受け渡し口 の1往復を走り、
    /// 止まる・動き出す・経路を引き直す・動けなくなるを自分で決める。
    ///
    /// NavMeshAgent は経路を探すのと、人に自分をよけてもらうためだけに付ける。
    /// 自動でよける機能（obstacleAvoidanceType）は切り、位置はこの script が動かす。
    ///
    /// この script はロボットだけを扱う。人も店も動かさない。
    /// 細い通路の区画と入口は、店の側で決めた値を StartTrip で受け取るだけ。
    /// </summary>
    [RequireComponent(typeof(NavMeshAgent))]
    public class ServingRobotController : MonoBehaviour
    {
        public enum Phase
        {
            Idle,        // 往復していない
            ToTable,     // 卓へ向かう（細い通路の入口で待つこともある）
            Dwell,       // 卓で25秒止まる（「止まった」に数えない）
            ToPass,      // 受け渡し口へ戻る
            StuckWait,   // 動けなくなって、スタッフが来て動かしてくれるのを待つ（v5 3章）
            Done         // 往復が終わった
        }

        [Header("設定")]
        public RobotSpec spec;
        public FrontSensor sensor;

        [Tooltip("1台めは1、2台めは2")]
        public int robotId = 1;

        [Tooltip("立ち往生したときにスタッフを呼ぶ先（ServiceOps が入れる。v5 3章）")]
        public ServiceOps ops;

        // ---- 戻りの線（v5。受け渡し口の前の点を通らずに待機場所へ抜ける）----
        Vector3 _standbyPoint;
        bool _hasStandby;
        readonly List<Vector3> _drinkQueues = new List<Vector3>();
        int _returnLineFallbacks;
        bool _returnLineUsed;

        /// <summary>この台の待機場所（ServiceOps が入れる）</summary>
        public void SetStandbyPoint(Vector3 p)
        {
            _standbyPoint = new Vector3(p.x, 0f, p.z);
            _hasStandby = true;
        }

        /// <summary>戻りの線が使えず、今までどおり受け渡し口の前へ戻った回数（報告用）</summary>
        public int ReturnLineFallbacks { get { return _returnLineFallbacks; } }

        /// <summary>直前の戻りで、戻りの線を使えたか（報告用）</summary>
        public bool ReturnLineUsed { get { return _returnLineUsed; } }

        [Header("いまの様子（読むだけ）")]
        [SerializeField] Phase _phase = Phase.Idle;
        [SerializeField] bool _enteredAisle;
        [SerializeField] bool _yieldHold;

        // ---- 1往復の測りもの ----
        public int TableId { get; private set; }

        /// <summary>
        /// この走りを始めた、店の中の時刻（秒）。始めた側が渡す。渡されなければ -1。
        /// 「いつ始まったか」をロボット自身が持つ。外から覚えておくと、
        /// 走り終わってすぐ次の仕事が入ったときに取りこぼす。
        /// </summary>
        public float RunStartS { get; private set; }

        public float DistanceM { get; private set; }
        public float TotalS { get; private set; }
        public float StoppedS { get; private set; }
        public int StopCount { get; private set; }
        public float MaxDecelMps2 { get; private set; }
        public float MinClearanceM { get; private set; }
        public bool Stuck { get; private set; }
        public bool MetRobot { get; private set; }
        public bool Aborted { get; private set; }

        /// <summary>
        /// この往復で動けなくなってスタッフを呼んだ回数（v5 3章）。
        /// 動かしてもらったあと、また動けなくなれば2回めを呼ぶので、Stuck（印）とは別に数える。
        /// </summary>
        public int StuckCount { get; private set; }

        /// <summary>スタッフが来るのを待った秒数の合計（StoppedS にも入っている）</summary>
        public float StuckWaitS { get; private set; }

        /// <summary>1往復が打ち切り（maxTripSec 超え）で終わったか</summary>
        public bool TimedOut { get; private set; }

        /// <summary>細い通路の入口で、ほかの台を待った秒数（秒）。stop_durations_s には入れない</summary>
        public float WaitAisleS { get; private set; }

        // ---- 依頼 v9：地図の上で自分で道を引く ----

        /// <summary>この往復で道を引き直した回数（回り込み・よけを含む。trips.csv の replans）</summary>
        public int Replans { get; private set; }

        /// <summary>3 章の回り込みで道を変えた回数（trips.csv の detours）</summary>
        public int Detours { get; private set; }

        /// <summary>
        /// 4 章でよけた回数（trips.csv の robot_yields）。
        /// よける場所へ動いた回数 ＋ 相手の道をよけた道に引き直した回数
        /// </summary>
        public int RobotYields { get; private set; }

        /// <summary>この往復で、1 回の道探しにかかった最大の時間（ミリ秒。trips.csv の plan_ms_max）</summary>
        public float PlanMsMax { get; private set; }

        /// <summary>
        /// この往復で、サイトの道順をたどったときの長さ（routes の length の和, m。
        /// trips.csv の site_route_m）。走りには使わない
        /// </summary>
        public float SiteRouteM { get; private set; }

        /// <summary>
        /// 往復が実際に終わった店の中の時刻（秒。卓での 25 秒を含む。trips.csv の trip_end_s）。
        /// 終わっていなければ -1
        /// </summary>
        public float TripEndS { get; private set; }

        /// <summary>
        /// この往復で、出発点が地図では通れないマス（0）だったために近くへ寄せた回数。
        /// 通路の入口で待つ点・横へどいた点は free_class では 0 になるので、ここに出る
        /// </summary>
        public int MapSnapStarts { get; private set; }

        /// <summary>この往復で、道が引けなかった回数</summary>
        public int PlanFails { get; private set; }

        /// <summary>いま走っているのが MapPlanner か（地図が無ければ SiteRoutes に落とす）</summary>
        public bool UseMapPlanner
        {
            get { return spec != null && spec.UseMapPlanner && _planner != null; }
        }

        /// <summary>run_info.json に出す、実際に使った道の決め方</summary>
        public PathMode ActivePathMode
        {
            get { return UseMapPlanner ? PathMode.MapPlanner : PathMode.SiteRoutes; }
        }

        /// <summary>
        /// 譲る順番（依頼 v9 の 4 章・エイイチの決定）。**小さいほど先に通る**。
        ///   0 … 料理を載せているロボット（卓へ向かっている。卓から次の卓へ向かう間も含む）
        ///   1 … 空で戻るロボット（受け渡し口・待機場所へ向かっている、待機場所へ動いている）
        /// 同じ状態なら 1 号機が先（robotId の小さい方）。
        /// </summary>
        public int YieldRank
        {
            get
            {
                Phase p = _phase == Phase.StuckWait ? _phaseBeforeStuck : _phase;
                return p == Phase.ToTable || p == Phase.Dwell ? 0 : 1;
            }
        }

        /// <summary>自分が相手より後に通る側か（＝自分がよける）</summary>
        public bool YieldsTo(ServingRobotController other)
        {
            if (other == null) return false;
            if (YieldRank != other.YieldRank) return YieldRank > other.YieldRank;
            return robotId > other.robotId;
        }

        /// <summary>いま回り込んだ道を走っているか（モニターが読む）</summary>
        public bool Detouring { get { return _onDetour; } }

        /// <summary>いま相手をよけて待っているか（モニターが読む）</summary>
        public bool YieldingToRobot { get { return _mapYield; } }

        // ---- すれ違い（依頼 v13 の 依頼1）----

        /// <summary>いますれ違っている相手（いなければ null）</summary>
        public ServingRobotController PassByOther { get { return _passByOther; } }

        /// <summary>
        /// すれ違いの走り方（速さを落として右へ寄る）をしているか。
        /// passByEnabled が切のとき、譲っている最中、幅が足りないときは false
        /// </summary>
        public bool PassByMoving
        {
            get { return _passByOther != null && !_passByNarrow && _passByShiftM > 0f; }
        }

        /// <summary>
        /// 向かい合ってはいるが、寄っても「体の幅×2 ＋ 体どうしの間」に足りないか。
        /// このときは今までの決まり（譲る順番・回り込み）に任せる
        /// </summary>
        public bool PassByNarrow { get { return _passByNarrow; } }

        /// <summary>直前の1コマで進んだ距離（m）。コマの粗さを確かめるのに使う</summary>
        public float LastStepM { get; private set; }

        /// <summary>止まった原因</summary>
        public enum StopCause
        {
            Guest,           // 歩く客とすれ違った
            Staff,           // スタッフとすれ違った
            PersonStanding,  // 人が立ち止まっていた
            Chair,           // 椅子
            OtherRobot,      // もう1台のロボット（Unity だけ）
            NoPath,          // 行き先への道が引けない（通路の幅が足りない）
            AisleQueue,      // 細い通路の順番待ち（Unity だけ）
            Unknown          // 分からない
        }

        /// <summary>どう動き出したか</summary>
        public enum StopResolve
        {
            SelfResume,      // 自分で動き出した
            Replanned,       // 経路を引き直した
            Stuck,           // 動けなくなった
            AisleWait,       // 入口で待った
            Detoured,        // 回り込んで動き出した（依頼 v9 の 3 章）
            YieldedToRobot   // よける場所へ動いた（依頼 v9 の 4 章）
        }

        /// <summary>止まり1回ぶんの記録</summary>
        public struct StopEvent
        {
            public float startTimeS;        // 店の中の時刻（秒）
            public Vector3 position;        // 止まった位置
            public float durationS;         // 止まっていた秒数
            public StopCause cause;
            public string causeDetail;      // 何に当たったか
            public StopResolve resolve;
            public float clearanceLeftM;    // 左の空き（ロボットの外周から）
            public float clearanceRightM;   // 右の空き
            public bool pathBlocked;        // 行き先への道が引けていなかったか

            // ---- 止まっている間、何で動けなかったかの内訳（秒）。B7 を調べるため ----
            /// <summary>前のセンサーが「ふさがっている」と言っていた秒数</summary>
            public float blockedS;
            /// <summary>行き先への道が引けていなかった秒数</summary>
            public float noPathS;
            /// <summary>細い通路の入口で順番を待っていた秒数</summary>
            public float yieldS;
            /// <summary>ふさがりが解けたあと、動き出すまで待っていた秒数</summary>
            public float clearWaitS;

            // ---- 道を引いたときの中身（調べるためだけ。動きは変えない）----
            /// <summary>道を引いた出発点（焼いた面へ寄せたあと）</summary>
            public Vector3 solveFrom;
            /// <summary>道を引いた行き先（同じく寄せたあと）</summary>
            public Vector3 solveTo;
            /// <summary>CalculatePath の結果（PathComplete / PathPartial / PathInvalid / なし）</summary>
            public string solveStatus;
            /// <summary>引けた角の数</summary>
            public int solveCorners;
            /// <summary>
            /// ロボットのいる位置から、いちばん近い焼いた面までの距離（m）。
            /// 面の上に立っていれば 0 に近い。探す半径で見つからなければ -1
            /// </summary>
            public float offMeshM;
            /// <summary>止まっている間に経路を引き直した回数</summary>
            public int replans;
        }

        readonly List<StopEvent> _stopEvents = new List<StopEvent>();

        /// <summary>この往復で止まった1回ずつの記録</summary>
        public IReadOnlyList<StopEvent> StopEvents { get { return _stopEvents; } }

        readonly List<float> _stopDurations = new List<float>();

        /// <summary>自分で動き出した止まりの長さ（秒）。動けなくなった分と、入口で待った分は入れない</summary>
        public IReadOnlyList<float> StopDurationsS { get { return _stopDurations; } }

        public Phase CurrentPhase { get { return _phase; } }
        public bool TripFinished { get { return _phase == Phase.Done; } }
        public bool HasEnteredAisle { get { return _enteredAisle; } }

        /// <summary>いまの速さ（m/秒。字幕が読むだけ）</summary>
        public float SpeedMps { get { return _speed; } }

        /// <summary>
        /// いま続けて動けていない秒数（人のよけ方と、キッチンのモニターが読む）。
        /// 動けなくなって人を待っているあいだは、60 秒＋待った分として続けて数える
        /// </summary>
        public float StoppedNowS
        {
            get
            {
                if (_phase != Phase.StuckWait) return _stoppedTimer;
                float base_ = spec != null ? spec.stuckAfterStoppedSec : 60f;
                return base_ + _stuckWaitTimer;
            }
        }

        /// <summary>いま止まっている理由の名前（キッチンのモニターが読む。記録と同じ言葉）</summary>
        public string StopCauseNow
        {
            get
            {
                if (_phase == Phase.StuckWait) return "立ち往生";
                if (_yieldHold) return "通路の順番待ち";
                if (_robotYield || _mapYield) return "もう1台のロボット";
                if (!_recordingStop) return "";
                return TripLoggerCauseName(_open.cause);
            }
        }

        static string TripLoggerCauseName(StopCause c)
        {
            switch (c)
            {
                case StopCause.Guest: return "お客さんとすれ違った";
                case StopCause.Staff: return "スタッフとすれ違った";
                case StopCause.PersonStanding: return "人が立ち止まっていた";
                case StopCause.Chair: return "椅子・荷物・ベビーカー";
                case StopCause.NoPath: return "通路の幅が足りない";
                case StopCause.OtherRobot: return "もう1台のロボット";
                case StopCause.AisleQueue: return "通路の順番待ち";
                default: return "分からない";
            }
        }

        /// <summary>細い通路の入口で待っている最中か（字幕が読むだけ）</summary>
        public bool YieldHolding { get { return _yieldHold; } }

        /// <summary>卓での受け渡しの残り秒数（字幕が読むだけ）</summary>
        public float DwellRemainS
        {
            get { return spec != null ? Mathf.Max(0f, spec.tableDwellSec - _dwellTimer) : 0f; }
        }

        /// <summary>
        /// スタッフを待っている秒数（字幕が読むだけ。v5 3章）。
        /// 受け渡し口へ移すのをやめたので「残り」ではなく、呼んでから待っている長さを出す
        /// </summary>
        public float StuckWaitRemainS
        {
            get { return _stuckWaitTimer; }
        }
        public Bounds AisleZone { get { return _aisleZone; } }

        /// <summary>
        /// いまたどっている区間の線（1つ前の点 → いま目指す点）。
        /// 譲る側が「この道から横へどく」ために読む（2026-10-23）。
        ///
        /// サイトの routes の点をたどっているときは、その2点をそのまま返す。
        /// 地図で自分の道を引いているとき（MapPlanner・依頼 v9）は、引いた道の
        /// いまの区間を返す（人のよけ方は変えないので、ここは新しい道を返すようにして残す）。
        /// 点が無い・長さが 0 のときは、いまの位置と向きから2 m 先までの線にする。
        /// </summary>
        public bool CurrentLegSegment(out Vector3 a, out Vector3 b)
        {
            int n = WayCount, i = WayIndex;
            if (n > 0 && i >= 0 && i < n)
            {
                b = WayPoint(i);
                a = i > 0 ? WayPoint(i - 1) : transform.position;
                a.y = 0f;
                if ((b - a).sqrMagnitude > 0.0001f) return true;
            }

            a = transform.position; a.y = 0f;
            Vector3 f = transform.forward; f.y = 0f;
            if (f.sqrMagnitude < 0.0001f) { b = a; return false; }
            b = a + f.normalized * 2f;
            return true;
        }

        /// <summary>
        /// これから進む道（v6b）。**いまの位置から先**を aheadM 分だけ、
        /// 残りの点をつないで返す（into の先頭はいまの位置）。
        ///
        /// SiteRoutes ではサイトの routes の残りの点、
        /// MapPlanner（依頼 v9）では地図で引いた道の残りの曲がり角。
        /// 譲る側（人・ほかのロボット）が「自分がこの道の上にいるか」を見るために読む。
        /// 点が無いときは、いまの向きへ aheadM の線1本にする。
        /// </summary>
        public void PathAhead(float aheadM, List<Vector3> into)
        {
            if (into == null) return;
            into.Clear();
            if (aheadM <= 0f) return;

            Vector3 here = transform.position; here.y = 0f;
            into.Add(here);

            float left = aheadM;
            Vector3 from = here;

            int n = WayCount;
            for (int i = WayIndex; i >= 0 && i < n && left > 0f; i++)
            {
                Vector3 to = WayPoint(i);
                Vector3 d = to - from;
                float len = d.magnitude;
                if (len < 0.0001f) continue;

                if (len >= left)
                {
                    into.Add(from + d / len * left);
                    return;
                }
                into.Add(to);
                left -= len;
                from = to;
            }

            if (left > 0f && into.Count == 1)
            {
                // 道順の点が無い。いまの向きへ伸ばす
                Vector3 f = transform.forward; f.y = 0f;
                if (f.sqrMagnitude > 0.0001f) into.Add(here + f.normalized * aheadM);
            }
        }


        /// <summary>1走りで寄る卓1つぶん</summary>
        public struct RunStop
        {
            public int tableId;
            public Vector3 point;          // ロボットが止まる位置（通路の上）
            public Vector3 aisleEntrance;  // その通路に入る前に待つ位置
            public Bounds aisleZone;       // 譲り合いの区画
        }

        readonly List<RunStop> _stops = new List<RunStop>();
        readonly List<int> _tableIds = new List<int>();
        int _stopIndex;

        /// <summary>この走りで寄る卓の番号</summary>
        public IReadOnlyList<int> TableIds { get { return _tableIds; } }

        /// <summary>この走りで何卓に届けたか</summary>
        public int StopsCompleted { get; private set; }

        /// <summary>この走りで寄る卓の数</summary>
        public int RunStopCount { get { return _stops.Count; } }

        readonly List<float> _stopArrivedS = new List<float>();

        /// <summary>
        /// 止まる所ごとに着いた時刻（店の中, 秒）。着いていない所は -1（依頼 v8 の 2）。
        /// trips.csv の stop_arrived_s に出す
        /// </summary>
        public IReadOnlyList<float> StopArrivedS { get { return _stopArrivedS; } }

        /// <summary>
        /// 止まる所に着いた（引数：ロボット・止まる所の番号）。依頼 v8 の 1。
        /// ServiceOps が受けて、その所の皿を「届いた」にする。
        /// 受け渡しの 25 秒（tableDwellSec）の **前** に出る
        /// </summary>
        public event Action<ServingRobotController, int> StopArrived;

        /// <summary>1往復が終わったときに呼ばれる</summary>
        public event Action<ServingRobotController> TripCompleted;

        // ---- いまシーンにいるロボット（譲り合いの判定に使う）----
        static readonly List<ServingRobotController> _all = new List<ServingRobotController>();
        public static IReadOnlyList<ServingRobotController> All { get { return _all; } }

        // ---- 内部 ----
        NavMeshAgent _agent;
        NavMeshPath _path;      // Awake で作る。フィールドの初期化で new すると
                                // 「InitializeNavMeshPath is not allowed to be called
                                //  from a MonoBehaviour constructor」になるため
        Vector3[] _corners = new Vector3[0];
        int _cornerIndex;
        Vector3 _destination;
        Vector3 _destinationOnMesh;
        bool _hasDestinationOnMesh;
        Vector3 _tablePoint;
        Vector3 _passPoint;
        Vector3 _aisleEntrance;
        Bounds _aisleZone;
        bool _hasAisleZone;

        float _speed;
        float _dwellTimer;
        float _clearTimer;
        float _stoppedTimer;        // いま続けて止まっている秒数
        float _lastReplanAtStopped;
        float _stuckWaitTimer;
        Phase _phaseBeforeStuck = Phase.ToTable;   // 動けなくなる直前に向かっていた先（v5 3章）

        // ---- 入口での順番待ち（v5）----
        float _yieldHoldS;          // 入口で待っている秒数
        bool _yieldGaveUp;          // 待ちすぎたので、この区間はもう譲らない
        Vector3 _aisleWaitPoint;    // 入口の手前で待つ点

        // ---- 台どうしの譲り合い（v5）----
        bool _robotYield;                    // いま譲って下がっている最中
        float _robotYieldS;                  // 譲り始めてからの秒数
        float _blockedByRobotS;              // もう1台にふさがれている秒数
        ServingRobotController _robotYieldTo;// 道をゆずる相手
        float _robotYieldStartS;             // 譲り始めた時刻（yields.csv）
        Vector3 _robotYieldAt;               // 譲り始めた位置（yields.csv）
        bool _robotYieldStepped;             // 相手の道から横へどけたか（false なら来た道へ戻った）
        float _repathCooldown;
        bool _inStop;
        float _currentStopS;
        bool _inWait;
        float _currentWaitS;
        Vector3 _pathEndPoint;
        bool _hasPathEnd;
        bool _wasBlocked;
        bool _hasBrakeCause;
        StopCause _brakeCause;
        string _brakeDetail = "";
        string _aisleBlockerName = "";
        bool _replannedInStop;
        bool _recordingStop;
        StopEvent _open;
        bool _pathValid;

        // ---- 途中までの経路から出る（B7）----
        /// <summary>この止まりの中で、最後に強く引き直したときの no_path_s</summary>
        float _lastEscapeAtNoPathS;
        /// <summary>いま通路の入口へ出ようとしているか</summary>
        bool _escapeToEntrance;

        // ---- 依頼 v9：地図の上で自分で道を引く（内部）----
        RobotMap _map;
        GridPlanner _planner;

        /// <summary>前がふさがって止まっている秒数（3 章の 3 秒を数える）</summary>
        float _blockedFrontS;

        /// <summary>いま回り込んだ道を走っているか</summary>
        bool _onDetour;

        /// <summary>向かい合って止まっている秒数（4 章の 10 秒を数える）</summary>
        float _headOnS;

        /// <summary>いまよける場所へ動いて待っているか（4 章）</summary>
        bool _mapYield;
        float _mapYieldS;
        ServingRobotController _mapYieldTo;

        /// <summary>いま道を引き直してよけている相手（1 回の出会いを 1 回だけ数えるため）</summary>
        ServingRobotController _avoidTarget;
        float _mapYieldStartS;
        Vector3 _mapYieldAt;
        bool _mapYieldStepped;

        /// <summary>この止まりの終わり方を上書きする（回り込み・よけ）</summary>
        bool _hasResolveOverride;
        StopResolve _resolveOverride;

        /// <summary>先の道を入れる作業場（毎コマ作り直さない）</summary>
        readonly List<Vector3> _aheadMine = new List<Vector3>();
        readonly List<Vector3> _aheadOther = new List<Vector3>();

        static readonly Vector3[] NoCorners = new Vector3[0];

        // ---- いまたどっている点の並び（サイトの道順と、地図で引いた道をまとめて見る）----

        /// <summary>
        /// いまたどっている点の数。
        /// SiteRoutes では道順の点（_legPoints）、MapPlanner では引いた道の曲がり角（_corners）。
        /// PathAhead・CurrentLegSegment は、どちらでも「これから進む道」を返せるように、ここを見る
        /// </summary>
        int WayCount { get { return _legPoints != null ? _legPoints.Count : _corners.Length; } }

        /// <summary>いま目指している点の番号</summary>
        int WayIndex { get { return _legPoints != null ? _legIndex : _cornerIndex; } }

        /// <summary>i 番目の点（y は 0）</summary>
        Vector3 WayPoint(int i)
        {
            Vector3 p = _legPoints != null ? _legPoints[i] : _corners[i];
            p.y = 0f;
            return p;
        }

        /// <summary>
        /// ロボットが歩く NavMesh の種類（agentTypeID）。既定の 0。
        /// NavMesh が人用とロボット用の2枚になったので、引くときは必ず種類を指定する
        /// （指定しないと「どの種類か決められない」と警告が出て、思わぬ面を拾う）。
        /// </summary>
        public const int RobotAgentTypeId = 0;

        static NavMeshQueryFilter RobotFilter
        {
            get
            {
                NavMeshQueryFilter f = new NavMeshQueryFilter();
                f.agentTypeID = RobotAgentTypeId;
                f.areaMask = NavMesh.AllAreas;
                return f;
            }
        }

        /// <summary>
        /// stops.csv の cause_detail に書く印。
        /// この印がある止まりは「途中までの経路に縛られて、引き直して出た」もの。
        /// CSV を割らないように、区切りはカンマを使わない。
        /// </summary>
        public const string PartialPathMark = "途中までの経路";

        void Awake()
        {
            _path = new NavMeshPath();
            _agent = GetComponent<NavMeshAgent>();
            ApplySpecToAgent();
        }

        void OnEnable()
        {
            if (!_all.Contains(this)) _all.Add(this);
        }

        void OnDisable()
        {
            _all.Remove(this);
        }

        void ApplySpecToAgent()
        {
            if (_agent == null || spec == null) return;
            _agent.radius = spec.Radius;
            _agent.height = spec.height;
            _agent.speed = spec.maxSpeed;
            _agent.acceleration = spec.acceleration;
            _agent.angularSpeed = spec.turnSpeedDegPerSec;
            // 自動でよける機能は使わない（実機のよけ方と違うため）
            // ロボットはロボット用の面（半径 navAgentRadius で焼いたもの）を歩く
            if (_agent.agentTypeID != RobotAgentTypeId) _agent.agentTypeID = RobotAgentTypeId;
            _agent.obstacleAvoidanceType = ObstacleAvoidanceType.NoObstacleAvoidance;
            _agent.avoidancePriority = spec.avoidancePriority;
            // 位置と向きはこの script が動かす。agent は経路を探すのと、人に見てもらうためだけ
            _agent.updatePosition = false;
            _agent.updateRotation = false;
            _agent.autoRepath = false;
            // isStopped は NavMesh に乗っている agent にしか使えない
            if (_agent.enabled && _agent.isOnNavMesh) _agent.isStopped = true;
        }

        /// <summary>受け渡し口に置き直して、次の往復を始める準備をする</summary>
        /// <summary>
        /// 仕事がないときの待機場所へ、ゆっくり下がる（B2）。
        /// 往復ではないので、距離・止まり・動けなくなったの記録には数えない。
        /// 止まる決まり（前のセンサー・譲り合い）もここでは働かせない。
        /// すでにその場所の近くにいれば何もしない。
        /// </summary>
        public void GoStandby(Vector3 spot)
        {
            if (_phase != Phase.Idle && _phase != Phase.Done) return;
            if (_agent == null || !Application.isPlaying) return;
            if (!_agent.enabled || !_agent.isOnNavMesh) return;

            Vector3 here = transform.position; here.y = 0f;
            Vector3 to = spot; to.y = 0f;
            if (Vector3.Distance(here, to) <= spec.arriveRadius) { _agent.isStopped = true; return; }

            _agent.isStopped = false;
            _agent.speed = spec.maxSpeed;
            _agent.SetDestination(spot);
        }

        public void PlaceAt(Vector3 position, Quaternion rotation)
        {
            transform.SetPositionAndRotation(position, rotation);
            _speed = 0f;

            if (_agent == null) return;
            if (!Application.isPlaying) return;      // 編集モードでは NavMeshAgent を触らない

            if (!_agent.enabled) _agent.enabled = true;
            _agent.Warp(position);
            if (_agent.isOnNavMesh)
            {
                _agent.isStopped = true;
                _agent.nextPosition = position;
            }
        }

        /// <summary>1往復を始める（細い通路の譲り合いなし。場面の再現などに使う）</summary>
        public void StartTrip(int tableId, Vector3 tablePoint, Vector3 passPoint)
        {
            StartTrip(tableId, tablePoint, passPoint, passPoint, new Bounds());
        }

        /// <summary>1往復を始める（1卓だけ）</summary>
        public void StartTrip(int tableId, Vector3 tablePoint, Vector3 passPoint,
                              Vector3 aisleEntrance, Bounds aisleZone)
        {
            RunStop s = new RunStop();
            s.tableId = tableId;
            s.point = tablePoint;
            s.aisleEntrance = aisleEntrance;
            s.aisleZone = aisleZone;
            List<RunStop> one = new List<RunStop>();
            one.Add(s);
            StartRun(one, passPoint);
        }

        /// <summary>
        /// 1走りを始める。受け渡し口を出て、渡された卓を順に回り、受け渡し口へ戻る。
        /// 止まる決まり（前のセンサー・譲り合い・動けなくなったときの扱い）は1卓のときと同じ。
        /// </summary>
        public void StartRun(IList<RunStop> stops, Vector3 passPoint)
        {
            StartRun(stops, passPoint, -1f);
        }

        /// <summary>始めた時刻（店の中, 秒）も渡して1走りを始める。記録に使う</summary>
        public void StartRun(IList<RunStop> stops, Vector3 passPoint, float startS)
        {
            RunStartS = startS;
            _stops.Clear();
            _tableIds.Clear();
            _stopArrivedS.Clear();
            if (stops != null)
            {
                for (int i = 0; i < stops.Count; i++)
                {
                    _stops.Add(stops[i]);
                    _tableIds.Add(stops[i].tableId);
                    _stopArrivedS.Add(-1f);     // 着いたら時刻を入れる（依頼 v8 の 2）
                }
            }
            _stopIndex = 0;
            StopsCompleted = 0;
            ResetTo = "";               // 打ち切りで置き直したときだけ入れる（依頼 v8b の A）

            if (_stops.Count == 0) { _phase = Phase.Idle; return; }
            BeginRun(passPoint);
        }

        void BeginRun(Vector3 passPoint)
        {
            RunStop first = _stops[0];
            int tableId = first.tableId;
            Vector3 tablePoint = first.point;
            Vector3 aisleEntrance = first.aisleEntrance;
            Bounds aisleZone = first.aisleZone;

            if (_path == null) _path = new NavMeshPath();
            ApplySpecToAgent();
            if (Application.isPlaying && _agent != null)
            {
                if (!_agent.enabled) _agent.enabled = true;
                if (!_agent.isOnNavMesh) _agent.Warp(transform.position);
            }

            TableId = tableId;
            _tablePoint = tablePoint;
            _passPoint = passPoint;
            _aisleEntrance = aisleEntrance;
            _aisleZone = aisleZone;
            _hasAisleZone = aisleZone.size.x > 0.01f && aisleZone.size.z > 0.01f;

            DistanceM = 0f;
            TotalS = 0f;
            StoppedS = 0f;
            StopCount = 0;
            MaxDecelMps2 = 0f;
            MinClearanceM = float.PositiveInfinity;
            Stuck = false;
            StuckCount = 0;
            StuckWaitS = 0f;
            LegWasNarrow = false;
            MetRobot = false;
            Aborted = false;
            TimedOut = false;
            WaitAisleS = 0f;
            _stopDurations.Clear();
            _stopEvents.Clear();

            _speed = 0f;
            _dwellTimer = 0f;
            _clearTimer = 0f;
            _hasDestinationOnMesh = false;
            _wasBlocked = false;
            _hasBrakeCause = false;
            _stoppedTimer = 0f;
            _lastReplanAtStopped = 0f;
            _stuckWaitTimer = 0f;
            _phaseBeforeStuck = Phase.ToTable;
            _yieldHoldS = 0f;
            _yieldGaveUp = false;
            _robotYield = false;
            _robotYieldS = 0f;
            _robotYieldTo = null;
            _robotYieldStepped = false;
            _robotYieldStartS = 0f;
            _blockedByRobotS = 0f;
            _repathCooldown = 0f;
            _inStop = false;
            _currentStopS = 0f;
            _lastEscapeAtNoPathS = 0f;
            _escapeToEntrance = false;

            // ---- 依頼 v9 ----
            Replans = 0;
            Detours = 0;
            RobotYields = 0;
            PlanMsMax = 0f;
            SiteRouteM = 0f;
            TripEndS = -1f;
            MapSnapStarts = 0;
            PlanFails = 0;
            _blockedFrontS = 0f;
            _onDetour = false;
            _headOnS = 0f;
            _mapYield = false;
            _mapYieldS = 0f;
            _mapYieldTo = null;
            _avoidTarget = null;
            _hasResolveOverride = false;

            // 譲り合いを使わない／区画が無いときは、はじめから「入った」扱いにする
            _enteredAisle = !(spec != null && spec.aisleYielding) || !_hasAisleZone;
            _yieldHold = false;

            _phase = Phase.ToTable;
            // v5b: サイトの道順を受け取る（店を建て直すこともあるので、1 走りごとに引き直す）
            StoreBuilder store = FindObjectOfType<StoreBuilder>();
            _routes = store != null ? store.Routes : null;

            // 依頼 v9: ロボットが走れる所の地図を受け取る。道探しはこの上でやる（NavMesh は使わない）。
            // 地図が無いのに MapPlanner だと走れないので、はっきり言ってから SiteRoutes に落とす
            _map = store != null ? store.RobotMap : null;
            _planner = _map != null ? _map.Planner : null;
            if (spec != null && spec.pathMode == PathMode.MapPlanner && _planner == null)
                Debug.LogError("[ServiceSim] ロボット" + robotId + "号機: pathMode が MapPlanner ですが、" +
                               "店に地図（map.json）がありません。この回は SiteRoutes で走ります" +
                               "（JsonStoreLayout の mapJson を確かめてください）");
            // ドリンクバーの並ぶ点（戻りの線を離すために使う。v5）
            _drinkQueues.Clear();
            if (store != null)
            {
                IList<Vector3> qs = store.PointsOf(PlaceKind.DrinkBarQueue);
                for (int i = 0; i < qs.Count; i++) _drinkQueues.Add(new Vector3(qs[i].x, 0f, qs[i].z));
            }
            _snapFallbacks = 0;
            bool narrow;
            BeginLeg(RouteTo(0, tableId, out narrow), _tablePoint, narrow);
        }

        /// <summary>次の卓へ向かう。もう無ければ受け渡し口へ戻る</summary>
        void NextStopOrReturn()
        {
            if (_stopIndex + 1 < _stops.Count)
            {
                int prevTableId = _stops[_stopIndex].tableId;
                _stopIndex++;
                RunStop s = _stops[_stopIndex];
                TableId = s.tableId;
                _tablePoint = s.point;
                _aisleEntrance = s.aisleEntrance;
                _aisleZone = s.aisleZone;
                _hasAisleZone = s.aisleZone.size.x > 0.01f && s.aisleZone.size.z > 0.01f;
                _enteredAisle = !(spec != null && spec.aisleYielding) || !_hasAisleZone;
                _yieldHold = false;
                _escapeToEntrance = false;
                _lastEscapeAtNoPathS = 0f;
                _phase = Phase.ToTable;
                bool narrowNext;
                BeginLeg(RouteTo(prevTableId, s.tableId, out narrowNext), _tablePoint, narrowNext);
                return;
            }
            _phase = Phase.ToPass;
            _escapeToEntrance = false;
            _lastEscapeAtNoPathS = 0f;
            bool narrowBack;
            List<Vector3> back = RouteToPass(_stops[_stopIndex].tableId, out narrowBack);

            // 受け渡し口の前の点を通らず、その少し下を通って右の待機場所へ抜ける（v5）。
            // 線が通れる床に乗らないときは、今までどおり受け渡し口の前へ戻る
            List<Vector3> line = ReturnLine(back);
            _returnLineUsed = line != null;
            if (line != null) back = line;

            BeginLeg(back, _returnLineUsed ? _standbyPoint : _passPoint, narrowBack);
        }

        // ---------------- 戻りの線（v5）----------------

        /// <summary>
        /// 戻る台の線。受け渡し口の前の点のかわりに、その少し下（passReturnOffsetM）の点へ入り、
        /// 右へ抜けて自分の待機場所で終わる。
        ///
        /// 1006 の回で、受け渡し口の前（x 14.0〜15.8 / z 4.8〜6.6）の「もう1台のロボット」が
        /// 209 回あり、そのうち 2 号機の 182 回では、相手の 91% が
        /// 「戻ってきた台」か「待機場所にいる台」だった。出る台と戻る台がここで交差していたため。
        ///
        /// サイトの道順は変えない（最後の点だけ差し替える）。
        /// 線が使えないときは null を返し、今までどおり受け渡し口の前へ戻る。
        /// </summary>
        List<Vector3> ReturnLine(List<Vector3> routeToPass)
        {
            // 依頼 v9：戻りの線は SiteRoutes のときだけ働く。
            // MapPlanner では地図の上で受け渡し口まで自分で引く（後から補う線は使わない）
            if (UseMapPlanner) return null;
            if (spec == null || !spec.useReturnLine || !_hasStandby) return null;
            if (routeToPass == null || routeToPass.Count < 2) return null;

            Vector3 pass = _passPoint; pass.y = 0f;
            float z = pass.z - spec.passReturnOffsetM;
            Vector3 p1 = new Vector3(pass.x, 0f, z);
            Vector3 p2 = new Vector3(_standbyPoint.x, 0f, z);
            Vector3 p3 = _standbyPoint;

            List<Vector3> pts = new List<Vector3>(routeToPass);
            pts[pts.Count - 1] = p1;        // 受け渡し口の前の点を、その少し下へ差し替える
            pts.Add(p2);
            pts.Add(p3);

            // 道順から分かれたあとの区間だけを確かめる（分かれる前はサイトの道順そのまま）
            int from = Mathf.Max(0, pts.Count - 4);
            if (!ReturnLineWalkable(pts, from) ||
                !ReturnLineClear(p1, p2, p3))
            {
                _returnLineFallbacks++;
                return null;
            }
            return pts;
        }

        /// <summary>戻りの線が、ロボット用の焼いた面に乗っているか（刻みで調べる）</summary>
        bool ReturnLineWalkable(List<Vector3> pts, int from)
        {
            float step = Mathf.Max(0.01f, spec.passReturnCheckStepM);
            NavMeshHit hit;

            for (int i = from; i < pts.Count - 1; i++)
            {
                Vector3 a = pts[i], b = pts[i + 1];
                float len = Vector3.Distance(a, b);
                int n = Mathf.Max(1, Mathf.CeilToInt(len / step));
                for (int k = 0; k <= n; k++)
                {
                    Vector3 p = Vector3.Lerp(a, b, (float)k / n);
                    if (!NavMesh.SamplePosition(p, out hit, spec.Radius, RobotFilter)) return false;
                }
            }
            return true;
        }

        /// <summary>
        /// 戻りの線が、出ていく台の線（受け渡し口の前の点を通る線）と
        /// ドリンクバーの並ぶ点から、決めた距離だけ離れているか。
        /// 離れを見るのは、道順から分かれたあとの3点と、そのあいだだけ
        /// （分かれる前はサイトの道順そのままなので、出る線と重なっていてよい）。
        /// </summary>
        bool ReturnLineClear(Vector3 p1, Vector3 p2, Vector3 p3)
        {
            float step = Mathf.Max(0.01f, spec.passReturnCheckStepM);
            Vector3 pass = _passPoint; pass.y = 0f;
            Vector3[] line = { p1, p2, p3 };

            for (int i = 0; i < line.Length - 1; i++)
            {
                float len = Vector3.Distance(line[i], line[i + 1]);
                int n = Mathf.Max(1, Mathf.CeilToInt(len / step));
                for (int k = 0; k <= n; k++)
                {
                    Vector3 p = Vector3.Lerp(line[i], line[i + 1], (float)k / n);
                    if (Vector3.Distance(p, pass) < spec.passReturnSeparationM) return false;
                    for (int q = 0; q < _drinkQueues.Count; q++)
                        if (Vector3.Distance(p, _drinkQueues[q]) < spec.passReturnKeepAwayM) return false;
                }
            }
            return true;
        }

        /// <summary>往復を打ち切る</summary>
        public void AbortTrip()
        {
            _legPoints = null;
            if (_phase == Phase.Idle || _phase == Phase.Done) return;
            Aborted = true;
            Finish();
        }

        void Update()
        {
            if (spec == null) return;
            if (_phase == Phase.Idle || _phase == Phase.Done)
            {
                if (_pathLine != null) _pathLine.Hide();   // 走っていないときは線を消す（6 章）
                return;
            }

            float dt = Time.deltaTime;
            if (dt <= 0f) return;

            LastStepM = 0f;

            FrontSensor.Reading reading = sensor != null ? sensor.Sense() : new FrontSensor.Reading();
            if (reading.sawRobot) MetRobot = true;

            // 卓での25秒。測りの時間には入れない
            if (_phase == Phase.Dwell)
            {
                _dwellTimer += dt;
                if (_dwellTimer >= spec.tableDwellSec) NextStopOrReturn();
                SyncAgent();
                return;
            }

            TotalS += dt;
            UpdateClearance();

            // 1往復の打ち切り（この往復は timeout として1行に残す）
            if (TotalS > spec.maxTripSec)
            {
                TimedOut = true;
                // ほかの台と重ならない所へ置き直す（依頼 v8b の A）。
                // 前は必ず受け渡し口の点に置いていたので、2台が打ち切られると
                // 同じ点で重なり、お互いを「もう1台のロボット」として止まり続けていた
                Vector3 to = PickResetPoint();
                PlaceAt(to, transform.rotation);
                Finish();
                return;
            }

            // 動けなくなって、スタッフが来るのを待っているあいだ（v5 3章）。
            // 受け渡し口へ移す（warp）のはやめた。スタッフが歩いて来て rescue_s 動かすと
            // Rescued() が呼ばれ、止まっていた所から走りを続ける。
            // 待っているあいだは「止まっていた」に数える。
            // いつまでも来ないときは、上の maxTripSec の打ち切りが働く（timeout=1）。
            if (_phase == Phase.StuckWait)
            {
                StoppedS += dt;
                StuckWaitS += dt;
                _stuckWaitTimer += dt;
                SyncAgent();
                return;
            }

            // ---- 通路の入口へ出ている途中（B7）。着いたら、もう一度もとの行き先を目指す ----
            // MapPlanner では escape を使わない（依頼 v9 の 3 章）
            if (!UseMapPlanner) UpdateEscape();

            // ---- ロボットどうしのすれ違い（依頼 v13 の 依頼1）----
            // 向かい合った2台が、止まらずに速さを落として右へ寄れるかを見る。
            // passByEnabled が切なら何もしない（cs:3 と同じ動き）
            UpdatePassBy(dt);

            // ---- 台どうしでふさぎ合ったら、決まった順で片方が譲る ----
            // MapPlanner では「料理を載せている方が先」（依頼 v9 の 4 章）、
            // SiteRoutes ではこれまでの「番号の小さい台が先」（v5〜v8b）
            //
            // すれ違いの走り方に入っているあいだは、譲り合いに入らない（依頼 v13）。
            // 寄っても幅が足りないときだけ、今までどおりここに任せる
            if (!PassByMoving)
            {
                if (UseMapPlanner) UpdateRobotYieldMap(dt, reading);
                else UpdateRobotYield(dt, reading);
            }

            // ---- 細い通路の譲り合い ----
            bool waitingAtEntrance = !_robotYield && !_mapYield && UpdateAisleYielding();

            // ---- 走る・止まる ----
            bool blocked = reading.blocked;

            // すれ違い中は、前にいるのが相手のロボットだけなら、体どうしの間
            // （passBySeparationM）まで全停止しない（依頼 v13）。
            // 人・椅子が前にいるときは、今までどおり止まる
            if (PassByMoving && blocked && reading.nearestIsRobot &&
                !reading.sawPerson && !reading.sawChair &&
                reading.nearestDistance > spec.passBySeparationM)
                blocked = false;

            // 原因は、ブレーキを踏み始めた瞬間（前が塞がったと分かった瞬間）のものを覚えておく。
            // 速さが0になるまで待つと、そのあいだに相手が通り過ぎて「分からない」になってしまう
            if (blocked && !_wasBlocked)
            {
                _brakeCause = CauseOf(reading);
                _brakeDetail = string.IsNullOrEmpty(reading.nearestName) ? "" : reading.nearestName;
                _hasBrakeCause = true;
            }
            _wasBlocked = blocked;

            if (blocked) _clearTimer = 0f;
            else _clearTimer += dt;

            // 行き先への道が引けていないときも止まる（進めないのに走っている扱いにしない）
            bool hasPath = _corners.Length > 0 && _cornerIndex < _corners.Length;
            bool wantMove = !blocked && _clearTimer >= spec.resumeAfterClearSec && !waitingAtEntrance && hasPath;

            // すれ違い中は速さの上限を passBySpeedMps にする（依頼 v13）。止まらない
            float topSpeed = PassByMoving
                ? Mathf.Min(spec.maxSpeed, spec.passBySpeedMps) : spec.maxSpeed;

            // 2026-10-26（直し②-a）：1コマで体どうしの間を passBySeparationM より
            // 詰めない速さにする。2台が近づくので間は 2 台ぶんの速さで縮むため、
            // 残りを 2 で割る。これで 0.1 m を行き過ぎて 0.06 m になるのを防ぐ
            if (PassByMoving && _passByOther != null && dt > 0f)
            {
                float room = Gap(_passByOther) - spec.passBySeparationM;
                if (room < 0f) room = 0f;
                float cap = room / (2f * dt);
                if (cap < topSpeed) topSpeed = cap;
            }

            float target = wantMove ? topSpeed : 0f;
            float prevSpeed = _speed;
            if (target > _speed) _speed = Mathf.Min(target, _speed + spec.acceleration * dt);
            else _speed = Mathf.Max(target, _speed - spec.deceleration * dt);

            float decel = (prevSpeed - _speed) / dt;
            if (decel > MaxDecelMps2) MaxDecelMps2 = decel;

            if (_speed <= spec.stoppedSpeedEpsilon)
            {
                StoppedS += dt;

                if (waitingAtEntrance)
                {
                    // 譲り合いで入口に待っている分。止まった回数・長さには数えない
                    WaitAisleS += dt;
                    _stoppedTimer = 0f;
                    _lastReplanAtStopped = 0f;
                    if (!_inWait)
                    {
                        _inWait = true;
                        _currentWaitS = 0f;
                        BeginStopRecord(StopCause.AisleQueue,
                            _aisleBlockerName.Length > 0 ? "先に入っている台：" + _aisleBlockerName : "順番待ち", false);
                    }
                    _currentWaitS += dt;
                    _open.yieldS += dt;
                }
                else
                {
                    if (_inWait) { _inWait = false; EndStopRecord(_currentWaitS, StopResolve.AisleWait); }

                    _stoppedTimer += dt;
                    if (!_inStop)
                    {
                        _inStop = true;
                        _currentStopS = 0f;
                        _replannedInStop = false;
                        _lastEscapeAtNoPathS = 0f;   // no_path_s は止まりごとに数え直す（B7）

                        string detail;
                        StopCause cause;
                        // 道をゆずって下がっているあいだは、相手の台が原因（v5）。
                        // 下がり終えると道が引けていない状態になるが、それは「幅が足りない」ではない
                        if (_robotYield)
                        {
                            cause = StopCause.OtherRobot;
                            detail = _robotYieldTo != null
                                ? "道をゆずって下がった：" + _robotYieldTo.name : "道をゆずって下がった";
                        }
                        else if (_mapYield)
                        {
                            // よける場所へ動いて相手を待っているあいだ（依頼 v9 の 4 章）
                            cause = StopCause.OtherRobot;
                            detail = _mapYieldTo != null
                                ? "よける場所で待った：" + _mapYieldTo.name : "よける場所で待った";
                        }
                        else if (!hasPath) cause = CauseOfNoPath(out detail);
                        else if (_hasBrakeCause)
                        {
                            cause = _brakeCause;      // ブレーキを踏み始めたときに見たもの
                            detail = _brakeDetail;
                        }
                        else
                        {
                            cause = CauseOf(reading);
                            detail = string.IsNullOrEmpty(reading.nearestName) ? "" : reading.nearestName;
                        }
                        BeginStopRecord(cause, detail, !hasPath);
                        _hasBrakeCause = false;
                    }
                    _currentStopS += dt;

                    // 何で動けないかを秒数で分けて残す（B7 を調べるため）。
                    // 止まる決まりは変えない。数えるだけ
                    if (blocked) _open.blockedS += dt;
                    if (!hasPath) _open.noPathS += dt;
                    if (!blocked && hasPath && _clearTimer < spec.resumeAfterClearSec)
                        _open.clearWaitS += dt;

                    if (UseMapPlanner)
                    {
                        // 依頼 v9 の 3 章：止まって 3 秒たっても前が空かなければ回り込む。
                        // 「10 秒ごとに引き直す」と escape は使わない
                        UpdateDetour(dt, blocked);
                    }
                    else
                    {
                        // 止まって10秒ごとに経路を引き直す
                        if (_stoppedTimer - _lastReplanAtStopped >= spec.replanAfterStoppedSec)
                        {
                            _lastReplanAtStopped = _stoppedTimer;
                            _replannedInStop = true;
                            _open.replans++;
                            SetDestination(_destination);
                        }
                    }

                    // 途中までの経路に縛られているとき（B7）。
                    // 「道が引けていなかった秒数」がしきい値を超えたら、
                    // 引き直しの間隔（repathRetrySec）を待たずに強く引き直す。
                    // それでも途中までしか引けなければ、いちど通路の入口を目指して出る。
                    if (!UseMapPlanner && !hasPath && !_escapeToEntrance &&
                        _open.noPathS - _lastEscapeAtNoPathS >= spec.noPathForceReplanSec)
                    {
                        EscapePartialPath();
                    }

                    // 60秒動けなかったら「動けなくなった」
                    if (_stoppedTimer >= spec.stuckAfterStoppedSec)
                    {
                        EnterStuck();
                        SyncAgent();
                        return;
                    }
                }
            }
            else
            {
                if (_inWait) { _inWait = false; EndStopRecord(_currentWaitS, StopResolve.AisleWait); }
                if (_inStop)
                {
                    _inStop = false;
                    StopCount++;
                    _stopDurations.Add(_currentStopS);
                    EndStopRecord(_currentStopS, ResolveOfStop());
                }
                _stoppedTimer = 0f;
                _lastReplanAtStopped = 0f;
                _blockedFrontS = 0f;
            }

            FaceAndMove(dt);
            SyncAgent();
            UpdatePathLine();
            CheckArrival();
        }

        // ---------------- 見せる（依頼 v9 の 6 章）----------------

        RobotPathLine _pathLine;

        /// <summary>
        /// いま引いている道を床の上に細い線で出す。
        /// 見た目だけで、動きには効かない。RobotSpec.showPathLine で切れる
        /// </summary>
        void UpdatePathLine()
        {
            if (spec == null || !spec.showPathLine)
            {
                if (_pathLine != null) _pathLine.Hide();
                return;
            }
            if (_pathLine == null)
            {
                _pathLine = RobotPathLine.Attach(transform, spec.pathLineWidthM);
                if (_pathLine == null) return;
            }

            int n = WayCount, i = WayIndex;
            if (n == 0 || i >= n) { _pathLine.Hide(); return; }

            _lineBuf.Clear();
            for (int k = i; k < n; k++) _lineBuf.Add(WayPoint(k));
            // 料理を載せているとき（卓へ向かっている）と空のときで色を変える
            _pathLine.Show(_lineBuf, 0, transform.position,
                           YieldRank == 0, spec.pathLineHeightM);
        }

        readonly List<Vector3> _lineBuf = new List<Vector3>();

        // ---------------- 台どうしの譲り合い（v5）----------------

        /// <summary>
        /// 2台がふさぎ合ったとき、決まった順で片方が譲る。
        ///
        /// 実際の配膳ロボットは台どうしで連絡を取り合い、片方が道を譲る。
        /// ここでは **番号の小さい台が先**。番号の大きい台が譲る。
        ///
        /// 2026-10-23：譲る側は **後ろへ下がるのではなく、相手の道から横へ
        /// yieldSideClearM（0.6 m）以上どいた点で待ち、相手が通り過ぎたら出る**。
        /// 前は来た道へ下がっていたので、相手も同じ道を来ると向かい合ったまま溜まっていた。
        /// どく点が無ければ、手前の分かれ道（道順の1つ前の点）の外で待つ。
        /// 待ちすぎたら（yieldSideWaitMaxSec）あきらめて進む。
        /// </summary>
        void UpdateRobotYield(float dt, FrontSensor.Reading reading)
        {
            if (_robotYield)
            {
                _robotYieldS += dt;

                bool passed = _robotYieldTo == null || Gap(_robotYieldTo) >= spec.robotYieldClearM;
                float limit = spec.yieldOrderEnabled && spec.yieldSideWaitMaxSec > 0f
                    ? spec.yieldSideWaitMaxSec : spec.robotYieldMaxSec;
                bool tooLong = limit > 0f && _robotYieldS >= limit;
                if (passed || tooLong)
                {
                    if (ops != null)
                        ops.AddYield(_robotYieldStartS, _robotYieldAt,
                                     "ロボット" + robotId + "号機", RobotSpec.RankRobot,
                                     _robotYieldTo != null ? "ロボット" + _robotYieldTo.robotId + "号機" : "",
                                     _robotYieldS, _robotYieldStepped, passed);

                    _robotYield = false;
                    _robotYieldTo = null;
                    _robotYieldS = 0f;
                    _blockedByRobotS = 0f;
                    ResumeLeg(_phase == Phase.ToPass ? _passPoint : _tablePoint);
                }
                return;
            }

            // ふさいでいるのがもう1台か
            ServingRobotController other = reading.blocked && reading.nearestIsRobot
                ? NearestOtherRobot() : null;
            if (other == null) { _blockedByRobotS = 0f; return; }

            _blockedByRobotS += dt;
            if (_blockedByRobotS < spec.robotYieldAfterSec) return;

            // 番号の小さい台が先。自分が小さければ譲らない（相手がどく）
            if (robotId <= other.robotId) return;

            _robotYield = true;
            _robotYieldS = 0f;
            _blockedByRobotS = 0f;
            _robotYieldTo = other;
            _robotYieldStartS = ops != null ? ops.NowS : 0f;
            _robotYieldAt = transform.position;

            Vector3 goal;
            _robotYieldStepped = TryYieldSidePoint(other, out goal);
            if (!_robotYieldStepped) goal = YieldBackPoint(other);
            SetDestination(goal, true);
        }

        /// <summary>
        /// 相手の道から横へ yieldSideClearM 以上どいた点を探す（2026-10-23）。
        /// 相手がいまたどっている線に直角の向きへ、近い順に試す。
        /// ロボットが通れる面に乗り、寄せたあとも横の距離が足りているものだけを使う。
        /// </summary>
        bool TryYieldSidePoint(ServingRobotController other, out Vector3 point)
        {
            point = transform.position;
            // 依頼 v9：横へどく点（yieldOrder）は SiteRoutes のときだけ働く。
            // MapPlanner では 4 章のよける場所（地図のマスから選ぶ）を使う
            if (UseMapPlanner) return false;
            if (spec == null || !spec.yieldOrderEnabled || other == null) return false;

            Vector3 a, b;
            if (!other.CurrentLegSegment(out a, out b)) return false;

            if (_sideCandidates == null) _sideCandidates = new List<Vector3>();
            YieldSide.Candidates(a, b, transform.position,
                                 spec.yieldSideClearM + spec.Radius,
                                 spec.yieldSideSearchM, spec.yieldSideStepM,
                                 _sideCandidates);

            NavMeshHit hit;
            for (int i = 0; i < _sideCandidates.Count; i++)
            {
                if (!NavMesh.SamplePosition(_sideCandidates[i], out hit, spec.yieldSideStepM, RobotFilter))
                    continue;
                if (!YieldSide.FarEnough(a, b, hit.position, spec.yieldSideClearM + spec.Radius))
                    continue;
                point = hit.position;
                return true;
            }
            return false;
        }

        List<Vector3> _sideCandidates;

        // ---------------- 打ち切りで置き直す先（依頼 v8b の A）----------------

        /// <summary>
        /// 打ち切りで置き直した先（trips.csv の reset_to）。
        /// pass / standby1 / standby2 …。打ち切りでない往復は空
        /// </summary>
        public string ResetTo { get; private set; }

        /// <summary>
        /// 置く先を選ぶ。ほかの台が近く（直径＋余裕）にいない最初の点。
        ///   受け渡し口の点 → 待機場所を順に
        /// どこも空いていなければ、ほかの台からいちばん遠い待機場所
        /// （待機場所が無ければ受け渡し口の点）。
        /// </summary>
        Vector3 PickResetPoint()
        {
            float near = spec != null ? spec.diameter + ResetClearMarginM : 0.8f;

            IList<Vector3> standby = ops != null && ops.store != null
                ? ops.store.RobotStandbyPoints : null;

            // 1. 受け渡し口の点
            if (NearestRobotDistance(_passPoint) > near) { ResetTo = "pass"; return _passPoint; }

            // 2. 待機場所を順に
            if (standby != null)
            {
                for (int i = 0; i < standby.Count; i++)
                {
                    if (NearestRobotDistance(standby[i]) <= near) continue;
                    ResetTo = "standby" + (i + 1);
                    return standby[i];
                }

                // 3. どこも空いていない。ほかの台からいちばん遠い待機場所
                int best = -1;
                float bestD = -1f;
                for (int i = 0; i < standby.Count; i++)
                {
                    float d = NearestRobotDistance(standby[i]);
                    if (d <= bestD) continue;
                    best = i; bestD = d;
                }
                if (best >= 0)
                {
                    ResetTo = "standby" + (best + 1);
                    return standby[best];
                }
            }

            ResetTo = "pass";
            return _passPoint;
        }

        /// <summary>置く先として見るときの、直径に足す余裕（m）</summary>
        const float ResetClearMarginM = 0.3f;

        /// <summary>その点から、いちばん近いほかの台までの距離（m）。ほかに台が無ければ大きい値</summary>
        float NearestRobotDistance(Vector3 p)
        {
            p.y = 0f;
            float best = float.MaxValue;
            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;
                Vector3 q = o.transform.position; q.y = 0f;
                float d = Vector3.Distance(p, q);
                if (d < best) best = d;
            }
            return best;
        }

        /// <summary>相手の外周と自分の外周のあいだの空き（m）</summary>
        float Gap(ServingRobotController other)
        {
            Vector3 d = other.transform.position - transform.position;
            d.y = 0f;
            return d.magnitude - spec.Radius * 2f;
        }

        /// <summary>いちばん近いもう1台（譲る相手を決めるのに使う）</summary>
        ServingRobotController NearestOtherRobot()
        {
            ServingRobotController best = null;
            float bestD = float.MaxValue;
            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;
                Vector3 d = o.transform.position - transform.position;
                d.y = 0f;
                float m = d.magnitude;
                if (m >= bestD) continue;
                best = o; bestD = m;
            }
            return best;
        }

        /// <summary>
        /// 横へどく点が無かったときの逃げ道。
        /// 手前の分かれ道（道順の1つ前の点）まで戻って、そこで待つ。
        /// それも無ければ、相手と反対の向きへ robotYieldBackM 下がる。
        /// </summary>
        Vector3 YieldBackPoint(ServingRobotController other)
        {
            NavMeshHit hit;

            if (_legPoints != null && _legIndex > 0 && _legIndex - 1 < _legPoints.Count)
            {
                Vector3 back = _legPoints[_legIndex - 1];
                if (NavMesh.SamplePosition(back, out hit, spec.Radius * 2f, RobotFilter))
                    return hit.position;
            }

            Vector3 away = transform.position - other.transform.position;
            away.y = 0f;
            if (away.sqrMagnitude < 0.0001f) away = -transform.forward;
            away.Normalize();
            Vector3 p = transform.position + away * spec.robotYieldBackM;
            if (NavMesh.SamplePosition(p, out hit, spec.Radius * 2f, RobotFilter)) return hit.position;
            return transform.position;
        }

        /// <summary>
        /// 細い通路に入る前の譲り合い。
        /// 行き先の通路にもう1台がいたら、太い通路側の入口で待つ。
        /// 戻り（ToPass）では譲らない（出ないと詰まるため）。
        /// </summary>
        bool UpdateAisleYielding()
        {
            if (_phase != Phase.ToTable) return false;
            if (!spec.aisleYielding || !_hasAisleZone || _enteredAisle) return false;

            if (ContainsXZ(_aisleZone, transform.position))
            {
                _enteredAisle = true;
                if (_yieldHold)
                {
                    _yieldHold = false;
                    ResumeLeg(_tablePoint);
                }
                return false;
            }

            // 待ちすぎたら譲らずに進む（人の robotYieldMaxSec と同じ考え方。v5）。
            // この区間のあいだは、もう譲らない
            if (_yieldHold)
            {
                _yieldHoldS += Time.deltaTime;
                if (spec.aisleWaitMaxSec > 0f && _yieldHoldS >= spec.aisleWaitMaxSec)
                {
                    _yieldGaveUp = true;
                    _yieldHold = false;
                    _yieldHoldS = 0f;
                    ResumeLeg(_tablePoint);
                    return false;
                }
            }

            if (!_yieldGaveUp && MustWaitForAisle())
            {
                if (!_yieldHold)
                {
                    _yieldHold = true;
                    _yieldHoldS = 0f;
                    // 入口の点そのものに立つと、中の台が出られなくなる。
                    // 通路の外へ下がり、出てくる道をふさがない所で待つ
                    _aisleWaitPoint = AisleWaitPoint();
                    SetDestination(_aisleWaitPoint, true);
                }
            }
            else if (_yieldHold)
            {
                _yieldHold = false;
                _yieldHoldS = 0f;
                ResumeLeg(_tablePoint);
            }

            if (!_yieldHold) return false;

            Vector3 a = transform.position; a.y = 0f;
            Vector3 b = _aisleWaitPoint; b.y = 0f;
            return Vector3.Distance(a, b) <= spec.arriveRadius;
        }

        /// <summary>
        /// 通路の入口で待つ点（v5）。入口の点から通路の外へ下がり、横へずれる。
        /// 中の台が出てくる道をふさがないため。焼いた面に乗らなければ、下がるだけ・入口そのものの順に落とす。
        /// </summary>
        Vector3 AisleWaitPoint()
        {
            Vector3 entrance = _aisleEntrance; entrance.y = 0f;
            if (!_hasAisleZone) return entrance;

            Vector3 center = _aisleZone.center; center.y = 0f;
            Vector3 outward = entrance - center;
            outward.y = 0f;
            if (outward.sqrMagnitude < 0.0001f) return entrance;
            outward.Normalize();
            Vector3 side = new Vector3(-outward.z, 0f, outward.x);

            Vector3 back = entrance + outward * spec.aisleWaitSetbackM;
            Vector3[] tries =
            {
                back + side * spec.aisleWaitSideM,
                back - side * spec.aisleWaitSideM,
                back
            };

            NavMeshHit hit;
            for (int i = 0; i < tries.Length; i++)
                if (NavMesh.SamplePosition(tries[i], out hit, spec.Radius, RobotFilter)) return hit.position;

            return entrance;
        }

        /// <summary>
        /// 途中までの経路に縛られた止まりから出る（B7）。
        ///   1. 引き直しの間隔（repathRetrySec）を待たずに強く引き直す
        ///   2. それでも最後までつながらなければ、いちど通路の入口を目指して出る
        /// どちらも「経路の引き直し」なので、この止まりは resolved が「経路を引き直した」になる。
        /// cause_detail には「途中までの経路」の印を足す。
        /// </summary>
        void EscapePartialPath()
        {
            _lastEscapeAtNoPathS = _open.noPathS;
            _replannedInStop = true;
            _open.replans++;
            MarkPartialPath();

            // 1. 間隔を無視して引き直す
            SetDestination(_destination, true);
            if (_pathValid && _cornerIndex < _corners.Length) return;

            // 2. それでも途中まで。通路の入口へ出る
            if (!spec.escapeToAisleEntranceOnPartialPath) return;

            Vector3 here = transform.position; here.y = 0f;
            Vector3 exit = _aisleEntrance; exit.y = 0f;
            if (Vector3.Distance(here, exit) <= spec.arriveRadius) return;

            _escapeToEntrance = true;
            SetDestination(_aisleEntrance, true);
        }

        /// <summary>通路の入口へ出ている途中。着いたら、もとの行き先をもう一度目指す</summary>
        void UpdateEscape()
        {
            if (!_escapeToEntrance) return;

            Vector3 here = transform.position; here.y = 0f;
            Vector3 exit = _aisleEntrance; exit.y = 0f;
            if (Vector3.Distance(here, exit) > spec.arriveRadius) return;

            _escapeToEntrance = false;
            ResumeLeg(_phase == Phase.ToPass ? _passPoint : _tablePoint);
        }

        /// <summary>この止まりに「途中までの経路」の印をつける（1回だけ）</summary>
        void MarkPartialPath()
        {
            if (!_recordingStop) return;
            string d = _open.causeDetail;
            if (!string.IsNullOrEmpty(d) && d.Contains(PartialPathMark)) return;
            _open.causeDetail = string.IsNullOrEmpty(d) ? PartialPathMark : d + "／" + PartialPathMark;
        }

        bool MustWaitForAisle()
        {
            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;

                // その通路の中にいる台がいれば入らない
                if (ContainsXZ(_aisleZone, o.transform.position)) { _aisleBlockerName = o.name; return true; }

                // 同じ通路を目指していて、まだ入っていない台。番号の小さい方が先に入る
                if (o._phase == Phase.ToTable && !o._enteredAisle && o._hasAisleZone &&
                    SameZone(o._aisleZone, _aisleZone) && o.robotId < robotId)
                {
                    _aisleBlockerName = o.name;
                    return true;
                }
            }
            _aisleBlockerName = "";
            return false;
        }

        static bool ContainsXZ(Bounds b, Vector3 p)
        {
            return p.x >= b.min.x && p.x <= b.max.x && p.z >= b.min.z && p.z <= b.max.z;
        }

        static bool SameZone(Bounds a, Bounds b)
        {
            Vector3 d = a.center - b.center;
            d.y = 0f;
            return d.sqrMagnitude < 0.0025f;   // 5 cm 以内なら同じ通路
        }

        /// <summary>
        /// 動けなくなった。スタッフを呼んで、来るまで待つ（v5 3章）。
        /// Stuck は「この往復で1回でも動けなくなったか」の印なので、1往復に1回だけ立てる。
        /// 呼ぶ方は何度でも起こりうる（動かしてもらった先でまた動けなくなる）ので、
        /// 呼び出しは Stuck の印とは別に毎回出す。
        /// </summary>
        void EnterStuck()
        {
            if (_phase == Phase.StuckWait) return;   // すでに待っている
            Stuck = true;
            _mapYield = false;
            _mapYieldTo = null;
            _headOnS = 0f;
            if (_inStop) EndStopRecord(_currentStopS, StopResolve.Stuck);
            // 動けなくなった分は「自分で動き出した止まり」には数えない
            _inStop = false;
            _currentStopS = 0f;
            _stuckWaitTimer = 0f;
            _escapeToEntrance = false;
            _lastEscapeAtNoPathS = 0f;
            StuckCount++;
            _phaseBeforeStuck = _phase;
            _phase = Phase.StuckWait;
            if (ops != null) ops.PostRescue(this);
        }

        /// <summary>
        /// スタッフが来て rescue_s 動かし終わった（StaffAgent が呼ぶ。v5 3章）。
        /// 止まっていた所から、同じ道順の続きを走り直す。道順そのものは変えない。
        /// </summary>
        public void Rescued()
        {
            if (_phase != Phase.StuckWait) return;
            if (ops != null) ops.RescueFinished(_stuckWaitTimer);
            _stuckWaitTimer = 0f;
            _stoppedTimer = 0f;
            _lastReplanAtStopped = 0f;
            _clearTimer = 0f;
            bool toPass = _phaseBeforeStuck == Phase.ToPass;
            _phase = toPass ? Phase.ToPass : Phase.ToTable;
            ResumeLeg(toPass ? _passPoint : _tablePoint);
        }

        /// <summary>止まった原因を、前のセンサーが見たものから決める</summary>
        StopCause CauseOf(FrontSensor.Reading r)
        {
            if (r.nearestIsRobot) return StopCause.OtherRobot;
            if (r.nearestIsChair) return StopCause.Chair;
            if (r.nearestIsPerson)
            {
                if (r.nearestPersonStanding) return StopCause.PersonStanding;
                return r.nearestPersonKind == PersonKind.Staff ? StopCause.Staff : StopCause.Guest;
            }
            if (r.sawRobot) return StopCause.OtherRobot;
            if (r.sawChair) return StopCause.Chair;
            if (r.sawPerson) return StopCause.Guest;
            return StopCause.Unknown;
        }

        /// <summary>
        /// 行き先への道が引けないときの原因。
        /// ふさいでいるもの（引かれた椅子・もう1台のロボット）が見つかれば、その原因にする。
        /// 何も見つからないときだけ「通路の幅が足りない」にする。
        /// </summary>
        StopCause CauseOfNoPath(out string detail)
        {
            StopCause cause;
            string name;

            // 行き止まりの点 → 行き先 → 自分のまわり、の順に探す
            if (_hasPathEnd && FindBlocker(_pathEndPoint, out cause, out name))
            {
                detail = "経路なし：" + name;
                return cause;
            }
            if (FindBlocker(_destination, out cause, out name))
            {
                detail = "経路なし：" + name;
                return cause;
            }
            if (FindBlocker(transform.position, out cause, out name))
            {
                detail = "経路なし：" + name;
                return cause;
            }

            detail = "経路なし：ふさいでいるものが見つからない";
            return StopCause.NoPath;
        }

        const int BlockMax = 32;
        readonly Collider[] _blockHits = new Collider[BlockMax];

        /// <summary>
        /// その点のまわりで、道をふさぎうるもの（引かれた椅子・ほかのロボット）のうち
        /// いちばん近いものを探す。見つかれば true。
        /// </summary>
        bool FindBlocker(Vector3 around, out StopCause cause, out string name)
        {
            cause = StopCause.NoPath;
            name = "";

            Vector3 center = new Vector3(around.x, spec.clearanceProbeHeight, around.z);
            int n = Physics.OverlapSphereNonAlloc(center, spec.pathBlockSearchRadius, _blockHits,
                                                  ~0, QueryTriggerInteraction.Ignore);
            if (n > BlockMax) n = BlockMax;

            float best = float.MaxValue;
            for (int i = 0; i < n; i++)
            {
                Collider col = _blockHits[i];
                if (col == null) continue;

                Chair chair = col.GetComponentInParent<Chair>();
                if (chair != null)
                {
                    // 引かれていない椅子は NavMesh を削らないので、道をふさがない
                    if (!chair.IsPulled) continue;
                    float d = Vector3.Distance(center, chair.transform.position);
                    if (d < best)
                    {
                        best = d;
                        cause = StopCause.Chair;
                        name = chair.transform.parent != null
                            ? chair.transform.parent.name + "/" + chair.name : chair.name;
                    }
                    continue;
                }

                ServingRobotController other = col.GetComponentInParent<ServingRobotController>();
                if (other != null && other != this)
                {
                    float d = Vector3.Distance(center, other.transform.position);
                    if (d < best)
                    {
                        best = d;
                        cause = StopCause.OtherRobot;
                        name = other.name;
                    }
                }
            }
            return best < float.MaxValue;
        }

        // ---- 道を引いたときの中身（調べるためだけ。動きは変えない）----
        Vector3 _lastSolveFrom, _lastSolveTo;
        string _lastSolveStatus = "";
        int _lastSolveCorners;
        float _lastOffMeshM = -1f;

        /// <summary>
        /// いま開いている止まりの記録に、最後に道を引いたときの中身を写す。
        /// 止まっているあいだは 10 秒ごとに引き直すので、最後の1回ぶんが残る。
        /// 記録するだけで、止まる・動くの決まりには使わない。
        /// </summary>
        void RecordSolveIntoOpenStop()
        {
            if (!_recordingStop) return;
            _open.solveFrom = _lastSolveFrom;
            _open.solveTo = _lastSolveTo;
            _open.solveStatus = _lastSolveStatus;
            _open.solveCorners = _lastSolveCorners;
            _open.offMeshM = _lastOffMeshM;
        }

        /// <summary>
        /// この止まりの終わり方。
        /// 依頼 v9 の 3 章（回り込んで動き出した）・4 章（よける場所へ動いた）が働いたら、
        /// 「経路を引き直した」よりそちらを出す
        /// </summary>
        StopResolve ResolveOfStop()
        {
            if (_hasResolveOverride) return _resolveOverride;
            return _replannedInStop ? StopResolve.Replanned : StopResolve.SelfResume;
        }

        void BeginStopRecord(StopCause cause, string detail, bool pathBlocked)
        {
            _hasResolveOverride = false;
            _open = new StopEvent();
            _open.startTimeS = Time.time;
            _open.position = transform.position;
            _open.cause = cause;
            _open.causeDetail = detail == null ? "" : detail;
            _open.pathBlocked = pathBlocked;
            float left, right;
            MeasureSideClearance(out left, out right);
            _open.clearanceLeftM = left;
            _open.clearanceRightM = right;
            _recordingStop = true;
            RecordSolveIntoOpenStop();   // 止まり始めの時点の、直前に引いた中身
        }

        void EndStopRecord(float durationS, StopResolve resolve)
        {
            if (!_recordingStop) return;
            _recordingStop = false;
            _open.durationS = durationS;
            _open.resolve = resolve;
            _stopEvents.Add(_open);
        }

        /// <summary>
        /// 止まった位置での、左右の空き（ロボットの外周から壁・席・椅子まで）。
        /// 人とロボットは数えない（動くもののため）。
        /// </summary>
        void MeasureSideClearance(out float left, out float right)
        {
            left = ProbeSide(-transform.right);
            right = ProbeSide(transform.right);
        }

        const int ProbeMax = 16;
        readonly RaycastHit[] _probe = new RaycastHit[ProbeMax];

        float ProbeSide(Vector3 dir)
        {
            float range = spec.clearanceProbeRange;
            Vector3 origin = transform.position + Vector3.up * spec.clearanceProbeHeight;
            int n = Physics.RaycastNonAlloc(origin, dir, _probe, range, ~0, QueryTriggerInteraction.Ignore);
            if (n > ProbeMax) n = ProbeMax;

            float best = range;
            for (int i = 0; i < n; i++)
            {
                Collider col = _probe[i].collider;
                if (col == null) continue;
                if (col.GetComponentInParent<PersonBody>() != null) continue;
                if (col.GetComponentInParent<ServingRobotController>() != null) continue;
                if (_probe[i].distance < best) best = _probe[i].distance;
            }
            return Mathf.Max(0f, best - spec.Radius);
        }

        void CheckArrival()
        {
            if (_yieldHold) return;         // いまの行き先は通路の入口なので、着いても卓ではない
            if (_escapeToEntrance) return;  // 途中までの経路から出ている途中（B7）。行き先は通路の入口
            if (_mapYield) return;          // いまの行き先はよける場所（依頼 v9 の 4 章）

            Vector3 here = transform.position; here.y = 0f;
            Vector3 goal = _hasDestinationOnMesh ? _destinationOnMesh : _destination; goal.y = 0f;

            // v5b: 道順の途中の点は、少し手前でも「着いた」として次の点へ進む。
            // 最後の点（卓の止まる位置・受け渡し口）は今までどおり arriveRadius で見る
            bool midWaypoint = _legPoints != null && _legIndex + 1 < _legPoints.Count;
            float reach = midWaypoint
                ? Mathf.Max(spec.arriveRadius, spec.waypointReachDistance)
                : spec.arriveRadius;
            bool near = Vector3.Distance(here, goal) <= reach;

            // 最後までつながった経路の角を使い切ったら、着いたとみなす
            bool consumed = _pathValid && _corners.Length > 0 && _cornerIndex >= _corners.Length;
            if (!near && !consumed) return;

            if (AdvanceWaypoint()) return;     // まだ区間の途中

            if (_phase == Phase.ToTable)
            {
                _phase = Phase.Dwell;
                _dwellTimer = 0f;
                _speed = 0f;
                StopsCompleted = _stopIndex + 1;   // この卓には届いた

                // 卓に着いた時点を残して知らせる（依頼 v8 の 1）。
                // サイトの試算と同じで、ここで皿は「届いた」。
                // 受け渡しの 25 秒（tableDwellSec）はこのあと
                int at = _stopIndex;
                if (at >= 0 && at < _stopArrivedS.Count)
                    _stopArrivedS[at] = ops != null ? ops.NowS : 0f;
                if (StopArrived != null) StopArrived(this, at);
            }
            else if (_phase == Phase.ToPass)
            {
                Finish();
            }
        }

        void Finish()
        {
            _phase = Phase.Done;
            _speed = 0f;
            _legPoints = null;
            _mapYield = false;
            _mapYieldTo = null;
            // 往復が実際に終わった店の中の時刻（卓での25秒を含む。依頼 v9 の 5 章）
            TripEndS = ops != null ? ops.NowS : -1f;
            // まだ呼んだままのスタッフがいたら、呼び出しを下げる（v5 3章）
            if (ops != null) ops.CancelRescue(this);
            if (_inWait) { _inWait = false; EndStopRecord(_currentWaitS, StopResolve.AisleWait); }
            if (_inStop)
            {
                _inStop = false;
                StopCount++;
                _stopDurations.Add(_currentStopS);
                EndStopRecord(_currentStopS, ResolveOfStop());
            }
            if (float.IsInfinity(MinClearanceM)) MinClearanceM = -1f;   // 人がいなかった
            if (TripCompleted != null) TripCompleted(this);
        }

        // ---- サイトの道順（v5b）----
        RouteBook _routes;
        List<Vector3> _legPoints;         // いまの区間の曲がり角の点（最後が行き先そのもの）
        int _legIndex;
        bool _legIsNarrow;                // この区間が 0.8 m の道か（記録用）
        int _snapFallbacks;               // 止まる位置を 0.1 m で面に寄せられず、広げた回数

        /// <summary>いまの区間が 0.8 m の道か（記録用。v5b）</summary>
        public bool UsedNarrowAisle { get { return _legIsNarrow; } }

        /// <summary>
        /// この往復のどこかで 0.8 m の道を使ったか（trips.csv の via_narrow。v5 3章）。
        /// 1往復で何卓も回るので、区間ごとの印（UsedNarrowAisle）とは別に、
        /// 1回でも使ったら立てたままにする。
        /// </summary>
        public bool LegWasNarrow { get; private set; }

        /// <summary>道順の点を 0.1 m で面に寄せられなかった回数（報告用。v5b）</summary>
        public int RouteSnapFallbacks { get { return _snapFallbacks; } }

        /// <summary>
        /// 区間（受け渡し口→卓 / 卓→卓 / 卓→受け渡し口）を始める（v5b）。
        /// サイトの道順があればその点を順にたどり、無ければ行き先まで直接向かう。
        /// いまいる所に近い先頭の点は飛ばす（受け渡し口で始めるときなど）。
        /// </summary>
        void BeginLeg(List<Vector3> points, Vector3 fallbackTarget, bool narrow)
        {
            // サイトの道順をたどったときの長さを足す（走りには使わない。trips.csv の site_route_m）
            SiteRouteM += _lastRouteLenM;
            _lastRouteLenM = 0f;

            _legIsNarrow = narrow;
            if (narrow) LegWasNarrow = true;
            _legPoints = null;
            _legIndex = 0;
            _onDetour = false;
            _blockedFrontS = 0f;

            // 依頼 v9: 地図の上で、行き先まで自分で道を引く。サイトの routes の点はたどらない。
            // 「狭い道を使ったか」は、引いた道に 1 のマスが入ったかで決まる（narrow は使わない）
            if (UseMapPlanner)
            {
                _legIsNarrow = false;
                PlanOnMap(fallbackTarget, MarkMode.None, true, false);
                return;
            }

            if (spec != null && spec.followSiteRoutes && points != null && points.Count >= 2)
            {
                _legPoints = points;
                Vector3 here = transform.position; here.y = 0f;
                // 先頭の点は、いまいる所と同じことが多い（受け渡し口）。近ければ飛ばす
                while (_legIndex < _legPoints.Count - 1)
                {
                    Vector3 p = _legPoints[_legIndex]; p.y = 0f;
                    if (Vector3.Distance(here, p) > spec.waypointReachDistance) break;
                    _legIndex++;
                }
                SetDestination(_legPoints[_legIndex], true);
                return;
            }
            SetDestination(fallbackTarget, true);
        }

        /// <summary>いまの区間の、いま目指している点をもう一度行き先にする（v5b）</summary>
        void ResumeLeg(Vector3 fallbackTarget)
        {
            if (_legPoints != null && _legIndex < _legPoints.Count)
            {
                SetDestination(_legPoints[_legIndex], true);
                return;
            }
            SetDestination(fallbackTarget, true);
        }

        /// <summary>
        /// 道順の途中の点に着いたら、次の点へ進む（v5b）。
        /// 戻り値 true なら、まだ区間の途中（卓にも受け渡し口にも着いていない）。
        /// </summary>
        bool AdvanceWaypoint()
        {
            if (_legPoints == null || _legIndex + 1 >= _legPoints.Count) return false;
            _legIndex++;
            SetDestination(_legPoints[_legIndex], true);
            return true;
        }

        /// <summary>
        /// 直前に引いたサイトの道順の長さ（m）。BeginLeg が SiteRouteM に足す。
        /// MapPlanner でも「サイトの道順をたどったらこの長さ」として記録に残す（依頼 v9 の 5 章）
        /// </summary>
        float _lastRouteLenM;

        /// <summary>その卓へ行く道順（受け渡し口から / 直前の卓から）</summary>
        List<Vector3> RouteTo(int fromTableId, int toTableId, out bool narrow)
        {
            narrow = false;
            _lastRouteLenM = 0f;
            if (_routes == null) return null;
            RouteBook.Route used;
            List<Vector3> pts;
            if (fromTableId <= 0)
            {
                pts = _routes.PointsFromPass(toTableId, out used);
            }
            else
            {
                bool reversed;
                pts = _routes.PointsBetween(fromTableId, toTableId, out used, out reversed);
            }
            if (used != null) { narrow = used.IsNarrow; _lastRouteLenM = used.length; }
            return pts;
        }

        /// <summary>卓 → 受け渡し口の道順</summary>
        List<Vector3> RouteToPass(int fromTableId, out bool narrow)
        {
            narrow = false;
            _lastRouteLenM = 0f;
            if (_routes == null) return null;
            RouteBook.Route used;
            List<Vector3> pts = _routes.PointsToPass(fromTableId, out used);
            if (used != null) { narrow = used.IsNarrow; _lastRouteLenM = used.length; }
            return pts;
        }

        void SetDestination(Vector3 point) { SetDestination(point, false); }

        void SetDestination(Vector3 point, bool force)
        {
            // 依頼 v9: 地図の上で引く（NavMesh はロボットの道探しには使わない）
            if (UseMapPlanner)
            {
                PlanOnMap(point, MarkMode.None, force);
                return;
            }

            // 引き直しの間隔を空ける。いまの経路は消さない
            if (_repathCooldown > 0f && !force)
            {
                _destination = point;
                return;
            }
            _repathCooldown = spec.repathRetrySec;
            if (_path == null) _path = new NavMeshPath();

            _destination = point;
            _pathValid = false;
            _corners = new Vector3[0];
            _cornerIndex = 0;

            // v5b: 道順の最後の点（止まる位置そのもの）だけは寄せない（0.1 m 以下）。
            // 途中の点・通路の入口・道順を使わないときは、今までどおり広めに寄せる。
            bool lastRoutePoint =
                _legPoints != null && _legIndex == _legPoints.Count - 1 &&
                Vector3.Distance(point, _legPoints[_legIndex]) < 0.001f;
            float snap = lastRoutePoint
                ? Mathf.Min(spec.routePointSnapM, spec.Radius * 4f)
                : spec.Radius * 4f;
            SolvePath(point, RobotFilter, snap);
        }

        /// <summary>
        /// 行き先まで道を引き、_path・_corners に入れる。
        /// snap は、点を焼いた面へ寄せるときに探す半径（m）。
        /// 止まる位置がその半径で面に乗らなければ、広げて引き直し、回数を数える（v5b）。
        /// </summary>
        void SolvePath(Vector3 point, NavMeshQueryFilter filter, float snap)
        {
            if (_path == null) _path = new NavMeshPath();

            NavMeshHit hit;
            Vector3 from = transform.position;
            // いまの位置が焼いた面からどれだけ離れているか（調べるためだけ。動きは変えない）
            _lastOffMeshM = NavMesh.SamplePosition(transform.position, out hit, spec.Radius * 4f, filter)
                ? Vector3.Distance(transform.position, hit.position) : -1f;
            if (_lastOffMeshM >= 0f) from = hit.position;

            Vector3 to = point;
            if (NavMesh.SamplePosition(to, out hit, snap, filter)) to = hit.position;
            else if (snap < spec.Radius * 4f)
            {
                _snapFallbacks++;
                if (NavMesh.SamplePosition(to, out hit, spec.Radius * 4f, filter)) to = hit.position;
            }

            _lastSolveFrom = from;
            _lastSolveTo = to;

            if (!NavMesh.CalculatePath(from, to, filter, _path) || _path.corners.Length == 0)
            {
                _lastSolveStatus = "引けない";
                _lastSolveCorners = _path != null ? _path.corners.Length : 0;
                RecordSolveIntoOpenStop();
                return;
            }

            bool complete = _path.status == NavMeshPathStatus.PathComplete;
            _lastSolveStatus = _path.status.ToString();
            _lastSolveCorners = _path.corners.Length;
            RecordSolveIntoOpenStop();

            _destinationOnMesh = to;
            _hasDestinationOnMesh = true;
            _corners = _path.corners;
            _cornerIndex = 0;
            _pathValid = complete;
            if (!complete && _corners.Length > 0)
            {
                _pathEndPoint = _corners[_corners.Length - 1];
                _hasPathEnd = true;
            }
            else _hasPathEnd = false;

            // 足もとの角は飛ばす
            Vector3 here = transform.position; here.y = 0f;
            while (_cornerIndex < _corners.Length)
            {
                Vector3 c = _corners[_cornerIndex]; c.y = 0f;
                if (Vector3.Distance(here, c) > spec.cornerReachDistance) break;
                _cornerIndex++;
            }
        }

        // ================= 依頼 v9：地図の上で自分で道を引く =================

        /// <summary>道を引くときに差し込むもの</summary>
        enum MarkMode
        {
            None,     // そのまま引く
            Detour,   // 近くの人・ロボットを通れないマスとして置く（3 章）
            Avoid     // 優先が高い台の先の道を重くする（4 章）
        }

        /// <summary>
        /// 地図の上で、行き先まで道を引く（依頼 v9 の 2 章）。
        /// 引き直しは 1 台につき replanMinIntervalSec（0.5 秒）に 1 回まで（force で無視できる）。
        /// </summary>
        bool PlanOnMap(Vector3 point, MarkMode marks, bool force)
        {
            return PlanOnMap(point, marks, force, true);
        }

        /// <summary>
        /// isReplan が false のときは「引き直し」に数えない
        /// （区間を始めるときの1回目は引き直しではない）
        /// </summary>
        bool PlanOnMap(Vector3 point, MarkMode marks, bool force, bool isReplan)
        {
            if (_repathCooldown > 0f && !force)
            {
                _destination = point;
                return false;
            }
            _repathCooldown = Mathf.Max(0.01f, spec.replanMinIntervalSec);

            _destination = point;
            _pathValid = false;
            _hasPathEnd = false;
            _corners = NoCorners;
            _cornerIndex = 0;

            _planner.ClearMarks();
            if (marks == MarkMode.Detour) AddDetourMarks();
            else
            {
                // ふつうに引き直したら、もう回り込みの道ではない（モニターの出し分けに使う）
                _onDetour = false;
                if (marks == MarkMode.Avoid) AddAvoidMarks();
            }

            GridPlanner.Status s = _planner.Plan(transform.position, point, spec.PlannerSettings());
            return AfterMapPlan(s, isReplan);
        }

        /// <summary>引いた結果を受け取る。引けたら true</summary>
        bool AfterMapPlan(GridPlanner.Status status, bool isReplan)
        {
            if (isReplan)
            {
                Replans++;
                if (_recordingStop) _open.replans++;
            }
            if (_planner.LastPlanMs > PlanMsMax) PlanMsMax = _planner.LastPlanMs;
            if (_planner.LastStartWasBlocked) MapSnapStarts++;

            // stops.csv に出す中身（調べるためだけ。動きは変えない）
            _lastSolveFrom = transform.position;
            _lastSolveTo = _destination;
            _lastSolveStatus = _planner.StatusText;
            _lastSolveCorners = _planner.Points.Count;
            _lastOffMeshM = _planner.LastStartSnapM;
            RecordSolveIntoOpenStop();

            if (status != GridPlanner.Status.Ok || _planner.Points.Count == 0)
            {
                PlanFails++;
                return false;
            }

            _corners = new Vector3[_planner.Points.Count];
            for (int i = 0; i < _corners.Length; i++) _corners[i] = _planner.Points[i];
            _cornerIndex = 0;
            _pathValid = true;
            _hasDestinationOnMesh = true;
            _destinationOnMesh = _corners[_corners.Length - 1];

            if (_planner.UsedNarrow) { _legIsNarrow = true; LegWasNarrow = true; }

            // 足もとの角は飛ばす（最後の1つは残す。着いたと見るのに使う）
            Vector3 here = transform.position; here.y = 0f;
            while (_cornerIndex < _corners.Length - 1)
            {
                Vector3 c = _corners[_cornerIndex]; c.y = 0f;
                if (Vector3.Distance(here, c) > spec.cornerReachDistance) break;
                _cornerIndex++;
            }
            return true;
        }

        /// <summary>
        /// 回り込むために、近くの人・ほかのロボットをその 1 回だけ通れないマスとして置く（3 章）。
        /// 人は 人の半径＋ロボットの半径＋detourPersonMarginM、
        /// ロボットは 2 台の半径の和＋detourPersonMarginM。
        /// </summary>
        void AddDetourMarks()
        {
            Vector3 me = transform.position; me.y = 0f;
            float near = spec.detourNearM;
            float margin = spec.detourPersonMarginM;

            IReadOnlyList<PersonBody> people = PersonBody.All;
            for (int i = 0; i < people.Count; i++)
            {
                PersonBody p = people[i];
                if (p == null || p.kind == PersonKind.Seated) continue;
                Vector3 q = p.transform.position; q.y = 0f;
                if (Vector3.Distance(me, q) > near) continue;
                _planner.BlockCircle(q, p.radius + spec.Radius + margin);
            }

            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;
                Vector3 q = o.transform.position; q.y = 0f;
                if (Vector3.Distance(me, q) > near) continue;
                _planner.BlockCircle(q, spec.Radius * 2f + margin);
            }
        }

        /// <summary>
        /// 優先が高い台の先の道を重くして、相手の道とかぶらない道を選ばせる（4 章）。
        /// 相手の体は通れないマスとして置く。
        /// </summary>
        void AddAvoidMarks()
        {
            ServingRobotController other = _mapYieldTo;
            if (other == null) return;

            other.PathAhead(spec.robotPathAheadM, _aheadOther);
            float costR = spec.Radius * 2f + spec.robotPathCostMarginM;
            for (int i = 1; i < _aheadOther.Count; i++)
                _planner.CostLine(_aheadOther[i - 1], _aheadOther[i], costR, spec.robotPathCostAdd);

            Vector3 q = other.transform.position; q.y = 0f;
            _planner.BlockCircle(q, spec.Radius * 2f);
        }

        /// <summary>いまの道の、ここから行き先までの残りの長さ（m）</summary>
        float RemainingPathM()
        {
            if (_corners.Length == 0 || _cornerIndex >= _corners.Length)
                return Vector3.Distance(Flat(transform.position), Flat(_destination));

            float sum = 0f;
            Vector3 from = Flat(transform.position);
            for (int i = _cornerIndex; i < _corners.Length; i++)
            {
                Vector3 to = Flat(_corners[i]);
                sum += Vector3.Distance(from, to);
                from = to;
            }
            return sum;
        }

        static Vector3 Flat(Vector3 v) { v.y = 0f; return v; }

        /// <summary>
        /// 前がふさがって止まっているとき（3 章）。
        /// detourAfterSec（3 秒）たっても空かなければ、回り込む道を探す。
        /// 引けなければ待ち、3 秒ごとに探し直す。
        /// 止まる位置そのものに人が立っているときは、回り込まずに待つ。
        /// </summary>
        void UpdateDetour(float dt, bool blocked)
        {
            if (!blocked)
            {
                _blockedFrontS = 0f;
                return;
            }
            _blockedFrontS += dt;
            if (_blockedFrontS < spec.detourAfterSec) return;
            _blockedFrontS = 0f;                       // 次の 3 秒を数え直す
            if (PersonOnGoal()) return;
            TryDetour();
        }

        /// <summary>行き先（止まる位置）そのものに人が立っているか</summary>
        bool PersonOnGoal()
        {
            Vector3 goal = Flat(_destination);
            IReadOnlyList<PersonBody> people = PersonBody.All;
            for (int i = 0; i < people.Count; i++)
            {
                PersonBody p = people[i];
                if (p == null || p.kind == PersonKind.Seated) continue;
                Vector3 q = p.transform.position; q.y = 0f;
                if (Vector3.Distance(goal, q) <= p.radius + spec.Radius) return true;
            }
            return false;
        }

        /// <summary>
        /// 回り込む道を引いてみる。
        /// 延びた距離が大きすぎる道（もとの残りの道の detourMaxFactor 倍＋detourExtraAddM より長い）は
        /// 取らずに待つ。引けなければ、もとの道に戻す。
        /// </summary>
        bool TryDetour()
        {
            float remain = RemainingPathM();
            float limit = remain * spec.detourMaxFactor + spec.detourExtraAddM;

            Vector3[] prevCorners = _corners;
            int prevIndex = _cornerIndex;
            bool prevValid = _pathValid;
            Vector3 prevOnMesh = _destinationOnMesh;
            bool prevHasOnMesh = _hasDestinationOnMesh;

            if (PlanOnMap(_destination, MarkMode.Detour, true) && _planner.LastLengthM <= limit)
            {
                Detours++;
                _onDetour = true;
                SetResolveOverride(StopResolve.Detoured);
                return true;
            }

            // 取らない。もとの道に戻す
            _corners = prevCorners;
            _cornerIndex = prevIndex;
            _pathValid = prevValid;
            _destinationOnMesh = prevOnMesh;
            _hasDestinationOnMesh = prevHasOnMesh;
            return false;
        }

        /// <summary>この止まりの終わり方を上書きする（回り込み・よけ）</summary>
        void SetResolveOverride(StopResolve r)
        {
            _hasResolveOverride = true;
            _resolveOverride = r;
            _replannedInStop = true;
        }

        // ---------------- ロボットどうし（4 章）----------------

        /// <summary>
        /// 優先の順で、片方が道をゆずる（依頼 v9 の 4 章・エイイチの決定）。
        ///   1. 料理を載せているロボットが先
        ///   2. 空で戻るロボットは後
        ///   3. 同じ状態なら 1 号機が先
        ///
        /// 優先が低い側は、相手が robotReplanNearM（3 m）以内に来て先の道がかぶったら、
        /// 相手の道を重くして引き直す。かぶらない道が引けないときは、
        /// 近く（robotYieldSpotSearchM）でよける場所を探してそこで待つ（最大 robotYieldMaxSec）。
        /// 向かい合って headOnStuckSec（10 秒）止まったら、引き直しを待たずによける場所へ動く。
        ///
        /// 優先が高い側はここで何もしない（相手を気にせず道を引く）。
        /// どちらも止まり続けないよう、前のセンサーの止まりからは 3 章の回り込みで抜ける。
        /// </summary>
        void UpdateRobotYieldMap(float dt, FrontSensor.Reading reading)
        {
            if (_mapYield)
            {
                _mapYieldS += dt;
                bool passed = _mapYieldTo == null || Gap(_mapYieldTo) >= spec.robotYieldClearM;
                bool tooLong = spec.robotYieldMaxSec > 0f && _mapYieldS >= spec.robotYieldMaxSec;
                if (!passed && !tooLong) return;

                if (ops != null)
                    ops.AddYield(_mapYieldStartS, _mapYieldAt,
                                 "ロボット" + robotId + "号機", RobotSpec.RankRobot,
                                 _mapYieldTo != null ? "ロボット" + _mapYieldTo.robotId + "号機" : "",
                                 _mapYieldS, _mapYieldStepped, passed);

                _mapYield = false;
                _mapYieldTo = null;
                _mapYieldS = 0f;
                _headOnS = 0f;
                ResumeLeg(GoalOfPhase());
                return;
            }

            ServingRobotController other = HigherPriorityNear();
            if (other == null)
            {
                _headOnS = 0f;
                _avoidTarget = null;       // この出会いは終わった
                return;
            }

            // 向かい合って止まっているか
            bool stopped = _speed <= spec.stoppedSpeedEpsilon;
            bool headOn = stopped && IsHeadOn(other);
            if (headOn) _headOnS += dt; else _headOnS = 0f;

            // ここから先は道を引く。引き直しは 1 台につき replanMinIntervalSec（0.5 秒）に 1 回まで。
            // 毎コマ引くと、1 回 10〜80 ms の道探しが回を押しつぶす
            if (_repathCooldown > 0f) return;

            // 2台が向かい合って headOnStuckSec（10 秒）止まったら、よける場所へ動く
            if (spec.headOnStuckSec > 0f && _headOnS >= spec.headOnStuckSec)
            {
                StartMapYield(other);
                return;
            }

            if (!PathsOverlap(other)) return;

            // 相手の道とかぶらない道があれば、そちらを選ぶ
            _mapYieldTo = other;
            if (TryAvoidPlan(other)) return;
            _mapYieldTo = null;

            // かぶらない道が引けない。止まっているならよける場所へ動く
            if (stopped) StartMapYield(other);
        }

        Vector3 GoalOfPhase()
        {
            return _phase == Phase.ToPass ? _passPoint : _tablePoint;
        }

        /// <summary>
        /// 近く（robotReplanNearM）にいて、自分より先に通る台。いなければ null。
        /// いちばん近い 1 台だけを見る
        /// </summary>
        ServingRobotController HigherPriorityNear()
        {
            ServingRobotController best = null;
            float bestD = spec.robotReplanNearM;
            Vector3 me = Flat(transform.position);
            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;
                if (!YieldsTo(o)) continue;
                float d = Vector3.Distance(me, Flat(o.transform.position));
                if (d > bestD) continue;
                best = o; bestD = d;
            }
            return best;
        }

        /// <summary>2 台が向かい合っているか（向きの内積が headOnDotMax 以下）</summary>
        bool IsHeadOn(ServingRobotController other)
        {
            Vector3 a = transform.forward; a.y = 0f;
            Vector3 b = other.transform.forward; b.y = 0f;
            if (a.sqrMagnitude < 0.0001f || b.sqrMagnitude < 0.0001f) return false;
            return Vector3.Dot(a.normalized, b.normalized) <= spec.headOnDotMax;
        }

        /// <summary>自分の先の道と、相手の先の道がかぶっているか</summary>
        bool PathsOverlap(ServingRobotController other)
        {
            PathAhead(spec.robotPathAheadM, _aheadMine);
            other.PathAhead(spec.robotPathAheadM, _aheadOther);
            float limit = spec.Radius * 2f + spec.robotPathCostMarginM;
            return PolylineGap(_aheadMine, _aheadOther) < limit;
        }

        /// <summary>
        /// 相手の道を重くして引き直す。かぶらない道になったら使う。
        /// かぶったままなら、もとの道に戻して false
        /// </summary>
        bool TryAvoidPlan(ServingRobotController other)
        {
            Vector3[] prevCorners = _corners;
            int prevIndex = _cornerIndex;
            bool prevValid = _pathValid;
            Vector3 prevOnMesh = _destinationOnMesh;
            bool prevHasOnMesh = _hasDestinationOnMesh;

            if (PlanOnMap(_destination, MarkMode.Avoid, true) && !PathsOverlap(other))
            {
                // 同じ相手との 1 回の出会いは 1 回だけ数える
                // （近くにいるあいだ 0.5 秒ごとに引き直すので、毎回数えると膨らむ）
                if (_avoidTarget != other) { RobotYields++; _avoidTarget = other; }
                SetResolveOverride(StopResolve.YieldedToRobot);
                return true;
            }

            _corners = prevCorners;
            _cornerIndex = prevIndex;
            _pathValid = prevValid;
            _destinationOnMesh = prevOnMesh;
            _hasDestinationOnMesh = prevHasOnMesh;
            return false;
        }

        /// <summary>
        /// よける場所へ動いて、相手が通り過ぎるまで待つ。
        /// 場所が無い・そこへの道が引けないときは何もしない（待って、次のコマでまた見る）
        /// </summary>
        void StartMapYield(ServingRobotController other)
        {
            FindYieldSpots(other, _yieldSpots, 3);
            if (_yieldSpots.Count == 0) return;

            // 近い順に試す。道が引けた所へ動く
            Vector3[] prevCorners = _corners;
            int prevIndex = _cornerIndex;
            bool prevValid = _pathValid;
            Vector3 prevOnMesh = _destinationOnMesh;
            bool prevHasOnMesh = _hasDestinationOnMesh;
            Vector3 prevGoal = _destination;

            for (int i = 0; i < _yieldSpots.Count; i++)
            {
                if (!PlanOnMap(_yieldSpots[i], MarkMode.None, true)) continue;

                _mapYield = true;
                _mapYieldS = 0f;
                _mapYieldTo = other;
                _mapYieldStepped = true;
                _mapYieldStartS = ops != null ? ops.NowS : 0f;
                _mapYieldAt = transform.position;
                _headOnS = 0f;
                RobotYields++;
                SetResolveOverride(StopResolve.YieldedToRobot);
                return;
            }

            // どこへも引けない。もとの道に戻す
            _corners = prevCorners;
            _cornerIndex = prevIndex;
            _pathValid = prevValid;
            _destinationOnMesh = prevOnMesh;
            _hasDestinationOnMesh = prevHasOnMesh;
            _destination = prevGoal;
        }

        readonly List<Vector3> _yieldSpots = new List<Vector3>();

        /// <summary>
        /// よける場所を探す（4 章）。
        /// 近く（robotYieldSpotSearchM）で、相手の先の道から
        /// 2 台の半径の和＋robotYieldSeparationM 以上離れ、自分が入れるマス（free_class 1 以上）。
        /// 近い順に、互いに YieldSpotApartM 以上離れたものを max 個まで返す
        /// （となりのマスばかり並べても、どれも同じ所なので意味がない）。
        /// 同じ距離ならマスの番号の小さい方（乱数を使わない）。
        /// </summary>
        void FindYieldSpots(ServingRobotController other, List<Vector3> into, int max)
        {
            into.Clear();
            if (_map == null || other == null) return;

            other.PathAhead(spec.robotPathAheadM, _aheadOther);
            if (_aheadOther.Count < 2) return;

            float need = spec.Radius * 2f + spec.robotYieldSeparationM;
            float search = spec.robotYieldSpotSearchM;
            Vector3 me = Flat(transform.position);

            // 自分のまわりを輪の形に、内側から外側へ見ていく（並べ替えをしないで近い順にする）
            float res = _map.res;
            int cx = Mathf.FloorToInt(me.x / res);
            int cy = Mathf.FloorToInt(me.z / res);
            int maxR = Mathf.Max(1, Mathf.CeilToInt(search / res));
            float search2 = search * search;
            float apart2 = YieldSpotApartM * YieldSpotApartM;

            for (int r = 0; r <= maxR && into.Count < max; r++)
            {
                for (int iy = cy - r; iy <= cy + r && into.Count < max; iy++)
                {
                    if (iy < 0 || iy >= _map.ny) continue;
                    for (int ix = cx - r; ix <= cx + r; ix++)
                    {
                        if (ix < 0 || ix >= _map.nx) continue;
                        // 輪の上だけ見る（中は前の回で見た）
                        if (r > 0 && Mathf.Abs(ix - cx) != r && Mathf.Abs(iy - cy) != r) continue;

                        int i = _map.Index(ix, iy);
                        if (_map.ClassOf(i) < 1) continue;
                        Vector3 c = _map.Center(i);
                        float dx = c.x - me.x, dz = c.z - me.z;
                        if (dx * dx + dz * dz > search2) continue;
                        if (PointToPolyline(c, _aheadOther) < need) continue;

                        bool tooClose = false;
                        for (int k = 0; k < into.Count; k++)
                            if ((into[k] - c).sqrMagnitude < apart2) { tooClose = true; break; }
                        if (tooClose) continue;

                        into.Add(c);
                        if (into.Count >= max) break;
                    }
                }
            }
        }

        /// <summary>よける場所の候補どうしを、これだけ離してから並べる（m）</summary>
        const float YieldSpotApartM = 0.5f;

        // ---------------- 線と線の離れ ----------------

        /// <summary>2 本の折れ線のいちばん近いところの距離（m）</summary>
        static float PolylineGap(List<Vector3> a, List<Vector3> b)
        {
            if (a == null || b == null || a.Count < 2 || b.Count < 2) return float.MaxValue;
            float best = float.MaxValue;
            for (int i = 1; i < a.Count; i++)
            {
                for (int k = 1; k < b.Count; k++)
                {
                    float d = SegmentGap(a[i - 1], a[i], b[k - 1], b[k]);
                    if (d < best) best = d;
                }
            }
            return best;
        }

        /// <summary>点から折れ線までの距離（m）</summary>
        static float PointToPolyline(Vector3 p, List<Vector3> line)
        {
            float best = float.MaxValue;
            for (int i = 1; i < line.Count; i++)
            {
                float d = PointToSegment(p, line[i - 1], line[i]);
                if (d < best) best = d;
            }
            return best;
        }

        static float PointToSegment(Vector3 p, Vector3 a, Vector3 b)
        {
            Vector3 v = b - a; v.y = 0f;
            Vector3 w = p - a; w.y = 0f;
            float l2 = v.sqrMagnitude;
            float t = l2 > 1e-12f ? Mathf.Clamp01(Vector3.Dot(w, v) / l2) : 0f;
            Vector3 q = a + v * t;
            q.y = 0f;
            Vector3 pf = p; pf.y = 0f;
            return Vector3.Distance(pf, q);
        }

        /// <summary>
        /// 線分と線分のいちばん近いところの距離（m）。
        /// 交わっているときは 0（端点だけ見ると、交わっていても離れていることになってしまう）
        /// </summary>
        static float SegmentGap(Vector3 a1, Vector3 a2, Vector3 b1, Vector3 b2)
        {
            if (SegmentsCross(a1, a2, b1, b2)) return 0f;
            float d = PointToSegment(a1, b1, b2);
            d = Mathf.Min(d, PointToSegment(a2, b1, b2));
            d = Mathf.Min(d, PointToSegment(b1, a1, a2));
            d = Mathf.Min(d, PointToSegment(b2, a1, a2));
            return d;
        }

        static bool SegmentsCross(Vector3 a1, Vector3 a2, Vector3 b1, Vector3 b2)
        {
            float d1 = Cross(b1, b2, a1), d2 = Cross(b1, b2, a2);
            float d3 = Cross(a1, a2, b1), d4 = Cross(a1, a2, b2);
            return ((d1 > 0f && d2 < 0f) || (d1 < 0f && d2 > 0f)) &&
                   ((d3 > 0f && d4 < 0f) || (d3 < 0f && d4 > 0f));
        }

        static float Cross(Vector3 a, Vector3 b, Vector3 p)
        {
            return (b.x - a.x) * (p.z - a.z) - (b.z - a.z) * (p.x - a.x);
        }

        // ---------------- すれ違い（依頼 v13 の 依頼1）----------------

        ServingRobotController _passByOther;
        bool _passByNarrow;
        float _passByShiftM;

        /// <summary>寄る線の始まり。すれ違いに入った所を覚えて、そこから右へ寄った線を走る</summary>
        Vector3 _passByLaneFrom;
        bool _passByHasLane;

        /// <summary>すれ違い中に止まっていた秒数と、今の決まりに渡したか</summary>
        float _passByStallS;
        bool _passByGaveUp;

        /// <summary>
        /// いま「渡す前に離れている」最中か（2026-10-26。依頼 v13 の直し②-c）。
        /// 鼻先 0.06 m のまま今までの決まりに渡すと、譲る場所も回り込む道も
        /// 見つからず固まる（545 秒）。前のセンサーが届く passByBackOffM（0.6 m）まで
        /// 下がってから渡す
        /// </summary>
        bool _passByBackingOff;

        /// <summary>いま離れている最中か（FaceAndMove が読む）</summary>
        public bool PassByBackingOff { get { return _passByBackingOff; } }

        /// <summary>寄った先が通れる床かを見るときの、焼いた面を探す半径（m）</summary>
        const float PassBySampleM = 0.05f;

        /// <summary>寄った線の上で、どれだけ先を狙うか（m）</summary>
        const float PassByLookaheadM = 1.0f;

        /// <summary>
        /// 向かい合ってくる台とすれ違えるかを見る（依頼 v13 の 依頼1）。
        ///
        /// 入ったら、2台とも速さを passBySpeedMps まで落とし、止まらずに
        /// 進む向きの右へ PassByShiftM（体の幅＋間 の半分）寄って通る。
        /// 譲り合いの順位は付けない。
        ///
        /// 通れる幅が足りないときは _passByNarrow にして、
        /// 今までの決まり（譲る順番・回り込み）に任せる。
        ///
        /// 2026-10-26 の直し（依頼 v13 の確かめのあと）:
        ///   ① 要る幅に **人が横を通る分** を入れた（CanPassByHere）。
        ///      前は NavMesh だけで見ていたので 1.2 m の通路でも寄ってしまい、
        ///      壁際 0.05 m しか残らず人がまったく通れなかった
        ///   ② 間が passBySeparationM を割ったら、3 秒を待たずに離れに入る。
        ///      渡す前に passByBackOffM（0.6 m）まで下がる（鼻先 0.06 m で
        ///      渡すと立て直せず 545 秒固まった）
        /// どちらも passByEnabled が入のときだけ働く。切のときの動きは cs:9 と同じ
        /// </summary>
        void UpdatePassBy(float dt)
        {
            _passByNarrow = false;
            _passByShiftM = 0f;

            if (spec == null || !spec.passByEnabled ||
                _mapYield || _robotYield || _yieldHold ||
                _phase == Phase.Dwell || _phase == Phase.StuckWait)
            {
                _passByOther = null;
                _passByHasLane = false;
                _passByStallS = 0f;
                _passByGaveUp = false;
                _passByBackingOff = false;
                return;
            }

            // いまの相手とのすれ違いが続いているか。続いていなければ新しく探す
            if (_passByOther != null &&
                (_passByOther.YieldingToRobot ||
                 !RobotPassBy.ContinuesMoving(this, _passByOther, spec)))
            {
                _passByOther = null;
                _passByHasLane = false;
                _passByStallS = 0f;
                _passByGaveUp = false;
            }
            if (_passByOther == null)
            {
                _passByOther = NearestPassByStart();
                _passByHasLane = false;
                _passByStallS = 0f;
                _passByGaveUp = false;
            }
            if (_passByOther == null) return;

            // 寄って通ろうとしても止まってしまったら、detourAfterSec（3 秒）で
            // 今の決まりに渡す（依頼 v13 の「3秒空かなければ回り込む道を探す」）。
            // いちど渡したら、この出会いのあいだは戻さない（行ったり来たりしない）
            // 2026-10-26（直し②-b）：間が passBySeparationM を割ったら、
            // 3 秒を待たずに離れに入る（割ったままだと2台とも止まり続けるため）
            float gapNow = Gap(_passByOther);
            bool tooClose = gapNow < spec.passBySeparationM;

            if (!_passByGaveUp)
            {
                if (_speed <= spec.stoppedSpeedEpsilon) _passByStallS += dt;
                else _passByStallS = 0f;
                if (tooClose || _passByStallS >= spec.detourAfterSec) _passByGaveUp = true;
            }
            if (_passByGaveUp)
            {
                // 直し②-c：前のセンサーが届く所まで離れてから、今までの決まりに渡す。
                // 離れ終わるか、下がれなくなったら渡す
                _passByBackingOff = spec.passByBackOffM > 0f &&
                                    gapNow < spec.passByBackOffM &&
                                    CanBackOff();
                _passByNarrow = true;
                _passByHasLane = false;
                _passByShiftM = 0f;
                return;
            }
            _passByBackingOff = false;

            float want = spec.PassByShiftM;
            // 2026-10-26（直し①）：人が横を通れる幅があるかも見る
            if (!CanPassByHere(this, want) || !CanPassByHere(_passByOther, want))
            {
                _passByNarrow = true;
                _passByHasLane = false;
                return;
            }

            _passByShiftM = want;
            if (!_passByHasLane)
            {
                _passByLaneFrom = Flat(transform.position);
                _passByHasLane = true;
            }
        }

        /// <summary>
        /// これからすれ違う台のうち、いちばん近い1台。
        /// 同じ距離なら号機の小さい方（乱数を使わない）
        /// </summary>
        ServingRobotController NearestPassByStart()
        {
            ServingRobotController best = null;
            float bestD = float.MaxValue;
            for (int i = 0; i < _all.Count; i++)
            {
                ServingRobotController o = _all[i];
                if (o == null || o == this) continue;
                if (o.YieldingToRobot) continue;          // よけて待っている台とはすれ違わない
                if (!RobotPassBy.Starts(this, o, spec)) continue;
                float d = RobotPassBy.CenterDistance(this, o);
                if (d > bestD || (d == bestD && best != null && o.robotId > best.robotId)) continue;
                best = o; bestD = d;
            }
            return best;
        }

        /// <summary>
        /// そこで すれ違ってよいか（2026-10-26。依頼 v13 の直し①）。
        ///
        /// 前は「寄った先がロボット用の焼いた面に乗るか」だけを見ていた。
        /// ロボット用の面は体の半径 0.25 m で削ってあるので、1.2 m の通路でも
        /// 中心を ±0.35 m まで置けてしまい、0.3 m の寄せが通っていた。
        /// その結果、2台が寄ると壁際に 0.05 m しか残らず **人がまったく通れない**。
        ///
        /// いまは地図の clear_mm（そのマスから障害物までの実距離）で
        /// 通路の幅そのものを見る。
        ///   要る幅 = 体の幅×2 ＋ 2台の間 ＋ 人が横を通る分（RobotSpec.PassByNeedWidthM）
        ///   中心線から壁まで = clear_mm なので、その半分あるかで見る
        /// 足りなければ すれ違わず、今までの決まり（料理を載せた方が先・
        /// 3秒で回り込む）に渡す。
        ///
        /// 地図が無いとき（SiteRoutes で走るとき）は すれ違わない
        /// （幅を確かめられないまま人をふさぐ方が悪いため）
        /// </summary>
        /// <summary>
        /// 後ろへ1歩さがれるか（2026-10-26。依頼 v13 の直し②-c）。
        /// 下がった先がロボットの通れる床でなければ、下がらずに渡す
        /// </summary>
        bool CanBackOff()
        {
            if (spec == null) return false;
            Vector3 back = Flat(transform.position) - Flat(transform.forward).normalized * 0.2f;
            NavMeshHit hit;
            return NavMesh.SamplePosition(back, out hit, spec.Radius, RobotFilter);
        }

        bool CanPassByHere(ServingRobotController r, float want)
        {
            if (r == null || spec == null) return false;
            if (_map == null) return false;

            float needHalf = spec.PassByNeedWidthM * 0.5f;

            // 2台それぞれの足元と、寄った先・その少し先を見る
            Vector3 right = RobotPassBy.RightOf(r.transform.forward);
            Vector3 fwd = Flat(r.transform.forward);
            if (fwd.sqrMagnitude > 0.0001f) fwd = fwd.normalized;
            Vector3 here = Flat(r.transform.position);

            for (int i = 0; i <= 2; i++)
            {
                Vector3 p = here + fwd * (0.5f * i);
                int cell = _map.CellAt(p);
                if (cell < 0) return false;
                if (_map.ClearM(cell) < needHalf) return false;
            }

            // 寄った先がロボットの通れる床であることも、これまでどおり見る
            NavMeshHit hit;
            return NavMesh.SamplePosition(r.transform.position + right * want,
                                          out hit, PassBySampleM, RobotFilter);
        }

        /// <summary>
        /// すれ違い中に狙う点。すれ違いに入った所から今の角へ引いた線の、
        /// 右 _passByShiftM の線の上を走る（寄りすぎて壁へ向かわないよう、
        /// 乗らなければ寄せを減らす）
        /// </summary>
        Vector3 PassByTarget(Vector3 here, Vector3 corner)
        {
            Vector3 a = _passByHasLane ? _passByLaneFrom : Flat(here);
            Vector3 b = Flat(corner);
            Vector3 ab = b - a;
            float len = ab.magnitude;
            if (len < 0.05f) return corner;

            Vector3 dir = ab / len;
            Vector3 right = RobotPassBy.RightOf(dir);
            Vector3 foot = YieldSide.ProjectOnSegment(a, b, here);
            float ahead = Mathf.Min(PassByLookaheadM, Vector3.Distance(foot, b));

            for (int i = 0; i < 3; i++)
            {
                float f = 1f - 0.33f * i;
                Vector3 p = foot + dir * ahead + right * (_passByShiftM * f);
                NavMeshHit hit;
                if (NavMesh.SamplePosition(p, out hit, PassBySampleM, RobotFilter)) return p;
            }
            return corner;
        }

        void FaceAndMove(float dt)
        {
            if (_repathCooldown > 0f) _repathCooldown -= dt;

            // 2026-10-26（直し②-c）：今までの決まりに渡す前に、前のセンサーが届く所まで
            // 後ろへ下がる。下がっている間は道を追わない（向きも変えない）。
            // 記録の後始末（止まりの開き・閉じ）は Tick 側に残るので飛ばさない
            if (_passByBackingOff) { BackOffStep(dt); return; }

            if (_cornerIndex >= _corners.Length)
            {
                if (_repathCooldown <= 0f) SetDestination(_destination);
                return;
            }

            Vector3 here = transform.position;
            Vector3 corner = _corners[_cornerIndex];
            Vector3 to = corner - here; to.y = 0f;
            float d = to.magnitude;

            while (d <= spec.cornerReachDistance && _cornerIndex < _corners.Length - 1)
            {
                _cornerIndex++;
                corner = _corners[_cornerIndex];
                to = corner - here; to.y = 0f;
                d = to.magnitude;
            }

            if (d > 0.0001f)
            {
                Vector3 dir = to / d;

                // すれ違い中だけ、狙う点を進む向きの右へ寄せる（依頼 v13）。
                // 角の進み方・1コマで進む距離の決め方は変えない。
                // 切のときはこの if に入らないので、1行も計算が変わらない
                if (PassByMoving)
                {
                    Vector3 aim = PassByTarget(here, corner) - here;
                    aim.y = 0f;
                    if (aim.sqrMagnitude > 0.0001f) dir = aim.normalized;
                }

                // 向きを変える
                Quaternion want = Quaternion.LookRotation(dir, Vector3.up);
                transform.rotation = Quaternion.RotateTowards(transform.rotation, want, spec.turnSpeedDegPerSec * dt);

                float step = Mathf.Min(_speed * dt, d);
                if (step > 0f)
                {
                    transform.position = here + dir * step;
                    DistanceM += step;
                    LastStepM = step;
                }
            }

            if (d <= spec.cornerReachDistance && _cornerIndex >= _corners.Length - 1) _cornerIndex = _corners.Length;
        }

        /// <summary>
        /// 1コマぶん後ろへ下がる（2026-10-26。依頼 v13 の直し②-c）。
        /// 下がった先がロボットの通れる床でなければ、その場で離れをやめる
        /// （次のコマで今までの決まりに渡る）
        /// </summary>
        void BackOffStep(float dt)
        {
            if (spec == null) { _passByBackingOff = false; return; }

            Vector3 fwd = Flat(transform.forward);
            if (fwd.sqrMagnitude < 0.0001f) { _passByBackingOff = false; return; }
            fwd = fwd.normalized;

            float step = Mathf.Max(0f, spec.passByBackSpeedMps) * dt;
            if (step <= 0f) { _passByBackingOff = false; return; }

            Vector3 to = Flat(transform.position) - fwd * step;
            NavMeshHit hit;
            if (!NavMesh.SamplePosition(to, out hit, spec.Radius, RobotFilter))
            {
                _passByBackingOff = false;      // もう下がれない。このまま渡す
                return;
            }

            Vector3 next = hit.position;
            next.y = transform.position.y;
            float moved = Vector3.Distance(Flat(transform.position), Flat(next));
            transform.position = next;
            DistanceM += moved;
            LastStepM = moved;
            _speed = 0f;                        // 下がりは「走っている」に数えない
        }

        void SyncAgent()
        {
            if (_agent == null || !_agent.enabled) return;
            if (!_agent.isOnNavMesh)
            {
                _agent.Warp(transform.position);
                return;
            }
            _agent.nextPosition = transform.position;
        }

        void UpdateClearance()
        {
            float myR = spec.Radius;
            Vector3 me = transform.position; me.y = 0f;
            IReadOnlyList<PersonBody> all = PersonBody.All;
            for (int i = 0; i < all.Count; i++)
            {
                PersonBody p = all[i];
                if (p == null || !p.walksInAisle) continue;
                Vector3 q = p.transform.position; q.y = 0f;
                float gap = Vector3.Distance(me, q) - myR - p.radius;
                if (gap < MinClearanceM) MinClearanceM = gap;
            }
        }

        /// <summary>経路が最後までつながっているか（引き直しに失敗しているときは false）</summary>
        public bool PathComplete { get { return _pathValid; } }

        void OnDrawGizmosSelected()
        {
            if (!_hasAisleZone) return;
            Gizmos.color = _yieldHold ? new Color(1f, 0.8f, 0.1f, 0.6f) : new Color(0.2f, 0.9f, 0.5f, 0.35f);
            Gizmos.DrawWireCube(new Vector3(_aisleZone.center.x, 0.05f, _aisleZone.center.z),
                                new Vector3(_aisleZone.size.x, 0.1f, _aisleZone.size.z));
            Gizmos.DrawWireSphere(_aisleEntrance + Vector3.up * 0.05f, 0.25f);
        }
    }
}
```

### Assets/ServiceSim/Robot/RobotPassBy.cs

```csharp
using System.Collections.Generic;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// すれ違い1回ぶん（passes.csv に1行。依頼 v13 の 依頼1）。
    /// 同じ2台の1回は1行（2台で2行にしない）。
    /// </summary>
    public struct PassRow
    {
        /// <summary>「すれ違い中」に入った・終わった店の中の時刻（秒）</summary>
        public float startS, endS;

        /// <summary>すれ違った2台（号機の小さい方を a にする）</summary>
        public int robotA, robotB;

        /// <summary>いちばん近づいたときの、2台の真ん中の位置</summary>
        public float x, z;

        /// <summary>いちばん近づいたときの体どうしの間（m）。中心どうしの距離 − 体の幅</summary>
        public float minGapM;

        /// <summary>そのあいだで、2台のうちいちばん遅かった速さ（m/秒）</summary>
        public float minSpeedMps;

        /// <summary>
        /// 終わり方。
        ///   すれ違えた … 止まらずに通り過ぎた
        ///   幅が足りず今の決まりへ … 寄っても体の幅×2＋間 に足りず、譲る順番の決まりに入った
        ///   向かい合って止まった … そのあいだに2台とも1秒以上止まった
        /// </summary>
        public string outcome;

        public const string OutcomePassed = "すれ違えた";
        public const string OutcomeNarrow = "幅が足りず今の決まりへ";
        public const string OutcomeFaceStop = "向かい合って止まった";
    }

    /// <summary>
    /// ロボットどうしのすれ違いの **形の計算**（依頼 v13 の 依頼1）。
    ///
    /// 実店舗の動画では、空の2台が狭い所で2台とも速さを落とし、止まらずに
    /// 体どうしの間を詰めてすれ違っていた。ここはその判定だけを置く。
    /// 動かす所は ServingRobotController、数える所は PassByWatch。
    ///
    /// 通れる床かどうかは呼ぶ側が確かめる（人とロボットで見る区画が違うため）。
    /// 寄れるかどうかは **NavMesh**（体の半径 0.25 m で焼いてある）で見る。
    /// 地図の free_class は「中心を置ける所」を道の幅の半分で削ったものなので、
    /// 1.2 m の通路でも帯が 0.45 m しかなく、すれ違いの判定には使えない。
    /// </summary>
    public static class RobotPassBy
    {
        /// <summary>「向かい合っている」と見る向きの差（度）。依頼 v13 の 135 度</summary>
        public const float FaceDeg = 135f;

        /// <summary>向かい合って止まったと数える速さ（m/秒。依頼 v13）</summary>
        public const float FaceStopSpeedMps = 0.05f;

        /// <summary>向かい合って止まったと数えるまでの長さ（秒。依頼 v13）</summary>
        public const float FaceStopMinS = 1.0f;

        static Vector3 Flat(Vector3 v) { v.y = 0f; return v; }

        /// <summary>進む向き（止まっていても transform の向きを使う）</summary>
        static Vector3 Heading(ServingRobotController r)
        {
            Vector3 f = Flat(r.transform.forward);
            return f.sqrMagnitude < 0.000001f ? Vector3.forward : f.normalized;
        }

        /// <summary>中心どうしの距離（m）</summary>
        public static float CenterDistance(ServingRobotController a, ServingRobotController b)
        {
            return Vector3.Distance(Flat(a.transform.position), Flat(b.transform.position));
        }

        /// <summary>体どうしの間（m）。重なっていれば 0 より小さくなる</summary>
        public static float BodyGap(ServingRobotController a, ServingRobotController b, RobotSpec spec)
        {
            return CenterDistance(a, b) - spec.diameter;
        }

        /// <summary>進む向きの差が FaceDeg 以上か（＝こちらへ向かってくる）</summary>
        public static bool HeadingsOppose(ServingRobotController a, ServingRobotController b)
        {
            return Vector3.Dot(Heading(a), Heading(b)) <= Mathf.Cos(FaceDeg * Mathf.Deg2Rad);
        }

        /// <summary>相手が自分の前にいるか（進む向きより前）</summary>
        public static bool IsAhead(ServingRobotController me, ServingRobotController other)
        {
            Vector3 d = Flat(other.transform.position) - Flat(me.transform.position);
            if (d.sqrMagnitude < 0.000001f) return true;
            return Vector3.Dot(Heading(me), d.normalized) > 0f;
        }

        /// <summary>
        /// 「すれ違い中」に入る形か（依頼 v13）。
        /// 中心どうしが passByDistanceM 以内・向きの差が 135 度以上・互いに相手が前。
        /// 2台のどちらから見ても同じ答えになる（順番に頼らない）
        /// </summary>
        public static bool Starts(ServingRobotController a, ServingRobotController b, RobotSpec spec)
        {
            if (a == null || b == null || spec == null) return false;
            if (CenterDistance(a, b) > spec.passByDistanceM) return false;
            if (!HeadingsOppose(a, b)) return false;
            return IsAhead(a, b) && IsAhead(b, a);
        }

        /// <summary>
        /// 「すれ違い中」が続いているか（依頼 v13）。
        /// 相手が自分の後ろ側に回るか、2台の距離が passByDistanceM を超えたら終わり。
        /// 向きの差は見ない（曲がっている途中で切れないようにする）
        /// </summary>
        public static bool Continues(ServingRobotController a, ServingRobotController b, RobotSpec spec)
        {
            if (a == null || b == null || spec == null) return false;
            if (CenterDistance(a, b) > spec.passByDistanceM) return false;
            return IsAhead(a, b) && IsAhead(b, a);
        }

        /// <summary>
        /// すれ違いの **走り方** を続けるか。
        ///
        /// 数の数え方（Continues）は「相手が後ろ側に回ったら終わり」だが、
        /// 真横に並んだ瞬間に寄せを戻すと、体が離れないうちに相手側へ戻ってしまう。
        /// そこで走り方だけは、体どうしが robotYieldClearM（0.6 m）空くまで続ける
        /// </summary>
        public static bool ContinuesMoving(ServingRobotController a, ServingRobotController b,
                                          RobotSpec spec)
        {
            if (a == null || b == null || spec == null) return false;
            if (CenterDistance(a, b) > spec.passByDistanceM) return false;
            if (IsAhead(a, b) && IsAhead(b, a)) return true;
            return BodyGap(a, b, spec) < spec.robotYieldClearM;
        }

        /// <summary>
        /// 向かい合って止まっている形か（入でも切でも数える。依頼 v13）。
        /// 距離が passByDistanceM 以内・向きの差 135 度以上・2台とも 0.05 m/秒 以下
        /// </summary>
        public static bool FacingAndStopped(ServingRobotController a, ServingRobotController b,
                                            RobotSpec spec)
        {
            if (a == null || b == null || spec == null) return false;
            if (CenterDistance(a, b) > spec.passByDistanceM) return false;
            if (!HeadingsOppose(a, b)) return false;
            return a.SpeedMps <= FaceStopSpeedMps && b.SpeedMps <= FaceStopSpeedMps;
        }

        /// <summary>進む向きの右（Unity は左手系なので up × 前）</summary>
        public static Vector3 RightOf(Vector3 forward)
        {
            Vector3 f = Flat(forward);
            if (f.sqrMagnitude < 0.000001f) return Vector3.right;
            f.Normalize();
            return new Vector3(f.z, 0f, -f.x);
        }
    }

    /// <summary>
    /// すれ違いの数を数える（依頼 v13 の 依頼1「数の定義」）。
    ///
    /// ★ ここは **見るだけ**。動きには1つも効かない。
    ///   passByEnabled が切の回でも同じように数える（入の回と比べるため）。
    /// 2台組ごとに1つずつ見るので、同じ2台の1回は1回（2台で2回にしない）。
    /// ExperimentRunner が1コマごとに Tick を呼ぶ。
    /// </summary>
    public class PassByWatch
    {
        /// <summary>いま続いている、2台組ぶんの様子</summary>
        class Live
        {
            public int idA, idB;
            public bool passing;          // すれ違い中
            public float startS;
            public float minGapM;
            public float minSpeedMps;
            public Vector3 closestAt;     // いちばん近づいたときの真ん中
            public bool sawNarrow;        // 幅が足りず今の決まりに入った
            public bool sawFaceStop;      // そのあいだに向かい合って止まった

            public float faceStopS;       // 2台とも止まっている秒数
            public bool faceStopCounted;  // 1秒を超えて1回と数えたか
            public string faceStopPlace;  // 1回と数えた時の場所（2026-10-26）
            public bool faceStopOverlap;  // そのあいだ体が重なったか（間が負）
        }

        readonly List<Live> _live = new List<Live>();
        readonly List<PassRow> _rows = new List<PassRow>();

        /// <summary>すれ違いの回数（終わった分だけ数える）</summary>
        public int PassCount { get; private set; }

        /// <summary>減速していた秒数（ロボットごとの「すれ違い中」の合計）</summary>
        public float PassSlowS { get; private set; }

        /// <summary>向かい合って止まった回数と、いちばん長かった秒数（ぜんぶ合わせた数）</summary>
        public int FaceStopCount { get; private set; }
        public float FaceStopMaxS { get; private set; }

        // ---- 2026-10-26（依頼 v13 の直し③）：場所で分けて数える ----
        //
        // 受け渡し口は点が1つしかないので、2台が同じ点へ向かうと必ず向かい合って
        // 両方止まる。これは「通路でふさぎ合っている」のとは別の現象なので、
        // 混ぜずに分けて数える（2026-10-26 の確かめで、切の 28 件のうち
        // 26 件が受け渡し口だった）。
        // 体が重なった（間が負）ものも別に数える（依頼 v8b の A の再発を見張る）。

        /// <summary>受け渡し口のそばで向かい合って止まった回数・最長（荷積みの順番待ち）</summary>
        public int FaceStopAtPassCount { get; private set; }
        public float FaceStopAtPassMaxS { get; private set; }

        /// <summary>待機場所のそばで向かい合って止まった回数・最長</summary>
        public int FaceStopAtStandbyCount { get; private set; }
        public float FaceStopAtStandbyMaxS { get; private set; }

        /// <summary>
        /// 通路でふさぎ合った回数・最長（受け渡し口・待機場所のそばを除いた分）。
        /// **すれ違いの直しが効いたかは、この数で見る**
        /// </summary>
        public int FaceStopInAisleCount { get; private set; }
        public float FaceStopInAisleMaxS { get; private set; }

        /// <summary>そのうち、体が重なっていた（間が負）回数</summary>
        public int FaceStopOverlapCount { get; private set; }

        /// <summary>場所を見分けるための点（ExperimentRunner が回し始めに入れる）</summary>
        public void SetPlaces(Vector3 passPoint, IList<Vector3> standby)
        {
            _passPoint = passPoint;
            _standby.Clear();
            if (standby != null) for (int i = 0; i < standby.Count; i++) _standby.Add(standby[i]);
            _hasPlaces = true;
        }

        Vector3 _passPoint;
        readonly List<Vector3> _standby = new List<Vector3>();
        bool _hasPlaces;

        /// <summary>受け渡し口・待機場所のそばと見る半径（m）</summary>
        const float PlaceNearM = 1.2f;

        /// <summary>その場所の呼び名（受け渡し口／待機場所／通路）</summary>
        string PlaceOf(Vector3 at)
        {
            if (!_hasPlaces) return PlaceAisle;
            Vector3 p = at; p.y = 0f;
            Vector3 q = _passPoint; q.y = 0f;
            if (Vector3.Distance(p, q) <= PlaceNearM) return PlaceAtPass;
            for (int i = 0; i < _standby.Count; i++)
            {
                Vector3 r = _standby[i]; r.y = 0f;
                if (Vector3.Distance(p, r) <= PlaceNearM) return PlaceAtStandby;
            }
            return PlaceAisle;
        }

        public const string PlaceAtPass = "受け渡し口";
        public const string PlaceAtStandby = "待機場所";
        public const string PlaceAisle = "通路";

        public IReadOnlyList<PassRow> Rows { get { return _rows; } }

        public void Reset()
        {
            _live.Clear();
            _rows.Clear();
            PassCount = 0;
            PassSlowS = 0f;
            FaceStopCount = 0;
            FaceStopMaxS = 0f;
            FaceStopAtPassCount = 0; FaceStopAtPassMaxS = 0f;
            FaceStopAtStandbyCount = 0; FaceStopAtStandbyMaxS = 0f;
            FaceStopInAisleCount = 0; FaceStopInAisleMaxS = 0f;
            FaceStopOverlapCount = 0;
        }

        /// <summary>1コマぶん見る。robots の並びのまま見るので、同じ回なら同じ順番になる</summary>
        public void Tick(float dt, float nowS, IList<ServingRobotController> robots, RobotSpec spec)
        {
            if (spec == null || robots == null || dt <= 0f) return;

            // 「すれ違い中」のロボット（減速していた秒数は台ごとに足す）
            _slowNow.Clear();

            for (int i = 0; i < robots.Count; i++)
            {
                ServingRobotController a = robots[i];
                if (a == null) continue;
                for (int k = i + 1; k < robots.Count; k++)
                {
                    ServingRobotController b = robots[k];
                    if (b == null) continue;
                    TickPair(dt, nowS, a, b, spec);
                }
            }

            PassSlowS += dt * _slowNow.Count;
        }

        readonly List<int> _slowNow = new List<int>();

        void TickPair(float dt, float nowS, ServingRobotController a, ServingRobotController b,
                      RobotSpec spec)
        {
            int idA = Mathf.Min(a.robotId, b.robotId);
            int idB = Mathf.Max(a.robotId, b.robotId);
            Live L = Find(idA, idB);

            // ---- 向かい合って止まった（入でも切でも数える）----
            if (RobotPassBy.FacingAndStopped(a, b, spec))
            {
                if (L == null) L = Add(idA, idB);
                L.faceStopS += dt;

                // 2026-10-26（直し③）：体が重なったら覚えておく
                float gapNow = RobotPassBy.BodyGap(a, b, spec);
                if (gapNow < 0f) L.faceStopOverlap = true;

                if (!L.faceStopCounted && L.faceStopS >= RobotPassBy.FaceStopMinS)
                {
                    L.faceStopCounted = true;
                    FaceStopCount++;
                    // 2026-10-26（直し③）：1回と数えたときの場所で振り分ける
                    Vector3 mid = (a.transform.position + b.transform.position) * 0.5f;
                    L.faceStopPlace = PlaceOf(mid);
                    if (L.faceStopPlace == PlaceAtPass) FaceStopAtPassCount++;
                    else if (L.faceStopPlace == PlaceAtStandby) FaceStopAtStandbyCount++;
                    else FaceStopInAisleCount++;
                    if (L.faceStopOverlap) FaceStopOverlapCount++;
                    if (L.passing) L.sawFaceStop = true;
                }
                if (L.faceStopCounted)
                {
                    if (L.faceStopS > FaceStopMaxS) FaceStopMaxS = L.faceStopS;
                    if (L.faceStopPlace == PlaceAtPass)
                    { if (L.faceStopS > FaceStopAtPassMaxS) FaceStopAtPassMaxS = L.faceStopS; }
                    else if (L.faceStopPlace == PlaceAtStandby)
                    { if (L.faceStopS > FaceStopAtStandbyMaxS) FaceStopAtStandbyMaxS = L.faceStopS; }
                    else
                    { if (L.faceStopS > FaceStopInAisleMaxS) FaceStopInAisleMaxS = L.faceStopS; }
                }
            }
            else if (L != null)
            {
                L.faceStopS = 0f;
                L.faceStopCounted = false;
                L.faceStopPlace = null;
                L.faceStopOverlap = false;
            }

            // ---- すれ違い中 ----
            bool passing = L != null && L.passing
                ? RobotPassBy.Continues(a, b, spec)
                : RobotPassBy.Starts(a, b, spec);

            if (passing)
            {
                if (L == null) L = Add(idA, idB);
                if (!L.passing)
                {
                    L.passing = true;
                    L.startS = nowS;
                    L.minGapM = float.MaxValue;
                    L.minSpeedMps = float.MaxValue;
                    L.sawNarrow = false;
                    L.sawFaceStop = L.faceStopCounted;
                }

                float gap = RobotPassBy.BodyGap(a, b, spec);
                if (gap < L.minGapM)
                {
                    L.minGapM = gap;
                    L.closestAt = (a.transform.position + b.transform.position) * 0.5f;
                }
                float slower = Mathf.Min(a.SpeedMps, b.SpeedMps);
                if (slower < L.minSpeedMps) L.minSpeedMps = slower;
                if (a.PassByNarrow || b.PassByNarrow) L.sawNarrow = true;
                if (L.faceStopCounted) L.sawFaceStop = true;

                if (!_slowNow.Contains(a.robotId)) _slowNow.Add(a.robotId);
                if (!_slowNow.Contains(b.robotId)) _slowNow.Add(b.robotId);
                return;
            }

            if (L == null || !L.passing) return;

            // ---- すれ違いが終わった。1行にする ----
            PassRow r = new PassRow();
            r.startS = L.startS;
            r.endS = nowS;
            r.robotA = L.idA;
            r.robotB = L.idB;
            r.x = L.closestAt.x;
            r.z = L.closestAt.z;
            r.minGapM = L.minGapM == float.MaxValue ? 0f : L.minGapM;
            r.minSpeedMps = L.minSpeedMps == float.MaxValue ? 0f : L.minSpeedMps;
            r.outcome = L.sawFaceStop ? PassRow.OutcomeFaceStop
                      : L.sawNarrow ? PassRow.OutcomeNarrow
                                    : PassRow.OutcomePassed;
            _rows.Add(r);
            PassCount++;

            L.passing = false;
        }

        Live Find(int idA, int idB)
        {
            for (int i = 0; i < _live.Count; i++)
                if (_live[i].idA == idA && _live[i].idB == idB) return _live[i];
            return null;
        }

        Live Add(int idA, int idB)
        {
            Live L = new Live();
            L.idA = idA;
            L.idB = idB;
            _live.Add(L);
            return L;
        }
    }
}
```

### Assets/ServiceSim/Store/SimHud.cs

```csharp
using System.Globalization;
using UnityEngine;

namespace ServiceSim
{
    /// <summary>
    /// 実物の動画と秒数を比べられる画面（依頼 v13 の 依頼2。2026-10-26）。
    ///
    /// 録画はエイイチが Mac の画面録画で撮るので、Unity Recorder は入れない。
    /// この script がするのは2つだけ:
    ///   1. 画面の左上に「店の時計」と「いまの倍率」を出す
    ///   2. 1倍で回す（1コマ 0.05 秒のまま、実時間に合わせて待つ）
    ///
    /// 店の時計 … 回し始めからのシミュレーションの中の経過（時:分:秒）。ServiceOps.NowS。
    /// 倍率     … 直近 measureWindowSec の「シミュレーションの時間 ÷ 実時間」。
    ///            決め打ちの値ではなく **測った値** を出す（コマが重いと下がる）。
    ///
    /// 測る回に影響を出さないための決まり:
    ///   ・show・realTimeSpeed の初期値は **どちらも切**
    ///   ・画面なしの実行（-batchmode / -nographics）では描かない
    ///   ・1倍で回した回は **見る専用**。ExperimentRunner が記録を書き出さない
    ///     （出力フォルダも作らない）。段階Bなどの測る回は、1倍が入っていると
    ///     「押す前の確かめ」で止まる
    ///   ・描くのは OnGUI だけで、店の回し方・ロボット・人には1つも触らない
    /// </summary>
    public class SimHud : MonoBehaviour
    {
        /// <summary>Setup が作るときの名前</summary>
        public const string RootName = "SimHud";

        [Header("参照（Setup が入れる）")]
        [Tooltip("店の時計を読む先。空なら回し始めに自分で探す")]
        public ServiceOps ops;

        [Header("出し方（録画のときだけ入れる）")]
        [Tooltip("画面の左上に、店の時計と倍率を出す。" +
                 "測る回は切のままにする（初期値は切）")]
        public bool show = false;

        [Tooltip("1倍で回す。1コマ 0.05 秒のまま、実時間に合わせて待つので" +
                 "中身の進み方は変わらない。" +
                 "入れた回は **見る専用** で、記録を書き出さない（初期値は切）")]
        public bool realTimeSpeed = false;

        [Header("細かい決まり")]
        [Tooltip("倍率を測る窓（実時間の秒）。短いとちらつき、長いと反応が鈍い")]
        public float measureWindowSec = 2f;

        [Tooltip("字の大きさ（画面の高さに対する割合）。録画の見やすさで決める")]
        public float fontScale = 0.028f;

        [Tooltip("画面の左上からの余白（px）")]
        public Vector2 margin = new Vector2(16f, 16f);

        // ---- 測るための覚え（描くだけに使う。回し方には入らない）----
        float _winRealS, _winSimS, _lastSimS = -1f, _lastRealS = -1f;
        float _rate;                 // 測った倍率
        bool _hasRate;

        GUIStyle _style;
        Texture2D _back;

        /// <summary>
        /// 画面に描けるか（画面なしの実行では描かない）。
        /// -batchmode でも -nographics なら graphicsDeviceType が Null になる
        /// </summary>
        public static bool CanDraw
        {
            get
            {
                return !Application.isBatchMode &&
                       SystemInfo.graphicsDeviceType != UnityEngine.Rendering.GraphicsDeviceType.Null;
            }
        }

        /// <summary>
        /// いま1倍で回しているか（ExperimentRunner が「見る専用」の判定に読む）。
        /// 画面なしの実行では、入れてあっても1倍にしない（夜に待たせないため）
        /// </summary>
        public bool RealTimeNow { get { return realTimeSpeed && CanDraw; } }

        /// <summary>測った倍率（まだ測れていなければ -1）</summary>
        public float MeasuredRate { get { return _hasRate ? _rate : -1f; } }

        void OnEnable()
        {
            _frameRealStart = Time.realtimeSinceStartup;
            _winRealS = 0f;
            _winSimS = 0f;
            _lastSimS = -1f;
            _lastRealS = -1f;
            _hasRate = false;
        }

        void Update()
        {
            if (ops == null) ops = FindObjectOfType<ServiceOps>();
            if (ops == null) return;

            // 倍率を測る（店の中の時間の進み ÷ 実時間の進み）。
            //
            // 実時間は **realtimeSinceStartup** で測る。
            // Time.captureDeltaTime を入れて回していると、Unity は deltaTime も
            // unscaledDeltaTime もその値（0.05）に固定するので、
            // そちらを使うと倍率が何倍で回していても 1.0 に見えてしまう
            float sim = ops.NowS;
            float real = Time.realtimeSinceStartup;
            if (_lastSimS >= 0f && _lastRealS >= 0f)
            {
                float dSim = sim - _lastSimS;
                float dReal = real - _lastRealS;
                if (dSim >= 0f && dReal > 0f)
                {
                    _winSimS += dSim;
                    _winRealS += dReal;
                    if (_winRealS >= Mathf.Max(0.2f, measureWindowSec))
                    {
                        _rate = _winSimS / _winRealS;
                        _hasRate = true;
                        _winSimS = 0f;
                        _winRealS = 0f;
                    }
                }
            }
            _lastSimS = sim;
            _lastRealS = real;
        }

        /// <summary>
        /// 1倍にするための待ち（依頼 v13 の 依頼2）。
        ///
        /// Time.captureDeltaTime は触らない（1コマ 0.05 秒のまま）。
        /// 1コマの実時間が 0.05 秒になるまで待つだけなので、
        /// 中身の進み方（どの順で何が起きるか）は1つも変わらない。
        /// LateUpdate なので、その回のコマの計算が終わったあとに待つ。
        /// 画面なしの実行では待たない
        /// </summary>
        void LateUpdate()
        {
            if (!RealTimeNow) return;

            float step = Time.captureDeltaTime;
            if (step <= 0f) return;                 // 1コマ固定で回していないときは何もしない

            float used = Time.realtimeSinceStartup - _frameRealStart;
            float wait = step - used;
            if (wait > 0f && wait < 1f)
            {
                // ミリ秒まででよい（1コマ 50 ミリ秒に対して十分細かい）
                System.Threading.Thread.Sleep((int)(wait * 1000f));
            }
            _frameRealStart = Time.realtimeSinceStartup;
        }

        float _frameRealStart;

        void Start() { _frameRealStart = Time.realtimeSinceStartup; }

        void OnGUI()
        {
            if (!show || !CanDraw || ops == null) return;

            if (_style == null)
            {
                _style = new GUIStyle(GUI.skin.label);
                _style.alignment = TextAnchor.UpperLeft;
                _style.richText = false;
            }
            _style.fontSize = Mathf.Max(11, Mathf.RoundToInt(Screen.height * fontScale));
            _style.normal.textColor = Color.white;

            string text = Clock(ops.NowS) + "   " + RateText();

            Vector2 size = _style.CalcSize(new GUIContent(text));
            Rect box = new Rect(margin.x, margin.y, size.x + 20f, size.y + 12f);

            if (_back == null)
            {
                _back = new Texture2D(1, 1);
                _back.SetPixel(0, 0, new Color(0f, 0f, 0f, 0.55f));
                _back.Apply();
            }
            GUI.DrawTexture(box, _back);
            GUI.Label(new Rect(box.x + 10f, box.y + 6f, size.x, size.y), text, _style);
        }

        /// <summary>店の時計（時:分:秒）</summary>
        public static string Clock(float seconds)
        {
            if (seconds < 0f) seconds = 0f;
            int t = Mathf.FloorToInt(seconds);
            int h = t / 3600;
            int m = (t / 60) % 60;
            int s = t % 60;
            return h.ToString(CultureInfo.InvariantCulture) + ":" +
                   m.ToString("00", CultureInfo.InvariantCulture) + ":" +
                   s.ToString("00", CultureInfo.InvariantCulture);
        }

        string RateText()
        {
            if (RealTimeNow && !_hasRate) return "1.0 倍（実時間）";
            if (!_hasRate) return "— 倍";
            string n = _rate >= 10f ? _rate.ToString("0", CultureInfo.InvariantCulture)
                                    : _rate.ToString("0.0", CultureInfo.InvariantCulture);
            return n + " 倍" + (RealTimeNow ? "（実時間）" : "");
        }

        void OnDisable()
        {
            if (_back != null) { Destroy(_back); _back = null; }
        }
    }
}
```

## 夜の実行係（ServiceSim の tools/）

### tools/make_queue.py

```python
#!/usr/bin/env python3
"""make_queue.py — 夜に回す「条件 × 種」を queue.json に並べる（依頼 v12 の 4 章）

条件は Unity と同じ所から引く（手で写さない）:
  Assets/ServiceSim/Experiment/serving-robot-params.json の unity_conditions

最初の夜：6条件（③ fam_p1・⑦ fam_p5 × ロボット 0・1・2 台）× 種 0〜9 ＝ 60 回
          ＋ ③2台・すれ違い「入」× 種 0〜9 ＝ 10 回  … 合わせて 70 回
スタッフ4人・42組/時・上げ膳（mode=serve）・1往復3卓・レジ1つは、
その JSON の条件がそのまま持っている値なので、ここでは選び直さない。

すれ違い（依頼 v13 の 依頼1）は条件ごとに持たせる（pass_by: on / off）。
標準は off（基準データ cs:7 の18回と同じ動き）で、
--pass-by-extra で「その条件だけ on の回」を足す。
実行ファイルには -ssPassBy on|off で渡す（依頼 v12 の 2 章に足す引数）。

使い方（プロジェクトの中で）
  # 最初の夜（70回）
  python3 tools/make_queue.py --out queue.json \
      --pass-by-extra fam_p1_s4_r2_a42
  # 60回だけ
  python3 tools/make_queue.py --out queue.json
  python3 tools/make_queue.py --conditions fam_p1_s4_r1_a42 --seeds 1 --out /tmp/q.json

標準ライブラリだけで動く。Unity も台帳も読み書きしない。
"""
import argparse
import json
import os
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)
PARAMS = os.path.join(PROJECT, 'Assets', 'ServiceSim', 'Experiment',
                      'serving-robot-params.json')

# 条件の名前の付け方は Unity の SimParams.Condition.Id と同じにする。
#   {店}_s{スタッフ}_r{ロボット}_a{来客/時}   例: fam_p1_s4_r1_a42
# 実行ファイルに -ssCondition で渡す字（依頼 v12 の 2 章）
def condition_id(c):
    return '%s_s%d_r%d_a%d' % (c['store'], int(c['staff']), int(c['robots']),
                               int(c['arrivals_per_h']))


def parse_seeds(text):
    """'0-9' / '0,1,2' / '3' を種の一覧にする"""
    out = []
    for part in str(text).split(','):
        part = part.strip()
        if not part:
            continue
        if '-' in part[1:]:                      # 先頭の - は負の数のため避ける
            a, b = part.split('-', 1)
            lo, hi = int(a), int(b)
            if hi < lo:
                lo, hi = hi, lo
            out.extend(range(lo, hi + 1))
        else:
            out.append(int(part))
    seen, uniq = set(), []
    for s in out:
        if s not in seen:
            seen.add(s)
            uniq.append(s)
    return uniq


def load_conditions(path):
    with open(path, encoding='utf-8') as f:
        d = json.load(f)
    meta = d.get('meta') or {}
    conds = []
    for item in d.get('unity_conditions') or []:
        c = item.get('condition') or {}
        missing = [k for k in ('store', 'staff', 'robots', 'arrivals_per_h', 'mode')
                   if k not in c]
        if missing:
            sys.exit('unity_conditions に %s がありません: %r' % (missing, c))
        conds.append(c)
    return meta, conds


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--params', default=PARAMS, help='serving-robot-params.json')
    ap.add_argument('--seeds', default='0-9', help='種（例 0-9 / 0,1,2）')
    ap.add_argument('--conditions', default='',
                    help='条件の名前をカンマ区切りで絞る（既定は6条件ぜんぶ）')
    ap.add_argument('--label', default='night', help='出力フォルダに入る名札')
    ap.add_argument('--pass-by-extra', default='',
                    help='すれ違い「入」でも回す条件（カンマ区切り）。'
                         'その条件だけ、同じ種で on の回を足す（依頼 v13）')
    ap.add_argument('--pass-by', default='off', choices=('off', 'on'),
                    help='ふだんの回のすれ違い（既定 off＝基準データと同じ動き）')
    ap.add_argument('--out', default=os.path.join(PROJECT, 'queue.json'))
    a = ap.parse_args()

    if not os.path.exists(a.params):
        sys.exit('試算の値が見つかりません: %s' % a.params)

    meta, conds = load_conditions(a.params)
    seeds = parse_seeds(a.seeds)
    if not seeds:
        sys.exit('--seeds が空です')

    want = [s.strip() for s in a.conditions.split(',') if s.strip()]
    if want:
        known = {condition_id(c) for c in conds}
        bad = [w for w in want if w not in known]
        if bad:
            sys.exit('知らない条件です: %s\n  使える条件: %s'
                     % (', '.join(bad), ', '.join(sorted(known))))
        conds = [c for c in conds if condition_id(c) in want]

    extra = [s.strip() for s in a.pass_by_extra.split(',') if s.strip()]
    known = {condition_id(c) for c in conds}
    bad = [w for w in extra if w not in known]
    if bad:
        sys.exit('--pass-by-extra に知らない条件があります: %s\n  使える条件: %s'
                 % (', '.join(bad), ', '.join(sorted(known))))

    def make(c, seed, pass_by):
        return {
            'condition': condition_id(c),
            'seed': seed,
            'pass_by': pass_by,          # 'on' / 'off'（実行ファイルへ -ssPassBy で渡す）
            'store': c['store'],
            'staff': int(c['staff']),
            'robots': int(c['robots']),
            'arrivals_per_h': int(c['arrivals_per_h']),
            'mode': c.get('mode', 'serve'),
            'trays': int(c.get('trays', 1)),
            'fragile_by': c.get('fragile_by', 'any'),
            'registers': int(c.get('registers', 1)),
        }

    runs = []
    for c in conds:
        for s in seeds:
            runs.append(make(c, s, a.pass_by))
    # すれ違い「入」でも回す条件を、同じ種で足す（依頼 v13）
    for c in conds:
        if condition_id(c) not in extra:
            continue
        if int(c['robots']) < 2:
            print('  ※ %s はロボットが %d 台なので、すれ違いは起きません（足しますが参考）'
                  % (condition_id(c), int(c['robots'])))
        for s in seeds:
            runs.append(make(c, s, 'on'))

    q = {
        'schema': 'servicesim-night-queue/0.1',
        'label': a.label,
        'params_model': meta.get('model', ''),
        'layout_generation': meta.get('layout_generation', ''),
        'params_source': os.path.relpath(a.params, PROJECT),
        'seeds': seeds,
        'conditions': [condition_id(c) for c in conds],
        'pass_by': a.pass_by,
        'pass_by_extra': extra,
        'count': len(runs),
        'runs': runs,
    }

    out_dir = os.path.dirname(os.path.abspath(a.out))
    if out_dir and not os.path.isdir(out_dir):
        os.makedirs(out_dir)
    with open(a.out, 'w', encoding='utf-8') as f:
        json.dump(q, f, ensure_ascii=False, indent=1)
        f.write('\n')

    print('並べました: %s' % a.out)
    print('  試算の版 %s / 店の形の版 %s' % (q['params_model'], q['layout_generation']))
    n_on = sum(1 for r in runs if r['pass_by'] == 'on')
    print('  条件 %d / 種 %s / 合わせて %d 回（すれ違い 切 %d 回・入 %d 回）'
          % (len(conds), ','.join(str(s) for s in seeds), len(runs), len(runs) - n_on, n_on))
    for cid in q['conditions']:
        mark = '（＋すれ違い入も %d 回）' % len(seeds) if cid in extra else ''
        print('    %s%s' % (cid, mark))


if __name__ == '__main__':
    main()
```

### tools/night_runner.py

```python
#!/usr/bin/env python3
"""night_runner.py — 夜のあいだ、並べた回を全部回す（依頼 v12 の 4 章）

  python3 tools/night_runner.py --queue queue.json --parallel 2 --night 20261026

やること
  ・始める前に、KIOXIA がつながっているか・実行ファイルがあるかを確かめる（無ければ始めない）
  ・caffeinate で Mac をスリープさせない（終わったら必ず解く）
  ・N 本まで同時に実行ファイルを起動する。1本終わったら次を起動する
  ・台帳に completed の行がある回は飛ばす（途中で止めても続きから回せる）
  ・1回の上限（既定 90 分）を超えたら止め、status=stopped の行を台帳に足す
  ・落ちた回は status=failed の行を足す
  ・止まった・落ちた回は、その夜のうちに1度だけやり直す（出力フォルダに _try2）
  ・途中で KIOXIA が外れたら、新しい回を始めずに待ち、走っている回が終わったら止まる
  ・時刻では止めない（並べた回が全部終わるまで回す）
  ・記録は service_sim/unity/night/{night}/runner_log.txt
  ・全部終わったら judge_night.py を呼ぶ

実行ファイルの呼び方（依頼 v12 の 2 章）
  {実行ファイル} -batchmode -nographics -logFile {その回のログ}
      -ssCondition fam_p1_s4_r1_a42 -ssSeed 3 -ssLabel night -ssNight 20261026
      -ssParallel N
  終了コード：0 = 最後まで回った／2 = 途中で止まった／1 = 失敗

出力フォルダの名前（依頼 v12 の 2 章）
  {night}_{ラベル}_{cs}_{店}_r{台数}_s{種}        例 20261026_night_cs3_fam_p1_r1_s3
  ★ この形は **実行ファイル（Assets 側・依頼 v12 の 1〜3 章）と合わせる決まり**。
    いまの Unity 側（ServiceSimStageB.FolderName）は "cs{N}" なので、それにそろえてある。
    依頼文の例は "ss12" だったので、変えるなら --cs-token で合わせられる。

標準ライブラリだけで動く。台帳は tools/ledger.py を通してしか触らない。
"""
import argparse
import datetime as dt
import json
import os
import re
import signal
import subprocess
import sys
import time

import ledger as L

HERE = os.path.dirname(os.path.abspath(__file__))
PROJECT = os.path.dirname(HERE)

DEFAULT_VOLUME = '/Volumes/KIOXIA'
DEFAULT_UNITY_DIR = '/Volumes/KIOXIA/r2r2r-data/service_sim/unity'
DEFAULT_BUILD = os.path.join(PROJECT, 'Builds', 'headless', 'ServiceSim.app',
                             'Contents', 'MacOS', 'ServiceSim')
DEFAULT_LIMIT_MIN = 90.0

COND_RE = re.compile(r'^(?P<store>[a-z0-9_]+?)_s(?P<staff>\d+)_r(?P<robots>\d+)_a(?P<arr>\d+)$')


# ---------------- 小さな道具 ----------------

class Log(object):
    def __init__(self, path):
        self.path = path
        d = os.path.dirname(path)
        if d and not os.path.isdir(d):
            os.makedirs(d)
        self._f = open(path, 'a', encoding='utf-8')

    def __call__(self, msg):
        line = '%s  %s' % (dt.datetime.now().strftime('%Y-%m-%d %H:%M:%S'), msg)
        print(line, flush=True)
        self._f.write(line + '\n')
        self._f.flush()

    def close(self):
        try:
            self._f.close()
        except Exception:
            pass


def parse_condition(cid):
    m = COND_RE.match(cid)
    if not m:
        return None
    return {'store': m.group('store'), 'staff': int(m.group('staff')),
            'robots': int(m.group('robots')), 'arrivals_per_h': int(m.group('arr'))}


def read_unity_cs(build_path, log):
    """
    台帳に書く版の字（ServiceSim/cs:N）。
    まず実行ファイルのそばの build_info.json（依頼 v12 の 1 章）、
    無ければ cm status をプロジェクトで打つ。どちらも駄目なら空。
    """
    info = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(
        os.path.abspath(build_path)))), 'build_info.json')
    for cand in (os.path.join(os.path.dirname(os.path.abspath(build_path)), 'build_info.json'),
                 info,
                 os.path.join(PROJECT, 'Builds', 'headless', 'build_info.json')):
        if os.path.exists(cand):
            try:
                with open(cand, encoding='utf-8') as f:
                    v = (json.load(f).get('unity_cs') or '').strip()
                if v:
                    return v, os.path.relpath(cand, PROJECT)
            except Exception as e:
                log('build_info.json を読めません（%s）: %s' % (cand, e))
    for cm in ('/usr/local/bin/cm', '/opt/homebrew/bin/cm', 'cm'):
        try:
            out = subprocess.run([cm, 'status', '--machinereadable'], cwd=PROJECT,
                                 capture_output=True, text=True, timeout=30).stdout
        except Exception:
            continue
        for line in out.split('\n'):
            if line.startswith('STATUS '):
                f = line.strip().split(' ')
                if len(f) > 2 and 'ServiceSim' in f[2]:
                    return 'ServiceSim/cs:%s' % f[1], 'cm status'
                return '', 'cm status（別のワークスペース: %s）' % (f[2] if len(f) > 2 else '?')
    return '', '（分からない）'


def cs_token(unity_cs, fmt):
    """ServiceSim/cs:3 → cs3（出力フォルダの名前に入る字）"""
    n = ''
    m = re.search(r'cs:(\d+)', unity_cs or '')
    if m:
        n = m.group(1)
    if not n:
        n = 'unknown'
    return fmt.replace('{N}', n)


def folder_name(night, label, token, cond, seed, pass_by='off', tryno=1):
    """
    出力フォルダの名前。
    すれ違い「入」の回だけ _passon を付ける（2026-10-26。依頼 v13）。
    同じ条件・同じ種で 入 と 切 を両方回すので、付けないと名前がぶつかる。
    「切」の回の名前は付ける前と同じまま（基準データと並べるため）
    """
    name = '%s_%s_%s_%s_r%d_s%d' % (night, label, token, cond['store'], cond['robots'], seed)
    if str(pass_by) == 'on':
        name += '_passon'
    return name if tryno <= 1 else '%s_try%d' % (name, tryno)


def volume_ok(volume, unity_dir):
    return os.path.ismount(volume) and os.path.isdir(unity_dir)


# ---------------- 実行係 ----------------

class Runner(object):
    def __init__(self, a, log):
        self.a = a
        self.log = log
        self.unity_dir = a.unity_dir
        self.night_dir = os.path.join(a.unity_dir, 'night', a.night)
        self.limit_s = a.limit_min * 60.0
        self.unity_cs, self.cs_from = read_unity_cs(a.build, log)
        self.token = cs_token(self.unity_cs, a.cs_token)
        self.build_id = ''
        self.running = []       # [{proc, item, folder, started, tryno, logfile}]
        self.done = []          # [(folder, status, wall_s, tryno, item)]
        self.retry = []         # やり直す回
        self.stopping = False   # KIOXIA が外れたら True（新しい回を始めない）

    # ---- 前の確かめ ----
    def precheck(self, queue):
        ok = True
        if not volume_ok(self.a.volume, self.unity_dir):
            self.log('NG: KIOXIA がつながっていません（%s / %s）' % (self.a.volume, self.unity_dir))
            ok = False
        if not (os.path.isfile(self.a.build) and os.access(self.a.build, os.X_OK)):
            # から回し（--dry-run）は、実行ファイルを作る前に打つコマンドを見るためのものなので通す
            msg = '実行ファイルがありません（または実行できません）: %s' % self.a.build
            if self.a.dry_run:
                self.log('（から回しなので通します）' + msg)
            else:
                self.log('NG: ' + msg)
                ok = False
        if ok:
            try:
                cols = L.read_header(self.unity_dir)
                self.log('台帳: %s（%d 列）' % (L.ledger_path(self.unity_dir), len(cols)))
            except L.LedgerError as e:
                self.log('NG: %s' % e)
                ok = False
        if not self.unity_cs:
            msg = ('版が分かりません（%s）。build_info.json か cm status を直してください'
                   % self.cs_from)
            if self.a.dry_run:
                self.log('（から回しなので通します）' + msg)
            else:
                self.log('NG: ' + msg)
                ok = False
        else:
            self.log('版: %s（%s）→ 出力フォルダの字 %s' % (self.unity_cs, self.cs_from, self.token))
        bi = os.path.join(os.path.dirname(os.path.abspath(self.a.build)), 'build_info.json')
        for cand in (bi, os.path.join(PROJECT, 'Builds', 'headless', 'build_info.json')):
            if os.path.exists(cand):
                try:
                    with open(cand, encoding='utf-8') as f:
                        self.build_id = (json.load(f).get('build_id') or '').strip()
                except Exception:
                    pass
                break
        pm = queue.get('params_model', '')
        if pm:
            self.log('並べた回の試算の版: %s / 店の形の版: %s'
                     % (pm, queue.get('layout_generation', '')))
        return ok

    # ---- 1回ぶん ----
    def start_one(self, item, tryno):
        cond = parse_condition(item['condition'])
        if cond is None:
            self.log('飛ばします（条件の名前が読めません）: %s' % item['condition'])
            self.done.append((item['condition'], 'skipped', 0.0, tryno, item))
            return
        pass_by = str(item.get('pass_by', 'off'))
        folder = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'],
                             pass_by, tryno)
        logfile = os.path.join(self.night_dir, 'logs', folder + '.log')
        if not self.a.dry_run:
            d = os.path.dirname(logfile)
            if not os.path.isdir(d):
                os.makedirs(d)

        cmd = [self.a.build, '-batchmode', '-nographics', '-logFile', logfile,
               '-ssCondition', item['condition'], '-ssSeed', str(item['seed']),
               '-ssLabel', self.a.label, '-ssNight', self.a.night,
               '-ssParallel', str(self.a.parallel),
               # 2026-10-26（依頼 v13）：すれ違いの入切を回ごとに渡す
               '-ssPassBy', pass_by]
        if tryno > 1:
            cmd += ['-ssTry', str(tryno)]
        if self.a.dry_run:
            self.log('（から回し）%d 回め %s 種%s すれ違い%s → %s' %
                     (len(self.done) + 1, item['condition'], item['seed'],
                      '入' if pass_by == 'on' else '切', folder))
            self.log('  %s' % ' '.join(cmd))
            self.done.append((folder, 'dry-run', 0.0, tryno, item))
            return

        proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        self.running.append({'proc': proc, 'item': item, 'folder': folder,
                             'started': time.time(), 'tryno': tryno, 'logfile': logfile})
        self.log('始めました（%d本目・同時 %d）: %s 種%s すれ違い%s → %s（pid %d）'
                 % (len(self.done) + len(self.running), len(self.running),
                    item['condition'], item['seed'],
                    '入' if pass_by == 'on' else '切', folder, proc.pid))

    def reap(self):
        """終わった回を片付ける。1つでも片付けたら True"""
        changed = False
        for r in list(self.running):
            code = r['proc'].poll()
            over = (time.time() - r['started']) > self.limit_s
            if code is None and not over:
                continue
            if code is None and over:
                self.log('上限 %.1f 分を超えたので止めます: %s（pid %d）'
                         % (self.a.limit_min, r['folder'], r['proc'].pid))
                self._kill(r['proc'])
                code = 2
            wall = time.time() - r['started']
            status = {0: 'completed', 2: 'stopped'}.get(code, 'failed')
            self.log('おわり: %s / %s / 終了コード %s / 実時間 %.1f 分'
                     % (r['folder'], status, code, wall / 60.0))
            if status != 'completed':
                self._write_row(r, status, wall)
                if r['tryno'] == 1 and not self.a.no_retry:
                    self.retry.append(r['item'])
                    self.log('その夜のうちに1度やり直します: %s 種%s'
                             % (r['item']['condition'], r['item']['seed']))
            self.running.remove(r)
            self.done.append((r['folder'], status, wall, r['tryno'], r['item']))
            changed = True
        return changed

    @staticmethod
    def _kill(proc):
        try:
            proc.send_signal(signal.SIGTERM)
        except Exception:
            return
        for _ in range(100):           # 10 秒待つ
            if proc.poll() is not None:
                return
            time.sleep(0.1)
        try:
            proc.kill()
        except Exception:
            pass

    def _write_row(self, r, status, wall):
        """止まった・落ちた回の行を台帳に足す（実行ファイルが書けていないときだけ）"""
        if not volume_ok(self.a.volume, self.unity_dir):
            self.log('台帳に書けません（KIOXIA が外れています）: %s' % r['folder'])
            return
        cond = parse_condition(r['item']['condition']) or {}
        row = {
            'timestamp': dt.datetime.now().strftime('%Y-%m-%dT%H:%M:%S'),
            'run_folder': r['folder'],
            'status': status,
            'unity_cs': self.unity_cs,
            'store': cond.get('store', ''),
            'staff': cond.get('staff', ''),
            'robots': cond.get('robots', ''),
            'arrivals_per_h': cond.get('arrivals_per_h', ''),
            'mode': r['item'].get('mode', ''),
            'trays': r['item'].get('trays', ''),
            'fragile_by': r['item'].get('fragile_by', ''),
            'seed': r['item']['seed'],
            'wall_seconds': '%.4f' % wall,
        }
        try:
            L.append_row(self.unity_dir, row, log=self.log)
        except L.LedgerError as e:
            self.log('台帳に書けませんでした: %s' % e)

    # ---- 回す ----
    def run(self, queue):
        skip = L.completed_folders(self.unity_dir)
        items, skipped = [], 0
        for item in queue['runs']:
            cond = parse_condition(item['condition'])
            if cond is None:
                continue
            f = folder_name(self.a.night, self.a.label, self.token, cond, item['seed'],
                            str(item.get('pass_by', 'off')))
            if f in skip:
                skipped += 1
                continue
            items.append(item)
        self.log('並べた回 %d / もう completed の回を飛ばす %d / 回す %d'
                 % (len(queue['runs']), skipped, len(items)))

        queue_items = list(items)
        while queue_items or self.running or self.retry:
            # KIOXIA が外れたら、新しい回を始めない
            if not self.stopping and not volume_ok(self.a.volume, self.unity_dir):
                self.stopping = True
                self.log('KIOXIA が外れました。新しい回は始めません。'
                         '走っている %d 本が終わったら止まります' % len(self.running))

            while (not self.stopping and len(self.running) < self.a.parallel
                   and (queue_items or self.retry)):
                if queue_items:
                    self.start_one(queue_items.pop(0), 1)
                else:
                    self.start_one(self.retry.pop(0), 2)
                if self.a.stagger > 0 and not self.a.dry_run:
                    time.sleep(self.a.stagger)

            if not self.running:
                if self.stopping:
                    break
                if not queue_items and not self.retry:
                    break
                continue
            if not self.reap():
                time.sleep(self.a.poll)

        return not self.stopping

    # ---- まとめ ----
    def summary(self):
        n = {}
        for _, status, _, _, _ in self.done:
            n[status] = n.get(status, 0) + 1
        self.log('--- まとめ ---')
        for k in sorted(n):
            self.log('  %s: %d 回' % (k, n[k]))
        walls = [w for _, s, w, _, _ in self.done if s == 'completed' and w > 0]
        if walls:
            self.log('  completed の実時間: 平均 %.1f 分 / 最長 %.1f 分'
                     % (sum(walls) / len(walls) / 60.0, max(walls) / 60.0))
        return n

    def call_judge(self):
        judge = os.path.join(HERE, 'judge_night.py')
        if not os.path.exists(judge):
            self.log('judge_night.py がないので判定は呼びません: %s' % judge)
            return None
        pattern = '%s_%s_*' % (self.a.night, self.a.label)
        out = os.path.join('night', self.a.night)
        cmd = [sys.executable, judge, '--runs', pattern, '--out', out]
        if self.a.baseline:
            cmd += ['--baseline', self.a.baseline]
        self.log('判定を呼びます（%s で）: %s' % (self.unity_dir, ' '.join(cmd)))
        if self.a.dry_run:
            self.log('（から回しなので呼びません）')
            return None
        r = subprocess.run(cmd, cwd=self.unity_dir, capture_output=True, text=True)
        if r.returncode != 0:
            self.log('判定が失敗しました（終了コード %d）: %s' % (r.returncode, r.stderr.strip()))
        else:
            self.log('判定を書きました: %s' % os.path.join(self.unity_dir, out, 'judge.md'))
        return r.returncode


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--queue', default=os.path.join(PROJECT, 'queue.json'))
    ap.add_argument('--parallel', type=int, default=2, help='同時に走らせる本数')
    ap.add_argument('--night', required=True, help='その夜の名前（例 20261026）')
    ap.add_argument('--label', default='night', help='出力フォルダに入る名札')
    ap.add_argument('--build', default=DEFAULT_BUILD, help='画面なしの実行ファイル')
    ap.add_argument('--volume', default=DEFAULT_VOLUME)
    ap.add_argument('--unity-dir', default=DEFAULT_UNITY_DIR)
    ap.add_argument('--limit-min', type=float, default=DEFAULT_LIMIT_MIN,
                    help='1回の上限（分）。超えたら止めて stopped にする')
    ap.add_argument('--cs-token', default='cs{N}',
                    help='出力フォルダに入る版の字。cs{N} か ss{N}')
    ap.add_argument('--baseline', default='', help='判定に渡す基準データの glob')
    ap.add_argument('--stagger', type=float, default=5.0,
                    help='同時に起こすときに少しずらす秒数（焼き直しがぶつからないように）')
    ap.add_argument('--poll', type=float, default=5.0, help='終わったかを見に行く間隔（秒）')
    ap.add_argument('--no-retry', action='store_true', help='止まった回をやり直さない')
    ap.add_argument('--no-caffeinate', action='store_true')
    ap.add_argument('--dry-run', action='store_true',
                    help='実行ファイルを起こさず、打つコマンドだけ見る')
    a = ap.parse_args()

    if a.parallel < 1:
        sys.exit('--parallel は 1 以上にしてください')
    if not re.match(r'^\d{8}$', a.night):
        sys.exit('--night は 20261026 の形にしてください')
    if not os.path.exists(a.queue):
        sys.exit('並べた回がありません: %s（先に make_queue.py）' % a.queue)

    night_dir = os.path.join(a.unity_dir, 'night', a.night)
    try:
        if not os.path.isdir(night_dir):
            os.makedirs(night_dir)
    except OSError as e:
        sys.exit('記録の置き場所を作れません: %s\n  %s' % (night_dir, e))
    log = Log(os.path.join(night_dir, 'runner_log.txt'))

    with open(a.queue, encoding='utf-8') as f:
        queue = json.load(f)

    log('=== 夜の実行係を始めます（night %s・同時 %d 本・上限 %.1f 分）==='
        % (a.night, a.parallel, a.limit_min))
    log('並べた回: %s（%d 回）' % (a.queue, len(queue.get('runs') or [])))

    r = Runner(a, log)
    if not r.precheck(queue):
        log('確かめで引っかかったので始めません')
        log.close()
        return 1

    caff = None
    if not a.no_caffeinate and not a.dry_run:
        try:
            caff = subprocess.Popen(['/usr/bin/caffeinate', '-dimsu'])
            log('スリープを止めました（caffeinate pid %d）' % caff.pid)
        except Exception as e:
            log('caffeinate を起こせませんでした（手でスリープを切ってください）: %s' % e)

    code = 0
    try:
        finished = r.run(queue)
        n = r.summary()
        if not finished:
            log('KIOXIA が外れたので、途中で止まりました')
            code = 2
        elif n.get('failed') or n.get('stopped'):
            code = 2
        if finished and not a.dry_run:
            r.call_judge()
    except KeyboardInterrupt:
        log('手で止められました。走っている回を止めます')
        for x in list(r.running):
            Runner._kill(x['proc'])
        code = 2
    finally:
        if caff is not None:
            try:
                caff.terminate()
                log('スリープの設定を戻しました')
            except Exception:
                pass
        log('=== 夜の実行係を終わります（終了コード %d）===' % code)
        log.close()
    return code


if __name__ == '__main__':
    sys.exit(main())
```

### tools/ledger.py

```python
#!/usr/bin/env python3
"""ledger.py — 台帳 unity_ledger_v1.csv に鍵をかけて1行足す（依頼 v12 の 3 章）

夜は実行ファイルを何本も同時に走らせるので、台帳に同時に書かないよう鍵をかける。
ここは **台帳を壊すと戻しにくい所** なので、決まりを狭くしてある。

決まり（C# の ServiceSimLedger と同じ）
  ・**追記だけ**。前の行は読むだけで、書き換えない・消さない
  ・同じ run_folder の行は2度書かない
  ・列の並びは **実体の1行め（ヘッダ）が正**。
    C# 側が「ヘッダとコードの列が1字でも違えば追記しない」を守っているので、
    実体のヘッダをそのまま信じれば、C# と Python の列がずれることはない。
    そのうえで、頭の並びと ledger_schema_version があることだけ確かめる
  ・値にカンマ・引用符・改行は入れない（CSV を壊さないため）

鍵
  unity_ledger_v1.lock を O_EXCL で作れたら持ち主。閉じるときに消す。
  作れなければ少し待って何度か試す。
  C# から new FileStream(lock, FileMode.CreateNew) で取るのと同じやり方なので、
  Unity 側（依頼 v12 の 3 章）と同じ鍵を取り合える。
  持ち主が落ちて鍵が残ったときは、中の時刻を見て古ければ1度だけ壊す。
"""
import csv
import errno
import os
import socket
import time

LEDGER_NAME = 'unity_ledger_v1.csv'
LOCK_NAME = 'unity_ledger_v1.lock'

# ヘッダの頭の並び（ここだけは変わらない前提で確かめる）
HEADER_PREFIX = ('timestamp', 'run_folder', 'status', 'unity_cs',
                 'layout_generation', 'params_model')
# これが無ければ台帳と見なさない
HEADER_MUST_HAVE = ('seed', 'store', 'robots', 'ledger_schema_version')

LOCK_STALE_SEC = 120.0      # これより古い鍵は、持ち主が落ちたと見て壊す


class LedgerError(Exception):
    pass


# ---------------- 鍵 ----------------

class Lock(object):
    """with Lock(dir) as lk: ... の形で使う"""

    def __init__(self, unity_dir, timeout=60.0, poll=0.25, log=None):
        self.path = os.path.join(unity_dir, LOCK_NAME)
        self.timeout = timeout
        self.poll = poll
        self._fd = None
        self._log = log

    def _note(self, msg):
        if self._log:
            self._log(msg)

    def __enter__(self):
        end = time.time() + self.timeout
        broke = False
        while True:
            try:
                self._fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
                os.write(self._fd, ('%d %s %f\n' % (os.getpid(), socket.gethostname(),
                                                    time.time())).encode('utf-8'))
                return self
            except OSError as e:
                if e.errno != errno.EEXIST:
                    raise
            # もう誰かが持っている
            if not broke and self._stale():
                broke = True
                self._note('台帳の鍵が %.0f 秒より古いので壊します: %s'
                           % (LOCK_STALE_SEC, self.path))
                try:
                    os.unlink(self.path)
                except OSError:
                    pass
                continue
            if time.time() >= end:
                raise LedgerError('台帳の鍵を %.0f 秒待っても取れません: %s'
                                  % (self.timeout, self.path))
            time.sleep(self.poll)

    def _stale(self):
        try:
            age = time.time() - os.path.getmtime(self.path)
        except OSError:
            return False
        return age > LOCK_STALE_SEC

    def __exit__(self, *exc):
        if self._fd is not None:
            try:
                os.close(self._fd)
            except OSError:
                pass
            self._fd = None
        try:
            os.unlink(self.path)
        except OSError:
            pass
        return False


# ---------------- 読む ----------------

def ledger_path(unity_dir):
    return os.path.join(unity_dir, LEDGER_NAME)


def read_header(unity_dir):
    """実体の1行めを列の一覧で返す。台帳の形でなければ LedgerError"""
    p = ledger_path(unity_dir)
    if not os.path.exists(p):
        raise LedgerError('台帳がありません: %s' % p)
    with open(p, encoding='utf-8') as f:
        line = f.readline()
    if not line:
        raise LedgerError('台帳が空です（ヘッダがありません）: %s' % p)
    cols = line.rstrip('\r\n').split(',')
    if tuple(cols[:len(HEADER_PREFIX)]) != HEADER_PREFIX:
        raise LedgerError('台帳のヘッダの頭が違います: %s\n  実体: %s\n  待っていたもの: %s'
                          % (p, ','.join(cols[:len(HEADER_PREFIX)]), ','.join(HEADER_PREFIX)))
    missing = [c for c in HEADER_MUST_HAVE if c not in cols]
    if missing:
        raise LedgerError('台帳のヘッダに %s がありません: %s' % (missing, p))
    return cols


def read_rows(unity_dir):
    """台帳の行を run_folder → 行（dict）で返す。読むだけ"""
    p = ledger_path(unity_dir)
    out = {}
    if not os.path.exists(p):
        return out
    with open(p, encoding='utf-8') as f:
        for r in csv.DictReader(f):
            folder = (r.get('run_folder') or '').strip()
            if folder:
                out[folder] = r
    return out


def has_row(unity_dir, run_folder):
    return run_folder in read_rows(unity_dir)


def completed_folders(unity_dir, status='completed'):
    return {k for k, r in read_rows(unity_dir).items()
            if (r.get('status') or '').strip() == status}


# ---------------- 書く ----------------

def clean(value):
    """CSV を壊さない字にする（C# の ServiceSimLedger.Clean と同じ）"""
    if value is None:
        return ''
    s = str(value)
    s = s.replace(',', ' ').replace('"', ' ')
    s = s.replace('\r', ' ').replace('\n', ' ')
    return s.strip()


def append_row(unity_dir, row, log=None, timeout=60.0):
    """
    台帳に1行足す。足したら True、足さなかったら False（理由は log に出す）。
    row は 列名 → 値。ヘッダに無い列名が入っていたら LedgerError（打ち間違いを通さない）。
    鍵は呼ぶ側で取らず、ここで取る。
    """
    def note(msg):
        if log:
            log(msg)

    folder = clean(row.get('run_folder'))
    if not folder:
        raise LedgerError('run_folder が空なので足しません（照合キーです）')

    with Lock(unity_dir, timeout=timeout, log=log):
        cols = read_header(unity_dir)          # 鍵を取ってから読む
        unknown = [k for k in row if k not in cols]
        if unknown:
            raise LedgerError('台帳に無い列名です: %s' % ', '.join(sorted(unknown)))

        if folder in read_rows(unity_dir):
            note('もう台帳にあるので足しません（2度書かない）: %s' % folder)
            return False

        line = ','.join(clean(row.get(c)) for c in cols)
        if line.count(',') != len(cols) - 1:
            raise LedgerError('列の数が合いません（%d 列のはず）' % len(cols))

        p = ledger_path(unity_dir)
        # 1行めが改行で終わっていない台帳に足すと行がくっつくので、確かめてから足す
        with open(p, 'rb') as f:
            if f.seek(0, os.SEEK_END) > 0:
                f.seek(-1, os.SEEK_END)
                needs_nl = f.read(1) != b'\n'
            else:
                needs_nl = False
        with open(p, 'a', encoding='utf-8', newline='') as f:
            if needs_nl:
                f.write('\n')
            f.write(line + '\n')
            f.flush()
            os.fsync(f.fileno())
        note('台帳に1行足しました: %s / %s' % (folder, clean(row.get('status'))))
        return True
```
